#!/usr/bin/env bash set -euo pipefail if [[ "${AGENTS_SERVER_INSTANCE:-default}" != "default" ]]; then echo "Direct deployment does not support named instances. Use instances.sh update NAME or a signed in-app update." >&2 exit 2 fi SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REMOTE_HOST="${AGENTSDOCK_REMOTE_HOST:-${ZENITHDOCK_REMOTE_HOST:-${1:-}}}" REMOTE_APP_DIR="${AGENTSDOCK_REMOTE_APP_DIR:-${ZENITHDOCK_REMOTE_APP_DIR:-.local/share/agents-server/current}}" REMOTE_SERVER_PATH="$REMOTE_APP_DIR/agent_server.py" REMOTE_SERVER_DIR="$(dirname "$REMOTE_SERVER_PATH")" REMOTE_PYTHON="$REMOTE_APP_DIR/.venv/bin/python" SERVICE_NAME="${AGENTSDOCK_SERVER_SERVICE:-${ZENITHDOCK_AGENT_SERVICE:-agents-server.service}}" HEALTH_ATTEMPTS="${AGENTSDOCK_HEALTH_ATTEMPTS:-${ZENITHDOCK_HEALTH_ATTEMPTS:-45}}" HEALTH_TOKEN="${AGENTSDOCK_AGENT_TOKEN:-${ZENITHDOCK_AGENT_TOKEN:-}}" RUNTIME_FILES=( "$SCRIPT_DIR/server_instances.py" "$SCRIPT_DIR/agent_server.py" "$SCRIPT_DIR/team_hub_host.py" "$SCRIPT_DIR/secure_peer_runtime.py" "$SCRIPT_DIR/team_mail_runtime.py" "$SCRIPT_DIR/team_mail_websocket.py" "$SCRIPT_DIR/team_mail_grants.py" "$SCRIPT_DIR/secure_peer_delivery.py" "$SCRIPT_DIR/agentsdock_jobs.py" "$SCRIPT_DIR/agentsdock_chats.py" "$SCRIPT_DIR/chat_mailbox.py" "$SCRIPT_DIR/agentsdock_emergency.py" "$SCRIPT_DIR/agentsdock_publish.py" "$SCRIPT_DIR/agentsdock_mail.py" "$SCRIPT_DIR/agentsdock_team.py" "$SCRIPT_DIR/provider_commands.py" "$SCRIPT_DIR/claude_sdk_client.py" "$SCRIPT_DIR/codex_app_server.py" "$SCRIPT_DIR/codex_auth.py" "$SCRIPT_DIR/codex_provider.py" "$SCRIPT_DIR/side_questions.py" "$SCRIPT_DIR/workspace_git.py" "$SCRIPT_DIR/codex_side_question.py" "$SCRIPT_DIR/claude_side_question.py" "$SCRIPT_DIR/cursor_agent_client.py" "$SCRIPT_DIR/opencode_agent_client.py" "$SCRIPT_DIR/cursor_process_guard.py" "$SCRIPT_DIR/claude_history_repair.py" "$SCRIPT_DIR/codex_history_repair.py" "$SCRIPT_DIR/claude_history_provenance.py" "$SCRIPT_DIR/claude_background_reconciliation.py" "$SCRIPT_DIR/public_chat_shares.py" "$SCRIPT_DIR/public_chat_transcript.py" "$SCRIPT_DIR/public_chat_share_routes.py" "$SCRIPT_DIR/interactive_chat_shares.py" "$SCRIPT_DIR/interactive_chat_share_routes.py" "$SCRIPT_DIR/interactive_chat_share_web.py" "$SCRIPT_DIR/interactive_chat_projection.py" "$SCRIPT_DIR/interactive_chat_runtime.py" "$SCRIPT_DIR/interactive_chat_native.py" "$SCRIPT_DIR/shared_chat_videos.py" "$SCRIPT_DIR/shared_chat_video_stream.py" "$SCRIPT_DIR/interactive_chat_controls.py" "$SCRIPT_DIR/update_runner.py" "$SCRIPT_DIR/release-public-key.pem" "$SCRIPT_DIR/VERSION" "$SCRIPT_DIR/LICENSE" "$SCRIPT_DIR/NOTICE" ) if [[ -z "$REMOTE_HOST" ]]; then cat >&2 <<'USAGE' Usage: AGENTSDOCK_REMOTE_HOST= ./deploy.sh ./deploy.sh Optional: AGENTSDOCK_REMOTE_APP_DIR= AGENTSDOCK_SERVER_SERVICE= AGENTSDOCK_AGENT_TOKEN= AGENTSDOCK_HEALTH_ATTEMPTS= Direct in-place deployment is refused when the target hosts Team Hub or has a paired Teamspace client. Use the signed managed update so its versioned rollback and Teamspace continuity checks remain in force. USAGE exit 2 fi if [[ -n "$HEALTH_TOKEN" ]] && [[ ! "$HEALTH_TOKEN" =~ ^[A-Za-z0-9_-]{32,}$ ]]; then echo "AGENTSDOCK_AGENT_TOKEN is invalid." >&2 exit 2 fi if [[ -z "$HEALTH_TOKEN" ]]; then echo "AGENTSDOCK_AGENT_TOKEN is required to prove exact post-deploy version and preserve Teamspace state." >&2 echo " Use the signed managed update if authenticated in-place health is unavailable." >&2 exit 2 fi REMOTE_HAS_HUB_RUNTIME="$( ssh "$REMOTE_HOST" \ "if [[ -f '$REMOTE_SERVER_DIR/team_hub_host.py' || -d '$REMOTE_SERVER_DIR/agentsdock_team_hub' ]]; then printf present; else printf absent; fi" )" if [[ "$REMOTE_HAS_HUB_RUNTIME" != "present" && "$REMOTE_HAS_HUB_RUNTIME" != "absent" ]]; then echo "Could not determine whether the target already contains Team Hub runtime files." >&2 exit 1 fi TARGET_HUB_STATE="unknown" EXPECTED_SERVER_IDENTITY="" if [[ -n "$HEALTH_TOKEN" ]]; then if TARGET_HEALTH="$( ssh "$REMOTE_HOST" \ "curl -fsS --connect-timeout 2 --max-time 5 --max-filesize 1048576 -H 'Authorization: Bearer ${HEALTH_TOKEN}' http://127.0.0.1:7850/api/health" )"; then if ((${#TARGET_HEALTH} > 1048576)); then echo "Target health response exceeds its safety limit." >&2 exit 1 fi if ! TARGET_HUB_RESULT="$( printf '%s' "$TARGET_HEALTH" | python3 -c ' import json import re import sys def valid_identifier(value): return ( isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_.:-]{8,240}", value) is not None ) def secure_peer_available(capabilities): capability = capabilities.get("secure_peer_v1") required = { "available": True, "state_available": True, "state_error_code": None, "required": False, "version": 1, "control_path": "/api/admin/secure-peers/v1/status", "proxy_prefix": "/api/team-hub-secure", } return isinstance(capability, dict) and all( capability.get(key) == expected for key, expected in required.items() ) try: health = json.load(sys.stdin) except (UnicodeError, json.JSONDecodeError): raise SystemExit(2) if not isinstance(health, dict) or health.get("ok") is not True: raise SystemExit(2) server_identity = health.get("server_identity") if not valid_identifier(server_identity): raise SystemExit(2) capabilities = health.get("capabilities") if not isinstance(capabilities, dict) or "team_hub_v1" not in capabilities: print(f"legacy\t{server_identity}") raise SystemExit(0) capability = capabilities.get("team_hub_v1") if not isinstance(capability, dict): raise SystemExit(2) if capability.get("designated_host") is True: print(f"host\t{server_identity}") elif not secure_peer_available(capabilities): raise SystemExit(2) elif ( capability.get("designated_host") is False and capability.get("available") is False and capability.get("version") == 1 and capability.get("base_path") is None and capability.get("hub_id") is None and capability.get("host_server_identity") is None ): print(f"disabled\t{server_identity}") elif ( capability.get("designated_host") is False and capability.get("available") is True and capability.get("version") == 1 and capability.get("transport") == "secure_peer" and capability.get("hub_url") is None and secure_peer_available(capabilities) ): connection_id = capability.get("connection_id") hub_id = capability.get("hub_id") host_server_identity = capability.get("host_server_identity") if not all(valid_identifier(value) for value in ( connection_id, hub_id, host_server_identity, )): raise SystemExit(2) base_path = f"/api/team-hub-secure/{connection_id}" route = { "transport": "secure_peer", "hub_url": None, "base_path": base_path, "connection_id": connection_id, "host_server_identity": host_server_identity, "hub_id": hub_id, } if capability.get("base_path") != base_path or capability.get("routes") != [route]: raise SystemExit(2) print(f"client\t{server_identity}") else: raise SystemExit(2) ' )"; then echo "Target returned a malformed or quarantined Teamspace capability; refusing in-place deployment." >&2 exit 1 fi TARGET_HUB_STATE="${TARGET_HUB_RESULT%%$'\t'*}" EXPECTED_SERVER_IDENTITY="${TARGET_HUB_RESULT#*$'\t'}" elif [[ "$REMOTE_HAS_HUB_RUNTIME" == "absent" ]]; then TARGET_HUB_STATE="legacy" fi elif [[ "$REMOTE_HAS_HUB_RUNTIME" == "absent" ]]; then TARGET_HUB_STATE="legacy" fi if [[ "$TARGET_HUB_STATE" == "host" ]]; then echo "Target is the designated Team Hub host; use the signed managed update instead of deploy.sh." >&2 exit 1 fi if [[ "$TARGET_HUB_STATE" == "legacy" ]]; then echo "Target does not report the exact disabled Team Hub capability; refusing in-place deployment." >&2 echo " Use the signed installer/update so existing configuration and state cannot be migrated without rollback." >&2 exit 1 fi if [[ "$TARGET_HUB_STATE" == "unknown" ]]; then echo "Could not prove that the existing Team Hub runtime is disabled; set AGENTSDOCK_AGENT_TOKEN or use the signed managed update." >&2 exit 1 fi if [[ ( "$TARGET_HUB_STATE" == "disabled" || "$TARGET_HUB_STATE" == "client" ) && -z "$EXPECTED_SERVER_IDENTITY" ]]; then echo "Target did not provide a stable server identity; refusing in-place deployment." >&2 exit 1 fi # team_hub_v1 is intentionally a live route capability: an offline paired # client can temporarily look disabled there. Bind the deployment decision to # durable secure-peer control state before touching the active release. if ! TARGET_SECURE_PEER_STATUS="$( ssh "$REMOTE_HOST" \ "curl -fsS --connect-timeout 2 --max-time 5 --max-filesize 1048576 -H 'X-AgentsDock-Token: ${HEALTH_TOKEN}' http://127.0.0.1:7850/api/admin/secure-peers/v1/status" )"; then echo "Could not verify durable secure-peer pairing state; use the signed managed update." >&2 exit 1 fi if ((${#TARGET_SECURE_PEER_STATUS} > 1048576)); then echo "Target secure-peer status response exceeds its safety limit." >&2 exit 1 fi if ! TARGET_PEER_STATE="$( printf '%s' "$TARGET_SECURE_PEER_STATUS" | python3 -c ' import json import re import sys def valid_identifier(value): return ( isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_.:-]{8,240}", value) is not None ) expected_server_identity = sys.argv[1] try: status = json.load(sys.stdin) except (UnicodeError, json.JSONDecodeError): raise SystemExit(2) if ( not isinstance(status, dict) or status.get("version") not in {1, 2} or "active_connection_id" not in status ): raise SystemExit(2) if status.get("server_identity") != expected_server_identity: raise SystemExit(2) status_version = status["version"] active_connection_id = status.get("active_connection_id") if active_connection_id is not None and not valid_identifier(active_connection_id): raise SystemExit(2) pairings = status.get("pairings") if not isinstance(pairings, list): raise SystemExit(2) paired_client = active_connection_id is not None active_connection_seen = active_connection_id is None known_statuses = { "requesting", "pending_approval", "approved", "connected", "rejected", "revoked", "expired", "error", } trust_statuses = { "pending": {"requesting", "pending_approval"}, "approved": {"approved", "connected"}, "rejected": {"rejected"}, "cancelled": {"rejected"}, "revoked": {"revoked"}, "expired": {"expired"}, "error": {"error"}, } known_transport_states = {"online", "reconnecting", "offline", "disconnected", "revoked"} for pairing in pairings: if not isinstance(pairing, dict): raise SystemExit(2) direction = pairing.get("direction") if direction not in {"incoming", "outgoing"}: raise SystemExit(2) if direction != "outgoing": continue connection_id = pairing.get("connection_id") pairing_status = pairing.get("status") if not valid_identifier(connection_id) or pairing_status not in known_statuses: raise SystemExit(2) if connection_id == active_connection_id: active_connection_seen = True paired_client = True if status_version == 1: actionable = pairing_status in { "requesting", "pending_approval", "approved", "connected", } else: trust_state = pairing.get("trust_state") transport_state = pairing.get("transport_state") if ( trust_state not in trust_statuses or pairing_status not in trust_statuses[trust_state] or transport_state not in known_transport_states ): raise SystemExit(2) actionable = trust_state in {"pending", "approved"} # Pending or approved outgoing relationships can change while the process # is down. Only the managed updater can preserve or reject them under an # exact snapshot and rollback transaction. if actionable: paired_client = True if not active_connection_seen: raise SystemExit(2) print("client" if paired_client else "unpaired") ' "$EXPECTED_SERVER_IDENTITY" )"; then echo "Target returned malformed or quarantined secure-peer status; refusing in-place deployment." >&2 exit 1 fi if [[ "$TARGET_HUB_STATE" == "client" || "$TARGET_PEER_STATE" == "client" ]]; then echo "Target is a paired secure-peer Teamspace client; use the signed managed update instead of deploy.sh." >&2 exit 1 fi echo "Deploying AgentsServer runtime to $REMOTE_HOST:$REMOTE_SERVER_DIR" ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_SERVER_DIR'" scp "${RUNTIME_FILES[@]}" "$REMOTE_HOST:$REMOTE_SERVER_DIR/" rsync -a --delete --delete-excluded \ --exclude '__pycache__/' \ --exclude '*.pyc' \ --exclude '*.pyo' \ "$SCRIPT_DIR/agentsdock_team_hub/" \ "$REMOTE_HOST:$REMOTE_SERVER_DIR/agentsdock_team_hub/" echo "Checking server runtime dependencies" ssh "$REMOTE_HOST" " if ! '$REMOTE_PYTHON' -c 'from importlib.metadata import version; import claude_agent_sdk, croniter, cryptography, dateutil, tzdata; sdk_version = version(\"claude-agent-sdk\"); raise SystemExit(0 if sdk_version == \"0.2.130\" else f\"expected claude-agent-sdk 0.2.130, got {sdk_version}\")' >/dev/null 2>&1; then if [[ -x \"\$HOME/.local/bin/uv\" ]]; then \"\$HOME/.local/bin/uv\" pip install --python '$REMOTE_PYTHON' \ --no-binary claude-agent-sdk \ 'claude-agent-sdk==0.2.130' 'croniter>=6,<7' 'cryptography>=44,<47' 'python-dateutil>=2.9,<3' 'tzdata>=2025.2' else '$REMOTE_PYTHON' -m pip install \ --no-binary claude-agent-sdk \ 'claude-agent-sdk==0.2.130' 'croniter>=6,<7' 'cryptography>=44,<47' 'python-dateutil>=2.9,<3' 'tzdata>=2025.2' fi fi PYTHONPATH='$REMOTE_SERVER_DIR' '$REMOTE_PYTHON' -c 'from importlib.metadata import version; import workspace_git, codex_auth, codex_provider, side_questions, codex_side_question, claude_side_question, agentsdock_team_hub, claude_agent_sdk, cursor_agent_client, cursor_process_guard, secure_peer_delivery, secure_peer_runtime, team_hub_host, agentsdock_mail, agentsdock_team, provider_commands, opencode_agent_client; from agentsdock_team_hub import secure_peer, secure_peer_hub; sdk_version = version(\"claude-agent-sdk\"); raise SystemExit(0 if sdk_version == \"0.2.130\" else f\"expected claude-agent-sdk 0.2.130, got {sdk_version}\")' " echo "Compiling server on $REMOTE_HOST" ssh "$REMOTE_HOST" "chmod 755 '$REMOTE_SERVER_DIR/agentsdock_jobs.py' '$REMOTE_SERVER_DIR/agentsdock_chats.py' '$REMOTE_SERVER_DIR/agentsdock_emergency.py' '$REMOTE_SERVER_DIR/agentsdock_publish.py' '$REMOTE_SERVER_DIR/agentsdock_mail.py' '$REMOTE_SERVER_DIR/agentsdock_team.py' && '$REMOTE_PYTHON' -m compileall -q '$REMOTE_SERVER_DIR/agentsdock_team_hub' && '$REMOTE_PYTHON' -m py_compile '$REMOTE_SERVER_PATH' '$REMOTE_SERVER_DIR/workspace_git.py' '$REMOTE_SERVER_DIR/team_hub_host.py' '$REMOTE_SERVER_DIR/secure_peer_runtime.py' '$REMOTE_SERVER_DIR/team_mail_runtime.py' '$REMOTE_SERVER_DIR/team_mail_websocket.py' '$REMOTE_SERVER_DIR/team_mail_grants.py' '$REMOTE_SERVER_DIR/secure_peer_delivery.py' '$REMOTE_SERVER_DIR/agentsdock_jobs.py' '$REMOTE_SERVER_DIR/agentsdock_chats.py' '$REMOTE_SERVER_DIR/chat_mailbox.py' '$REMOTE_SERVER_DIR/provider_commands.py' '$REMOTE_SERVER_DIR/agentsdock_emergency.py' '$REMOTE_SERVER_DIR/agentsdock_publish.py' '$REMOTE_SERVER_DIR/agentsdock_mail.py' '$REMOTE_SERVER_DIR/agentsdock_team.py' '$REMOTE_SERVER_DIR/claude_sdk_client.py' '$REMOTE_SERVER_DIR/claude_background_reconciliation.py' '$REMOTE_SERVER_DIR/codex_app_server.py' '$REMOTE_SERVER_DIR/codex_auth.py' '$REMOTE_SERVER_DIR/codex_provider.py' '$REMOTE_SERVER_DIR/side_questions.py' '$REMOTE_SERVER_DIR/codex_side_question.py' '$REMOTE_SERVER_DIR/claude_side_question.py' '$REMOTE_SERVER_DIR/cursor_agent_client.py' '$REMOTE_SERVER_DIR/opencode_agent_client.py' '$REMOTE_SERVER_DIR/cursor_process_guard.py' '$REMOTE_SERVER_DIR/claude_history_repair.py' '$REMOTE_SERVER_DIR/claude_history_provenance.py' '$REMOTE_SERVER_DIR/codex_history_repair.py' '$REMOTE_SERVER_DIR/public_chat_shares.py' '$REMOTE_SERVER_DIR/public_chat_transcript.py' '$REMOTE_SERVER_DIR/public_chat_share_routes.py' '$REMOTE_SERVER_DIR/interactive_chat_shares.py' '$REMOTE_SERVER_DIR/interactive_chat_share_routes.py' '$REMOTE_SERVER_DIR/interactive_chat_share_web.py' '$REMOTE_SERVER_DIR/interactive_chat_projection.py' '$REMOTE_SERVER_DIR/interactive_chat_runtime.py' '$REMOTE_SERVER_DIR/interactive_chat_native.py' '$REMOTE_SERVER_DIR/shared_chat_videos.py' '$REMOTE_SERVER_DIR/shared_chat_video_stream.py' '$REMOTE_SERVER_DIR/interactive_chat_controls.py' '$REMOTE_SERVER_DIR/update_runner.py' '$REMOTE_SERVER_DIR/server_instances.py'" echo "Restarting $SERVICE_NAME" ssh "$REMOTE_HOST" "systemctl --user restart '$SERVICE_NAME'" echo "Checking health" HEALTH_OK=0 EXPECTED_VERSION="$(tr -d '[:space:]' < "$SCRIPT_DIR/VERSION")" for ((attempt = 1; attempt <= HEALTH_ATTEMPTS; attempt++)); do if REMOTE_HEALTH="$( ssh "$REMOTE_HOST" \ "curl -fsS --connect-timeout 2 --max-time 5 --max-filesize 1048576 -H 'Authorization: Bearer ${HEALTH_TOKEN}' http://127.0.0.1:7850/api/health" )"; then if ((${#REMOTE_HEALTH} <= 1048576)) && \ printf '%s' "$REMOTE_HEALTH" | python3 -c ' import json import re import sys expected_version = sys.argv[1] expected_server_identity = sys.argv[2] try: health = json.load(sys.stdin) except (UnicodeError, json.JSONDecodeError): raise SystemExit(1) if not isinstance(health, dict) or health.get("ok") is not True: raise SystemExit(1) if health.get("server_version") != expected_version: raise SystemExit(1) server_identity = health.get("server_identity") if not isinstance(server_identity, str) or re.fullmatch(r"[A-Za-z0-9_.:-]{8,240}", server_identity) is None: raise SystemExit(1) if server_identity != expected_server_identity: raise SystemExit(1) capabilities = health.get("capabilities") capability = capabilities.get("team_hub_v1") if isinstance(capabilities, dict) else None if not isinstance(capability, dict): raise SystemExit(1) required = { "available": False, "designated_host": False, "version": 1, "base_path": None, "hub_id": None, "host_server_identity": None, "transport": None, "hub_url": None, "routes": [], } if any(capability.get(key) != value for key, value in required.items()): raise SystemExit(1) secure_capability = capabilities.get("secure_peer_v1") secure_required = { "available": True, "state_available": True, "state_error_code": None, "required": False, "version": 1, "control_path": "/api/admin/secure-peers/v1/status", "proxy_prefix": "/api/team-hub-secure", } if not isinstance(secure_capability, dict) or any( secure_capability.get(key) != value for key, value in secure_required.items() ): raise SystemExit(1) ' "$EXPECTED_VERSION" "$EXPECTED_SERVER_IDENTITY"; then printf '%s\n' "$REMOTE_HEALTH" HEALTH_OK=1 break fi fi sleep 1 done if [[ "$HEALTH_OK" != "1" ]]; then echo "Authenticated health did not prove exact version $EXPECTED_VERSION with Teamspace unpaired and secure-peer state available." >&2 exit 1 fi echo