File size: 24,877 Bytes
ee9fcd0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 | #!/usr/bin/env bash
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 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
|