#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT_PATH="$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}")"
DEFAULT_HOME="${HOME:-}"
# SSM RunShellScript runs as root with HOME=/root, but openclaw instances
# live under /home/ssm-user.  Detect this situation and fall back.
# We also export HOME so that tools like git config --global work correctly.
if [[ -z "$DEFAULT_HOME" || "$DEFAULT_HOME" == "/root" ]]; then
  if [[ -d "/home/ssm-user" ]]; then
    DEFAULT_HOME="/home/ssm-user"
    export HOME="$DEFAULT_HOME"
  elif command -v getent >/dev/null 2>&1; then
    DEFAULT_HOME="$(
      getent passwd "$(id -un 2>/dev/null || printf '%s' "${USER:-ssm-user}")" 2>/dev/null | cut -d: -f6
    )"
  fi
fi
DEFAULT_HOME="${DEFAULT_HOME:-/home/${SUDO_USER:-${USER:-ssm-user}}}"
BASE_DIR="${OPENCLAW_INSTALL_DIR:-${DEFAULT_HOME}/openclaw}"
INSTANCES_DIR="$BASE_DIR/instances"
CACHE_DIR="$BASE_DIR/cache"
SOURCE_CACHE_DIR="$CACHE_DIR/source"
LOCK_DIR="$BASE_DIR/locks"
META_VERSION="2026-03-26"

DEFAULT_REPO_URL="${OPENCLAW_REPO_URL:-https://github.com/openclaw/openclaw.git}"
DEFAULT_REPO_REF="${OPENCLAW_REPO_REF:-main}"
DEFAULT_DOCKER_COMPOSE_VERSION="${OPENCLAW_DOCKER_COMPOSE_VERSION:-v5.0.1}"
DEFAULT_DOCKER_BUILDX_VERSION="${OPENCLAW_DOCKER_BUILDX_VERSION:-v0.21.2}"
DEFAULT_GATEWAY_PORT=18789
DEFAULT_BRIDGE_PORT=18790
DEFAULT_HOST_BIND="${OPENCLAW_GATEWAY_BIND:-127.0.0.1}"
DEFAULT_TZ="${OPENCLAW_TZ:-UTC}"
DEFAULT_ENABLE_QMD="${OPENCLAW_ENABLE_QMD:-0}"
DEFAULT_CPU_LIMIT="${OPENCLAW_CPU_LIMIT:-1.0}"
DEFAULT_MEMORY_LIMIT="${OPENCLAW_MEMORY_LIMIT:-2g}"
DEFAULT_PRIMARY_MODEL="${OPENCLAW_PRIMARY_MODEL:-}"
DEFAULT_IMAGE_MODEL="${OPENCLAW_IMAGE_MODEL:-}"

CONTAINER_HOME="/home/node/.openclaw"
CONTAINER_WORKSPACE_DIR="$CONTAINER_HOME/workspace"
CONTAINER_GATEWAY_PORT=18789
CONTAINER_BRIDGE_PORT=18790

CURRENT_INSTANCE=""
QUICK_WIZARD="no"
ORIGINAL_ARGS=()

usage() {
  cat <<'EOF'
Usage:
  ./openclaw-deploy.sh
  ./openclaw-deploy.sh install-host
  ./openclaw-deploy.sh create-instance --instance <name> [options]
  ./openclaw-deploy.sh deploy-instance --instance <name> [options]
  ./openclaw-deploy.sh deploy --instance <name> [options]
  ./openclaw-deploy.sh configure-model --instance <name> [options]
  ./openclaw-deploy.sh configure-gateway --instance <name> [options]
  ./openclaw-deploy.sh configure-channel --instance <name> [options]
  ./openclaw-deploy.sh login-codex --instance <name>
  ./openclaw-deploy.sh pair-device --instance <name>
  ./openclaw-deploy.sh repair-control-ui --instance <name> [--revert]
  ./openclaw-deploy.sh migrate-codex --instance <target> (--from-container <container> | --from-path <dir>)
  ./openclaw-deploy.sh doctor --instance <name>
  ./openclaw-deploy.sh status [--instance <name>]
  ./openclaw-deploy.sh logs --instance <name>
  ./openclaw-deploy.sh restart --instance <name>
  ./openclaw-deploy.sh remove --instance <name> [--purge-data] [--yes]
  ./openclaw-deploy.sh list-instances
  ./openclaw-deploy.sh webchat [--instance <name>] [--ec2-instance-id <id>] [--aws-region <region>] [--local-port <port>]
  ./openclaw-deploy.sh upload-file --file <path> --s3-bucket <bucket> --ec2-instance-id <id> --dest-path <remote-path> [options]

Upload options:
  --file <path>            Local file path to upload to S3
  --s3-bucket <bucket>     Target S3 bucket name
  --s3-key <key>           S3 object key (default: basename of --file)
  --ec2-instance-id <id>   Target EC2 instance ID for SSM download
  --dest-path <remote>     Destination path on the EC2 instance
  --aws-region <region>    AWS region (default: AWS_DEFAULT_REGION env or us-east-1)

Webchat options:
  --local-port <port>      Local port for SSM port-forward (default: 18789)

Common deploy options:
  --instance <name>        Instance name, unique per user on the shared EC2 host
  --repo-ref <ref>         Git branch, tag, or commit to deploy
  --repo-url <url>         OpenClaw repository URL
  --gateway-port <port>    Host port exposed for the gateway
  --bridge-port <port>     Host port exposed for the bridge
  --bind <addr>            Host bind address, default 127.0.0.1
  --allow-origin <origin>  Repeatable, extra Control UI allowed origins for configure-gateway
  --tz <tz>                Timezone, default UTC
  --enable-qmd             Enable QMD in generated config
  --cpu <limit>            CPU limit written to compose file
  --memory <limit>         Memory limit written to compose file
  --advanced-setup         Run post-deploy advanced setup only when explicitly requested
  --non-interactive        Fail instead of prompting for missing values

Model options:
  --provider openai|anthropic|openrouter|bedrock|bailian|custom
  --primary-model <id>
  --fallbacks <csv>
  --alias <name=modelRef>  Repeatable
  --allowlist <csv>
  --image-model <modelRef>
  --api-key <value>
  --base-url <url>
  --provider-id <id>       For custom provider
  --provider-name <name>   For custom provider
  --api-type <kind>        For custom provider, default openai-completions

Channel options:
  --channel telegram|discord|slack
  --token <value>
  --dm-policy <value>
  --allow-from <csv>
  --disable
  --revert
EOF
}

log() {
  printf '[openclaw-deploy] %s\n' "$*"
}

step() {
  printf '\n'
  printf '==> %s\n' "$*"
}

info() {
  printf '    %s\n' "$*"
}

fail() {
  printf 'ERROR: %s\n' "$*" >&2
  exit 1
}

require_cmd() {
  command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1"
}

as_root() {
  if [[ "$(id -u)" -eq 0 ]]; then
    "$@"
  else
    require_cmd sudo
    sudo "$@"
  fi
}

trim() {
  local value="${1:-}"
  value="${value#"${value%%[![:space:]]*}"}"
  value="${value%"${value##*[![:space:]]}"}"
  printf '%s' "$value"
}

shell_join_args() {
  local arg
  for arg in "$@"; do
    printf '%q ' "$arg"
  done
}

normalize_instance_config_layout() {
  local instance_name="$1"
  local current_path legacy_path
  current_path="$(instance_openclaw_config_path "$instance_name")"
  legacy_path="$(legacy_instance_openclaw_config_path "$instance_name")"

  if [[ -f "$legacy_path" ]]; then
    mkdir -p "$(dirname "$current_path")"
    if [[ ! -f "$current_path" ]] || ! cmp -s "$legacy_path" "$current_path"; then
      cp "$legacy_path" "$current_path"
    fi
    rm -f "$legacy_path"
  fi
}

sanitize_instance_name() {
  local raw="$1"
  local value
  value="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9-' '-')"
  value="${value#-}"
  value="${value%-}"
  [[ -n "$value" ]] || fail "实例名不能为空"
  printf '%s\n' "$value"
}

instance_dir() {
  printf '%s/%s\n' "$INSTANCES_DIR" "$1"
}

instance_env_path() {
  printf '%s/.env\n' "$(instance_dir "$1")"
}

instance_meta_path() {
  printf '%s/meta.json\n' "$(instance_dir "$1")"
}

instance_compose_path() {
  printf '%s/docker-compose.yml\n' "$(instance_dir "$1")"
}

instance_config_dir() {
  printf '%s/data/config\n' "$(instance_dir "$1")"
}

instance_openclaw_config_path() {
  printf '%s/.openclaw/openclaw.json\n' "$(instance_config_dir "$1")"
}

legacy_instance_openclaw_config_path() {
  printf '%s/openclaw.json\n' "$(instance_config_dir "$1")"
}

instance_workspace_dir() {
  printf '%s/workspace\n' "$(instance_config_dir "$1")"
}

instance_state_dir() {
  printf '%s/state\n' "$(instance_dir "$1")"
}

instance_log_dir() {
  printf '%s/logs\n' "$(instance_dir "$1")"
}

instance_owner_default() {
  printf '%s\n' "${SUDO_USER:-${USER:-unknown}}"
}

project_name_for() {
  printf 'openclaw-%s\n' "$1"
}

ensure_base_dirs() {
  mkdir -p "$INSTANCES_DIR" "$CACHE_DIR" "$SOURCE_CACHE_DIR" "$LOCK_DIR"
  # When running as root (e.g. SSM send-command), fix ownership so that
  # ssm-user interactive sessions can also manage the same directories.
  if [[ "$(id -u)" == "0" && -d "/home/ssm-user" ]]; then
    chown -R ssm-user:ssm-user "$BASE_DIR" 2>/dev/null || true
  fi
}

prompt_default() {
  local label="$1"
  local default_value="$2"
  local input
  read -r -p "$label [$default_value]: " input
  input="$(trim "${input:-}")"
  if [[ -z "$input" ]]; then
    printf '%s\n' "$default_value"
  else
    printf '%s\n' "$input"
  fi
}

prompt_optional() {
  local label="$1"
  local default_value="${2:-}"
  local input
  if [[ -n "$default_value" ]]; then
    read -r -p "$label [$default_value]: " input
  else
    read -r -p "$label: " input
  fi
  input="$(trim "${input:-}")"
  if [[ -z "$input" ]]; then
    printf '%s\n' "$default_value"
  else
    printf '%s\n' "$input"
  fi
}

prompt_secret() {
  local label="$1"
  local input
  read -r -s -p "$label: " input
  printf '\n'
  printf '%s\n' "$(trim "${input:-}")"
}

prompt_yes_no() {
  local label="$1"
  local default_value="$2"
  local input normalized
  while true; do
    read -r -p "$label [$default_value]: " input
    normalized="$(trim "${input:-$default_value}")"
    normalized="$(printf '%s' "$normalized" | tr '[:upper:]' '[:lower:]')"
    case "$normalized" in
      y|yes) printf 'yes\n'; return 0 ;;
      n|no) printf 'no\n'; return 0 ;;
    esac
    printf 'Please answer yes or no.\n' >&2
  done
}

confirm_action() {
  local label="$1"
  local auto_yes="${2:-no}"
  if [[ "$auto_yes" == "yes" ]]; then
    return 0
  fi
  [[ -t 0 ]] || fail "$label 需要确认，请加 --yes"
  [[ "$(prompt_yes_no "$label" "no")" == "yes" ]]
}

select_from_menu() {
  local label="$1"
  shift
  local options=("$@")
  local option_count="${#options[@]}"
  (( option_count > 0 )) || fail "select_from_menu requires at least one option"

  if (( option_count == 1 )); then
    printf '1\n'
    return 0
  fi

  if [[ "${ARG_YES:-no}" == "yes" ]]; then
    info "$label: 检测到 --yes，默认选择第 1 项。"
    printf '1\n'
    return 0
  fi

  [[ -t 0 ]] || fail "$label 需要交互选择；非交互场景请确保只有一个待选项，或显式处理。"

  local idx choice
  printf '%s\n' "$label" >&2
  for idx in "${!options[@]}"; do
    printf '  %d. %s\n' "$((idx + 1))" "${options[$idx]}" >&2
  done

  while true; do
    read -r -p "选择 [1]: " choice
    choice="${choice:-1}"
    if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= option_count )); then
      printf '%s\n' "$choice"
      return 0
    fi
    printf 'Invalid choice, try again.\n' >&2
  done
}

current_instance_prefix() {
  if [[ -n "$CURRENT_INSTANCE" ]]; then
    printf '当前实例: %s\n' "$CURRENT_INSTANCE"
  fi
}

announce_step() {
  local number="$1"
  local title="$2"
  local reason="$3"
  printf '\n'
  current_instance_prefix
  printf '[%s] %s\n' "$number" "$title"
  printf '  %s\n' "$reason"
}

print_instance_success_summary() {
  local instance_name="${1:-$CURRENT_INSTANCE}"
  local local_port="${2:-${OPENCLAW_GATEWAY_PORT:-18789}}"
  [[ -n "$instance_name" ]] || return 0
  printf '\n'
  printf '实例: %s\n' "$instance_name"
  printf 'Dashboard: http://127.0.0.1:%s/#token=%s\n' "$local_port" "$OPENCLAW_GATEWAY_TOKEN"
  printf 'SSH 隧道: ssh -L %s:127.0.0.1:%s <your-user>@<ec2-ip>\n' "$local_port" "$OPENCLAW_GATEWAY_PORT"
  printf '浏览器访问: 固定打开 http://127.0.0.1:%s/#token=...\n' "$local_port"
  printf '下一步:\n'
  printf '  ./openclaw-deploy.sh status --instance %s\n' "$instance_name"
  printf '  ./openclaw-deploy.sh logs --instance %s\n' "$instance_name"
  printf '  ./openclaw-deploy.sh configure-model --instance %s\n' "$instance_name"
  printf '  ./openclaw-deploy.sh login-codex --instance %s\n' "$instance_name"
  printf '  ./openclaw-deploy.sh configure-channel --instance %s\n' "$instance_name"
}

deploy_model_setup_requested() {
  [[ -n "${ARG_PROVIDER:-}" || -n "${ARG_PRIMARY_MODEL:-}" || -n "${ARG_FALLBACKS:-}" || -n "${ARG_ALLOWLIST:-}" || -n "${ARG_IMAGE_MODEL:-}" || -n "${ARG_API_KEY:-}" || -n "${ARG_BASE_URL:-}" || -n "${ARG_PROVIDER_ID:-}" || -n "${ARG_PROVIDER_NAME:-}" || -n "${ARG_API_TYPE:-}" || "${#MODEL_ALIASES[@]}" -gt 0 ]]
}

deploy_channel_setup_requested() {
  [[ -n "${ARG_CHANNEL:-}" || -n "${ARG_CHANNEL_TOKEN:-}" || -n "${ARG_ALLOW_FROM:-}" || "${ARG_DISABLE:-no}" == "yes" ]]
}

deploy_codex_migrate_requested() {
  [[ -n "${ARG_FROM_CONTAINER:-}" || -n "${ARG_FROM_PATH:-}" ]]
}

reserved_ports_for_instances() {
  if [[ ! -d "$INSTANCES_DIR" ]]; then
    return 0
  fi
  python3 - "$INSTANCES_DIR" <<'PY'
import os
import sys
from pathlib import Path

root = Path(sys.argv[1])
for env_path in root.glob("*/.env"):
    values = {}
    for line in env_path.read_text(encoding="utf-8").splitlines():
        if "=" not in line:
            continue
        key, value = line.split("=", 1)
        value = value.strip()
        if len(value) >= 2 and value[0] == "'" and value[-1] == "'":
            value = value[1:-1].replace("'\"'\"'", "'")
        values[key] = value
    for key in ("OPENCLAW_GATEWAY_PORT", "OPENCLAW_BRIDGE_PORT"):
        raw = values.get(key, "").strip()
        if raw.isdigit():
            print(raw)
PY
}

allocate_port_pair() {
  local instance_name="$1"
  local base_offset
  base_offset="$(python3 - "$instance_name" <<'PY'
import hashlib
import sys
name = sys.argv[1]
value = int(hashlib.sha256(name.encode("utf-8")).hexdigest()[:6], 16)
print((value % 2000) * 2)
PY
)"

  local used_ports=()
  local line
  while IFS= read -r line; do
    [[ -n "$line" ]] || continue
    used_ports+=("$line")
  done < <(reserved_ports_for_instances)

  local candidate_gateway=$((DEFAULT_GATEWAY_PORT + base_offset))
  local candidate_bridge=$((candidate_gateway + 1))
  while true; do
    if (( candidate_gateway > 65535 || candidate_bridge > 65535 )); then
      fail "Unable to allocate port pair: candidates $candidate_gateway/$candidate_bridge exceed valid range"
    fi
    local collision="no"
    if [[ "${#used_ports[@]}" -gt 0 ]]; then
      local used
      for used in "${used_ports[@]}"; do
        if [[ "$used" == "$candidate_gateway" || "$used" == "$candidate_bridge" ]]; then
          collision="yes"
          break
        fi
      done
    fi
    if [[ "$collision" == "no" ]] && ! port_listening "$candidate_gateway" && ! port_listening "$candidate_bridge"; then
      printf '%s %s\n' "$candidate_gateway" "$candidate_bridge"
      return 0
    fi
    candidate_gateway=$((candidate_gateway + 2))
    candidate_bridge=$((candidate_bridge + 2))
  done
}

reserved_ports_for_other_instances() {
  local instance_name="$1"
  if [[ ! -d "$INSTANCES_DIR" ]]; then
    return 0
  fi
  python3 - "$INSTANCES_DIR" "$instance_name" <<'PY'
import sys
from pathlib import Path

root = Path(sys.argv[1])
current = sys.argv[2]
for env_path in root.glob("*/.env"):
    if env_path.parent.name == current:
        continue
    values = {}
    for line in env_path.read_text(encoding="utf-8").splitlines():
        if "=" not in line:
            continue
        key, value = line.split("=", 1)
        value = value.strip()
        if len(value) >= 2 and value[0] == "'" and value[-1] == "'":
            value = value[1:-1].replace("'\"'\"'", "'")
        values[key] = value
    for key in ("OPENCLAW_GATEWAY_PORT", "OPENCLAW_BRIDGE_PORT"):
        raw = values.get(key, "").strip()
        if raw.isdigit():
            print(raw)
PY
}

check_instance_ports() {
  local instance_name="$1"
  local gateway_port="$2"
  local bridge_port="$3"
  local current_gateway="${4:-}"
  local current_bridge="${5:-}"

  [[ "$gateway_port" =~ ^[0-9]+$ ]] || { printf 'Gateway 端口必须是数字: %s\n' "$gateway_port"; return 1; }
  [[ "$bridge_port" =~ ^[0-9]+$ ]] || { printf 'Bridge 端口必须是数字: %s\n' "$bridge_port"; return 1; }
  (( gateway_port >= 1 && gateway_port <= 65535 )) || { printf 'Gateway 端口超出范围: %s\n' "$gateway_port"; return 1; }
  (( bridge_port >= 1 && bridge_port <= 65535 )) || { printf 'Bridge 端口超出范围: %s\n' "$bridge_port"; return 1; }
  [[ "$gateway_port" != "$bridge_port" ]] || { printf 'Gateway 端口和 Bridge 端口不能相同\n'; return 1; }

  local used_ports=()
  local line
  while IFS= read -r line; do
    [[ -n "$line" ]] || continue
    used_ports+=("$line")
  done < <(reserved_ports_for_other_instances "$instance_name")

  local used
  for used in "${used_ports[@]}"; do
    [[ "$used" != "$gateway_port" && "$used" != "$bridge_port" ]] || { printf '端口冲突: %s 已被其他实例占用\n' "$used"; return 1; }
  done

  if [[ "$gateway_port" != "$current_gateway" && "$gateway_port" != "$current_bridge" ]] && port_listening "$gateway_port"; then
    printf 'Gateway 端口已被当前宿主机上的其他进程占用: %s\n' "$gateway_port"
    return 1
  fi
  if [[ "$bridge_port" != "$current_gateway" && "$bridge_port" != "$current_bridge" ]] && port_listening "$bridge_port"; then
    printf 'Bridge 端口已被当前宿主机上的其他进程占用: %s\n' "$bridge_port"
    return 1
  fi

  return 0
}

validate_instance_ports() {
  local error
  if error="$(check_instance_ports "$@")"; then
    return 0
  fi
  fail "$error"
}

port_listening() {
  local port="$1"
  python3 - "$port" <<'PY'
import socket
import sys
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(0.2)
try:
    raise SystemExit(0 if sock.connect_ex(("127.0.0.1", int(sys.argv[1]))) == 0 else 1)
finally:
    sock.close()
PY
}

