diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 4243f3ae99d..af7eab402b5 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -97,7 +97,11 @@ jobs: openclaw_build_arg="OPENCLAW_VERSION=${OPENCLAW_VERSION_INPUT}" build_args+=(--build-arg "$openclaw_build_arg") fi - scripts/check-production-build-args.sh "${build_args[@]}" + if [ "${#build_args[@]}" -gt 0 ]; then + scripts/check-production-build-args.sh "${build_args[@]}" + else + scripts/check-production-build-args.sh + fi if [ -n "${OPENCLAW_VERSION_INPUT}" ]; then if [[ "$OPENCLAW_VERSION_INPUT" == *$'\r'* || "$OPENCLAW_VERSION_INPUT" == *$'\n'* ]]; then echo "ERROR: OpenClaw version must not contain CR or LF characters." >&2 diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 521dc15f111..f832aa17e88 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -59,7 +59,7 @@ jobs: real-openclaw-dist-harness: runs-on: ubuntu-latest - timeout-minutes: 12 + timeout-minutes: 20 env: # This required proof reads reviewed npm metadata/tarballs. Keep npm's # transient-registry retry policy explicit at the hard merge boundary. @@ -86,6 +86,11 @@ jobs: NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS: "1" run: npx vitest run --project integration test/openclaw-real-patched-dist-harness.test.ts --silent=false --reporter=default + - name: Audit managed OpenClaw security finding suppressions + env: + NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS: "1" + run: npx vitest run --project integration test/openclaw-security-audit-suppressions-real.test.ts --silent=false --reporter=default + cli-test-shards: runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/Dockerfile b/Dockerfile index 87e65d708b5..cdde20c53b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -838,6 +838,10 @@ ARG NEMOCLAW_UPSTREAM_PROVIDER= ARG NEMOCLAW_PRIMARY_MODEL_REF=inference/nvidia/nemotron-3-super-120b-a12b # Default dashboard port 18789 — override at runtime via NEMOCLAW_DASHBOARD_PORT. ARG CHAT_UI_URL=http://127.0.0.1:18789 +ARG NEMOCLAW_DASHBOARD_BIND= +# Internal audit provenance for WSL's default all-interface dashboard forward. +# Onboarding rewrites this for managed OpenClaw images built on WSL. +ARG NEMOCLAW_WSL_DASHBOARD_EXPOSURE=0 ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1 ARG NEMOCLAW_INFERENCE_API=openai-completions ARG NEMOCLAW_CONTEXT_WINDOW=131072 @@ -876,6 +880,9 @@ ARG NEMOCLAW_EXTRA_AGENTS_JSON_B64=W10= # since terminal-based pairing is impossible in those contexts. # Default: "0" (device auth enabled for local deployments — secure by default). ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0 +# Internal audit provenance for the opt-out above. Standard onboarding rewrites +# this to managed-onboard; direct image builders retain operator provenance. +ARG NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE=operator # Compatibility build arg for older custom Dockerfiles and rebuild tooling. # NemoClaw-managed images intentionally do not consume it; gateway auth tokens # are generated at container startup and are never baked into image layers. @@ -932,7 +939,10 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_MESSAGING_PLAN_B64=${NEMOCLAW_MESSAGING_PLAN_B64} \ NEMOCLAW_EXTRA_AGENTS_JSON_B64=${NEMOCLAW_EXTRA_AGENTS_JSON_B64} \ NEMOCLAW_OPENCLAW_WECHAT_PLUGIN_PREINSTALLED=1 \ + NEMOCLAW_DASHBOARD_BIND=${NEMOCLAW_DASHBOARD_BIND} \ + NEMOCLAW_WSL_DASHBOARD_EXPOSURE=${NEMOCLAW_WSL_DASHBOARD_EXPOSURE} \ NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \ + NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE=${NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE} \ NEMOCLAW_PROXY_HOST=${NEMOCLAW_PROXY_HOST} \ NEMOCLAW_PROXY_PORT=${NEMOCLAW_PROXY_PORT} \ NEMOCLAW_WEB_SEARCH_ENABLED=${NEMOCLAW_WEB_SEARCH_ENABLED} \ diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 5081f84de5e..fc8e608f231 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -151,7 +151,8 @@ Use these details when your first-run path needs more control. - The dashboard binds to `127.0.0.1` on the host running NemoClaw. + Outside WSL, the dashboard forward binds to `127.0.0.1` on the host running NemoClaw. + On WSL, it binds on all interfaces so the Windows host can reach it, while the ready summary still prints a loopback dashboard URL. When you connect over SSH, forward the dashboard port from your workstation, substituting the port from the ready summary. ```bash @@ -200,7 +201,8 @@ Use these details when your first-run path needs more control. If it cannot find the binary or blocking host preflight checks fail, it prints diagnostics and a `To finish setup, run:` block with the explicit `nemoclaw onboard` command. - Onboarding builds the sandbox image with `NEMOCLAW_DISABLE_DEVICE_AUTH=1` so the dashboard is usable during setup. + Onboarding builds the sandbox image with a managed `NEMOCLAW_DISABLE_DEVICE_AUTH=1` compatibility setting so the dashboard is usable during setup. + NemoClaw records that this value came from onboarding rather than reporting it as an operator-selected opt-out. This build-time setting is baked into the image and setting it after onboarding does not affect an existing sandbox. diff --git a/docs/manage-sandboxes/lifecycle.mdx b/docs/manage-sandboxes/lifecycle.mdx index 294ee176991..e933ecc1d2a 100644 --- a/docs/manage-sandboxes/lifecycle.mdx +++ b/docs/manage-sandboxes/lifecycle.mdx @@ -118,6 +118,8 @@ If the forward stopped, or the installer reported that no active forward was fou openshell forward start --background my-gpt-claw ``` +On WSL, use `0.0.0.0:` as the forward target so the Windows host can continue to reach the dashboard. + To list active forwards across all sandboxes, run the following command. ```bash diff --git a/docs/manage-sandboxes/runtime-controls.mdx b/docs/manage-sandboxes/runtime-controls.mdx index 49ef25b239a..76c1247f926 100644 --- a/docs/manage-sandboxes/runtime-controls.mdx +++ b/docs/manage-sandboxes/runtime-controls.mdx @@ -32,7 +32,7 @@ The following table maps each commonly changed item to the layer that owns it an | Channel tokens (Slack / Discord / Telegram bot credentials) | Rebuild required (tokens are baked into the sandbox image at onboard so they never leave the host clear-text) | `$$nemoclaw channels add ` then accept the rebuild prompt | | Channel enable/disable (turn a configured channel off without removing the token) | Rebuild required (`openclaw.json` is the source of truth at runtime, refer to #3453) | `$$nemoclaw channels stop ` then rebuild | | Dashboard forward port | Runtime. Port is re-resolved on next `connect` | `NEMOCLAW_DASHBOARD_PORT= $$nemoclaw connect` | -| Dashboard bind address (loopback compared to all interfaces) | Runtime. Applies on next `connect` | `NEMOCLAW_DASHBOARD_BIND=0.0.0.0 $$nemoclaw connect` (refer to #3259) | +| Dashboard bind address (loopback compared to all interfaces) | Build and runtime. Requires onboarding with the same remote-bind opt-in before `connect` can expose an existing sandbox | `NEMOCLAW_DASHBOARD_BIND=0.0.0.0 $$nemoclaw onboard --recreate-sandbox` for an existing local-only sandbox, then use `NEMOCLAW_DASHBOARD_BIND=0.0.0.0 $$nemoclaw connect` later (refer to #3259) | | Gateway process environment or startup-only plugin state | Runtime after gateway restart | `$$nemoclaw gateway restart` | | Default OpenClaw workspace template seed (`AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`, `TOOLS.md`, `HEARTBEAT.md`) | Locked at first sandbox boot. Re-onboard required to change the bake-time choice. | Set `NEMOCLAW_MINIMAL_BOOTSTRAP=1` before `$$nemoclaw onboard` to skip default template seeding for new/pristine workspaces. **Does not delete files already present.** Partial mitigation for #2598 (cuts ~3k tokens of project-context overhead off OpenClaw's per-turn bootstrap injection). | | Web search provider (Brave, Tavily, or disabled) | Rebuild required. Onboarding bakes the provider plugin configuration and credential attachment into the image. | Set `NEMOCLAW_WEB_SEARCH_PROVIDER=brave`, `tavily`, or `none`, rerun `$$nemoclaw onboard`, and accept recreation or pass `--recreate-sandbox`. | diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 416260dce42..76a1f2a2bc8 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3115,7 +3115,7 @@ Passthrough commands do not consume flags intended for the downstream command as | `NEMOCLAW_OLLAMA_PROXY_PORT` | 11435 | Ollama auth proxy | | `NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT` | 11437 | Host-side OpenRouter runtime adapter | -| `NEMOCLAW_DASHBOARD_BIND` | *unset* (loopback) | Dashboard or API forward bind address. Set to `0.0.0.0` to opt in to remote bind for SSH-deployed hosts. | +| `NEMOCLAW_DASHBOARD_BIND` | *unset* (loopback outside WSL) | Dashboard or API forward bind address. WSL uses an all-interface forward for Windows-host reachability. Set to `0.0.0.0` to opt in to remote bind on other SSH-deployed hosts. | | `NEMOCLAW_GATEWAY_WS_HOST` | *unset* (auto-derived inside the sandbox; loopback elsewhere) | Host used for the in-sandbox `OPENCLAW_GATEWAY_URL`; inside the sandbox it defaults to the primary interface address so `sessions_spawn` sub-agents can dial the gateway through the enforced network path. | @@ -3133,9 +3133,10 @@ If you run Ollama on port 11435, set `NEMOCLAW_OLLAMA_PROXY_PORT` to another fre Keep the OpenShell gateway on loopback and use `NEMOCLAW_DASHBOARD_BIND` when you need remote browser/API access. `NEMOCLAW_DASHBOARD_BIND` controls the dashboard or API port forward bind address. -By default the forward stays on `127.0.0.1` (loopback only). -Set `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` before `$$nemoclaw onboard` (or `$$nemoclaw connect`) to bind the forward on all interfaces, which is useful when the host is reached over SSH or a cloud workstation. -Only `0.0.0.0` enables the remote bind; other values are ignored. +Outside WSL, the forward stays on `127.0.0.1` (loopback only) by default. +On WSL, NemoClaw binds the host-side forward on all interfaces so the Windows host can reach it, while the ready summary continues to print a loopback dashboard URL. +Set `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` before `$$nemoclaw onboard` to prepare the sandbox for remote exposure and bind the forward on all interfaces. Use the same setting for later `$$nemoclaw connect` calls. A sandbox created without this opt-in must be recreated with `NEMOCLAW_DASHBOARD_BIND=0.0.0.0 $$nemoclaw onboard --recreate-sandbox` before a remote-bind connect is allowed. +Only `0.0.0.0` enables the remote bind; onboarding rejects any other non-empty value. diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 8e7041fc627..bcc98013e5f 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -301,6 +301,9 @@ Remote/headless hosts should keep the OpenShell gateway on loopback and bind the NEMOCLAW_DASHBOARD_BIND=0.0.0.0 NEMOCLAW_GATEWAY_PORT=8990 $$nemoclaw onboard ``` +Use `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` again on later `$$nemoclaw connect` calls. +If the sandbox was originally created without remote bind, recreate it with the same onboard command plus `--recreate-sandbox` before connecting remotely. + Docker-driver gateways on OpenShell 0.0.72 reject `NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0` while gateway JWT auth is active. Use `NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0` only on supported gateway modes and only when other hosts on the network should be able to reach the gateway. diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index bcf0f52f1e1..703d6816d04 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -643,10 +643,10 @@ Device authentication requires each connecting device to go through a pairing fl | Aspect | Detail | |---|---| -| Default | Enabled. The gateway requires device pairing for all connections. | -| What you can change | Set `NEMOCLAW_DISABLE_DEVICE_AUTH=1` as a Docker build argument to disable device authentication. This is a build-time setting baked into `openclaw.json` and verified by hash at startup. | +| Default | The base Dockerfile enables device authentication for loopback dashboards. Standard onboarding currently applies a managed compatibility opt-out for immediate dashboard access. NemoClaw also disables device authentication for non-loopback `CHAT_UI_URL` values. | +| What you can change | Outside managed onboarding, set `NEMOCLAW_DISABLE_DEVICE_AUTH=1` only as a deliberate build-time opt-out. The setting and its managed-vs-operator provenance are baked into `openclaw.json` audit metadata and the config is verified by hash at startup. | | Risk if relaxed | Disabling device auth allows any device on the network to connect to the gateway without proving identity. This is dangerous when combined with LAN-bind changes or cloudflared tunnels in remote deployments, resulting in an unauthenticated, publicly reachable dashboard. | -| Recommendation | Keep device auth enabled (the default). Only disable it for headless or development environments where no untrusted devices can reach the gateway. | +| Recommendation | Prefer loopback access or SSH port forwarding so device authentication stays enabled. If a browser-only remote dashboard requires the compatibility setting, use HTTPS and restrict who can reach the dashboard. | ### Gateway Bind Address @@ -655,7 +655,7 @@ NemoClaw binds the OpenShell gateway to loopback by default. | Aspect | Detail | |---|---| | Default | `NEMOCLAW_GATEWAY_BIND_ADDRESS=127.0.0.1`. | -| What you can change | Keep Docker-driver gateways on loopback. Set `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` for remote dashboard/API access. | +| What you can change | Keep Docker-driver gateways on loopback. Set `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` during onboarding and on later `connect` calls for remote dashboard/API access. Recreate a local-only sandbox before changing it to a remote bind. | | Risk if relaxed | Other hosts on the network may be able to reach the OpenShell gateway; Docker-driver gateways on OpenShell 0.0.72 reject wildcard gateway binds while gateway JWT auth is active. | | Recommendation | Keep the gateway loopback default and expose only the dashboard forward when remote access is needed. | @@ -683,6 +683,29 @@ The `allowInsecureAuth` setting controls whether the gateway permits non-HTTPS a | Risk if relaxed | Allowing insecure auth over HTTPS defeats the purpose of TLS, because authentication tokens transit in cleartext. | | Recommendation | Use `https://` for any deployment accessible beyond `localhost`. The default local URL (`http://127.0.0.1:18789`) correctly allows insecure auth for local development. | +OpenClaw's security audit keeps NemoClaw-managed loopback `allowInsecureAuth` findings and provenance-known loopback device-auth opt-out findings visible as accepted findings instead of counting them as unexplained active findings. +Device-auth findings record whether the opt-out came from NemoClaw's managed onboarding compatibility behavior or an operator-provided `NEMOCLAW_DISABLE_DEVICE_AUTH=1`; an opt-out with missing provenance remains active. +For audit reporting, NemoClaw treats a non-loopback `CHAT_UI_URL`, an onboard-time `NEMOCLAW_DASHBOARD_BIND=0.0.0.0`, or WSL's default all-interface dashboard forward as remote dashboard exposure. +For an explicit `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` bind, use the same setting on later `connect` calls. +If the sandbox was created without that explicit setting, NemoClaw refuses the remote forward until you recreate it with `NEMOCLAW_DASHBOARD_BIND=0.0.0.0 $$nemoclaw onboard --recreate-sandbox`; this keeps the generated audit state aligned with the host exposure state. +On WSL, the ready summary still uses a loopback URL, but the generated audit configuration leaves the device-auth and insecure-auth findings active. +For an explicit remote bind with a loopback `CHAT_UI_URL`, NemoClaw disables device auth and enables OpenClaw's Host-header origin fallback because the browser's remote origin is not known at image-build time. Both settings expand access and must remain explicit; use HTTPS or an SSH local forward when possible. +In that state, NemoClaw does not add the loopback-only audit suppressions, so the resulting device-auth, insecure-auth, and Host-header fallback findings remain active. +The generated configuration uses exact audit check IDs and flag details, records why each setting is present, and leaves the original severity and remediation under `suppressedFindings` in JSON output. +The audit also reports that suppressions are active so you can review the accepted risk. +These suppressions change audit reporting only. +They do not make either flag safe, weaken enforcement, or suppress unrelated findings. + +Review the accepted findings and their recorded reasons: + +```bash +openclaw security audit --json | jq '.suppressedFindings' +``` + +Remove the underlying risky condition when dashboard compatibility no longer requires it. +Remove these suppressions only after the pinned OpenClaw audit contract test proves that OpenClaw natively classifies intentional loopback development HTTP without them, or after NemoClaw onboarding defaults `CHAT_UI_URL` to `https://localhost` with a generated local certificate. +Regression contracts: `test/generate-openclaw-config-security-audit.test.ts` locks generated suppression scope, `test/openclaw-security-audit-suppressions-real.test.ts` locks the pinned OpenClaw check IDs and details, and `test/e2e/live/dashboard-remote-bind.test.ts` proves a clean-host remote bind leaves all three risky findings active. The preceding removal condition applies to all three contracts. + ### Auto-Pair Client Allowlist The auto-pair watcher automatically approves device pairing requests from recognized clients, so you do not need to manually approve the Control UI. diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index 62124801b0f..a7dac4d060a 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -16,7 +16,9 @@ // NEMOCLAW_TOOL_DISCLOSURE, // NEMOCLAW_AGENT_TIMEOUT, NEMOCLAW_AGENT_HEARTBEAT_EVERY, // NEMOCLAW_INFERENCE_COMPAT_B64, +// NEMOCLAW_DASHBOARD_BIND, NEMOCLAW_WSL_DASHBOARD_EXPOSURE, // NEMOCLAW_DISABLE_DEVICE_AUTH, +// NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE, // NEMOCLAW_EXTRA_AGENTS_JSON_B64, // NEMOCLAW_PROXY_HOST, NEMOCLAW_PROXY_PORT, // NEMOCLAW_OPENCLAW_MANAGED_PROXY, NEMOCLAW_WEB_SEARCH_ENABLED, @@ -48,6 +50,21 @@ const MODEL_SETUP_EFFECT_KEYS: Record> = { const DEFAULT_DASHBOARD_PORT = 18789; const MIN_DASHBOARD_PORT = 1024; const MAX_DASHBOARD_PORT = 65535; +const REMOTE_DASHBOARD_BIND_VALUES = new Set(["0.0.0.0"]); +const DEVICE_AUTH_OPT_OUT_SOURCES = new Set(["operator", "managed-onboard"]); +const BOOLEAN_BUILD_FLAG_VALUES = new Set(["0", "1"]); + +function readOptionalEnumEnv(env: Env, name: string, allowedValues: ReadonlySet): string { + const value = env[name] ?? ""; + if (value !== "" && !allowedValues.has(value)) { + throw new Error(`${name} must be empty or one of: ${[...allowedValues].join(", ")}`); + } + return value; +} + +function readBooleanBuildFlag(env: Env, name: string): boolean { + return readOptionalEnumEnv(env, name, BOOLEAN_BUILD_FLAG_VALUES) === "1"; +} // Local Ollama small-context compaction policy (NemoClaw #5468). // @@ -1236,8 +1253,48 @@ export function buildConfig(env: Env = process.env): JsonObject { const origins = unique([loopbackOrigin, chatOrigin, portlessOrigin].filter(Boolean) as string[]); const isRemote = !isLoopback(parsed.hostname || ""); - const disableDeviceAuth = env.NEMOCLAW_DISABLE_DEVICE_AUTH === "1" || isRemote; + const dashboardBind = readOptionalEnumEnv( + env, + "NEMOCLAW_DASHBOARD_BIND", + REMOTE_DASHBOARD_BIND_VALUES, + ); + const remoteBindOptIn = dashboardBind === "0.0.0.0"; + const wslDashboardExposure = readBooleanBuildFlag(env, "NEMOCLAW_WSL_DASHBOARD_EXPOSURE"); + const hasRemoteDashboardExposure = isRemote || remoteBindOptIn || wslDashboardExposure; + const deviceAuthOptOut = env.NEMOCLAW_DISABLE_DEVICE_AUTH === "1"; + const deviceAuthOptOutSource = readOptionalEnumEnv( + env, + "NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE", + DEVICE_AUTH_OPT_OUT_SOURCES, + ); + const managedDeviceAuthOptOut = deviceAuthOptOut && deviceAuthOptOutSource === "managed-onboard"; + const disableDeviceAuth = deviceAuthOptOut || hasRemoteDashboardExposure; const allowInsecure = parsed.scheme === "http"; + const securityAuditSuppressions: JsonObject[] = []; + if (allowInsecure && !hasRemoteDashboardExposure) { + const reason = + "NemoClaw derives this setting from a loopback HTTP CHAT_UI_URL; use HTTPS for non-loopback dashboards."; + securityAuditSuppressions.push( + { checkId: "gateway.control_ui.insecure_auth", reason }, + { + checkId: "config.insecure_or_dangerous_flags", + detailIncludes: "gateway.controlUi.allowInsecureAuth=true", + reason, + }, + ); + } + if (managedDeviceAuthOptOut && !hasRemoteDashboardExposure) { + const reason = + "NemoClaw onboarding disables device authentication for immediate dashboard access (managed compatibility behavior; see #1217)."; + securityAuditSuppressions.push( + { checkId: "gateway.control_ui.device_auth_disabled", reason }, + { + checkId: "config.insecure_or_dangerous_flags", + detailIncludes: "gateway.controlUi.dangerouslyDisableDeviceAuth=true", + reason, + }, + ); + } const providerModels: JsonObject[] = [ { @@ -1357,6 +1414,9 @@ export function buildConfig(env: Env = process.env): JsonObject { channels: { defaults: {} }, tools: openclawTools, update: { checkOnStart: false }, + ...(securityAuditSuppressions.length > 0 + ? { security: { audit: { suppressions: securityAuditSuppressions } } } + : {}), plugins, gateway: { mode: "local", @@ -1365,6 +1425,7 @@ export function buildConfig(env: Env = process.env): JsonObject { allowInsecureAuth: allowInsecure, dangerouslyDisableDeviceAuth: disableDeviceAuth, allowedOrigins: origins, + ...(remoteBindOptIn && !isRemote ? { dangerouslyAllowHostHeaderOriginFallback: true } : {}), }, trustedProxies: ["127.0.0.1", "::1"], auth: { token: "" }, diff --git a/src/lib/actions/sandbox/forward-health.ts b/src/lib/actions/sandbox/forward-health.ts index a4c71045543..79d96a96b42 100644 --- a/src/lib/actions/sandbox/forward-health.ts +++ b/src/lib/actions/sandbox/forward-health.ts @@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process"; export type SandboxForwardListEntry = { sandboxName: string; + bind?: string; port: string; status: string; }; @@ -22,10 +23,17 @@ export function classifySandboxForwardHealth( entries: SandboxForwardListEntry[], sandboxName: string, port: string, + expectedBind?: string, ): Exclude { const liveEntries = liveEntriesForPort(entries, port); if (liveEntries.some((entry) => entry.sandboxName !== sandboxName)) return "occupied"; - return liveEntries.some((entry) => entry.sandboxName === sandboxName); + return liveEntries.some( + (entry) => + entry.sandboxName === sandboxName && + (expectedBind === undefined || + entry.bind === expectedBind || + (expectedBind === "0.0.0.0" && ["::", "[::]", "*"].includes(entry.bind ?? ""))), + ); } /** @@ -42,8 +50,9 @@ export function classifyForwardHealthWithReachability( sandboxName: string, port: string, isReachable: () => boolean, + expectedBind?: string, ): Exclude { - const ownership = classifySandboxForwardHealth(entries, sandboxName, port); + const ownership = classifySandboxForwardHealth(entries, sandboxName, port, expectedBind); if (ownership !== true) return ownership; return isReachable(); } diff --git a/src/lib/actions/sandbox/forward-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index a8b3b0c8eb2..6d296ab78d8 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -10,6 +10,8 @@ import { getActiveMessagingHostForward } from "../../messaging/host-forward"; import type { SandboxMessagingHostForwardPlan } from "../../messaging/manifest"; import { hydrateDerivedSandboxMessagingPlanFields } from "../../messaging/persistence"; import { parseSandboxMessagingPlan } from "../../messaging/plan-validation"; +import { isRemoteDashboardBindRequested } from "../../onboard/dockerfile-remote-dashboard-bind-contract"; +import { isWsl } from "../../platform"; import * as registry from "../../state/registry"; import { parseForwardList } from "../../state/sandbox-session"; import { @@ -61,7 +63,26 @@ export function resolveSandboxDashboardPort( * confirms the new entry is running, false otherwise. */ export function ensureSandboxPortForward(sandboxName: string): boolean { - return ensureSandboxPortForwardForPort(sandboxName, resolveSandboxDashboardPort(sandboxName)); + const port = resolveSandboxDashboardPort(sandboxName); + const remoteBindRequested = isRemoteDashboardBindRequested(process.env.NEMOCLAW_DASHBOARD_BIND); + const allInterfaceBindRequired = remoteBindRequested || isWsl(); + if ( + remoteBindRequested && + registry.getSandbox(sandboxName)?.dashboardRemoteBindPrepared !== true + ) { + console.error( + ` Refusing remote dashboard bind for '${sandboxName}': its generated configuration was not prepared for remote exposure. Re-run onboarding with NEMOCLAW_DASHBOARD_BIND=0.0.0.0 and --recreate-sandbox before reconnecting.`, + ); + return false; + } + return ensureSandboxPortForwardForPort(sandboxName, port, { + forwardTarget: allInterfaceBindRequired ? `0.0.0.0:${port}` : String(port), + forceRestart: remoteBindRequested, + expectedBind: allInterfaceBindRequired ? "0.0.0.0" : "127.0.0.1", + beforeStart: remoteBindRequested + ? () => registry.getSandbox(sandboxName)?.dashboardRemoteBindPrepared === true + : undefined, + }); } /** @@ -79,12 +100,19 @@ export function ensureSandboxPortForward(sandboxName: string): boolean { * cannot prove that OpenShell assigned this sandbox the requested host port. */ export function isSandboxForwardHealthy(sandboxName: string): SandboxForwardHealth { - return isSandboxPortForwardHealthy(sandboxName, resolveSandboxDashboardPort(sandboxName)); + const allInterfaceBindRequired = + isRemoteDashboardBindRequested(process.env.NEMOCLAW_DASHBOARD_BIND) || isWsl(); + return isSandboxPortForwardHealthy( + sandboxName, + resolveSandboxDashboardPort(sandboxName), + allInterfaceBindRequired ? "0.0.0.0" : "127.0.0.1", + ); } export function isSandboxPortForwardHealthy( sandboxName: string, port: number, + expectedBind?: string, ): SandboxForwardHealth { const result = captureOpenshell(["forward", "list"], { ignoreError: true, @@ -92,14 +120,33 @@ export function isSandboxPortForwardHealthy( }); if (!result || isCommandTimeout(result) || result.status !== 0) return null; const entries = parseForwardList(result.output) as SandboxForwardListEntry[]; - return classifyForwardHealthWithReachability(entries, sandboxName, String(port), () => - isLocalForwardReachable(port), + return classifyForwardHealthWithReachability( + entries, + sandboxName, + String(port), + () => isLocalForwardReachable(port), + expectedBind, ); } -export function ensureSandboxPortForwardForPort(sandboxName: string, port: number): boolean { - let forwardHealth = isSandboxPortForwardHealthy(sandboxName, port); - if (forwardHealth === true) return true; +export function ensureSandboxPortForwardForPort( + sandboxName: string, + port: number, + options: { + forwardTarget?: string; + forceRestart?: boolean; + expectedBind?: string; + beforeStart?: () => boolean; + } = {}, +): boolean { + const { + forwardTarget = String(port), + forceRestart = false, + expectedBind, + beforeStart = () => true, + } = options; + let forwardHealth = isSandboxPortForwardHealthy(sandboxName, port, expectedBind); + if (forwardHealth === true && !forceRestart) return true; if (forwardHealth === "occupied") return false; const configuredWaitMs = Number(process.env.NEMOCLAW_FORWARD_RECOVERY_WAIT_MS ?? "3000"); const waitMs = Number.isFinite(configuredWaitMs) ? Math.max(0, configuredWaitMs) : 3000; @@ -133,10 +180,12 @@ export function ensureSandboxPortForwardForPort(sandboxName: string, port: numbe }; const stopSettled = waitUntil( () => { - stopState.health = isSandboxPortForwardHealthy(sandboxName, port); + stopState.health = isSandboxPortForwardHealthy(sandboxName, port, expectedBind); stopState.portReleased = !isLocalForwardReachable(port); return ( - stopState.health === true || stopState.health === "occupied" || stopState.portReleased + (!forceRestart && stopState.health === true) || + stopState.health === "occupied" || + stopState.portReleased ); }, { @@ -146,12 +195,13 @@ export function ensureSandboxPortForwardForPort(sandboxName: string, port: numbe backoffFactor: 1.5, }, ); - if (stopState.health === true) return true; + if (stopState.health === true && !forceRestart) return true; if (stopState.health === "occupied" || !stopSettled || !stopState.portReleased) return false; } + if (!beforeStart()) return false; const startResult = runOpenshell( - ["forward", "start", "--background", String(port), sandboxName], + ["forward", "start", "--background", forwardTarget, sandboxName], { ignoreError: true, }, @@ -162,7 +212,7 @@ export function ensureSandboxPortForwardForPort(sandboxName: string, port: numbe // entry becomes visible. Poll for the exact live sandbox+port owner instead // of accepting an arbitrary reachable listener or failing on the first // metadata refresh. - let health = isSandboxPortForwardHealthy(sandboxName, port); + let health = isSandboxPortForwardHealthy(sandboxName, port, expectedBind); if (health === true) return true; if (health === "occupied") return false; if (waitMs === 0) return false; @@ -170,7 +220,7 @@ export function ensureSandboxPortForwardForPort(sandboxName: string, port: numbe let occupied = false; const settled = waitUntil( () => { - health = isSandboxPortForwardHealthy(sandboxName, port); + health = isSandboxPortForwardHealthy(sandboxName, port, expectedBind); if (health === "occupied") { occupied = true; return true; diff --git a/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts b/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts index 023027c34ff..4887ce6e8a1 100644 --- a/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts +++ b/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts @@ -67,7 +67,11 @@ describe("preflightRebuildImage", () => { const result = successful( await preflightRebuildImage(input(null), { stageBuildContext, - prepareDockerfilePatch: vi.fn(async () => ({ buildId: "1", resolvedBaseImage: null })), + prepareDockerfilePatch: vi.fn(async () => ({ + buildId: "1", + dashboardRemoteBindPrepared: false, + resolvedBaseImage: null, + })), buildImage, removeImage: vi.fn(() => ({ status: 0 }) as never), }), @@ -112,6 +116,7 @@ describe("preflightRebuildImage", () => { })), prepareDockerfilePatch: vi.fn(async () => ({ buildId: "root-link", + dashboardRemoteBindPrepared: false, resolvedBaseImage: null, })), buildImage, @@ -139,7 +144,11 @@ describe("preflightRebuildImage", () => { const removeImage = vi.fn(() => ({ status: 0 }) as never); try { const result = await preflightRebuildImage(input(dockerfile), { - prepareDockerfilePatch: vi.fn(async () => ({ buildId: "1", resolvedBaseImage: null })), + prepareDockerfilePatch: vi.fn(async () => ({ + buildId: "1", + dashboardRemoteBindPrepared: false, + resolvedBaseImage: null, + })), buildImage: vi.fn(() => ({ status: 1, stderr: "dockerfile validation failed" }) as never), removeImage, }); @@ -159,7 +168,11 @@ describe("preflightRebuildImage", () => { try { const result = successful( await preflightRebuildImage(input(dockerfile), { - prepareDockerfilePatch: vi.fn(async () => ({ buildId: "1", resolvedBaseImage: null })), + prepareDockerfilePatch: vi.fn(async () => ({ + buildId: "1", + dashboardRemoteBindPrepared: false, + resolvedBaseImage: null, + })), buildImage, removeImage, }), @@ -188,7 +201,11 @@ describe("preflightRebuildImage", () => { try { const result = successful( await preflightRebuildImage(input(dockerfile), { - prepareDockerfilePatch: vi.fn(async () => ({ buildId: "1", resolvedBaseImage: null })), + prepareDockerfilePatch: vi.fn(async () => ({ + buildId: "1", + dashboardRemoteBindPrepared: false, + resolvedBaseImage: null, + })), buildImage: vi.fn((stagedDockerfile) => { builtDockerfiles.push(fs.readFileSync(stagedDockerfile, "utf8")); return { status: 0 } as never; @@ -236,7 +253,11 @@ describe("preflightRebuildImage", () => { try { const result = successful( await preflightRebuildImage(input(dockerfile), { - prepareDockerfilePatch: vi.fn(async () => ({ buildId: "1", resolvedBaseImage: null })), + prepareDockerfilePatch: vi.fn(async () => ({ + buildId: "1", + dashboardRemoteBindPrepared: true, + resolvedBaseImage: null, + })), buildImage: vi.fn(() => ({ status: 0 }) as never), removeImage, }), @@ -247,6 +268,7 @@ describe("preflightRebuildImage", () => { ); expect(processOnce).toHaveBeenCalledWith("exit", expect.any(Function)); expect(removeImage).toHaveBeenCalledTimes(2); + expect(result.prepared.dashboardRemoteBindPrepared).toBe(true); expect(disposePreparedBuildContext(result.prepared)).toBe(true); } finally { processOnce.mockRestore(); diff --git a/src/lib/actions/sandbox/rebuild-custom-image-preflight.ts b/src/lib/actions/sandbox/rebuild-custom-image-preflight.ts index 8eb3dbba4c1..d8399931cdf 100644 --- a/src/lib/actions/sandbox/rebuild-custom-image-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-custom-image-preflight.ts @@ -93,7 +93,7 @@ export async function preflightRebuildImage( }, }); cleanup = createIdempotentBuildContextCleanup(staged.cleanupBuildCtx); - const { buildId } = await preparePatch({ + const { buildId, dashboardRemoteBindPrepared } = await preparePatch({ agent: input.agent, fromDockerfile: input.fromDockerfile, sandboxBaseImage: OPENCLAW_SANDBOX_BASE_IMAGE, @@ -131,6 +131,7 @@ export async function preflightRebuildImage( ...staged, cleanupBuildCtx: cleanup, buildId, + dashboardRemoteBindPrepared, contextFingerprint, verifyBuildCtx: createBuildContextVerifier(staged.buildCtx, contextFingerprint), rebuildTarget: { diff --git a/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts b/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts index 5d8b067e13e..f195815a921 100644 --- a/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts +++ b/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts @@ -27,7 +27,11 @@ describe("managed DCode rebuild image configuration", () => { let reasoningDuringPatch: string | undefined; const prepareDockerfilePatch = vi.fn(async () => { reasoningDuringPatch = process.env.NEMOCLAW_REASONING; - return { buildId: "dcode-fidelity", resolvedBaseImage: null }; + return { + buildId: "dcode-fidelity", + dashboardRemoteBindPrepared: false, + resolvedBaseImage: null, + }; }); try { @@ -73,6 +77,7 @@ describe("managed DCode rebuild image configuration", () => { fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); const prepareDockerfilePatch = vi.fn(async () => ({ buildId: "dcode-auto-approval", + dashboardRemoteBindPrepared: false, resolvedBaseImage: null, })); @@ -106,6 +111,39 @@ describe("managed DCode rebuild image configuration", () => { } }); + it("preserves remote dashboard bind preparation from the managed Dockerfile patch (#6024)", async () => { + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-remote-bind-")); + const stagedDockerfile = path.join(testRoot, "Dockerfile"); + fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); + + try { + const result = await prepareManagedDcodeRebuildImage(dcodeInput(), { + stageBuildContext: () => ({ + buildCtx: testRoot, + stagedDockerfile, + origin: "generated" as const, + cleanupBuildCtx: () => { + fs.rmSync(testRoot, { recursive: true, force: true }); + return true; + }, + }), + prepareDockerfilePatch: async () => ({ + buildId: "dcode-remote-bind", + dashboardRemoteBindPrepared: true, + resolvedBaseImage: null, + }), + buildImage: () => ({ status: 0 }) as never, + removeImage: () => ({ status: 0 }) as never, + }); + + const prepared = expectPreparedImage(result); + expect(prepared.dashboardRemoteBindPrepared).toBe(true); + disposePreparedDcodeRebuildImage(prepared); + } finally { + fs.rmSync(testRoot, { recursive: true, force: true }); + } + }); + it("defaults missing compatible-endpoint reasoning without borrowing ambient state (#6195)", async () => { const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-reasoning-")); const stagedDockerfile = path.join(buildCtx, "Dockerfile"); @@ -129,7 +167,11 @@ describe("managed DCode rebuild image configuration", () => { }), prepareDockerfilePatch: async () => { reasoningDuringPatch = process.env.NEMOCLAW_REASONING; - return { buildId: "dcode-reasoning-default", resolvedBaseImage: null }; + return { + buildId: "dcode-reasoning-default", + dashboardRemoteBindPrepared: false, + resolvedBaseImage: null, + }; }, buildImage: () => ({ status: 0 }) as never, removeImage: () => ({ status: 0 }) as never, diff --git a/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts b/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts index 8e5ccaf174f..6c4ee5f43f5 100644 --- a/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts @@ -138,7 +138,7 @@ export async function prepareManagedDcodeRebuildImage( }); cleanupBuildContext = createIdempotentBuildContextCleanup(staged.cleanupBuildCtx); - const { buildId } = await preparePatch({ + const { buildId, dashboardRemoteBindPrepared } = await preparePatch({ agent: input.agent, fromDockerfile: null, sandboxBaseImage: OPENCLAW_SANDBOX_BASE_IMAGE, @@ -177,6 +177,7 @@ export async function prepareManagedDcodeRebuildImage( ...staged, cleanupBuildCtx: cleanupBuildContext, buildId, + dashboardRemoteBindPrepared, contextFingerprint, dcodeAutoApprovalMode: input.dcodeAutoApprovalMode, verifyBuildCtx: createBuildContextVerifier(staged.buildCtx, contextFingerprint), diff --git a/src/lib/actions/sandbox/rebuild-managed-image-preparation.test.ts b/src/lib/actions/sandbox/rebuild-managed-image-preparation.test.ts index 8472564e314..165e5e51719 100644 --- a/src/lib/actions/sandbox/rebuild-managed-image-preparation.test.ts +++ b/src/lib/actions/sandbox/rebuild-managed-image-preparation.test.ts @@ -89,6 +89,7 @@ describe("managed DCode rebuild image preparation", () => { })), prepareDockerfilePatch: vi.fn(async () => ({ buildId: "dcode-build-cleanup", + dashboardRemoteBindPrepared: false, resolvedBaseImage: null, })), buildImage: vi.fn(() => ({ status: 0 }) as never), @@ -122,6 +123,7 @@ describe("managed DCode rebuild image preparation", () => { })), prepareDockerfilePatch: vi.fn(async () => ({ buildId: "dcode-build-failure", + dashboardRemoteBindPrepared: false, resolvedBaseImage: null, })), buildImage: vi.fn( diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 1c74dcfe27e..a2beca6c25a 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2773,28 +2773,28 @@ async function createSandboxWithBaseImageResolution( const envMessagingState = MessagingHostStateApplier.readPlanStateFromEnv(); const plannedMessagingState = envMessagingState?.plan.sandboxName === sandboxName ? envMessagingState : undefined; - sandboxBuildPatchConfig.prepareSandboxBuildPatchConfig({ - configuredMessagingChannels: - getChannelsFromPlan(plannedMessagingState?.plan) ?? activeMessagingChannels, - }); - const buildId = await preparedDcodeRebuild.resolveSandboxBuildId({ - preparedBuildContext, - agent, - fromDockerfile, - stagedDockerfile, - model, - chatUiUrl, - provider, - endpointUrl: createIntent?.endpointUrl ?? null, - preferredInferenceApi, - webSearchConfig, - toolDisclosure: effectiveToolDisclosure, - ...(isManagedDcodeAgent ? { dcodeAutoApprovalMode: dcodeAutoApprovalPlan.mode } : {}), - hermesToolGateways, - sandboxGpuConfig: effectiveSandboxGpuConfig, - ...baseImageResolutionFlow.getBaseImageResolutionPatchOptions(baseImageResolutionContext), - gatewayPort: GATEWAY_PORT, - }); + const configuredMessagingChannels = + getChannelsFromPlan(plannedMessagingState?.plan) ?? activeMessagingChannels; + sandboxBuildPatchConfig.prepareSandboxBuildPatchConfig({ configuredMessagingChannels }); + const { buildId, dashboardRemoteBindPrepared } = + await preparedDcodeRebuild.resolveSandboxBuildPatch({ + preparedBuildContext, + agent, + fromDockerfile, + stagedDockerfile, + model, + chatUiUrl, + provider, + endpointUrl: createIntent?.endpointUrl ?? null, + preferredInferenceApi, + webSearchConfig, + toolDisclosure: effectiveToolDisclosure, + ...(isManagedDcodeAgent ? { dcodeAutoApprovalMode: dcodeAutoApprovalPlan.mode } : {}), + hermesToolGateways, + sandboxGpuConfig: effectiveSandboxGpuConfig, + ...baseImageResolutionFlow.getBaseImageResolutionPatchOptions(baseImageResolutionContext), + gatewayPort: GATEWAY_PORT, + }); const sandboxReadyTimeoutSecs = getSandboxReadyTimeoutSecs(effectiveSandboxGpuConfig); const { createResult, prebuild, effectiveDashboardPort, dockerGpuCreatePatch } = await runSandboxCreateStep( @@ -2996,7 +2996,7 @@ async function createSandboxWithBaseImageResolution( ...(isManagedDcodeAgent ? { dcodeAutoApprovalMode: dcodeAutoApprovalPlan.mode } : {}), policyTier: resolvedCreatePolicyTier, // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. - ...sandboxRegistration.creationFidelity(webSearchConfig, fromDockerfile, normalizeHermesAuthMethod(hermesAuthMethod)), + ...sandboxRegistration.creationFidelity(webSearchConfig, fromDockerfile, normalizeHermesAuthMethod(hermesAuthMethod), dashboardRemoteBindPrepared), plannedMessagingState, preservedMcpState, hermesToolGateways, diff --git a/src/lib/onboard/build-context-stage.ts b/src/lib/onboard/build-context-stage.ts index 1e5c8a5dcd1..e5aec698dbc 100644 --- a/src/lib/onboard/build-context-stage.ts +++ b/src/lib/onboard/build-context-stage.ts @@ -40,6 +40,7 @@ export interface CreateSandboxBuildContextResult extends StagedBuildContext { /** Exact staged and patched context transferred from rebuild preflight to create. */ export interface PreparedSandboxBuildContext extends CreateSandboxBuildContextResult { buildId: string; + dashboardRemoteBindPrepared?: boolean; /** Recheck retained bytes at the final one-shot consumption boundary. */ verifyBuildCtx?(): boolean; /** Exact recorded target authorized to consume a generic rebuild handoff. */ diff --git a/src/lib/onboard/dockerfile-patch.test.ts b/src/lib/onboard/dockerfile-patch.test.ts index ccd994f3134..6a71d0aa1c0 100644 --- a/src/lib/onboard/dockerfile-patch.test.ts +++ b/src/lib/onboard/dockerfile-patch.test.ts @@ -102,6 +102,71 @@ describe("dockerfile patch helpers", () => { expect(isValidProxyPort("70000")).toBe(false); }); + it("records WSL dashboard exposure in managed OpenClaw build input (#6024)", () => { + const dockerfilePath = dockerfileWith("ARG NEMOCLAW_WSL_DASHBOARD_EXPOSURE=0\n"); + + patchStagedDockerfile( + dockerfilePath, + "custom-model", + "http://127.0.0.1:18789", + "build-1", + null, + null, + null, + null, + false, + null, + [], + { wslDashboardExposure: true }, + ); + + expect(fs.readFileSync(dockerfilePath, "utf-8")).toContain( + "ARG NEMOCLAW_WSL_DASHBOARD_EXPOSURE=1", + ); + }); + + it("keeps legacy non-WSL Dockerfiles compatible without the exposure arg (#6024)", () => { + const dockerfilePath = dockerfileWith("ARG CHAT_UI_URL=http://127.0.0.1:18789\n"); + + expect(() => + patchStagedDockerfile( + dockerfilePath, + "custom-model", + "http://127.0.0.1:18789", + "build-1", + null, + null, + null, + null, + false, + null, + [], + { wslDashboardExposure: false }, + ), + ).not.toThrow(); + }); + + it("fails closed when a WSL managed Dockerfile cannot record exposure (#6024)", () => { + const dockerfilePath = dockerfileWith("ARG CHAT_UI_URL=http://127.0.0.1:18789\n"); + + expect(() => + patchStagedDockerfile( + dockerfilePath, + "custom-model", + "http://127.0.0.1:18789", + "build-1", + null, + null, + null, + null, + false, + null, + [], + { wslDashboardExposure: true }, + ), + ).toThrow(/cannot record WSL dashboard exposure/); + }); + it("fails when an OTEL env value has no matching Dockerfile ARG", () => { process.env.NEMOCLAW_OPENCLAW_OTEL_ENDPOINT = "http://host.openshell.internal:4318"; const dockerfilePath = dockerfileWith( @@ -171,6 +236,7 @@ describe("dockerfile patch helpers", () => { "ARG NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME=old", "ARG NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE=old", "ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0", + "ARG NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE=operator", "ARG NEMOCLAW_MESSAGING_PLAN_B64=old", ].join("\n"), ); @@ -208,6 +274,7 @@ describe("dockerfile patch helpers", () => { expect(patched).toContain("ARG NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME=nemoclaw-local"); expect(patched).toContain("ARG NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE=0.5"); expect(patched).toContain("ARG NEMOCLAW_DISABLE_DEVICE_AUTH=1"); + expect(patched).toContain("ARG NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE=managed-onboard"); const patchedMessagingPlan = readMessagingPlanArg(patched) as { channels?: Array<{ channelId?: string; active?: boolean }>; buildSteps?: unknown; diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index c26c8473a92..d6a37b801fa 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -29,6 +29,7 @@ import { type DcodeAutoApprovalMode, isDcodeAutoApprovalMode, } from "./dcode-auto-approval"; +import * as remoteDashboardBindContract from "./dockerfile-remote-dashboard-bind-contract"; import { dockerfileInstructions, readDockerfilePatchSnapshot, @@ -86,9 +87,11 @@ export interface PatchStagedDockerfileOptions { buildIdPolicy?: DockerfileBuildIdPolicy; toolDisclosure?: ToolDisclosure; requireToolDisclosureContract?: boolean; + trustedManagedDockerfile?: boolean; baseImageResolutionMetadata?: SandboxBaseImageResolutionMetadata | null; dcodeAutoApprovalMode?: DcodeAutoApprovalMode; upstreamEndpointUrl?: string | null; + wslDashboardExposure?: boolean; } export function patchDcodeAutoApprovalDockerArg( @@ -118,6 +121,10 @@ export function isValidProxyPort(value: string): boolean { return port >= 1 && port <= 65535; } +export type PatchedDockerfileMetadata = { dashboardRemoteBindPrepared: boolean }; + +export { hasPreparedRemoteDashboardBind } from "./dockerfile-remote-dashboard-bind-contract"; + export function patchStagedDockerfile( dockerfilePath: string, model: string, @@ -131,7 +138,7 @@ export function patchStagedDockerfile( inferenceBaseUrlOverride: string | null = null, hermesToolGateways: string[] = [], options: PatchStagedDockerfileOptions = {}, -): void { +): PatchedDockerfileMetadata { const sanitizedModel = sanitizeDockerArg(model); const sandboxInference = getSandboxInferenceConfig( sanitizedModel, @@ -212,6 +219,28 @@ export function patchStagedDockerfile( /^ARG CHAT_UI_URL=.*$/m, `ARG CHAT_UI_URL=${sanitizeDockerArg(chatUiUrl)}`, ); + if (options.wslDashboardExposure !== undefined) { + const wslDashboardExposureArg = /^ARG NEMOCLAW_WSL_DASHBOARD_EXPOSURE=.*$/m; + const hasWslDashboardExposureArg = wslDashboardExposureArg.test(dockerfile); + if (options.wslDashboardExposure && !hasWslDashboardExposureArg) { + throw new Error( + "Dockerfile is missing ARG NEMOCLAW_WSL_DASHBOARD_EXPOSURE; cannot record WSL dashboard exposure.", + ); + } + if (hasWslDashboardExposureArg) { + dockerfile = dockerfile.replace( + wslDashboardExposureArg, + `ARG NEMOCLAW_WSL_DASHBOARD_EXPOSURE=${options.wslDashboardExposure ? "1" : "0"}`, + ); + } + } + const remoteDashboardBind = remoteDashboardBindContract.patchRequestedRemoteDashboardBindContract( + dockerfile, + process.env.NEMOCLAW_DASHBOARD_BIND, + options.trustedManagedDockerfile === true, + ); + dockerfile = remoteDashboardBind.dockerfile; + const { dashboardRemoteBindPrepared } = remoteDashboardBind; dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_BASE_URL=.*$/m, `ARG NEMOCLAW_INFERENCE_BASE_URL=${sanitizeDockerArg(inferenceBaseUrl)}`, @@ -340,12 +369,8 @@ export function patchStagedDockerfile( dockerfile = dockerfile.replace(argPattern, `ARG ${envKey}=${sanitizeDockerArg(rawValue)}`); } } - // Onboard flow expects immediate dashboard access without device pairing, - // so disable device auth for images built during onboard (see #1217). - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_DISABLE_DEVICE_AUTH=.*$/m, - `ARG NEMOCLAW_DISABLE_DEVICE_AUTH=${sanitizeDockerArg("1")}`, - ); + // Keep the managed pairing opt-out distinct from an operator's choice. + dockerfile = remoteDashboardBindContract.patchManagedDeviceAuthOptOutContract(dockerfile); const messagingPlan = MessagingSetupApplier.readPlanFromEnv(); if (messagingPlan) { const hydratedMessagingPlan = hydrateDerivedSandboxMessagingPlanFields( @@ -424,4 +449,5 @@ export function patchStagedDockerfile( } replaceDockerfilePatchSnapshot(dockerfilePath, patchSnapshot, dockerfile); + return { dashboardRemoteBindPrepared }; } diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.test.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.test.ts new file mode 100644 index 00000000000..5047c092e8a --- /dev/null +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.test.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + isRemoteDashboardBindRequested, + resolveRequestedRemoteDashboardBind, +} from "./dockerfile-remote-dashboard-bind-contract"; + +describe("remote dashboard bind request policy", () => { + it("accepts an explicit remote bind for a managed Dockerfile", () => { + expect(resolveRequestedRemoteDashboardBind("0.0.0.0", true)).toBe("0.0.0.0"); + expect(isRemoteDashboardBindRequested("0.0.0.0")).toBe(true); + }); + + it("keeps an unset request on loopback", () => { + expect(resolveRequestedRemoteDashboardBind(undefined, false)).toBe(""); + expect(resolveRequestedRemoteDashboardBind("", false)).toBe(""); + }); + + it("rejects remote exposure for a custom Dockerfile", () => { + expect(() => resolveRequestedRemoteDashboardBind("0.0.0.0", false)).toThrow( + /custom --from Dockerfiles/, + ); + }); + + it("rejects unsupported bind values", () => { + expect(() => resolveRequestedRemoteDashboardBind("127.0.0.1", true)).toThrow( + "NEMOCLAW_DASHBOARD_BIND must be empty or 0.0.0.0.", + ); + }); +}); diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts new file mode 100644 index 00000000000..1c99e899a4d --- /dev/null +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; + +import { + type DockerfileInstruction, + dockerfileInstructions, + readDockerfilePatchSnapshot, +} from "./dockerfile-tool-disclosure-contract"; + +const REMOTE_BIND_ARG_RE = /^ARG\s+NEMOCLAW_DASHBOARD_BIND=/; +const REMOTE_BIND_PATCHED_ARG_RE = /^ARG\s+NEMOCLAW_DASHBOARD_BIND=0\.0\.0\.0$/; +const REMOTE_BIND_PROMOTION_RE = /NEMOCLAW_DASHBOARD_BIND=\$\{NEMOCLAW_DASHBOARD_BIND\}/; +const OPENCLAW_CONFIG_GENERATOR_RE = + /^RUN\s+(?:NEMOCLAW_OPENCLAW_MANAGED_PROXY=0\s+)?node\s+--experimental-strip-types\s+\/scripts\/generate-openclaw-config\.mts$/; +const SAFE_VALIDATION_GENERATOR_RE = + /^RUN\s+validation_home="\$validation_root\/progressive";\s+HOME=(?:"\$validation_home"|\$validation_home)\s+node\s+--experimental-strip-types\s+\/scripts\/generate-openclaw-config\.mts$/; +const PASSIVE_FINAL_STAGE_INSTRUCTION_RE = /^(?:ARG|ENV|WORKDIR|USER|HEALTHCHECK|ENTRYPOINT|CMD)\b/; +const CONFIG_MODE_RE = /^RUN\s+chmod\s+660\s+\/sandbox\/\.openclaw\/openclaw\.json$/; +const CONFIG_HASH_RE = + /^RUN\s+sha256sum\s+\/sandbox\/\.openclaw\/openclaw\.json\s+>\s+\/sandbox\/\.openclaw\/\.config-hash(?:\s+&&\s+chmod\s+660\s+\/sandbox\/\.openclaw\/\.config-hash)?(?:\s+&&\s+chown\s+sandbox:sandbox\s+\/sandbox\/\.openclaw\/\.config-hash)?$/; +const MESSAGING_BUILD_APPLIER_RE = + /^RUN\s+OPENCLAW_VERSION="\$\{OPENCLAW_VERSION\}"\s+node\s+--experimental-strip-types\s+\/src\/lib\/messaging\/applier\/build\/messaging-build-applier\.mts\s+--agent\s+openclaw\s+--phase\s+(?:agent-install|post-agent-install)$/; +const EXACT_CUSTOM_POST_GENERATOR_RUN_RE = [ + CONFIG_MODE_RE, + CONFIG_HASH_RE, + MESSAGING_BUILD_APPLIER_RE, +] as const; + +// Complex RUN instructions in the shipped Dockerfile are accepted only as +// exact normalized instructions. Prefix matching here would let a custom +// Dockerfile append `&& ` to an otherwise safe command. +// A lifecycle test verifies these digests against the checked-in Dockerfile. +const CANONICAL_POST_GENERATOR_RUN_SHA256 = new Set([ + "e7256f12c618bb424f53fec801378d92446d880c5935965ebb3b548694866b63", + "121d7732831a75b20dd31a58c65a5fdf3b6ff56ed24d61802ee4b0cca806d4e1", + "737edaaa69f80cf10d42fd349e0be068c1ef6e7375d5dcb4055b012420b58736", + "5b814e92449a6778385f588877fe72ebed80e601f8eb0c90c2842b17a489f3da", + "0e1a9a7bab2fab0a974577c3af8785157b4b9be2b4db32d5f4f9e5aa3c8c8171", + "a68297161e2c6463440b822f4e4be0518e745fb5fba8c61ab53b876724f7b666", + "865a9e486e1f0f54e33138a94d5cf51feb67daec4b6e6f0e21f9de22ef7e10f7", + "ca493ae7905fae5c587a8e5c31fcb3d423235940589c2decee99d7b338e87d88", + "d181ff3c36d8982f78b5627d1f4a02fd30d2667cd1ca8ffb97fb65535ae452ee", + "6d4094a9d7c21eeb408cadd728da7cd7e0ee9574746436be59c26b218c8ab218", + "fa9a9916a254ea4faa06339c759b89ade441bd54c22fa8fc4c927547e40ff456", + "d50e094416f150f74c24f81665be08064a1c5bd23c11d29575b20379b5a58ce2", + "42ef0b12e92ebe146c25367831b4ce3a2664f0fa99fd5e4fb98a8939d3af8800", + "8b49e78185185f1b7e24d01631186554fef21d2300db65c9bc9998e7ec00469f", +]); + +function instructionSha256(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +const postGeneratorInstructionAllowed = (instruction: DockerfileInstruction): boolean => { + const { text } = instruction; + if (PASSIVE_FINAL_STAGE_INSTRUCTION_RE.test(text)) return true; + if (SAFE_VALIDATION_GENERATOR_RE.test(text)) return true; + if (EXACT_CUSTOM_POST_GENERATOR_RUN_RE.some((pattern) => pattern.test(text))) return true; + return CANONICAL_POST_GENERATOR_RUN_SHA256.has(instructionSha256(text)); +}; + +const isPrimaryOpenClawConfigGenerator = (instruction: DockerfileInstruction): boolean => + OPENCLAW_CONFIG_GENERATOR_RE.test(instruction.text); + +export type PatchedRemoteDashboardBindContract = { + dockerfile: string; + dashboardRemoteBindPrepared: boolean; +}; + +export function isRemoteDashboardBindRequested(value: string | undefined): boolean { + return value === "0.0.0.0"; +} + +export function patchManagedDeviceAuthOptOutContract(dockerfile: string): string { + return dockerfile + .replace(/^ARG NEMOCLAW_DISABLE_DEVICE_AUTH=.*$/m, "ARG NEMOCLAW_DISABLE_DEVICE_AUTH=1") + .replace( + /^ARG NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE=.*$/m, + "ARG NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE=managed-onboard", + ); +} + +export function resolveRequestedRemoteDashboardBind( + value: string | undefined, + trustedManagedDockerfile: boolean, +): "" | "0.0.0.0" { + if (value === undefined || value === "") return ""; + if (!isRemoteDashboardBindRequested(value)) { + throw new Error("NEMOCLAW_DASHBOARD_BIND must be empty or 0.0.0.0."); + } + if (!trustedManagedDockerfile) { + throw new Error( + "Remote dashboard bind is unavailable with custom --from Dockerfiles until post-build runtime configuration attestation is implemented.", + ); + } + return "0.0.0.0"; +} + +export function patchRequestedRemoteDashboardBindContract( + dockerfile: string, + value: string | undefined, + trustedManagedDockerfile: boolean, +): PatchedRemoteDashboardBindContract { + return patchRemoteDashboardBindContract( + dockerfile, + resolveRequestedRemoteDashboardBind(value, trustedManagedDockerfile), + ); +} + +function finalStageInstructions(dockerfile: string): DockerfileInstruction[] { + const instructions = dockerfileInstructions(dockerfile); + const finalFromIndex = instructions.reduce( + (last, instruction, index) => (/^FROM(?:\s|$)/i.test(instruction.text) ? index : last), + -1, + ); + return instructions.slice(finalFromIndex + 1); +} + +export function findRemoteDashboardBindFinalStageArg( + dockerfile: string, +): DockerfileInstruction | undefined { + return finalStageInstructions(dockerfile).find((instruction) => + REMOTE_BIND_ARG_RE.test(instruction.text), + ); +} + +export function hasRemoteDashboardBindGenerationContract(dockerfile: string): boolean { + const finalStage = finalStageInstructions(dockerfile); + const argIndex = finalStage.findIndex((instruction) => + REMOTE_BIND_PATCHED_ARG_RE.test(instruction.text), + ); + const promotionIndex = finalStage.findIndex( + (instruction, index) => index > argIndex && REMOTE_BIND_PROMOTION_RE.test(instruction.text), + ); + const generatorIndex = finalStage.findIndex( + (instruction, index) => index > promotionIndex && isPrimaryOpenClawConfigGenerator(instruction), + ); + const invalidatorIndex = finalStage.findIndex( + (instruction, index) => index > generatorIndex && !postGeneratorInstructionAllowed(instruction), + ); + return ( + argIndex >= 0 && + promotionIndex > argIndex && + generatorIndex > promotionIndex && + invalidatorIndex < 0 + ); +} + +export function patchRemoteDashboardBindContract( + dockerfile: string, + dashboardBind: "" | "0.0.0.0", +): PatchedRemoteDashboardBindContract { + const dashboardBindArg = findRemoteDashboardBindFinalStageArg(dockerfile); + if (dashboardBind && !dashboardBindArg) { + throw new Error( + "Dockerfile is missing ARG NEMOCLAW_DASHBOARD_BIND; cannot prepare remote dashboard exposure.", + ); + } + const patchedDockerfile = dashboardBindArg + ? `${dockerfile.slice(0, dashboardBindArg.start)}ARG NEMOCLAW_DASHBOARD_BIND=${dashboardBind}${dockerfile.slice(dashboardBindArg.end)}` + : dockerfile; + const dashboardRemoteBindPrepared = + dashboardBind === "0.0.0.0" && hasRemoteDashboardBindGenerationContract(patchedDockerfile); + if (dashboardBind === "0.0.0.0" && !dashboardRemoteBindPrepared) { + throw new Error( + "Dockerfile declares ARG NEMOCLAW_DASHBOARD_BIND but does not promote it to " + + "generate-openclaw-config.mts or preserve the generated remote dashboard output; " + + "cannot prepare remote dashboard exposure.", + ); + } + return { dockerfile: patchedDockerfile, dashboardRemoteBindPrepared }; +} + +export function hasPreparedRemoteDashboardBind(dockerfilePath: string): boolean { + return hasRemoteDashboardBindGenerationContract( + readDockerfilePatchSnapshot(dockerfilePath).content, + ); +} diff --git a/src/lib/onboard/prepared-dcode-rebuild.test.ts b/src/lib/onboard/prepared-dcode-rebuild.test.ts index 1fa1ebb2b8c..808967611f1 100644 --- a/src/lib/onboard/prepared-dcode-rebuild.test.ts +++ b/src/lib/onboard/prepared-dcode-rebuild.test.ts @@ -349,7 +349,11 @@ describe("prepared DCode rebuild adapter", () => { }); it("uses the prepared build ID without patching and patches ordinary contexts", async () => { - const patch = vi.fn(async () => ({ buildId: "fresh-build", resolvedBaseImage: null })); + const patch = vi.fn(async () => ({ + buildId: "fresh-build", + dashboardRemoteBindPrepared: false, + resolvedBaseImage: null, + })); await expect( resolveSandboxBuildId(preparedBuildIdInput, { prepareSandboxDockerfilePatch: patch }), diff --git a/src/lib/onboard/prepared-dcode-rebuild.ts b/src/lib/onboard/prepared-dcode-rebuild.ts index 15e23cdf731..0fef7cdb7c4 100644 --- a/src/lib/onboard/prepared-dcode-rebuild.ts +++ b/src/lib/onboard/prepared-dcode-rebuild.ts @@ -245,15 +245,23 @@ type ResolveSandboxBuildIdInput = Omit< preparedBuildContext: PreparedSandboxBuildContext | null; }; -export async function resolveSandboxBuildId( +export type ResolvedSandboxBuildPatch = { + buildId: string; + dashboardRemoteBindPrepared: boolean; +}; + +export async function resolveSandboxBuildPatch( input: ResolveSandboxBuildIdInput, deps: PreparedDcodeRebuildDeps = {}, -): Promise { +): Promise { const { preparedBuildContext, ...patchInput } = input; assertPreparedDcodeTarget(preparedBuildContext, patchInput.agent, patchInput.fromDockerfile); if (preparedBuildContext) { verifyPreparedBuildContextForUse(preparedBuildContext); - return preparedBuildContext.buildId; + return { + buildId: preparedBuildContext.buildId, + dashboardRemoteBindPrepared: preparedBuildContext.dashboardRemoteBindPrepared === true, + }; } const result: SandboxDockerfilePatchResult = await ( @@ -263,5 +271,16 @@ export async function resolveSandboxBuildId( sandboxBaseImage: OPENCLAW_SANDBOX_BASE_IMAGE, sandboxBaseTag: SANDBOX_BASE_TAG, }); + return { + buildId: result.buildId, + dashboardRemoteBindPrepared: result.dashboardRemoteBindPrepared, + }; +} + +export async function resolveSandboxBuildId( + input: ResolveSandboxBuildIdInput, + deps: PreparedDcodeRebuildDeps = {}, +): Promise { + const result = await resolveSandboxBuildPatch(input, deps); return result.buildId; } diff --git a/src/lib/onboard/sandbox-create-launch.ts b/src/lib/onboard/sandbox-create-launch.ts index 6d780d0a13b..d74a932a764 100644 --- a/src/lib/onboard/sandbox-create-launch.ts +++ b/src/lib/onboard/sandbox-create-launch.ts @@ -91,6 +91,9 @@ export function prepareSandboxCreateLaunch(input: SandboxCreateLaunchInput): San : "0"; if (manageDashboard) { envArgs.push(formatEnvAssignment("NEMOCLAW_DASHBOARD_PORT", effectiveDashboardPort)); + if (env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0") { + envArgs.push(formatEnvAssignment("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0")); + } } appendOpenClawRuntimeEnvArgs(envArgs, input.agent ?? null); diff --git a/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts b/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts index e378a09b08d..549e22cb58b 100644 --- a/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts +++ b/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts @@ -84,6 +84,7 @@ describe("prepareSandboxDockerfilePatch", () => { resolutionHint: resolutionMetadata, deps: { isLinuxDockerDriverGatewayEnabled: vi.fn(() => true), + isWsl: vi.fn(() => false), pullAndResolveBaseImageDigest, enforceDockerGpuPatchPreserveNetwork: vi.fn(async () => false), patchStagedDockerfile, @@ -98,6 +99,8 @@ describe("prepareSandboxDockerfilePatch", () => { expect(patchStagedDockerfile.mock.calls[0]?.[11]).toEqual({ buildIdPolicy: "preserve", toolDisclosure: "progressive", + trustedManagedDockerfile: true, + wslDashboardExposure: false, requireToolDisclosureContract: false, baseImageResolutionMetadata: resolutionMetadata, }); @@ -123,6 +126,7 @@ describe("prepareSandboxDockerfilePatch", () => { log, deps: { isLinuxDockerDriverGatewayEnabled: vi.fn(() => true), + isWsl: vi.fn(() => false), pullAndResolveBaseImageDigest: vi.fn(() => ({ digest: "sha256:abcdef0123456789", ref: "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:abcdef0123456789", @@ -135,6 +139,7 @@ describe("prepareSandboxDockerfilePatch", () => { expect(result).toEqual({ buildId: "12345", + dashboardRemoteBindPrepared: false, resolvedBaseImage: { digest: "sha256:abcdef0123456789", ref: "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:abcdef0123456789", @@ -164,11 +169,44 @@ describe("prepareSandboxDockerfilePatch", () => { { buildIdPolicy: "preserve", toolDisclosure: "progressive", + trustedManagedDockerfile: true, + wslDashboardExposure: false, requireToolDisclosureContract: false, }, ); }); + it("records WSL all-interface dashboard exposure for managed OpenClaw builds (#6024)", async () => { + const patchStagedDockerfile = vi.fn(); + + await prepareSandboxDockerfilePatch({ + agent: { name: "openclaw" } as any, + fromDockerfile: null, + sandboxBaseImage: "ghcr.io/nvidia/nemoclaw/sandbox-base", + sandboxBaseTag: "latest", + stagedDockerfile: "/tmp/Dockerfile", + model: "model-a", + chatUiUrl: "http://127.0.0.1:7000", + provider: null, + preferredInferenceApi: null, + webSearchConfig: null, + hermesToolGateways: [], + sandboxGpuConfig, + deps: { + isLinuxDockerDriverGatewayEnabled: vi.fn(() => false), + isWsl: vi.fn(() => true), + enforceDockerGpuPatchPreserveNetwork: vi.fn(async () => false), + patchStagedDockerfile, + now: () => 1, + }, + }); + + expect(patchStagedDockerfile.mock.calls[0]?.[11]).toMatchObject({ + trustedManagedDockerfile: true, + wslDashboardExposure: true, + }); + }); + it("skips base-image resolution for agent default Dockerfiles", async () => { const pullAndResolveBaseImageDigest = vi.fn(); const dockerImageInspect = vi.fn(); @@ -188,6 +226,7 @@ describe("prepareSandboxDockerfilePatch", () => { sandboxGpuConfig, deps: { isLinuxDockerDriverGatewayEnabled: vi.fn(() => false), + isWsl: vi.fn(() => false), pullAndResolveBaseImageDigest, dockerImageInspect, enforceDockerGpuPatchPreserveNetwork: vi.fn(async () => false), @@ -202,6 +241,7 @@ describe("prepareSandboxDockerfilePatch", () => { expect(patchStagedDockerfile.mock.calls[0]?.[11]).toEqual({ buildIdPolicy: "preserve", toolDisclosure: "progressive", + trustedManagedDockerfile: true, requireToolDisclosureContract: false, }); }); @@ -224,6 +264,7 @@ describe("prepareSandboxDockerfilePatch", () => { sandboxGpuConfig, deps: { isLinuxDockerDriverGatewayEnabled: vi.fn(() => false), + isWsl: vi.fn(() => false), enforceDockerGpuPatchPreserveNetwork: vi.fn(async () => false), patchStagedDockerfile, now: () => 1, @@ -309,6 +350,7 @@ describe("prepareSandboxDockerfilePatch", () => { expect(patchStagedDockerfile.mock.calls[0]?.[11]).toEqual({ buildIdPolicy: "rewrite", toolDisclosure: "progressive", + trustedManagedDockerfile: true, requireToolDisclosureContract: false, }); }); diff --git a/src/lib/onboard/sandbox-dockerfile-patch-flow.ts b/src/lib/onboard/sandbox-dockerfile-patch-flow.ts index d623bbd1dbb..30589b67a2c 100644 --- a/src/lib/onboard/sandbox-dockerfile-patch-flow.ts +++ b/src/lib/onboard/sandbox-dockerfile-patch-flow.ts @@ -25,6 +25,7 @@ export type SandboxDockerfilePatchDeps = { dockerImageInspect?: (target: string, opts?: Record) => DockerRunResult; isLinuxDockerDriverGatewayEnabled?: () => boolean; enforceDockerGpuPatchPreserveNetwork?: EnforceDockerGpuPatchPreserveNetwork; + isWsl?: () => boolean; patchStagedDockerfile?: PatchStagedDockerfile; now?: () => number; }; @@ -56,6 +57,7 @@ export type PrepareSandboxDockerfilePatchInput = { export type SandboxDockerfilePatchResult = { buildId: string; + dashboardRemoteBindPrepared: boolean; resolvedBaseImage: ResolvedSandboxBaseImage | null; }; @@ -79,6 +81,11 @@ function linuxDockerDriverGatewayEnabled(): boolean { return isLinuxDockerDriverGatewayEnabled(); } +function wslHostDetected(): boolean { + const { isWsl } = require("../platform") as typeof import("../platform"); + return isWsl(); +} + function enforceDockerGpuPatchPreserveNetwork( ...args: Parameters ): ReturnType { @@ -172,11 +179,13 @@ export async function prepareSandboxDockerfilePatch({ // checked in here and known not to consume it. Custom --from Dockerfiles // and other managed agents retain the historical per-run rewrite. const managedAgentName = agent?.name ?? "openclaw"; + const managedOpenClawWslExposure = + !fromDockerfile && managedAgentName === "openclaw" && (deps.isWsl ?? wslHostDetected)(); const buildIdPolicy = !fromDockerfile && STABLE_MANAGED_BUILD_ID_AGENTS.has(managedAgentName) ? "preserve" : "rewrite"; - (deps.patchStagedDockerfile ?? patchStagedDockerfile)( + const patched = (deps.patchStagedDockerfile ?? patchStagedDockerfile)( stagedDockerfile, model, chatUiUrl, @@ -193,6 +202,10 @@ export async function prepareSandboxDockerfilePatch({ return { buildIdPolicy, toolDisclosure, + ...(!fromDockerfile ? { trustedManagedDockerfile: true } : {}), + ...(!fromDockerfile && managedAgentName === "openclaw" + ? { wslDashboardExposure: managedOpenClawWslExposure } + : {}), ...(endpointUrl ? { upstreamEndpointUrl: endpointUrl } : {}), ...(dcodeAutoApprovalMode ? { dcodeAutoApprovalMode } : {}), requireToolDisclosureContract: Boolean(fromDockerfile), @@ -201,5 +214,9 @@ export async function prepareSandboxDockerfilePatch({ })(), ); - return { buildId, resolvedBaseImage: resolved }; + return { + buildId, + dashboardRemoteBindPrepared: patched?.dashboardRemoteBindPrepared === true, + resolvedBaseImage: resolved, + }; } diff --git a/src/lib/onboard/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts index a5e9b666033..7a20b52a092 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -55,6 +55,7 @@ export interface CreatedSandboxRegistryEntryInput { hermesToolGateways: string[]; hermesDashboardState: HermesDashboardOnboardState; dashboardPort: number; + dashboardRemoteBindPrepared?: boolean; gatewayName: string; gatewayPort: number; } @@ -67,15 +68,21 @@ export function creationFidelity( webSearchConfig: WebSearchConfig | null, fromDockerfile: string | null, hermesAuthMethod: "oauth" | "api_key" | null, + dashboardRemoteBindPrepared?: boolean, ): Pick< SandboxEntry, - "webSearchEnabled" | "webSearchProvider" | "fromDockerfile" | "hermesAuthMethod" + | "webSearchEnabled" + | "webSearchProvider" + | "fromDockerfile" + | "hermesAuthMethod" + | "dashboardRemoteBindPrepared" > { return { webSearchEnabled: webSearchConfig?.fetchEnabled === true, webSearchProvider: webSearchConfig ? webSearchProviderForConfig(webSearchConfig) : null, fromDockerfile, hermesAuthMethod, + dashboardRemoteBindPrepared: dashboardRemoteBindPrepared === true, }; } @@ -143,6 +150,7 @@ export function buildCreatedSandboxRegistryEntry( input.hermesToolGateways.length > 0 ? [...input.hermesToolGateways] : undefined, ...getHermesDashboardRegistryFields(input.hermesDashboardState), dashboardPort: input.dashboardPort, + dashboardRemoteBindPrepared: input.dashboardRemoteBindPrepared === true, gatewayName: input.gatewayName, gatewayPort: input.gatewayPort, }; diff --git a/src/lib/onboard/sandbox-reuse.ts b/src/lib/onboard/sandbox-reuse.ts index 32a8c8ad5de..fc261ec5a59 100644 --- a/src/lib/onboard/sandbox-reuse.ts +++ b/src/lib/onboard/sandbox-reuse.ts @@ -41,6 +41,7 @@ export interface ReusedSandboxDashboardStateInput { gatewayName: string; gatewayPort: number; manageDashboard?: boolean; + getSandbox?(sandboxName: string): SandboxEntry | null; ensureDashboardForward(sandboxName: string, chatUiUrl: string): number; hermesDashboardForwarding: ReusedSandboxDashboardForwarding; updateSandbox?(sandboxName: string, updates: Partial): unknown; @@ -65,6 +66,16 @@ export function applyReusedSandboxDashboardState( input: ReusedSandboxDashboardStateInput, ): ReusedSandboxDashboardStateResult { const manageDashboard = input.manageDashboard ?? true; + if ( + manageDashboard && + input.env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0" && + (input.getSandbox ?? registry.getSandbox)(input.sandboxName)?.dashboardRemoteBindPrepared !== + true + ) { + throw new Error( + `Sandbox '${input.sandboxName}' was created without remote dashboard exposure. Re-run onboarding with NEMOCLAW_DASHBOARD_BIND=0.0.0.0 and --recreate-sandbox before opening a remote bind.`, + ); + } const dashboardPort = manageDashboard ? input.ensureDashboardForward(input.sandboxName, input.chatUiUrl) : 0; diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 63588983e08..8f7108c69f3 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -142,6 +142,8 @@ export interface SandboxEntry extends Partial { hermesDashboardInternalPort?: number | null; hermesDashboardTui?: boolean; dashboardPort?: number | null; + /** Remote dashboard exposure was included in the sandbox's generated config. */ + dashboardRemoteBindPrepared?: boolean; // OpenShell gateway registration name and host port bound to this sandbox. // Persisted so later lifecycle commands operate on the sandbox's own gateway // instead of the process-global `nemoclaw` singleton — a second sandbox on a @@ -544,6 +546,7 @@ export function registerSandbox(entry: SandboxEntry): void { hermesDashboardInternalPort: entry.hermesDashboardInternalPort ?? undefined, hermesDashboardTui: entry.hermesDashboardTui === true ? true : undefined, dashboardPort: entry.dashboardPort ?? undefined, + dashboardRemoteBindPrepared: entry.dashboardRemoteBindPrepared === true ? true : undefined, gatewayName: entry.gatewayName ?? undefined, gatewayPort: entry.gatewayPort ?? undefined, }; diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts new file mode 100644 index 00000000000..b78348b4185 --- /dev/null +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -0,0 +1,789 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + hasPreparedRemoteDashboardBind, + patchStagedDockerfile as patchStagedDockerfileImpl, +} from "../src/lib/onboard/dockerfile-patch"; +import { prepareSandboxCreateLaunch } from "../src/lib/onboard/sandbox-create-launch"; +import { prepareSandboxDockerfilePatch } from "../src/lib/onboard/sandbox-dockerfile-patch-flow"; +import { buildCreatedSandboxRegistryEntry } from "../src/lib/onboard/sandbox-registration"; +import { applyReusedSandboxDashboardState } from "../src/lib/onboard/sandbox-reuse"; + +const requireSource = createRequire(import.meta.url); +const { ensureSandboxPortForward } = requireSource( + "../src/lib/actions/sandbox/forward-recovery.js", +) as typeof import("../src/lib/actions/sandbox/forward-recovery.js"); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +function remoteBindDockerfile(...postGeneratorInstructions: string[]): string { + return [ + "FROM scratch", + "ARG NEMOCLAW_MODEL=", + "ARG CHAT_UI_URL=", + "ARG NEMOCLAW_DASHBOARD_BIND=", + "ENV NEMOCLAW_DASHBOARD_BIND=${NEMOCLAW_DASHBOARD_BIND}", + "RUN node --experimental-strip-types /scripts/generate-openclaw-config.mts", + ...postGeneratorInstructions, + ].join("\n"); +} + +const MANAGED_PROXY_PATCH = `RUN python3 -c " import json, os; path = os.path.expanduser('~/.openclaw/openclaw.json'); cfg = json.load(open(path)); cfg.setdefault('gateway', {}).setdefault('auth', {})['token'] = ''; proxy_host = os.environ.get('NEMOCLAW_PROXY_HOST') or '10.200.0.1'; proxy_port = os.environ.get('NEMOCLAW_PROXY_PORT') or '3128'; cfg['proxy'] = { 'enabled': True, 'proxyUrl': f'http://{proxy_host}:{proxy_port}', 'loopbackMode': 'gateway-only', }; json.dump(cfg, open(path, 'w'), indent=2); os.chmod(path, 0o600)"`; + +function patchStagedDockerfile( + dockerfilePath: string, + model: string, + chatUiUrl: string, +): ReturnType { + return patchStagedDockerfileImpl( + dockerfilePath, + model, + chatUiUrl, + undefined, + null, + null, + null, + null, + false, + null, + [], + { trustedManagedDockerfile: true }, + ); +} + +describe("remote dashboard bind production lifecycle", () => { + it.each([ + [ + "pre-generator NODE_OPTIONS", + "ENV NODE_OPTIONS=--require=/tmp/bypass.cjs", + "before-generator", + ], + ["pre-generator PATH", "ENV PATH=/tmp/bypass:${PATH}", "before-generator"], + ["pre-generator SHELL", 'SHELL ["/tmp/bypass-shell", "-c"]', "before-generator"], + ["post-generator PATH", "ENV PATH=/tmp/bypass:${PATH}", "before-config-hash"], + ["post-generator PYTHONPATH", "ENV PYTHONPATH=/tmp/bypass", "before-proxy-patch"], + ["replacement HEALTHCHECK", "HEALTHCHECK CMD /tmp/bypass-healthcheck", "append"], + ["replacement ENTRYPOINT", 'ENTRYPOINT ["/tmp/bypass-entrypoint"]', "append"], + ["replacement CMD", 'CMD ["/tmp/bypass-command"]', "append"], + ])("rejects custom --from remote bind with %s (#6024)", async (_label, instruction, location) => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-from-")); + const dockerfile = path.join(directory, "Dockerfile"); + const stockDockerfile = fs.readFileSync(path.join(process.cwd(), "Dockerfile"), "utf8"); + const generator = + "RUN NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 node --experimental-strip-types /scripts/generate-openclaw-config.mts"; + const proxyPatch = 'RUN python3 -c "\\\n'; + const configHash = + "RUN sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash"; + const body = + location === "before-generator" + ? stockDockerfile.replace(generator, `${instruction}\n${generator}`) + : location === "before-proxy-patch" + ? stockDockerfile.replace(proxyPatch, `${instruction}\n${proxyPatch}`) + : location === "before-config-hash" + ? stockDockerfile.replace(configHash, `${instruction}\n${configHash}`) + : `${stockDockerfile}\n${instruction}\n`; + fs.writeFileSync(dockerfile, body); + + try { + await expect( + prepareSandboxDockerfilePatch({ + agent: { name: "openclaw" } as never, + fromDockerfile: dockerfile, + sandboxBaseImage: "ghcr.io/nvidia/nemoclaw/sandbox-base", + sandboxBaseTag: "latest", + stagedDockerfile: dockerfile, + model: "test-model", + chatUiUrl: "http://127.0.0.1:18789", + provider: null, + preferredInferenceApi: null, + webSearchConfig: null, + hermesToolGateways: [], + sandboxGpuConfig: { mode: "0" } as never, + log: vi.fn(), + deps: { + isLinuxDockerDriverGatewayEnabled: () => false, + pullAndResolveBaseImageDigest: () => ({ + digest: "sha256:custom", + ref: "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:custom", + }), + enforceDockerGpuPatchPreserveNetwork: async () => false, + now: () => 1, + }, + }), + ).rejects.toThrow(/custom --from Dockerfiles.*runtime configuration attestation/); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("prepares remote bind from the exact checked-in Dockerfile instructions (#6024)", () => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-stock-")); + const dockerfile = path.join(directory, "Dockerfile"); + fs.copyFileSync(path.join(process.cwd(), "Dockerfile"), dockerfile); + + try { + const result = patchStagedDockerfile(dockerfile, "test-model", "http://127.0.0.1:18789"); + expect(result.dashboardRemoteBindPrepared).toBe(true); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("carries the audited remote-exposure signal through image and sandbox creation (#6024)", () => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-")); + const dockerfile = path.join(directory, "Dockerfile"); + fs.writeFileSync( + dockerfile, + [ + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_DASHBOARD_BIND=", + "ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0", + "ENV NEMOCLAW_DASHBOARD_BIND=${NEMOCLAW_DASHBOARD_BIND}", + "RUN node --experimental-strip-types /scripts/generate-openclaw-config.mts", + ].join("\n"), + ); + + try { + patchStagedDockerfile(dockerfile, "test-model", "http://127.0.0.1:18789"); + expect(fs.readFileSync(dockerfile, "utf8")).toContain("ARG NEMOCLAW_DASHBOARD_BIND=0.0.0.0"); + + const launch = prepareSandboxCreateLaunch({ + agent: { name: "openclaw" } as never, + chatUiUrl: "http://127.0.0.1:18789", + createArgs: [], + env: { NEMOCLAW_DASHBOARD_BIND: "0.0.0.0" }, + extraPlaceholderKeys: [], + getDashboardForwardPort: () => "18789", + hermesDashboardState: { enabled: false, config: null }, + openshellShellCommand: (args) => args.join(" "), + buildEnv: () => ({}), + }); + expect(launch.envArgs).toContain("NEMOCLAW_DASHBOARD_BIND=0.0.0.0"); + + const entry = buildCreatedSandboxRegistryEntry({ + sandboxName: "beta", + inferenceSelection: { + model: "test-model", + provider: "nvidia", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + compatibleEndpointReasoning: null, + nimContainer: null, + }, + runtimeFields: { + gpuEnabled: false, + hostGpuDetected: false, + sandboxGpuEnabled: false, + sandboxGpuMode: "0", + sandboxGpuDevice: null, + sandboxGpuProof: null, + openshellDriver: "docker", + openshellVersion: "0.1.2", + }, + agent: { name: "openclaw" } as never, + agentVersionKnown: true, + imageTag: null, + appliedPolicies: [], + plannedMessagingState: undefined, + hermesToolGateways: [], + hermesDashboardState: { enabled: false, config: null }, + dashboardPort: 18789, + dashboardRemoteBindPrepared: hasPreparedRemoteDashboardBind(dockerfile), + gatewayName: "nemoclaw", + gatewayPort: 8080, + }); + expect(entry.dashboardRemoteBindPrepared).toBe(true); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("refuses remote preparation when a custom Dockerfile lacks the bind contract (#6024)", () => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-custom-")); + const dockerfile = path.join(directory, "Dockerfile"); + fs.writeFileSync( + dockerfile, + ["ARG NEMOCLAW_MODEL=", "ARG CHAT_UI_URL=", "FROM scratch"].join("\n"), + ); + + try { + expect(() => + patchStagedDockerfile(dockerfile, "test-model", "http://127.0.0.1:18789"), + ).toThrow(/missing ARG NEMOCLAW_DASHBOARD_BIND/); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("refuses remote preparation when a custom Dockerfile declares but never consumes the bind arg (#6024)", () => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-unused-")); + const dockerfile = path.join(directory, "Dockerfile"); + fs.writeFileSync( + dockerfile, + [ + "FROM scratch", + "ARG NEMOCLAW_MODEL=", + "ARG CHAT_UI_URL=", + "ARG NEMOCLAW_DASHBOARD_BIND=", + ].join("\n"), + ); + + try { + expect(() => + patchStagedDockerfile(dockerfile, "test-model", "http://127.0.0.1:18789"), + ).toThrow(/does not promote it to generate-openclaw-config/); + expect(hasPreparedRemoteDashboardBind(dockerfile)).toBe(false); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("rejects remote-bind proof that only appears in an unused build stage (#6024)", () => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-decoy-")); + const dockerfile = path.join(directory, "Dockerfile"); + fs.writeFileSync( + dockerfile, + [ + "FROM scratch AS decoy", + "ARG NEMOCLAW_DASHBOARD_BIND=", + "ENV NEMOCLAW_DASHBOARD_BIND=${NEMOCLAW_DASHBOARD_BIND}", + "RUN node --experimental-strip-types /scripts/generate-openclaw-config.mts", + "FROM scratch", + "ARG NEMOCLAW_MODEL=", + "ARG CHAT_UI_URL=", + "ARG NEMOCLAW_DASHBOARD_BIND=", + ].join("\n"), + ); + + try { + expect(() => + patchStagedDockerfile(dockerfile, "test-model", "http://127.0.0.1:18789"), + ).toThrow(/does not promote it to generate-openclaw-config/); + expect(hasPreparedRemoteDashboardBind(dockerfile)).toBe(false); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("rejects final-stage config overwrites after the remote-bind generator (#6024)", () => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-overwrite-")); + const dockerfile = path.join(directory, "Dockerfile"); + fs.writeFileSync( + dockerfile, + [ + "FROM scratch", + "ARG NEMOCLAW_MODEL=", + "ARG CHAT_UI_URL=", + "ARG NEMOCLAW_DASHBOARD_BIND=", + "ENV NEMOCLAW_DASHBOARD_BIND=${NEMOCLAW_DASHBOARD_BIND}", + "RUN node --experimental-strip-types /scripts/generate-openclaw-config.mts", + "RUN printf '{}' > /sandbox/.openclaw/openclaw.json", + ].join("\n"), + ); + + try { + expect(() => + patchStagedDockerfile(dockerfile, "test-model", "http://127.0.0.1:18789"), + ).toThrow(/preserve the generated remote dashboard output/); + expect(hasPreparedRemoteDashboardBind(dockerfile)).toBe(false); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it.each([ + [ + "generator", + remoteBindDockerfile().replace( + "RUN node --experimental-strip-types /scripts/generate-openclaw-config.mts", + "RUN node --experimental-strip-types /scripts/generate-openclaw-config.mts && printf '{}' > /sandbox/.openclaw/openclaw.json", + ), + ], + [ + "allowlisted command", + remoteBindDockerfile( + "RUN chmod 660 /sandbox/.openclaw/openclaw.json && printf '{}' > /sandbox/.openclaw/openclaw.json", + ), + ], + ])("rejects a compound %s instruction that appends a config rewrite (#6024)", (_label, body) => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-compound-")); + const dockerfile = path.join(directory, "Dockerfile"); + fs.writeFileSync(dockerfile, body); + + try { + expect(() => + patchStagedDockerfile(dockerfile, "test-model", "http://127.0.0.1:18789"), + ).toThrow(/generate-openclaw-config|preserve the generated remote dashboard output/); + expect(hasPreparedRemoteDashboardBind(dockerfile)).toBe(false); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("rejects Node rewrites after the remote-bind generator (#6024)", () => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-node-")); + const dockerfile = path.join(directory, "Dockerfile"); + fs.writeFileSync( + dockerfile, + remoteBindDockerfile( + `RUN node -e "require('node:fs').writeFileSync('/sandbox/.openclaw/openclaw.json','{}')"`, + ), + ); + + try { + expect(() => + patchStagedDockerfile(dockerfile, "test-model", "http://127.0.0.1:18789"), + ).toThrow(/preserve the generated remote dashboard output/); + expect(hasPreparedRemoteDashboardBind(dockerfile)).toBe(false); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("rejects Python rewrites after the remote-bind generator (#6024)", () => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-python-")); + const dockerfile = path.join(directory, "Dockerfile"); + fs.writeFileSync( + dockerfile, + remoteBindDockerfile( + `RUN python3 -c "import json; json.dump({}, open('/sandbox/.openclaw/openclaw.json','w'))"`, + ), + ); + + try { + expect(() => + patchStagedDockerfile(dockerfile, "test-model", "http://127.0.0.1:18789"), + ).toThrow(/preserve the generated remote dashboard output/); + expect(hasPreparedRemoteDashboardBind(dockerfile)).toBe(false); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("rejects tee rewrites after the remote-bind generator (#6024)", () => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-tee-")); + const dockerfile = path.join(directory, "Dockerfile"); + fs.writeFileSync( + dockerfile, + remoteBindDockerfile("RUN printf '{}' | tee /sandbox/.openclaw/openclaw.json >/dev/null"), + ); + + try { + expect(() => + patchStagedDockerfile(dockerfile, "test-model", "http://127.0.0.1:18789"), + ).toThrow(/preserve the generated remote dashboard output/); + expect(hasPreparedRemoteDashboardBind(dockerfile)).toBe(false); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("allows final-stage config metadata updates after the remote-bind generator (#6024)", () => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-metadata-")); + const dockerfile = path.join(directory, "Dockerfile"); + fs.writeFileSync( + dockerfile, + [ + "FROM scratch", + "ARG NEMOCLAW_MODEL=", + "ARG CHAT_UI_URL=", + "ARG NEMOCLAW_DASHBOARD_BIND=", + "ENV NEMOCLAW_DASHBOARD_BIND=${NEMOCLAW_DASHBOARD_BIND}", + "RUN node --experimental-strip-types /scripts/generate-openclaw-config.mts", + "RUN chmod 660 /sandbox/.openclaw/openclaw.json", + "RUN sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash", + ].join("\n"), + ); + + try { + expect(() => + patchStagedDockerfile(dockerfile, "test-model", "http://127.0.0.1:18789"), + ).not.toThrow(); + expect(hasPreparedRemoteDashboardBind(dockerfile)).toBe(true); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("allows the managed token/proxy patch and hash refresh after the remote-bind generator (#6024)", () => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-managed-")); + const dockerfile = path.join(directory, "Dockerfile"); + fs.writeFileSync( + dockerfile, + remoteBindDockerfile( + MANAGED_PROXY_PATCH, + "RUN sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash && chmod 660 /sandbox/.openclaw/.config-hash && chown sandbox:sandbox /sandbox/.openclaw/.config-hash", + ), + ); + + try { + expect(() => + patchStagedDockerfile(dockerfile, "test-model", "http://127.0.0.1:18789"), + ).not.toThrow(); + expect(hasPreparedRemoteDashboardBind(dockerfile)).toBe(true); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("rejects a config reset embedded inside the managed proxy patch shape (#6024)", () => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-embedded-")); + const dockerfile = path.join(directory, "Dockerfile"); + const embeddedReset = MANAGED_PROXY_PATCH.replace( + "proxy_host = os.environ.get", + "cfg = {}; proxy_host = os.environ.get", + ); + fs.writeFileSync(dockerfile, remoteBindDockerfile(embeddedReset)); + + try { + expect(() => + patchStagedDockerfile(dockerfile, "test-model", "http://127.0.0.1:18789"), + ).toThrow(/preserve the generated remote dashboard output/); + expect(hasPreparedRemoteDashboardBind(dockerfile)).toBe(false); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("rejects final-stage config regeneration after the remote-bind generator (#6024)", () => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-regenerate-")); + const dockerfile = path.join(directory, "Dockerfile"); + fs.writeFileSync( + dockerfile, + [ + "FROM scratch", + "ARG NEMOCLAW_MODEL=", + "ARG CHAT_UI_URL=", + "ARG NEMOCLAW_DASHBOARD_BIND=", + "ENV NEMOCLAW_DASHBOARD_BIND=${NEMOCLAW_DASHBOARD_BIND}", + "RUN node --experimental-strip-types /scripts/generate-openclaw-config.mts", + "RUN node --experimental-strip-types /scripts/generate-openclaw-config.mts", + ].join("\n"), + ); + + try { + expect(() => + patchStagedDockerfile(dockerfile, "test-model", "http://127.0.0.1:18789"), + ).toThrow(/preserve the generated remote dashboard output/); + expect(hasPreparedRemoteDashboardBind(dockerfile)).toBe(false); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("allows validation-home config generation after the remote-bind generator (#6024)", () => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-validation-")); + const dockerfile = path.join(directory, "Dockerfile"); + fs.writeFileSync( + dockerfile, + [ + "FROM scratch", + "ARG NEMOCLAW_MODEL=", + "ARG CHAT_UI_URL=", + "ARG NEMOCLAW_DASHBOARD_BIND=", + "ENV NEMOCLAW_DASHBOARD_BIND=${NEMOCLAW_DASHBOARD_BIND}", + "RUN node --experimental-strip-types /scripts/generate-openclaw-config.mts", + 'RUN validation_home="$validation_root/progressive"; HOME="$validation_home" node --experimental-strip-types /scripts/generate-openclaw-config.mts', + ].join("\n"), + ); + + try { + expect(() => + patchStagedDockerfile(dockerfile, "test-model", "http://127.0.0.1:18789"), + ).not.toThrow(); + expect(hasPreparedRemoteDashboardBind(dockerfile)).toBe(true); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("fails closed when connect requests remote exposure for a local-only sandbox (#6024)", () => { + const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); + const registry = requireSource("../src/lib/state/registry.js"); + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "beta", + dashboardPort: 18789, + }); + const runOpenshell = vi.spyOn(openshellRuntime, "runOpenshell"); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(ensureSandboxPortForward("beta")).toBe(false); + expect(runOpenshell).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith(expect.stringContaining("not prepared for remote exposure")); + }); + + it("refuses to reuse a local-only sandbox for remote exposure during onboarding (#6024)", () => { + const ensureDashboardForward = vi.fn(); + expect(() => + applyReusedSandboxDashboardState({ + sandboxName: "beta", + chatUiUrl: "http://127.0.0.1:18789", + env: { NEMOCLAW_DASHBOARD_BIND: "0.0.0.0" }, + agent: { name: "openclaw" } as never, + model: "test-model", + provider: "nvidia", + selectionVerified: true, + sandboxGpuConfig: { mode: "0" } as never, + gatewayName: "nemoclaw", + gatewayPort: 8080, + getSandbox: () => ({ name: "beta" }), + ensureDashboardForward, + hermesDashboardForwarding: { + resolveStateForPort: () => ({ enabled: false, config: null }), + ensureForState: vi.fn(), + }, + updateReusedSandboxMetadata: vi.fn(), + }), + ).toThrow(/--recreate-sandbox/); + expect(ensureDashboardForward).not.toHaveBeenCalled(); + }); + + it("force-restarts a healthy forward on all interfaces only after preparation (#6024)", () => { + const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); + const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.js"); + const registry = requireSource("../src/lib/state/registry.js"); + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "0"); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "beta", + dashboardPort: 18789, + dashboardRemoteBindPrepared: true, + }); + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockReturnValue(true); + vi.spyOn(openshellRuntime, "captureOpenshell").mockReturnValue({ + status: 0, + output: "SANDBOX BIND PORT PID STATUS\nbeta 0.0.0.0 18789 12345 running", + }); + const runOpenshell = vi + .spyOn(openshellRuntime, "runOpenshell") + .mockReturnValue({ status: 0 } as never); + + expect(ensureSandboxPortForward("beta")).toBe(true); + expect(runOpenshell).toHaveBeenCalledWith( + ["forward", "stop", "18789", "beta"], + expect.anything(), + ); + expect(runOpenshell).toHaveBeenCalledWith( + ["forward", "start", "--background", "0.0.0.0:18789", "beta"], + { ignoreError: true }, + ); + }); + + it("rejects a loopback forward after requesting remote exposure (#6024)", () => { + const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); + const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.js"); + const registry = requireSource("../src/lib/state/registry.js"); + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "0"); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "beta", + dashboardPort: 18789, + dashboardRemoteBindPrepared: true, + }); + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockReturnValue(true); + vi.spyOn(openshellRuntime, "captureOpenshell").mockReturnValue({ + status: 0, + output: "SANDBOX BIND PORT PID STATUS\nbeta 127.0.0.1 18789 12345 running", + }); + const runOpenshell = vi + .spyOn(openshellRuntime, "runOpenshell") + .mockReturnValue({ status: 0 } as never); + + expect(ensureSandboxPortForward("beta")).toBe(false); + expect(runOpenshell).toHaveBeenCalledWith( + ["forward", "start", "--background", "0.0.0.0:18789", "beta"], + { ignoreError: true }, + ); + }); + + it("does not replace another sandbox's forward during remote-bind recovery (#6024)", () => { + const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); + const registry = requireSource("../src/lib/state/registry.js"); + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "beta", + dashboardPort: 18789, + dashboardRemoteBindPrepared: true, + }); + vi.spyOn(openshellRuntime, "captureOpenshell").mockReturnValue({ + status: 0, + output: "SANDBOX BIND PORT PID STATUS\nalpha 0.0.0.0 18789 12345 running", + }); + const runOpenshell = vi.spyOn(openshellRuntime, "runOpenshell"); + + expect(ensureSandboxPortForward("beta")).toBe(false); + expect(runOpenshell).not.toHaveBeenCalled(); + }); + + it("forceRestart re-verifies remote-bind preparation before opening the forward (#6024)", () => { + const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); + const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.js"); + const registry = requireSource("../src/lib/state/registry.js"); + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "0"); + vi.spyOn(registry, "getSandbox") + .mockReturnValueOnce({ + name: "beta", + dashboardPort: 18789, + dashboardRemoteBindPrepared: true, + }) + .mockReturnValueOnce({ + name: "beta", + dashboardPort: 18789, + dashboardRemoteBindPrepared: true, + }) + .mockReturnValue({ name: "beta", dashboardPort: 18789 }); + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockReturnValue(false); + vi.spyOn(openshellRuntime, "captureOpenshell").mockReturnValue({ status: 0, output: "" }); + const runOpenshell = vi + .spyOn(openshellRuntime, "runOpenshell") + .mockReturnValue({ status: 0 } as never); + + expect(ensureSandboxPortForward("beta")).toBe(false); + expect( + runOpenshell.mock.calls.some( + ([rawArgs]) => Array.isArray(rawArgs) && rawArgs[0] === "forward" && rawArgs[1] === "start", + ), + ).toBe(false); + }); + + it("restores loopback when default connect finds an all-interface forward (#6024)", () => { + const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); + const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.js"); + const registry = requireSource("../src/lib/state/registry.js"); + let started = false; + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", ""); + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "0"); + vi.stubEnv("WSL_DISTRO_NAME", ""); + vi.stubEnv("WSL_INTEROP", ""); + vi.spyOn(os, "release").mockReturnValue("6.8.0-linux"); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "beta", + dashboardPort: 18789, + dashboardRemoteBindPrepared: true, + }); + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockReturnValue(true); + vi.spyOn(openshellRuntime, "captureOpenshell").mockImplementation(() => ({ + status: 0, + output: started + ? "SANDBOX BIND PORT PID STATUS\nbeta 127.0.0.1 18789 12345 running" + : "SANDBOX BIND PORT PID STATUS\nbeta 0.0.0.0 18789 12345 running", + })); + const runOpenshell = vi + .spyOn(openshellRuntime, "runOpenshell") + .mockImplementation((rawArgs: unknown) => { + const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; + started ||= args[0] === "forward" && args[1] === "start"; + return { status: 0 } as never; + }); + + expect(ensureSandboxPortForward("beta")).toBe(true); + expect(runOpenshell).toHaveBeenCalledWith( + ["forward", "stop", "18789", "beta"], + expect.anything(), + ); + expect(runOpenshell).toHaveBeenCalledWith( + ["forward", "start", "--background", "18789", "beta"], + { ignoreError: true }, + ); + }); + + it("restores an all-interface forward for WSL without remote-bind opt-in (#6024)", () => { + const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); + const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.js"); + const registry = requireSource("../src/lib/state/registry.js"); + let started = false; + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", ""); + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "0"); + vi.stubEnv("WSL_DISTRO_NAME", "Ubuntu"); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "beta", + dashboardPort: 18789, + }); + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockImplementation(() => started); + vi.spyOn(openshellRuntime, "captureOpenshell").mockImplementation(() => ({ + status: 0, + output: started + ? "SANDBOX BIND PORT PID STATUS\nbeta 0.0.0.0 18789 12345 running" + : "", + })); + const runOpenshell = vi + .spyOn(openshellRuntime, "runOpenshell") + .mockImplementation((rawArgs: unknown) => { + const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; + started ||= args[0] === "forward" && args[1] === "start"; + return { status: 0 } as never; + }); + + expect(ensureSandboxPortForward("beta")).toBe(true); + expect(runOpenshell).toHaveBeenCalledWith( + ["forward", "start", "--background", "0.0.0.0:18789", "beta"], + { ignoreError: true }, + ); + }); + + it("keeps a prepared sandbox on loopback without remote-bind opt-in (#6024)", () => { + const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); + const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.js"); + const registry = requireSource("../src/lib/state/registry.js"); + let started = false; + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", ""); + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "0"); + vi.stubEnv("WSL_DISTRO_NAME", ""); + vi.stubEnv("WSL_INTEROP", ""); + vi.spyOn(os, "release").mockReturnValue("6.8.0-linux"); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "beta", + dashboardPort: 18789, + dashboardRemoteBindPrepared: true, + }); + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockImplementation(() => started); + vi.spyOn(openshellRuntime, "captureOpenshell").mockImplementation(() => ({ + status: 0, + output: started + ? "SANDBOX BIND PORT PID STATUS\nbeta 127.0.0.1 18789 12345 running" + : "", + })); + const runOpenshell = vi + .spyOn(openshellRuntime, "runOpenshell") + .mockImplementation((rawArgs: unknown) => { + const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; + started ||= args[0] === "forward" && args[1] === "start"; + return { status: 0 } as never; + }); + + expect(ensureSandboxPortForward("beta")).toBe(true); + expect(runOpenshell).toHaveBeenCalledWith( + ["forward", "start", "--background", "18789", "beta"], + { ignoreError: true }, + ); + }); +}); diff --git a/test/e2e/brev-e2e.test.ts b/test/e2e/brev-e2e.test.ts index b40f3c72474..3804572f94b 100644 --- a/test/e2e/brev-e2e.test.ts +++ b/test/e2e/brev-e2e.test.ts @@ -892,6 +892,8 @@ function pollForSandboxReady(elapsed: () => string): void { // redirected descriptors lets the SSH session exit cleanly while retaining // least-privilege Docker socket ownership. console.log(`[${elapsed()}] Starting nemoclaw onboard in background...`); + const remoteBindEnv = + TEST_SUITE === "dashboard-remote-bind" ? "NEMOCLAW_DASHBOARD_BIND=0.0.0.0 " : ""; // Launch onboard in background. The SSH command may exit with code 255 // (SSH error) because background processes keep file descriptors open. // That's fine — we just need the process to start; we'll poll for @@ -901,7 +903,7 @@ function pollForSandboxReady(elapsed: () => string): void { [ `source ~/.nvm/nvm.sh 2>/dev/null || true`, `cd ${remoteDir}`, - `sg docker -c ${shellQuote("nohup nemoclaw onboard --non-interactive /tmp/nemoclaw-onboard.log 2>&1 &")}`, + `sg docker -c ${shellQuote(`${remoteBindEnv}nohup nemoclaw onboard --non-interactive /tmp/nemoclaw-onboard.log 2>&1 &`)}`, `sleep 2`, `echo "onboard launched"`, ].join(" && "), @@ -986,6 +988,43 @@ function pollForSandboxReady(elapsed: () => string): void { } } +function readProductDashboardRemoteBindPrepared(): boolean { + const script = [ + `const fs = require("fs");`, + `const os = require("os");`, + `const path = require("path");`, + `const file = path.join(os.homedir(), ".nemoclaw", "sandboxes.json");`, + `const data = JSON.parse(fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "{}");`, + `process.stdout.write(String(data?.sandboxes?.["e2e-test"]?.dashboardRemoteBindPrepared === true));`, + ].join(""); + return ssh(`node -e ${shellQuote(script)}`, { timeout: 10_000 }).trim() === "true"; +} + +const DASHBOARD_REMOTE_BIND_PROOF_WAIT_MS = 180_000; +const DASHBOARD_REMOTE_BIND_PROOF_POLL_MS = 5_000; + +function waitForProductDashboardRemoteBindPrepared(elapsed: () => string): true { + console.log(`[${elapsed()}] Waiting for product-written dashboard remote-bind registry proof...`); + const deadline = Date.now() + DASHBOARD_REMOTE_BIND_PROOF_WAIT_MS; + let prepared = readProductDashboardRemoteBindPrepared(); + while (!prepared && Date.now() < deadline) { + execSync(`sleep ${DASHBOARD_REMOTE_BIND_PROOF_POLL_MS / 1000}`); + prepared = readProductDashboardRemoteBindPrepared(); + } + + const failLog = prepared + ? "" + : ssh("tail -120 /tmp/nemoclaw-onboard.log 2>/dev/null || echo 'no log'", { + timeout: 10_000, + }); + expect( + prepared, + "dashboard-remote-bind E2E did not observe product-written remote bind proof before " + + `manual registry handoff.\n${failLog}`, + ).toBe(true); + return true; +} + /** * Kill the hung onboard process tree and write the sandbox registry manually. * @@ -997,6 +1036,12 @@ function pollForSandboxReady(elapsed: () => string): void { */ function writeManualRegistry(elapsed: () => string): void { console.log(`[${elapsed()}] Sandbox ready — killing hung onboard and writing registry...`); + const dashboardRemoteBindPrepared = + TEST_SUITE === "dashboard-remote-bind" && waitForProductDashboardRemoteBindPrepared(elapsed); + expect( + dashboardRemoteBindPrepared || TEST_SUITE !== "dashboard-remote-bind", + "dashboard-remote-bind E2E must preserve the product-written remote bind proof", + ).toBe(true); // Kill hung onboard processes. pkill may kill the SSH connection itself // if the pattern matches too broadly, so wrap in try/catch. try { @@ -1023,6 +1068,7 @@ function writeManualRegistry(elapsed: () => string): void { provider: null, gpuEnabled: false, policies: ["pypi", "npm"], + ...(dashboardRemoteBindPrepared ? { dashboardRemoteBindPrepared: true } : {}), }, }, }, @@ -1263,7 +1309,7 @@ describe.runIf(hasRequiredVars && hasAuthenticatedBrev)("Brev E2E", () => { ); it.runIf(TEST_SUITE === "dashboard-remote-bind")( - "dashboard forward binds to all interfaces for remote browser origins", + "clean-host remote bind reaches Ready with active audit findings and wildcard forwarding", () => { const output = runRemoteCommand( [ @@ -1276,7 +1322,7 @@ describe.runIf(hasRequiredVars && hasAuthenticatedBrev)("Brev E2E", () => { ].join(" "), 300_000, ); - expect(output).toContain("dashboard forward binds all interfaces"); + expect(output).toContain("clean-host remote bind keeps audit risks active"); expect(output).not.toMatch(/FAIL|Failed/i); }, 300_000, diff --git a/test/e2e/live/dashboard-remote-bind.test.ts b/test/e2e/live/dashboard-remote-bind.test.ts index fcaf782a4c1..22f40ad117a 100644 --- a/test/e2e/live/dashboard-remote-bind.test.ts +++ b/test/e2e/live/dashboard-remote-bind.test.ts @@ -4,8 +4,9 @@ import os from "node:os"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; -import { sandboxAccessEnv } from "../fixtures/clients/sandbox.ts"; +import { sandboxAccessEnv, trustedSandboxShellScript } from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; +import { parseJsonFromText } from "./json-envelope.ts"; // Branch validation provisions and onboards a real remote sandbox first; this // test restarts only that sandbox's dashboard forward and proves the explicit @@ -46,8 +47,26 @@ function remoteHostCandidate(): string { return process.env.NEMOCLAW_E2E_REMOTE_HOST || externalIpv4 || os.hostname(); } +function stripAnsi(output: string): string { + return output.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ""); +} + +function connectStartedDashboardForward( + result: { exitCode: number | null; stdout: string; stderr: string }, + sandboxName: string, + dashboardPort: string, +): boolean { + const output = stripAnsi(`${result.stdout}\n${result.stderr}`); + return ( + result.exitCode === 0 || + (result.exitCode === null && + output.includes(`Forwarding port ${dashboardPort}`) && + output.includes(`sandbox ${sandboxName}`)) + ); +} + runDashboardRemoteBindTest( - "dashboard forward binds all interfaces when remote bind is explicitly requested", + "clean-host remote bind keeps audit risks active and binds all interfaces", async ({ artifacts, host, sandbox }) => { const sandboxName = process.env.NEMOCLAW_SANDBOX_NAME || "e2e-test"; const dashboardPort = process.env.NEMOCLAW_DASHBOARD_PORT || "18789"; @@ -89,7 +108,10 @@ runDashboardRemoteBindTest( }, timeoutMs: 120_000, }); - expect(connect.exitCode, `nemoclaw connect failed\n${connect.stderr}`).toBe(0); + expect( + connectStartedDashboardForward(connect, sandboxName, dashboardPort), + `nemoclaw connect did not complete or print background-forward proof\nstdout:\n${connect.stdout}\nstderr:\n${connect.stderr}`, + ).toBe(true); const forwardList = await sandbox.openshell(["forward", "list"], { artifactName: "dashboard-remote-bind-forward-list", @@ -112,5 +134,33 @@ runDashboardRemoteBindTest( bindsAllInterfaces(forwardLine, dashboardPort), `Could not prove dashboard forward uses 0.0.0.0:${dashboardPort}: ${forwardLine}`, ).toBe(true); + + const audit = await sandbox.execShell( + sandboxName, + trustedSandboxShellScript("openclaw security audit --json"), + { + artifactName: "dashboard-remote-bind-security-audit", + env: sandboxAccessEnv(), + timeoutMs: 60_000, + }, + ); + expect(audit.exitCode, `OpenClaw security audit failed\n${audit.stderr}`).toBe(0); + const auditResult = parseJsonFromText(audit.stdout) as { + findings: Array<{ checkId: string; detail: string }>; + suppressedFindings?: unknown[]; + }; + expect(auditResult.suppressedFindings ?? []).toEqual([]); + expect(auditResult.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: "gateway.control_ui.insecure_auth" }), + expect.objectContaining({ checkId: "gateway.control_ui.device_auth_disabled" }), + expect.objectContaining({ + checkId: "config.insecure_or_dangerous_flags", + detail: expect.stringContaining( + "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true", + ), + }), + ]), + ); }, ); diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 8a48af3502b..d25111d810c 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -25,6 +25,10 @@ "test/e2e/support/openclaw-tui-run-classification.test.ts" ] }, + { + "live": "test/e2e/live/dashboard-remote-bind.test.ts", + "liveOnlyReason": "Requires a real OpenShell dashboard forward on a remote Brev host to prove all-interface bind behavior after background connect." + }, { "live": "test/e2e/live/sandbox-operations.test.ts", "liveOnlyReason": "The final-destroy gateway release contract needs two real Docker/OpenShell sandboxes plus host 127.0.0.1 port binding; a fast mock cannot prove the shared-gateway ownership boundary." diff --git a/test/generate-openclaw-config-security-audit.test.ts b/test/generate-openclaw-config-security-audit.test.ts new file mode 100644 index 00000000000..a6c2ab4c1fe --- /dev/null +++ b/test/generate-openclaw-config-security-audit.test.ts @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { buildConfig } from "../scripts/generate-openclaw-config.mts"; + +const BASE_ENV: Record = { + NEMOCLAW_MODEL: "test-model", + NEMOCLAW_PROVIDER_KEY: "test-provider", + NEMOCLAW_PRIMARY_MODEL_REF: "test-ref", + NEMOCLAW_INFERENCE_BASE_URL: "http://localhost:8080", + NEMOCLAW_INFERENCE_API: "openai", +}; + +function buildSecurityAuditConfig(chatUiUrl: string, overrides: Record = {}): any { + return buildConfig({ ...BASE_ENV, CHAT_UI_URL: chatUiUrl, ...overrides }); +} + +describe("generate-openclaw-config.mts: managed security audit findings", () => { + it("explains NemoClaw-managed insecure auth findings (#6024)", () => { + const config = buildSecurityAuditConfig("http://127.0.0.1:18789"); + expect(config.gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback).toBeUndefined(); + expect(config.security.audit.suppressions).toEqual([ + { + checkId: "gateway.control_ui.insecure_auth", + reason: + "NemoClaw derives this setting from a loopback HTTP CHAT_UI_URL; use HTTPS for non-loopback dashboards.", + }, + { + checkId: "config.insecure_or_dangerous_flags", + detailIncludes: "gateway.controlUi.allowInsecureAuth=true", + reason: + "NemoClaw derives this setting from a loopback HTTP CHAT_UI_URL; use HTTPS for non-loopback dashboards.", + }, + ]); + }); + + it("keeps remote device auth findings active (#6024)", () => { + const config = buildSecurityAuditConfig("https://nemoclaw0-xxx.brevlab.com:18789", { + NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + }); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + expect(config.security).toBeUndefined(); + }); + + it("keeps all remote HTTP security findings active (#6024)", () => { + const config = buildSecurityAuditConfig("http://remote.example:18789", { + NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + }); + expect(config.gateway.controlUi.allowInsecureAuth).toBe(true); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + expect(config.gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback).toBeUndefined(); + expect(config.security).toBeUndefined(); + }); + + it("keeps loopback HTTP findings active when the dashboard bind is remote (#6024)", () => { + const config = buildSecurityAuditConfig("http://127.0.0.1:18789", { + NEMOCLAW_DASHBOARD_BIND: "0.0.0.0", + }); + expect(config.gateway.controlUi.allowInsecureAuth).toBe(true); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + expect(config.gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback).toBe(true); + expect(config.security?.audit?.suppressions ?? []).toEqual([]); + }); + + it("keeps loopback HTTP findings active for a WSL all-interface forward (#6024)", () => { + const config = buildSecurityAuditConfig("http://127.0.0.1:18789", { + NEMOCLAW_WSL_DASHBOARD_EXPOSURE: "1", + NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE: "managed-onboard", + }); + expect(config.gateway.controlUi.allowInsecureAuth).toBe(true); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + expect(config.gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback).toBeUndefined(); + expect(config.security?.audit?.suppressions ?? []).toEqual([]); + }); + + it("keeps explicit device auth findings active when the dashboard bind is remote (#6024)", () => { + const config = buildSecurityAuditConfig("http://127.0.0.1:18789", { + NEMOCLAW_DASHBOARD_BIND: "0.0.0.0", + NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + }); + expect(config.gateway.controlUi.allowInsecureAuth).toBe(true); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + expect(config.security).toBeUndefined(); + }); + + it("keeps an operator device auth opt-out active on loopback (#6024)", () => { + const config = buildSecurityAuditConfig("https://127.0.0.1:18789", { + NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE: "operator", + }); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + expect(config.security).toBeUndefined(); + }); + + it("reports the managed onboarding device auth compatibility source truthfully (#6024)", () => { + const config = buildSecurityAuditConfig("https://127.0.0.1:18789", { + NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE: "managed-onboard", + }); + + expect(config.security.audit.suppressions[0].reason).toContain("NemoClaw onboarding"); + expect(config.security.audit.suppressions[0].reason).not.toContain("explicitly opts out"); + }); + + it("keeps device auth findings active when opt-out provenance is missing (#6024)", () => { + const config = buildSecurityAuditConfig("https://127.0.0.1:18789", { + NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + }); + + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + expect(config.security).toBeUndefined(); + }); + + it.each([ + ["NEMOCLAW_DASHBOARD_BIND", "127.0.0.1"], + ["NEMOCLAW_WSL_DASHBOARD_EXPOSURE", "2"], + ["NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE", "untrusted"], + ])("rejects an invalid %s value (#6024)", (name, value) => { + expect(() => buildSecurityAuditConfig("https://127.0.0.1:18789", { [name]: value })).toThrow( + `${name} must be empty or one of:`, + ); + }); + + it("omits audit suppressions for a loopback HTTPS dashboard (#6024)", () => { + const config = buildSecurityAuditConfig("https://127.0.0.1:18789"); + expect(config.security).toBeUndefined(); + }); +}); diff --git a/test/helpers/rebuild-managed-image-preflight-harness.ts b/test/helpers/rebuild-managed-image-preflight-harness.ts index 2222cb7277c..9987a29ccba 100644 --- a/test/helpers/rebuild-managed-image-preflight-harness.ts +++ b/test/helpers/rebuild-managed-image-preflight-harness.ts @@ -77,6 +77,7 @@ export async function createPreparedDcodeImageFixture( })); const prepareDockerfilePatch = vi.fn(async () => ({ buildId: "dcode-build-1", + dashboardRemoteBindPrepared: false, resolvedBaseImage: null, })); const buildImage = vi.fn(() => ({ status: 0 }) as never); diff --git a/test/openclaw-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index 2f78dd930b1..21e7177d26a 100644 --- a/test/openclaw-dependency-review.test.ts +++ b/test/openclaw-dependency-review.test.ts @@ -581,7 +581,7 @@ grep -Fq -- '--phase post-agent-install' Dockerfile expect(pr.permissions).toEqual({ contents: "read" }); expect(prJob).toBeUndefined(); - expect(mainJob?.["timeout-minutes"]).toBe(12); + expect(mainJob?.["timeout-minutes"]).toBe(20); expect(requiredStep(mainJob, "Audit the real patched OpenClaw distribution").env).toMatchObject( { NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS: "1", @@ -590,6 +590,12 @@ grep -Fq -- '--phase post-agent-install' Dockerfile expect(requiredStep(mainJob, "Audit the real patched OpenClaw distribution").run).toContain( "test/openclaw-real-patched-dist-harness.test.ts", ); + expect( + requiredStep(mainJob, "Audit managed OpenClaw security finding suppressions").env, + ).toEqual({ NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS: "1" }); + expect( + requiredStep(mainJob, "Audit managed OpenClaw security finding suppressions").run, + ).toContain("test/openclaw-security-audit-suppressions-real.test.ts"); expect(requiredStep(mainJob, "Install test dependencies").run).toBe("npm ci --ignore-scripts"); expect(mainJob.env).toMatchObject({ npm_config_fetch_retries: "3", diff --git a/test/openclaw-security-audit-suppressions-real.test.ts b/test/openclaw-security-audit-suppressions-real.test.ts new file mode 100644 index 00000000000..a7c56295edf --- /dev/null +++ b/test/openclaw-security-audit-suppressions-real.test.ts @@ -0,0 +1,300 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { buildConfig } from "../scripts/generate-openclaw-config.mts"; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const OPENCLAW_AUDIT_TIMEOUT_MS = 120_000; +const OPENCLAW_AUDIT_SUITE_TIMEOUT_MS = OPENCLAW_AUDIT_TIMEOUT_MS * 7; +const BASE_ENV: Record = { + NEMOCLAW_MODEL: "test-model", + NEMOCLAW_PROVIDER_KEY: "test-provider", + NEMOCLAW_PRIMARY_MODEL_REF: "test-provider/test-model", + NEMOCLAW_INFERENCE_BASE_URL: "http://127.0.0.1:8000/v1", + NEMOCLAW_INFERENCE_API: "openai-completions", +}; + +interface AuditFinding { + checkId: string; + severity: string; + detail: string; + remediation?: string; + suppression?: { reason?: string }; +} + +interface AuditResult { + findings: AuditFinding[]; + suppressedFindings?: AuditFinding[]; +} + +interface ReviewedOpenClawPackage { + integrity: string; + tarball: string; + version: string; +} + +function reviewedOpenClawPackage(): ReviewedOpenClawPackage { + const dockerfile = fs.readFileSync(path.join(REPO_ROOT, "Dockerfile"), "utf-8"); + const version = dockerfile.match(/^ARG OPENCLAW_VERSION=([^\s]+)/m)?.[1]; + assert.ok(version, "Dockerfile is missing ARG OPENCLAW_VERSION"); + const pinKey = version.replaceAll(".", "_"); + const integrity = dockerfile.match( + new RegExp(`^ARG OPENCLAW_${pinKey}_INTEGRITY=([^\\s]+)`, "m"), + )?.[1]; + const tarball = dockerfile.match( + new RegExp(`^ARG OPENCLAW_${pinKey}_TARBALL=([^\\s]+)`, "m"), + )?.[1]; + assert.ok(integrity, `Dockerfile is missing the OpenClaw ${version} integrity pin`); + assert.ok(tarball, `Dockerfile is missing the OpenClaw ${version} tarball pin`); + return { integrity, tarball, version }; +} + +function installReviewedOpenClaw(workspace: string): string { + const reviewed = reviewedOpenClawPackage(); + const runtime = path.join(workspace, "runtime"); + const childEnv: NodeJS.ProcessEnv = { + HOME: path.join(workspace, "npm-home"), + NPM_CONFIG_CACHE: path.join(workspace, "npm-cache"), + NPM_CONFIG_FETCH_RETRIES: "3", + NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "10000", + NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "60000", + PATH: process.env.PATH, + }; + const packed = spawnSync( + "npm", + ["pack", reviewed.tarball, "--pack-destination", workspace, "--json"], + { + encoding: "utf-8", + env: childEnv, + maxBuffer: 10 * 1024 * 1024, + timeout: OPENCLAW_AUDIT_TIMEOUT_MS, + }, + ); + const packFailure = packed.error?.message || packed.stderr || packed.stdout || "empty output"; + assert.equal(packed.error, undefined, `OpenClaw npm pack failed: ${packFailure}`); + assert.equal(packed.status, 0, `OpenClaw npm pack failed: ${packFailure}`); + assert.ok(packed.stdout.trim(), `OpenClaw npm pack failed: ${packFailure}`); + const packResult = JSON.parse(packed.stdout)[0] as { filename?: string; integrity?: string }; + assert.equal(packResult.integrity, reviewed.integrity, "OpenClaw tarball integrity mismatch"); + assert.ok(packResult.filename, "OpenClaw npm pack omitted the archive filename"); + assert.equal(path.basename(packResult.filename), packResult.filename, "Unsafe npm pack filename"); + const archive = path.resolve(workspace, packResult.filename); + assert.ok( + archive.startsWith(`${path.resolve(workspace)}${path.sep}`), + "OpenClaw archive escaped workspace", + ); + const installed = spawnSync( + "npm", + ["install", "--prefix", runtime, "--ignore-scripts", "--no-audit", "--no-fund", archive], + { + encoding: "utf-8", + env: childEnv, + maxBuffer: 10 * 1024 * 1024, + timeout: OPENCLAW_AUDIT_TIMEOUT_MS, + }, + ); + const installFailure = + installed.error?.message || installed.stderr || installed.stdout || "empty output"; + assert.equal(installed.error, undefined, `OpenClaw install failed: ${installFailure}`); + assert.equal(installed.status, 0, `OpenClaw install failed: ${installFailure}`); + const binary = path.join(runtime, "node_modules", ".bin", "openclaw"); + assert.ok(fs.existsSync(binary), "Reviewed OpenClaw install omitted its CLI binary"); + return binary; +} + +function runOpenClawAudit( + binary: string, + chatUiUrl: string, + overrides: Record = {}, +): AuditResult { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-audit-")); + try { + const home = path.join(tmp, "home"); + const configDir = path.join(home, ".openclaw"); + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(configDir, "openclaw.json"), + JSON.stringify(buildConfig({ ...BASE_ENV, CHAT_UI_URL: chatUiUrl, ...overrides })), + { mode: 0o600 }, + ); + const audit = spawnSync(binary, ["security", "audit", "--json"], { + encoding: "utf-8", + env: { HOME: home, PATH: process.env.PATH }, + maxBuffer: 10 * 1024 * 1024, + timeout: OPENCLAW_AUDIT_TIMEOUT_MS, + }); + const auditFailure = audit.error?.message || audit.stderr || audit.stdout || "empty output"; + assert.equal(audit.error, undefined, `OpenClaw audit failed: ${auditFailure}`); + assert.equal(audit.status, 0, `OpenClaw audit failed: ${auditFailure}`); + assert.ok(audit.stdout.trim(), `OpenClaw audit failed: ${auditFailure}`); + return JSON.parse(audit.stdout) as AuditResult; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +function findingForFlag(findings: AuditFinding[], flag: string): AuditFinding | undefined { + return findings.find( + (finding) => + finding.checkId === "config.insecure_or_dangerous_flags" && finding.detail.includes(flag), + ); +} + +function managedAuthFindings(findings: AuditFinding[]): AuditFinding[] { + return findings.filter( + (finding) => + finding.checkId === "gateway.control_ui.insecure_auth" || + finding.checkId === "gateway.control_ui.device_auth_disabled" || + findingForFlag([finding], "gateway.controlUi.allowInsecureAuth=true") !== undefined || + findingForFlag([finding], "gateway.controlUi.dangerouslyDisableDeviceAuth=true") !== + undefined, + ); +} + +describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS !== "1")( + "OpenClaw managed security audit consumer contract", + () => { + it( + "pins exact OpenClaw checkIds while suppressing only managed findings (#6024)", + () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-audit-suite-")); + try { + const binary = installReviewedOpenClaw(workspace); + const loopback = runOpenClawAudit(binary, "http://127.0.0.1:18789"); + const loopbackSuppressions = loopback.suppressedFindings ?? []; + const suppressedDirect = loopbackSuppressions.find( + (finding) => finding.checkId === "gateway.control_ui.insecure_auth", + ); + expect(suppressedDirect).toMatchObject({ + severity: "warn", + remediation: expect.stringContaining("HTTPS"), + suppression: { reason: expect.stringContaining("loopback HTTP CHAT_UI_URL") }, + }); + expect( + findingForFlag(loopbackSuppressions, "gateway.controlUi.allowInsecureAuth=true"), + ).toMatchObject({ + severity: "warn", + remediation: expect.any(String), + suppression: { reason: expect.stringContaining("loopback HTTP CHAT_UI_URL") }, + }); + expect( + loopback.findings.some((finding) => finding.checkId === "gateway.loopback_no_auth"), + ).toBe(true); + + const remoteOnboard = runOpenClawAudit(binary, "http://remote.example:18789", { + NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + }); + expect( + remoteOnboard.findings.some( + (finding) => finding.checkId === "gateway.control_ui.insecure_auth", + ), + ).toBe(true); + expect( + findingForFlag(remoteOnboard.findings, "gateway.controlUi.allowInsecureAuth=true"), + ).toBeDefined(); + expect( + remoteOnboard.findings.some( + (finding) => finding.checkId === "gateway.control_ui.device_auth_disabled", + ), + ).toBe(true); + expect( + findingForFlag( + remoteOnboard.findings, + "gateway.controlUi.dangerouslyDisableDeviceAuth=true", + ), + ).toBeDefined(); + expect(managedAuthFindings(remoteOnboard.findings)).toHaveLength(4); + expect(remoteOnboard.suppressedFindings ?? []).toHaveLength(0); + + const remoteWithoutOptOut = runOpenClawAudit(binary, "http://remote.example:18789"); + expect(managedAuthFindings(remoteWithoutOptOut.findings)).toHaveLength(4); + expect(remoteWithoutOptOut.suppressedFindings ?? []).toHaveLength(0); + + const remoteHttpsOnboard = runOpenClawAudit(binary, "https://remote.example:18789", { + NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + }); + expect(managedAuthFindings(remoteHttpsOnboard.findings)).toHaveLength(2); + expect( + remoteHttpsOnboard.findings.some( + (finding) => finding.checkId === "gateway.control_ui.device_auth_disabled", + ), + ).toBe(true); + expect( + findingForFlag( + remoteHttpsOnboard.findings, + "gateway.controlUi.dangerouslyDisableDeviceAuth=true", + ), + ).toBeDefined(); + expect( + remoteHttpsOnboard.findings.some( + (finding) => finding.checkId === "gateway.control_ui.insecure_auth", + ), + ).toBe(false); + expect(remoteHttpsOnboard.suppressedFindings ?? []).toHaveLength(0); + + const remoteBindOnboard = runOpenClawAudit(binary, "http://127.0.0.1:18789", { + NEMOCLAW_DASHBOARD_BIND: "0.0.0.0", + }); + expect(managedAuthFindings(remoteBindOnboard.findings)).toHaveLength(4); + expect( + remoteBindOnboard.findings.some( + (finding) => finding.checkId === "gateway.control_ui.insecure_auth", + ), + ).toBe(true); + expect( + remoteBindOnboard.findings.some( + (finding) => finding.checkId === "gateway.control_ui.device_auth_disabled", + ), + ).toBe(true); + expect( + findingForFlag(remoteBindOnboard.findings, "gateway.controlUi.allowInsecureAuth=true"), + ).toBeDefined(); + expect( + findingForFlag( + remoteBindOnboard.findings, + "gateway.controlUi.dangerouslyDisableDeviceAuth=true", + ), + ).toBeDefined(); + expect( + findingForFlag( + remoteBindOnboard.findings, + "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true", + ), + ).toBeDefined(); + expect(remoteBindOnboard.suppressedFindings ?? []).toHaveLength(0); + + const explicitOptOut = runOpenClawAudit(binary, "https://127.0.0.1:18789", { + NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE: "operator", + }); + expect( + explicitOptOut.findings.find( + (finding) => finding.checkId === "gateway.control_ui.device_auth_disabled", + ), + ).toMatchObject({ + severity: "critical", + remediation: expect.any(String), + }); + expect( + findingForFlag( + explicitOptOut.findings, + "gateway.controlUi.dangerouslyDisableDeviceAuth=true", + ), + ).toBeDefined(); + expect(explicitOptOut.suppressedFindings ?? []).toHaveLength(0); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } + }, + OPENCLAW_AUDIT_SUITE_TIMEOUT_MS, + ); + }, +); diff --git a/test/process-recovery-primitives.test.ts b/test/process-recovery-primitives.test.ts index 4c090259422..700239b4ac4 100644 --- a/test/process-recovery-primitives.test.ts +++ b/test/process-recovery-primitives.test.ts @@ -169,6 +169,34 @@ describe("classifySandboxForwardHealth", () => { ), ).toBe(true); }); + + it("requires the requested bind when classifying a remote forward", () => { + expect( + classifySandboxForwardHealth( + [ + { + sandboxName: "beta", + bind: "127.0.0.1", + port: "18790", + status: "running", + }, + ], + "beta", + "18790", + "0.0.0.0", + ), + ).toBe(false); + expect( + ["::", "[::]", "*"].map((bind) => + classifySandboxForwardHealth( + [{ sandboxName: "beta", bind, port: "18790", status: "running" }], + "beta", + "18790", + "0.0.0.0", + ), + ), + ).toEqual([true, true, true]); + }); }); describe("classifyForwardHealthWithReachability", () => { diff --git a/test/process-recovery.test.ts b/test/process-recovery.test.ts index ab563c92d2d..4c18045f1d4 100644 --- a/test/process-recovery.test.ts +++ b/test/process-recovery.test.ts @@ -178,6 +178,57 @@ beta 127.0.0.1 18789 12345 running`; ).toBe(false); }); + it("restarts a loopback forward when remote dashboard bind is requested (#6024)", () => { + const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); + const agentRuntime = requireSource("../src/lib/agent/runtime.js"); + const registry = requireSource("../src/lib/state/registry.js"); + const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.js"); + const childProcess = requireSource("node:child_process"); + let forwardStarted = false; + + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "0"); + vi.spyOn(childProcess, "spawnSync").mockReturnValue({ + status: 0, + stdout: "__NEMOCLAW_SANDBOX_EXEC_STARTED__\nRUNNING\n", + stderr: "", + } as never); + vi.spyOn(agentRuntime, "getSessionAgent").mockReturnValue(null); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "beta", + agent: "openclaw", + dashboardPort: 18789, + dashboardRemoteBindPrepared: true, + }); + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockReturnValue(true); + vi.spyOn(openshellRuntime, "captureOpenshell").mockImplementation(() => ({ + status: 0, + output: + "SANDBOX BIND PORT PID STATUS\n" + + `beta ${forwardStarted ? "0.0.0.0" : "127.0.0.1"} 18789 12345 running`, + })); + const runOpenshell = vi + .spyOn(openshellRuntime, "runOpenshell") + .mockImplementation((rawArgs: unknown) => { + const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; + forwardStarted ||= args[0] === "forward" && args[1] === "start"; + return { status: 0 } as never; + }); + + expect( + withFakeOpenshellBinary(() => checkAndRecoverSandboxProcesses("beta", { quiet: true })), + ).toEqual({ + checked: true, + wasRunning: true, + recovered: false, + forwardRecovered: true, + }); + expect(runOpenshell).toHaveBeenCalledWith( + ["forward", "start", "--background", "0.0.0.0:18789", "beta"], + { ignoreError: true }, + ); + }); + it("waits for a stopped forward listener to release before starting its replacement", () => { const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.js"); diff --git a/tools/advisors/repo-read-only-tools.mts b/tools/advisors/repo-read-only-tools.mts index e6beede393c..e03437ae53f 100644 --- a/tools/advisors/repo-read-only-tools.mts +++ b/tools/advisors/repo-read-only-tools.mts @@ -44,7 +44,7 @@ function createRepoPathGuard(cwd: string): RepoPathGuard { ? path.join(os.homedir(), normalizedCandidate.slice(2)) : normalizedCandidate; const lexicalPath = path.resolve(lexicalRoot, expandedCandidate); - if (!isContainedPath(lexicalRoot, lexicalPath)) { + if (!isContainedPath(lexicalRoot, lexicalPath) && !isContainedPath(realRoot, lexicalPath)) { throw new Error(`Advisor read-only path is outside the workspace: ${candidate}`); }