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}`);
}