with_lock() {
  local name="$1"
  shift
  local lock_path="$LOCK_DIR/$name.lock"
  local owner_file="$lock_path/owner"
  local timeout_seconds="${OPENCLAW_LOCK_TIMEOUT_SECONDS:-120}"
  local stale_seconds="${OPENCLAW_LOCK_STALE_SECONDS:-1800}"
  local sleep_seconds=1
  local deadline=$((SECONDS + timeout_seconds))
  local rc=0
  mkdir -p "$LOCK_DIR"
  while ! mkdir "$lock_path" 2>/dev/null; do
    if lock_is_stale "$lock_path" "$stale_seconds"; then
      cleanup_stale_lock "$lock_path"
      continue
    fi
    if (( SECONDS >= deadline )); then
      fail "等待锁 $name 超时。请检查是否有其他部署进程正在运行，或删除陈旧锁目录: $lock_path"
    fi
    sleep "$sleep_seconds"
  done
  write_lock_owner_file "$owner_file"
  trap 'rm -f "$owner_file" >/dev/null 2>&1; rmdir "$lock_path" >/dev/null 2>&1 || true; trap - INT TERM HUP' INT TERM HUP
  if "$@"; then
    :
  else
    rc=$?
  fi
  rm -f "$owner_file" >/dev/null 2>&1 || true
  rmdir "$lock_path" >/dev/null 2>&1 || true
  trap - INT TERM HUP
  return "$rc"
}

write_lock_owner_file() {
  local owner_file="$1"
  cat >"$owner_file" <<EOF
pid=$$
started_at=$(date +%s)
user=$(id -un 2>/dev/null || printf '%s' "${USER:-unknown}")
host=$(hostname 2>/dev/null || printf unknown)
EOF
}

lock_is_stale() {
  local lock_path="$1"
  local stale_seconds="$2"
  local owner_file="$lock_path/owner"
  local pid started_at now
  [[ -d "$lock_path" ]] || return 1
  [[ -f "$owner_file" ]] || return 0
  pid="$(awk -F= '$1=="pid" {print $2}' "$owner_file" 2>/dev/null)"
  started_at="$(awk -F= '$1=="started_at" {print $2}' "$owner_file" 2>/dev/null)"
  if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
    if [[ -n "$started_at" ]] && [[ "$started_at" =~ ^[0-9]+$ ]]; then
      now="$(date +%s)"
      (( now - started_at > stale_seconds )) && return 0
    fi
    return 1
  fi
  return 0
}

cleanup_stale_lock() {
  local lock_path="$1"
  rm -rf "$lock_path"
}

instance_lock_name() {
  local instance_name="$1"
  printf 'instance-%s\n' "$instance_name"
}

with_instance_lock() {
  local instance_name="$1"
  shift
  with_lock "$(instance_lock_name "$instance_name")" "$@"
}

write_if_changed() {
  local target="$1"
  local tmp
  tmp="$(mktemp "${TMPDIR:-/tmp}/openclaw.XXXXXX")"
  cat >"$tmp"
  if [[ ! -f "$target" ]] || ! cmp -s "$tmp" "$target"; then
    if ! mkdir -p "$(dirname "$target")"; then
      rm -f "$tmp"
      return 1
    fi
    if ! mv "$tmp" "$target"; then
      rm -f "$tmp"
      return 1
    fi
    return 0
  fi
  rm -f "$tmp"
  return 0
}

write_env_file() {
  local env_path="$1"
  python3 - "$env_path" <<'PY'
import os
import sys
from pathlib import Path

keys = [
    "INSTANCE_NAME",
    "INSTANCE_OWNER",
    "OPENCLAW_REPO_URL",
    "OPENCLAW_REPO_REF",
    "OPENCLAW_SOURCE_CACHE",
    "OPENCLAW_SOURCE_COMMIT",
    "OPENCLAW_IMAGE",
    "OPENCLAW_GATEWAY_PORT",
    "OPENCLAW_BRIDGE_PORT",
    "OPENCLAW_HOST_BIND",
    "OPENCLAW_TZ",
    "OPENCLAW_ENABLE_QMD",
    "OPENCLAW_CONFIG_DIR",
    "OPENCLAW_WORKSPACE_DIR",
    "OPENCLAW_RUNTIME_UID",
    "OPENCLAW_RUNTIME_GID",
    "OPENCLAW_GATEWAY_TOKEN",
    "OPENCLAW_ALLOWED_ORIGINS",
    "OPENCLAW_CPU_LIMIT",
    "OPENCLAW_MEMORY_LIMIT",
    "OPENCLAW_PRIMARY_MODEL",
    "OPENCLAW_FALLBACK_MODELS",
    "OPENCLAW_ALLOWLIST",
    "OPENCLAW_IMAGE_MODEL",
    "OPENCLAW_MODEL_PROVIDER",
    "OPENCLAW_CUSTOM_PROVIDER_ID",
    "OPENCLAW_CUSTOM_PROVIDER_NAME",
    "OPENCLAW_CUSTOM_PROVIDER_BASE_URL",
    "OPENCLAW_CUSTOM_PROVIDER_API_TYPE",
    "OPENCLAW_CUSTOM_PROVIDER_PRIMARY_MODEL",
    "OPENAI_API_KEY",
    "ANTHROPIC_API_KEY",
    "OPENROUTER_API_KEY",
    "OPENCLAW_BAILIAN_API_KEY",
    "OPENCLAW_BAILIAN_BASE_URL",
    "OPENCLAW_BAILIAN_PRIMARY_MODEL",
    "OPENCLAW_CUSTOM_PROVIDER_API_KEY",
    "TELEGRAM_BOT_TOKEN",
    "DISCORD_BOT_TOKEN",
    "SLACK_BOT_TOKEN",
]

def dotenv_escape(value: str) -> str:
    return "'" + value.replace("'", "'\"'\"'") + "'"

lines = []
for key in keys:
    value = os.environ.get(key, "")
    lines.append(f"{key}={dotenv_escape(value)}")

p = Path(sys.argv[1])
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
}

current_uid() {
  id -u
}

current_gid() {
  id -g
}

write_meta_file() {
  local meta_path="$1"
  python3 - "$meta_path" <<'PY'
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

path = Path(sys.argv[1])
created = None
if path.exists():
    try:
        created = json.loads(path.read_text(encoding="utf-8")).get("createdAt")
    except Exception:
        created = None
now = datetime.now(timezone.utc).isoformat()
payload = {
    "version": "2026-03-26",
    "instance": os.environ["INSTANCE_NAME"],
    "owner": os.environ["INSTANCE_OWNER"],
    "repoUrl": os.environ["OPENCLAW_REPO_URL"],
    "repoRef": os.environ["OPENCLAW_REPO_REF"],
    "sourceCommit": os.environ.get("OPENCLAW_SOURCE_COMMIT", ""),
    "image": os.environ["OPENCLAW_IMAGE"],
    "gatewayPort": int(os.environ["OPENCLAW_GATEWAY_PORT"]),
    "bridgePort": int(os.environ["OPENCLAW_BRIDGE_PORT"]),
    "hostBind": os.environ["OPENCLAW_HOST_BIND"],
    "allowedOrigins": [x for x in os.environ.get("OPENCLAW_ALLOWED_ORIGINS", "").split(",") if x],
    "timezone": os.environ["OPENCLAW_TZ"],
    "qmdEnabled": os.environ["OPENCLAW_ENABLE_QMD"] == "1",
    "cpuLimit": os.environ["OPENCLAW_CPU_LIMIT"],
    "memoryLimit": os.environ["OPENCLAW_MEMORY_LIMIT"],
    "createdAt": created or now,
    "lastUpdatedAt": now,
}
path.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
PY
}

load_instance_env() {
  local instance_name="$1"
  local env_path
  env_path="$(instance_env_path "$instance_name")"
  [[ -f "$env_path" ]] || fail "Instance env not found: $env_path"
  normalize_instance_config_layout "$instance_name"
  set -a
  # shellcheck disable=SC1090
  source "$env_path"
  set +a
  OPENCLAW_RUNTIME_UID="${OPENCLAW_RUNTIME_UID:-$(current_uid)}"
  OPENCLAW_RUNTIME_GID="${OPENCLAW_RUNTIME_GID:-$(current_gid)}"
  export OPENCLAW_RUNTIME_UID OPENCLAW_RUNTIME_GID
  CURRENT_INSTANCE="$instance_name"
  normalize_loaded_instance_env "$instance_name"
}

normalize_loaded_instance_env() {
  local instance_name="$1"
  local current_uid_value current_gid_value
  OPENCLAW_ENV_NEEDS_NORMALIZE="no"
  OPENCLAW_RUNTIME_UID_MISMATCH="no"
  OPENCLAW_RUNTIME_GID_MISMATCH="no"
  OPENCLAW_ALLOWED_ORIGINS="${OPENCLAW_ALLOWED_ORIGINS:-}"
  current_uid_value="$(current_uid)"
  current_gid_value="$(current_gid)"

  if [[ -z "${OPENCLAW_RUNTIME_UID:-}" ]]; then
    OPENCLAW_RUNTIME_UID="$current_uid_value"
    OPENCLAW_ENV_NEEDS_NORMALIZE="yes"
  fi
  if [[ -z "${OPENCLAW_RUNTIME_GID:-}" ]]; then
    OPENCLAW_RUNTIME_GID="$current_gid_value"
    OPENCLAW_ENV_NEEDS_NORMALIZE="yes"
  fi

  if [[ "${OPENCLAW_RUNTIME_UID:-}" != "$current_uid_value" ]]; then
    OPENCLAW_RUNTIME_UID_MISMATCH="yes"
  fi

  if [[ "${OPENCLAW_RUNTIME_GID:-}" != "$current_gid_value" ]]; then
    OPENCLAW_RUNTIME_GID_MISMATCH="yes"
  fi

  export OPENCLAW_RUNTIME_UID OPENCLAW_RUNTIME_GID OPENCLAW_ENV_NEEDS_NORMALIZE OPENCLAW_RUNTIME_UID_MISMATCH OPENCLAW_RUNTIME_GID_MISMATCH OPENCLAW_ALLOWED_ORIGINS
}

normalize_origin_csv() {
  python3 - "$@" <<'PY'
import sys

items = []
seen = set()

for raw in sys.argv[1:]:
    for part in str(raw).split(","):
        value = part.strip()
        if not value or value in seen:
            continue
        seen.add(value)
        items.append(value)

print(",".join(items))
PY
}

has_nonempty_values() {
  local value
  for value in "$@"; do
    if [[ -n "$(trim "$value")" ]]; then
      return 0
    fi
  done
  return 1
}

ensure_instance_env_normalized() {
  local instance_name="$1"
  local current_uid_value current_gid_value
  current_uid_value="$(current_uid)"
  current_gid_value="$(current_gid)"
  if [[ "${OPENCLAW_RUNTIME_UID_MISMATCH:-no}" == "yes" ]]; then
    fail "实例 $instance_name 记录的运行 UID 是 ${OPENCLAW_RUNTIME_UID}，当前用户 UID 是 ${current_uid_value}。这是高风险变更：继续操作可能直接改坏实例目录权限。请换回原部署用户，或确认后再手动调整该实例 .env 里的 OPENCLAW_RUNTIME_UID。"
  fi
  if [[ "${OPENCLAW_RUNTIME_GID_MISMATCH:-no}" == "yes" ]]; then
    info "实例 $instance_name 记录的运行 GID 是 ${OPENCLAW_RUNTIME_GID}，当前用户 GID 是 ${current_gid_value}。这次会自动切换到当前 GID；如果你依赖旧组权限，请随后检查实例目录和共享目录权限。"
    OPENCLAW_RUNTIME_GID="$current_gid_value"
    OPENCLAW_ENV_NEEDS_NORMALIZE="yes"
  fi
  [[ "${OPENCLAW_ENV_NEEDS_NORMALIZE:-no}" == "yes" ]] || return 0
  persist_instance_state
  OPENCLAW_ENV_NEEDS_NORMALIZE="no"
  OPENCLAW_RUNTIME_GID_MISMATCH="no"
  export OPENCLAW_ENV_NEEDS_NORMALIZE OPENCLAW_RUNTIME_GID_MISMATCH
}

instance_state() {
  local instance_name="$1"
  local compose_path config_path
  compose_path="$(instance_compose_path "$instance_name")"
  config_path="$(instance_openclaw_config_path "$instance_name")"

  if [[ -z "${OPENCLAW_IMAGE:-}" ]]; then
    if [[ -f "$config_path" ]]; then
      printf 'dirty\n'
    else
      printf 'skeleton\n'
    fi
    return 0
  fi

  if [[ -f "$compose_path" && -f "$config_path" ]]; then
    printf 'deployed\n'
  else
    printf 'dirty\n'
  fi
}

ensure_instance_deployed() {
  local instance_name="$1"
  local action="$2"
  local state
  state="$(instance_state "$instance_name")"
  case "$state" in
    deployed)
      return 0
      ;;
    skeleton)
      fail "实例 $instance_name 还没完成部署，不能执行 $action。先运行 ./openclaw-deploy.sh deploy-instance --instance $instance_name"
      ;;
    dirty)
      fail "实例 $instance_name 当前状态异常，不能执行 $action。先修复配置，或重新运行 deploy-instance。"
      ;;
    *)
      fail "实例 $instance_name 状态未知，不能执行 $action。"
      ;;
  esac
}

health_probe_host() {
  case "${OPENCLAW_HOST_BIND:-127.0.0.1}" in
    ""|0.0.0.0|::|localhost|::1)
      printf '127.0.0.1\n'
      ;;
    *)
      printf '%s\n' "$OPENCLAW_HOST_BIND"
      ;;
  esac
}

host_uid_mode_failure() {
  local action="$1"
  local output="${2:-}"
  local summary docker_diag docker_code docker_message

  summary="$(compose_failure_summary "$output")"
  docker_diag="$(docker_access_diagnosis)"
  docker_code="${docker_diag%%|*}"
  docker_message="${docker_diag#*|}"

  case "$docker_code" in
    socket-permission)
      fail "$action 失败。$docker_message"
      ;;
    startup-race)
      fail "$action 失败。$docker_message 请等待几秒后重试。"
      ;;
    daemon-stopped|daemon-unhealthy|daemon-error|missing)
      fail "$action 失败。$docker_message"
      ;;
  esac

  if compose_failure_looks_like_uid_gid_issue "$output"; then
    fail "$action 失败。摘要: $summary。当前实例使用宿主机 uid/gid ${OPENCLAW_RUNTIME_UID}:${OPENCLAW_RUNTIME_GID} 运行容器；这次报错看起来与容器内文件权限或运行身份有关。请先检查实例目录权限；如果确认是镜像不兼容宿主机 uid/gid 模式，再调整该实例 .env 中的 OPENCLAW_RUNTIME_UID/OPENCLAW_RUNTIME_GID（常见兼容值为 1000:1000）后重试。"
  fi

  if [[ "$summary" != "unknown compose error" ]]; then
    fail "$action 失败。摘要: $summary"
  fi

  fail "$action 失败，但当前摘要不足以判断是否与 uid/gid 有关。请先查看上一条 Docker/Compose 报错。"
}

docker_project_resources_exist() {
  local project_name="$1"
  require_cmd docker
  local containers networks
  containers="$(docker ps -aq --filter "label=com.docker.compose.project=$project_name")"
  networks="$(docker network ls -q --filter "label=com.docker.compose.project=$project_name")"
  [[ -n "$containers" || -n "$networks" ]]
}

cleanup_project_resources_without_compose() {
  local project_name="$1"
  require_cmd docker
  docker_daemon_ready || fail "Docker 当前不可访问，无法安全清理实例 $project_name 的遗留资源。"

  local containers networks container cleanup_failed="no"
  containers="$(docker ps -aq --filter "label=com.docker.compose.project=$project_name")"
  if [[ -n "$containers" ]]; then
    info "检测到实例 $project_name 仍有容器残留，开始逐个清理。"
    while IFS= read -r container; do
      [[ -n "$container" ]] || continue
      if docker rm -f "$container" >/dev/null; then
        info "已删除残留容器: $container"
      else
        info "删除残留容器失败: $container"
        cleanup_failed="yes"
      fi
    done <<<"$containers"
  fi

  networks="$(docker network ls -q --filter "label=com.docker.compose.project=$project_name")"
  if [[ -n "$networks" ]]; then
    info "检测到实例 $project_name 仍有网络残留，开始逐个清理。"
    while IFS= read -r container; do
      [[ -n "$container" ]] || continue
      if docker network rm "$container" >/dev/null; then
        info "已删除残留网络: $container"
      else
        info "删除残留网络失败: $container"
        cleanup_failed="yes"
      fi
    done <<<"$networks"
  fi

  if [[ "$cleanup_failed" == "yes" ]]; then
    fail "实例 $project_name 的残留资源未清理干净，请先处理失败项后再继续。"
  fi
}

compose_failure_summary() {
  local output="${1:-}"
  local summary=""
  summary="$(printf '%s\n' "$output" | awk 'NF { line=$0 } END { print line }')"
  summary="$(trim "$summary")"
  if [[ -z "$summary" ]]; then
    summary="unknown compose error"
  fi
  printf '%s\n' "$summary"
}

docker_access_diagnosis() {
  local info_output summary

  if ! docker_binary_ready; then
    printf 'missing|这台机器还没装好 Docker。\n'
    return 0
  fi

  if info_output="$(docker info 2>&1)"; then
    printf 'ok|Docker daemon 可访问。\n'
    return 0
  fi

  summary="$(compose_failure_summary "$info_output")"

  if printf '%s\n' "$info_output" | grep -qiE 'permission denied|connect: permission denied|got permission denied'; then
    printf 'socket-permission|当前用户没有 Docker 权限。摘要: %s\n' "$summary"
    return 0
  fi

  if printf '%s\n' "$info_output" | grep -qiE 'no such file or directory'; then
    if command_ready systemctl && docker_service_running; then
      printf 'startup-race|Docker 服务刚启动，socket 还没就绪。摘要: %s\n' "$summary"
    else
      printf 'daemon-stopped|Docker daemon 还没启动。摘要: %s\n' "$summary"
    fi
    return 0
  fi

  if printf '%s\n' "$info_output" | grep -qiE 'cannot connect to the docker daemon|is the docker daemon running|error during connect|connection refused|context deadline exceeded|EOF'; then
    if command_ready systemctl && docker_service_running; then
      printf 'daemon-unhealthy|Docker 服务已启动，但 daemon 还没准备好或当前异常。摘要: %s\n' "$summary"
    else
      printf 'daemon-stopped|Docker daemon 当前未运行。摘要: %s\n' "$summary"
    fi
    return 0
  fi

  printf 'daemon-error|Docker 当前不可访问。摘要: %s\n' "$summary"
}

compose_failure_looks_like_uid_gid_issue() {
  local output="${1:-}"
  [[ -n "$output" ]] || return 1
  printf '%s\n' "$output" | grep -qiE 'operation not permitted|permission denied|EACCES|chown|chmod|uid|gid|user .* not found|/home/node|\.openclaw|openclaw\.json'
}

docker_accessible_for_cleanup() {
  docker_binary_ready && docker_daemon_ready
}

compose_for_instance() {
  local instance_name="$1"
  shift
  if docker_binary_ready && docker compose version >/dev/null 2>&1; then
    docker compose \
      -p "$(project_name_for "$instance_name")" \
      --env-file "$(instance_env_path "$instance_name")" \
      -f "$(instance_compose_path "$instance_name")" \
      "$@"
    return 0
  fi

  if command_ready docker-compose; then
    docker-compose \
      -p "$(project_name_for "$instance_name")" \
      --env-file "$(instance_env_path "$instance_name")" \
      -f "$(instance_compose_path "$instance_name")" \
      "$@"
    return 0
  fi

  fail "Docker Compose is not available. Please install docker compose plugin or docker-compose."
}

detect_os() {
  [[ -f /etc/os-release ]] || fail "Unsupported host: /etc/os-release not found"
  # shellcheck disable=SC1091
  source /etc/os-release
  local os="${ID:-}"
  local version="${VERSION_ID:-}"
  case "$os" in
    ubuntu)
      [[ "$version" == 22.04 || "$version" == 24.04 ]] || fail "Only Ubuntu 22.04/24.04 are supported"
      printf 'ubuntu\n'
      ;;
    amzn)
      [[ "$version" == 2023 ]] || fail "Only Amazon Linux 2023 is supported"
      printf 'amzn\n'
      ;;
    *)
      fail "Unsupported operating system: ${PRETTY_NAME:-$os}"
      ;;
  esac
}

docker_ready() {
  command -v docker >/dev/null 2>&1 &&
    docker_compose_ready &&
    docker info >/dev/null 2>&1
}

command_ready() {
  command -v "$1" >/dev/null 2>&1
}

docker_binary_ready() {
  command_ready docker
}

docker_compose_ready() {
  (docker_binary_ready && docker compose version >/dev/null 2>&1) ||
    command_ready docker-compose
}

docker_buildx_ready() {
  docker_binary_ready && docker buildx version >/dev/null 2>&1
}

docker_daemon_ready() {
  docker_binary_ready && docker info >/dev/null 2>&1
}

