| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # agent-canvas all-in-one entrypoint | |
| # | |
| # Starts three services (plus an optional fourth): | |
| # 1. Agent Server on port $AGENT_SERVER_PORT (default 18000) | |
| # 2. Automation on port $AUTOMATION_PORT (default 18001) | |
| # 3. Static server on port $PORT (default 8000) | |
| # Routes /api/automation/* β automation, /api/* β agent-server, | |
| # and serves the frontend static build for everything else. | |
| # 4. (Optional) Public-mode static server on $PUBLIC_MODE_PORT | |
| # Same frontend, but with --auth-required (no baked session key). | |
| # Used by auth-mode E2E tests. Only started when PUBLIC_MODE_PORT is set. | |
| # | |
| # Environment variables: | |
| # PORT β Unified entry point port (default: 8000) | |
| # AGENT_SERVER_PORT β Internal agent-server port (default: 18000) | |
| # AUTOMATION_PORT β Internal automation port (default: 18001) | |
| # AGENT_CANVAS_BASE_PATH β Static frontend mount path (default: /canvas) | |
| # VSCODE_PORT β Internal editor port (default: 8001). The image does | |
| # not EXPOSE it and the editor is reached through | |
| # VSCODE_BASE_PATH on $PORT, but openvscode-server | |
| # binds 0.0.0.0, so `docker run --network host` does | |
| # leave it directly reachable with only its connection | |
| # token in front of it. | |
| # VSCODE_BASE_PATH β Path prefix the editor is served under on $PORT | |
| # (default: /vscode). Exported to agent-server as | |
| # OH_VSCODE_BASE_PATH and routed by the static server. | |
| # agent-server's own OH_VSCODE_PORT / OH_VSCODE_BASE_PATH | |
| # take precedence over these aliases; whichever is set, | |
| # one effective pair drives both the editor process and | |
| # the proxy route. | |
| # PUBLIC_MODE_PORT β If set, starts a second static server on this port | |
| # with --auth-required (no session key injected) | |
| # OH_SECRET_KEY β Secret key for settings encryption (auto-generated | |
| # and persisted if not provided) | |
| # OPENHANDS_AUTOMATION_API_KEY β Override automation backend auth key | |
| # (defaults to session API key β both backends | |
| # use the same `X-Session-API-Key` header) | |
| # AUTOMATION_AGENT_SERVER_URL β URL the automation service uses to reach the | |
| # agent-server (default: http://127.0.0.1:AGENT_SERVER_PORT). | |
| # Setting this enables local-mode auth so the session | |
| # API key is validated internally instead of against the | |
| # OpenHands cloud API. | |
| # FILE_STORE β Storage backend for automation tarballs (default: local). | |
| # Without this the automation backend may fall back to | |
| # S3/GCS which fails without cloud credentials. | |
| # LOCAL_STORAGE_PATH β Directory for local file storage (default: ~/.openhands/storage) | |
| # AUTOMATION_BASE_URL β Publicly-reachable base URL for the automation | |
| # service, used in callback URLs and injected into | |
| # sandboxes (default: http://127.0.0.1:$PORT). | |
| # Override in production when the external URL differs. | |
| # AUTOMATION_WORKSPACE_BASE β Directory for automation run workspaces | |
| # (default: ~/.openhands/workspaces) | |
| # Any agent-server or automation env vars are passed through. | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| set -uo pipefail | |
| log() { printf '[agent-canvas] %s\n' "$*"; } | |
| log_error() { printf '[agent-canvas] ERROR: %s\n' "$*" >&2; } | |
| # ββ Load centralized defaults (generated from config/defaults.json at build) β | |
| # shellcheck source=/dev/null | |
| if [ -f /opt/agent-canvas/defaults.env ]; then | |
| # shellcheck disable=SC1091 | |
| . /opt/agent-canvas/defaults.env | |
| fi | |
| PORT="${PORT:-${CONFIG_PROXY_PORT:-8000}}" | |
| AGENT_SERVER_PORT="${AGENT_SERVER_PORT:-${CONFIG_AGENT_SERVER_PORT:-18000}}" | |
| AUTOMATION_PORT="${AUTOMATION_PORT:-${CONFIG_AUTOMATION_PORT:-18001}}" | |
| # The bundled editor is reached through a path prefix on the proxy port rather | |
| # than a published port of its own. The same prefix has to reach agent-server | |
| # (it launches openvscode-server with --server-base-path and advertises the | |
| # prefix from /api/vscode/url) and the static-server route table below, or the | |
| # advertised URL and the route serving it disagree. | |
| # | |
| # Two env var names reach the same setting: OH_VSCODE_PORT / OH_VSCODE_BASE_PATH | |
| # are agent-server's own documented variables, which a deployment may already | |
| # set and which this entrypoint passes through like any other OH_* var, while | |
| # VSCODE_PORT / VSCODE_BASE_PATH are this image's aliases. They collapse to one | |
| # effective pair here, before anything reads them β resolving them | |
| # independently would let `OH_VSCODE_BASE_PATH=/editor` move the editor without | |
| # moving the route, leaving the button pointing at a path the proxy never | |
| # serves. | |
| # >>> vscode-config: this block is extracted and executed by | |
| # >>> __tests__/scripts/docker-vscode-route-sync.test.ts β keep the markers. | |
| # The canvas mount is resolved here rather than alongside the ports above | |
| # because the collision guard below compares the two prefixes: keeping both | |
| # inside the extracted block is what lets that comparison be tested against the | |
| # real defaults instead of only against values a test injects. | |
| AGENT_CANVAS_BASE_PATH="${AGENT_CANVAS_BASE_PATH:-${CONFIG_CANVAS_BASE_PATH:-/canvas}}" | |
| VSCODE_PORT="${OH_VSCODE_PORT:-${VSCODE_PORT:-${CONFIG_VSCODE_PORT:-8001}}}" | |
| VSCODE_BASE_PATH="${OH_VSCODE_BASE_PATH:-${VSCODE_BASE_PATH:-${CONFIG_VSCODE_BASE_PATH:-/vscode}}}" | |
| # Accept "editor", "/editor" and "/editor/" alike: agent-server strips the | |
| # slashes when it builds the advertised URL, the static-server route table | |
| # needs the leading one, so settle on one spelling rather than one per use site. | |
| normalize_base_path() { | |
| local p="$1" | |
| while [ "${p#/}" != "$p" ]; do p="${p#/}"; done | |
| while [ "${p%/}" != "$p" ]; do p="${p%/}"; done | |
| printf '/%s' "$p" | |
| } | |
| VSCODE_BASE_PATH="$(normalize_base_path "$VSCODE_BASE_PATH")" | |
| if [ "$VSCODE_BASE_PATH" = "/" ]; then | |
| log_error "VSCODE_BASE_PATH resolved to the site root β that would route the whole origin to the editor instead of the canvas. Set a prefix such as /vscode." | |
| exit 1 | |
| fi | |
| # The canvas mount gets the same treatment, for the same reason and with the | |
| # same function. static-server normalizes whatever `--base-path` it is handed | |
| # (`canvas` and `/canvas/` both mount at `/canvas`), so comparing a normalized | |
| # editor prefix against a raw canvas one below would let `AGENT_CANVAS_BASE_PATH=canvas` | |
| # with `OH_VSCODE_BASE_PATH=/canvas` past the collision guard and then land both | |
| # on `/canvas` β where the editor route, registered after the SPA mount, takes | |
| # the application over. Normalizing here rather than at the comparison keeps the | |
| # value passed to `--base-path` further down identical to the one guarded. | |
| AGENT_CANVAS_BASE_PATH="$(normalize_base_path "$AGENT_CANVAS_BASE_PATH")" | |
| # static-server keys its route table by prefix and the editor route is | |
| # registered last, so a prefix that collides with an earlier route silently | |
| # replaces it rather than failing: OH_VSCODE_BASE_PATH=/api would send every | |
| # API call to the editor port. Reject collisions and anything that is not a | |
| # plain single-segment path β '=' would be mis-split by the --route parser | |
| # (it cuts at the first '='), and whitespace, '?', '#' or '..' have no | |
| # meaningful reading as a route prefix. | |
| VSCODE_PATH_SEGMENT="${VSCODE_BASE_PATH#/}" | |
| case "$VSCODE_PATH_SEGMENT" in | |
| */*) | |
| log_error "VSCODE_BASE_PATH must be a single path segment (got '$VSCODE_BASE_PATH'). Use a prefix such as /vscode." | |
| exit 1 | |
| ;; | |
| .|..) | |
| log_error "VSCODE_BASE_PATH must not be a relative path segment (got '$VSCODE_BASE_PATH'). Use a prefix such as /vscode." | |
| exit 1 | |
| ;; | |
| *[!A-Za-z0-9._-]*) | |
| log_error "VSCODE_BASE_PATH may only contain letters, digits, '.', '_' and '-' (got '$VSCODE_BASE_PATH'). Use a prefix such as /vscode." | |
| exit 1 | |
| ;; | |
| esac | |
| for reserved in /api /sockets /server_info /alive /health /ready /docs /redoc /openapi.json "${AGENT_CANVAS_BASE_PATH:-}"; do | |
| if [ -n "$reserved" ] && [ "$VSCODE_BASE_PATH" = "$reserved" ]; then | |
| log_error "VSCODE_BASE_PATH '$VSCODE_BASE_PATH' collides with an existing route and would take it over. Set a different prefix, such as /vscode." | |
| exit 1 | |
| fi | |
| done | |
| # The port ends up in a proxy target URL, so a non-numeric value fails at the | |
| # first editor request instead of at startup. Catch it here. | |
| case "$VSCODE_PORT" in | |
| ''|*[!0-9]*) | |
| log_error "VSCODE_PORT must be a number (got '$VSCODE_PORT')." | |
| exit 1 | |
| ;; | |
| esac | |
| export OH_VSCODE_PORT="$VSCODE_PORT" | |
| export OH_VSCODE_BASE_PATH="$VSCODE_BASE_PATH" | |
| # The single route string every static-server instance registers. Derived from | |
| # the exported pair above so the advertised URL and the route cannot diverge. | |
| VSCODE_ROUTE="${VSCODE_BASE_PATH}=http://127.0.0.1:${VSCODE_PORT}" | |
| # <<< vscode-config | |
| # Persistence paths β keep settings, conversations, bash history under a | |
| # single well-known directory that the VOLUME directive exposes. | |
| OPENHANDS_DIR="${HOME}/.openhands" | |
| STATE_DIR="${OPENHANDS_DIR}/${CONFIG_STATE_SUBDIR:-agent-canvas}" | |
| export OH_PERSISTENCE_DIR="${OH_PERSISTENCE_DIR:-${OPENHANDS_DIR}}" | |
| export OH_CONVERSATIONS_PATH="${OH_CONVERSATIONS_PATH:-${OPENHANDS_DIR}/${CONFIG_CONVERSATIONS:-agent-canvas/conversations}}" | |
| export OH_BASH_EVENTS_DIR="${OH_BASH_EVENTS_DIR:-${OPENHANDS_DIR}/${CONFIG_BASH_EVENTS:-agent-canvas/bash_events}}" | |
| # OH_SECRET_KEY is required for settings/secrets encryption. Without it the | |
| # agent-server refuses to return encrypted secrets β conversation creation | |
| # fails with a 503. Auto-generate and persist (just like the session API key) | |
| # so the image never runs with a known default. | |
| SECRET_KEY_FILE="${STATE_DIR}/secret-key.txt" | |
| if [ -z "${OH_SECRET_KEY:-}" ]; then | |
| if [ -f "$SECRET_KEY_FILE" ]; then | |
| OH_SECRET_KEY="$(cat "$SECRET_KEY_FILE")" | |
| else | |
| OH_SECRET_KEY="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" | |
| mkdir -p "$(dirname "$SECRET_KEY_FILE")" | |
| printf '%s' "$OH_SECRET_KEY" > "$SECRET_KEY_FILE" | |
| chmod 600 "$SECRET_KEY_FILE" | |
| log "Generated OH_SECRET_KEY (persisted to $SECRET_KEY_FILE)" | |
| fi | |
| fi | |
| export OH_SECRET_KEY | |
| # API key β generate one if not provided so the image doesn't run wide-open | |
| # by default. LOCAL_BACKEND_API_KEY is the single user-facing env var. | |
| # Persisted so restarts reuse the same key. | |
| API_KEY_FILE="${STATE_DIR}/api-key.txt" | |
| if [ -z "${LOCAL_BACKEND_API_KEY:-}" ] && [ -z "${OH_SESSION_API_KEYS_0:-}" ]; then | |
| if [ -f "$API_KEY_FILE" ]; then | |
| LOCAL_BACKEND_API_KEY="$(cat "$API_KEY_FILE")" | |
| else | |
| LOCAL_BACKEND_API_KEY="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" | |
| mkdir -p "$(dirname "$API_KEY_FILE")" | |
| printf '%s' "$LOCAL_BACKEND_API_KEY" > "$API_KEY_FILE" | |
| chmod 600 "$API_KEY_FILE" | |
| log "Generated API key (persisted to $API_KEY_FILE)" | |
| fi | |
| export OH_SESSION_API_KEYS_0="$LOCAL_BACKEND_API_KEY" | |
| fi | |
| # Both backends share the same API key value and the same `X-Session-API-Key` | |
| # header for authentication. Default OPENHANDS_AUTOMATION_API_KEY to the | |
| # API key so a single credential secures the whole stack. | |
| EFFECTIVE_SESSION_KEY="${OH_SESSION_API_KEYS_0:-${LOCAL_BACKEND_API_KEY:-}}" | |
| if [ -z "$EFFECTIVE_SESSION_KEY" ]; then | |
| log "ERROR: No session API key available β cannot configure automation auth" | |
| exit 1 | |
| fi | |
| export OPENHANDS_AUTOMATION_API_KEY="${OPENHANDS_AUTOMATION_API_KEY:-${EFFECTIVE_SESSION_KEY}}" | |
| export AUTOMATION_LOCAL_API_KEY="${AUTOMATION_LOCAL_API_KEY:-${EFFECTIVE_SESSION_KEY}}" | |
| export AUTOMATION_AGENT_SERVER_API_KEY="${AUTOMATION_AGENT_SERVER_API_KEY:-${EFFECTIVE_SESSION_KEY}}" | |
| export OPENHANDS_REMOTE_WS_READY_REQUIRED="${OPENHANDS_REMOTE_WS_READY_REQUIRED:-false}" | |
| if [ -z "${AUTOMATION_POSTHOG_API_KEY:-}" ]; then | |
| if [ -n "${VITE_POSTHOG_API_KEY:-}" ]; then | |
| export AUTOMATION_POSTHOG_API_KEY="$VITE_POSTHOG_API_KEY" | |
| elif [ "${VITE_DO_NOT_TRACK:-}" != "1" ]; then | |
| export AUTOMATION_POSTHOG_API_KEY="${CONFIG_POSTHOG_API_KEY:-}" | |
| fi | |
| fi | |
| if [ -n "${AUTOMATION_POSTHOG_API_KEY:-}" ]; then | |
| export AUTOMATION_POSTHOG_HOST="${AUTOMATION_POSTHOG_HOST:-${VITE_POSTHOG_HOST:-${CONFIG_POSTHOG_HOST:-}}}" | |
| fi | |
| # Configure product analytics for the agent-server. The SDK uses its own | |
| # OH_TELEMETRY_* variables, so mirror the same Canvas/PostHog defaults used by | |
| # the frontend and automation backend while preserving explicit operator | |
| # overrides. Consent stays in persisted settings, where the backend/UI owns it. | |
| if [ "${VITE_DO_NOT_TRACK:-}" = "1" ]; then | |
| export DO_NOT_TRACK="${DO_NOT_TRACK:-1}" | |
| fi | |
| if [ -z "${OH_TELEMETRY_POSTHOG_API_KEY:-}" ]; then | |
| if [ -n "${VITE_POSTHOG_API_KEY:-}" ]; then | |
| export OH_TELEMETRY_POSTHOG_API_KEY="$VITE_POSTHOG_API_KEY" | |
| elif [ "${DO_NOT_TRACK:-}" != "1" ]; then | |
| export OH_TELEMETRY_POSTHOG_API_KEY="${CONFIG_POSTHOG_API_KEY:-}" | |
| fi | |
| fi | |
| if [ -z "${OH_TELEMETRY_EXPORTER:-}" ] && [ -n "${OH_TELEMETRY_POSTHOG_API_KEY:-}" ]; then | |
| export OH_TELEMETRY_EXPORTER="posthog" | |
| fi | |
| if [ "${OH_TELEMETRY_EXPORTER:-}" = "posthog" ] && [ -n "${OH_TELEMETRY_POSTHOG_API_KEY:-}" ]; then | |
| export OH_TELEMETRY_POSTHOG_HOST="${OH_TELEMETRY_POSTHOG_HOST:-${VITE_POSTHOG_HOST:-${CONFIG_POSTHOG_HOST:-}}}" | |
| fi | |
| # AGENT_SERVER_URL β needed by automation sandbox callbacks. | |
| export AGENT_SERVER_URL="${AGENT_SERVER_URL:-http://127.0.0.1:${AGENT_SERVER_PORT}}" | |
| # AUTOMATION_AGENT_SERVER_URL β the URL the automation service uses to reach | |
| # the agent-server REST API (tarball upload, bash dispatch, auth key minting). | |
| # When set, ServiceSettings.is_local_mode returns True, enabling local API key | |
| # authentication. Without this, the automation server falls back to validating | |
| # keys against the OpenHands cloud API (app.all-hands.dev), which returns 401 | |
| # for locally-generated session keys. | |
| export AUTOMATION_AGENT_SERVER_URL="${AUTOMATION_AGENT_SERVER_URL:-http://127.0.0.1:${AGENT_SERVER_PORT}}" | |
| # Keep the legacy canvas_ui_tool module importable when the agent-server restores | |
| # conversations whose persisted metadata still references its module qualname. | |
| # It is also imported at startup below (--import-modules) so its builtin | |
| # FinishTool registration lets automation runs resolve the tool on their | |
| # remote conversations (see the note at the bottom of tools/canvas_ui_tool.py). | |
| export OH_EXTRA_PYTHON_PATH="${OH_EXTRA_PYTHON_PATH:-/opt/agent-canvas/tools}" | |
| AGENT_SERVER_IMPORT_MODULES="canvas_ui_tool" | |
| # Track child PIDs so we can clean up on exit. | |
| PIDS=() | |
| cleanup() { | |
| log "Shutting down..." | |
| for pid in "${PIDS[@]}"; do | |
| kill "$pid" 2>/dev/null || true | |
| done | |
| wait 2>/dev/null || true | |
| exit 0 | |
| } | |
| trap cleanup EXIT SIGINT SIGTERM | |
| # ββ 1. Start Agent Server ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| log "Starting agent-server on port $AGENT_SERVER_PORT..." | |
| if command -v openhands-agent-server >/dev/null 2>&1; then | |
| # Binary build (production image) | |
| openhands-agent-server --port "$AGENT_SERVER_PORT" \ | |
| --import-modules "$AGENT_SERVER_IMPORT_MODULES" & | |
| elif [ -x /agent-server/.venv/bin/python ]; then | |
| # Source build (development image) | |
| /agent-server/.venv/bin/python -m openhands.agent_server --port "$AGENT_SERVER_PORT" \ | |
| --import-modules "$AGENT_SERVER_IMPORT_MODULES" & | |
| else | |
| log_error "Cannot find agent-server binary or source venv." | |
| exit 1 | |
| fi | |
| PIDS+=($!) | |
| # ββ 2. Start Automation Server βββββββββββββββββββββββββββββββββββββββββββββββ | |
| log "Starting automation server on port $AUTOMATION_PORT..." | |
| # File storage β use local filesystem unless the user has configured cloud | |
| # storage. Without FILE_STORE=local the automation backend may fall back | |
| # to a cloud provider (S3/GCS) which will fail without credentials, causing | |
| # tarball-based presets (preset/prompt, preset/plugin) to silently error. | |
| export FILE_STORE="${FILE_STORE:-local}" | |
| export LOCAL_STORAGE_PATH="${LOCAL_STORAGE_PATH:-${OPENHANDS_DIR}/storage}" | |
| mkdir -p "$LOCAL_STORAGE_PATH" | |
| # AUTOMATION_BASE_URL β the publicly-reachable base URL for the automation | |
| # service. Appended to callback URLs and injected into each sandbox as | |
| # AUTOMATION_API_URL. Defaults to the unified ingress. | |
| export AUTOMATION_BASE_URL="${AUTOMATION_BASE_URL:-http://127.0.0.1:${PORT}}" | |
| # AUTOMATION_WORKSPACE_BASE β where automation runs unpack tarballs. | |
| export AUTOMATION_WORKSPACE_BASE="${AUTOMATION_WORKSPACE_BASE:-${OPENHANDS_DIR}/workspaces}" | |
| mkdir -p "$AUTOMATION_WORKSPACE_BASE" | |
| # Default to SQLite so the automation server works out of the box without | |
| # an external PostgreSQL instance. Users can override AUTOMATION_DB_URL to | |
| # point at a real Postgres for production deployments. | |
| if [ -z "${AUTOMATION_DB_URL:-}" ]; then | |
| AUTOMATION_DB_FILE="${OPENHANDS_DIR}/${CONFIG_AUTOMATION_DB:-automation/automations.db}" | |
| mkdir -p "$(dirname "$AUTOMATION_DB_FILE")" | |
| export AUTOMATION_DB_URL="sqlite+aiosqlite:///${AUTOMATION_DB_FILE}" | |
| log "Using SQLite database: $AUTOMATION_DB_URL" | |
| fi | |
| # The automation server uses uvicorn. Set AUTOMATION_PORT via its CLI. | |
| if command -v uvicorn >/dev/null 2>&1; then | |
| uvicorn openhands.automation.app:app \ | |
| --host 0.0.0.0 \ | |
| --port "$AUTOMATION_PORT" & | |
| PIDS+=($!) | |
| elif python -c "import openhands.automation" 2>/dev/null; then | |
| python -m uvicorn openhands.automation.app:app \ | |
| --host 0.0.0.0 \ | |
| --port "$AUTOMATION_PORT" & | |
| PIDS+=($!) | |
| else | |
| log "WARNING: Automation server not found, skipping." | |
| fi | |
| # ββ 3. Wait for backends to be ready βββββββββββββββββββββββββββββββββββββββββ | |
| wait_for_port() { | |
| local port=$1 name=$2 max_wait=${3:-30} | |
| local elapsed=0 | |
| while ! (echo >/dev/tcp/127.0.0.1/"$port") 2>/dev/null; do | |
| sleep 1 | |
| elapsed=$((elapsed + 1)) | |
| if [ "$elapsed" -ge "$max_wait" ]; then | |
| log "WARNING: $name on port $port did not become ready within ${max_wait}s" | |
| return 1 | |
| fi | |
| done | |
| log "$name is ready on port $port" | |
| } | |
| wait_for_port "$AGENT_SERVER_PORT" "Agent Server" 60 & | |
| WAIT_PID1=$! | |
| wait_for_port "$AUTOMATION_PORT" "Automation Server" 60 & | |
| WAIT_PID2=$! | |
| wait "$WAIT_PID1" "$WAIT_PID2" | |
| # ββ 4. Start static server (frontend + proxy) ββββββββββββββββββββββββββββββββ | |
| log "Starting frontend + proxy on port $PORT..." | |
| # Describe the local runtime services so the frontend can populate the agent's | |
| # <RUNTIME_SERVICES> system-prompt block (without it the agent does not know how | |
| # to reach the local automation backend and falls back to the cloud API). These | |
| # URLs are runtime config (overridable at `docker run`), so build the JSON here | |
| # from the sandbox-facing URLs the entrypoint already exports. static-server.mjs | |
| # appends it to /server_info as runtime_services and also injects the legacy | |
| # window global for older frontend bundles. | |
| RUNTIME_SERVICES_INFO="$(node /opt/agent-canvas/runtime-services-info.mjs \ | |
| --mode docker \ | |
| --agent-host-alias 127.0.0.1 \ | |
| --agent-server-url "$AGENT_SERVER_URL" \ | |
| --automation-url "$AUTOMATION_BASE_URL")" | |
| # EFFECTIVE_SESSION_KEY is set above from LOCAL_BACKEND_API_KEY or the persisted api-key.txt | |
| node /opt/agent-canvas/static-server.mjs \ | |
| --port "$PORT" \ | |
| --host :: \ | |
| --dir /opt/agent-canvas/frontend \ | |
| --base-path "$AGENT_CANVAS_BASE_PATH" \ | |
| --session-api-key "$EFFECTIVE_SESSION_KEY" \ | |
| --runtime-services-info "$RUNTIME_SERVICES_INFO" \ | |
| --route "/api/automation=http://127.0.0.1:${AUTOMATION_PORT}" \ | |
| --route "/api=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "/server_info=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "/sockets=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "/alive=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "/health=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "/ready=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "/docs=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "/redoc=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "/openapi.json=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "$VSCODE_ROUTE" \ | |
| --vscode-base-path "$VSCODE_BASE_PATH" \ | |
| --no-referrer-prefix "$VSCODE_BASE_PATH" & | |
| STATIC_PID=$! | |
| PIDS+=("$STATIC_PID") | |
| # ββ 5. (Optional) Public-mode static server βββββββββββββββββββββββββββββββββ | |
| # When PUBLIC_MODE_PORT is set, start a second static-server instance that | |
| # serves the same frontend WITHOUT injecting the session key into the HTML | |
| # (--auth-required). This is used by auth-mode E2E tests to verify the | |
| # ApiKeyEntryScreen gate, key rotation recovery, etc. | |
| # | |
| # Neither the editor route nor --vscode-base-path is registered here, and the | |
| # pair is deliberate: the route is what would serve the editor, and the flag is | |
| # what tells the frontend this origin can. Omitting only the route would leave | |
| # the control rendering and falling through to the SPA, because the agent-server | |
| # it shares with the main instance still reports the editor as available. | |
| # | |
| # --auth-required only | |
| # controls whether the session key is injected into the served HTML; the | |
| # dispatcher matches routes before it reaches that flag, so proxied paths are | |
| # not gated by it. The routes above are safe on that footing because | |
| # agent-server enforces the session key itself, but the editor's own | |
| # credential is the connection token agent-server puts in the query string β | |
| # and agent-server derives that token from session_api_keys[0], so it is the | |
| # same secret that authenticates /api. Registering the route here would put | |
| # that secret in a browser-navigable URL on the origin that exists precisely | |
| # to test the unauthenticated case, where it would persist in history and | |
| # leak by Referer from the workbench's own subresources. | |
| # | |
| # The token's scope is upstream's to fix and is tracked in | |
| # OpenHands/software-agent-sdk#4317; if the editor gets a credential of its own, | |
| # this exclusion and the --no-referrer-prefix below can both be revisited. | |
| if [ -n "${PUBLIC_MODE_PORT:-}" ]; then | |
| log "Starting public-mode frontend on port $PUBLIC_MODE_PORT (--auth-required)..." | |
| node /opt/agent-canvas/static-server.mjs \ | |
| --port "$PUBLIC_MODE_PORT" \ | |
| --host :: \ | |
| --dir /opt/agent-canvas/frontend \ | |
| --base-path "$AGENT_CANVAS_BASE_PATH" \ | |
| --auth-required \ | |
| --runtime-services-info "$RUNTIME_SERVICES_INFO" \ | |
| --route "/api/automation=http://127.0.0.1:${AUTOMATION_PORT}" \ | |
| --route "/api=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "/server_info=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "/sockets=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "/alive=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "/health=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "/ready=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "/docs=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "/redoc=http://127.0.0.1:${AGENT_SERVER_PORT}" \ | |
| --route "/openapi.json=http://127.0.0.1:${AGENT_SERVER_PORT}" & | |
| PIDS+=($!) | |
| fi | |
| log "All services started. Unified entry point: http://0.0.0.0:${PORT}/" | |
| # Keep the container alive while the static-server (ingress) is running. | |
| # Backend crashes (agent-server, automation) are tolerated β the proxy | |
| # returns 502 for downed routes, matching the non-Docker path where each | |
| # service is an independent host process. | |
| # | |
| # Pattern: `sleep & wait $!` makes `wait` (a bash builtin) the foreground | |
| # operation. Unlike a bare `sleep`, the builtin `wait` is interrupted | |
| # immediately when a trapped signal (SIGTERM/SIGINT) arrives, so cleanup() | |
| # fires without delay. cleanup() calls `exit 0` to terminate after the | |
| # trap returns. The loop re-checks the static-server PID every 10 s so the | |
| # container exits promptly if the ingress process dies on its own. | |
| while kill -0 "$STATIC_PID" 2>/dev/null; do | |
| sleep 10 & wait $! | |
| done | |
| log_error "Static server (PID $STATIC_PID) exited" | |
| exit 1 | |