From 35976da7a0785cda4002f7379395a6515d12f0c1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 05:45:57 +0100 Subject: [PATCH] fix: harden Docker/GCP onboarding flow (#26253) (thanks @pandego) --- CHANGELOG.md | 1 + Dockerfile | 3 +- docker-setup.sh | 82 +++++++++++++++++++++++++++++++++++++++- docs/install/gcp.md | 23 ++++++++++- src/docker-setup.test.ts | 21 ++++++++++ src/dockerfile.test.ts | 2 +- 6 files changed, 127 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfb74303243..0162138f63a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during `pnpm install`, reuse existing gateway token during `docker-setup.sh` reruns so `.env` stays aligned with config, auto-bootstrap Control UI allowed origins for non-loopback Docker binds, and add GCP docs guidance for tokenized dashboard links + pairing recovery commands. (#26253) Thanks @pandego. - Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808. - Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156) - Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. diff --git a/Dockerfile b/Dockerfile index c5f7b1dc277..2229a299a56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,8 +25,7 @@ COPY --chown=node:node scripts ./scripts USER node # Reduce OOM risk on low-memory hosts during dependency installation. # Docker builds on small VMs may otherwise fail with "Killed" (exit 137). -ENV NODE_OPTIONS=--max-old-space-size=2048 -RUN pnpm install --frozen-lockfile +RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile # Optionally install Chromium and Xvfb for browser automation. # Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ... diff --git a/docker-setup.sh b/docker-setup.sh index c0cd925c4c3..1f6e51cd75d 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -20,6 +20,78 @@ require_cmd() { fi } +read_config_gateway_token() { + local config_path="$OPENCLAW_CONFIG_DIR/openclaw.json" + if [[ ! -f "$config_path" ]]; then + return 0 + fi + if command -v python3 >/dev/null 2>&1; then + python3 - "$config_path" <<'PY' +import json +import sys + +path = sys.argv[1] +try: + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) +except Exception: + raise SystemExit(0) + +gateway = cfg.get("gateway") +if not isinstance(gateway, dict): + raise SystemExit(0) +auth = gateway.get("auth") +if not isinstance(auth, dict): + raise SystemExit(0) +token = auth.get("token") +if isinstance(token, str): + token = token.strip() + if token: + print(token) +PY + return 0 + fi + if command -v node >/dev/null 2>&1; then + node - "$config_path" <<'NODE' +const fs = require("node:fs"); +const configPath = process.argv[2]; +try { + const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); + const token = cfg?.gateway?.auth?.token; + if (typeof token === "string" && token.trim().length > 0) { + process.stdout.write(token.trim()); + } +} catch { + // Keep docker-setup resilient when config parsing fails. +} +NODE + fi +} + +ensure_control_ui_allowed_origins() { + if [[ "${OPENCLAW_GATEWAY_BIND}" == "loopback" ]]; then + return 0 + fi + + local allowed_origin_json + local current_allowed_origins + allowed_origin_json="$(printf '["http://127.0.0.1:%s"]' "$OPENCLAW_GATEWAY_PORT")" + current_allowed_origins="$( + docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ + config get gateway.controlUi.allowedOrigins 2>/dev/null || true + )" + current_allowed_origins="${current_allowed_origins//$'\r'/}" + + if [[ -n "$current_allowed_origins" && "$current_allowed_origins" != "null" && "$current_allowed_origins" != "[]" ]]; then + echo "Control UI allowlist already configured; leaving gateway.controlUi.allowedOrigins unchanged." + return 0 + fi + + docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ + config set gateway.controlUi.allowedOrigins "$allowed_origin_json" --strict-json >/dev/null + echo "Set gateway.controlUi.allowedOrigins to $allowed_origin_json for non-loopback bind." +} + contains_disallowed_chars() { local value="$1" [[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]] @@ -97,7 +169,11 @@ export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS" export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME" if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then - if command -v openssl >/dev/null 2>&1; then + EXISTING_CONFIG_TOKEN="$(read_config_gateway_token || true)" + if [[ -n "$EXISTING_CONFIG_TOKEN" ]]; then + OPENCLAW_GATEWAY_TOKEN="$EXISTING_CONFIG_TOKEN" + echo "Reusing gateway token from $OPENCLAW_CONFIG_DIR/openclaw.json" + elif command -v openssl >/dev/null 2>&1; then OPENCLAW_GATEWAY_TOKEN="$(openssl rand -hex 32)" else OPENCLAW_GATEWAY_TOKEN="$(python3 - <<'PY' @@ -273,6 +349,10 @@ echo " - Install Gateway daemon: No" echo "" docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli onboard --no-install-daemon +echo "" +echo "==> Control UI origin allowlist" +ensure_control_ui_allowed_origins + echo "" echo "==> Provider setup (optional)" echo "WhatsApp (QR):" diff --git a/docs/install/gcp.md b/docs/install/gcp.md index a4485611402..2c6bdd8ac1f 100644 --- a/docs/install/gcp.md +++ b/docs/install/gcp.md @@ -353,6 +353,14 @@ docker compose up -d openclaw-gateway If build fails with `Killed` / `exit code 137` during `pnpm install --frozen-lockfile`, the VM is out of memory. Use `e2-small` minimum, or `e2-medium` for more reliable first builds. +When binding to LAN (`OPENCLAW_GATEWAY_BIND=lan`), configure a trusted browser origin before continuing: + +```bash +docker compose run --rm openclaw-cli config set gateway.controlUi.allowedOrigins '["http://127.0.0.1:18789"]' --strict-json +``` + +If you changed the gateway port, replace `18789` with your configured port. + Verify binaries: ```bash @@ -397,7 +405,20 @@ Open in your browser: `http://127.0.0.1:18789/` -Paste your gateway token. +Fetch a fresh tokenized dashboard link: + +```bash +docker compose run --rm openclaw-cli dashboard --no-open +``` + +Paste the token from that URL. + +If Control UI shows `unauthorized` or `disconnected (1008): pairing required`, approve the browser device: + +```bash +docker compose run --rm openclaw-cli devices list +docker compose run --rm openclaw-cli devices approve +``` --- diff --git a/src/docker-setup.test.ts b/src/docker-setup.test.ts index 20f754990e3..8737ff5a793 100644 --- a/src/docker-setup.test.ts +++ b/src/docker-setup.test.ts @@ -168,6 +168,27 @@ describe("docker-setup.sh", () => { expect(identityDirStat.isDirectory()).toBe(true); }); + it("reuses existing config token when OPENCLAW_GATEWAY_TOKEN is unset", async () => { + const activeSandbox = requireSandbox(sandbox); + const configDir = join(activeSandbox.rootDir, "config-token-reuse"); + const workspaceDir = join(activeSandbox.rootDir, "workspace-token-reuse"); + await mkdir(configDir, { recursive: true }); + await writeFile( + join(configDir, "openclaw.json"), + JSON.stringify({ gateway: { auth: { mode: "token", token: "config-token-123" } } }), + ); + + const result = runDockerSetup(activeSandbox, { + OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_CONFIG_DIR: configDir, + OPENCLAW_WORKSPACE_DIR: workspaceDir, + }); + + expect(result.status).toBe(0); + const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8"); + expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN=config-token-123"); + }); + it("rejects injected multiline OPENCLAW_EXTRA_MOUNTS values", async () => { const activeSandbox = requireSandbox(sandbox); diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 4e75caeb420..5cd55d9b53f 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -9,7 +9,7 @@ const dockerfilePath = join(repoRoot, "Dockerfile"); describe("Dockerfile", () => { it("installs optional browser dependencies after pnpm install", async () => { const dockerfile = await readFile(dockerfilePath, "utf8"); - const installIndex = dockerfile.indexOf("RUN pnpm install --frozen-lockfile"); + const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); const browserArgIndex = dockerfile.indexOf("ARG OPENCLAW_INSTALL_BROWSER"); expect(installIndex).toBeGreaterThan(-1);