docker_service_running() {
  command_ready systemctl && systemctl is-active --quiet docker
}

docker_compose_plugin_arch() {
  case "$(uname -m)" in
    x86_64|amd64) printf 'x86_64\n' ;;
    aarch64|arm64) printf 'aarch64\n' ;;
    *)
      fail "Unsupported architecture for Docker Compose plugin install: $(uname -m)"
      ;;
  esac
}

install_docker_compose_plugin_manually() {
  local arch version plugin_dir plugin_path url
  arch="$(docker_compose_plugin_arch)"
  version="$DEFAULT_DOCKER_COMPOSE_VERSION"
  plugin_dir="/usr/local/lib/docker/cli-plugins"
  plugin_path="$plugin_dir/docker-compose"
  url="https://github.com/docker/compose/releases/download/${version}/docker-compose-linux-${arch}"

  info "检测到 Docker Engine 已存在，但 Docker Compose 缺失。现在按 Docker 官方 Linux 手工安装方式补装 Compose plugin。"
  info "下载地址: $url"

  as_root mkdir -p "$plugin_dir"
  as_root curl -fSL "$url" -o "$plugin_path"
  as_root chmod +x "$plugin_path"
}

install_docker_buildx_plugin_manually() {
  local arch version plugin_dir plugin_path url
  arch="$(docker_compose_plugin_arch)"
  version="$DEFAULT_DOCKER_BUILDX_VERSION"
  plugin_dir="/usr/local/lib/docker/cli-plugins"
  plugin_path="$plugin_dir/docker-buildx"
  url="https://github.com/docker/buildx/releases/download/${version}/buildx-${version}.linux-${arch}"

  info "检测到 Docker Engine 已存在，但 BuildX 缺失。现在自动补装 BuildX 插件。"
  info "下载地址: $url"

  as_root mkdir -p "$plugin_dir"
  as_root curl -fSL "$url" -o "$plugin_path"
  as_root chmod +x "$plugin_path"
}

mem_total_mb() {
  awk '/MemTotal:/ {printf "%d\n", $2 / 1024}' /proc/meminfo
}

swap_total_mb() {
  awk '/SwapTotal:/ {printf "%d\n", $2 / 1024}' /proc/meminfo
}

warn_if_build_memory_low() {
  local mem_mb swap_mb
  mem_mb="$(mem_total_mb)"
  swap_mb="$(swap_total_mb)"

  if (( mem_mb >= 3500 )) || (( swap_mb >= 1024 )); then
    return 0
  fi

  printf '    检测到宿主机内存较小（约 %s MiB）且 swap 不足（约 %s MiB）。当前部署不会自动修改 swap；如果 Docker 构建失败，请先由管理员单独完成宿主机内存/swap 调优后再重试。\n' "$mem_mb" "$swap_mb" >&2
}

current_shell_user() {
  printf '%s\n' "${SUDO_USER:-${USER:-$(id -un)}}"
}

user_in_docker_group() {
  id -nG "$(current_shell_user)" 2>/dev/null | grep -qw docker
}

host_ready() {
  command_ready git &&
    command_ready curl &&
    command_ready python3 &&
    command_ready jq &&
    docker_binary_ready &&
    docker_compose_ready &&
    docker_buildx_ready &&
    docker_daemon_ready
}

host_install_complete() {
  command_ready git &&
    command_ready curl &&
    command_ready python3 &&
    command_ready jq &&
    docker_binary_ready &&
    docker_compose_ready &&
    docker_buildx_ready &&
    docker_service_running
}

host_dependency_summary() {
  printf '  - git: %s\n' "$([[ "$(command_ready git && printf yes || printf no)" == yes ]] && printf 'ok' || printf 'missing')"
  printf '  - curl: %s\n' "$([[ "$(command_ready curl && printf yes || printf no)" == yes ]] && printf 'ok' || printf 'missing')"
  printf '  - python3: %s\n' "$([[ "$(command_ready python3 && printf yes || printf no)" == yes ]] && printf 'ok' || printf 'missing')"
  printf '  - jq: %s\n' "$([[ "$(command_ready jq && printf yes || printf no)" == yes ]] && printf 'ok' || printf 'missing')"
  printf '  - docker: %s\n' "$([[ "$(docker_binary_ready && printf yes || printf no)" == yes ]] && printf 'ok' || printf 'missing')"
  printf '  - docker compose: %s\n' "$([[ "$(docker_compose_ready && printf yes || printf no)" == yes ]] && printf 'ok' || printf 'missing')"
  printf '  - docker buildx: %s\n' "$([[ "$(docker_buildx_ready && printf yes || printf no)" == yes ]] && printf 'ok' || printf 'missing')"
  printf '  - docker daemon: %s\n' "$([[ "$(docker_daemon_ready && printf yes || printf no)" == yes ]] && printf 'ok' || printf 'not-ready')"
  if getent group docker >/dev/null 2>&1; then
    printf '  - docker group membership(%s): %s\n' "$(current_shell_user)" "$([[ "$(user_in_docker_group && printf yes || printf no)" == yes ]] && printf 'ok' || printf 'missing')"
  fi
}

docker_permission_help_message() {
  local in_group="no"
  if user_in_docker_group; then
    in_group="yes"
  fi

  if [[ "$in_group" == "yes" ]]; then
    printf '%s\n' "Docker 已安装，但当前 shell/SSM 会话还不能访问 /var/run/docker.sock。当前用户已经在 docker 组里，这通常表示组权限还没有刷新。请退出当前 shell/SSM 会话，使用同一个 Linux 用户重新登录后再试。重新登录后可先运行 'docker info' 验证。"
  else
    printf '%s\n' "Docker 已安装，但当前用户还不能访问 /var/run/docker.sock。请先确认这个 Linux 用户已经加入 docker 组，然后退出当前 shell/SSM 会话并重新登录后再试。重新登录后可先运行 'docker info' 验证。"
  fi
}

