Ctrl K
ComunidadGuías…

deploy.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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=<ssh-host> ./deploy.sh
  ./deploy.sh <ssh-host>

Optional:
  AGENTSDOCK_REMOTE_APP_DIR=<remote-app-dir>
  AGENTSDOCK_SERVER_SERVICE=<systemd-user-service>
  AGENTSDOCK_AGENT_TOKEN=<health-check-token>
  AGENTSDOCK_HEALTH_ATTEMPTS=<startup-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