diff --git a/.agents/skills/nemoclaw-user-configure-security/references/best-practices.md b/.agents/skills/nemoclaw-user-configure-security/references/best-practices.md index b7e2de71a2d..52aff6e0641 100644 --- a/.agents/skills/nemoclaw-user-configure-security/references/best-practices.md +++ b/.agents/skills/nemoclaw-user-configure-security/references/best-practices.md @@ -209,18 +209,8 @@ The container mounts system directories read-only to prevent the agent from modi ### Read-Only `.openclaw` Config -The `/sandbox/.openclaw` directory contains the OpenClaw gateway configuration (model routing, CORS settings, channel config). -The gateway auth token is **not** stored in this directory — it is generated at container startup and passed via the `OPENCLAW_GATEWAY_TOKEN` environment variable only to the gateway process (which runs as the `gateway` user). - -The token file location depends on the startup mode: - -- **Root mode** (production): `/run/nemoclaw/gateway-token` (`gateway:gateway 0400`). - The sandbox user cannot read this file (different uid), cannot read the gateway process env (`/proc/pid/environ` is uid-gated), and `no-new-privileges` prevents escalation. -- **Non-root mode** (dev/fallback): `/tmp/.runtime/nemoclaw/gateway-token` (`sandbox:sandbox 0400`). - Without uid separation the sandbox user owns the file, matching the pre-externalization security posture. - The token is not exported to the shell env or written to rc files. - -The container mounts `.openclaw` read-only while writable agent state (plugins, agent data) lives in `/sandbox/.openclaw-data` through symlinks. +The `/sandbox/.openclaw` directory contains the OpenClaw gateway configuration, including auth tokens and CORS settings. +The container mounts it read-only while writable agent state (plugins, agent data) lives in `/sandbox/.openclaw-data` through symlinks. Multiple defense layers protect this directory: @@ -228,13 +218,12 @@ Multiple defense layers protect this directory: - **Immutable flag.** The entrypoint applies `chattr +i` to the directory and all symlinks, preventing modification even if other controls fail. - **Symlink validation.** At startup, the entrypoint verifies every symlink in `.openclaw` points to the expected `.openclaw-data` target. If any symlink points elsewhere, the container refuses to start. - **Config integrity hash.** The build process pins a SHA256 hash of `openclaw.json`. The entrypoint verifies it at startup and refuses to start if the hash does not match. -- **Externalized gateway token.** The gateway auth token never appears in `openclaw.json`. It is generated at container startup, written to a mode-dependent token file, and passed to the gateway process via an environment variable. In root mode, the token file is owned by the `gateway` user and unreadable by the sandbox agent. | Aspect | Detail | |---|---| -| Default | The container mounts `/sandbox/.openclaw` as read-only, root-owned, immutable, and integrity-verified at startup. `/sandbox/.openclaw-data` remains writable. The gateway auth token is stored separately: `/run/nemoclaw/gateway-token` in root mode (`gateway:gateway 0400`) or `/tmp/.runtime/nemoclaw/gateway-token` in non-root mode (`sandbox:sandbox 0400`). | +| Default | The container mounts `/sandbox/.openclaw` as read-only, root-owned, immutable, and integrity-verified at startup. `/sandbox/.openclaw-data` remains writable. | | What you can change | Move `/sandbox/.openclaw` from `read_only` to `read_write` in the policy file. | -| Risk if relaxed | A writable `.openclaw` directory lets the agent modify its own gateway config: disabling CORS or redirecting inference to an attacker-controlled endpoint. This is the single most dangerous filesystem change. | +| Risk if relaxed | A writable `.openclaw` directory lets the agent modify its own gateway config: disabling CORS, changing auth tokens, or redirecting inference to an attacker-controlled endpoint. This is the single most dangerous filesystem change. | | Recommendation | Never make `/sandbox/.openclaw` writable. | ### Writable Paths diff --git a/Dockerfile b/Dockerfile index 4201b2d61cf..2126222b662 100644 --- a/Dockerfile +++ b/Dockerfile @@ -227,9 +227,8 @@ ARG NEMOCLAW_DISCORD_GUILDS_B64=e30= # Set to "1" to disable device-pairing auth (development/headless only). # Default: "0" (device auth enabled — secure by default). ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0 -# Unique per build to bust Docker cache for config materialization layers. +# Unique per build to ensure each image gets a fresh auth token. # Pass --build-arg NEMOCLAW_BUILD_ID=$(date +%s) to bust the cache. -# Gateway auth token is generated at container startup by the entrypoint. ARG NEMOCLAW_BUILD_ID=default # Sandbox egress proxy host/port. Defaults match the OpenShell-injected # gateway (10.200.0.1:3128). Operators on non-default networks can override @@ -268,18 +267,11 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ WORKDIR /sandbox USER sandbox -# Write openclaw.json with gateway config but WITHOUT the real auth token. -# The gateway auth token is generated at container startup by the entrypoint -# and passed via OPENCLAW_GATEWAY_TOKEN env var only to the gateway process -# (running as 'gateway' user). The token file location depends on startup mode: -# Root mode: /run/nemoclaw/gateway-token (gateway:gateway 0400) -# Non-root mode: $XDG_RUNTIME_DIR/nemoclaw/gateway-token (sandbox:sandbox 0400) -# In root mode the sandbox user cannot read the env var (/proc/pid/environ is -# uid-gated) or the file (wrong uid, no-new-privileges blocks escalation). -# See: scripts/nemoclaw-start.sh generate_gateway_token() -# +# Write the COMPLETE openclaw.json including gateway config and auth token. # This file is immutable at runtime (Landlock read-only on /sandbox/.openclaw). +# No runtime writes to openclaw.json are needed or possible. # Build args (NEMOCLAW_MODEL, CHAT_UI_URL) customize per deployment. +# Auth token is generated per build so each image has a unique token. # # Temporary workaround for NemoClaw#1738: the OpenClaw Discord extension's # gateway uses `ws` (via @buape/carbon), which ignores HTTPS_PROXY/HTTP_PROXY @@ -291,8 +283,8 @@ USER sandbox # the OpenShell proxy. Mirror of the Telegram treatment immediately below. # Remove once OpenClaw lands an env-var-honouring fix for the Discord # gateway equivalent to openclaw/openclaw#62878 (Slack Socket Mode). -RUN python3 -c "\ -import base64, json, os; \ +RUN NEMOCLAW_BUILD_ID="${NEMOCLAW_BUILD_ID}" python3 -c "\ +import base64, json, os, secrets; \ from urllib.parse import urlparse; \ proxy_url = f\"http://{os.environ['NEMOCLAW_PROXY_HOST']}:{os.environ['NEMOCLAW_PROXY_PORT']}\"; \ model = os.environ['NEMOCLAW_MODEL']; \ @@ -341,7 +333,7 @@ config = { \ 'allowedOrigins': origins, \ }, \ 'trustedProxies': ['127.0.0.1', '::1'], \ - 'auth': {'token': ''} \ + 'auth': {'token': secrets.token_hex(32)} \ } \ }; \ config.update({ \ @@ -364,17 +356,6 @@ os.chmod(path, 0o600)" RUN openclaw doctor --fix > /dev/null 2>&1 || true \ && openclaw plugins install /opt/nemoclaw > /dev/null 2>&1 || true -# SECURITY: Clear any gateway auth token that openclaw doctor/plugins may have -# auto-generated. The real token is created at container startup by the -# entrypoint (generate_gateway_token) and never stored in openclaw.json. -RUN python3 -c "\ -import json, os; \ -path = os.path.expanduser('~/.openclaw/openclaw.json'); \ -cfg = json.load(open(path)); \ -cfg.setdefault('gateway', {}).setdefault('auth', {})['token'] = ''; \ -json.dump(cfg, open(path, 'w'), indent=2); \ -os.chmod(path, 0o600)" - # Lock openclaw.json via DAC: chown to root so the sandbox user cannot modify # it at runtime. This works regardless of Landlock enforcement status. # The Landlock policy (/sandbox/.openclaw in read_only) provides defense-in-depth diff --git a/docs/security/best-practices.md b/docs/security/best-practices.md index 8de6efe2682..b0f4c6a082c 100644 --- a/docs/security/best-practices.md +++ b/docs/security/best-practices.md @@ -229,18 +229,8 @@ The container mounts system directories read-only to prevent the agent from modi ### Read-Only `.openclaw` Config -The `/sandbox/.openclaw` directory contains the OpenClaw gateway configuration (model routing, CORS settings, channel config). -The gateway auth token is **not** stored in this directory — it is generated at container startup and passed via the `OPENCLAW_GATEWAY_TOKEN` environment variable only to the gateway process (which runs as the `gateway` user). - -The token file location depends on the startup mode: - -- **Root mode** (production): `/run/nemoclaw/gateway-token` (`gateway:gateway 0400`). - The sandbox user cannot read this file (different uid), cannot read the gateway process env (`/proc/pid/environ` is uid-gated), and `no-new-privileges` prevents escalation. -- **Non-root mode** (dev/fallback): `/tmp/.runtime/nemoclaw/gateway-token` (`sandbox:sandbox 0400`). - Without uid separation the sandbox user owns the file, matching the pre-externalization security posture. - The token is not exported to the shell env or written to rc files. - -The container mounts `.openclaw` read-only while writable agent state (plugins, agent data) lives in `/sandbox/.openclaw-data` through symlinks. +The `/sandbox/.openclaw` directory contains the OpenClaw gateway configuration, including auth tokens and CORS settings. +The container mounts it read-only while writable agent state (plugins, agent data) lives in `/sandbox/.openclaw-data` through symlinks. Multiple defense layers protect this directory: @@ -248,13 +238,12 @@ Multiple defense layers protect this directory: - **Immutable flag.** The entrypoint applies `chattr +i` to the directory and all symlinks, preventing modification even if other controls fail. - **Symlink validation.** At startup, the entrypoint verifies every symlink in `.openclaw` points to the expected `.openclaw-data` target. If any symlink points elsewhere, the container refuses to start. - **Config integrity hash.** The build process pins a SHA256 hash of `openclaw.json`. The entrypoint verifies it at startup and refuses to start if the hash does not match. -- **Externalized gateway token.** The gateway auth token never appears in `openclaw.json`. It is generated at container startup, written to a mode-dependent token file, and passed to the gateway process via an environment variable. In root mode, the token file is owned by the `gateway` user and unreadable by the sandbox agent. | Aspect | Detail | |---|---| -| Default | The container mounts `/sandbox/.openclaw` as read-only, root-owned, immutable, and integrity-verified at startup. `/sandbox/.openclaw-data` remains writable. The gateway auth token is stored separately: `/run/nemoclaw/gateway-token` in root mode (`gateway:gateway 0400`) or `/tmp/.runtime/nemoclaw/gateway-token` in non-root mode (`sandbox:sandbox 0400`). | +| Default | The container mounts `/sandbox/.openclaw` as read-only, root-owned, immutable, and integrity-verified at startup. `/sandbox/.openclaw-data` remains writable. | | What you can change | Move `/sandbox/.openclaw` from `read_only` to `read_write` in the policy file. | -| Risk if relaxed | A writable `.openclaw` directory lets the agent modify its own gateway config: disabling CORS or redirecting inference to an attacker-controlled endpoint. This is the single most dangerous filesystem change. | +| Risk if relaxed | A writable `.openclaw` directory lets the agent modify its own gateway config: disabling CORS, changing auth tokens, or redirecting inference to an attacker-controlled endpoint. This is the single most dangerous filesystem change. | | Recommendation | Never make `/sandbox/.openclaw` writable. | ### Writable Paths diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 0295259cd53..466c465ada1 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -466,43 +466,6 @@ PYSLACK printf '[channels] Config hash recomputed after Slack token override\n' >&2 } -# ── Gateway auth token (externalized) ────────────────────────── -# The gateway auth token is NOT stored in openclaw.json. It is generated -# at container startup and passed as OPENCLAW_GATEWAY_TOKEN env var only -# to the gateway process launch line. OpenClaw reads this natively via -# its resolveGatewayCredentialsFromValues() path. -# -# Token file location depends on startup mode: -# Root mode: /run/nemoclaw/gateway-token (gateway:gateway 0400) -# Host reads via kubectl exec (runs as root in pod). -# Sandbox user cannot access: wrong uid, /proc/pid/environ -# is uid-gated, no-new-privileges blocks escalation. -# Non-root mode: $XDG_RUNTIME_DIR/nemoclaw/gateway-token (sandbox:sandbox 0400) -# Host reads via openshell sandbox download (sandbox user). -# No uid isolation — matches pre-externalization posture. -# -# Both paths regenerate the token on every container start. -GATEWAY_TOKEN_DIR="/run/nemoclaw" -GATEWAY_TOKEN_FILE="${GATEWAY_TOKEN_DIR}/gateway-token" - -generate_gateway_token() { - [ "$(id -u)" -eq 0 ] || { - printf '[SECURITY] generate_gateway_token requires root — skipping\n' >&2 - return 1 - } - - mkdir -p "$GATEWAY_TOKEN_DIR" - chmod 755 "$GATEWAY_TOKEN_DIR" - - python3 -c "import secrets; print(secrets.token_hex(32), end='')" \ - >"$GATEWAY_TOKEN_FILE" - - chown gateway:gateway "$GATEWAY_TOKEN_FILE" - chmod 400 "$GATEWAY_TOKEN_FILE" - printf '[token] Gateway auth token generated at %s (gateway:gateway 0400)\n' \ - "$GATEWAY_TOKEN_FILE" >&2 -} - # ── Slack channel guard (unhandled-rejection safety net) ───────── # Prevents the gateway from crashing when a Slack channel fails to # initialize (e.g., invalid_auth, token_revoked, unresolved placeholder @@ -638,10 +601,74 @@ SLACK_GUARD_EOF } _read_gateway_token() { - # Read the gateway token from the externalized file. - # Callable by root (entrypoint) and gateway user only. - # Returns the token on stdout; empty output means no token. - cat "$GATEWAY_TOKEN_FILE" 2>/dev/null || true + python3 - <<'PYTOKEN' +import json +try: + with open('/sandbox/.openclaw/openclaw.json') as f: + cfg = json.load(f) + print(cfg.get('gateway', {}).get('auth', {}).get('token', '')) +except Exception: + print('') +PYTOKEN +} + +export_gateway_token() { + local token + token="$(_read_gateway_token)" + local marker_begin="# nemoclaw-gateway-token begin" + local marker_end="# nemoclaw-gateway-token end" + + if [ -z "$token" ]; then + # Remove any stale marker blocks from rc files so revoked/old tokens + # are not re-exported in later interactive sessions. + unset OPENCLAW_GATEWAY_TOKEN + for rc_file in "${_SANDBOX_HOME}/.bashrc" "${_SANDBOX_HOME}/.profile"; do + if [ -f "$rc_file" ] && grep -qF "$marker_begin" "$rc_file" 2>/dev/null; then + local tmp + tmp="$(mktemp)" || continue + awk -v b="$marker_begin" -v e="$marker_end" \ + '$0==b{s=1;next} $0==e{s=0;next} !s' "$rc_file" >"$tmp" 2>/dev/null || { + rm -f "$tmp" + continue + } + cat "$tmp" >"$rc_file" 2>/dev/null || true + rm -f "$tmp" + fi + done + return + fi + export OPENCLAW_GATEWAY_TOKEN="$token" + + # Persist to .bashrc/.profile so interactive sessions (openshell sandbox + # connect) also see the token — same pattern as the proxy config above. + # Shell-escape the token so quotes/dollars/backticks cannot break the + # sourced snippet or allow code injection. + local escaped_token + escaped_token="$(printf '%s' "$token" | sed "s/'/'\\\\''/g")" + local snippet + snippet="${marker_begin} +export OPENCLAW_GATEWAY_TOKEN='${escaped_token}' +${marker_end}" + + for rc_file in "${_SANDBOX_HOME}/.bashrc" "${_SANDBOX_HOME}/.profile"; do + [ -f "$rc_file" ] || continue + # All writes use || true because Landlock may block writes even though + # DAC (-w) says writable (#804) — same pattern as install_configure_guard. + if grep -qF "$marker_begin" "$rc_file" 2>/dev/null; then + local tmp + tmp="$(mktemp)" || continue + awk -v b="$marker_begin" -v e="$marker_end" \ + '$0==b{s=1;next} $0==e{s=0;next} !s' "$rc_file" >"$tmp" 2>/dev/null || { + rm -f "$tmp" + continue + } + printf '%s\n' "$snippet" >>"$tmp" + cat "$tmp" >"$rc_file" 2>/dev/null || true + rm -f "$tmp" + else + printf '\n%s\n' "$snippet" >>"$rc_file" 2>/dev/null || true + fi + done } install_configure_guard() { @@ -1393,19 +1420,7 @@ if [ "$(id -u)" -ne 0 ]; then apply_model_override apply_cors_override apply_slack_token_override - # Non-root: no privilege separation — uid separation is unavailable, so the - # sandbox user can read the token file. This is no worse than the pre-PR - # state where the token lived in openclaw.json (also sandbox-readable). - # Write the token to a restrictive file (0400) so it is not world-readable, - # and pass it on the gateway launch line (not exported to the shell env). - _NONROOT_GATEWAY_TOKEN="$(python3 -c "import secrets; print(secrets.token_hex(32), end='')")" - _NONROOT_TOKEN_DIR="${XDG_RUNTIME_DIR:-/tmp}/nemoclaw" - _NONROOT_TOKEN_FILE="${_NONROOT_TOKEN_DIR}/gateway-token" - mkdir -p "$_NONROOT_TOKEN_DIR" - rm -f "$_NONROOT_TOKEN_FILE" - printf '%s' "$_NONROOT_GATEWAY_TOKEN" >"$_NONROOT_TOKEN_FILE" - chmod 0400 "$_NONROOT_TOKEN_FILE" - printf '[SECURITY] Non-root mode — gateway token at %s (no uid isolation)\n' "$_NONROOT_TOKEN_FILE" >&2 + export_gateway_token install_configure_guard configure_messaging_channels install_slack_channel_guard @@ -1495,11 +1510,8 @@ if [ "$(id -u)" -ne 0 ]; then # inject code into any Node process via NODE_OPTIONS). validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_CIAO_GUARD_SCRIPT" "$_SLACK_GUARD_SCRIPT" - # Start gateway in background, auto-pair, then wait. - # Pass OPENCLAW_GATEWAY_TOKEN only on this launch line so it lives solely - # in the gateway process env — not exported to the sandbox shell. - OPENCLAW_GATEWAY_TOKEN="$_NONROOT_GATEWAY_TOKEN" \ - nohup "$OPENCLAW" gateway run --port "${_DASHBOARD_PORT}" >/tmp/gateway.log 2>&1 & + # Start gateway in background, auto-pair, then wait + nohup "$OPENCLAW" gateway run --port "${_DASHBOARD_PORT}" >/tmp/gateway.log 2>&1 & GATEWAY_PID=$! echo "[gateway] openclaw gateway launched (pid $GATEWAY_PID)" >&2 start_auto_pair @@ -1524,7 +1536,7 @@ verify_config_integrity /sandbox/.openclaw apply_model_override apply_cors_override apply_slack_token_override -generate_gateway_token +export_gateway_token install_configure_guard # Inject messaging channel config if provider tokens are present. @@ -1640,10 +1652,7 @@ validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON # SECURITY: The sandbox user cannot kill this process because it runs # under a different UID. The fake-HOME attack no longer works because # the agent cannot restart the gateway with a tampered config. -# SECURITY: OPENCLAW_GATEWAY_TOKEN is passed only to the gateway process -# env — the sandbox user cannot read /proc//environ (different uid). -OPENCLAW_GATEWAY_TOKEN="$(_read_gateway_token)" \ - nohup gosu gateway "$OPENCLAW" gateway run --port "${_DASHBOARD_PORT}" >/tmp/gateway.log 2>&1 & +nohup gosu gateway "$OPENCLAW" gateway run --port "${_DASHBOARD_PORT}" >/tmp/gateway.log 2>&1 & GATEWAY_PID=$! echo "[gateway] openclaw gateway launched as 'gateway' user (pid $GATEWAY_PID)" >&2 diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 8567fe81446..085e9ba91a4 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -9,7 +9,7 @@ const crypto = require("node:crypto"); const fs = require("fs"); const os = require("os"); const path = require("path"); -const { execFileSync, spawn, spawnSync } = require("child_process"); +const { spawn, spawnSync } = require("child_process"); const pRetry = require("p-retry"); /** Parse a numeric env var, returning `fallback` when unset or non-finite. */ @@ -6791,21 +6791,6 @@ function ensureDashboardForward( } } -function findFileRecursive(dir: string, filename: string): string | null { - if (!fs.existsSync(dir)) return null; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const e of entries) { - const p = path.join(dir, e.name); - if (e.isDirectory()) { - const found = findFileRecursive(p, filename); - if (found) return found; - } else if (e.name === filename) { - return p; - } - } - return null; -} - function findOpenclawJsonPath(dir: string): string | null { if (!fs.existsSync(dir)) return null; const entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -6822,64 +6807,13 @@ function findOpenclawJsonPath(dir: string): string | null { } /** - * Pull gateway auth token from the sandbox. - * - * Tries three retrieval paths in order: - * 1. kubectl exec cat /run/nemoclaw/gateway-token (root mode — gateway:gateway 0400) - * 2. sandbox download /tmp/.runtime/nemoclaw/gateway-token (non-root mode — sandbox:sandbox 0400) - * 3. sandbox download openclaw.json → gateway.auth.token (pre-externalization images) - * - * Path 1 uses the same kubectl-via-K3s pattern as shields.ts — it runs as - * root inside the pod so it can read gateway-owned files. - * Path 2 works because sandbox download runs as the sandbox user, which owns - * the non-root token file. + * Pull gateway.auth.token from the sandbox image via openshell sandbox download + * so onboard can print copy-paste Control UI URLs with #token= (same idea as nemoclaw-start.sh). */ function fetchGatewayAuthTokenFromSandbox(sandboxName: string): string | null { - // 1. Root mode: kubectl exec reads gateway:gateway 0400 file (same as shields.ts) - try { - const k3sContainer = "openshell-cluster-nemoclaw"; - const result = execFileSync( - "docker", - [ - "exec", - k3sContainer, - "kubectl", - "exec", - "-n", - "openshell", - sandboxName, - "-c", - "agent", - "--", - "cat", - "/run/nemoclaw/gateway-token", - ], - { stdio: ["ignore", "pipe", "pipe"], timeout: 15000 }, - ); - const token = result.toString().trim(); - if (token.length > 0) return token; - } catch { - // kubectl exec not available or file absent — fall through - } - - // 2. Non-root mode: token at $XDG_RUNTIME_DIR/nemoclaw/gateway-token - // (sandbox-owned, downloadable via openshell sandbox download) const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-token-")); try { const destDir = `${tmpDir}${path.sep}`; - const nonRootResult = runOpenshell( - ["sandbox", "download", sandboxName, "/tmp/.runtime/nemoclaw/gateway-token", destDir], - { ignoreError: true, stdio: ["ignore", "ignore", "ignore"] }, - ); - if (nonRootResult.status === 0) { - const tokenPath = findFileRecursive(tmpDir, "gateway-token"); - if (tokenPath) { - const token = fs.readFileSync(tokenPath, "utf-8").trim(); - if (token.length > 0) return token; - } - } - - // 3. Legacy: openclaw.json (pre-externalization images) const result = runOpenshell( ["sandbox", "download", sandboxName, "/sandbox/.openclaw/openclaw.json", destDir], { ignoreError: true, stdio: ["ignore", "ignore", "ignore"] }, @@ -7145,7 +7079,9 @@ function printDashboard( for (const entry of dashboardAccess) { console.log(` ${entry.label}: ${entry.url}`); } - console.log(` Token: see /tmp/gateway.log inside the sandbox, or re-run onboard.`); + console.log( + ` Token: nemoclaw ${sandboxName} connect → jq -r '.gateway.auth.token' /sandbox/.openclaw/openclaw.json`, + ); console.log( ` append #token= to the URL, or see /tmp/gateway.log inside the sandbox.`, ); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index b6e4a0e93fc..d89268b18f4 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -99,6 +99,13 @@ describe("nemoclaw-start _SANDBOX_HOME variable (#1609)", () => { } }); + it("uses _SANDBOX_HOME for rc file paths in export_gateway_token", () => { + const exportFn = src.match(/export_gateway_token\(\) \{([\s\S]*?)^\}/m); + expect(exportFn).toBeTruthy(); + expect(exportFn[1]).toContain("${_SANDBOX_HOME}/.bashrc"); + expect(exportFn[1]).toContain("${_SANDBOX_HOME}/.profile"); + }); + it("uses _SANDBOX_HOME for rc file paths in install_configure_guard", () => { const guardFn = src.match( /install_configure_guard\(\) \{([\s\S]*?)^validate_openclaw_symlinks/m, @@ -109,174 +116,50 @@ describe("nemoclaw-start _SANDBOX_HOME variable (#1609)", () => { }); }); -describe("nemoclaw-start externalized gateway token", () => { +describe("nemoclaw-start gateway token export (#1114)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - it("defines generate_gateway_token that writes to GATEWAY_TOKEN_FILE", () => { - const fn = src.match(/generate_gateway_token\(\) \{([\s\S]*?)^\}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("GATEWAY_TOKEN_FILE"); - expect(fn[1]).toContain("secrets.token_hex(32)"); - expect(fn[1]).toContain("chown gateway:gateway"); - expect(fn[1]).toContain("chmod 400"); - }); - - it("defines GATEWAY_TOKEN_FILE at /run/nemoclaw/gateway-token", () => { - expect(src).toContain('GATEWAY_TOKEN_FILE="${GATEWAY_TOKEN_DIR}/gateway-token"'); - expect(src).toContain('GATEWAY_TOKEN_DIR="/run/nemoclaw"'); - }); - - it("defines _read_gateway_token that reads from the token file", () => { - const fn = src.match(/_read_gateway_token\(\) \{([\s\S]*?)^\}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("GATEWAY_TOKEN_FILE"); - }); - - it("passes OPENCLAW_GATEWAY_TOKEN env var only on gateway launch line", () => { - expect(src).toMatch( - /OPENCLAW_GATEWAY_TOKEN="\$\(_read_gateway_token\)"[\s\\]*\n\s*nohup gosu gateway/, - ); - }); - - it("does not export token to sandbox user shell env", () => { - // export_gateway_token must not exist — token must never reach .bashrc - expect(src).not.toMatch(/^export_gateway_token\(\)/m); - }); - - it("print_dashboard_urls uses _read_gateway_token", () => { + it("defines _read_gateway_token helper used by both export and dashboard", () => { + expect(src).toMatch(/_read_gateway_token\(\) \{/); + // export_gateway_token calls the helper + expect(src).toMatch(/token="\$\(_read_gateway_token\)"/); + // print_dashboard_urls also calls the helper const dashboardFn = src.match(/print_dashboard_urls\(\) \{([\s\S]*?)^\}/m); expect(dashboardFn).toBeTruthy(); expect(dashboardFn[1]).toContain("_read_gateway_token"); }); - it("calls generate_gateway_token in root path", () => { - const rootBlock = src.match( - /# ── Root path[\s\S]*?generate_gateway_token/, - ); - expect(rootBlock).toBeTruthy(); - }); - - it("generates a token in non-root mode for gateway auth", () => { - // The non-root path generates a token for the gateway - expect(src).toContain("_NONROOT_GATEWAY_TOKEN"); - expect(src).toContain('OPENCLAW_GATEWAY_TOKEN="$_NONROOT_GATEWAY_TOKEN"'); + it("uses with-open context manager in the Python snippet", () => { + const helperFn = src.match(/_read_gateway_token\(\) \{([\s\S]*?)^\}/m); + expect(helperFn).toBeTruthy(); + expect(helperFn[1]).toContain("with open("); }); - it("writes non-root token file with restrictive permissions", () => { - // Non-root token file uses XDG_RUNTIME_DIR, not /run - expect(src).toContain('_NONROOT_TOKEN_DIR="${XDG_RUNTIME_DIR:-/tmp}/nemoclaw"'); - // File must be locked down to 0400 - expect(src).toContain('chmod 0400 "$_NONROOT_TOKEN_FILE"'); + it("unsets stale OPENCLAW_GATEWAY_TOKEN when token is empty", () => { + const exportFn = src.match(/export_gateway_token\(\) \{([\s\S]*?)^\}/m); + expect(exportFn).toBeTruthy(); + const body = exportFn[1]; + // Must unset before returning on empty token + const unsetPos = body.indexOf("unset OPENCLAW_GATEWAY_TOKEN"); + const returnPos = body.indexOf("return"); + expect(unsetPos).toBeGreaterThan(-1); + expect(returnPos).toBeGreaterThan(-1); + expect(unsetPos).toBeLessThan(returnPos); }); - it("does not export token to sandbox shell env", () => { - expect(src).not.toMatch(/export OPENCLAW_GATEWAY_TOKEN/); + it("shell-escapes the token before embedding in rc snippet", () => { + const exportFn = src.match(/export_gateway_token\(\) \{([\s\S]*?)^\}/m); + expect(exportFn).toBeTruthy(); + const body = exportFn[1]; + // Must use single quotes around the escaped token value + expect(body).toContain("escaped_token"); + expect(body).toMatch(/export OPENCLAW_GATEWAY_TOKEN='\$\{escaped_token\}'/); }); -}); - -describe("Dockerfile gateway token externalization", () => { - const dockerfile = fs.readFileSync( - path.join(import.meta.dirname, "..", "Dockerfile"), - "utf-8", - ); - it("writes empty token in initial openclaw.json config", () => { - expect(dockerfile).toContain("'auth': {'token': ''}"); - }); - - it("clears any auto-generated token after openclaw doctor/plugins", () => { - // openclaw doctor --fix may auto-generate a gateway token when it finds - // an empty one. A post-doctor step must re-clear it so the token is never - // baked into the image. Verify the clearing step comes AFTER doctor. - const doctorIdx = dockerfile.indexOf("openclaw doctor --fix"); - const clearIdx = dockerfile.indexOf("cfg.setdefault('gateway', {}).setdefault('auth', {})['token'] = ''"); - expect(doctorIdx).toBeGreaterThan(-1); - expect(clearIdx).toBeGreaterThan(-1); - expect(clearIdx).toBeGreaterThan(doctorIdx); - }); - - it("pins config hash after token is cleared", () => { - const clearIdx = dockerfile.indexOf("['token'] = ''"); - const hashIdx = dockerfile.indexOf("sha256sum /sandbox/.openclaw/openclaw.json"); - // Both must exist and hash must come after the clear step - expect(clearIdx).toBeGreaterThan(-1); - expect(hashIdx).toBeGreaterThan(-1); - expect(hashIdx).toBeGreaterThan(clearIdx); - }); -}); - -describe("gateway token security regression tests", () => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); - const dockerfile = fs.readFileSync( - path.join(import.meta.dirname, "..", "Dockerfile"), - "utf-8", - ); - - it("openclaw.json never contains a non-empty gateway auth token at build time", () => { - // The Dockerfile must write an empty token, clear after doctor, then pin hash. - // At no point should a real token survive into the final image layer. - expect(dockerfile).toContain("'auth': {'token': ''}"); - // Post-doctor clearing step exists - expect(dockerfile).toContain("cfg.setdefault('gateway', {}).setdefault('auth', {})['token'] = ''"); - }); - - it("gateway process runs under a distinct uid via gosu in root mode", () => { - // The gateway must run as the 'gateway' user, not as sandbox or root. - // This ensures /proc//environ is uid-gated from the sandbox user. - expect(src).toMatch(/gosu gateway.*gateway run/); - }); - - it("sandbox shell env and rc files never receive the gateway token", () => { - // No global export of OPENCLAW_GATEWAY_TOKEN - expect(src).not.toMatch(/export OPENCLAW_GATEWAY_TOKEN/); - // Old export_gateway_token function must not exist (wrote to .bashrc/.profile) - expect(src).not.toMatch(/^export_gateway_token\(\)/m); - // No marker blocks for token in rc files - expect(src).not.toContain("nemoclaw-gateway-token begin"); - }); - - it("non-root token file uses restrictive permissions (0400)", () => { - expect(src).toContain('chmod 0400 "$_NONROOT_TOKEN_FILE"'); - }); - - it("non-root token file is removed before rewrite to prevent stale reads", () => { - // rm -f before write prevents reading a stale token from a previous start - const rmIdx = src.indexOf('rm -f "$_NONROOT_TOKEN_FILE"'); - const writeIdx = src.indexOf('printf \'%s\' "$_NONROOT_GATEWAY_TOKEN" >"$_NONROOT_TOKEN_FILE"'); - expect(rmIdx).toBeGreaterThan(-1); - expect(writeIdx).toBeGreaterThan(-1); - expect(writeIdx).toBeGreaterThan(rmIdx); - }); - - it("host-side token retrieval tries three paths in the correct order", () => { - const onboardSrc = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), - "utf-8", - ); - const fn = onboardSrc.match( - /function fetchGatewayAuthTokenFromSandbox[\s\S]*?^}/m, - ); - expect(fn).toBeTruthy(); - const body = fn[0]; - // Path 1: kubectl exec for root-mode token - const kubectlIdx = body.indexOf("/run/nemoclaw/gateway-token"); - // Path 2: sandbox download for non-root token - const nonRootIdx = body.indexOf("/tmp/.runtime/nemoclaw/gateway-token"); - // Path 3: legacy openclaw.json fallback - const legacyIdx = body.indexOf("openclaw.json"); - expect(kubectlIdx).toBeGreaterThan(-1); - expect(nonRootIdx).toBeGreaterThan(-1); - expect(legacyIdx).toBeGreaterThan(-1); - // Correct order: kubectl → non-root download → legacy - expect(nonRootIdx).toBeGreaterThan(kubectlIdx); - expect(legacyIdx).toBeGreaterThan(nonRootIdx); - }); - - it("entrypoint documents both root and non-root token paths", () => { - // Root mode path documented - expect(src).toContain("Root mode: /run/nemoclaw/gateway-token"); - // Non-root mode path documented - expect(src).toContain("Non-root mode: $XDG_RUNTIME_DIR/nemoclaw/gateway-token"); + it("calls export_gateway_token in both root and non-root paths", () => { + const calls = src.match(/export_gateway_token/g) || []; + // definition + 2 call sites + expect(calls.length).toBeGreaterThanOrEqual(3); }); }); @@ -421,12 +304,12 @@ describe("runtime model override (#759)", () => { const nonRootBlock = src.match(/if \[ "\$\(id -u\)" -ne 0 \]; then([\s\S]*?)# ── Root path/); expect(nonRootBlock).toBeTruthy(); expect(nonRootBlock[1]).toMatch( - /verify_config_integrity[\s\S]*?apply_model_override/, + /verify_config_integrity[\s\S]*?apply_model_override[\s\S]*?export_gateway_token/, ); - // Root path: verify_config_integrity → apply_model_override → apply_cors_override → generate_gateway_token + // Root path: verify_config_integrity → apply_model_override → apply_cors_override const rootBlock = src.match( - /# ── Root path[\s\S]*?verify_config_integrity[\s\S]*?apply_model_override[\s\S]*?apply_cors_override[\s\S]*?generate_gateway_token/, + /# ── Root path[\s\S]*?verify_config_integrity[\s\S]*?apply_model_override[\s\S]*?apply_cors_override[\s\S]*?export_gateway_token/, ); expect(rootBlock).toBeTruthy(); }); @@ -543,11 +426,11 @@ describe("runtime CORS origin override (#719)", () => { const nonRootBlock = src.match(/if \[ "\$\(id -u\)" -ne 0 \]; then([\s\S]*?)# ── Root path/); expect(nonRootBlock).toBeTruthy(); expect(nonRootBlock[1]).toMatch( - /apply_model_override[\s\S]*?apply_cors_override[\s\S]*?apply_slack_token_override/, + /apply_model_override[\s\S]*?apply_cors_override[\s\S]*?apply_slack_token_override[\s\S]*?export_gateway_token/, ); const rootBlock = src.match( - /# ── Root path[\s\S]*?apply_model_override\n\s*apply_cors_override\n\s*apply_slack_token_override\n\s*generate_gateway_token/, + /# ── Root path[\s\S]*?apply_model_override[\s\S]*?apply_cors_override[\s\S]*?apply_slack_token_override[\s\S]*?export_gateway_token/, ); expect(rootBlock).toBeTruthy(); });