collect_missing_packages() {
  local os="$1"
  local packages=()

  command_ready git || packages+=("git")
  command_ready curl || packages+=("curl")
  command_ready python3 || packages+=("python3")
  command_ready jq || packages+=("jq")

  if ! docker_binary_ready; then
    if [[ "$os" == "ubuntu" ]]; then
      packages+=("docker.io" "docker-compose-plugin" "ca-certificates")
    else
      packages+=("docker" "shadow-utils")
    fi
  elif ! docker_compose_ready; then
    if [[ "$os" == "ubuntu" ]]; then
      packages+=("docker-compose-plugin")
    fi
  fi

  if [[ ${#packages[@]} -eq 0 ]]; then
    return 0
  fi

  printf '%s\n' "${packages[@]}" | awk '!seen[$0]++'
}

ensure_docker_service_running() {
  if docker_daemon_ready; then
    return 0
  fi
  if command_ready systemctl; then
    as_root systemctl enable --now docker
  fi
  if docker_daemon_ready; then
    return 0
  fi
  docker_service_running
}

install_host() {
  announce_step 1 "宿主机依赖" "只处理 Docker 和基础命令，不初始化实例目录。"
  local os
  os="$(detect_os)"

  if host_ready; then
    info "检测到宿主机依赖已经齐全，直接跳过安装。"
    host_dependency_summary
  else
    local missing_packages=()
    mapfile -t missing_packages < <(collect_missing_packages "$os")

    if [[ ${#missing_packages[@]} -gt 0 ]]; then
      info "检测到有缺失项，下面只安装缺少的组件。"
      printf '  - 待安装包: %s\n' "${missing_packages[*]}"
      if [[ "$os" == "ubuntu" ]]; then
        as_root apt-get update
        as_root apt-get install -y "${missing_packages[@]}"
      else
        as_root dnf install -y "${missing_packages[@]}"
      fi
    else
      if ! docker_compose_ready; then
        info "Docker Engine 已存在，但 Docker Compose 还不可用。"
      else
        info "命令基本都存在，主要问题是 Docker 服务还没有就绪。"
      fi
    fi
  fi

  if docker_binary_ready && ! docker_compose_ready; then
    install_docker_compose_plugin_manually
  fi

  if docker_binary_ready && ! docker_buildx_ready; then
    install_docker_buildx_plugin_manually
  fi

  if ensure_docker_service_running; then
    :
  else
    fail "Docker daemon is not reachable after host setup"
  fi

  if ! docker_daemon_ready && docker_service_running; then
    local docker_diag docker_code docker_message
    docker_diag="$(docker_access_diagnosis)"
    docker_code="${docker_diag%%|*}"
    docker_message="${docker_diag#*|}"
    if [[ "$docker_code" == "socket-permission" ]]; then
      fail "$docker_message $(docker_permission_help_message)"
    fi
    fail "$docker_message"
  fi

  if ! host_install_complete; then
    if [[ "$os" == "amzn" && ! docker_compose_ready ]]; then
      fail "宿主机基础包已经安装，但 Docker Compose 仍不可用。请先确认这台 Amazon Linux 2023 机器上能运行 'docker compose' 或 'docker-compose'，再继续部署。"
    fi
    fail "宿主机依赖安装未完成，请先确认 git/curl/python3/jq/docker/docker compose/docker buildx/systemd 状态。"
  fi

  host_dependency_summary
  info "宿主机依赖已就绪。实例目录和共享缓存会在后续部署时自动创建到 \$HOME/openclaw。"
}

ensure_instance_storage_ready() {
  ensure_base_dirs
  if [[ -d "$INSTANCES_DIR" && -w "$INSTANCES_DIR" && -d "$LOCK_DIR" && -w "$LOCK_DIR" && -d "$SOURCE_CACHE_DIR" && -w "$SOURCE_CACHE_DIR" ]]; then
    return 0
  fi
  fail "共享目录无法在 \$HOME/openclaw 下创建或写入。请检查当前用户的 HOME 路径和目录权限。"
}

ensure_host_ready() {
  if host_ready; then
    return 0
  fi
  if docker_binary_ready && docker_compose_ready && docker_service_running && ! docker_daemon_ready; then
    local docker_diag docker_code docker_message
    docker_diag="$(docker_access_diagnosis)"
    docker_code="${docker_diag%%|*}"
    docker_message="${docker_diag#*|}"
    if [[ "$docker_code" == "socket-permission" ]]; then
      local cur_user
      cur_user="$(current_shell_user)"
      if [[ -t 0 ]] && command -v sudo >/dev/null 2>&1; then
        info "$docker_message"
        info "尝试自动修复: 将用户 $cur_user 加入 docker 组..."
        if sudo usermod -aG docker "$cur_user" 2>/dev/null; then
          log "已将 $cur_user 加入 docker 组。"
          if sg docker -c "docker info" >/dev/null 2>&1; then
            local reexec_cmd joined_args
            joined_args="$(shell_join_args "${ORIGINAL_ARGS[@]}")"
            reexec_cmd="cd $(printf '%q' "$PWD") && exec bash $(printf '%q' "$SCRIPT_PATH") $joined_args"
            log "Docker 权限已在新组上下文中可用，正在自动继续执行当前脚本..."
            exec sg docker -c "$reexec_cmd"
          fi
          printf '\n'
          printf '用户已加入 docker 组，但当前会话仍无法立即使用 Docker。\n'
          printf '请退出当前 shell/SSM 会话并重新连接后，再重新运行此脚本。\n\n'
          exit 0
        fi
      fi
      fail "$docker_message $(docker_permission_help_message)"
    fi
    fail "$docker_message"
  fi
  if [[ -t 0 ]]; then
    if [[ "$QUICK_WIZARD" == "yes" ]]; then
      info "宿主机还没准备好，下面可以自动补齐 Docker 和基础依赖。"
    else
      info "检测到宿主机公共依赖还没准备好。"
    fi
    host_dependency_summary
    if [[ "$(prompt_yes_no "现在自动安装 Docker 和基础依赖吗" "yes")" == "yes" ]]; then
      install_host
      return 0
    fi
  fi
  fail "宿主机依赖未就绪。请先补齐 Docker、Docker Compose、Git、Curl、jq，或运行 ./openclaw-deploy.sh install-host。"
}

choose_instance_interactive() {
  if [[ "$QUICK_WIZARD" == "yes" ]]; then
    printf '实例名默认使用当前用户派生值。\n' >&2
    printf '直接回车即可继续。\n' >&2
  else
    printf '这台 EC2 是多人共用的，每个人都应该使用自己的实例名。\n' >&2
    printf '实例名会决定你的目录、端口、容器名和配置空间，请不要使用别人的实例名。\n' >&2
  fi
  local suggestion
  suggestion="$(sanitize_instance_name "$(instance_owner_default)")"
  local chosen
  chosen="$(sanitize_instance_name "$(prompt_default "实例名" "$suggestion")")"
  printf '%s\n' "$chosen"
}

repo_cache_key() {
  local repo_url="$1"
  local repo_ref="$2"
  python3 - "$repo_url" "$repo_ref" <<'PY'
import hashlib
import sys

repo_url = sys.argv[1]
repo_ref = sys.argv[2]
digest = hashlib.sha256(f"{repo_url}\n{repo_ref}".encode("utf-8")).hexdigest()
print(digest[:24])
PY
}

repo_cache_path() {
  local repo_url="$1"
  local repo_ref="$2"
  local cache_key
  cache_key="$(repo_cache_key "$repo_url" "$repo_ref")"
  printf '%s/%s\n' "$SOURCE_CACHE_DIR" "$cache_key"
}

allocate_ports_for_instance() {
  local instance_name="$1"
  local existing_env
  existing_env="$(instance_env_path "$instance_name")"
  if [[ -f "$existing_env" ]]; then
    load_instance_env "$instance_name"
    printf '%s %s\n' "$OPENCLAW_GATEWAY_PORT" "$OPENCLAW_BRIDGE_PORT"
    return 0
  fi
  allocate_port_pair "$instance_name"
}

sync_source_cache_inner() {
  local repo_url="$1"
  local repo_ref="$2"
  local cache_path
  cache_path="$(repo_cache_path "$repo_url" "$repo_ref")"
  umask 0022
  mkdir -p "$cache_path"
  chmod 755 "$cache_path" 2>/dev/null || true
  [[ -w "$cache_path" ]] || fail "共享源码缓存目录不可写: $cache_path"
  if [[ -e "$cache_path/.git" && ! -w "$cache_path/.git" ]]; then
    fail "共享源码缓存仓库元数据不可写: $cache_path/.git"
  fi
  git config --global --add safe.directory "$cache_path" 2>/dev/null || true
  if [[ ! -d "$cache_path/.git" ]]; then
    git init "$cache_path" >/dev/null || fail "git init failed: $cache_path"
    git -C "$cache_path" remote add origin "$repo_url" || fail "git remote add failed"
  fi
  git -C "$cache_path" remote set-url origin "$repo_url" || fail "git remote set-url failed"
  if ! git -C "$cache_path" fetch --depth 1 origin "$repo_ref" 2>/dev/null && \
     ! git -C "$cache_path" fetch --depth 1 origin; then
    fail "git fetch failed: $repo_url ref=$repo_ref"
  fi
  if git -C "$cache_path" rev-parse --verify -q FETCH_HEAD >/dev/null; then
    git -C "$cache_path" checkout -q FETCH_HEAD || fail "git checkout FETCH_HEAD failed"
  else
    git -C "$cache_path" checkout -q "$repo_ref" || fail "git checkout $repo_ref failed"
  fi
  git -C "$cache_path" rev-parse HEAD
}

sync_source_cache() {
  local repo_url="$1"
  local repo_ref="$2"
  local cache_key
  require_cmd git
  cache_key="$(repo_cache_key "$repo_url" "$repo_ref")"
  with_lock "source-cache-$cache_key" sync_source_cache_inner "$repo_url" "$repo_ref"
}

build_image_inner() {
  local source_dir="$1"
  local commit="$2"
  local image="openclaw:${commit:0:12}"
  if docker image inspect "$image" >/dev/null 2>&1; then
    printf '%s\n' "$image"
    return 0
  fi
  warn_if_build_memory_low
  # Try BuildKit-capable docker build first; fall back to legacy builder
  # for older Docker versions that lack buildx / BuildKit support.
  if docker buildx version >/dev/null 2>&1; then
    docker build \
      --network=host \
      --label "org.openclaw.deploy.commit=$commit" \
      --label "org.openclaw.deploy.ref=$OPENCLAW_REPO_REF" \
      -t "$image" \
      -f "$source_dir/Dockerfile" \
      "$source_dir" >/dev/null \
    || fail "Docker build (BuildKit) 失败，请查看上方输出。"
  else
    DOCKER_BUILDKIT=0 docker build \
      --label "org.openclaw.deploy.commit=$commit" \
      --label "org.openclaw.deploy.ref=$OPENCLAW_REPO_REF" \
      -t "$image" \
      -f "$source_dir/Dockerfile" \
      "$source_dir" >/dev/null \
    || fail "Docker build (legacy) 失败。该 Dockerfile 可能需要 BuildKit 支持，请升级 Docker 或安装 docker-buildx 插件。"
  fi
  printf '%s\n' "$image"
}

build_image_for_commit() {
  local source_dir="$1"
  local commit="$2"
  with_lock "docker-build-${commit:0:12}" build_image_inner "$source_dir" "$commit"
}

write_compose_file() {
  local compose_path="$1"
  local instance_name="$2"
  write_if_changed "$compose_path" <<EOF
services:
  openclaw-gateway:
    image: \${OPENCLAW_IMAGE}
    user: "\${OPENCLAW_RUNTIME_UID}:\${OPENCLAW_RUNTIME_GID}"
    restart: unless-stopped
    environment:
      HOME: ${CONTAINER_HOME}
      TZ: \${OPENCLAW_TZ}
      OPENCLAW_GATEWAY_TOKEN: \${OPENCLAW_GATEWAY_TOKEN}
      OPENAI_API_KEY: \${OPENAI_API_KEY:-}
      ANTHROPIC_API_KEY: \${ANTHROPIC_API_KEY:-}
      OPENROUTER_API_KEY: \${OPENROUTER_API_KEY:-}
      OPENCLAW_BAILIAN_API_KEY: \${OPENCLAW_BAILIAN_API_KEY:-}
      OPENCLAW_CUSTOM_PROVIDER_API_KEY: \${OPENCLAW_CUSTOM_PROVIDER_API_KEY:-}
      TELEGRAM_BOT_TOKEN: \${TELEGRAM_BOT_TOKEN:-}
      DISCORD_BOT_TOKEN: \${DISCORD_BOT_TOKEN:-}
      SLACK_BOT_TOKEN: \${SLACK_BOT_TOKEN:-}
    ports:
      - "\${OPENCLAW_HOST_BIND}:\${OPENCLAW_GATEWAY_PORT}:${CONTAINER_GATEWAY_PORT}"
      - "\${OPENCLAW_HOST_BIND}:\${OPENCLAW_BRIDGE_PORT}:${CONTAINER_BRIDGE_PORT}"
    volumes:
      - \${OPENCLAW_CONFIG_DIR}:${CONTAINER_HOME}
    healthcheck:
      test: ["CMD-SHELL", "node dist/index.js health --token \"\$\$OPENCLAW_GATEWAY_TOKEN\" >/dev/null 2>&1 || wget -qO- http://127.0.0.1:${CONTAINER_GATEWAY_PORT}/healthz >/dev/null 2>&1"]
      interval: 30s
      timeout: 30s
      retries: 10
      start_period: 90s
    deploy:
      resources:
        limits:
          cpus: "\${OPENCLAW_CPU_LIMIT}"
          memory: \${OPENCLAW_MEMORY_LIMIT}

  openclaw-cli:
    image: \${OPENCLAW_IMAGE}
    user: "\${OPENCLAW_RUNTIME_UID}:\${OPENCLAW_RUNTIME_GID}"
    profiles: ["manual"]
    network_mode: "service:openclaw-gateway"
    entrypoint: ["openclaw"]
    environment:
      HOME: ${CONTAINER_HOME}
      TZ: \${OPENCLAW_TZ}
      OPENCLAW_GATEWAY_TOKEN: \${OPENCLAW_GATEWAY_TOKEN}
      OPENAI_API_KEY: \${OPENAI_API_KEY:-}
      ANTHROPIC_API_KEY: \${ANTHROPIC_API_KEY:-}
      OPENROUTER_API_KEY: \${OPENROUTER_API_KEY:-}
      OPENCLAW_BAILIAN_API_KEY: \${OPENCLAW_BAILIAN_API_KEY:-}
      OPENCLAW_CUSTOM_PROVIDER_API_KEY: \${OPENCLAW_CUSTOM_PROVIDER_API_KEY:-}
      TELEGRAM_BOT_TOKEN: \${TELEGRAM_BOT_TOKEN:-}
      DISCORD_BOT_TOKEN: \${DISCORD_BOT_TOKEN:-}
      SLACK_BOT_TOKEN: \${SLACK_BOT_TOKEN:-}
    volumes:
      - \${OPENCLAW_CONFIG_DIR}:${CONTAINER_HOME}
EOF
}

create_instance_skeleton() {
  local instance_name="$1"
  CURRENT_INSTANCE="$instance_name"
  ensure_base_dirs
  mkdir -p "$(instance_dir "$instance_name")" \
    "$(instance_config_dir "$instance_name")" \
    "$(instance_workspace_dir "$instance_name")" \
    "$(instance_state_dir "$instance_name")" \
    "$(instance_log_dir "$instance_name")"
  chmod 700 "$(instance_dir "$instance_name")" || true
}

ensure_instance_runtime_dirs() {
  local instance_name="$1"
  local config_dir workspace_dir state_dir log_dir
  config_dir="$(instance_config_dir "$instance_name")"
  workspace_dir="$(instance_workspace_dir "$instance_name")"
  state_dir="$(instance_state_dir "$instance_name")"
  log_dir="$(instance_log_dir "$instance_name")"

  mkdir -p "$config_dir" "$workspace_dir" "$state_dir" "$log_dir"
  chmod 755 "$config_dir" "$workspace_dir" "$state_dir" "$log_dir" || true
}

random_token() {
  if command -v openssl >/dev/null 2>&1; then
    openssl rand -hex 24
  else
    python3 - <<'PY'
import secrets
print(secrets.token_hex(24))
PY
  fi
}

prepare_instance_defaults() {
  local instance_name="$1"
  with_instance_lock "$instance_name" _prepare_instance_defaults_locked "$instance_name"
  load_instance_env "$instance_name"
}

_prepare_instance_defaults_locked() {
  local instance_name="$1"
  if [[ -f "$(instance_env_path "$instance_name")" ]]; then
    return 0
  fi

  local ports
  ports="$(allocate_ports_for_instance "$instance_name")"
  INSTANCE_NAME="$instance_name"
  INSTANCE_OWNER="$(instance_owner_default)"
  OPENCLAW_REPO_URL="$DEFAULT_REPO_URL"
  OPENCLAW_REPO_REF="$DEFAULT_REPO_REF"
  OPENCLAW_SOURCE_CACHE=""
  OPENCLAW_SOURCE_COMMIT=""
  OPENCLAW_IMAGE=""
  OPENCLAW_GATEWAY_PORT="${ports%% *}"
  OPENCLAW_BRIDGE_PORT="${ports##* }"
  OPENCLAW_HOST_BIND="$DEFAULT_HOST_BIND"
  OPENCLAW_TZ="$DEFAULT_TZ"
  OPENCLAW_ENABLE_QMD="$DEFAULT_ENABLE_QMD"
  OPENCLAW_CONFIG_DIR="$(instance_config_dir "$instance_name")"
  OPENCLAW_WORKSPACE_DIR="$(instance_workspace_dir "$instance_name")"
  OPENCLAW_RUNTIME_UID="$(current_uid)"
  OPENCLAW_RUNTIME_GID="$(current_gid)"
  OPENCLAW_GATEWAY_TOKEN="$(random_token)"
  OPENCLAW_ALLOWED_ORIGINS=""
  OPENCLAW_CPU_LIMIT="$DEFAULT_CPU_LIMIT"
  OPENCLAW_MEMORY_LIMIT="$DEFAULT_MEMORY_LIMIT"
  OPENCLAW_PRIMARY_MODEL="$DEFAULT_PRIMARY_MODEL"
  OPENCLAW_FALLBACK_MODELS=""
  OPENCLAW_ALLOWLIST=""
  OPENCLAW_IMAGE_MODEL="$DEFAULT_IMAGE_MODEL"
  OPENCLAW_MODEL_PROVIDER=""
  OPENCLAW_CUSTOM_PROVIDER_ID=""
  OPENCLAW_CUSTOM_PROVIDER_NAME=""
  OPENCLAW_CUSTOM_PROVIDER_BASE_URL=""
  OPENCLAW_CUSTOM_PROVIDER_API_TYPE="openai-completions"
  OPENCLAW_CUSTOM_PROVIDER_PRIMARY_MODEL=""
  OPENAI_API_KEY=""
  ANTHROPIC_API_KEY=""
  OPENROUTER_API_KEY=""
  OPENCLAW_BAILIAN_API_KEY=""
  OPENCLAW_BAILIAN_BASE_URL="https://coding.dashscope.aliyuncs.com/v1"
  OPENCLAW_BAILIAN_PRIMARY_MODEL=""
  OPENCLAW_CUSTOM_PROVIDER_API_KEY=""
  TELEGRAM_BOT_TOKEN=""
  DISCORD_BOT_TOKEN=""
  SLACK_BOT_TOKEN=""

  export INSTANCE_NAME INSTANCE_OWNER OPENCLAW_REPO_URL OPENCLAW_REPO_REF OPENCLAW_SOURCE_CACHE OPENCLAW_SOURCE_COMMIT OPENCLAW_IMAGE
  export OPENCLAW_GATEWAY_PORT OPENCLAW_BRIDGE_PORT OPENCLAW_HOST_BIND OPENCLAW_TZ OPENCLAW_ENABLE_QMD OPENCLAW_CONFIG_DIR OPENCLAW_WORKSPACE_DIR OPENCLAW_RUNTIME_UID OPENCLAW_RUNTIME_GID
  export OPENCLAW_GATEWAY_TOKEN OPENCLAW_ALLOWED_ORIGINS OPENCLAW_CPU_LIMIT OPENCLAW_MEMORY_LIMIT OPENCLAW_PRIMARY_MODEL OPENCLAW_FALLBACK_MODELS OPENCLAW_ALLOWLIST OPENCLAW_IMAGE_MODEL
  export OPENCLAW_MODEL_PROVIDER OPENCLAW_CUSTOM_PROVIDER_ID OPENCLAW_CUSTOM_PROVIDER_NAME OPENCLAW_CUSTOM_PROVIDER_BASE_URL OPENCLAW_CUSTOM_PROVIDER_API_TYPE
  export OPENCLAW_CUSTOM_PROVIDER_PRIMARY_MODEL OPENAI_API_KEY ANTHROPIC_API_KEY OPENROUTER_API_KEY OPENCLAW_BAILIAN_API_KEY OPENCLAW_BAILIAN_BASE_URL
  export OPENCLAW_BAILIAN_PRIMARY_MODEL OPENCLAW_CUSTOM_PROVIDER_API_KEY TELEGRAM_BOT_TOKEN DISCORD_BOT_TOKEN SLACK_BOT_TOKEN
  write_env_file "$(instance_env_path "$instance_name")"
  write_compose_file "$(instance_compose_path "$instance_name")" "$instance_name"
  write_meta_file "$(instance_meta_path "$instance_name")"
}

persist_instance_state() {
  OPENCLAW_RUNTIME_UID="${OPENCLAW_RUNTIME_UID:-$(current_uid)}"
  OPENCLAW_RUNTIME_GID="${OPENCLAW_RUNTIME_GID:-$(current_gid)}"
  export INSTANCE_NAME INSTANCE_OWNER OPENCLAW_REPO_URL OPENCLAW_REPO_REF OPENCLAW_SOURCE_CACHE OPENCLAW_SOURCE_COMMIT OPENCLAW_IMAGE
  export OPENCLAW_GATEWAY_PORT OPENCLAW_BRIDGE_PORT OPENCLAW_HOST_BIND OPENCLAW_TZ OPENCLAW_ENABLE_QMD OPENCLAW_CONFIG_DIR OPENCLAW_WORKSPACE_DIR OPENCLAW_RUNTIME_UID OPENCLAW_RUNTIME_GID
  export OPENCLAW_GATEWAY_TOKEN OPENCLAW_ALLOWED_ORIGINS OPENCLAW_CPU_LIMIT OPENCLAW_MEMORY_LIMIT OPENCLAW_PRIMARY_MODEL OPENCLAW_FALLBACK_MODELS OPENCLAW_ALLOWLIST OPENCLAW_IMAGE_MODEL
  export OPENCLAW_MODEL_PROVIDER OPENCLAW_CUSTOM_PROVIDER_ID OPENCLAW_CUSTOM_PROVIDER_NAME OPENCLAW_CUSTOM_PROVIDER_BASE_URL OPENCLAW_CUSTOM_PROVIDER_API_TYPE
  export OPENCLAW_CUSTOM_PROVIDER_PRIMARY_MODEL OPENAI_API_KEY ANTHROPIC_API_KEY OPENROUTER_API_KEY OPENCLAW_BAILIAN_API_KEY OPENCLAW_BAILIAN_BASE_URL
  export OPENCLAW_BAILIAN_PRIMARY_MODEL OPENCLAW_CUSTOM_PROVIDER_API_KEY TELEGRAM_BOT_TOKEN DISCORD_BOT_TOKEN SLACK_BOT_TOKEN
  write_env_file "$(instance_env_path "$INSTANCE_NAME")"
  write_compose_file "$(instance_compose_path "$INSTANCE_NAME")" "$INSTANCE_NAME"
  write_meta_file "$(instance_meta_path "$INSTANCE_NAME")"
}

persist_instance_state_locked() {
  local instance_name="$1"
  with_instance_lock "$instance_name" persist_instance_state
}

run_cli_in_instance() {
  local instance_name="$1"
  shift
  [[ $# -gt 0 ]] || fail "CLI args are required"
  load_instance_env "$instance_name"
  compose_for_instance "$instance_name" run --rm openclaw-cli "$@"
}

run_cli_in_instance_noninteractive() {
  local instance_name="$1"
  shift
  [[ $# -gt 0 ]] || fail "CLI args are required"
  load_instance_env "$instance_name"
  compose_for_instance "$instance_name" run --rm -T openclaw-cli "$@" </dev/null
}

write_minimal_instance_config() {
  local instance_name="$1"
  local config_path
  config_path="$(instance_openclaw_config_path "$instance_name")"
  mkdir -p "$(dirname "$config_path")"
  python3 - "$config_path" <<'PY'
import json
import sys
from pathlib import Path

config_path = Path(sys.argv[1])
payload = {
    "gateway": {},
    "agents": {
        "defaults": {},
    },
    "models": {},
    "channels": {
        "telegram": {},
        "discord": {},
        "slack": {},
    },
}
config_path.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
PY
}

run_onboard_if_needed() {
  local instance_name="$1"
  local config_path
  config_path="$(instance_openclaw_config_path "$instance_name")"
  if [[ -f "$config_path" ]]; then
    return 0
  fi
  step "生成基础 OpenClaw 配置"
  info "这一步会调用 OpenClaw 的无交互向导，生成该实例自己的 openclaw.json。"
  local compose_output
  if ! compose_output="$(compose_for_instance "$instance_name" run --rm \
    -e "OPENCLAW_GATEWAY_TOKEN=$OPENCLAW_GATEWAY_TOKEN" \
    openclaw-cli onboard \
    --non-interactive \
    --accept-risk \
    --mode local \
    --auth-choice skip \
    --workspace "$CONTAINER_WORKSPACE_DIR" \
    --gateway-port "$CONTAINER_GATEWAY_PORT" \
    --gateway-bind lan \
    --gateway-auth token \
    --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN \
    --skip-search \
    --skip-skills \
    --skip-ui \
    --skip-health 2>&1)"; then
    host_uid_mode_failure "OpenClaw 初始化" "$compose_output"
  fi
  if [[ ! -f "$config_path" ]]; then
    info "OpenClaw 向导未生成配置文件，改为由部署脚本写入最小 openclaw.json。"
    write_minimal_instance_config "$instance_name"
  fi
  [[ -f "$config_path" ]] || fail "基础配置生成失败: $config_path"
}

patch_instance_config() {
  local instance_name="$1"
  local control_ui_danger_mode="${2:-default}"
  local config_path
  config_path="$(instance_openclaw_config_path "$instance_name")"
  [[ -f "$config_path" ]] || fail "Config not found: $config_path"
  python3 - "$config_path" "$control_ui_danger_mode" <<'PY'
import json
import os
import sys
from pathlib import Path

config_path = Path(sys.argv[1])
dangermode = sys.argv[2]
cfg = json.loads(config_path.read_text(encoding="utf-8"))

workspace = os.environ["CONTAINER_WORKSPACE_DIR"]
primary = os.environ.get("OPENCLAW_PRIMARY_MODEL", "").strip()
fallbacks = [x.strip() for x in os.environ.get("OPENCLAW_FALLBACK_MODELS", "").split(",") if x.strip()]
allowlist = [x.strip() for x in os.environ.get("OPENCLAW_ALLOWLIST", "").split(",") if x.strip()]
image_model = os.environ.get("OPENCLAW_IMAGE_MODEL", "").strip()
provider = os.environ.get("OPENCLAW_MODEL_PROVIDER", "").strip()
host_bind = os.environ.get("OPENCLAW_HOST_BIND", "").strip()
extra_allowed_origins = [x.strip() for x in os.environ.get("OPENCLAW_ALLOWED_ORIGINS", "").split(",") if x.strip()]
gateway_port = os.environ.get("OPENCLAW_GATEWAY_PORT", "").strip() or "18789"
container_port = os.environ.get("CONTAINER_GATEWAY_PORT", "").strip() or "18789"

def env_ref(name: str):
    return {"source": "env", "provider": "default", "id": name}

cfg.setdefault("gateway", {})
cfg["gateway"]["port"] = int(os.environ["CONTAINER_GATEWAY_PORT"])
# Container gateway always binds 0.0.0.0 (lan) inside Docker.
# Host-level access restriction is handled by Docker port mapping
# (OPENCLAW_HOST_BIND:host_port:container_port in docker-compose.yml).
cfg["gateway"]["bind"] = "lan"
cfg["gateway"]["mode"] = "local"
cfg["gateway"].setdefault("auth", {})
cfg["gateway"]["auth"]["mode"] = "token"
if cfg["gateway"]["auth"].get("type") == "token":
    cfg["gateway"]["auth"].pop("type", None)
cfg["gateway"]["auth"]["token"] = env_ref("OPENCLAW_GATEWAY_TOKEN")
control_ui = cfg["gateway"].setdefault("controlUi", {})
control_ui["allowInsecureAuth"] = True
if dangermode == "enable":
    control_ui["dangerouslyDisableDeviceAuth"] = True
elif dangermode == "disable":
    control_ui.pop("dangerouslyDisableDeviceAuth", None)
allowed_origins = []
# Include both host port (direct access) and container port (SSM port-forward default)
for origin in (
    f"http://127.0.0.1:{gateway_port}", f"http://localhost:{gateway_port}",
    f"http://127.0.0.1:{container_port}", f"http://localhost:{container_port}",
    *extra_allowed_origins,
):
    if origin not in allowed_origins:
        allowed_origins.append(origin)
control_ui["allowedOrigins"] = allowed_origins

agents = cfg.setdefault("agents", {})
defaults = agents.setdefault("defaults", {})
defaults["workspace"] = workspace
defaults.setdefault("models", {})
defaults["model"] = defaults.get("model", {})

if primary:
    defaults["model"]["primary"] = primary
if fallbacks:
    defaults["model"]["fallbacks"] = fallbacks
elif isinstance(defaults.get("model"), dict):
    defaults["model"].pop("fallbacks", None)

if image_model:
    defaults["imageModel"] = image_model

if allowlist:
    defaults["allowlist"] = allowlist

if provider == "bailian":
    provider_id = "bailian"
    primary_model = os.environ.get("OPENCLAW_BAILIAN_PRIMARY_MODEL", "").strip()
    short_name = primary_model.split("/", 1)[-1] if "/" in primary_model else primary_model
    cfg.setdefault("models", {}).setdefault("providers", {})
    cfg["models"]["providers"][provider_id] = {
        "baseUrl": os.environ.get("OPENCLAW_BAILIAN_BASE_URL", "").strip(),
        "apiKey": env_ref("OPENCLAW_BAILIAN_API_KEY"),
        "api": "openai-completions",
        "models": [
            {
                "id": short_name,
                "name": short_name,
                "input": ["text", "image"],
                "contextWindow": 262144,
                "maxTokens": 32768,
            }
        ],
    }
    defaults["models"][primary_model] = {}

if provider == "custom":
    provider_id = os.environ.get("OPENCLAW_CUSTOM_PROVIDER_ID", "").strip()
    provider_name = os.environ.get("OPENCLAW_CUSTOM_PROVIDER_NAME", "").strip() or provider_id
    base_url = os.environ.get("OPENCLAW_CUSTOM_PROVIDER_BASE_URL", "").strip()
    api_type = os.environ.get("OPENCLAW_CUSTOM_PROVIDER_API_TYPE", "").strip() or "openai-completions"
    primary_model = os.environ.get("OPENCLAW_CUSTOM_PROVIDER_PRIMARY_MODEL", "").strip()
    short_name = primary_model.split("/", 1)[-1] if "/" in primary_model else primary_model
    cfg.setdefault("models", {}).setdefault("providers", {})
    cfg["models"]["providers"][provider_id] = {
        "name": provider_name,
        "baseUrl": base_url,
        "apiKey": env_ref("OPENCLAW_CUSTOM_PROVIDER_API_KEY"),
        "api": api_type,
        "models": [
            {
                "id": short_name,
                "name": short_name,
                "input": ["text", "image"],
                "contextWindow": 262144,
                "maxTokens": 32768,
            }
        ],
    }
    if primary_model:
        defaults["models"][primary_model] = {}
        defaults["model"]["primary"] = primary_model

channels = cfg.setdefault("channels", {})
for channel_name in ("telegram", "discord", "slack"):
    channels.setdefault(channel_name, {})

config_path.write_text(json.dumps(cfg, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
PY
}

print_control_ui_repair_summary() {
  local instance_name="$1"
  local repair_mode="$2"
  printf '\n'
  printf '实例: %s\n' "$instance_name"
  if [[ "$repair_mode" == "enable" ]]; then
    printf 'Control UI 修复: 已启用 dangerouslyDisableDeviceAuth 临时绕过设备配对。\n'
    printf '浏览器访问: http://127.0.0.1:18789/\n'
    printf "Token 获取: grep '^OPENCLAW_GATEWAY_TOKEN=' %s | cut -d= -f2 | tr -d \"'\"\n" "$(instance_env_path "$instance_name")"
    printf '回退命令: ./openclaw-deploy.sh repair-control-ui --instance %s --revert\n' "$instance_name"
  else
    printf 'Control UI 修复: 已关闭 dangerouslyDisableDeviceAuth，恢复默认设备配对保护。\n'
    printf '如仍需临时绕过: ./openclaw-deploy.sh repair-control-ui --instance %s\n' "$instance_name"
  fi
  printf '下一步:\n'
  printf '  ./openclaw-deploy.sh status --instance %s\n' "$instance_name"
  printf '  ./openclaw-deploy.sh logs --instance %s\n' "$instance_name"
}

apply_aliases_to_config() {
  local config_path
  config_path="$(instance_openclaw_config_path "$INSTANCE_NAME")"
  [[ -f "$config_path" ]] || return 0
  if [[ "${#MODEL_ALIASES[@]}" -eq 0 ]]; then
    return 0
  fi
  local alias_entry
  for alias_entry in "${MODEL_ALIASES[@]}"; do
    [[ "$alias_entry" == *"="* ]] || fail "Invalid model alias format (expected name=value): $alias_entry"
  done
  python3 - "$config_path" "${MODEL_ALIASES[@]}" <<'PY'
import json
import sys
from pathlib import Path

path = Path(sys.argv[1])
cfg = json.loads(path.read_text(encoding="utf-8"))
defaults = cfg.setdefault("agents", {}).setdefault("defaults", {})
aliases = defaults.setdefault("aliases", {})
for pair in sys.argv[2:]:
    if "=" not in pair:
        print(f"Invalid alias format: {pair}", file=sys.stderr)
        raise SystemExit(1)
    name, value = pair.split("=", 1)
    if not name.strip():
        print(f"Invalid alias: empty name in {pair}", file=sys.stderr)
        raise SystemExit(1)
    aliases[name.strip()] = value.strip()
path.write_text(json.dumps(cfg, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
PY
}

configure_model_defaults_from_args() {
  if [[ -n "${ARG_PRIMARY_MODEL:-}" ]]; then
    OPENCLAW_PRIMARY_MODEL="$ARG_PRIMARY_MODEL"
  fi
  if [[ -n "${ARG_FALLBACKS:-}" ]]; then
    OPENCLAW_FALLBACK_MODELS="$ARG_FALLBACKS"
  fi
  if [[ -n "${ARG_ALLOWLIST:-}" ]]; then
    OPENCLAW_ALLOWLIST="$ARG_ALLOWLIST"
  fi
  if [[ -n "${ARG_IMAGE_MODEL:-}" ]]; then
    OPENCLAW_IMAGE_MODEL="$ARG_IMAGE_MODEL"
  fi
}

configure_model() {
  local instance_name="$1"
  load_instance_env "$instance_name"
  ensure_instance_deployed "$instance_name" "configure-model"
  with_instance_lock "$instance_name" _configure_model_locked "$instance_name"
}

_configure_model_locked() {
  local instance_name="$1"
  CURRENT_INSTANCE="$instance_name"
  ensure_instance_env_normalized "$instance_name"
  announce_step "模型" "配置模型" "只更新当前实例的模型凭据和默认模型。"

  local provider="${ARG_PROVIDER:-}"
  if [[ -z "$provider" && "$ARG_NON_INTERACTIVE" == "yes" ]]; then
    fail "--provider is required in --non-interactive mode"
  fi
  if [[ -z "$provider" && -t 0 ]]; then
    provider="$(prompt_default "模型提供商(openai/anthropic/openrouter/bedrock/bailian/custom)" "${OPENCLAW_MODEL_PROVIDER:-openai}")"
  fi
  provider="$(trim "$provider")"
  [[ -n "$provider" ]] || fail "Provider is required"
  OPENCLAW_MODEL_PROVIDER="$provider"
  configure_model_defaults_from_args

  case "$provider" in
    openai)
      if [[ -z "${ARG_API_KEY:-}" && -t 0 ]]; then
        ARG_API_KEY="$(prompt_secret "请输入 OpenAI API Key")"
      fi
      if [[ -z "${ARG_API_KEY:-}" && "$ARG_NON_INTERACTIVE" == "yes" ]]; then
        fail "--api-key is required in --non-interactive mode"
      fi
      [[ -n "${ARG_API_KEY:-}" ]] || fail "OpenAI API key is required"
      OPENAI_API_KEY="$ARG_API_KEY"
      if [[ -z "$OPENCLAW_PRIMARY_MODEL" && "$ARG_NON_INTERACTIVE" == "yes" ]]; then
        fail "--primary-model is required in --non-interactive mode"
      fi
      [[ -n "$OPENCLAW_PRIMARY_MODEL" ]] || OPENCLAW_PRIMARY_MODEL="$(prompt_default "默认模型(openai/...)" "openai/gpt-5.4")"
      ;;
    anthropic)
      if [[ -z "${ARG_API_KEY:-}" && -t 0 ]]; then
        ARG_API_KEY="$(prompt_secret "请输入 Anthropic API Key")"
      fi
      if [[ -z "${ARG_API_KEY:-}" && "$ARG_NON_INTERACTIVE" == "yes" ]]; then
        fail "--api-key is required in --non-interactive mode"
      fi
      [[ -n "${ARG_API_KEY:-}" ]] || fail "Anthropic API key is required"
      ANTHROPIC_API_KEY="$ARG_API_KEY"
      if [[ -z "$OPENCLAW_PRIMARY_MODEL" && "$ARG_NON_INTERACTIVE" == "yes" ]]; then
        fail "--primary-model is required in --non-interactive mode"
      fi
      [[ -n "$OPENCLAW_PRIMARY_MODEL" ]] || OPENCLAW_PRIMARY_MODEL="$(prompt_default "默认模型(anthropic/...)" "anthropic/claude-sonnet-4-5")"
      ;;
    openrouter)
      if [[ -z "${ARG_API_KEY:-}" && -t 0 ]]; then
        ARG_API_KEY="$(prompt_secret "请输入 OpenRouter API Key")"
      fi
      if [[ -z "${ARG_API_KEY:-}" && "$ARG_NON_INTERACTIVE" == "yes" ]]; then
        fail "--api-key is required in --non-interactive mode"
      fi
      [[ -n "${ARG_API_KEY:-}" ]] || fail "OpenRouter API key is required"
      OPENROUTER_API_KEY="$ARG_API_KEY"
      if [[ -z "$OPENCLAW_PRIMARY_MODEL" && "$ARG_NON_INTERACTIVE" == "yes" ]]; then
        fail "--primary-model is required in --non-interactive mode"
      fi
      [[ -n "$OPENCLAW_PRIMARY_MODEL" ]] || OPENCLAW_PRIMARY_MODEL="$(prompt_default "默认模型(openrouter/...)" "openrouter/openai/gpt-4.1")"
      ;;
    bedrock)
      if [[ -z "$OPENCLAW_PRIMARY_MODEL" && "$ARG_NON_INTERACTIVE" == "yes" ]]; then
        fail "--primary-model is required in --non-interactive mode"
      fi
      [[ -n "$OPENCLAW_PRIMARY_MODEL" ]] || OPENCLAW_PRIMARY_MODEL="$(prompt_default "默认模型(bedrock/...)" "bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0")"
      ;;
    bailian)
      if [[ -z "${ARG_API_KEY:-}" && -t 0 ]]; then
        ARG_API_KEY="$(prompt_secret "请输入百炼 API Key")"
      fi
      if [[ -z "${ARG_API_KEY:-}" && "$ARG_NON_INTERACTIVE" == "yes" ]]; then
        fail "--api-key is required in --non-interactive mode"
      fi
      [[ -n "${ARG_API_KEY:-}" ]] || fail "Bailian API key is required"
      OPENCLAW_BAILIAN_API_KEY="$ARG_API_KEY"
      OPENCLAW_BAILIAN_BASE_URL="${ARG_BASE_URL:-${OPENCLAW_BAILIAN_BASE_URL:-https://coding.dashscope.aliyuncs.com/v1}}"
      OPENCLAW_BAILIAN_PRIMARY_MODEL="${ARG_PRIMARY_MODEL:-${OPENCLAW_BAILIAN_PRIMARY_MODEL:-bailian/qwen3.5-plus}}"
      OPENCLAW_PRIMARY_MODEL="$OPENCLAW_BAILIAN_PRIMARY_MODEL"
      ;;
    custom)
      OPENCLAW_CUSTOM_PROVIDER_ID="${ARG_PROVIDER_ID:-${OPENCLAW_CUSTOM_PROVIDER_ID:-}}"
      OPENCLAW_CUSTOM_PROVIDER_NAME="${ARG_PROVIDER_NAME:-${OPENCLAW_CUSTOM_PROVIDER_NAME:-}}"
      OPENCLAW_CUSTOM_PROVIDER_BASE_URL="${ARG_BASE_URL:-${OPENCLAW_CUSTOM_PROVIDER_BASE_URL:-}}"
      OPENCLAW_CUSTOM_PROVIDER_API_TYPE="${ARG_API_TYPE:-${OPENCLAW_CUSTOM_PROVIDER_API_TYPE:-openai-completions}}"
      OPENCLAW_CUSTOM_PROVIDER_PRIMARY_MODEL="${ARG_PRIMARY_MODEL:-${OPENCLAW_CUSTOM_PROVIDER_PRIMARY_MODEL:-}}"
      OPENCLAW_CUSTOM_PROVIDER_API_KEY="${ARG_API_KEY:-${OPENCLAW_CUSTOM_PROVIDER_API_KEY:-}}"
      if [[ "$ARG_NON_INTERACTIVE" == "yes" ]]; then
        [[ -n "$OPENCLAW_CUSTOM_PROVIDER_ID" && -n "$OPENCLAW_CUSTOM_PROVIDER_NAME" && -n "$OPENCLAW_CUSTOM_PROVIDER_BASE_URL" && -n "$OPENCLAW_CUSTOM_PROVIDER_PRIMARY_MODEL" && -n "$OPENCLAW_CUSTOM_PROVIDER_API_KEY" ]] || fail "--provider-id, --provider-name, --base-url, --primary-model, and --api-key are required in --non-interactive mode"
      fi
      if [[ -t 0 ]]; then
        [[ -n "$OPENCLAW_CUSTOM_PROVIDER_ID" ]] || OPENCLAW_CUSTOM_PROVIDER_ID="$(prompt_default "自定义 provider id" "custom-openai")"
        [[ -n "$OPENCLAW_CUSTOM_PROVIDER_NAME" ]] || OPENCLAW_CUSTOM_PROVIDER_NAME="$(prompt_default "自定义 provider 名称" "Custom OpenAI Compatible")"
        [[ -n "$OPENCLAW_CUSTOM_PROVIDER_BASE_URL" ]] || OPENCLAW_CUSTOM_PROVIDER_BASE_URL="$(prompt_default "Base URL" "https://example.com/v1")"
        [[ -n "$OPENCLAW_CUSTOM_PROVIDER_PRIMARY_MODEL" ]] || OPENCLAW_CUSTOM_PROVIDER_PRIMARY_MODEL="$(prompt_default "默认模型(${OPENCLAW_CUSTOM_PROVIDER_ID}/...)" "${OPENCLAW_CUSTOM_PROVIDER_ID}/gpt-4.1")"
        [[ -n "$OPENCLAW_CUSTOM_PROVIDER_API_KEY" ]] || OPENCLAW_CUSTOM_PROVIDER_API_KEY="$(prompt_secret "自定义 provider API Key")"
      fi
      [[ -n "$OPENCLAW_CUSTOM_PROVIDER_ID" && -n "$OPENCLAW_CUSTOM_PROVIDER_BASE_URL" && -n "$OPENCLAW_CUSTOM_PROVIDER_PRIMARY_MODEL" ]] || fail "Custom provider config is incomplete"
      [[ -n "$OPENCLAW_CUSTOM_PROVIDER_API_KEY" ]] || fail "Custom provider API key is required"
      OPENCLAW_PRIMARY_MODEL="$OPENCLAW_CUSTOM_PROVIDER_PRIMARY_MODEL"
      ;;
    *)
      fail "Unsupported provider: $provider"
      ;;
  esac

  persist_instance_state
  export CONTAINER_WORKSPACE_DIR CONTAINER_GATEWAY_PORT
  patch_instance_config "$instance_name"
  apply_aliases_to_config
  local model_check_output=""
  if ! model_check_output="$(validate_model_configuration "$instance_name" 2>&1)"; then
    info "模型配置已写入，但即时探测尚未通过: $(compose_failure_summary "$model_check_output")"
  fi
  local compose_output
  if ! compose_output="$(compose_for_instance "$instance_name" up -d --force-recreate openclaw-gateway 2>&1)"; then
    host_uid_mode_failure "模型配置后的服务启动" "$compose_output"
  fi
  info "模型配置已写入当前实例。"
  print_instance_success_summary "$instance_name"
}

configure_gateway() {
  local instance_name="$1"
  load_instance_env "$instance_name"
  ensure_instance_deployed "$instance_name" "configure-gateway"
  with_instance_lock "$instance_name" _configure_gateway_locked "$instance_name"
}

_configure_gateway_locked() {
  local instance_name="$1"
  local current_gateway_port current_bridge_port
  CURRENT_INSTANCE="$instance_name"
  ensure_instance_env_normalized "$instance_name"
  announce_step "网关" "配置访问入口" "只更新当前实例的端口、绑定地址和访问令牌。"
  current_gateway_port="${OPENCLAW_GATEWAY_PORT:-}"
  current_bridge_port="${OPENCLAW_BRIDGE_PORT:-}"

  if [[ -n "${ARG_GATEWAY_PORT:-}" ]]; then
    OPENCLAW_GATEWAY_PORT="$ARG_GATEWAY_PORT"
  elif [[ -t 0 ]]; then
    OPENCLAW_GATEWAY_PORT="$(prompt_default "Gateway 端口" "$OPENCLAW_GATEWAY_PORT")"
  fi
  if [[ -n "${ARG_BRIDGE_PORT:-}" ]]; then
    OPENCLAW_BRIDGE_PORT="$ARG_BRIDGE_PORT"
  elif [[ -t 0 ]]; then
    OPENCLAW_BRIDGE_PORT="$(prompt_default "Bridge 端口" "$OPENCLAW_BRIDGE_PORT")"
  fi
  if [[ -n "${ARG_BIND:-}" ]]; then
    OPENCLAW_HOST_BIND="$ARG_BIND"
  elif [[ -t 0 ]]; then
    OPENCLAW_HOST_BIND="$(prompt_default "绑定地址" "$OPENCLAW_HOST_BIND")"
  fi
  if [[ -n "${ARG_TOKEN:-}" ]]; then
    OPENCLAW_GATEWAY_TOKEN="$ARG_TOKEN"
  elif [[ -t 0 && "$(prompt_yes_no "是否为当前实例重新生成访问 token" "no")" == "yes" ]]; then
    OPENCLAW_GATEWAY_TOKEN="$(random_token)"
  fi
  if [[ "${#GATEWAY_ALLOWED_ORIGIN_ARGS[@]}" -gt 0 ]] && has_nonempty_values "${GATEWAY_ALLOWED_ORIGIN_ARGS[@]}"; then
    OPENCLAW_ALLOWED_ORIGINS="$(normalize_origin_csv "${GATEWAY_ALLOWED_ORIGIN_ARGS[@]}")"
  fi

  validate_instance_ports "$instance_name" "$OPENCLAW_GATEWAY_PORT" "$OPENCLAW_BRIDGE_PORT" "$current_gateway_port" "$current_bridge_port"

  persist_instance_state
  export CONTAINER_WORKSPACE_DIR CONTAINER_GATEWAY_PORT
  patch_instance_config "$instance_name"
  local compose_output
  if ! compose_output="$(compose_for_instance "$instance_name" up -d --force-recreate openclaw-gateway 2>&1)"; then
    host_uid_mode_failure "网关配置更新" "$compose_output"
  fi
  info "当前实例的网关配置已更新。"
  print_instance_success_summary "$instance_name"
}

configure_channel() {
  local instance_name="$1"
  load_instance_env "$instance_name"
  ensure_instance_deployed "$instance_name" "configure-channel"
  with_instance_lock "$instance_name" _configure_channel_locked "$instance_name"
}

_configure_channel_locked() {
  local instance_name="$1"
  CURRENT_INSTANCE="$instance_name"
  ensure_instance_env_normalized "$instance_name"
  announce_step "频道" "配置聊天通道" "只修改当前实例的 channel 配置。"

  local channel="${ARG_CHANNEL:-}"
  if [[ -z "$channel" && "$ARG_NON_INTERACTIVE" == "yes" ]]; then
    fail "--channel is required in --non-interactive mode"
  fi
  [[ -n "$channel" || ! -t 0 ]] || channel="$(prompt_default "选择 channel(telegram/discord/slack)" "telegram")"
  [[ -n "$channel" ]] || fail "Channel is required"
  local token="${ARG_CHANNEL_TOKEN:-}"
  local dm_policy="${ARG_DM_POLICY:-pairing}"
  local allow_from="${ARG_ALLOW_FROM:-}"
  local disabled="${ARG_DISABLE:-no}"
  if [[ "$disabled" != "yes" && -z "$token" && "$ARG_NON_INTERACTIVE" == "yes" ]]; then
    fail "--token is required in --non-interactive mode"
  fi
  if [[ "$disabled" != "yes" && -z "$token" && -t 0 ]]; then
    token="$(prompt_secret "请输入 $channel token")"
  fi
  local config_path
  config_path="$(instance_openclaw_config_path "$instance_name")"
  [[ -f "$config_path" ]] || fail "Config not found: $config_path"

  case "$channel" in
    telegram) [[ "$disabled" == "yes" ]] || TELEGRAM_BOT_TOKEN="$token" ;;
    discord) [[ "$disabled" == "yes" ]] || DISCORD_BOT_TOKEN="$token" ;;
    slack) [[ "$disabled" == "yes" ]] || SLACK_BOT_TOKEN="$token" ;;
    *) fail "Unsupported channel: $channel" ;;
  esac
  persist_instance_state

  python3 - "$config_path" "$channel" "$dm_policy" "$allow_from" "$disabled" <<'PY'
import json
import sys
from pathlib import Path

if len(sys.argv) < 6:
    print(f"Internal error: expected 5 arguments, got {len(sys.argv) - 1}", file=sys.stderr)
    raise SystemExit(1)

path = Path(sys.argv[1])
channel = sys.argv[2]
dm_policy = sys.argv[3]
allow_from = [x.strip() for x in sys.argv[4].split(",") if x.strip()]
disabled = sys.argv[5] == "yes"
cfg = json.loads(path.read_text(encoding="utf-8"))
channels = cfg.setdefault("channels", {})
entry = channels.setdefault(channel, {})
entry["enabled"] = not disabled
if not disabled:
    env_map = {
        "telegram": "TELEGRAM_BOT_TOKEN",
        "discord": "DISCORD_BOT_TOKEN",
        "slack": "SLACK_BOT_TOKEN",
    }
    entry["dmPolicy"] = dm_policy
    if allow_from:
        entry["allowFrom"] = allow_from
    token_key = "botToken"
    entry[token_key] = {"source": "env", "provider": "default", "id": env_map[channel]}
path.write_text(json.dumps(cfg, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
PY

  local compose_output
  if ! compose_output="$(compose_for_instance "$instance_name" up -d --force-recreate openclaw-gateway 2>&1)"; then
    host_uid_mode_failure "频道配置更新" "$compose_output"
  fi
  info "频道配置已更新。"
  print_instance_success_summary "$instance_name"
}

login_codex() {
  local instance_name="$1"
  load_instance_env "$instance_name"
  ensure_instance_deployed "$instance_name" "login-codex"
  with_instance_lock "$instance_name" _login_codex_locked "$instance_name"
}

_login_codex_locked() {
  local instance_name="$1"
  CURRENT_INSTANCE="$instance_name"
  ensure_instance_env_normalized "$instance_name"
  announce_step "Codex" "登录 Codex" "把 Codex 认证写入当前实例。"
  printf '如果浏览器回调页报错，请复制完整回调 URL 再粘贴回命令行，这是 headless 登录里的正常现象。\n'
  if ! compose_for_instance "$instance_name" run --rm openclaw-cli models auth login --provider openai-codex --set-default; then
    host_uid_mode_failure "Codex 登录"
  fi
  print_instance_success_summary "$instance_name"
}

list_pending_device_requests() {
  local instance_name="$1"
  local output=""
  if ! output="$(run_cli_in_instance_noninteractive "$instance_name" devices list --json 2>/dev/null)"; then
    output="$(run_cli_in_instance_noninteractive "$instance_name" devices list 2>&1)" || fail "读取待配对设备失败: $(compose_failure_summary "$output")"
  fi
  python3 - "$output" <<'PY'
import json
import re
import sys

raw = sys.argv[1]

def emit(req_id: str, label: str):
    req_id = (req_id or "").strip()
    label = (label or "").strip()
    if not req_id:
        return
    print(f"{req_id}\t{label or req_id}")

try:
    payload = json.loads(raw)
except Exception:
    payload = None

if payload is not None:
    rows = payload
    if isinstance(rows, dict):
        for key in ("requests", "devices", "items", "results", "pending"):
            if isinstance(rows.get(key), list):
                rows = rows[key]
                break
        else:
            rows = [rows]
    if isinstance(rows, list):
        for item in rows:
            if not isinstance(item, dict):
                continue
            status = str(item.get("status", item.get("state", ""))).strip().lower()
            if status and status not in ("pending", "requested", "awaiting_approval"):
                continue
            req_id = ""
            for key in ("requestId", "id", "deviceId"):
                value = item.get(key)
                if value:
                    req_id = str(value)
                    break
            label_parts = []
            for key in ("name", "deviceName", "label", "status", "createdAt", "updatedAt"):
                value = item.get(key)
                if value:
                    label_parts.append(f"{key}={value}")
            emit(req_id, " | ".join(label_parts))
        raise SystemExit(0)

lines = [line.rstrip() for line in raw.splitlines() if line.strip()]
candidate_lines = [line for line in lines if "pending" in line.lower()]
if not candidate_lines:
    candidate_lines = [
        line for line in lines
        if not re.match(r"^[=\-+| ]+$", line)
        and "request" not in line.lower()
        and "device" not in line.lower()
        and "status" not in line.lower()
    ]

for line in candidate_lines:
    match = re.search(r"\b([A-Za-z0-9][A-Za-z0-9._:-]{5,})\b", line)
    if not match:
        continue
    emit(match.group(1), line)
PY
}

pair_device() {
  local instance_name="$1"
  load_instance_env "$instance_name"
  ensure_instance_deployed "$instance_name" "pair-device"
  with_instance_lock "$instance_name" _pair_device_locked "$instance_name"
}

repair_control_ui() {
  local instance_name="$1"
  load_instance_env "$instance_name"
  ensure_instance_deployed "$instance_name" "repair-control-ui"
  with_instance_lock "$instance_name" _repair_control_ui_locked "$instance_name"
}

_repair_control_ui_locked() {
  local instance_name="$1"
  local repair_mode="enable"
  local compose_output
  CURRENT_INSTANCE="$instance_name"
  ensure_instance_env_normalized "$instance_name"
  export CONTAINER_WORKSPACE_DIR CONTAINER_GATEWAY_PORT
  if [[ "${ARG_REVERT:-no}" == "yes" ]]; then
    repair_mode="disable"
    announce_step "Control UI" "恢复默认设备配对保护" "删除危险绕过开关并重启当前实例的 gateway。"
  else
    announce_step "Control UI" "临时绕过设备配对" "为当前实例开启 dangerouslyDisableDeviceAuth，并重启 gateway。"
  fi
  patch_instance_config "$instance_name" "$repair_mode"
  if ! compose_output="$(compose_for_instance "$instance_name" up -d --force-recreate openclaw-gateway 2>&1)"; then
    host_uid_mode_failure "Control UI 修复" "$compose_output"
  fi
  info "当前实例的 Control UI 配置已更新。"
  print_control_ui_repair_summary "$instance_name" "$repair_mode"
}

_pair_device_locked() {
  local instance_name="$1"
  CURRENT_INSTANCE="$instance_name"
  ensure_instance_env_normalized "$instance_name"
  announce_step "Pairing" "批准设备配对" "读取当前实例的待批准设备请求，并批准你选中的那一个。"

  # Directly read the on-disk device JSON files instead of going through the
  # CLI (which itself needs a paired device — chicken-and-egg problem).
  local devices_dir
  devices_dir="$(instance_config_dir "$instance_name")/.openclaw/devices"
  local paired_path="$devices_dir/paired.json"
  local pending_path="$devices_dir/pending.json"

  if [[ ! -f "$pending_path" ]]; then
    info "当前没有待批准的设备 pairing 请求。"
    info "如果浏览器仍然显示 pairing required，请刷新页面后再重试，或查看日志。"
    printf '下一步:\n'
    printf '  ./openclaw-deploy.sh logs --instance %s\n' "$instance_name"
    return 0
  fi

  local approved_count
  approved_count="$(python3 - "$paired_path" "$pending_path" "${ARG_YES:-no}" <<'PY'
import json, sys, time

paired_path = sys.argv[1]
pending_path = sys.argv[2]
auto_yes = sys.argv[3] == "yes"

try:
    with open(pending_path) as f:
        pending = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
    pending = {}

if not pending:
    print("0")
    sys.exit(0)

try:
    with open(paired_path) as f:
        paired = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
    paired = {}

items = list(pending.items())

# In --yes / non-interactive mode, approve all pending requests.
# In interactive mode, let the user pick.
if not auto_yes and sys.stdin.isatty():
    print("选择要批准的设备:", file=sys.stderr)
    for i, (req_id, req) in enumerate(items):
        did = req.get("deviceId", "?")[:16]
        client = req.get("clientId", "?")
        platform = req.get("platform", "?")
        print(f"  {i+1}. {client} ({platform}) device={did}...", file=sys.stderr)
    print(f"  A. 批准全部 ({len(items)} 个)", file=sys.stderr)
    choice = input("选择 [A]: ").strip() or "A"
    if choice.upper() == "A":
        selected = items
    elif choice.isdigit() and 1 <= int(choice) <= len(items):
        selected = [items[int(choice) - 1]]
    else:
        print("Invalid choice", file=sys.stderr)
        print("0")
        sys.exit(1)
else:
    selected = items

now_ms = int(time.time() * 1000)
count = 0
for req_id, req in selected:
    did = req["deviceId"]
    paired[did] = {
        "deviceId": did,
        "publicKey": req.get("publicKey", ""),
        "platform": req.get("platform", "unknown"),
        "clientId": req.get("clientId", "unknown"),
        "clientMode": req.get("clientMode", "unknown"),
        "role": "operator",
        "roles": ["operator"],
        "scopes": req.get("scopes", ["operator.admin", "operator.read", "operator.write", "operator.approvals", "operator.pairing"]),
        "approvedScopes": req.get("scopes", ["operator.admin", "operator.read", "operator.write", "operator.approvals", "operator.pairing"]),
        "remoteIp": req.get("remoteIp", ""),
        "tokens": {},
        "createdAtMs": now_ms,
        "approvedAtMs": now_ms,
    }
    del pending[req_id]
    client = req.get("clientId", "?")
    print(f"  Approved: {client} device={did[:16]}...", file=sys.stderr)
    count += 1

with open(paired_path, "w") as f:
    json.dump(paired, f, indent=2)
with open(pending_path, "w") as f:
    json.dump(pending, f, indent=2)
print(str(count))
PY
  )" || fail "设备 pairing 批准脚本执行失败"

  if [[ "$approved_count" == "0" ]]; then
    info "当前没有待批准的设备 pairing 请求。"
    info "如果浏览器仍然显示 pairing required，请刷新页面后再重试，或查看日志。"
  else
    info "已批准 $approved_count 个设备 pairing 请求。"
    # Signal the gateway to reload device state
    local container_name
    container_name="$(compose_for_instance "$instance_name" ps -q openclaw-gateway 2>/dev/null || true)"
    if [[ -n "$container_name" ]]; then
      docker kill --signal=SIGUSR1 "$container_name" >/dev/null 2>&1 || true
      info "已通知 gateway 重载配置。"
    fi
  fi
  printf '下一步:\n'
  printf '  1. 回到浏览器刷新当前 OpenClaw 页面\n'
  printf '  2. 如果仍然失败，运行 ./openclaw-deploy.sh logs --instance %s\n' "$instance_name"
}

clone_codex_from_paths() {
  local source_config_dir="$1"
  local target_config_dir="$2"
  mkdir -p "$target_config_dir/agents/main/agent"
  python3 - "$source_config_dir" "$target_config_dir" <<'PY'
import json
import shutil
import sys
from pathlib import Path

src = Path(sys.argv[1])
dst = Path(sys.argv[2])

src_cfg = src / "openclaw.json"
dst_cfg = dst / "openclaw.json"
src_auth = src / "agents/main/agent/auth-profiles.json"
src_models = src / "agents/main/agent/models.json"
dst_auth = dst / "agents/main/agent/auth-profiles.json"
dst_models = dst / "agents/main/agent/models.json"

if not src_cfg.exists():
    raise SystemExit(f"Source config not found: {src_cfg}")
if not src_auth.exists():
    raise SystemExit(f"Source auth store not found: {src_auth}")

def load_json(path, default):
    if not path.exists():
        return default
    return json.loads(path.read_text(encoding="utf-8"))

cfg_src = load_json(src_cfg, {})
cfg_dst = load_json(dst_cfg, {})
cfg_dst.setdefault("auth", {})
cfg_dst["auth"].setdefault("profiles", {})
for key, value in (cfg_src.get("auth", {}).get("profiles", {}) or {}).items():
    if str(key).startswith("openai-codex:"):
        cfg_dst["auth"]["profiles"][key] = value

src_defaults = cfg_src.get("agents", {}).get("defaults", {}) or {}
dst_defaults = cfg_dst.setdefault("agents", {}).setdefault("defaults", {})
if str((src_defaults.get("model") or {}).get("primary", "")).startswith("openai-codex/"):
    dst_defaults["model"] = {"primary": src_defaults["model"]["primary"]}
src_models_map = src_defaults.get("models") or {}
if src_models_map:
    dst_defaults.setdefault("models", {})
    for key, value in src_models_map.items():
        if str(key).startswith("openai-codex/"):
            dst_defaults["models"][key] = value
dst_cfg.write_text(json.dumps(cfg_dst, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")

shutil.copy2(src_auth, dst_auth)
if src_models.exists():
    shutil.copy2(src_models, dst_models)
PY
}

migrate_codex() {
  local target_instance="$1"
  local from_container="$2"
  local from_path="$3"
  load_instance_env "$target_instance"
  ensure_instance_deployed "$target_instance" "migrate-codex"
  with_instance_lock "$target_instance" _migrate_codex_locked "$target_instance" "$from_container" "$from_path"
}

_migrate_codex_locked() {
  local target_instance="$1"
  local from_container="$2"
  local from_path="$3"
  CURRENT_INSTANCE="$target_instance"
  ensure_instance_env_normalized "$target_instance"
  announce_step "Codex" "迁移 Codex" "把旧实例里的 Codex 登录状态复制到当前实例。"

  local source_dir=""
  if [[ -n "$from_container" ]]; then
    source_dir="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-codex.XXXXXX")"
    trap 'rm -rf "$source_dir"' RETURN
    if ! docker cp "$from_container:$CONTAINER_HOME/." "$source_dir"; then
      fail "Failed to copy Codex data from container: $from_container"
    fi
  elif [[ -n "$from_path" ]]; then
    source_dir="$from_path"
  else
    fail "Either --from-container or --from-path is required"
  fi

  confirm_action "确认把 Codex 状态迁移到实例 $target_instance 吗" "${ARG_YES:-no}" || fail "Migration cancelled"
  if ! clone_codex_from_paths "$source_dir" "$(instance_config_dir "$target_instance")"; then
    fail "Failed to clone Codex configuration"
  fi
  local compose_output
  if ! compose_output="$(compose_for_instance "$target_instance" up -d --force-recreate openclaw-gateway 2>&1)"; then
    host_uid_mode_failure "Codex 迁移后的服务启动" "$compose_output"
  fi
  info "Codex 状态已迁移到当前实例。"
  print_instance_success_summary "$target_instance"
}

status_instance() {
  local instance_name="$1"
  local instance_runtime_state
  load_instance_env "$instance_name"
  instance_runtime_state="$(instance_state "$instance_name")"

  local state="$instance_runtime_state"
  local health=""
  local ps_json
  local ps_output_file
  local compose_error=""
  ps_output_file="$(mktemp "${TMPDIR:-/tmp}/openclaw-ps.XXXXXX")"

  if [[ "$instance_runtime_state" == "deployed" ]] && compose_for_instance "$instance_name" ps --format json >"$ps_output_file" 2>"$ps_output_file.stderr"; then
    ps_json="$(cat "$ps_output_file")"
    if [[ -z "$(trim "$ps_json")" ]]; then
      state="stopped"
      health=""
    elif read -r state health < <(python3 - <<'PY' "$ps_json"
import json
import sys
try:
    rows = json.loads(sys.argv[1])
except Exception:
    raise SystemExit(1)
if isinstance(rows, dict):
    rows = [rows]
for row in rows:
    if row.get("Service") == "openclaw-gateway":
        print(row.get("State", "unknown"), row.get("Health", ""))
        break
else:
    print("stopped", "")
PY
    ); then
      :
    else
      state="invalid"
      compose_error="Docker Compose ps 返回了非 JSON 输出"
    fi
  elif [[ "$instance_runtime_state" == "deployed" ]]; then
    local docker_diag docker_code docker_message
    state="invalid"
    compose_error="$(compose_failure_summary "$(cat "$ps_output_file.stderr" 2>/dev/null || true)")"
    docker_diag="$(docker_access_diagnosis)"
    docker_code="${docker_diag%%|*}"
    docker_message="${docker_diag#*|}"
    case "$docker_code" in
      socket-permission|startup-race|daemon-stopped|daemon-unhealthy|daemon-error|missing)
        compose_error="$docker_message"
        ;;
    esac
  fi
  rm -f "$ps_output_file"
  rm -f "$ps_output_file.stderr"

  printf '实例: %s\n' "$instance_name"
  printf '  归属人: %s\n' "$INSTANCE_OWNER"
  printf '  状态: %s' "$state"
  [[ -n "$health" ]] && printf ' (%s)' "$health"
  printf '\n'
  if [[ "$state" == "invalid" && -n "$compose_error" ]]; then
    printf '  Compose 错误: %s\n' "$compose_error"
  fi
  printf '  端口: gateway=%s bridge=%s bind=%s\n' "$OPENCLAW_GATEWAY_PORT" "$OPENCLAW_BRIDGE_PORT" "$OPENCLAW_HOST_BIND"
  printf '  模型: %s\n' "${OPENCLAW_PRIMARY_MODEL:-<unset>}"
  printf '  镜像: %s\n' "${OPENCLAW_IMAGE:-<unset>}"
}

list_instances() {
  if [[ ! -d "$INSTANCES_DIR" ]]; then
    printf '还没有创建任何实例。\n'
    return 0
  fi
  local found="no"
  local env_file
  for env_file in "$INSTANCES_DIR"/*/.env; do
    [[ -e "$env_file" ]] || continue
    found="yes"
    status_instance "$(basename "$(dirname "$env_file")")"
  done
  [[ "$found" == "yes" ]] || printf '还没有创建任何实例。\n'
}

doctor_instance() {
  local instance_name="$1"
  load_instance_env "$instance_name"
  ensure_instance_deployed "$instance_name" "doctor"
  CURRENT_INSTANCE="$instance_name"
  announce_step "检查" "实例检查" "检查容器、端口和基础健康状态。"
  require_cmd curl

  local failed=0
  local degraded=0
  local probe_host compose_error model_error docker_diag docker_code docker_message
  probe_host="$(health_probe_host)"

  if compose_error="$(compose_for_instance "$instance_name" ps 2>&1)"; then
    info "Docker Compose 状态读取成功。"
  else
    docker_diag="$(docker_access_diagnosis)"
    docker_code="${docker_diag%%|*}"
    docker_message="${docker_diag#*|}"
    case "$docker_code" in
      socket-permission|startup-race|daemon-stopped|daemon-unhealthy|daemon-error|missing)
        info "Docker Compose 状态读取失败: $docker_message"
        ;;
      *)
        info "Docker Compose 状态读取失败: $(compose_failure_summary "$compose_error")"
        ;;
    esac
    failed=1
  fi

  if curl -fsS "http://${probe_host}:${OPENCLAW_GATEWAY_PORT}/healthz" >/dev/null 2>&1; then
    info "Gateway /healthz 正常（探测地址: ${probe_host}）。"
  else
    info "Gateway /healthz 不可用（探测地址: ${probe_host}）。"
    failed=1
  fi

  if [[ -f "$(instance_openclaw_config_path "$instance_name")" ]]; then
    info "配置文件存在。"
  else
    info "配置文件缺失。"
    failed=1
  fi

  if model_error="$(validate_model_configuration "$instance_name" 2>&1)"; then
    info "模型检查通过。"
  else
    info "模型检查未通过: $(compose_failure_summary "$model_error")"
    degraded=1
  fi

  if [[ "$failed" -eq 0 && "$degraded" -eq 0 ]]; then
    printf 'Result: ok\n'
  elif [[ "$failed" -eq 0 ]]; then
    printf 'Result: degraded\n'
  else
    printf 'Result: failed\n'
    return 1
  fi
}

validate_model_configuration() {
  local instance_name="$1"
  local output=""
  if output="$(run_cli_in_instance "$instance_name" models status --probe 2>&1)"; then
    return 0
  fi
  local first_error
  first_error="$(printf '%s' "$output" | tail -n 1)"
  if output="$(run_cli_in_instance "$instance_name" models list 2>&1)"; then
    return 0
  fi
  if [[ -n "$(printf '%s' "$output" | tail -n 1)" ]]; then
    printf '%s\n' "$(printf '%s' "$output" | tail -n 1)"
  else
    printf '%s\n' "$first_error"
  fi
  return 1
}

remove_instance() {
  local instance_name="$1"
  local purge_data="$2"
  local auto_yes="$3"
  local project_name state
  load_instance_env "$instance_name"
  CURRENT_INSTANCE="$instance_name"
  state="$(instance_state "$instance_name")"
  project_name="$(project_name_for "$instance_name")"
  confirm_action "确认删除实例 $instance_name 的容器吗" "$auto_yes" || fail "Remove cancelled"
  with_instance_lock "$instance_name" _remove_instance_locked "$instance_name" "$purge_data" "$auto_yes" "$state" "$project_name"
}

_remove_instance_locked() {
  local instance_name="$1"
  local purge_data="$2"
  local auto_yes="$3"
  local state="$4"
  local project_name="$5"
  case "$state" in
    deployed)
      compose_for_instance "$instance_name" down --remove-orphans || fail "实例 $instance_name 的容器关闭失败，已停止 purge 以避免留下孤儿资源。"
      ;;
    skeleton)
      if docker_accessible_for_cleanup; then
        if docker_project_resources_exist "$project_name"; then
          cleanup_project_resources_without_compose "$project_name"
        else
          info "实例 $instance_name 还没有完成 deploy，未发现需要清理的 Docker 遗留资源。"
        fi
      else
        if [[ "$purge_data" == "yes" ]]; then
          fail "实例 $instance_name 处于 skeleton 状态，且当前无法访问 Docker；为避免误删仍有关联容器/网络的数据目录，已停止 purge。"
        fi
        info "实例 $instance_name 还没有完成 deploy，且当前无法访问 Docker，跳过遗留资源检查。"
      fi
      ;;
    dirty)
      if docker_accessible_for_cleanup; then
        cleanup_project_resources_without_compose "$project_name"
      else
        fail "实例 $instance_name 当前处于不完整/损坏状态，且 Docker 不可访问，无法安全清理遗留资源。$( [[ "$purge_data" == "yes" ]] && printf ' 已停止 purge 以避免留下孤儿资源。' )"
      fi
      ;;
  esac

  if [[ "$purge_data" == "yes" ]]; then
    confirm_action "确认彻底删除实例 $instance_name 的数据目录吗" "$auto_yes" || fail "Purge cancelled"
    rm -rf "$(instance_dir "$instance_name")"
  fi
}

deploy_instance() {
  local instance_name="$1"
  local current_gateway_port current_bridge_port
  local port_error=""
  local quick_interactive="no"
  CURRENT_INSTANCE="$instance_name"
  if [[ "$QUICK_WIZARD" == "yes" && -t 0 ]]; then
    quick_interactive="yes"
  fi
  announce_step 1 "确认实例名" "这台 EC2 是多人共用的，所有部署都会先锁定实例名，避免操作到别人的实例。"
  info "当前实例: $instance_name"

  announce_step 2 "检查宿主机环境" "先确认 Docker 和基础依赖已经准备好，避免部署到一半失败。"
  ensure_host_ready
  ensure_instance_storage_ready

  announce_step 3 "确认当前实例参数" "这里会决定当前实例的源码版本、端口和访问地址。已有实例会优先沿用旧配置。"
  prepare_instance_defaults "$instance_name"
  load_instance_env "$instance_name"
  CURRENT_INSTANCE="$instance_name"
  current_gateway_port="$OPENCLAW_GATEWAY_PORT"
  current_bridge_port="$OPENCLAW_BRIDGE_PORT"

  if [[ -n "${ARG_REPO_URL:-}" ]]; then
    OPENCLAW_REPO_URL="$ARG_REPO_URL"
  elif [[ "$ARG_NON_INTERACTIVE" == "yes" && -z "$OPENCLAW_REPO_URL" ]]; then
    fail "--repo-url is required in --non-interactive mode"
  elif [[ "$quick_interactive" != "yes" && -t 0 && -z "$OPENCLAW_SOURCE_COMMIT" ]]; then
    OPENCLAW_REPO_URL="$(prompt_default "源码仓库地址" "$OPENCLAW_REPO_URL")"
  fi
  if [[ -n "${ARG_REPO_REF:-}" ]]; then
    OPENCLAW_REPO_REF="$ARG_REPO_REF"
  elif [[ "$ARG_NON_INTERACTIVE" == "yes" && -z "$OPENCLAW_REPO_REF" ]]; then
    fail "--repo-ref is required in --non-interactive mode"
  elif [[ "$quick_interactive" != "yes" && -t 0 ]]; then
    OPENCLAW_REPO_REF="$(prompt_default "源码分支或 tag" "$OPENCLAW_REPO_REF")"
  fi
  if [[ -n "${ARG_GATEWAY_PORT:-}" ]]; then
    OPENCLAW_GATEWAY_PORT="$ARG_GATEWAY_PORT"
  elif [[ "$quick_interactive" != "yes" && -t 0 ]]; then
    OPENCLAW_GATEWAY_PORT="$(prompt_default "Gateway 端口" "$OPENCLAW_GATEWAY_PORT")"
  fi
  if [[ -n "${ARG_BRIDGE_PORT:-}" ]]; then
    OPENCLAW_BRIDGE_PORT="$ARG_BRIDGE_PORT"
  elif [[ "$quick_interactive" != "yes" && -t 0 ]]; then
    OPENCLAW_BRIDGE_PORT="$(prompt_default "Bridge 端口" "$OPENCLAW_BRIDGE_PORT")"
  fi
  if [[ -n "${ARG_BIND:-}" ]]; then
    OPENCLAW_HOST_BIND="$ARG_BIND"
  elif [[ "$quick_interactive" != "yes" && -t 0 ]]; then
    OPENCLAW_HOST_BIND="$(prompt_default "绑定地址" "$OPENCLAW_HOST_BIND")"
  fi
  if [[ -n "${ARG_TZ:-}" ]]; then
    OPENCLAW_TZ="$ARG_TZ"
  elif [[ "$quick_interactive" != "yes" && -t 0 ]]; then
    OPENCLAW_TZ="$(prompt_default "时区" "$OPENCLAW_TZ")"
  fi
  if [[ "${ARG_ENABLE_QMD:-no}" == "yes" ]]; then
    OPENCLAW_ENABLE_QMD="1"
  elif [[ "$ARG_NON_INTERACTIVE" == "yes" ]]; then
    :
  elif [[ "$quick_interactive" != "yes" && -t 0 ]]; then
    OPENCLAW_ENABLE_QMD="$([[ "$(prompt_yes_no "是否启用 QMD" "$([[ "$OPENCLAW_ENABLE_QMD" == "1" ]] && printf yes || printf no)")" == "yes" ]] && printf 1 || printf 0)"
  fi
  if [[ -n "${ARG_CPU_LIMIT:-}" ]]; then
    OPENCLAW_CPU_LIMIT="$ARG_CPU_LIMIT"
  fi
  if [[ -n "${ARG_MEMORY_LIMIT:-}" ]]; then
    OPENCLAW_MEMORY_LIMIT="$ARG_MEMORY_LIMIT"
  fi

  if port_error="$(check_instance_ports "$instance_name" "$OPENCLAW_GATEWAY_PORT" "$OPENCLAW_BRIDGE_PORT" "$current_gateway_port" "$current_bridge_port")"; then
    :
  elif [[ "$quick_interactive" == "yes" && -z "${ARG_GATEWAY_PORT:-}" && -z "${ARG_BRIDGE_PORT:-}" ]]; then
    info "检测到当前默认端口不可用: $port_error"
    while true; do
      OPENCLAW_GATEWAY_PORT="$(prompt_default "Gateway 端口" "$OPENCLAW_GATEWAY_PORT")"
      OPENCLAW_BRIDGE_PORT="$(prompt_default "Bridge 端口" "$OPENCLAW_BRIDGE_PORT")"
      if port_error="$(check_instance_ports "$instance_name" "$OPENCLAW_GATEWAY_PORT" "$OPENCLAW_BRIDGE_PORT" "$current_gateway_port" "$current_bridge_port")"; then
        break
      fi
      info "端口仍不可用: $port_error"
    done
  else
    fail "$port_error"
  fi

  announce_step 4 "检查可复用镜像" "先查看是否有已构建好的镜像可直接复用，避免重复构建。"
  local commit="" reuse_image=""

  # 尝试通过 ls-remote 获取远程 commit（不需要 clone）
  local remote_commit=""
  remote_commit="$(git ls-remote "$OPENCLAW_REPO_URL" "$OPENCLAW_REPO_REF" 2>/dev/null | awk '{print $1}' | head -1)" || true

  if [[ -n "$remote_commit" ]] && docker image inspect "openclaw:${remote_commit:0:12}" >/dev/null 2>&1; then
    reuse_image="openclaw:${remote_commit:0:12}"
    commit="$remote_commit"
    info "发现已有镜像 $reuse_image (commit ${commit:0:12})，跳过源码拉取和构建。"
  else
    # 检查本地是否有其他已构建的 openclaw 镜像可复用
    local existing_images
    existing_images="$(docker images openclaw --format '{{.Tag}}  {{.CreatedSince}}' 2>/dev/null | head -5)"
    if [[ -n "$existing_images" ]] && [[ -t 0 ]]; then
      info "本地已有以下 openclaw 镜像:"
      printf '%s\n' "$existing_images" | while read -r line; do printf '    %s\n' "$line"; done
      if [[ "$(prompt_yes_no "是否复用已有镜像（跳过构建）" "yes")" == "yes" ]]; then
        local chosen_tag
        chosen_tag="$(printf '%s\n' "$existing_images" | head -1 | awk '{print $1}')"
        reuse_image="openclaw:${chosen_tag}"
        commit="${chosen_tag}"
        info "复用镜像: $reuse_image"
      fi
    fi
  fi

  if [[ -n "$reuse_image" ]]; then
    OPENCLAW_SOURCE_CACHE="$(repo_cache_path "$OPENCLAW_REPO_URL" "$OPENCLAW_REPO_REF")"
    OPENCLAW_SOURCE_COMMIT="$commit"
    OPENCLAW_IMAGE="$reuse_image"
  else
    announce_step "4b" "准备共享源码缓存" "同一个 Linux 账号下的多个实例会复用 home 目录里的源码缓存，避免重复拉代码。"
    commit="$(sync_source_cache "$OPENCLAW_REPO_URL" "$OPENCLAW_REPO_REF")"
    OPENCLAW_SOURCE_CACHE="$(repo_cache_path "$OPENCLAW_REPO_URL" "$OPENCLAW_REPO_REF")"
    OPENCLAW_SOURCE_COMMIT="$commit"
    info "当前源码提交: $commit"

    announce_step 5 "构建或复用 Docker 镜像" "如果这个提交已经构建过，就直接复用镜像，能明显提升多人环境下的部署速度。"
    OPENCLAW_IMAGE="$(build_image_for_commit "$OPENCLAW_SOURCE_CACHE" "$commit")"
  fi
  info "当前实例将使用镜像: $OPENCLAW_IMAGE"

  announce_step 6 "生成实例配置并启动服务" "这一步会生成当前实例自己的 compose、配置和数据目录，然后把服务真正启动起来。"
  with_instance_lock "$instance_name" _deploy_instance_runtime_locked "$instance_name"

  if [[ "${ARG_SKIP_MODEL_SETUP:-no}" != "yes" ]] && deploy_model_setup_requested; then
    announce_step 7 "高级模型配置" "检测到显式模型参数，继续把模型配置写入当前实例。"
    configure_model "$instance_name"
  fi

  if [[ "${ARG_SKIP_CODEX_SETUP:-no}" != "yes" ]] && deploy_codex_migrate_requested; then
    announce_step 8 "高级 Codex 配置" "检测到 Codex 迁移参数，继续把登录状态迁移到当前实例。"
    migrate_codex "$instance_name" "$ARG_FROM_CONTAINER" "$ARG_FROM_PATH"
  fi

  if [[ "${ARG_SKIP_CHANNEL_SETUP:-no}" != "yes" ]] && deploy_channel_setup_requested; then
    announce_step 9 "高级频道配置" "检测到显式频道参数，继续把 channel 配置写入当前实例。"
    configure_channel "$instance_name"
  elif [[ "${ARG_ADVANCED_SETUP:-no}" == "yes" && -t 0 && "$quick_interactive" != "yes" ]]; then
    announce_step 9 "高级配置已后置" "这次部署默认不会进入模型、Codex、频道交互。后续请按需执行下面的命令。"
  fi

  announce_step 10 "最后检查和使用说明" "最后我会检查服务状态，并告诉你这个实例接下来怎么访问。"
  local _doctor_ok=0
  for _doctor_wait in 5 10 15; do
    sleep "$_doctor_wait"
    if doctor_instance "$instance_name" 2>/dev/null; then
      _doctor_ok=1
      break
    fi
  done
  if [[ "$_doctor_ok" -eq 0 ]]; then
    info "Gateway 尚未就绪（刚启动或正在重载），可稍后运行 doctor 复查。"
  fi
  print_instance_success_summary "$instance_name"
}

_deploy_instance_runtime_locked() {
  local instance_name="$1"
  CURRENT_INSTANCE="$instance_name"
  ensure_instance_env_normalized "$instance_name"
  ensure_instance_runtime_dirs "$instance_name"
  persist_instance_state
  write_compose_file "$(instance_compose_path "$instance_name")" "$instance_name"
  compose_for_instance "$instance_name" pull --ignore-pull-failures >/dev/null 2>&1 || true
  run_onboard_if_needed "$instance_name"
  export CONTAINER_WORKSPACE_DIR CONTAINER_GATEWAY_PORT
  patch_instance_config "$instance_name"
  local compose_output
  if ! compose_output="$(compose_for_instance "$instance_name" up -d openclaw-gateway 2>&1)"; then
    host_uid_mode_failure "OpenClaw 服务启动" "$compose_output"
  fi
  # The gateway's own startup may overwrite the config (e.g. resetting bind
  # back to 127.0.0.1).  Re-patch after the container has started and force
  # a restart so the patched values (bind=lan, controlUi, etc.) take effect.
  sleep 3
  patch_instance_config "$instance_name"
  compose_for_instance "$instance_name" restart openclaw-gateway >/dev/null 2>&1 || true
}

interactive_wizard() {
  [[ -t 0 && -t 1 ]] || fail "无参模式只支持交互式 quick wizard。非 TTY 环境请改用 ./openclaw-deploy.sh deploy-instance --instance <name> [options]"
  QUICK_WIZARD="yes"
  printf '进入 quick wizard。\n'
  printf '\n'
  printf '请选择操作模式:\n'
  printf '  1) 本地部署 (在 EC2 上执行时选择此项)\n'
  printf '  2) 上传文件到 EC2 (在本地 Mac/PC 上执行时选择此项)\n'
  printf '\n'
  local mode
  read -r -p "选择 [1]: " mode
  mode="${mode:-1}"
  case "$mode" in
    1)
      local instance_name
      instance_name="$(choose_instance_interactive)"
      deploy_instance "$instance_name"
      ;;
    2)
      _interactive_upload_to_ec2
      ;;
    *)
      fail "无效选择: $mode"
      ;;
  esac
}

_interactive_upload_to_ec2() {
  require_cmd aws

  local aws_profile aws_region s3_bucket ec2_id dest_path local_file

  local_file="${BASH_SOURCE[0]}"
  [[ -f "$local_file" ]] || fail "无法找到默认文件路径: $local_file"

  printf '\n==> 上传文件到 EC2\n'
  printf '    默认文件: %s\n\n' "$local_file"

  local_file="$(prompt_default "本地文件路径" "$local_file")"
  [[ -f "$local_file" ]] || fail "本地文件不存在: $local_file"

  aws_profile="$(prompt_default "AWS Profile" "${AWS_PROFILE:-openclaw}")"
  aws_region="$(prompt_default "AWS Region" "${AWS_DEFAULT_REGION:-eu-west-1}")"
  s3_bucket="$(prompt_default "S3 中转 Bucket" "${OPENCLAW_S3_BUCKET:-}")"
  ec2_id="$(prompt_default "EC2 Instance ID" "${OPENCLAW_EC2_ID:-}")"
  dest_path="$(prompt_default "EC2 目标路径" "/home/ssm-user")"

  export AWS_PROFILE="$aws_profile"

  [[ -n "$s3_bucket" ]] || fail "S3 Bucket 不能为空"
  [[ -n "$ec2_id" ]]    || fail "EC2 Instance ID 不能为空"

  if [[ ! "$ec2_id" =~ ^i-[a-fA-F0-9]+$ ]]; then
    fail "EC2 Instance ID 格式无效: $ec2_id"
  fi
  if [[ ! "$s3_bucket" =~ ^[a-zA-Z0-9._-]+$ ]]; then
    fail "S3 Bucket 名称包含非法字符: $s3_bucket"
  fi
  if [[ ! "$dest_path" =~ ^[a-zA-Z0-9_./-]+$ ]]; then
    fail "目标路径包含非法字符: $dest_path"
  fi

  local s3_key
  s3_key="$(basename "$local_file")"
  local s3_uri="s3://${s3_bucket}/${s3_key}"

  printf '\n'
  step "上传文件到 S3"
  info "本地文件: $local_file"
  info "S3 目标: $s3_uri"
  aws s3 cp "$local_file" "$s3_uri" --region "$aws_region"
  log "上传完成。"

  step "通过 SSM 下载到 EC2: $ec2_id"
  info "EC2 目标: ${dest_path}/${s3_key}"

  local cmd_id
  cmd_id=$(aws ssm send-command \
    --region "$aws_region" \
    --instance-ids "$ec2_id" \
    --document-name "AWS-RunShellScript" \
    --parameters "commands=[\"aws s3 cp '${s3_uri}' '${dest_path}/' --region '${aws_region}' && chmod +x '${dest_path}/${s3_key}'\"]" \
    --query "Command.CommandId" \
    --output text)
  log "SSM CommandId: $cmd_id"

  step "等待 SSM 命令完成..."
  local status="InProgress"
  local waited=0
  local timeout=120

  while [[ "$status" == "InProgress" || "$status" == "Pending" ]]; do
    sleep 5
    waited=$((waited + 5))
    status=$(aws ssm get-command-invocation \
      --region "$aws_region" \
      --command-id "$cmd_id" \
      --instance-id "$ec2_id" \
      --query "Status" \
      --output text 2>/dev/null || echo "Pending")
    info "状态: $status (${waited}s)"
    if [[ $waited -ge $timeout ]]; then
      fail "SSM 命令超时 (${timeout}s, CommandId: $cmd_id)"
    fi
  done

  if [[ "$status" == "Success" ]]; then
    log "文件已成功上传到 EC2。"
    printf '\n'
    printf '==> 下一步: SSM 进入 EC2 后使用该文件\n'
    printf '    aws ssm start-session --target %s --region %s\n' "$ec2_id" "$aws_region"
    printf '    ls -l %s/%s\n\n' "$dest_path" "$s3_key"
  else
    local err_out
    err_out=$(aws ssm get-command-invocation \
      --region "$aws_region" \
      --command-id "$cmd_id" \
      --instance-id "$ec2_id" \
      --query "StandardErrorContent" \
      --output text 2>/dev/null || echo "")
    [[ -n "$err_out" ]] && log "SSM stderr: $err_out"
    if [[ "$err_out" == *"403"* || "$err_out" == *"Forbidden"* || "$err_out" == *"AccessDenied"* ]]; then
      printf '\n'
      printf '提示: EC2 实例的 IAM Role 可能缺少 S3 读取权限。请确认 Role 已附加以下策略:\n'
      printf '  - s3:GetObject on arn:aws:s3:::%s/*\n' "$s3_bucket"
      printf '  - s3:ListBucket on arn:aws:s3:::%s\n' "$s3_bucket"
      printf '\n'
    fi
    fail "SSM 命令失败: $status (CommandId: $cmd_id)"
  fi
}

restart_instance() {
  local instance_name="$1"
  load_instance_env "$instance_name"
  ensure_instance_deployed "$instance_name" "restart"
  with_instance_lock "$instance_name" _restart_instance_locked "$instance_name"
}

_restart_instance_locked() {
  local instance_name="$1"
  CURRENT_INSTANCE="$instance_name"
  ensure_instance_env_normalized "$instance_name"
  export CONTAINER_WORKSPACE_DIR CONTAINER_GATEWAY_PORT
  patch_instance_config "$instance_name"
  local compose_output
  if ! compose_output="$(compose_for_instance "$instance_name" up -d --force-recreate openclaw-gateway 2>&1)"; then
    host_uid_mode_failure "实例重启" "$compose_output"
  fi
}

logs_instance() {
  local instance_name="$1"
  load_instance_env "$instance_name"
  ensure_instance_deployed "$instance_name" "logs"
  compose_for_instance "$instance_name" logs -f openclaw-gateway
}

upload_file() {
  [[ -n "$ARG_FILE" ]]            || fail "--file is required"
  [[ -n "$ARG_S3_BUCKET" ]]       || fail "--s3-bucket is required"
  [[ -n "$ARG_EC2_INSTANCE_ID" ]] || fail "--ec2-instance-id is required"
  [[ -n "$ARG_DEST_PATH" ]]       || fail "--dest-path is required"
  [[ -f "$ARG_FILE" ]]            || fail "File not found: $ARG_FILE"

  if [[ ! "$ARG_DEST_PATH" =~ ^[a-zA-Z0-9_./-]+$ ]]; then
    fail "--dest-path contains unsafe characters: $ARG_DEST_PATH"
  fi
  if [[ ! "$ARG_S3_BUCKET" =~ ^[a-zA-Z0-9._-]+$ ]]; then
    fail "--s3-bucket contains unsafe characters: $ARG_S3_BUCKET"
  fi
  if [[ ! "$ARG_EC2_INSTANCE_ID" =~ ^i-[a-fA-F0-9]+$ ]]; then
    fail "--ec2-instance-id format invalid: $ARG_EC2_INSTANCE_ID"
  fi

  require_cmd aws

  local s3_key="${ARG_S3_KEY:-$(basename "$ARG_FILE")}"
  local s3_uri="s3://${ARG_S3_BUCKET}/${s3_key}"

  step "Uploading file to S3"
  info "Local file : $ARG_FILE"
  info "S3 target  : $s3_uri"
  aws s3 cp "$ARG_FILE" "$s3_uri" --region "$ARG_AWS_REGION"
  log "Upload complete."

  step "Sending SSM command to EC2 instance: $ARG_EC2_INSTANCE_ID"
  info "Download target: $ARG_DEST_PATH"

  local cmd_id
  cmd_id=$(aws ssm send-command \
    --region "$ARG_AWS_REGION" \
    --instance-ids "$ARG_EC2_INSTANCE_ID" \
    --document-name "AWS-RunShellScript" \
    --parameters "commands=[\"aws s3 cp '${s3_uri}' '${ARG_DEST_PATH}' --region '${ARG_AWS_REGION}'\"]" \
    --query "Command.CommandId" \
    --output text)
  log "SSM CommandId: $cmd_id"

  step "Waiting for SSM command to complete..."
  local status="InProgress"
  local waited=0
  local timeout=120

  while [[ "$status" == "InProgress" || "$status" == "Pending" ]]; do
    sleep 5
    waited=$((waited + 5))
    status=$(aws ssm get-command-invocation \
      --region "$ARG_AWS_REGION" \
      --command-id "$cmd_id" \
      --instance-id "$ARG_EC2_INSTANCE_ID" \
      --query "Status" \
      --output text 2>/dev/null || echo "Pending")
    info "Status: $status (${waited}s elapsed)"
    if [[ $waited -ge $timeout ]]; then
      fail "Timed out waiting for SSM command after ${timeout}s (CommandId: $cmd_id)"
    fi
  done

  if [[ "$status" == "Success" ]]; then
    log "File successfully downloaded to $ARG_DEST_PATH on EC2 $ARG_EC2_INSTANCE_ID"
  else
    local err_out
    err_out=$(aws ssm get-command-invocation \
      --region "$ARG_AWS_REGION" \
      --command-id "$cmd_id" \
      --instance-id "$ARG_EC2_INSTANCE_ID" \
      --query "StandardErrorContent" \
      --output text 2>/dev/null || echo "")
    [[ -n "$err_out" ]] && log "SSM stderr: $err_out"
    if [[ "$err_out" == *"403"* || "$err_out" == *"Forbidden"* || "$err_out" == *"AccessDenied"* ]]; then
      printf '\n'
      printf '提示: EC2 实例的 IAM Role 可能缺少 S3 读取权限。请确认 Role 已附加以下策略:\n'
      printf '  - s3:GetObject on arn:aws:s3:::%s/*\n' "$ARG_S3_BUCKET"
      printf '  - s3:ListBucket on arn:aws:s3:::%s\n' "$ARG_S3_BUCKET"
      printf '\n'
    fi
    fail "SSM command failed with status: $status (CommandId: $cmd_id)"
  fi
}

_webchat_detect_instance() {
  if [[ ! -d "$INSTANCES_DIR" ]]; then
    fail "No instances found. Deploy one first with: ./openclaw-deploy.sh deploy --instance <name>"
  fi
  local instances=()
  local env_file
  for env_file in "$INSTANCES_DIR"/*/.env; do
    [[ -e "$env_file" ]] || continue
    instances+=("$(basename "$(dirname "$env_file")")")
  done
  if [[ ${#instances[@]} -eq 0 ]]; then
    fail "No instances found. Deploy one first."
  elif [[ ${#instances[@]} -eq 1 ]]; then
    info "Auto-selected instance: ${instances[0]}"
    printf '%s\n' "${instances[0]}"
  else
    printf '\nAvailable instances:\n' >&2
    local idx
    for idx in "${!instances[@]}"; do
      printf '  %d. %s\n' "$((idx + 1))" "${instances[$idx]}" >&2
    done
    local choice
    while true; do
      read -r -p "Select instance [1]: " choice
      choice="${choice:-1}"
      if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#instances[@]} )); then
        printf '%s\n' "${instances[$((choice - 1))]}"
        return 0
      fi
      printf 'Invalid choice, try again.\n' >&2
    done
  fi
}

_webchat_detect_ec2_id() {
  local aws_region="$1"
  # Try EC2 instance metadata (if running on EC2)
  local instance_id
  instance_id="$(curl -sf --connect-timeout 2 http://169.254.169.254/latest/meta-data/instance-id 2>/dev/null || true)"
  if [[ "$instance_id" =~ ^i-[a-fA-F0-9]+$ ]]; then
    info "Auto-detected EC2 instance (metadata): $instance_id"
    printf '%s\n' "$instance_id"
    return 0
  fi
  # Try finding openclaw EC2 instances via AWS CLI
  local ids
  ids="$(aws ec2 describe-instances \
    --region "$aws_region" \
    --filters "Name=instance-state-name,Values=running" "Name=tag:Name,Values=*openclaw*" \
    --query "Reservations[].Instances[].InstanceId" \
    --output text 2>/dev/null || true)"
  if [[ -n "$ids" ]]; then
    local arr=($ids)
    if [[ ${#arr[@]} -eq 1 ]]; then
      info "Auto-detected EC2 instance (tag match): ${arr[0]}"
      printf '%s\n' "${arr[0]}"
      return 0
    elif [[ ${#arr[@]} -gt 1 ]]; then
      printf '\nFound multiple openclaw EC2 instances:\n' >&2
      local i
      for i in "${!arr[@]}"; do
        printf '  %d. %s\n' "$((i + 1))" "${arr[$i]}" >&2
      done
      local choice
      while true; do
        read -r -p "Select instance [1]: " choice
        choice="${choice:-1}"
        if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#arr[@]} )); then
          printf '%s\n' "${arr[$((choice - 1))]}"
          return 0
        fi
        printf 'Invalid choice, try again.\n' >&2
      done
    fi
  fi
  # Fallback: prompt user
  local input
  read -r -p "EC2 Instance ID (e.g. i-0123456789abcdef0): " input
  input="$(trim "${input:-}")"
  [[ -n "$input" ]] || fail "EC2 Instance ID is required"
  printf '%s\n' "$input"
}

_webchat_detect_region() {
  # Try AWS CLI configured region
  local region
  region="$(aws configure get region 2>/dev/null || true)"
  if [[ -n "$region" ]]; then
    printf '%s\n' "$region"
    return 0
  fi
  # Fallback to env or default
  printf '%s\n' "${AWS_DEFAULT_REGION:-us-east-1}"
}

_webchat_remote_pair() {
  local ec2_id="$1" region="$2" instance_name="$3"
  local deploy_script=""

  deploy_script="$(
    aws ssm send-command \
      --region "$region" \
      --instance-ids "$ec2_id" \
      --document-name "AWS-RunShellScript" \
      --parameters "commands=[\"for candidate in /home/ssm-user/openclaw-deploy.sh /home/ssm-user/openclaw/openclaw-deploy.sh; do if [ -f \\\"\\\$candidate\\\" ]; then printf '%s\\\\n' \\\"\\\$candidate\\\"; exit 0; fi; done; find /home/ssm-user -maxdepth 3 -type f -name openclaw-deploy.sh | head -1\"]" \
      --query "Command.CommandId" \
      --output text 2>/dev/null
  )" || fail "无法发送 SSM 脚本定位命令"

  local detect_status="InProgress" detect_waited=0
  while [[ "$detect_status" == "InProgress" || "$detect_status" == "Pending" ]]; do
    sleep 2
    detect_waited=$((detect_waited + 2))
    detect_status="$(
      aws ssm get-command-invocation \
        --region "$region" \
        --command-id "$deploy_script" \
        --instance-id "$ec2_id" \
        --query "Status" \
        --output text 2>/dev/null || echo "Pending"
    )"
    if [[ $detect_waited -ge 30 ]]; then
      fail "定位远程部署脚本超时"
    fi
  done

  [[ "$detect_status" == "Success" ]] || fail "无法定位 EC2 上的 openclaw-deploy.sh"
  deploy_script="$(
    aws ssm get-command-invocation \
      --region "$region" \
      --command-id "$deploy_script" \
      --instance-id "$ec2_id" \
      --query "StandardOutputContent" \
      --output text 2>/dev/null | awk 'NF {print; exit}'
  )"
  [[ -n "$deploy_script" ]] || fail "EC2 上未找到 openclaw-deploy.sh"

  info "先在浏览器中打开 WebChat URL，触发配对请求后按回车继续..."
  read -r -p "按回车继续配对..." _

  step "在 EC2 上执行配对..."
  local cmd_id
  cmd_id=$(aws ssm send-command \
    --region "$region" \
    --instance-ids "$ec2_id" \
    --document-name "AWS-RunShellScript" \
    --parameters "commands=[\"mkdir -p /home/ssm-user/openclaw/locks 2>/dev/null; chown -R ssm-user:ssm-user /home/ssm-user/openclaw/locks 2>/dev/null; su - ssm-user -c \\\"HOME=/home/ssm-user OPENCLAW_INSTALL_DIR=/home/ssm-user/openclaw bash '${deploy_script}' pair-device --instance '${instance_name}' --yes\\\" 2>&1\"]" \
    --query "Command.CommandId" \
    --output text) || fail "无法发送 SSM 配对命令"
  log "SSM CommandId: $cmd_id"

  local status="InProgress" waited=0
  while [[ "$status" == "InProgress" || "$status" == "Pending" ]]; do
    sleep 3
    waited=$((waited + 3))
    status=$(aws ssm get-command-invocation \
      --region "$region" \
      --command-id "$cmd_id" \
      --instance-id "$ec2_id" \
      --query "Status" \
      --output text 2>/dev/null || echo "Pending")
    if [[ $waited -ge 60 ]]; then
      fail "配对命令超时"
    fi
  done

  local output
  output="$(aws ssm get-command-invocation \
    --region "$region" \
    --command-id "$cmd_id" \
    --instance-id "$ec2_id" \
    --query "StandardOutputContent" \
    --output text 2>/dev/null || echo "")"

  if [[ "$status" == "Success" ]]; then
    log "远程配对完成。"
    [[ -n "$output" ]] && printf '%s\n' "$output"
    printf '\n请回到浏览器刷新页面。\n'
  else
    local err_out
    err_out="$(aws ssm get-command-invocation \
      --region "$region" \
      --command-id "$cmd_id" \
      --instance-id "$ec2_id" \
      --query "StandardErrorContent" \
      --output text 2>/dev/null || echo "")"
    [[ -n "$output" ]] && log "stdout: $output"
    [[ -n "$err_out" ]] && log "stderr: $err_out"
    info "远程配对失败。你也可以 SSM 进入 EC2 手动执行:"
    printf '  aws ssm start-session --target %s --region %s\n' "$ec2_id" "$region"
    printf '  bash %s pair-device --instance %s\n' "$deploy_script" "$instance_name"
  fi
}

webchat() {
  local instance_name="${1:-}"
  local ec2_instance_id="${2:-}"
  local aws_region="${3:-}"
  local local_port="${4:-18789}"

  require_cmd aws

  # Ensure AWS credentials are available
  if [[ -z "${AWS_PROFILE:-}" ]]; then
    if [[ -t 0 ]]; then
      local aws_profile
      aws_profile="$(prompt_default "AWS Profile" "openclaw")"
      export AWS_PROFILE="$aws_profile"
    fi
  fi

  # --- Interactive auto-detection ---
  if [[ -z "$aws_region" ]] || [[ "$aws_region" == "us-east-1" && -z "${AWS_DEFAULT_REGION:-}" ]]; then
    aws_region="$(_webchat_detect_region)"
    info "AWS region: $aws_region"
  fi

  if [[ -z "$instance_name" ]]; then
    instance_name="$(_webchat_detect_instance)"
  fi

  if [[ -z "$ec2_instance_id" ]]; then
    ec2_instance_id="$(_webchat_detect_ec2_id "$aws_region")"
  fi

  if [[ ! "$ec2_instance_id" =~ ^i-[a-fA-F0-9]+$ ]]; then
    fail "EC2 Instance ID format invalid: $ec2_instance_id"
  fi

  local remote_port="" token=""
  local env_path
  env_path="$(instance_env_path "$instance_name")"

  if [[ -f "$env_path" ]]; then
    load_instance_env "$instance_name"
    remote_port="$OPENCLAW_GATEWAY_PORT"
    token="$OPENCLAW_GATEWAY_TOKEN"
  else
    info "本地未找到实例 $instance_name 的配置，从 EC2 远程获取..."
    local remote_env_path="/home/ssm-user/openclaw/instances/${instance_name}/.env"
    local remote_env
    remote_env="$(aws ssm send-command \
      --region "$aws_region" \
      --instance-ids "$ec2_instance_id" \
      --document-name "AWS-RunShellScript" \
      --parameters "commands=[\"cat '${remote_env_path}'\"]" \
      --query "Command.CommandId" \
      --output text)" || fail "无法发送 SSM 命令"

    local cmd_status="InProgress"
    local waited=0
    while [[ "$cmd_status" == "InProgress" || "$cmd_status" == "Pending" ]]; do
      sleep 3
      waited=$((waited + 3))
      cmd_status=$(aws ssm get-command-invocation \
        --region "$aws_region" \
        --command-id "$remote_env" \
        --instance-id "$ec2_instance_id" \
        --query "Status" \
        --output text 2>/dev/null || echo "Pending")
      if [[ $waited -ge 30 ]]; then
        fail "从 EC2 获取实例配置超时"
      fi
    done

    if [[ "$cmd_status" != "Success" ]]; then
      fail "无法从 EC2 读取实例配置。请确认实例 $instance_name 已在 EC2 上部署。"
    fi

    local env_content
    env_content="$(aws ssm get-command-invocation \
      --region "$aws_region" \
      --command-id "$remote_env" \
      --instance-id "$ec2_instance_id" \
      --query "StandardOutputContent" \
      --output text)"

    remote_port="$(printf '%s\n' "$env_content" | grep '^OPENCLAW_GATEWAY_PORT=' | head -1 | sed "s/^OPENCLAW_GATEWAY_PORT=//; s/^'//; s/'$//")"
    token="$(printf '%s\n' "$env_content" | grep '^OPENCLAW_GATEWAY_TOKEN=' | head -1 | sed "s/^OPENCLAW_GATEWAY_TOKEN=//; s/^'//; s/'$//")"

    [[ -n "$remote_port" ]] || fail "无法从 EC2 获取 OPENCLAW_GATEWAY_PORT"
    [[ -n "$token" ]]       || fail "无法从 EC2 获取 OPENCLAW_GATEWAY_TOKEN"
    log "已从 EC2 获取: port=$remote_port"
  fi

  if port_listening "$local_port"; then
    local blocking_pid
    blocking_pid="$(lsof -ti:"$local_port" 2>/dev/null | head -1)"
    if [[ -n "$blocking_pid" ]] && ps -p "$blocking_pid" -o command= 2>/dev/null | grep -q "session-manager-plugin"; then
      info "端口 $local_port 被旧的 SSM 端口转发占用 (PID $blocking_pid)"
      if [[ -t 0 ]] && [[ "$(prompt_yes_no "是否终止旧进程并继续" "yes")" == "yes" ]]; then
        kill "$blocking_pid" 2>/dev/null
        sleep 1
      else
        fail "Local port $local_port is already in use. Try --local-port <other-port>"
      fi
    else
      fail "Local port $local_port is already in use. Try --local-port <other-port>"
    fi
    if port_listening "$local_port"; then
      fail "Local port $local_port is still in use after cleanup. Try --local-port <other-port>"
    fi
  fi

  step "Starting SSM port-forward: localhost:${local_port} -> EC2 ${ec2_instance_id}:${remote_port}"

  local ssm_pid=""
  cleanup_ssm() {
    if [[ -n "$ssm_pid" ]] && kill -0 "$ssm_pid" 2>/dev/null; then
      info "Stopping SSM port-forward (PID $ssm_pid)..."
      kill "$ssm_pid" 2>/dev/null || true
      wait "$ssm_pid" 2>/dev/null || true
    fi
  }
  trap cleanup_ssm EXIT INT TERM

  aws ssm start-session \
    --target "$ec2_instance_id" \
    --region "$aws_region" \
    --document-name AWS-StartPortForwardingSession \
    --parameters "{\"portNumber\":[\"${remote_port}\"],\"localPortNumber\":[\"${local_port}\"]}" &
  ssm_pid=$!

  local deadline=$((SECONDS + 30))
  while ! port_listening "$local_port"; do
    if ! kill -0 "$ssm_pid" 2>/dev/null; then
      fail "SSM port-forward process exited unexpectedly. Check your AWS credentials and EC2 instance ID."
    fi
    if (( SECONDS >= deadline )); then
      fail "Timed out waiting for local port ${local_port} to become ready."
    fi
    sleep 1
  done

  local url="http://127.0.0.1:${local_port}/#token=${token}"
  printf '\n'
  info "Port-forward is ready."
  printf '  WebChat URL: %s\n' "$url"
  printf '\n'
  printf '  Press Ctrl+C to stop the port-forward.\n'
  printf '\n'

  if [[ -t 0 ]]; then
    if [[ "$(prompt_yes_no "Pair a device now?" "no")" == "yes" ]]; then
      if [[ -f "$env_path" ]]; then
        pair_device "$instance_name"
      else
        info "设备配对需要在 EC2 上执行，正在通过 SSM 远程配对..."
        _webchat_remote_pair "$ec2_instance_id" "$aws_region" "$instance_name"
      fi
    fi
  fi

  info "Port-forward is active. Press Ctrl+C to stop."
  wait "$ssm_pid" 2>/dev/null || true
}

parse_args() {
  COMMAND="${1:-}"
  shift || true

  ARG_INSTANCE=""
  ARG_REPO_URL=""
  ARG_REPO_REF=""
  ARG_GATEWAY_PORT=""
  ARG_BRIDGE_PORT=""
  ARG_BIND=""
  ARG_TZ=""
  ARG_ENABLE_QMD="no"
  ARG_CPU_LIMIT=""
  ARG_MEMORY_LIMIT=""
  ARG_PROVIDER=""
  ARG_PRIMARY_MODEL=""
  ARG_FALLBACKS=""
  ARG_ALLOWLIST=""
  ARG_IMAGE_MODEL=""
  ARG_API_KEY=""
  ARG_BASE_URL=""
  ARG_PROVIDER_ID=""
  ARG_PROVIDER_NAME=""
  ARG_API_TYPE=""
  ARG_CHANNEL=""
  ARG_CHANNEL_TOKEN=""
  ARG_DM_POLICY="pairing"
  ARG_ALLOW_FROM=""
  ARG_DISABLE="no"
  ARG_REVERT="no"
  ARG_FROM_CONTAINER=""
  ARG_FROM_PATH=""
  ARG_PURGE_DATA="no"
  ARG_YES="no"
  ARG_NON_INTERACTIVE="no"
  ARG_ADVANCED_SETUP="no"
  ARG_TOKEN=""
  ARG_SKIP_MODEL_SETUP="no"
  ARG_SKIP_CODEX_SETUP="no"
  ARG_SKIP_CHANNEL_SETUP="no"
  ARG_FILE=""
  ARG_S3_BUCKET=""
  ARG_S3_KEY=""
  ARG_EC2_INSTANCE_ID=""
  ARG_DEST_PATH=""
  ARG_AWS_REGION="${AWS_DEFAULT_REGION:-us-east-1}"
  ARG_LOCAL_PORT="18789"
  MODEL_ALIASES=()
  GATEWAY_ALLOWED_ORIGIN_ARGS=()

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --instance) ARG_INSTANCE="$2"; shift 2 ;;
      --repo-url) ARG_REPO_URL="$2"; shift 2 ;;
      --repo-ref) ARG_REPO_REF="$2"; shift 2 ;;
      --gateway-port) ARG_GATEWAY_PORT="$2"; shift 2 ;;
      --bridge-port) ARG_BRIDGE_PORT="$2"; shift 2 ;;
      --bind) ARG_BIND="$2"; shift 2 ;;
      --tz) ARG_TZ="$2"; shift 2 ;;
      --enable-qmd) ARG_ENABLE_QMD="yes"; shift ;;
      --cpu) ARG_CPU_LIMIT="$2"; shift 2 ;;
      --memory) ARG_MEMORY_LIMIT="$2"; shift 2 ;;
      --provider) ARG_PROVIDER="$2"; shift 2 ;;
      --primary-model) ARG_PRIMARY_MODEL="$2"; shift 2 ;;
      --fallbacks) ARG_FALLBACKS="$2"; shift 2 ;;
      --allowlist) ARG_ALLOWLIST="$2"; shift 2 ;;
      --image-model) ARG_IMAGE_MODEL="$2"; shift 2 ;;
      --api-key) ARG_API_KEY="$2"; shift 2 ;;
      --base-url) ARG_BASE_URL="$2"; shift 2 ;;
      --provider-id) ARG_PROVIDER_ID="$2"; shift 2 ;;
      --provider-name) ARG_PROVIDER_NAME="$2"; shift 2 ;;
      --api-type) ARG_API_TYPE="$2"; shift 2 ;;
      --alias) MODEL_ALIASES+=("$2"); shift 2 ;;
      --channel) ARG_CHANNEL="$2"; shift 2 ;;
      --token) ARG_CHANNEL_TOKEN="$2"; shift 2 ;;
      --dm-policy) ARG_DM_POLICY="$2"; shift 2 ;;
      --allow-from) ARG_ALLOW_FROM="$2"; shift 2 ;;
      --allow-origin) GATEWAY_ALLOWED_ORIGIN_ARGS+=("$(trim "$2")"); shift 2 ;;
      --disable) ARG_DISABLE="yes"; shift ;;
      --revert) ARG_REVERT="yes"; shift ;;
      --from-container) ARG_FROM_CONTAINER="$2"; shift 2 ;;
      --from-path) ARG_FROM_PATH="$2"; shift 2 ;;
      --purge-data) ARG_PURGE_DATA="yes"; shift ;;
      --yes) ARG_YES="yes"; shift ;;
      --non-interactive) ARG_NON_INTERACTIVE="yes"; shift ;;
      --advanced-setup) ARG_ADVANCED_SETUP="yes"; shift ;;
      --gateway-token) ARG_TOKEN="$2"; shift 2 ;;
      --skip-model-setup) ARG_SKIP_MODEL_SETUP="yes"; shift ;;
      --skip-codex-setup) ARG_SKIP_CODEX_SETUP="yes"; shift ;;
      --skip-channel-setup) ARG_SKIP_CHANNEL_SETUP="yes"; shift ;;
      --file) ARG_FILE="$2"; shift 2 ;;
      --s3-bucket) ARG_S3_BUCKET="$2"; shift 2 ;;
      --s3-key) ARG_S3_KEY="$2"; shift 2 ;;
      --ec2-instance-id) ARG_EC2_INSTANCE_ID="$2"; shift 2 ;;
      --dest-path) ARG_DEST_PATH="$2"; shift 2 ;;
      --aws-region) ARG_AWS_REGION="$2"; shift 2 ;;
      --local-port) ARG_LOCAL_PORT="$2"; shift 2 ;;
      -h|--help) usage; exit 0 ;;
      *) fail "Unknown argument: $1" ;;
    esac
  done

  if [[ -n "$ARG_INSTANCE" ]]; then
    ARG_INSTANCE="$(sanitize_instance_name "$ARG_INSTANCE")"
  fi
}

main() {
  ORIGINAL_ARGS=("$@")
  parse_args "$@"
  case "$COMMAND" in
    "")
      interactive_wizard
      ;;
    install-host)
      install_host
      ;;
    create-instance)
      [[ -n "$ARG_INSTANCE" ]] || fail "--instance is required"
      ensure_instance_storage_ready
      prepare_instance_defaults "$ARG_INSTANCE"
      printf '实例骨架已创建: %s\n' "$ARG_INSTANCE"
      ;;
    deploy|deploy-instance)
      [[ -n "$ARG_INSTANCE" ]] || fail "--instance is required"
      deploy_instance "$ARG_INSTANCE"
      ;;
    configure-model)
      [[ -n "$ARG_INSTANCE" ]] || fail "--instance is required"
      configure_model "$ARG_INSTANCE"
      ;;
    configure-gateway)
      [[ -n "$ARG_INSTANCE" ]] || fail "--instance is required"
      configure_gateway "$ARG_INSTANCE"
      ;;
    configure-channel)
      [[ -n "$ARG_INSTANCE" ]] || fail "--instance is required"
      configure_channel "$ARG_INSTANCE"
      ;;
    login-codex)
      [[ -n "$ARG_INSTANCE" ]] || fail "--instance is required"
      login_codex "$ARG_INSTANCE"
      ;;
    pair-device)
      [[ -n "$ARG_INSTANCE" ]] || fail "--instance is required"
      pair_device "$ARG_INSTANCE"
      ;;
    repair-control-ui)
      [[ -n "$ARG_INSTANCE" ]] || fail "--instance is required"
      repair_control_ui "$ARG_INSTANCE"
      ;;
    migrate-codex)
      [[ -n "$ARG_INSTANCE" ]] || fail "--instance is required"
      migrate_codex "$ARG_INSTANCE" "$ARG_FROM_CONTAINER" "$ARG_FROM_PATH"
      ;;
    doctor)
      [[ -n "$ARG_INSTANCE" ]] || fail "--instance is required"
      doctor_instance "$ARG_INSTANCE"
      ;;
    status)
      if [[ -n "$ARG_INSTANCE" ]]; then
        status_instance "$ARG_INSTANCE"
      else
        list_instances
      fi
      ;;
    logs)
      [[ -n "$ARG_INSTANCE" ]] || fail "--instance is required"
      logs_instance "$ARG_INSTANCE"
      ;;
    restart)
      [[ -n "$ARG_INSTANCE" ]] || fail "--instance is required"
      restart_instance "$ARG_INSTANCE"
      ;;
    remove)
      [[ -n "$ARG_INSTANCE" ]] || fail "--instance is required"
      remove_instance "$ARG_INSTANCE" "$ARG_PURGE_DATA" "$ARG_YES"
      ;;
    list-instances)
      list_instances
      ;;
    upload-file)
      upload_file
      ;;
    webchat)
      webchat "$ARG_INSTANCE" "$ARG_EC2_INSTANCE_ID" "$ARG_AWS_REGION" "$ARG_LOCAL_PORT"
      ;;
    help|-h|--help)
      usage
      ;;
    *)
      fail "Unknown command: $COMMAND"
      ;;
  esac
}

main "$@"
