From f69bdb7bea1e2665e2f4b89db6e33ceeaf99f432 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Fri, 10 Jul 2026 02:34:32 +0800 Subject: [PATCH 01/46] fix(openclaw): contextualize managed audit findings Signed-off-by: Chengjie Wang --- docs/security/best-practices.mdx | 20 +++++- scripts/generate-openclaw-config.mts | 28 ++++++++ ...ate-openclaw-config-security-audit.test.ts | 65 +++++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 test/generate-openclaw-config-security-audit.test.ts diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index a55474afc5..aad81d4e09 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 | Enabled for loopback dashboards. NemoClaw disables device authentication for non-loopback `CHAT_UI_URL` values and when `NEMOCLAW_DISABLE_DEVICE_AUTH=1`. | +| What you can change | Use a loopback `CHAT_UI_URL` to retain device authentication. Set `NEMOCLAW_DISABLE_DEVICE_AUTH=1` only as a deliberate build-time opt-out. The setting is baked into `openclaw.json` and 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 @@ -683,6 +683,20 @@ 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 `allowInsecureAuth` and `dangerouslyDisableDeviceAuth` findings visible as accepted findings instead of counting them as unexplained active findings. +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. + ### 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 091f7d867b..6cbd8bdf38 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -1192,6 +1192,31 @@ export function buildConfig(env: Env = process.env): JsonObject { const isRemote = !isLoopback(parsed.hostname || ""); const disableDeviceAuth = env.NEMOCLAW_DISABLE_DEVICE_AUTH === "1" || isRemote; const allowInsecure = parsed.scheme === "http"; + const securityAuditSuppressions: JsonObject[] = []; + if (allowInsecure) { + const reason = + "NemoClaw derives this setting from an 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 (disableDeviceAuth) { + const reason = + "NemoClaw enables this compatibility setting for non-loopback or explicitly opted-out dashboards; use loopback access to retain device authentication."; + 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[] = [ { @@ -1311,6 +1336,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", 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 0000000000..19f98d9df6 --- /dev/null +++ b/test/generate-openclaw-config-security-audit.test.ts @@ -0,0 +1,65 @@ +// 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): any { + return buildConfig({ ...BASE_ENV, CHAT_UI_URL: chatUiUrl }); +} + +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.security.audit.suppressions).toEqual([ + { + checkId: "gateway.control_ui.insecure_auth", + reason: + "NemoClaw derives this setting from an 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 an HTTP CHAT_UI_URL; use HTTPS for non-loopback dashboards.", + }, + ]); + }); + + it("explains NemoClaw-managed device auth findings (#6024)", () => { + const config = buildSecurityAuditConfig("https://nemoclaw0-xxx.brevlab.com:18789"); + expect(config.security.audit.suppressions).toEqual([ + { + checkId: "gateway.control_ui.device_auth_disabled", + reason: + "NemoClaw enables this compatibility setting for non-loopback or explicitly opted-out dashboards; use loopback access to retain device authentication.", + }, + { + checkId: "config.insecure_or_dangerous_flags", + detailIncludes: "gateway.controlUi.dangerouslyDisableDeviceAuth=true", + reason: + "NemoClaw enables this compatibility setting for non-loopback or explicitly opted-out dashboards; use loopback access to retain device authentication.", + }, + ]); + }); + + it("explains both managed flags for a non-loopback HTTP dashboard (#6024)", () => { + const config = buildSecurityAuditConfig("http://remote.example:18789"); + expect(config.security.audit.suppressions).toHaveLength(4); + expect(config.security.audit.suppressions.map((entry: any) => entry.checkId)).toEqual([ + "gateway.control_ui.insecure_auth", + "config.insecure_or_dangerous_flags", + "gateway.control_ui.device_auth_disabled", + "config.insecure_or_dangerous_flags", + ]); + }); +}); From 61b4c2ed9212507b4f7ec90a288c4d5740692c80 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Fri, 10 Jul 2026 02:54:05 +0800 Subject: [PATCH 02/46] test(openclaw): cover secure loopback audit config Signed-off-by: Chengjie Wang --- test/generate-openclaw-config-security-audit.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/generate-openclaw-config-security-audit.test.ts b/test/generate-openclaw-config-security-audit.test.ts index 19f98d9df6..ca3d66fc84 100644 --- a/test/generate-openclaw-config-security-audit.test.ts +++ b/test/generate-openclaw-config-security-audit.test.ts @@ -62,4 +62,9 @@ describe("generate-openclaw-config.mts: managed security audit findings", () => "config.insecure_or_dangerous_flags", ]); }); + + it("omits audit suppressions for a loopback HTTPS dashboard (#6024)", () => { + const config = buildSecurityAuditConfig("https://127.0.0.1:18789"); + expect(config.security).toBeUndefined(); + }); }); From f48b03a666f8a3ce96ae16f73bd0727021bec48e Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Fri, 10 Jul 2026 03:13:16 +0800 Subject: [PATCH 03/46] fix(openclaw): keep remote insecure auth findings active Signed-off-by: Chengjie Wang --- docs/security/best-practices.mdx | 3 +- scripts/generate-openclaw-config.mts | 4 +-- ...ate-openclaw-config-security-audit.test.ts | 36 ++++++++++++++----- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index aad81d4e09..6bfb2478cf 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -683,7 +683,8 @@ 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 `allowInsecureAuth` and `dangerouslyDisableDeviceAuth` findings visible as accepted findings instead of counting them as unexplained active findings. +OpenClaw's security audit keeps NemoClaw-managed loopback `allowInsecureAuth` and compatibility-driven `dangerouslyDisableDeviceAuth` findings visible as accepted findings instead of counting them as unexplained active findings. +For a non-loopback HTTP dashboard, the insecure-auth finding remains active until you switch `CHAT_UI_URL` to HTTPS. 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. diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index 6cbd8bdf38..1e0a17215c 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -1193,9 +1193,9 @@ export function buildConfig(env: Env = process.env): JsonObject { const disableDeviceAuth = env.NEMOCLAW_DISABLE_DEVICE_AUTH === "1" || isRemote; const allowInsecure = parsed.scheme === "http"; const securityAuditSuppressions: JsonObject[] = []; - if (allowInsecure) { + if (allowInsecure && !isRemote) { const reason = - "NemoClaw derives this setting from an HTTP CHAT_UI_URL; use HTTPS for non-loopback dashboards."; + "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 }, { diff --git a/test/generate-openclaw-config-security-audit.test.ts b/test/generate-openclaw-config-security-audit.test.ts index ca3d66fc84..d026839cd2 100644 --- a/test/generate-openclaw-config-security-audit.test.ts +++ b/test/generate-openclaw-config-security-audit.test.ts @@ -13,8 +13,8 @@ const BASE_ENV: Record = { NEMOCLAW_INFERENCE_API: "openai", }; -function buildSecurityAuditConfig(chatUiUrl: string): any { - return buildConfig({ ...BASE_ENV, CHAT_UI_URL: chatUiUrl }); +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", () => { @@ -24,13 +24,13 @@ describe("generate-openclaw-config.mts: managed security audit findings", () => { checkId: "gateway.control_ui.insecure_auth", reason: - "NemoClaw derives this setting from an HTTP CHAT_UI_URL; use HTTPS for non-loopback dashboards.", + "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 an HTTP CHAT_UI_URL; use HTTPS for non-loopback dashboards.", + "NemoClaw derives this setting from a loopback HTTP CHAT_UI_URL; use HTTPS for non-loopback dashboards.", }, ]); }); @@ -52,15 +52,35 @@ describe("generate-openclaw-config.mts: managed security audit findings", () => ]); }); - it("explains both managed flags for a non-loopback HTTP dashboard (#6024)", () => { + it("keeps remote HTTP insecure auth active while explaining managed device auth (#6024)", () => { const config = buildSecurityAuditConfig("http://remote.example:18789"); - expect(config.security.audit.suppressions).toHaveLength(4); + expect(config.gateway.controlUi.allowInsecureAuth).toBe(true); + expect(config.security.audit.suppressions).toEqual([ + { + checkId: "gateway.control_ui.device_auth_disabled", + reason: + "NemoClaw enables this compatibility setting for non-loopback or explicitly opted-out dashboards; use loopback access to retain device authentication.", + }, + { + checkId: "config.insecure_or_dangerous_flags", + detailIncludes: "gateway.controlUi.dangerouslyDisableDeviceAuth=true", + reason: + "NemoClaw enables this compatibility setting for non-loopback or explicitly opted-out dashboards; use loopback access to retain device authentication.", + }, + ]); + }); + + it("explains an explicit loopback device auth opt-out (#6024)", () => { + const config = buildSecurityAuditConfig("https://127.0.0.1:18789", { + NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + }); expect(config.security.audit.suppressions.map((entry: any) => entry.checkId)).toEqual([ - "gateway.control_ui.insecure_auth", - "config.insecure_or_dangerous_flags", "gateway.control_ui.device_auth_disabled", "config.insecure_or_dangerous_flags", ]); + expect(config.security.audit.suppressions[1].detailIncludes).toBe( + "gateway.controlUi.dangerouslyDisableDeviceAuth=true", + ); }); it("omits audit suppressions for a loopback HTTPS dashboard (#6024)", () => { From 9578a1e2728a0eef5d3b7816aa82e02ba172bf9e Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Fri, 10 Jul 2026 03:39:58 +0800 Subject: [PATCH 04/46] test(openclaw): verify audit suppression contract Signed-off-by: Chengjie Wang --- ...w-security-audit-suppressions-real.test.ts | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 test/openclaw-security-audit-suppressions-real.test.ts 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 0000000000..1d067bcce9 --- /dev/null +++ b/test/openclaw-security-audit-suppressions-real.test.ts @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +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 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[]; +} + +function reviewedOpenClawVersion(): string { + const dockerfile = fs.readFileSync(path.join(REPO_ROOT, "Dockerfile"), "utf-8"); + const version = dockerfile.match(/^ARG OPENCLAW_VERSION=([^\s]+)/m)?.[1]; + if (!version) throw new Error("Dockerfile is missing ARG OPENCLAW_VERSION"); + return version; +} + +function runOpenClawAudit(chatUiUrl: string): AuditResult { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-audit-")); + const home = path.join(tmp, "home"); + const configDir = path.join(home, ".openclaw"); + const cache = process.env.NEMOCLAW_REAL_OPENCLAW_AUDIT_NPM_CACHE || path.join(tmp, "npm-cache"); + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(configDir, "openclaw.json"), + JSON.stringify(buildConfig({ ...BASE_ENV, CHAT_UI_URL: chatUiUrl })), + { mode: 0o600 }, + ); + + try { + const version = reviewedOpenClawVersion(); + const childEnv: NodeJS.ProcessEnv = { ...process.env, HOME: home, NPM_CONFIG_CACHE: cache }; + for (const key of Object.keys(childEnv)) { + if (key.startsWith("VITEST") || key === "NODE_ENV") delete childEnv[key]; + } + const audit = spawnSync( + "npm", + [ + "exec", + "--yes", + `--package=openclaw@${version}`, + "--", + "openclaw", + "security", + "audit", + "--json", + ], + { + encoding: "utf-8", + env: childEnv, + maxBuffer: 10 * 1024 * 1024, + timeout: OPENCLAW_AUDIT_TIMEOUT_MS, + }, + ); + if (audit.error || audit.status !== 0 || !audit.stdout.trim()) { + throw new Error( + `OpenClaw audit failed: ${audit.error?.message || audit.stderr || audit.stdout || "empty output"}`, + ); + } + 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), + ); +} + +describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS !== "1")( + "OpenClaw managed security audit consumer contract", + () => { + it( + "suppresses only exact managed findings while preserving active risks (#6024)", + () => { + const loopback = runOpenClawAudit("http://127.0.0.1:18789"); + const suppressedDirect = loopback.suppressedFindings.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(loopback.suppressedFindings, "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 remote = runOpenClawAudit("http://remote.example:18789"); + expect( + remote.findings.some((finding) => finding.checkId === "gateway.control_ui.insecure_auth"), + ).toBe(true); + expect( + findingForFlag(remote.findings, "gateway.controlUi.allowInsecureAuth=true"), + ).toBeDefined(); + expect( + remote.suppressedFindings.some( + (finding) => finding.checkId === "gateway.control_ui.device_auth_disabled", + ), + ).toBe(true); + }, + OPENCLAW_AUDIT_TIMEOUT_MS, + ); + }, +); From 0986ad06b0cbe7d0089fda4a54e509bc206e8deb Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Fri, 10 Jul 2026 03:44:15 +0800 Subject: [PATCH 05/46] test(openclaw): keep audit harness linear Signed-off-by: Chengjie Wang --- ...w-security-audit-suppressions-real.test.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/test/openclaw-security-audit-suppressions-real.test.ts b/test/openclaw-security-audit-suppressions-real.test.ts index 1d067bcce9..4f400111ad 100644 --- a/test/openclaw-security-audit-suppressions-real.test.ts +++ b/test/openclaw-security-audit-suppressions-real.test.ts @@ -1,6 +1,7 @@ // 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"; @@ -36,7 +37,7 @@ interface AuditResult { function reviewedOpenClawVersion(): string { const dockerfile = fs.readFileSync(path.join(REPO_ROOT, "Dockerfile"), "utf-8"); const version = dockerfile.match(/^ARG OPENCLAW_VERSION=([^\s]+)/m)?.[1]; - if (!version) throw new Error("Dockerfile is missing ARG OPENCLAW_VERSION"); + assert.ok(version, "Dockerfile is missing ARG OPENCLAW_VERSION"); return version; } @@ -54,10 +55,11 @@ function runOpenClawAudit(chatUiUrl: string): AuditResult { try { const version = reviewedOpenClawVersion(); - const childEnv: NodeJS.ProcessEnv = { ...process.env, HOME: home, NPM_CONFIG_CACHE: cache }; - for (const key of Object.keys(childEnv)) { - if (key.startsWith("VITEST") || key === "NODE_ENV") delete childEnv[key]; - } + const childEnv = Object.fromEntries( + Object.entries({ ...process.env, HOME: home, NPM_CONFIG_CACHE: cache }).filter( + ([key]) => !key.startsWith("VITEST") && key !== "NODE_ENV", + ), + ); const audit = spawnSync( "npm", [ @@ -77,11 +79,10 @@ function runOpenClawAudit(chatUiUrl: string): AuditResult { timeout: OPENCLAW_AUDIT_TIMEOUT_MS, }, ); - if (audit.error || audit.status !== 0 || !audit.stdout.trim()) { - throw new Error( - `OpenClaw audit failed: ${audit.error?.message || audit.stderr || audit.stdout || "empty output"}`, - ); - } + 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 }); From c0f02081cea091520784383313cb445a5d99aa28 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Fri, 10 Jul 2026 03:58:54 +0800 Subject: [PATCH 06/46] fix(openclaw): keep remote device auth warnings active Signed-off-by: Chengjie Wang --- docs/security/best-practices.mdx | 4 +- scripts/generate-openclaw-config.mts | 7 ++-- ...ate-openclaw-config-security-audit.test.ts | 37 +++++-------------- ...w-security-audit-suppressions-real.test.ts | 22 +++++++++-- 4 files changed, 34 insertions(+), 36 deletions(-) diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 6bfb2478cf..3b62bed930 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -683,8 +683,8 @@ 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` and compatibility-driven `dangerouslyDisableDeviceAuth` findings visible as accepted findings instead of counting them as unexplained active findings. -For a non-loopback HTTP dashboard, the insecure-auth finding remains active until you switch `CHAT_UI_URL` to HTTPS. +OpenClaw's security audit keeps NemoClaw-managed loopback `allowInsecureAuth` findings and explicit `NEMOCLAW_DISABLE_DEVICE_AUTH=1` opt-out findings visible as accepted findings instead of counting them as unexplained active findings. +For a non-loopback dashboard, automatically disabled device-auth findings remain active; when that dashboard also uses HTTP, its insecure-auth findings remain active until you switch `CHAT_UI_URL` to HTTPS. 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. diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index 1e0a17215c..4969b2095f 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -1190,7 +1190,8 @@ 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 explicitDeviceAuthOptOut = env.NEMOCLAW_DISABLE_DEVICE_AUTH === "1"; + const disableDeviceAuth = explicitDeviceAuthOptOut || isRemote; const allowInsecure = parsed.scheme === "http"; const securityAuditSuppressions: JsonObject[] = []; if (allowInsecure && !isRemote) { @@ -1205,9 +1206,9 @@ export function buildConfig(env: Env = process.env): JsonObject { }, ); } - if (disableDeviceAuth) { + if (explicitDeviceAuthOptOut) { const reason = - "NemoClaw enables this compatibility setting for non-loopback or explicitly opted-out dashboards; use loopback access to retain device authentication."; + "NemoClaw applies this setting because NEMOCLAW_DISABLE_DEVICE_AUTH=1 explicitly opts out of device authentication."; securityAuditSuppressions.push( { checkId: "gateway.control_ui.device_auth_disabled", reason }, { diff --git a/test/generate-openclaw-config-security-audit.test.ts b/test/generate-openclaw-config-security-audit.test.ts index d026839cd2..80cb4f2fa5 100644 --- a/test/generate-openclaw-config-security-audit.test.ts +++ b/test/generate-openclaw-config-security-audit.test.ts @@ -35,39 +35,17 @@ describe("generate-openclaw-config.mts: managed security audit findings", () => ]); }); - it("explains NemoClaw-managed device auth findings (#6024)", () => { + it("keeps remote device auth findings active (#6024)", () => { const config = buildSecurityAuditConfig("https://nemoclaw0-xxx.brevlab.com:18789"); - expect(config.security.audit.suppressions).toEqual([ - { - checkId: "gateway.control_ui.device_auth_disabled", - reason: - "NemoClaw enables this compatibility setting for non-loopback or explicitly opted-out dashboards; use loopback access to retain device authentication.", - }, - { - checkId: "config.insecure_or_dangerous_flags", - detailIncludes: "gateway.controlUi.dangerouslyDisableDeviceAuth=true", - reason: - "NemoClaw enables this compatibility setting for non-loopback or explicitly opted-out dashboards; use loopback access to retain device authentication.", - }, - ]); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + expect(config.security).toBeUndefined(); }); - it("keeps remote HTTP insecure auth active while explaining managed device auth (#6024)", () => { + it("keeps all remote HTTP security findings active (#6024)", () => { const config = buildSecurityAuditConfig("http://remote.example:18789"); expect(config.gateway.controlUi.allowInsecureAuth).toBe(true); - expect(config.security.audit.suppressions).toEqual([ - { - checkId: "gateway.control_ui.device_auth_disabled", - reason: - "NemoClaw enables this compatibility setting for non-loopback or explicitly opted-out dashboards; use loopback access to retain device authentication.", - }, - { - checkId: "config.insecure_or_dangerous_flags", - detailIncludes: "gateway.controlUi.dangerouslyDisableDeviceAuth=true", - reason: - "NemoClaw enables this compatibility setting for non-loopback or explicitly opted-out dashboards; use loopback access to retain device authentication.", - }, - ]); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + expect(config.security).toBeUndefined(); }); it("explains an explicit loopback device auth opt-out (#6024)", () => { @@ -81,6 +59,9 @@ describe("generate-openclaw-config.mts: managed security audit findings", () => expect(config.security.audit.suppressions[1].detailIncludes).toBe( "gateway.controlUi.dangerouslyDisableDeviceAuth=true", ); + expect(config.security.audit.suppressions[0].reason).toContain( + "NEMOCLAW_DISABLE_DEVICE_AUTH=1", + ); }); it("omits audit suppressions for a loopback HTTPS dashboard (#6024)", () => { diff --git a/test/openclaw-security-audit-suppressions-real.test.ts b/test/openclaw-security-audit-suppressions-real.test.ts index 4f400111ad..de5ff07370 100644 --- a/test/openclaw-security-audit-suppressions-real.test.ts +++ b/test/openclaw-security-audit-suppressions-real.test.ts @@ -41,7 +41,7 @@ function reviewedOpenClawVersion(): string { return version; } -function runOpenClawAudit(chatUiUrl: string): AuditResult { +function runOpenClawAudit(chatUiUrl: string, overrides: Record = {}): AuditResult { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-audit-")); const home = path.join(tmp, "home"); const configDir = path.join(home, ".openclaw"); @@ -49,7 +49,7 @@ function runOpenClawAudit(chatUiUrl: string): AuditResult { fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); fs.writeFileSync( path.join(configDir, "openclaw.json"), - JSON.stringify(buildConfig({ ...BASE_ENV, CHAT_UI_URL: chatUiUrl })), + JSON.stringify(buildConfig({ ...BASE_ENV, CHAT_UI_URL: chatUiUrl, ...overrides })), { mode: 0o600 }, ); @@ -130,10 +130,26 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS !== "1")( findingForFlag(remote.findings, "gateway.controlUi.allowInsecureAuth=true"), ).toBeDefined(); expect( - remote.suppressedFindings.some( + remote.findings.some( (finding) => finding.checkId === "gateway.control_ui.device_auth_disabled", ), ).toBe(true); + expect( + findingForFlag(remote.findings, "gateway.controlUi.dangerouslyDisableDeviceAuth=true"), + ).toBeDefined(); + + const explicitOptOut = runOpenClawAudit("https://127.0.0.1:18789", { + NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + }); + expect( + explicitOptOut.suppressedFindings.find( + (finding) => finding.checkId === "gateway.control_ui.device_auth_disabled", + ), + ).toMatchObject({ + severity: "critical", + remediation: expect.any(String), + suppression: { reason: expect.stringContaining("NEMOCLAW_DISABLE_DEVICE_AUTH=1") }, + }); }, OPENCLAW_AUDIT_TIMEOUT_MS, ); From 6d13451531227023f2a8d0c5e4f76e51a28a2c56 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Fri, 10 Jul 2026 04:21:42 +0800 Subject: [PATCH 07/46] fix(openclaw): harden audit suppression contract Signed-off-by: Chengjie Wang --- .github/workflows/main.yaml | 5 + docs/security/best-practices.mdx | 4 +- scripts/generate-openclaw-config.mts | 2 +- ...ate-openclaw-config-security-audit.test.ts | 8 +- ...w-security-audit-suppressions-real.test.ts | 225 +++++++++++------- 5 files changed, 157 insertions(+), 87 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index d2b65b414f..9bac7dbd7e 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -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/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 3b62bed930..5460c85898 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -683,8 +683,8 @@ 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 explicit `NEMOCLAW_DISABLE_DEVICE_AUTH=1` opt-out findings visible as accepted findings instead of counting them as unexplained active findings. -For a non-loopback dashboard, automatically disabled device-auth findings remain active; when that dashboard also uses HTTP, its insecure-auth findings remain active until you switch `CHAT_UI_URL` to HTTPS. +OpenClaw's security audit keeps NemoClaw-managed loopback `allowInsecureAuth` findings and loopback-only explicit `NEMOCLAW_DISABLE_DEVICE_AUTH=1` opt-out findings visible as accepted findings instead of counting them as unexplained active findings. +For a non-loopback dashboard, device-auth findings remain active even when onboarding sets `NEMOCLAW_DISABLE_DEVICE_AUTH=1`; when that dashboard also uses HTTP, its insecure-auth findings remain active until you switch `CHAT_UI_URL` to HTTPS. 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. diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index 4969b2095f..bccca52a14 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -1206,7 +1206,7 @@ export function buildConfig(env: Env = process.env): JsonObject { }, ); } - if (explicitDeviceAuthOptOut) { + if (explicitDeviceAuthOptOut && !isRemote) { const reason = "NemoClaw applies this setting because NEMOCLAW_DISABLE_DEVICE_AUTH=1 explicitly opts out of device authentication."; securityAuditSuppressions.push( diff --git a/test/generate-openclaw-config-security-audit.test.ts b/test/generate-openclaw-config-security-audit.test.ts index 80cb4f2fa5..49e3bdc634 100644 --- a/test/generate-openclaw-config-security-audit.test.ts +++ b/test/generate-openclaw-config-security-audit.test.ts @@ -36,13 +36,17 @@ describe("generate-openclaw-config.mts: managed security audit findings", () => }); it("keeps remote device auth findings active (#6024)", () => { - const config = buildSecurityAuditConfig("https://nemoclaw0-xxx.brevlab.com:18789"); + 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"); + 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.security).toBeUndefined(); diff --git a/test/openclaw-security-audit-suppressions-real.test.ts b/test/openclaw-security-audit-suppressions-real.test.ts index de5ff07370..82f1a360ee 100644 --- a/test/openclaw-security-audit-suppressions-real.test.ts +++ b/test/openclaw-security-audit-suppressions-real.test.ts @@ -34,51 +34,102 @@ interface AuditResult { suppressedFindings: AuditFinding[]; } -function reviewedOpenClawVersion(): string { +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"); - return 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 runOpenClawAudit(chatUiUrl: string, overrides: Record = {}): AuditResult { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-audit-")); - const home = path.join(tmp, "home"); - const configDir = path.join(home, ".openclaw"); - const cache = process.env.NEMOCLAW_REAL_OPENCLAW_AUDIT_NPM_CACHE || path.join(tmp, "npm-cache"); - 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 }, +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 version = reviewedOpenClawVersion(); - const childEnv = Object.fromEntries( - Object.entries({ ...process.env, HOME: home, NPM_CONFIG_CACHE: cache }).filter( - ([key]) => !key.startsWith("VITEST") && key !== "NODE_ENV", - ), - ); - const audit = spawnSync( - "npm", - [ - "exec", - "--yes", - `--package=openclaw@${version}`, - "--", - "openclaw", - "security", - "audit", - "--json", - ], - { - encoding: "utf-8", - env: childEnv, - maxBuffer: 10 * 1024 * 1024, - timeout: OPENCLAW_AUDIT_TIMEOUT_MS, - }, + 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}`); @@ -102,54 +153,64 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS !== "1")( it( "suppresses only exact managed findings while preserving active risks (#6024)", () => { - const loopback = runOpenClawAudit("http://127.0.0.1:18789"); - const suppressedDirect = loopback.suppressedFindings.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(loopback.suppressedFindings, "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 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 suppressedDirect = loopback.suppressedFindings.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(loopback.suppressedFindings, "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 remote = runOpenClawAudit("http://remote.example:18789"); - expect( - remote.findings.some((finding) => finding.checkId === "gateway.control_ui.insecure_auth"), - ).toBe(true); - expect( - findingForFlag(remote.findings, "gateway.controlUi.allowInsecureAuth=true"), - ).toBeDefined(); - expect( - remote.findings.some( - (finding) => finding.checkId === "gateway.control_ui.device_auth_disabled", - ), - ).toBe(true); - expect( - findingForFlag(remote.findings, "gateway.controlUi.dangerouslyDisableDeviceAuth=true"), - ).toBeDefined(); + const remote = runOpenClawAudit(binary, "http://remote.example:18789", { + NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + }); + expect( + remote.findings.some( + (finding) => finding.checkId === "gateway.control_ui.insecure_auth", + ), + ).toBe(true); + expect( + findingForFlag(remote.findings, "gateway.controlUi.allowInsecureAuth=true"), + ).toBeDefined(); + expect( + remote.findings.some( + (finding) => finding.checkId === "gateway.control_ui.device_auth_disabled", + ), + ).toBe(true); + expect( + findingForFlag(remote.findings, "gateway.controlUi.dangerouslyDisableDeviceAuth=true"), + ).toBeDefined(); - const explicitOptOut = runOpenClawAudit("https://127.0.0.1:18789", { - NEMOCLAW_DISABLE_DEVICE_AUTH: "1", - }); - expect( - explicitOptOut.suppressedFindings.find( - (finding) => finding.checkId === "gateway.control_ui.device_auth_disabled", - ), - ).toMatchObject({ - severity: "critical", - remediation: expect.any(String), - suppression: { reason: expect.stringContaining("NEMOCLAW_DISABLE_DEVICE_AUTH=1") }, - }); + const explicitOptOut = runOpenClawAudit(binary, "https://127.0.0.1:18789", { + NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + }); + expect( + explicitOptOut.suppressedFindings.find( + (finding) => finding.checkId === "gateway.control_ui.device_auth_disabled", + ), + ).toMatchObject({ + severity: "critical", + remediation: expect.any(String), + suppression: { reason: expect.stringContaining("NEMOCLAW_DISABLE_DEVICE_AUTH=1") }, + }); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } }, OPENCLAW_AUDIT_TIMEOUT_MS, ); From 73d21e804a370df98af5a017cccbc318baa943fd Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Fri, 10 Jul 2026 04:30:36 +0800 Subject: [PATCH 08/46] test(openclaw): cover remote audit findings Signed-off-by: Chengjie Wang --- ...w-security-audit-suppressions-real.test.ts | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/test/openclaw-security-audit-suppressions-real.test.ts b/test/openclaw-security-audit-suppressions-real.test.ts index 82f1a360ee..1726bf4339 100644 --- a/test/openclaw-security-audit-suppressions-real.test.ts +++ b/test/openclaw-security-audit-suppressions-real.test.ts @@ -147,6 +147,17 @@ function findingForFlag(findings: AuditFinding[], flag: string): AuditFinding | ); } +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", () => { @@ -176,25 +187,34 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS !== "1")( loopback.findings.some((finding) => finding.checkId === "gateway.loopback_no_auth"), ).toBe(true); - const remote = runOpenClawAudit(binary, "http://remote.example:18789", { + const remoteOnboard = runOpenClawAudit(binary, "http://remote.example:18789", { NEMOCLAW_DISABLE_DEVICE_AUTH: "1", }); expect( - remote.findings.some( + remoteOnboard.findings.some( (finding) => finding.checkId === "gateway.control_ui.insecure_auth", ), ).toBe(true); expect( - findingForFlag(remote.findings, "gateway.controlUi.allowInsecureAuth=true"), + findingForFlag(remoteOnboard.findings, "gateway.controlUi.allowInsecureAuth=true"), ).toBeDefined(); expect( - remote.findings.some( + remoteOnboard.findings.some( (finding) => finding.checkId === "gateway.control_ui.device_auth_disabled", ), ).toBe(true); expect( - findingForFlag(remote.findings, "gateway.controlUi.dangerouslyDisableDeviceAuth=true"), + 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 explicitOptOut = runOpenClawAudit(binary, "https://127.0.0.1:18789", { NEMOCLAW_DISABLE_DEVICE_AUTH: "1", From 340085b825c12b2e917bd0ed28854ea7a8c851eb Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 9 Jul 2026 13:35:23 -0700 Subject: [PATCH 09/46] test(openclaw): complete real audit matrix Cover the production remote HTTPS override with the reviewed OpenClaw artifact. Size the trusted-main timeout for package setup and all five audit scenarios. Co-authored-by: Chengjie Wang Signed-off-by: Apurv Kumaria --- .github/workflows/main.yaml | 2 +- test/openclaw-dependency-review.test.ts | 8 ++++- ...w-security-audit-suppressions-real.test.ts | 34 ++++++++++++++++--- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 9bac7dbd7e..4c5d0e3ebf 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. diff --git a/test/openclaw-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index 7fe1acf983..41a5e35ce7 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 index 1726bf4339..63736bd0fb 100644 --- a/test/openclaw-security-audit-suppressions-real.test.ts +++ b/test/openclaw-security-audit-suppressions-real.test.ts @@ -13,6 +13,7 @@ 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", @@ -31,7 +32,7 @@ interface AuditFinding { interface AuditResult { findings: AuditFinding[]; - suppressedFindings: AuditFinding[]; + suppressedFindings?: AuditFinding[]; } interface ReviewedOpenClawPackage { @@ -168,7 +169,8 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS !== "1")( try { const binary = installReviewedOpenClaw(workspace); const loopback = runOpenClawAudit(binary, "http://127.0.0.1:18789"); - const suppressedDirect = loopback.suppressedFindings.find( + const loopbackSuppressions = loopback.suppressedFindings ?? []; + const suppressedDirect = loopbackSuppressions.find( (finding) => finding.checkId === "gateway.control_ui.insecure_auth", ); expect(suppressedDirect).toMatchObject({ @@ -177,7 +179,7 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS !== "1")( suppression: { reason: expect.stringContaining("loopback HTTP CHAT_UI_URL") }, }); expect( - findingForFlag(loopback.suppressedFindings, "gateway.controlUi.allowInsecureAuth=true"), + findingForFlag(loopbackSuppressions, "gateway.controlUi.allowInsecureAuth=true"), ).toMatchObject({ severity: "warn", remediation: expect.any(String), @@ -216,11 +218,33 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS !== "1")( 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 explicitOptOut = runOpenClawAudit(binary, "https://127.0.0.1:18789", { NEMOCLAW_DISABLE_DEVICE_AUTH: "1", }); expect( - explicitOptOut.suppressedFindings.find( + explicitOptOut.suppressedFindings?.find( (finding) => finding.checkId === "gateway.control_ui.device_auth_disabled", ), ).toMatchObject({ @@ -232,7 +256,7 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS !== "1")( fs.rmSync(workspace, { recursive: true, force: true }); } }, - OPENCLAW_AUDIT_TIMEOUT_MS, + OPENCLAW_AUDIT_SUITE_TIMEOUT_MS, ); }, ); From 843ab80820ddbf1b99b7debf3744f4fbec5ffc8c Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 9 Jul 2026 13:50:04 -0700 Subject: [PATCH 10/46] fix(openclaw): keep remote-bind audit findings active Treat the supported wildcard dashboard bind as remote exposure. Keep managed audit findings active for that externally reachable state. Co-authored-by: Chengjie Wang Signed-off-by: Apurv Kumaria --- scripts/generate-openclaw-config.mts | 5 +++-- ...rate-openclaw-config-security-audit.test.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index bccca52a14..7dd28bfc64 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -1190,11 +1190,12 @@ export function buildConfig(env: Env = process.env): JsonObject { const origins = unique([loopbackOrigin, chatOrigin, portlessOrigin].filter(Boolean) as string[]); const isRemote = !isLoopback(parsed.hostname || ""); + const hasRemoteDashboardExposure = isRemote || env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0"; const explicitDeviceAuthOptOut = env.NEMOCLAW_DISABLE_DEVICE_AUTH === "1"; const disableDeviceAuth = explicitDeviceAuthOptOut || isRemote; const allowInsecure = parsed.scheme === "http"; const securityAuditSuppressions: JsonObject[] = []; - if (allowInsecure && !isRemote) { + if (allowInsecure && !hasRemoteDashboardExposure) { const reason = "NemoClaw derives this setting from a loopback HTTP CHAT_UI_URL; use HTTPS for non-loopback dashboards."; securityAuditSuppressions.push( @@ -1206,7 +1207,7 @@ export function buildConfig(env: Env = process.env): JsonObject { }, ); } - if (explicitDeviceAuthOptOut && !isRemote) { + if (explicitDeviceAuthOptOut && !hasRemoteDashboardExposure) { const reason = "NemoClaw applies this setting because NEMOCLAW_DISABLE_DEVICE_AUTH=1 explicitly opts out of device authentication."; securityAuditSuppressions.push( diff --git a/test/generate-openclaw-config-security-audit.test.ts b/test/generate-openclaw-config-security-audit.test.ts index 49e3bdc634..1fd33bd3fd 100644 --- a/test/generate-openclaw-config-security-audit.test.ts +++ b/test/generate-openclaw-config-security-audit.test.ts @@ -52,6 +52,24 @@ describe("generate-openclaw-config.mts: managed security audit findings", () => 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.security).toBeUndefined(); + }); + + 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("explains an explicit loopback device auth opt-out (#6024)", () => { const config = buildSecurityAuditConfig("https://127.0.0.1:18789", { NEMOCLAW_DISABLE_DEVICE_AUTH: "1", From 6ba1e2dfbb40d8df034306d4024abb8db8106e6c Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 9 Jul 2026 13:54:18 -0700 Subject: [PATCH 11/46] docs(security): clarify remote dashboard audit scope Co-authored-by: Chengjie Wang Signed-off-by: Apurv Kumaria --- docs/security/best-practices.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 5460c85898..6051409e9d 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -684,7 +684,8 @@ The `allowInsecureAuth` setting controls whether the gateway permits non-HTTPS a | 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 loopback-only explicit `NEMOCLAW_DISABLE_DEVICE_AUTH=1` opt-out findings visible as accepted findings instead of counting them as unexplained active findings. -For a non-loopback dashboard, device-auth findings remain active even when onboarding sets `NEMOCLAW_DISABLE_DEVICE_AUTH=1`; when that dashboard also uses HTTP, its insecure-auth findings remain active until you switch `CHAT_UI_URL` to HTTPS. +For audit reporting, NemoClaw treats either a non-loopback `CHAT_UI_URL` or `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` as remote dashboard exposure. +In that state, it does not add the loopback-only audit suppressions, so any resulting device-auth and insecure-auth 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. From 81f75cd1829ac7da94fc6d1d5a0c4f3bfce6bdd3 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Fri, 10 Jul 2026 05:40:27 +0800 Subject: [PATCH 12/46] fix(openclaw): align remote bind audit lifecycle Signed-off-by: Chengjie Wang --- Dockerfile | 1 + docs/manage-sandboxes/runtime-controls.mdx | 2 +- docs/reference/commands.mdx | 2 +- docs/reference/troubleshooting.mdx | 2 + docs/security/best-practices.mdx | 5 +- src/lib/actions/sandbox/forward-recovery.ts | 35 +++- src/lib/onboard.ts | 1 + src/lib/onboard/dockerfile-patch.ts | 5 + src/lib/onboard/sandbox-create-launch.ts | 3 + src/lib/onboard/sandbox-registration.ts | 2 + src/lib/onboard/sandbox-reuse.ts | 11 ++ src/lib/state/registry.ts | 3 + test/dashboard-remote-bind-lifecycle.test.ts | 170 +++++++++++++++++++ 13 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 test/dashboard-remote-bind-lifecycle.test.ts diff --git a/Dockerfile b/Dockerfile index 8e670b0699..131f6eed7b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -826,6 +826,7 @@ 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= ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1 ARG NEMOCLAW_INFERENCE_API=openai-completions ARG NEMOCLAW_CONTEXT_WINDOW=131072 diff --git a/docs/manage-sandboxes/runtime-controls.mdx b/docs/manage-sandboxes/runtime-controls.mdx index bdf392c30f..97e0d39d27 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 dfd0a166d6..a445575538 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3105,7 +3105,7 @@ Keep the OpenShell gateway on loopback and use `NEMOCLAW_DASHBOARD_BIND` when yo `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. +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; other values are ignored. diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 005cba7bcf..512ae1bcbb 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -301,6 +301,8 @@ 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 6051409e9d..5671f742bb 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -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. | @@ -684,7 +684,7 @@ The `allowInsecureAuth` setting controls whether the gateway permits non-HTTPS a | 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 loopback-only explicit `NEMOCLAW_DISABLE_DEVICE_AUTH=1` opt-out findings visible as accepted findings instead of counting them as unexplained active findings. -For audit reporting, NemoClaw treats either a non-loopback `CHAT_UI_URL` or `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` as remote dashboard exposure. +For audit reporting, NemoClaw treats either a non-loopback `CHAT_UI_URL` or an onboard-time `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` as remote dashboard exposure. Use the same bind setting on later `connect` calls. If the sandbox was created without that 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. In that state, it does not add the loopback-only audit suppressions, so any resulting device-auth and insecure-auth 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. @@ -698,6 +698,7 @@ openclaw security audit --json | jq '.suppressedFindings' ``` Remove the underlying risky condition when dashboard compatibility no longer requires it. +Revisit these suppressions when OpenClaw can distinguish intentional loopback development HTTP from remote exposure, or when NemoClaw adopts HTTPS by default for local dashboards. ### Auto-Pair Client Allowlist diff --git a/src/lib/actions/sandbox/forward-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index a8b3b0c8eb..72db62ddab 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -61,7 +61,23 @@ 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 = process.env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0"; + 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, + remoteBindRequested ? `0.0.0.0:${port}` : String(port), + remoteBindRequested, + ); } /** @@ -97,9 +113,14 @@ export function isSandboxPortForwardHealthy( ); } -export function ensureSandboxPortForwardForPort(sandboxName: string, port: number): boolean { +export function ensureSandboxPortForwardForPort( + sandboxName: string, + port: number, + forwardTarget = String(port), + forceRestart = false, +): boolean { let forwardHealth = isSandboxPortForwardHealthy(sandboxName, port); - if (forwardHealth === true) return true; + 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; @@ -136,7 +157,9 @@ export function ensureSandboxPortForwardForPort(sandboxName: string, port: numbe stopState.health = isSandboxPortForwardHealthy(sandboxName, port); stopState.portReleased = !isLocalForwardReachable(port); return ( - stopState.health === true || stopState.health === "occupied" || stopState.portReleased + (!forceRestart && stopState.health === true) || + stopState.health === "occupied" || + stopState.portReleased ); }, { @@ -146,12 +169,12 @@ 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; } const startResult = runOpenshell( - ["forward", "start", "--background", String(port), sandboxName], + ["forward", "start", "--background", forwardTarget, sandboxName], { ignoreError: true, }, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index e0972d4a66..c00b56512b 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3013,6 +3013,7 @@ async function createSandboxWithBaseImageResolution( hermesToolGateways, hermesDashboardState: finalHermesDashboardState, dashboardPort: actualDashboardPort, + dashboardRemoteBindPrepared: process.env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0", gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, }), diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index 9d9f7e9de7..2d43bc1ae8 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -174,6 +174,11 @@ export function patchStagedDockerfile( /^ARG CHAT_UI_URL=.*$/m, `ARG CHAT_UI_URL=${sanitizeDockerArg(chatUiUrl)}`, ); + const dashboardBind = process.env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0" ? "0.0.0.0" : ""; + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_DASHBOARD_BIND=.*$/m, + `ARG NEMOCLAW_DASHBOARD_BIND=${dashboardBind}`, + ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_BASE_URL=.*$/m, `ARG NEMOCLAW_INFERENCE_BASE_URL=${sanitizeDockerArg(inferenceBaseUrl)}`, diff --git a/src/lib/onboard/sandbox-create-launch.ts b/src/lib/onboard/sandbox-create-launch.ts index 6d780d0a13..d74a932a76 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-registration.ts b/src/lib/onboard/sandbox-registration.ts index a5e9b66603..c8539cdfa9 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; } @@ -143,6 +144,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 32a8c8ad5d..fc261ec5a5 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 a3647e7748..df96c1d251 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -138,6 +138,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 @@ -541,6 +543,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 0000000000..adf91cffe9 --- /dev/null +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -0,0 +1,170 @@ +// 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 { patchStagedDockerfile } from "../src/lib/onboard/dockerfile-patch"; +import { prepareSandboxCreateLaunch } from "../src/lib/onboard/sandbox-create-launch"; +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.ts", +) as typeof import("../src/lib/actions/sandbox/forward-recovery.js"); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("remote dashboard bind production lifecycle", () => { + 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", + ].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: true, + gatewayName: "nemoclaw", + gatewayPort: 8080, + }); + expect(entry.dashboardRemoteBindPrepared).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", dashboardRemoteBindPrepared: false }), + ensureDashboardForward, + hermesDashboardForwarding: { + resolveStateForPort: () => ({ enabled: false, config: null }), + ensureForState: vi.fn(), + }, + updateReusedSandboxMetadata: vi.fn(), + }), + ).toThrow(/--recreate-sandbox/); + expect(ensureDashboardForward).not.toHaveBeenCalled(); + }); + + it("uses an all-interfaces target only for a sandbox prepared during onboarding (#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", "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").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 }, + ); + }); +}); From 1b5f2ac62a6a9bb64a9f13c4ca69a52d53012dbc Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Fri, 10 Jul 2026 05:42:55 +0800 Subject: [PATCH 13/46] refactor(onboard): keep registration entrypoint neutral Signed-off-by: Chengjie Wang --- src/lib/onboard.ts | 1 - src/lib/onboard/sandbox-registration.ts | 3 ++- test/dashboard-remote-bind-lifecycle.test.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index c00b56512b..e0972d4a66 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3013,7 +3013,6 @@ async function createSandboxWithBaseImageResolution( hermesToolGateways, hermesDashboardState: finalHermesDashboardState, dashboardPort: actualDashboardPort, - dashboardRemoteBindPrepared: process.env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0", gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, }), diff --git a/src/lib/onboard/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts index c8539cdfa9..a447688491 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -144,7 +144,8 @@ export function buildCreatedSandboxRegistryEntry( input.hermesToolGateways.length > 0 ? [...input.hermesToolGateways] : undefined, ...getHermesDashboardRegistryFields(input.hermesDashboardState), dashboardPort: input.dashboardPort, - dashboardRemoteBindPrepared: input.dashboardRemoteBindPrepared === true, + dashboardRemoteBindPrepared: + input.dashboardRemoteBindPrepared ?? process.env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0", gatewayName: input.gatewayName, gatewayPort: input.gatewayPort, }; diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index adf91cffe9..a783eb1980 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -82,7 +82,6 @@ describe("remote dashboard bind production lifecycle", () => { hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, dashboardPort: 18789, - dashboardRemoteBindPrepared: true, gatewayName: "nemoclaw", gatewayPort: 8080, }); From 742fc1682265f3e5455559fe413404d924bce072 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Fri, 10 Jul 2026 05:56:56 +0800 Subject: [PATCH 14/46] fix(openclaw): prepare remote dashboard posture Signed-off-by: Chengjie Wang --- docs/security/best-practices.mdx | 5 +- scripts/generate-openclaw-config.mts | 6 +- src/lib/actions/sandbox/forward-recovery.ts | 22 ++++-- test/dashboard-remote-bind-lifecycle.test.ts | 73 ++++++++++++++++++- test/e2e/brev-e2e.test.ts | 5 +- ...ate-openclaw-config-security-audit.test.ts | 2 + ...w-security-audit-suppressions-real.test.ts | 12 +++ 7 files changed, 110 insertions(+), 15 deletions(-) diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 5671f742bb..37ca1cdcc4 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -685,7 +685,8 @@ The `allowInsecureAuth` setting controls whether the gateway permits non-HTTPS a OpenClaw's security audit keeps NemoClaw-managed loopback `allowInsecureAuth` findings and loopback-only explicit `NEMOCLAW_DISABLE_DEVICE_AUTH=1` opt-out findings visible as accepted findings instead of counting them as unexplained active findings. For audit reporting, NemoClaw treats either a non-loopback `CHAT_UI_URL` or an onboard-time `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` as remote dashboard exposure. Use the same bind setting on later `connect` calls. If the sandbox was created without that 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. -In that state, it does not add the loopback-only audit suppressions, so any resulting device-auth and insecure-auth findings remain 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. @@ -698,7 +699,7 @@ openclaw security audit --json | jq '.suppressedFindings' ``` Remove the underlying risky condition when dashboard compatibility no longer requires it. -Revisit these suppressions when OpenClaw can distinguish intentional loopback development HTTP from remote exposure, or when NemoClaw adopts HTTPS by default for local dashboards. +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. ### Auto-Pair Client Allowlist diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index 7dd28bfc64..5b7999e9f4 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -1190,9 +1190,10 @@ export function buildConfig(env: Env = process.env): JsonObject { const origins = unique([loopbackOrigin, chatOrigin, portlessOrigin].filter(Boolean) as string[]); const isRemote = !isLoopback(parsed.hostname || ""); - const hasRemoteDashboardExposure = isRemote || env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0"; + const remoteBindOptIn = env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0"; + const hasRemoteDashboardExposure = isRemote || remoteBindOptIn; const explicitDeviceAuthOptOut = env.NEMOCLAW_DISABLE_DEVICE_AUTH === "1"; - const disableDeviceAuth = explicitDeviceAuthOptOut || isRemote; + const disableDeviceAuth = explicitDeviceAuthOptOut || hasRemoteDashboardExposure; const allowInsecure = parsed.scheme === "http"; const securityAuditSuppressions: JsonObject[] = []; if (allowInsecure && !hasRemoteDashboardExposure) { @@ -1349,6 +1350,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-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index 72db62ddab..c2ba7a719c 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -72,12 +72,13 @@ export function ensureSandboxPortForward(sandboxName: string): boolean { ); return false; } - return ensureSandboxPortForwardForPort( - sandboxName, - port, - remoteBindRequested ? `0.0.0.0:${port}` : String(port), - remoteBindRequested, - ); + return ensureSandboxPortForwardForPort(sandboxName, port, { + forwardTarget: remoteBindRequested ? `0.0.0.0:${port}` : String(port), + forceRestart: remoteBindRequested, + beforeStart: remoteBindRequested + ? () => registry.getSandbox(sandboxName)?.dashboardRemoteBindPrepared === true + : undefined, + }); } /** @@ -116,9 +117,13 @@ export function isSandboxPortForwardHealthy( export function ensureSandboxPortForwardForPort( sandboxName: string, port: number, - forwardTarget = String(port), - forceRestart = false, + options: { + forwardTarget?: string; + forceRestart?: boolean; + beforeStart?: () => boolean; + } = {}, ): boolean { + const { forwardTarget = String(port), forceRestart = false, beforeStart = () => true } = options; let forwardHealth = isSandboxPortForwardHealthy(sandboxName, port); if (forwardHealth === true && !forceRestart) return true; if (forwardHealth === "occupied") return false; @@ -173,6 +178,7 @@ export function ensureSandboxPortForwardForPort( if (stopState.health === "occupied" || !stopSettled || !stopState.portReleased) return false; } + if (!beforeStart()) return false; const startResult = runOpenshell( ["forward", "start", "--background", forwardTarget, sandboxName], { diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index a783eb1980..1029af56f1 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -13,7 +13,7 @@ import { buildCreatedSandboxRegistryEntry } from "../src/lib/onboard/sandbox-reg import { applyReusedSandboxDashboardState } from "../src/lib/onboard/sandbox-reuse"; const requireSource = createRequire(import.meta.url); -const { ensureSandboxPortForward } = requireSource( +const { ensureSandboxPortForward, ensureSandboxPortForwardForPort } = requireSource( "../src/lib/actions/sandbox/forward-recovery.ts", ) as typeof import("../src/lib/actions/sandbox/forward-recovery.js"); @@ -121,7 +121,7 @@ describe("remote dashboard bind production lifecycle", () => { sandboxGpuConfig: { mode: "0" } as never, gatewayName: "nemoclaw", gatewayPort: 8080, - getSandbox: () => ({ name: "beta", dashboardRemoteBindPrepared: false }), + getSandbox: () => ({ name: "beta" }), ensureDashboardForward, hermesDashboardForwarding: { resolveStateForPort: () => ({ enabled: false, config: null }), @@ -166,4 +166,73 @@ describe("remote dashboard bind production lifecycle", () => { { ignoreError: true }, ); }); + + it("re-verifies remote-bind preparation immediately 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("can force a loopback restart independently of remote-bind selection (#6024)", () => { + const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); + const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.js"); + let stopped = false; + let started = false; + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "0"); + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockImplementation( + () => !stopped || started, + ); + vi.spyOn(openshellRuntime, "captureOpenshell").mockImplementation(() => ({ + status: 0, + output: + !stopped || 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) : []; + if (args[0] === "forward" && args[1] === "stop") stopped = true; + if (args[0] === "forward" && args[1] === "start") started = true; + return { status: 0 } as never; + }); + + expect( + ensureSandboxPortForwardForPort("beta", 18789, { + forwardTarget: "18789", + forceRestart: true, + }), + ).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 b40f3c7247..ed941d9e55 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(" && "), @@ -1023,6 +1025,7 @@ function writeManualRegistry(elapsed: () => string): void { provider: null, gpuEnabled: false, policies: ["pypi", "npm"], + ...(TEST_SUITE === "dashboard-remote-bind" ? { dashboardRemoteBindPrepared: true } : {}), }, }, }, diff --git a/test/generate-openclaw-config-security-audit.test.ts b/test/generate-openclaw-config-security-audit.test.ts index 1fd33bd3fd..5acb175d61 100644 --- a/test/generate-openclaw-config-security-audit.test.ts +++ b/test/generate-openclaw-config-security-audit.test.ts @@ -57,6 +57,8 @@ describe("generate-openclaw-config.mts: managed security audit findings", () => 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).toBeUndefined(); }); diff --git a/test/openclaw-security-audit-suppressions-real.test.ts b/test/openclaw-security-audit-suppressions-real.test.ts index 63736bd0fb..a72334be85 100644 --- a/test/openclaw-security-audit-suppressions-real.test.ts +++ b/test/openclaw-security-audit-suppressions-real.test.ts @@ -240,6 +240,18 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS !== "1")( ).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( + 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", }); From 16e8a324e3c1ea744d89bc3bed1ec9711cc00a60 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Fri, 10 Jul 2026 06:00:59 +0800 Subject: [PATCH 15/46] test(openclaw): keep remote lifecycle checks linear Signed-off-by: Chengjie Wang --- test/dashboard-remote-bind-lifecycle.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index 1029af56f1..0091543304 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -219,8 +219,8 @@ describe("remote dashboard bind production lifecycle", () => { .spyOn(openshellRuntime, "runOpenshell") .mockImplementation((rawArgs: unknown) => { const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; - if (args[0] === "forward" && args[1] === "stop") stopped = true; - if (args[0] === "forward" && args[1] === "start") started = true; + stopped ||= args[0] === "forward" && args[1] === "stop"; + started ||= args[0] === "forward" && args[1] === "start"; return { status: 0 } as never; }); From ef1d37931b2f881a783a4bc7f57061960bcaba26 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Fri, 10 Jul 2026 06:10:32 +0800 Subject: [PATCH 16/46] fix(onboard): require remote bind contract Signed-off-by: Chengjie Wang --- src/lib/onboard/dockerfile-patch.ts | 8 +++++++- test/dashboard-remote-bind-lifecycle.test.ts | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index 2d43bc1ae8..c2ce83ec30 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -175,8 +175,14 @@ export function patchStagedDockerfile( `ARG CHAT_UI_URL=${sanitizeDockerArg(chatUiUrl)}`, ); const dashboardBind = process.env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0" ? "0.0.0.0" : ""; + const dashboardBindArgPattern = /^ARG NEMOCLAW_DASHBOARD_BIND=.*$/m; + if (dashboardBind && !dashboardBindArgPattern.test(dockerfile)) { + throw new Error( + "Dockerfile is missing ARG NEMOCLAW_DASHBOARD_BIND; cannot prepare remote dashboard exposure.", + ); + } dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_DASHBOARD_BIND=.*$/m, + dashboardBindArgPattern, `ARG NEMOCLAW_DASHBOARD_BIND=${dashboardBind}`, ); dockerfile = dockerfile.replace( diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index 0091543304..e934772989 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -91,6 +91,24 @@ describe("remote dashboard bind production lifecycle", () => { } }); + 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("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"); From 51231115f31df194ddfdc2b47921c7bfde28060b Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Fri, 10 Jul 2026 06:20:10 +0800 Subject: [PATCH 17/46] fix(onboard): persist verified remote bind state Signed-off-by: Chengjie Wang --- src/lib/onboard.ts | 3 +++ src/lib/onboard/dockerfile-patch.ts | 6 ++++++ src/lib/onboard/sandbox-registration.ts | 3 +-- test/dashboard-remote-bind-lifecycle.test.ts | 6 +++++- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index e0972d4a66..7f42e34592 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -90,6 +90,7 @@ const { selectResourceProfileForSandbox, }: typeof import("./onboard/resource-profile-selection") = require("./onboard/resource-profile-selection"); const { + hasPreparedRemoteDashboardBind, patchStagedDockerfile, }: typeof import("./onboard/dockerfile-patch") = require("./onboard/dockerfile-patch"); const { @@ -2806,6 +2807,7 @@ async function createSandboxWithBaseImageResolution( ...baseImageResolutionFlow.getBaseImageResolutionPatchOptions(baseImageResolutionContext), gatewayPort: GATEWAY_PORT, }); + const dashboardRemoteBindPrepared = hasPreparedRemoteDashboardBind(stagedDockerfile); const sandboxReadyTimeoutSecs = getSandboxReadyTimeoutSecs(effectiveSandboxGpuConfig); const { createResult, prebuild, effectiveDashboardPort, dockerGpuCreatePatch } = await runSandboxCreateStep( @@ -3013,6 +3015,7 @@ async function createSandboxWithBaseImageResolution( hermesToolGateways, hermesDashboardState: finalHermesDashboardState, dashboardPort: actualDashboardPort, + dashboardRemoteBindPrepared, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, }), diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index c2ce83ec30..a97764512c 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -88,6 +88,12 @@ export function isValidProxyPort(value: string): boolean { return port >= 1 && port <= 65535; } +export function hasPreparedRemoteDashboardBind(dockerfilePath: string): boolean { + return /^ARG NEMOCLAW_DASHBOARD_BIND=0\.0\.0\.0$/m.test( + readDockerfilePatchSnapshot(dockerfilePath).content, + ); +} + export function patchStagedDockerfile( dockerfilePath: string, model: string, diff --git a/src/lib/onboard/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts index a447688491..c8539cdfa9 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -144,8 +144,7 @@ export function buildCreatedSandboxRegistryEntry( input.hermesToolGateways.length > 0 ? [...input.hermesToolGateways] : undefined, ...getHermesDashboardRegistryFields(input.hermesDashboardState), dashboardPort: input.dashboardPort, - dashboardRemoteBindPrepared: - input.dashboardRemoteBindPrepared ?? process.env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0", + dashboardRemoteBindPrepared: input.dashboardRemoteBindPrepared === true, gatewayName: input.gatewayName, gatewayPort: input.gatewayPort, }; diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index e934772989..4afa7fcdc8 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -7,7 +7,10 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { patchStagedDockerfile } from "../src/lib/onboard/dockerfile-patch"; +import { + hasPreparedRemoteDashboardBind, + patchStagedDockerfile, +} from "../src/lib/onboard/dockerfile-patch"; import { prepareSandboxCreateLaunch } from "../src/lib/onboard/sandbox-create-launch"; import { buildCreatedSandboxRegistryEntry } from "../src/lib/onboard/sandbox-registration"; import { applyReusedSandboxDashboardState } from "../src/lib/onboard/sandbox-reuse"; @@ -82,6 +85,7 @@ describe("remote dashboard bind production lifecycle", () => { hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, dashboardPort: 18789, + dashboardRemoteBindPrepared: hasPreparedRemoteDashboardBind(dockerfile), gatewayName: "nemoclaw", gatewayPort: 8080, }); From 90e8cceae8425d3fb7dd7e067140e253badcd6e9 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 08:48:19 -0700 Subject: [PATCH 18/46] fix(openclaw): verify remote dashboard bind contract Signed-off-by: Prekshi Vyas --- Dockerfile | 1 + .../rebuild-custom-image-preflight.test.ts | 31 ++++++++++-- .../sandbox/rebuild-custom-image-preflight.ts | 3 +- ...ebuild-managed-image-configuration.test.ts | 12 ++++- .../rebuild-managed-image-preflight.ts | 3 +- src/lib/onboard.ts | 47 +++++++++---------- src/lib/onboard/build-context-stage.ts | 1 + src/lib/onboard/dockerfile-patch.ts | 30 +++++++++++- .../onboard/prepared-dcode-rebuild.test.ts | 6 ++- src/lib/onboard/prepared-dcode-rebuild.ts | 25 ++++++++-- .../sandbox-dockerfile-patch-flow.test.ts | 1 + .../onboard/sandbox-dockerfile-patch-flow.ts | 9 +++- src/lib/onboard/sandbox-registration.ts | 8 +++- test/dashboard-remote-bind-lifecycle.test.ts | 26 ++++++++++ 14 files changed, 160 insertions(+), 43 deletions(-) diff --git a/Dockerfile b/Dockerfile index 131f6eed7b..cfee503806 100644 --- a/Dockerfile +++ b/Dockerfile @@ -915,6 +915,7 @@ 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_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \ NEMOCLAW_PROXY_HOST=${NEMOCLAW_PROXY_HOST} \ NEMOCLAW_PROXY_PORT=${NEMOCLAW_PROXY_PORT} \ 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 023027c34f..5424cfe803 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: false, + resolvedBaseImage: null, + })), buildImage: vi.fn(() => ({ status: 0 }) as never), removeImage, }), diff --git a/src/lib/actions/sandbox/rebuild-custom-image-preflight.ts b/src/lib/actions/sandbox/rebuild-custom-image-preflight.ts index 8eb3dbba4c..d8399931cd 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 5d8b067e13..629c80f5c1 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 { @@ -129,7 +133,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 8e5ccaf174..6c4ee5f43f 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/onboard.ts b/src/lib/onboard.ts index 7f42e34592..f6c096d2a9 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -90,7 +90,6 @@ const { selectResourceProfileForSandbox, }: typeof import("./onboard/resource-profile-selection") = require("./onboard/resource-profile-selection"); const { - hasPreparedRemoteDashboardBind, patchStagedDockerfile, }: typeof import("./onboard/dockerfile-patch") = require("./onboard/dockerfile-patch"); const { @@ -2786,28 +2785,27 @@ 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, - preferredInferenceApi, - webSearchConfig, - toolDisclosure: effectiveToolDisclosure, - ...(isManagedDcodeAgent ? { dcodeAutoApprovalMode: dcodeAutoApprovalPlan.mode } : {}), - hermesToolGateways, - sandboxGpuConfig: effectiveSandboxGpuConfig, - ...baseImageResolutionFlow.getBaseImageResolutionPatchOptions(baseImageResolutionContext), - gatewayPort: GATEWAY_PORT, - }); - const dashboardRemoteBindPrepared = hasPreparedRemoteDashboardBind(stagedDockerfile); + const configuredMessagingChannels = + getChannelsFromPlan(plannedMessagingState?.plan) ?? activeMessagingChannels; + sandboxBuildPatchConfig.prepareSandboxBuildPatchConfig({ configuredMessagingChannels }); + const { buildId, dashboardRemoteBindPrepared } = + await preparedDcodeRebuild.resolveSandboxBuildPatch({ + preparedBuildContext, + agent, + fromDockerfile, + stagedDockerfile, + model, + chatUiUrl, + provider, + 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( @@ -3009,13 +3007,12 @@ 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, hermesDashboardState: finalHermesDashboardState, dashboardPort: actualDashboardPort, - dashboardRemoteBindPrepared, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, }), diff --git a/src/lib/onboard/build-context-stage.ts b/src/lib/onboard/build-context-stage.ts index 1e5c8a5dcd..e5aec698db 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.ts b/src/lib/onboard/dockerfile-patch.ts index a97764512c..9a6ec2ad04 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -88,8 +88,25 @@ export function isValidProxyPort(value: string): boolean { return port >= 1 && port <= 65535; } +export type PatchedDockerfileMetadata = { + dashboardRemoteBindPrepared: boolean; +}; + +function hasRemoteDashboardBindGenerationContract(dockerfile: string): boolean { + const argIndex = dockerfile.search(/^ARG NEMOCLAW_DASHBOARD_BIND=0\.0\.0\.0$/m); + if (argIndex < 0) return false; + const envIndex = dockerfile.search( + /^\s*(?:ENV\s+)?NEMOCLAW_DASHBOARD_BIND=\$\{NEMOCLAW_DASHBOARD_BIND\}\s*\\?$/m, + ); + if (envIndex < 0 || envIndex < argIndex) return false; + const generatorIndex = dockerfile.search( + /^RUN\b[^\n]*(?:\\\n[^\n]*)*generate-openclaw-config\.mts/m, + ); + return generatorIndex > envIndex; +} + export function hasPreparedRemoteDashboardBind(dockerfilePath: string): boolean { - return /^ARG NEMOCLAW_DASHBOARD_BIND=0\.0\.0\.0$/m.test( + return hasRemoteDashboardBindGenerationContract( readDockerfilePatchSnapshot(dockerfilePath).content, ); } @@ -107,7 +124,7 @@ export function patchStagedDockerfile( inferenceBaseUrlOverride: string | null = null, hermesToolGateways: string[] = [], options: PatchStagedDockerfileOptions = {}, -): void { +): PatchedDockerfileMetadata { const sanitizedModel = sanitizeDockerArg(model); const sandboxInference = getSandboxInferenceConfig( sanitizedModel, @@ -191,6 +208,14 @@ export function patchStagedDockerfile( dashboardBindArgPattern, `ARG NEMOCLAW_DASHBOARD_BIND=${dashboardBind}`, ); + const dashboardRemoteBindPrepared = + dashboardBind === "0.0.0.0" && hasRemoteDashboardBindGenerationContract(dockerfile); + 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; cannot prepare remote dashboard exposure.", + ); + } dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_BASE_URL=.*$/m, `ARG NEMOCLAW_INFERENCE_BASE_URL=${sanitizeDockerArg(inferenceBaseUrl)}`, @@ -375,4 +400,5 @@ export function patchStagedDockerfile( ); } replaceDockerfilePatchSnapshot(dockerfilePath, patchSnapshot, dockerfile); + return { dashboardRemoteBindPrepared }; } diff --git a/src/lib/onboard/prepared-dcode-rebuild.test.ts b/src/lib/onboard/prepared-dcode-rebuild.test.ts index 1fa1ebb2b8..808967611f 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 15e23cdf73..0fef7cdb7c 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-dockerfile-patch-flow.test.ts b/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts index e378a09b08..2611bdf034 100644 --- a/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts +++ b/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts @@ -135,6 +135,7 @@ describe("prepareSandboxDockerfilePatch", () => { expect(result).toEqual({ buildId: "12345", + dashboardRemoteBindPrepared: false, resolvedBaseImage: { digest: "sha256:abcdef0123456789", ref: "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:abcdef0123456789", diff --git a/src/lib/onboard/sandbox-dockerfile-patch-flow.ts b/src/lib/onboard/sandbox-dockerfile-patch-flow.ts index 0d8b1afc2d..9d03452d11 100644 --- a/src/lib/onboard/sandbox-dockerfile-patch-flow.ts +++ b/src/lib/onboard/sandbox-dockerfile-patch-flow.ts @@ -55,6 +55,7 @@ export type PrepareSandboxDockerfilePatchInput = { export type SandboxDockerfilePatchResult = { buildId: string; + dashboardRemoteBindPrepared: boolean; resolvedBaseImage: ResolvedSandboxBaseImage | null; }; @@ -174,7 +175,7 @@ export async function prepareSandboxDockerfilePatch({ !fromDockerfile && STABLE_MANAGED_BUILD_ID_AGENTS.has(managedAgentName) ? "preserve" : "rewrite"; - (deps.patchStagedDockerfile ?? patchStagedDockerfile)( + const patched = (deps.patchStagedDockerfile ?? patchStagedDockerfile)( stagedDockerfile, model, chatUiUrl, @@ -198,5 +199,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 c8539cdfa9..7a20b52a09 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -68,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, }; } diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index 4afa7fcdc8..e591312e9a 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -36,6 +36,8 @@ describe("remote dashboard bind production lifecycle", () => { "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"), ); @@ -113,6 +115,30 @@ describe("remote dashboard bind production lifecycle", () => { } }); + 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, + [ + "ARG NEMOCLAW_MODEL=", + "ARG CHAT_UI_URL=", + "ARG NEMOCLAW_DASHBOARD_BIND=", + "FROM scratch", + ].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("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"); From e37befeb5c31757683fd4112bc70b2eef1e164a4 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 08:50:37 -0700 Subject: [PATCH 19/46] test(openclaw): include remote bind metadata in mocks Signed-off-by: Prekshi Vyas --- .../actions/sandbox/rebuild-managed-image-configuration.test.ts | 1 + .../actions/sandbox/rebuild-managed-image-preparation.test.ts | 2 ++ test/helpers/rebuild-managed-image-preflight-harness.ts | 1 + 3 files changed, 4 insertions(+) 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 629c80f5c1..05204cf71e 100644 --- a/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts +++ b/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts @@ -77,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, })); 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 8472564e31..165e5e5171 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/test/helpers/rebuild-managed-image-preflight-harness.ts b/test/helpers/rebuild-managed-image-preflight-harness.ts index 2222cb7277..9987a29ccb 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); From 16b671dfe0913087a47a8080f581c84228839e60 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 09:14:13 -0700 Subject: [PATCH 20/46] ci: provision ripgrep for advisor tool shards Signed-off-by: Prekshi Vyas --- .github/actions/ci-cli-coverage-shard/action.yaml | 11 +++++++++++ tools/advisors/repo-read-only-tools.mts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/actions/ci-cli-coverage-shard/action.yaml b/.github/actions/ci-cli-coverage-shard/action.yaml index d45128bbfa..5cbccaee49 100644 --- a/.github/actions/ci-cli-coverage-shard/action.yaml +++ b/.github/actions/ci-cli-coverage-shard/action.yaml @@ -63,6 +63,17 @@ runs: npm install --ignore-scripts cd nemoclaw && npm install --ignore-scripts + - name: Install ripgrep for advisor tool tests + shell: bash + run: | + set -euo pipefail + if ! command -v rg >/dev/null 2>&1; then + RIPGREP_VERSION="14.1.0-1" + sudo apt-get update + sudo apt-get install -y --no-install-recommends "ripgrep=${RIPGREP_VERSION}" + fi + rg --version + - name: Build TypeScript plugin shell: bash run: cd nemoclaw && npm run build diff --git a/tools/advisors/repo-read-only-tools.mts b/tools/advisors/repo-read-only-tools.mts index e6beede393..e03437ae53 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}`); } From 721946abd2d825a39217eb0a9deaf2dbc1d64f5c Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 09:31:24 -0700 Subject: [PATCH 21/46] fix(onboard): require final-stage remote dashboard bind proof Signed-off-by: Prekshi Vyas --- src/lib/onboard/dockerfile-patch.ts | 35 ++++--------- ...ckerfile-remote-dashboard-bind-contract.ts | 51 +++++++++++++++++++ test/dashboard-remote-bind-lifecycle.test.ts | 28 ++++++++++ 3 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index a8d4bcc129..d6c534cf76 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -29,6 +29,11 @@ import { type DcodeAutoApprovalMode, isDcodeAutoApprovalMode, } from "./dcode-auto-approval"; +import { + findRemoteDashboardBindFinalStageArg, + hasPreparedRemoteDashboardBind, + hasRemoteDashboardBindGenerationContract, +} from "./dockerfile-remote-dashboard-bind-contract"; import { dockerfileInstructions, readDockerfilePatchSnapshot, @@ -97,24 +102,7 @@ export type PatchedDockerfileMetadata = { dashboardRemoteBindPrepared: boolean; }; -function hasRemoteDashboardBindGenerationContract(dockerfile: string): boolean { - const argIndex = dockerfile.search(/^ARG NEMOCLAW_DASHBOARD_BIND=0\.0\.0\.0$/m); - if (argIndex < 0) return false; - const envIndex = dockerfile.search( - /^\s*(?:ENV\s+)?NEMOCLAW_DASHBOARD_BIND=\$\{NEMOCLAW_DASHBOARD_BIND\}\s*\\?$/m, - ); - if (envIndex < 0 || envIndex < argIndex) return false; - const generatorIndex = dockerfile.search( - /^RUN\b[^\n]*(?:\\\n[^\n]*)*generate-openclaw-config\.mts/m, - ); - return generatorIndex > envIndex; -} - -export function hasPreparedRemoteDashboardBind(dockerfilePath: string): boolean { - return hasRemoteDashboardBindGenerationContract( - readDockerfilePatchSnapshot(dockerfilePath).content, - ); -} +export { hasPreparedRemoteDashboardBind }; export function patchStagedDockerfile( dockerfilePath: string, @@ -203,16 +191,15 @@ export function patchStagedDockerfile( `ARG CHAT_UI_URL=${sanitizeDockerArg(chatUiUrl)}`, ); const dashboardBind = process.env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0" ? "0.0.0.0" : ""; - const dashboardBindArgPattern = /^ARG NEMOCLAW_DASHBOARD_BIND=.*$/m; - if (dashboardBind && !dashboardBindArgPattern.test(dockerfile)) { + const dashboardBindArg = findRemoteDashboardBindFinalStageArg(dockerfile); + if (dashboardBind && !dashboardBindArg) { throw new Error( "Dockerfile is missing ARG NEMOCLAW_DASHBOARD_BIND; cannot prepare remote dashboard exposure.", ); } - dockerfile = dockerfile.replace( - dashboardBindArgPattern, - `ARG NEMOCLAW_DASHBOARD_BIND=${dashboardBind}`, - ); + if (dashboardBindArg) { + dockerfile = `${dockerfile.slice(0, dashboardBindArg.start)}ARG NEMOCLAW_DASHBOARD_BIND=${dashboardBind}${dockerfile.slice(dashboardBindArg.end)}`; + } const dashboardRemoteBindPrepared = dashboardBind === "0.0.0.0" && hasRemoteDashboardBindGenerationContract(dockerfile); if (dashboardBind === "0.0.0.0" && !dashboardRemoteBindPrepared) { 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 0000000000..77fcfea264 --- /dev/null +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + dockerfileInstructions, + readDockerfilePatchSnapshot, + type DockerfileInstruction, +} 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\b.*generate-openclaw-config\.mts/; + +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 && OPENCLAW_CONFIG_GENERATOR_RE.test(instruction.text), + ); + return argIndex >= 0 && promotionIndex > argIndex && generatorIndex > promotionIndex; +} + +export function hasPreparedRemoteDashboardBind(dockerfilePath: string): boolean { + return hasRemoteDashboardBindGenerationContract( + readDockerfilePatchSnapshot(dockerfilePath).content, + ); +} diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index e591312e9a..5600eaa618 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -122,10 +122,38 @@ describe("remote dashboard bind production lifecycle", () => { 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"), ); From 505b6c6187c0559019071261c31a4829b6280e29 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 09:48:18 -0700 Subject: [PATCH 22/46] fix(onboard): reject remote bind config overwrites Signed-off-by: Prekshi Vyas --- src/lib/onboard/dockerfile-patch.ts | 27 ++--- ...ckerfile-remote-dashboard-bind-contract.ts | 63 +++++++++- test/dashboard-remote-bind-lifecycle.test.ts | 109 ++++++++++++++++++ test/e2e/brev-e2e.test.ts | 20 +++- 4 files changed, 197 insertions(+), 22 deletions(-) diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index d6c534cf76..d96f777489 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -30,9 +30,8 @@ import { isDcodeAutoApprovalMode, } from "./dcode-auto-approval"; import { - findRemoteDashboardBindFinalStageArg, hasPreparedRemoteDashboardBind, - hasRemoteDashboardBindGenerationContract, + patchRemoteDashboardBindContract, } from "./dockerfile-remote-dashboard-bind-contract"; import { dockerfileInstructions, @@ -190,24 +189,12 @@ export function patchStagedDockerfile( /^ARG CHAT_UI_URL=.*$/m, `ARG CHAT_UI_URL=${sanitizeDockerArg(chatUiUrl)}`, ); - const dashboardBind = process.env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0" ? "0.0.0.0" : ""; - const dashboardBindArg = findRemoteDashboardBindFinalStageArg(dockerfile); - if (dashboardBind && !dashboardBindArg) { - throw new Error( - "Dockerfile is missing ARG NEMOCLAW_DASHBOARD_BIND; cannot prepare remote dashboard exposure.", - ); - } - if (dashboardBindArg) { - dockerfile = `${dockerfile.slice(0, dashboardBindArg.start)}ARG NEMOCLAW_DASHBOARD_BIND=${dashboardBind}${dockerfile.slice(dashboardBindArg.end)}`; - } - const dashboardRemoteBindPrepared = - dashboardBind === "0.0.0.0" && hasRemoteDashboardBindGenerationContract(dockerfile); - 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; cannot prepare remote dashboard exposure.", - ); - } + const remoteDashboardBind = patchRemoteDashboardBindContract( + dockerfile, + process.env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0" ? "0.0.0.0" : "", + ); + dockerfile = remoteDashboardBind.dockerfile; + const { dashboardRemoteBindPrepared } = remoteDashboardBind; dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_BASE_URL=.*$/m, `ARG NEMOCLAW_INFERENCE_BASE_URL=${sanitizeDockerArg(inferenceBaseUrl)}`, diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index 77fcfea264..a354b16609 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -11,6 +11,23 @@ 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\b.*generate-openclaw-config\.mts/; +const SAFE_VALIDATION_GENERATOR_HOME_RE = + /\bHOME=(?:"\$validation_home"|\$validation_home)(?=\s|$)/; +const OPENCLAW_CONFIG_TARGET_RE = `(?:/sandbox/|\\$HOME/|~/)?\\.openclaw/openclaw\\.json\\b`; +const OPENCLAW_CONFIG_REDIRECT_OVERWRITE_RE = new RegExp( + `(?:^|[\\s;&|])(?:[0-9]?>|>>)\\s*["']?${OPENCLAW_CONFIG_TARGET_RE}`, +); +const OPENCLAW_CONFIG_COPY_OVERWRITE_RE = new RegExp( + `\\b(?:cp|mv|install)\\b[\\s\\S]*\\s["']?${OPENCLAW_CONFIG_TARGET_RE}`, +); +const OPENCLAW_CONFIG_IN_PLACE_EDIT_RE = new RegExp( + `\\b(?:sed|perl)\\b[\\s\\S]*\\s-i\\b[\\s\\S]*${OPENCLAW_CONFIG_TARGET_RE}`, +); + +export type PatchedRemoteDashboardBindContract = { + dockerfile: string; + dashboardRemoteBindPrepared: boolean; +}; function finalStageInstructions(dockerfile: string): DockerfileInstruction[] { const instructions = dockerfileInstructions(dockerfile); @@ -29,6 +46,16 @@ export function findRemoteDashboardBindFinalStageArg( ); } +function invalidatesGeneratedOpenClawConfig(instruction: DockerfileInstruction): boolean { + return ( + OPENCLAW_CONFIG_REDIRECT_OVERWRITE_RE.test(instruction.text) || + OPENCLAW_CONFIG_COPY_OVERWRITE_RE.test(instruction.text) || + OPENCLAW_CONFIG_IN_PLACE_EDIT_RE.test(instruction.text) || + (OPENCLAW_CONFIG_GENERATOR_RE.test(instruction.text) && + !SAFE_VALIDATION_GENERATOR_HOME_RE.test(instruction.text)) + ); +} + export function hasRemoteDashboardBindGenerationContract(dockerfile: string): boolean { const finalStage = finalStageInstructions(dockerfile); const argIndex = finalStage.findIndex((instruction) => @@ -41,7 +68,41 @@ export function hasRemoteDashboardBindGenerationContract(dockerfile: string): bo (instruction, index) => index > promotionIndex && OPENCLAW_CONFIG_GENERATOR_RE.test(instruction.text), ); - return argIndex >= 0 && promotionIndex > argIndex && generatorIndex > promotionIndex; + const invalidatorIndex = finalStage.findIndex( + (instruction, index) => + index > generatorIndex && invalidatesGeneratedOpenClawConfig(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 { diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index 5600eaa618..1ba54c3651 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -167,6 +167,115 @@ describe("remote dashboard bind production lifecycle", () => { } }); + 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("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("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"); diff --git a/test/e2e/brev-e2e.test.ts b/test/e2e/brev-e2e.test.ts index ed941d9e55..cc9c97fb13 100644 --- a/test/e2e/brev-e2e.test.ts +++ b/test/e2e/brev-e2e.test.ts @@ -988,6 +988,18 @@ 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"; +} + /** * Kill the hung onboard process tree and write the sandbox registry manually. * @@ -999,6 +1011,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" && readProductDashboardRemoteBindPrepared(); + 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 { @@ -1025,7 +1043,7 @@ function writeManualRegistry(elapsed: () => string): void { provider: null, gpuEnabled: false, policies: ["pypi", "npm"], - ...(TEST_SUITE === "dashboard-remote-bind" ? { dashboardRemoteBindPrepared: true } : {}), + ...(dashboardRemoteBindPrepared ? { dashboardRemoteBindPrepared: true } : {}), }, }, }, From 9843ecc761e29c1bf56d1cc03fdfb9d7178e0c43 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 11:10:27 -0700 Subject: [PATCH 23/46] docs(troubleshooting): split remote bind guidance lines --- docs/reference/troubleshooting.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 830aa695aa..fbb3b0ef44 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -301,7 +301,8 @@ 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. +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. From 07771a9f9ed05d19cb8b5bec50e7de22a278f963 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 12:31:40 -0700 Subject: [PATCH 24/46] test(sandbox): budget MCP status subprocess suite --- src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index aba649976e..3543b0161c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -146,7 +146,7 @@ ${body} return { status: result.status, stdout: result.stdout }; } -describe("MCP status wire-level credential-resolution probe", () => { +describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, () => { it("probes by default for a single named server and surfaces the wire failure (#6379)", () => { const home = createTempHome("nemoclaw-mcp-resolution-single-"); const { stdout } = runHarness( From e86fd8c2f1867e495eff07fd0cd4a2d73e63efac Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 12:48:07 -0700 Subject: [PATCH 25/46] test(e2e): wait for dashboard remote bind proof --- test/e2e/brev-e2e.test.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/test/e2e/brev-e2e.test.ts b/test/e2e/brev-e2e.test.ts index cc9c97fb13..dc527b7d27 100644 --- a/test/e2e/brev-e2e.test.ts +++ b/test/e2e/brev-e2e.test.ts @@ -1000,6 +1000,28 @@ function readProductDashboardRemoteBindPrepared(): boolean { 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): boolean { + if (TEST_SUITE !== "dashboard-remote-bind") return false; + + console.log(`[${elapsed()}] Waiting for product-written dashboard remote-bind registry proof...`); + const deadline = Date.now() + DASHBOARD_REMOTE_BIND_PROOF_WAIT_MS; + while (Date.now() < deadline) { + if (readProductDashboardRemoteBindPrepared()) return true; + execSync(`sleep ${DASHBOARD_REMOTE_BIND_PROOF_POLL_MS / 1000}`); + } + + const failLog = ssh("tail -120 /tmp/nemoclaw-onboard.log 2>/dev/null || echo 'no log'", { + timeout: 10_000, + }); + throw new Error( + "dashboard-remote-bind E2E did not observe product-written remote bind proof before " + + `manual registry handoff.\n${failLog}`, + ); +} + /** * Kill the hung onboard process tree and write the sandbox registry manually. * @@ -1012,7 +1034,7 @@ function readProductDashboardRemoteBindPrepared(): boolean { function writeManualRegistry(elapsed: () => string): void { console.log(`[${elapsed()}] Sandbox ready — killing hung onboard and writing registry...`); const dashboardRemoteBindPrepared = - TEST_SUITE === "dashboard-remote-bind" && readProductDashboardRemoteBindPrepared(); + 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", From cc57a9632bec66f0c4793fd3f0c4f9fe132dbaf2 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 12:50:45 -0700 Subject: [PATCH 26/46] test(e2e): keep dashboard proof wait linear --- test/e2e/brev-e2e.test.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/test/e2e/brev-e2e.test.ts b/test/e2e/brev-e2e.test.ts index dc527b7d27..1939c6fbc8 100644 --- a/test/e2e/brev-e2e.test.ts +++ b/test/e2e/brev-e2e.test.ts @@ -1003,23 +1003,26 @@ function readProductDashboardRemoteBindPrepared(): boolean { const DASHBOARD_REMOTE_BIND_PROOF_WAIT_MS = 180_000; const DASHBOARD_REMOTE_BIND_PROOF_POLL_MS = 5_000; -function waitForProductDashboardRemoteBindPrepared(elapsed: () => string): boolean { - if (TEST_SUITE !== "dashboard-remote-bind") return false; - +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; - while (Date.now() < deadline) { - if (readProductDashboardRemoteBindPrepared()) return true; + let prepared = readProductDashboardRemoteBindPrepared(); + while (!prepared && Date.now() < deadline) { execSync(`sleep ${DASHBOARD_REMOTE_BIND_PROOF_POLL_MS / 1000}`); + prepared = readProductDashboardRemoteBindPrepared(); } - const failLog = ssh("tail -120 /tmp/nemoclaw-onboard.log 2>/dev/null || echo 'no log'", { - timeout: 10_000, - }); - throw new Error( + 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; } /** From 8967b472b865ba7ff1d8bf0df8c9006f5def3fc2 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 13:05:38 -0700 Subject: [PATCH 27/46] test(e2e): accept dashboard connect background proof --- test/e2e/live/dashboard-remote-bind.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/e2e/live/dashboard-remote-bind.test.ts b/test/e2e/live/dashboard-remote-bind.test.ts index fcaf782a4c..c60fc98e8d 100644 --- a/test/e2e/live/dashboard-remote-bind.test.ts +++ b/test/e2e/live/dashboard-remote-bind.test.ts @@ -46,6 +46,19 @@ function remoteHostCandidate(): string { return process.env.NEMOCLAW_E2E_REMOTE_HOST || externalIpv4 || os.hostname(); } +function connectStartedDashboardForward( + result: { exitCode: number | null; stdout: string }, + sandboxName: string, + dashboardPort: string, +): boolean { + return ( + result.exitCode === 0 || + (result.exitCode === null && + result.stdout.includes(`Forwarding port ${dashboardPort}`) && + result.stdout.includes(`sandbox ${sandboxName}`)) + ); +} + runDashboardRemoteBindTest( "dashboard forward binds all interfaces when remote bind is explicitly requested", async ({ artifacts, host, sandbox }) => { @@ -89,7 +102,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", From b7ac87e5ed8f4306a79d1e49c14bb7f72e2720f1 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 13:11:17 -0700 Subject: [PATCH 28/46] test(e2e): document dashboard remote bind parity --- ...ebuild-managed-image-configuration.test.ts | 33 +++++++++++++++++++ test/dashboard-remote-bind-lifecycle.test.ts | 2 +- test/e2e/mock-parity.json | 7 +++- 3 files changed, 40 insertions(+), 2 deletions(-) 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 05204cf71e..f195815a92 100644 --- a/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts +++ b/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts @@ -111,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"); diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index 1ba54c3651..afe078711f 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -17,7 +17,7 @@ import { applyReusedSandboxDashboardState } from "../src/lib/onboard/sandbox-reu const requireSource = createRequire(import.meta.url); const { ensureSandboxPortForward, ensureSandboxPortForwardForPort } = requireSource( - "../src/lib/actions/sandbox/forward-recovery.ts", + "../src/lib/actions/sandbox/forward-recovery.js", ) as typeof import("../src/lib/actions/sandbox/forward-recovery.js"); afterEach(() => { diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index cb4307863e..508bf44727 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -1,5 +1,10 @@ { "$comment": "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0", "version": 1, - "entries": [] + "entries": [ + { + "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." + } + ] } From ff1566083518d5e3c255bfee96456b2dd6e43b62 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 13:25:06 -0700 Subject: [PATCH 29/46] test(e2e): accept dashboard forward proof on stderr --- test/e2e/live/dashboard-remote-bind.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/e2e/live/dashboard-remote-bind.test.ts b/test/e2e/live/dashboard-remote-bind.test.ts index c60fc98e8d..9410e6483e 100644 --- a/test/e2e/live/dashboard-remote-bind.test.ts +++ b/test/e2e/live/dashboard-remote-bind.test.ts @@ -46,16 +46,21 @@ 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 }, + 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 && - result.stdout.includes(`Forwarding port ${dashboardPort}`) && - result.stdout.includes(`sandbox ${sandboxName}`)) + output.includes(`Forwarding port ${dashboardPort}`) && + output.includes(`sandbox ${sandboxName}`)) ); } From f44b5ec68036228cc07e57be6b43fc39e20efab8 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 13:57:19 -0700 Subject: [PATCH 30/46] fix(onboard): fail closed on remote bind rewrites --- ...ckerfile-remote-dashboard-bind-contract.ts | 31 +++++++++-------- test/dashboard-remote-bind-lifecycle.test.ts | 34 +++++++++++++++++++ 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index a354b16609..61d6f77b53 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -13,15 +13,13 @@ const REMOTE_BIND_PROMOTION_RE = /NEMOCLAW_DASHBOARD_BIND=\$\{NEMOCLAW_DASHBOARD const OPENCLAW_CONFIG_GENERATOR_RE = /^RUN\b.*generate-openclaw-config\.mts/; const SAFE_VALIDATION_GENERATOR_HOME_RE = /\bHOME=(?:"\$validation_home"|\$validation_home)(?=\s|$)/; -const OPENCLAW_CONFIG_TARGET_RE = `(?:/sandbox/|\\$HOME/|~/)?\\.openclaw/openclaw\\.json\\b`; -const OPENCLAW_CONFIG_REDIRECT_OVERWRITE_RE = new RegExp( - `(?:^|[\\s;&|])(?:[0-9]?>|>>)\\s*["']?${OPENCLAW_CONFIG_TARGET_RE}`, +const OPENCLAW_CONFIG_TARGET_PATTERN = `(?:/sandbox/|\\$HOME/|~/)?\\.openclaw/openclaw\\.json\\b`; +const OPENCLAW_CONFIG_TARGET_RE = new RegExp(OPENCLAW_CONFIG_TARGET_PATTERN); +const SAFE_OPENCLAW_CONFIG_CHMOD_RE = new RegExp( + `^RUN\\s+chmod\\s+[0-7]{3,4}\\s+["']?${OPENCLAW_CONFIG_TARGET_PATTERN}["']?$`, ); -const OPENCLAW_CONFIG_COPY_OVERWRITE_RE = new RegExp( - `\\b(?:cp|mv|install)\\b[\\s\\S]*\\s["']?${OPENCLAW_CONFIG_TARGET_RE}`, -); -const OPENCLAW_CONFIG_IN_PLACE_EDIT_RE = new RegExp( - `\\b(?:sed|perl)\\b[\\s\\S]*\\s-i\\b[\\s\\S]*${OPENCLAW_CONFIG_TARGET_RE}`, +const SAFE_OPENCLAW_CONFIG_HASH_RE = new RegExp( + `^RUN\\s+sha256sum\\s+["']?${OPENCLAW_CONFIG_TARGET_PATTERN}["']?\\s*>\\s*["']?(?:/sandbox/|\\$HOME/|~/)?\\.openclaw/\\.config-hash\\b["']?$`, ); export type PatchedRemoteDashboardBindContract = { @@ -46,13 +44,16 @@ export function findRemoteDashboardBindFinalStageArg( ); } -function invalidatesGeneratedOpenClawConfig(instruction: DockerfileInstruction): boolean { +function allowsPostGeneratorOpenClawConfigInstruction(instruction: DockerfileInstruction): boolean { + if (OPENCLAW_CONFIG_GENERATOR_RE.test(instruction.text)) { + return SAFE_VALIDATION_GENERATOR_HOME_RE.test(instruction.text); + } + if (!OPENCLAW_CONFIG_TARGET_RE.test(instruction.text)) { + return true; + } return ( - OPENCLAW_CONFIG_REDIRECT_OVERWRITE_RE.test(instruction.text) || - OPENCLAW_CONFIG_COPY_OVERWRITE_RE.test(instruction.text) || - OPENCLAW_CONFIG_IN_PLACE_EDIT_RE.test(instruction.text) || - (OPENCLAW_CONFIG_GENERATOR_RE.test(instruction.text) && - !SAFE_VALIDATION_GENERATOR_HOME_RE.test(instruction.text)) + SAFE_OPENCLAW_CONFIG_CHMOD_RE.test(instruction.text) || + SAFE_OPENCLAW_CONFIG_HASH_RE.test(instruction.text) ); } @@ -70,7 +71,7 @@ export function hasRemoteDashboardBindGenerationContract(dockerfile: string): bo ); const invalidatorIndex = finalStage.findIndex( (instruction, index) => - index > generatorIndex && invalidatesGeneratedOpenClawConfig(instruction), + index > generatorIndex && !allowsPostGeneratorOpenClawConfigInstruction(instruction), ); return ( argIndex >= 0 && diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index afe078711f..98ea6a1ecd 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -194,6 +194,40 @@ describe("remote dashboard bind production lifecycle", () => { } }); + it.each([ + ["node", `RUN node -e "require('fs').writeFileSync('/sandbox/.openclaw/openclaw.json','{}')"`], + [ + "python", + `RUN python3 -c "from pathlib import Path; Path('/sandbox/.openclaw/openclaw.json').write_text('{}')"`, + ], + ["tee", `RUN printf '{}' | tee /sandbox/.openclaw/openclaw.json >/dev/null`], + ])("rejects final-stage %s rewrites after the remote-bind generator (#6024)", (_label, writer) => { + vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-writer-")); + 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", + writer, + ].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 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-")); From 63ae5a20c5c56f1032292c9cef54693a9e0e32ca Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 13:57:54 -0700 Subject: [PATCH 31/46] fix(onboard): fail closed on remote bind config rewrites --- ...ckerfile-remote-dashboard-bind-contract.ts | 78 ++++++++++----- test/dashboard-remote-bind-lifecycle.test.ts | 95 +++++++++++++++++++ 2 files changed, 148 insertions(+), 25 deletions(-) diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index a354b16609..e3cfe5373e 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -10,19 +10,59 @@ import { 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\b.*generate-openclaw-config\.mts/; +const OPENCLAW_CONFIG_GENERATOR_RE = + /^RUN\b.*\bnode\s+--experimental-strip-types\s+\/scripts\/generate-openclaw-config\.mts\b/; const SAFE_VALIDATION_GENERATOR_HOME_RE = /\bHOME=(?:"\$validation_home"|\$validation_home)(?=\s|$)/; -const OPENCLAW_CONFIG_TARGET_RE = `(?:/sandbox/|\\$HOME/|~/)?\\.openclaw/openclaw\\.json\\b`; -const OPENCLAW_CONFIG_REDIRECT_OVERWRITE_RE = new RegExp( - `(?:^|[\\s;&|])(?:[0-9]?>|>>)\\s*["']?${OPENCLAW_CONFIG_TARGET_RE}`, -); -const OPENCLAW_CONFIG_COPY_OVERWRITE_RE = new RegExp( - `\\b(?:cp|mv|install)\\b[\\s\\S]*\\s["']?${OPENCLAW_CONFIG_TARGET_RE}`, -); -const OPENCLAW_CONFIG_IN_PLACE_EDIT_RE = new RegExp( - `\\b(?:sed|perl)\\b[\\s\\S]*\\s-i\\b[\\s\\S]*${OPENCLAW_CONFIG_TARGET_RE}`, -); +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 OPENCLAW_PLUGIN_INTEGRITY_RE = /^RUN\s+set -eu;\s+verify_openclaw_plugin_integrity\(\) \{/; +const NEMOCLAW_PLUGIN_INSTALL_RE = + /^RUN\s+NPM_CONFIG_IGNORE_SCRIPTS=true\s+npm_config_ignore_scripts=true\s+openclaw plugins install \/opt\/nemoclaw\b/; +const MANAGED_PROXY_TOKEN_PATCH_RE = + /^RUN\s+python3 -c ".*path = os\.path\.expanduser\('~\/\.openclaw\/openclaw\.json'\);.*cfg\.setdefault\('gateway', \{\}\)\.setdefault\('auth', \{\}\)\['token'\] = '';.*cfg\['proxy'\] = \{.*'loopbackMode': 'gateway-only'.*json\.dump\(cfg, open\(path, 'w'\), indent=2\);.*os\.chmod\(path, 0o600\)"$/; +const LEGACY_OPENCLAW_LAYOUT_RE = + /^RUN\s+set -eu;\s+config_dir=\/sandbox\/\.openclaw;\s+data_dir=\/sandbox\/\.openclaw-data;\s+/; +const GROUP_MEMBERSHIP_RE = /^RUN\s+if id gateway\b/; +const OPENCLAW_PERMISSION_RE = + /^RUN\s+set -eu;\s+if \[ -e \/tmp\/nemoclaw-legacy-openclaw-layout \]; then\b/; +const SHELL_HOOKS_RE = /^RUN\s+chmod 444 \/usr\/local\/lib\/nemoclaw\/sandbox-rlimits\.sh\b/; +const NEMOCLAW_STATE_RE = /^RUN\s+chown root:root \/sandbox\/\.nemoclaw\b/; +const DARWIN_VM_COMPAT_RE = /^RUN\s+if \[ "\$NEMOCLAW_DARWIN_VM_COMPAT" = "1" \]; then\b/; +const OTEL_PROXY_PATCH_RE = /^RUN\s+set -eu;\s+if \[ "\$NEMOCLAW_OPENCLAW_OTEL" = "1" \]; then\b/; + +const ALLOWED_POST_GENERATOR_RUN_RE = [ + CONFIG_MODE_RE, + CONFIG_HASH_RE, + MESSAGING_BUILD_APPLIER_RE, + OPENCLAW_PLUGIN_INTEGRITY_RE, + NEMOCLAW_PLUGIN_INSTALL_RE, + MANAGED_PROXY_TOKEN_PATCH_RE, + LEGACY_OPENCLAW_LAYOUT_RE, + GROUP_MEMBERSHIP_RE, + OPENCLAW_PERMISSION_RE, + SHELL_HOOKS_RE, + NEMOCLAW_STATE_RE, + DARWIN_VM_COMPAT_RE, + OTEL_PROXY_PATCH_RE, +] as const; + +const postGeneratorInstructionAllowed = (instruction: DockerfileInstruction): boolean => { + const { text } = instruction; + if (PASSIVE_FINAL_STAGE_INSTRUCTION_RE.test(text)) return true; + if (OPENCLAW_CONFIG_GENERATOR_RE.test(text)) { + return SAFE_VALIDATION_GENERATOR_HOME_RE.test(text); + } + return ALLOWED_POST_GENERATOR_RUN_RE.some((pattern) => pattern.test(text)); +}; + +const isPrimaryOpenClawConfigGenerator = (instruction: DockerfileInstruction): boolean => + OPENCLAW_CONFIG_GENERATOR_RE.test(instruction.text) && + !SAFE_VALIDATION_GENERATOR_HOME_RE.test(instruction.text); export type PatchedRemoteDashboardBindContract = { dockerfile: string; @@ -46,16 +86,6 @@ export function findRemoteDashboardBindFinalStageArg( ); } -function invalidatesGeneratedOpenClawConfig(instruction: DockerfileInstruction): boolean { - return ( - OPENCLAW_CONFIG_REDIRECT_OVERWRITE_RE.test(instruction.text) || - OPENCLAW_CONFIG_COPY_OVERWRITE_RE.test(instruction.text) || - OPENCLAW_CONFIG_IN_PLACE_EDIT_RE.test(instruction.text) || - (OPENCLAW_CONFIG_GENERATOR_RE.test(instruction.text) && - !SAFE_VALIDATION_GENERATOR_HOME_RE.test(instruction.text)) - ); -} - export function hasRemoteDashboardBindGenerationContract(dockerfile: string): boolean { const finalStage = finalStageInstructions(dockerfile); const argIndex = finalStage.findIndex((instruction) => @@ -65,12 +95,10 @@ export function hasRemoteDashboardBindGenerationContract(dockerfile: string): bo (instruction, index) => index > argIndex && REMOTE_BIND_PROMOTION_RE.test(instruction.text), ); const generatorIndex = finalStage.findIndex( - (instruction, index) => - index > promotionIndex && OPENCLAW_CONFIG_GENERATOR_RE.test(instruction.text), + (instruction, index) => index > promotionIndex && isPrimaryOpenClawConfigGenerator(instruction), ); const invalidatorIndex = finalStage.findIndex( - (instruction, index) => - index > generatorIndex && invalidatesGeneratedOpenClawConfig(instruction), + (instruction, index) => index > generatorIndex && !postGeneratorInstructionAllowed(instruction), ); return ( argIndex >= 0 && diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index afe078711f..830445fcf1 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -25,6 +25,18 @@ afterEach(() => { 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"); +} + describe("remote dashboard bind production lifecycle", () => { it("carries the audited remote-exposure signal through image and sandbox creation (#6024)", () => { vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); @@ -194,6 +206,67 @@ describe("remote dashboard bind production lifecycle", () => { } }); + 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-")); @@ -222,6 +295,28 @@ describe("remote dashboard bind production lifecycle", () => { } }); + 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( + `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)"`, + "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 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-")); From fa3d143778913a9ade1c899beb98198e33bc5395 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 14:53:38 -0700 Subject: [PATCH 32/46] fix(security): harden remote bind and auth provenance --- Dockerfile | 4 ++ docs/get-started/quickstart.mdx | 3 +- docs/security/best-practices.mdx | 7 +- scripts/generate-openclaw-config.mts | 15 +++-- src/lib/onboard/dockerfile-patch.test.ts | 2 + src/lib/onboard/dockerfile-patch.ts | 8 ++- ...ckerfile-remote-dashboard-bind-contract.ts | 66 ++++++++++--------- test/dashboard-remote-bind-lifecycle.test.ts | 44 +++++++++++++ ...ate-openclaw-config-security-audit.test.ts | 20 ++++++ ...w-security-audit-suppressions-real.test.ts | 1 + 10 files changed, 129 insertions(+), 41 deletions(-) diff --git a/Dockerfile b/Dockerfile index 849788e52b..a30836f664 100644 --- a/Dockerfile +++ b/Dockerfile @@ -877,6 +877,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. @@ -935,6 +938,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_OPENCLAW_WECHAT_PLUGIN_PREINSTALLED=1 \ NEMOCLAW_DASHBOARD_BIND=${NEMOCLAW_DASHBOARD_BIND} \ 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 1d57cd00a6..96bc932b65 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -200,7 +200,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/security/best-practices.mdx b/docs/security/best-practices.mdx index 37ca1cdcc4..febec6d0b2 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -643,8 +643,8 @@ Device authentication requires each connecting device to go through a pairing fl | Aspect | Detail | |---|---| -| Default | Enabled for loopback dashboards. NemoClaw disables device authentication for non-loopback `CHAT_UI_URL` values and when `NEMOCLAW_DISABLE_DEVICE_AUTH=1`. | -| What you can change | Use a loopback `CHAT_UI_URL` to retain device authentication. Set `NEMOCLAW_DISABLE_DEVICE_AUTH=1` only as a deliberate build-time opt-out. The setting is 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 | 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. | @@ -683,7 +683,8 @@ 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 loopback-only explicit `NEMOCLAW_DISABLE_DEVICE_AUTH=1` opt-out findings visible as accepted findings instead of counting them as unexplained active findings. +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 either a non-loopback `CHAT_UI_URL` or an onboard-time `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` as remote dashboard exposure. Use the same bind setting on later `connect` calls. If the sandbox was created without that 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. 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. diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index 5b7999e9f4..4c9c600879 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -1192,8 +1192,12 @@ export function buildConfig(env: Env = process.env): JsonObject { const isRemote = !isLoopback(parsed.hostname || ""); const remoteBindOptIn = env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0"; const hasRemoteDashboardExposure = isRemote || remoteBindOptIn; - const explicitDeviceAuthOptOut = env.NEMOCLAW_DISABLE_DEVICE_AUTH === "1"; - const disableDeviceAuth = explicitDeviceAuthOptOut || hasRemoteDashboardExposure; + const deviceAuthOptOut = env.NEMOCLAW_DISABLE_DEVICE_AUTH === "1"; + const deviceAuthOptOutSource = env.NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE; + const explicitDeviceAuthOptOut = deviceAuthOptOut && deviceAuthOptOutSource === "operator"; + const managedDeviceAuthOptOut = + deviceAuthOptOut && deviceAuthOptOutSource === "managed-onboard"; + const disableDeviceAuth = deviceAuthOptOut || hasRemoteDashboardExposure; const allowInsecure = parsed.scheme === "http"; const securityAuditSuppressions: JsonObject[] = []; if (allowInsecure && !hasRemoteDashboardExposure) { @@ -1208,9 +1212,10 @@ export function buildConfig(env: Env = process.env): JsonObject { }, ); } - if (explicitDeviceAuthOptOut && !hasRemoteDashboardExposure) { - const reason = - "NemoClaw applies this setting because NEMOCLAW_DISABLE_DEVICE_AUTH=1 explicitly opts out of device authentication."; + if ((explicitDeviceAuthOptOut || managedDeviceAuthOptOut) && !hasRemoteDashboardExposure) { + const reason = managedDeviceAuthOptOut + ? "NemoClaw onboarding disables device authentication for immediate dashboard access (managed compatibility behavior; see #1217)." + : "NemoClaw applies this setting because NEMOCLAW_DISABLE_DEVICE_AUTH=1 explicitly opts out of device authentication."; securityAuditSuppressions.push( { checkId: "gateway.control_ui.device_auth_disabled", reason }, { diff --git a/src/lib/onboard/dockerfile-patch.test.ts b/src/lib/onboard/dockerfile-patch.test.ts index ccd994f313..2c3d5fa1ba 100644 --- a/src/lib/onboard/dockerfile-patch.test.ts +++ b/src/lib/onboard/dockerfile-patch.test.ts @@ -171,6 +171,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 +209,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 1aabd71616..ee02ac56c2 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -357,11 +357,17 @@ export function patchStagedDockerfile( } } // Onboard flow expects immediate dashboard access without device pairing, - // so disable device auth for images built during onboard (see #1217). + // so disable device auth for images built during onboard (see #1217). Keep + // this managed compatibility choice distinct from an operator's explicit + // build-arg opt-out for security-audit reporting. dockerfile = dockerfile.replace( /^ARG NEMOCLAW_DISABLE_DEVICE_AUTH=.*$/m, `ARG NEMOCLAW_DISABLE_DEVICE_AUTH=${sanitizeDockerArg("1")}`, ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE=.*$/m, + `ARG NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE=${sanitizeDockerArg("managed-onboard")}`, + ); const messagingPlan = MessagingSetupApplier.readPlanFromEnv(); if (messagingPlan) { const hydratedMessagingPlan = hydrateDerivedSandboxMessagingPlanFields( diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index e3cfe5373e..337c890169 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { createHash } from "node:crypto"; + import { dockerfileInstructions, readDockerfilePatchSnapshot, @@ -11,58 +13,60 @@ 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\b.*\bnode\s+--experimental-strip-types\s+\/scripts\/generate-openclaw-config\.mts\b/; -const SAFE_VALIDATION_GENERATOR_HOME_RE = - /\bHOME=(?:"\$validation_home"|\$validation_home)(?=\s|$)/; + /^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 OPENCLAW_PLUGIN_INTEGRITY_RE = /^RUN\s+set -eu;\s+verify_openclaw_plugin_integrity\(\) \{/; -const NEMOCLAW_PLUGIN_INSTALL_RE = - /^RUN\s+NPM_CONFIG_IGNORE_SCRIPTS=true\s+npm_config_ignore_scripts=true\s+openclaw plugins install \/opt\/nemoclaw\b/; const MANAGED_PROXY_TOKEN_PATCH_RE = /^RUN\s+python3 -c ".*path = os\.path\.expanduser\('~\/\.openclaw\/openclaw\.json'\);.*cfg\.setdefault\('gateway', \{\}\)\.setdefault\('auth', \{\}\)\['token'\] = '';.*cfg\['proxy'\] = \{.*'loopbackMode': 'gateway-only'.*json\.dump\(cfg, open\(path, 'w'\), indent=2\);.*os\.chmod\(path, 0o600\)"$/; -const LEGACY_OPENCLAW_LAYOUT_RE = - /^RUN\s+set -eu;\s+config_dir=\/sandbox\/\.openclaw;\s+data_dir=\/sandbox\/\.openclaw-data;\s+/; -const GROUP_MEMBERSHIP_RE = /^RUN\s+if id gateway\b/; -const OPENCLAW_PERMISSION_RE = - /^RUN\s+set -eu;\s+if \[ -e \/tmp\/nemoclaw-legacy-openclaw-layout \]; then\b/; -const SHELL_HOOKS_RE = /^RUN\s+chmod 444 \/usr\/local\/lib\/nemoclaw\/sandbox-rlimits\.sh\b/; -const NEMOCLAW_STATE_RE = /^RUN\s+chown root:root \/sandbox\/\.nemoclaw\b/; -const DARWIN_VM_COMPAT_RE = /^RUN\s+if \[ "\$NEMOCLAW_DARWIN_VM_COMPAT" = "1" \]; then\b/; -const OTEL_PROXY_PATCH_RE = /^RUN\s+set -eu;\s+if \[ "\$NEMOCLAW_OPENCLAW_OTEL" = "1" \]; then\b/; -const ALLOWED_POST_GENERATOR_RUN_RE = [ +const EXACT_CUSTOM_POST_GENERATOR_RUN_RE = [ CONFIG_MODE_RE, CONFIG_HASH_RE, MESSAGING_BUILD_APPLIER_RE, - OPENCLAW_PLUGIN_INTEGRITY_RE, - NEMOCLAW_PLUGIN_INSTALL_RE, MANAGED_PROXY_TOKEN_PATCH_RE, - LEGACY_OPENCLAW_LAYOUT_RE, - GROUP_MEMBERSHIP_RE, - OPENCLAW_PERMISSION_RE, - SHELL_HOOKS_RE, - NEMOCLAW_STATE_RE, - DARWIN_VM_COMPAT_RE, - OTEL_PROXY_PATCH_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", + "96e127a525e71bfc6d61e1da86b83d53a3ae61ca5d7923aed6d26afac10326cd", + "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 (OPENCLAW_CONFIG_GENERATOR_RE.test(text)) { - return SAFE_VALIDATION_GENERATOR_HOME_RE.test(text); - } - return ALLOWED_POST_GENERATOR_RUN_RE.some((pattern) => pattern.test(text)); + 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) && - !SAFE_VALIDATION_GENERATOR_HOME_RE.test(instruction.text); + OPENCLAW_CONFIG_GENERATOR_RE.test(instruction.text); export type PatchedRemoteDashboardBindContract = { dockerfile: string; diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index 830445fcf1..45dc91c91a 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -38,6 +38,20 @@ function remoteBindDockerfile(...postGeneratorInstructions: string[]): string { } describe("remote dashboard bind production lifecycle", () => { + 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-")); @@ -206,6 +220,36 @@ describe("remote dashboard bind production lifecycle", () => { } }); + 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-")); diff --git a/test/generate-openclaw-config-security-audit.test.ts b/test/generate-openclaw-config-security-audit.test.ts index 5acb175d61..467c611918 100644 --- a/test/generate-openclaw-config-security-audit.test.ts +++ b/test/generate-openclaw-config-security-audit.test.ts @@ -75,6 +75,7 @@ describe("generate-openclaw-config.mts: managed security audit findings", () => it("explains an explicit loopback device auth opt-out (#6024)", () => { const config = buildSecurityAuditConfig("https://127.0.0.1:18789", { NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE: "operator", }); expect(config.security.audit.suppressions.map((entry: any) => entry.checkId)).toEqual([ "gateway.control_ui.device_auth_disabled", @@ -88,6 +89,25 @@ describe("generate-openclaw-config.mts: managed security audit findings", () => ); }); + 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("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/openclaw-security-audit-suppressions-real.test.ts b/test/openclaw-security-audit-suppressions-real.test.ts index a72334be85..15a49a7ebc 100644 --- a/test/openclaw-security-audit-suppressions-real.test.ts +++ b/test/openclaw-security-audit-suppressions-real.test.ts @@ -254,6 +254,7 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS !== "1")( const explicitOptOut = runOpenClawAudit(binary, "https://127.0.0.1:18789", { NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE: "operator", }); expect( explicitOptOut.suppressedFindings?.find( From 24006e1d1b0f411454db62f4dae318bb3282c4d5 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 15:17:14 -0700 Subject: [PATCH 33/46] fix(security): require exact managed proxy patch --- ...ckerfile-remote-dashboard-bind-contract.ts | 4 ---- test/dashboard-remote-bind-lifecycle.test.ts | 24 ++++++++++++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index 337c890169..d56e9cad36 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -22,14 +22,10 @@ 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 MANAGED_PROXY_TOKEN_PATCH_RE = - /^RUN\s+python3 -c ".*path = os\.path\.expanduser\('~\/\.openclaw\/openclaw\.json'\);.*cfg\.setdefault\('gateway', \{\}\)\.setdefault\('auth', \{\}\)\['token'\] = '';.*cfg\['proxy'\] = \{.*'loopbackMode': 'gateway-only'.*json\.dump\(cfg, open\(path, 'w'\), indent=2\);.*os\.chmod\(path, 0o600\)"$/; - const EXACT_CUSTOM_POST_GENERATOR_RUN_RE = [ CONFIG_MODE_RE, CONFIG_HASH_RE, MESSAGING_BUILD_APPLIER_RE, - MANAGED_PROXY_TOKEN_PATCH_RE, ] as const; // Complex RUN instructions in the shipped Dockerfile are accepted only as diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index 45dc91c91a..5ee3307c02 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -37,6 +37,8 @@ function remoteBindDockerfile(...postGeneratorInstructions: string[]): string { ].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)"`; + describe("remote dashboard bind production lifecycle", () => { it("prepares remote bind from the exact checked-in Dockerfile instructions (#6024)", () => { vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); @@ -346,7 +348,7 @@ describe("remote dashboard bind production lifecycle", () => { fs.writeFileSync( dockerfile, remoteBindDockerfile( - `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)"`, + 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", ), ); @@ -361,6 +363,26 @@ describe("remote dashboard bind production lifecycle", () => { } }); + 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-")); From e1e9e04aa3d32bdeb993af99904aa407ebda53c9 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 15:32:14 -0700 Subject: [PATCH 34/46] fix(security): verify remote forward state --- scripts/generate-openclaw-config.mts | 11 +++----- src/lib/actions/sandbox/forward-health.ts | 13 +++++++-- src/lib/actions/sandbox/forward-recovery.ts | 26 +++++++++++++----- test/dashboard-remote-bind-lifecycle.test.ts | 27 +++++++++++++++++++ ...ate-openclaw-config-security-audit.test.ts | 14 +++------- ...w-security-audit-suppressions-real.test.ts | 10 +++++-- test/process-recovery-primitives.test.ts | 26 ++++++++++++++++++ 7 files changed, 98 insertions(+), 29 deletions(-) diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index 4c9c600879..fb28af3cca 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -1194,9 +1194,7 @@ export function buildConfig(env: Env = process.env): JsonObject { const hasRemoteDashboardExposure = isRemote || remoteBindOptIn; const deviceAuthOptOut = env.NEMOCLAW_DISABLE_DEVICE_AUTH === "1"; const deviceAuthOptOutSource = env.NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE; - const explicitDeviceAuthOptOut = deviceAuthOptOut && deviceAuthOptOutSource === "operator"; - const managedDeviceAuthOptOut = - deviceAuthOptOut && deviceAuthOptOutSource === "managed-onboard"; + const managedDeviceAuthOptOut = deviceAuthOptOut && deviceAuthOptOutSource === "managed-onboard"; const disableDeviceAuth = deviceAuthOptOut || hasRemoteDashboardExposure; const allowInsecure = parsed.scheme === "http"; const securityAuditSuppressions: JsonObject[] = []; @@ -1212,10 +1210,9 @@ export function buildConfig(env: Env = process.env): JsonObject { }, ); } - if ((explicitDeviceAuthOptOut || managedDeviceAuthOptOut) && !hasRemoteDashboardExposure) { - const reason = managedDeviceAuthOptOut - ? "NemoClaw onboarding disables device authentication for immediate dashboard access (managed compatibility behavior; see #1217)." - : "NemoClaw applies this setting because NEMOCLAW_DISABLE_DEVICE_AUTH=1 explicitly opts out of device authentication."; + 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 }, { diff --git a/src/lib/actions/sandbox/forward-health.ts b/src/lib/actions/sandbox/forward-health.ts index a4c7104554..79d96a96b4 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 c2ba7a719c..4b86e2abd0 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -75,6 +75,7 @@ export function ensureSandboxPortForward(sandboxName: string): boolean { return ensureSandboxPortForwardForPort(sandboxName, port, { forwardTarget: remoteBindRequested ? `0.0.0.0:${port}` : String(port), forceRestart: remoteBindRequested, + expectedBind: remoteBindRequested ? "0.0.0.0" : undefined, beforeStart: remoteBindRequested ? () => registry.getSandbox(sandboxName)?.dashboardRemoteBindPrepared === true : undefined, @@ -102,6 +103,7 @@ export function isSandboxForwardHealthy(sandboxName: string): SandboxForwardHeal export function isSandboxPortForwardHealthy( sandboxName: string, port: number, + expectedBind?: string, ): SandboxForwardHealth { const result = captureOpenshell(["forward", "list"], { ignoreError: true, @@ -109,8 +111,12 @@ 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, ); } @@ -120,11 +126,17 @@ export function ensureSandboxPortForwardForPort( options: { forwardTarget?: string; forceRestart?: boolean; + expectedBind?: string; beforeStart?: () => boolean; } = {}, ): boolean { - const { forwardTarget = String(port), forceRestart = false, beforeStart = () => true } = options; - let forwardHealth = isSandboxPortForwardHealthy(sandboxName, port); + 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"); @@ -159,7 +171,7 @@ export function ensureSandboxPortForwardForPort( }; const stopSettled = waitUntil( () => { - stopState.health = isSandboxPortForwardHealthy(sandboxName, port); + stopState.health = isSandboxPortForwardHealthy(sandboxName, port, expectedBind); stopState.portReleased = !isLocalForwardReachable(port); return ( (!forceRestart && stopState.health === true) || @@ -191,7 +203,7 @@ export function ensureSandboxPortForwardForPort( // 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; @@ -199,7 +211,7 @@ export function ensureSandboxPortForwardForPort( 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/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index 5ee3307c02..0260b9c710 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -513,6 +513,33 @@ describe("remote dashboard bind production lifecycle", () => { ); }); + 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("re-verifies remote-bind preparation immediately before opening the forward (#6024)", () => { const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.js"); diff --git a/test/generate-openclaw-config-security-audit.test.ts b/test/generate-openclaw-config-security-audit.test.ts index 467c611918..c6fb38380f 100644 --- a/test/generate-openclaw-config-security-audit.test.ts +++ b/test/generate-openclaw-config-security-audit.test.ts @@ -72,21 +72,13 @@ describe("generate-openclaw-config.mts: managed security audit findings", () => expect(config.security).toBeUndefined(); }); - it("explains an explicit loopback device auth opt-out (#6024)", () => { + 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.security.audit.suppressions.map((entry: any) => entry.checkId)).toEqual([ - "gateway.control_ui.device_auth_disabled", - "config.insecure_or_dangerous_flags", - ]); - expect(config.security.audit.suppressions[1].detailIncludes).toBe( - "gateway.controlUi.dangerouslyDisableDeviceAuth=true", - ); - expect(config.security.audit.suppressions[0].reason).toContain( - "NEMOCLAW_DISABLE_DEVICE_AUTH=1", - ); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + expect(config.security).toBeUndefined(); }); it("reports the managed onboarding device auth compatibility source truthfully (#6024)", () => { diff --git a/test/openclaw-security-audit-suppressions-real.test.ts b/test/openclaw-security-audit-suppressions-real.test.ts index 15a49a7ebc..9175fedd4e 100644 --- a/test/openclaw-security-audit-suppressions-real.test.ts +++ b/test/openclaw-security-audit-suppressions-real.test.ts @@ -257,14 +257,20 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS !== "1")( NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE: "operator", }); expect( - explicitOptOut.suppressedFindings?.find( + explicitOptOut.findings.find( (finding) => finding.checkId === "gateway.control_ui.device_auth_disabled", ), ).toMatchObject({ severity: "critical", remediation: expect.any(String), - suppression: { reason: expect.stringContaining("NEMOCLAW_DISABLE_DEVICE_AUTH=1") }, }); + expect( + findingForFlag( + explicitOptOut.findings, + "gateway.controlUi.dangerouslyDisableDeviceAuth=true", + ), + ).toBeDefined(); + expect(explicitOptOut.suppressedFindings ?? []).toHaveLength(0); } finally { fs.rmSync(workspace, { recursive: true, force: true }); } diff --git a/test/process-recovery-primitives.test.ts b/test/process-recovery-primitives.test.ts index 4c09025942..f4098a4eeb 100644 --- a/test/process-recovery-primitives.test.ts +++ b/test/process-recovery-primitives.test.ts @@ -169,6 +169,32 @@ 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( + classifySandboxForwardHealth( + [{ sandboxName: "beta", bind: "::", port: "18790", status: "running" }], + "beta", + "18790", + "0.0.0.0", + ), + ).toBe(true); + }); }); describe("classifyForwardHealthWithReachability", () => { From 9286e8179b3d15da62addcc97903cc1767155373 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 15:52:05 -0700 Subject: [PATCH 35/46] fix(security): reject custom remote dashboard builds --- src/lib/onboard/dockerfile-patch.ts | 14 ++- .../sandbox-dockerfile-patch-flow.test.ts | 4 + .../onboard/sandbox-dockerfile-patch-flow.ts | 1 + test/dashboard-remote-bind-lifecycle.test.ts | 89 ++++++++++++++++++- 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index ee02ac56c2..c100c63de3 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -90,6 +90,7 @@ export interface PatchStagedDockerfileOptions { buildIdPolicy?: DockerfileBuildIdPolicy; toolDisclosure?: ToolDisclosure; requireToolDisclosureContract?: boolean; + trustedManagedDockerfile?: boolean; baseImageResolutionMetadata?: SandboxBaseImageResolutionMetadata | null; dcodeAutoApprovalMode?: DcodeAutoApprovalMode; upstreamEndpointUrl?: string | null; @@ -222,10 +223,15 @@ export function patchStagedDockerfile( /^ARG CHAT_UI_URL=.*$/m, `ARG CHAT_UI_URL=${sanitizeDockerArg(chatUiUrl)}`, ); - const remoteDashboardBind = patchRemoteDashboardBindContract( - dockerfile, - process.env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0" ? "0.0.0.0" : "", - ); + const requestedDashboardBind = process.env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0" ? "0.0.0.0" : ""; + // Dockerfile text is only defense-in-depth for repository-managed inputs. + // Custom --from images need post-build/runtime attestation before remote exposure is safe. + if (requestedDashboardBind && options.trustedManagedDockerfile !== true) { + throw new Error( + "Remote dashboard bind is unavailable with custom --from Dockerfiles until post-build runtime configuration attestation is implemented.", + ); + } + const remoteDashboardBind = patchRemoteDashboardBindContract(dockerfile, requestedDashboardBind); dockerfile = remoteDashboardBind.dockerfile; const { dashboardRemoteBindPrepared } = remoteDashboardBind; dockerfile = dockerfile.replace( diff --git a/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts b/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts index 2611bdf034..237892f06d 100644 --- a/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts +++ b/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts @@ -98,6 +98,7 @@ describe("prepareSandboxDockerfilePatch", () => { expect(patchStagedDockerfile.mock.calls[0]?.[11]).toEqual({ buildIdPolicy: "preserve", toolDisclosure: "progressive", + trustedManagedDockerfile: true, requireToolDisclosureContract: false, baseImageResolutionMetadata: resolutionMetadata, }); @@ -165,6 +166,7 @@ describe("prepareSandboxDockerfilePatch", () => { { buildIdPolicy: "preserve", toolDisclosure: "progressive", + trustedManagedDockerfile: true, requireToolDisclosureContract: false, }, ); @@ -203,6 +205,7 @@ describe("prepareSandboxDockerfilePatch", () => { expect(patchStagedDockerfile.mock.calls[0]?.[11]).toEqual({ buildIdPolicy: "preserve", toolDisclosure: "progressive", + trustedManagedDockerfile: true, requireToolDisclosureContract: false, }); }); @@ -310,6 +313,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 1b4569b85f..ab104f21a3 100644 --- a/src/lib/onboard/sandbox-dockerfile-patch-flow.ts +++ b/src/lib/onboard/sandbox-dockerfile-patch-flow.ts @@ -194,6 +194,7 @@ export async function prepareSandboxDockerfilePatch({ return { buildIdPolicy, toolDisclosure, + ...(!fromDockerfile ? { trustedManagedDockerfile: true } : {}), ...(endpointUrl ? { upstreamEndpointUrl: endpointUrl } : {}), ...(dcodeAutoApprovalMode ? { dcodeAutoApprovalMode } : {}), requireToolDisclosureContract: Boolean(fromDockerfile), diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index 0260b9c710..d5df763bdd 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -9,9 +9,10 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { hasPreparedRemoteDashboardBind, - patchStagedDockerfile, + 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"; @@ -39,7 +40,93 @@ function remoteBindDockerfile(...postGeneratorInstructions: string[]): string { 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-")); From 701fe7dbaf5a9fac204898a7e67fcef2220d052b Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 16:16:30 -0700 Subject: [PATCH 36/46] fix(security): harden remote dashboard bind contracts Signed-off-by: Prekshi Vyas --- .../actions/ci-cli-coverage-shard/action.yaml | 11 ++----- scripts/generate-openclaw-config.mts | 26 +++++++++++++-- src/lib/actions/sandbox/forward-recovery.ts | 3 +- src/lib/onboard/dockerfile-patch.ts | 13 +++----- ...ile-remote-dashboard-bind-contract.test.ts | 33 +++++++++++++++++++ ...ckerfile-remote-dashboard-bind-contract.ts | 22 ++++++++++++- test/dashboard-remote-bind-lifecycle.test.ts | 31 ++++++++--------- ...ate-openclaw-config-security-audit.test.ts | 11 ++++++- ...w-security-audit-suppressions-real.test.ts | 19 +++++++++++ 9 files changed, 129 insertions(+), 40 deletions(-) create mode 100644 src/lib/onboard/dockerfile-remote-dashboard-bind-contract.test.ts diff --git a/.github/actions/ci-cli-coverage-shard/action.yaml b/.github/actions/ci-cli-coverage-shard/action.yaml index 5cbccaee49..a1d7d99df6 100644 --- a/.github/actions/ci-cli-coverage-shard/action.yaml +++ b/.github/actions/ci-cli-coverage-shard/action.yaml @@ -63,16 +63,9 @@ runs: npm install --ignore-scripts cd nemoclaw && npm install --ignore-scripts - - name: Install ripgrep for advisor tool tests + - name: Verify ripgrep for advisor tool tests shell: bash - run: | - set -euo pipefail - if ! command -v rg >/dev/null 2>&1; then - RIPGREP_VERSION="14.1.0-1" - sudo apt-get update - sudo apt-get install -y --no-install-recommends "ripgrep=${RIPGREP_VERSION}" - fi - rg --version + run: rg --version - name: Build TypeScript plugin shell: bash diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index fb28af3cca..f9c7f44898 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -16,7 +16,8 @@ // NEMOCLAW_TOOL_DISCLOSURE, // NEMOCLAW_AGENT_TIMEOUT, NEMOCLAW_AGENT_HEARTBEAT_EVERY, // NEMOCLAW_INFERENCE_COMPAT_B64, -// NEMOCLAW_DISABLE_DEVICE_AUTH, +// NEMOCLAW_DASHBOARD_BIND, 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 +49,16 @@ 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"]); + +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; +} // Local Ollama small-context compaction policy (NemoClaw #5468). // @@ -1190,10 +1201,19 @@ export function buildConfig(env: Env = process.env): JsonObject { const origins = unique([loopbackOrigin, chatOrigin, portlessOrigin].filter(Boolean) as string[]); const isRemote = !isLoopback(parsed.hostname || ""); - const remoteBindOptIn = env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0"; + const dashboardBind = readOptionalEnumEnv( + env, + "NEMOCLAW_DASHBOARD_BIND", + REMOTE_DASHBOARD_BIND_VALUES, + ); + const remoteBindOptIn = dashboardBind === "0.0.0.0"; const hasRemoteDashboardExposure = isRemote || remoteBindOptIn; const deviceAuthOptOut = env.NEMOCLAW_DISABLE_DEVICE_AUTH === "1"; - const deviceAuthOptOutSource = env.NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE; + 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"; diff --git a/src/lib/actions/sandbox/forward-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index 4b86e2abd0..3cb330ea9a 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -10,6 +10,7 @@ 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 * as registry from "../../state/registry"; import { parseForwardList } from "../../state/sandbox-session"; import { @@ -62,7 +63,7 @@ export function resolveSandboxDashboardPort( */ export function ensureSandboxPortForward(sandboxName: string): boolean { const port = resolveSandboxDashboardPort(sandboxName); - const remoteBindRequested = process.env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0"; + const remoteBindRequested = isRemoteDashboardBindRequested(process.env.NEMOCLAW_DASHBOARD_BIND); if ( remoteBindRequested && registry.getSandbox(sandboxName)?.dashboardRemoteBindPrepared !== true diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index c100c63de3..6871348c51 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -32,6 +32,7 @@ import { import { hasPreparedRemoteDashboardBind, patchRemoteDashboardBindContract, + resolveRequestedRemoteDashboardBind, } from "./dockerfile-remote-dashboard-bind-contract"; import { dockerfileInstructions, @@ -223,14 +224,10 @@ export function patchStagedDockerfile( /^ARG CHAT_UI_URL=.*$/m, `ARG CHAT_UI_URL=${sanitizeDockerArg(chatUiUrl)}`, ); - const requestedDashboardBind = process.env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0" ? "0.0.0.0" : ""; - // Dockerfile text is only defense-in-depth for repository-managed inputs. - // Custom --from images need post-build/runtime attestation before remote exposure is safe. - if (requestedDashboardBind && options.trustedManagedDockerfile !== true) { - throw new Error( - "Remote dashboard bind is unavailable with custom --from Dockerfiles until post-build runtime configuration attestation is implemented.", - ); - } + const requestedDashboardBind = resolveRequestedRemoteDashboardBind( + process.env.NEMOCLAW_DASHBOARD_BIND, + options.trustedManagedDockerfile === true, + ); const remoteDashboardBind = patchRemoteDashboardBindContract(dockerfile, requestedDashboardBind); dockerfile = remoteDashboardBind.dockerfile; const { dashboardRemoteBindPrepared } = remoteDashboardBind; 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 0000000000..5047c092e8 --- /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 index d56e9cad36..78565e794e 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -4,9 +4,9 @@ import { createHash } from "node:crypto"; import { + type DockerfileInstruction, dockerfileInstructions, readDockerfilePatchSnapshot, - type DockerfileInstruction, } from "./dockerfile-tool-disclosure-contract"; const REMOTE_BIND_ARG_RE = /^ARG\s+NEMOCLAW_DASHBOARD_BIND=/; @@ -69,6 +69,26 @@ export type PatchedRemoteDashboardBindContract = { dashboardRemoteBindPrepared: boolean; }; +export function isRemoteDashboardBindRequested(value: string | undefined): boolean { + return value === "0.0.0.0"; +} + +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"; +} + function finalStageInstructions(dockerfile: string): DockerfileInstruction[] { const instructions = dockerfileInstructions(dockerfile); const finalFromIndex = instructions.reduce( diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index d5df763bdd..61a52d3135 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -17,7 +17,7 @@ import { buildCreatedSandboxRegistryEntry } from "../src/lib/onboard/sandbox-reg import { applyReusedSandboxDashboardState } from "../src/lib/onboard/sandbox-reuse"; const requireSource = createRequire(import.meta.url); -const { ensureSandboxPortForward, ensureSandboxPortForwardForPort } = requireSource( +const { ensureSandboxPortForward } = requireSource( "../src/lib/actions/sandbox/forward-recovery.js", ) as typeof import("../src/lib/actions/sandbox/forward-recovery.js"); @@ -659,37 +659,34 @@ describe("remote dashboard bind production lifecycle", () => { ).toBe(false); }); - it("can force a loopback restart independently of remote-bind selection (#6024)", () => { + 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"); - let stopped = false; + 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.spyOn(forwardHealth, "isLocalForwardReachable").mockImplementation( - () => !stopped || started, - ); + 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: - !stopped || started - ? "SANDBOX BIND PORT PID STATUS\nbeta 127.0.0.1 18789 12345 running" - : "", + 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) : []; - stopped ||= args[0] === "forward" && args[1] === "stop"; started ||= args[0] === "forward" && args[1] === "start"; return { status: 0 } as never; }); - expect( - ensureSandboxPortForwardForPort("beta", 18789, { - forwardTarget: "18789", - forceRestart: true, - }), - ).toBe(true); + expect(ensureSandboxPortForward("beta")).toBe(true); expect(runOpenshell).toHaveBeenCalledWith( ["forward", "start", "--background", "18789", "beta"], { ignoreError: true }, diff --git a/test/generate-openclaw-config-security-audit.test.ts b/test/generate-openclaw-config-security-audit.test.ts index c6fb38380f..951c1f89ed 100644 --- a/test/generate-openclaw-config-security-audit.test.ts +++ b/test/generate-openclaw-config-security-audit.test.ts @@ -59,7 +59,7 @@ describe("generate-openclaw-config.mts: managed security audit findings", () => expect(config.gateway.controlUi.allowInsecureAuth).toBe(true); expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); expect(config.gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback).toBe(true); - expect(config.security).toBeUndefined(); + expect(config.security?.audit?.suppressions ?? []).toEqual([]); }); it("keeps explicit device auth findings active when the dashboard bind is remote (#6024)", () => { @@ -100,6 +100,15 @@ describe("generate-openclaw-config.mts: managed security audit findings", () => expect(config.security).toBeUndefined(); }); + it.each([ + ["NEMOCLAW_DASHBOARD_BIND", "127.0.0.1"], + ["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/openclaw-security-audit-suppressions-real.test.ts b/test/openclaw-security-audit-suppressions-real.test.ts index 9175fedd4e..14c0483df6 100644 --- a/test/openclaw-security-audit-suppressions-real.test.ts +++ b/test/openclaw-security-audit-suppressions-real.test.ts @@ -244,6 +244,25 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS !== "1")( 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, From 932d3bad5e7e03fda25021c4c2106e03603657f1 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 16:37:16 -0700 Subject: [PATCH 37/46] test(security): make remote bind evidence explicit Signed-off-by: Prekshi Vyas --- .../actions/ci-cli-coverage-shard/action.yaml | 4 --- docs/security/best-practices.mdx | 1 + src/lib/onboard/dockerfile-patch.ts | 16 +++------ ...ckerfile-remote-dashboard-bind-contract.ts | 11 +++++++ test/dashboard-remote-bind-lifecycle.test.ts | 19 +++++++++++ test/e2e/brev-e2e.test.ts | 4 +-- test/e2e/live/dashboard-remote-bind.test.ts | 33 +++++++++++++++++-- ...ate-openclaw-config-security-audit.test.ts | 2 ++ ...w-security-audit-suppressions-real.test.ts | 2 +- 9 files changed, 72 insertions(+), 20 deletions(-) diff --git a/.github/actions/ci-cli-coverage-shard/action.yaml b/.github/actions/ci-cli-coverage-shard/action.yaml index a1d7d99df6..d45128bbfa 100644 --- a/.github/actions/ci-cli-coverage-shard/action.yaml +++ b/.github/actions/ci-cli-coverage-shard/action.yaml @@ -63,10 +63,6 @@ runs: npm install --ignore-scripts cd nemoclaw && npm install --ignore-scripts - - name: Verify ripgrep for advisor tool tests - shell: bash - run: rg --version - - name: Build TypeScript plugin shell: bash run: cd nemoclaw && npm run build diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index febec6d0b2..d59a882db8 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -701,6 +701,7 @@ 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 diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index 6871348c51..aa707c4d49 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -29,11 +29,7 @@ import { type DcodeAutoApprovalMode, isDcodeAutoApprovalMode, } from "./dcode-auto-approval"; -import { - hasPreparedRemoteDashboardBind, - patchRemoteDashboardBindContract, - resolveRequestedRemoteDashboardBind, -} from "./dockerfile-remote-dashboard-bind-contract"; +import * as remoteDashboardBindContract from "./dockerfile-remote-dashboard-bind-contract"; import { dockerfileInstructions, readDockerfilePatchSnapshot, @@ -124,11 +120,9 @@ export function isValidProxyPort(value: string): boolean { return port >= 1 && port <= 65535; } -export type PatchedDockerfileMetadata = { - dashboardRemoteBindPrepared: boolean; -}; +export type PatchedDockerfileMetadata = { dashboardRemoteBindPrepared: boolean }; -export { hasPreparedRemoteDashboardBind }; +export { hasPreparedRemoteDashboardBind } from "./dockerfile-remote-dashboard-bind-contract"; export function patchStagedDockerfile( dockerfilePath: string, @@ -224,11 +218,11 @@ export function patchStagedDockerfile( /^ARG CHAT_UI_URL=.*$/m, `ARG CHAT_UI_URL=${sanitizeDockerArg(chatUiUrl)}`, ); - const requestedDashboardBind = resolveRequestedRemoteDashboardBind( + const remoteDashboardBind = remoteDashboardBindContract.patchRequestedRemoteDashboardBindContract( + dockerfile, process.env.NEMOCLAW_DASHBOARD_BIND, options.trustedManagedDockerfile === true, ); - const remoteDashboardBind = patchRemoteDashboardBindContract(dockerfile, requestedDashboardBind); dockerfile = remoteDashboardBind.dockerfile; const { dashboardRemoteBindPrepared } = remoteDashboardBind; dockerfile = dockerfile.replace( diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index 78565e794e..a51e76c3f5 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -89,6 +89,17 @@ export function resolveRequestedRemoteDashboardBind( 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( diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index 61a52d3135..4165374af6 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -627,6 +627,25 @@ describe("remote dashboard bind production lifecycle", () => { ); }); + 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("re-verifies remote-bind preparation immediately before opening the forward (#6024)", () => { const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.js"); diff --git a/test/e2e/brev-e2e.test.ts b/test/e2e/brev-e2e.test.ts index 1939c6fbc8..3804572f94 100644 --- a/test/e2e/brev-e2e.test.ts +++ b/test/e2e/brev-e2e.test.ts @@ -1309,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( [ @@ -1322,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 9410e6483e..22f40ad117 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 @@ -65,7 +66,7 @@ function connectStartedDashboardForward( } 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"; @@ -133,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/generate-openclaw-config-security-audit.test.ts b/test/generate-openclaw-config-security-audit.test.ts index 951c1f89ed..41fef251da 100644 --- a/test/generate-openclaw-config-security-audit.test.ts +++ b/test/generate-openclaw-config-security-audit.test.ts @@ -20,6 +20,7 @@ function buildSecurityAuditConfig(chatUiUrl: string, overrides: Record { 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", @@ -49,6 +50,7 @@ describe("generate-openclaw-config.mts: managed security audit findings", () => }); expect(config.gateway.controlUi.allowInsecureAuth).toBe(true); expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + expect(config.gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback).toBeUndefined(); expect(config.security).toBeUndefined(); }); diff --git a/test/openclaw-security-audit-suppressions-real.test.ts b/test/openclaw-security-audit-suppressions-real.test.ts index 14c0483df6..a7c56295ed 100644 --- a/test/openclaw-security-audit-suppressions-real.test.ts +++ b/test/openclaw-security-audit-suppressions-real.test.ts @@ -163,7 +163,7 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS !== "1")( "OpenClaw managed security audit consumer contract", () => { it( - "suppresses only exact managed findings while preserving active risks (#6024)", + "pins exact OpenClaw checkIds while suppressing only managed findings (#6024)", () => { const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-audit-suite-")); try { From f3d033410c75ed52ac135b831c6d55a0dbbfd800 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 17:15:11 -0700 Subject: [PATCH 38/46] refactor(onboard): keep dashboard contract isolated Signed-off-by: Prekshi Vyas --- src/lib/onboard/dockerfile-patch.ts | 14 ++------------ .../dockerfile-remote-dashboard-bind-contract.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index aa707c4d49..55633fa927 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -353,18 +353,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). Keep - // this managed compatibility choice distinct from an operator's explicit - // build-arg opt-out for security-audit reporting. - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_DISABLE_DEVICE_AUTH=.*$/m, - `ARG NEMOCLAW_DISABLE_DEVICE_AUTH=${sanitizeDockerArg("1")}`, - ); - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE=.*$/m, - `ARG NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE=${sanitizeDockerArg("managed-onboard")}`, - ); + // 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( diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index a51e76c3f5..d5c691e605 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -73,6 +73,15 @@ export function isRemoteDashboardBindRequested(value: string | undefined): boole 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, From 2e59b3dff1297a7f0e46439f3537343fb9cac8d9 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 17:24:24 -0700 Subject: [PATCH 39/46] test(onboard): cover remote bind restart edges Signed-off-by: Prekshi Vyas --- .../rebuild-custom-image-preflight.test.ts | 3 ++- test/dashboard-remote-bind-lifecycle.test.ts | 25 ++++++++----------- test/process-recovery-primitives.test.ts | 14 ++++++----- 3 files changed, 21 insertions(+), 21 deletions(-) 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 5424cfe803..4887ce6e8a 100644 --- a/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts +++ b/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts @@ -255,7 +255,7 @@ describe("preflightRebuildImage", () => { await preflightRebuildImage(input(dockerfile), { prepareDockerfilePatch: vi.fn(async () => ({ buildId: "1", - dashboardRemoteBindPrepared: false, + dashboardRemoteBindPrepared: true, resolvedBaseImage: null, })), buildImage: vi.fn(() => ({ status: 0 }) as never), @@ -268,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/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index 4165374af6..e6229083c9 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -566,11 +566,10 @@ describe("remote dashboard bind production lifecycle", () => { expect(ensureDashboardForward).not.toHaveBeenCalled(); }); - it("uses an all-interfaces target only for a sandbox prepared during onboarding (#6024)", () => { + 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"); - let started = false; vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "0"); vi.spyOn(registry, "getSandbox").mockReturnValue({ @@ -578,22 +577,20 @@ describe("remote dashboard bind production lifecycle", () => { dashboardPort: 18789, dashboardRemoteBindPrepared: true, }); - vi.spyOn(forwardHealth, "isLocalForwardReachable").mockImplementation(() => started); - vi.spyOn(openshellRuntime, "captureOpenshell").mockImplementation(() => ({ + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockReturnValue(true); + vi.spyOn(openshellRuntime, "captureOpenshell").mockReturnValue({ status: 0, - output: started - ? "SANDBOX BIND PORT PID STATUS\nbeta 0.0.0.0 18789 12345 running" - : "", - })); + output: "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; - }); + .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 }, @@ -646,7 +643,7 @@ describe("remote dashboard bind production lifecycle", () => { expect(runOpenshell).not.toHaveBeenCalled(); }); - it("re-verifies remote-bind preparation immediately before opening the forward (#6024)", () => { + 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"); diff --git a/test/process-recovery-primitives.test.ts b/test/process-recovery-primitives.test.ts index f4098a4eeb..700239b4ac 100644 --- a/test/process-recovery-primitives.test.ts +++ b/test/process-recovery-primitives.test.ts @@ -187,13 +187,15 @@ describe("classifySandboxForwardHealth", () => { ), ).toBe(false); expect( - classifySandboxForwardHealth( - [{ sandboxName: "beta", bind: "::", port: "18790", status: "running" }], - "beta", - "18790", - "0.0.0.0", + ["::", "[::]", "*"].map((bind) => + classifySandboxForwardHealth( + [{ sandboxName: "beta", bind, port: "18790", status: "running" }], + "beta", + "18790", + "0.0.0.0", + ), ), - ).toBe(true); + ).toEqual([true, true, true]); }); }); From d0c186643e07f2c43414834049e307a36cac38d3 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 11 Jul 2026 09:38:27 -0700 Subject: [PATCH 40/46] fix(onboard): refresh dashboard bind trust digest Signed-off-by: Carlos Villela --- src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index d5c691e605..1c99e899a4 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -34,7 +34,7 @@ const EXACT_CUSTOM_POST_GENERATOR_RUN_RE = [ // A lifecycle test verifies these digests against the checked-in Dockerfile. const CANONICAL_POST_GENERATOR_RUN_SHA256 = new Set([ "e7256f12c618bb424f53fec801378d92446d880c5935965ebb3b548694866b63", - "96e127a525e71bfc6d61e1da86b83d53a3ae61ca5d7923aed6d26afac10326cd", + "121d7732831a75b20dd31a58c65a5fdf3b6ff56ed24d61802ee4b0cca806d4e1", "737edaaa69f80cf10d42fd349e0be068c1ef6e7375d5dcb4055b012420b58736", "5b814e92449a6778385f588877fe72ebed80e601f8eb0c90c2842b17a489f3da", "0e1a9a7bab2fab0a974577c3af8785157b4b9be2b4db32d5f4f9e5aa3c8c8171", From 7c7f47c1597c453103025c932b3120244ba441fa Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 11 Jul 2026 09:53:16 -0700 Subject: [PATCH 41/46] fix(connect): restore loopback dashboard forwarding Signed-off-by: Carlos Villela --- src/lib/actions/sandbox/forward-recovery.ts | 2 +- test/dashboard-remote-bind-lifecycle.test.ts | 38 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/lib/actions/sandbox/forward-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index 3cb330ea9a..3d3e851c2c 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -76,7 +76,7 @@ export function ensureSandboxPortForward(sandboxName: string): boolean { return ensureSandboxPortForwardForPort(sandboxName, port, { forwardTarget: remoteBindRequested ? `0.0.0.0:${port}` : String(port), forceRestart: remoteBindRequested, - expectedBind: remoteBindRequested ? "0.0.0.0" : undefined, + expectedBind: remoteBindRequested ? "0.0.0.0" : "127.0.0.1", beforeStart: remoteBindRequested ? () => registry.getSandbox(sandboxName)?.dashboardRemoteBindPrepared === true : undefined, diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index e6229083c9..041ca3bd80 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -675,6 +675,44 @@ describe("remote dashboard bind production lifecycle", () => { ).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.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("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"); From d1aef53ec7b2bd417543ef3372cab5f9be6b4e76 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 11 Jul 2026 10:11:00 -0700 Subject: [PATCH 42/46] fix(connect): preserve WSL dashboard forwarding --- docs/get-started/quickstart.mdx | 3 +- docs/manage-sandboxes/lifecycle.mdx | 2 + docs/reference/commands.mdx | 7 ++-- src/lib/actions/sandbox/forward-recovery.ts | 6 ++- test/dashboard-remote-bind-lifecycle.test.ts | 40 ++++++++++++++++++++ 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 5fd7b60758..fc8e608f23 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 diff --git a/docs/manage-sandboxes/lifecycle.mdx b/docs/manage-sandboxes/lifecycle.mdx index 294ee17699..e933ecc1d2 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/reference/commands.mdx b/docs/reference/commands.mdx index 21aa3b9d74..8cd18a2342 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3111,7 +3111,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. | @@ -3129,9 +3129,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). +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; other values are ignored. +Only `0.0.0.0` enables the remote bind; onboarding rejects any other non-empty value. diff --git a/src/lib/actions/sandbox/forward-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index 3d3e851c2c..ebdd423354 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -11,6 +11,7 @@ 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 { @@ -64,6 +65,7 @@ export function resolveSandboxDashboardPort( export function ensureSandboxPortForward(sandboxName: string): boolean { const port = resolveSandboxDashboardPort(sandboxName); const remoteBindRequested = isRemoteDashboardBindRequested(process.env.NEMOCLAW_DASHBOARD_BIND); + const allInterfaceBindRequired = remoteBindRequested || isWsl(); if ( remoteBindRequested && registry.getSandbox(sandboxName)?.dashboardRemoteBindPrepared !== true @@ -74,9 +76,9 @@ export function ensureSandboxPortForward(sandboxName: string): boolean { return false; } return ensureSandboxPortForwardForPort(sandboxName, port, { - forwardTarget: remoteBindRequested ? `0.0.0.0:${port}` : String(port), + forwardTarget: allInterfaceBindRequired ? `0.0.0.0:${port}` : String(port), forceRestart: remoteBindRequested, - expectedBind: remoteBindRequested ? "0.0.0.0" : "127.0.0.1", + expectedBind: allInterfaceBindRequired ? "0.0.0.0" : "127.0.0.1", beforeStart: remoteBindRequested ? () => registry.getSandbox(sandboxName)?.dashboardRemoteBindPrepared === true : undefined, diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index 041ca3bd80..b78348b418 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -682,6 +682,9 @@ describe("remote dashboard bind production lifecycle", () => { 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, @@ -713,6 +716,40 @@ describe("remote dashboard bind production lifecycle", () => { ); }); + 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"); @@ -720,6 +757,9 @@ describe("remote dashboard bind production lifecycle", () => { 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, From 59e89def493b3b957b5c2e4f69ffe75ad07ef289 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 11 Jul 2026 11:31:21 -0700 Subject: [PATCH 43/46] fix(security): preserve WSL dashboard audit findings Signed-off-by: Carlos Villela --- Dockerfile | 4 ++ docs/security/best-practices.mdx | 5 ++- scripts/generate-openclaw-config.mts | 11 +++++- src/lib/onboard/dockerfile-patch.test.ts | 23 ++++++++++++ src/lib/onboard/dockerfile-patch.ts | 13 +++++++ .../sandbox-dockerfile-patch-flow.test.ts | 37 +++++++++++++++++++ .../onboard/sandbox-dockerfile-patch-flow.ts | 11 ++++++ ...ate-openclaw-config-security-audit.test.ts | 13 +++++++ 8 files changed, 114 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index ad09d7167c..cdde20c53b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -839,6 +839,9 @@ 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 @@ -937,6 +940,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ 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} \ diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 1eadbd05ea..703d6816d0 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -685,7 +685,10 @@ The `allowInsecureAuth` setting controls whether the gateway permits non-HTTPS a 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 either a non-loopback `CHAT_UI_URL` or an onboard-time `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` as remote dashboard exposure. Use the same bind setting on later `connect` calls. If the sandbox was created without that 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. +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. diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index f9c7f44898..ee3a819886 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -16,7 +16,8 @@ // NEMOCLAW_TOOL_DISCLOSURE, // NEMOCLAW_AGENT_TIMEOUT, NEMOCLAW_AGENT_HEARTBEAT_EVERY, // NEMOCLAW_INFERENCE_COMPAT_B64, -// NEMOCLAW_DASHBOARD_BIND, NEMOCLAW_DISABLE_DEVICE_AUTH, +// 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, @@ -51,6 +52,7 @@ 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] ?? ""; @@ -60,6 +62,10 @@ function readOptionalEnumEnv(env: Env, name: string, allowedValues: ReadonlySet< 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). // // OpenClaw 2026.5.x auto-compaction reserves `reserveTokensFloor` tokens at the @@ -1207,7 +1213,8 @@ export function buildConfig(env: Env = process.env): JsonObject { REMOTE_DASHBOARD_BIND_VALUES, ); const remoteBindOptIn = dashboardBind === "0.0.0.0"; - const hasRemoteDashboardExposure = isRemote || remoteBindOptIn; + 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, diff --git a/src/lib/onboard/dockerfile-patch.test.ts b/src/lib/onboard/dockerfile-patch.test.ts index 2c3d5fa1ba..3bde806b00 100644 --- a/src/lib/onboard/dockerfile-patch.test.ts +++ b/src/lib/onboard/dockerfile-patch.test.ts @@ -102,6 +102,29 @@ 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("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( diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index 55633fa927..32003a8f07 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -91,6 +91,7 @@ export interface PatchStagedDockerfileOptions { baseImageResolutionMetadata?: SandboxBaseImageResolutionMetadata | null; dcodeAutoApprovalMode?: DcodeAutoApprovalMode; upstreamEndpointUrl?: string | null; + wslDashboardExposure?: boolean; } export function patchDcodeAutoApprovalDockerArg( @@ -218,6 +219,18 @@ 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; + if (!wslDashboardExposureArg.test(dockerfile)) { + throw new Error( + "Dockerfile is missing ARG NEMOCLAW_WSL_DASHBOARD_EXPOSURE; cannot record WSL dashboard exposure.", + ); + } + dockerfile = dockerfile.replace( + wslDashboardExposureArg, + `ARG NEMOCLAW_WSL_DASHBOARD_EXPOSURE=${options.wslDashboardExposure ? "1" : "0"}`, + ); + } const remoteDashboardBind = remoteDashboardBindContract.patchRequestedRemoteDashboardBindContract( dockerfile, process.env.NEMOCLAW_DASHBOARD_BIND, diff --git a/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts b/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts index 237892f06d..549e22cb58 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, @@ -99,6 +100,7 @@ describe("prepareSandboxDockerfilePatch", () => { buildIdPolicy: "preserve", toolDisclosure: "progressive", trustedManagedDockerfile: true, + wslDashboardExposure: false, requireToolDisclosureContract: false, baseImageResolutionMetadata: resolutionMetadata, }); @@ -124,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", @@ -167,11 +170,43 @@ 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(); @@ -191,6 +226,7 @@ describe("prepareSandboxDockerfilePatch", () => { sandboxGpuConfig, deps: { isLinuxDockerDriverGatewayEnabled: vi.fn(() => false), + isWsl: vi.fn(() => false), pullAndResolveBaseImageDigest, dockerImageInspect, enforceDockerGpuPatchPreserveNetwork: vi.fn(async () => false), @@ -228,6 +264,7 @@ describe("prepareSandboxDockerfilePatch", () => { sandboxGpuConfig, deps: { isLinuxDockerDriverGatewayEnabled: vi.fn(() => false), + isWsl: vi.fn(() => false), enforceDockerGpuPatchPreserveNetwork: vi.fn(async () => false), patchStagedDockerfile, now: () => 1, diff --git a/src/lib/onboard/sandbox-dockerfile-patch-flow.ts b/src/lib/onboard/sandbox-dockerfile-patch-flow.ts index ab104f21a3..30589b67a2 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; }; @@ -80,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 { @@ -173,6 +179,8 @@ 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" @@ -195,6 +203,9 @@ export async function prepareSandboxDockerfilePatch({ buildIdPolicy, toolDisclosure, ...(!fromDockerfile ? { trustedManagedDockerfile: true } : {}), + ...(!fromDockerfile && managedAgentName === "openclaw" + ? { wslDashboardExposure: managedOpenClawWslExposure } + : {}), ...(endpointUrl ? { upstreamEndpointUrl: endpointUrl } : {}), ...(dcodeAutoApprovalMode ? { dcodeAutoApprovalMode } : {}), requireToolDisclosureContract: Boolean(fromDockerfile), diff --git a/test/generate-openclaw-config-security-audit.test.ts b/test/generate-openclaw-config-security-audit.test.ts index 41fef251da..a6c2ab4c1f 100644 --- a/test/generate-openclaw-config-security-audit.test.ts +++ b/test/generate-openclaw-config-security-audit.test.ts @@ -64,6 +64,18 @@ describe("generate-openclaw-config.mts: managed security audit findings", () => 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", @@ -104,6 +116,7 @@ describe("generate-openclaw-config.mts: managed security audit findings", () => 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( From 94b712830d0d441154cd047c95e5ac2fcdec4323 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 11 Jul 2026 11:40:30 -0700 Subject: [PATCH 44/46] fix(onboard): keep legacy Dockerfiles compatible Signed-off-by: Carlos Villela --- src/lib/onboard/dockerfile-patch.test.ts | 42 ++++++++++++++++++++++++ src/lib/onboard/dockerfile-patch.ts | 13 +++++--- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/lib/onboard/dockerfile-patch.test.ts b/src/lib/onboard/dockerfile-patch.test.ts index 3bde806b00..6a71d0aa1c 100644 --- a/src/lib/onboard/dockerfile-patch.test.ts +++ b/src/lib/onboard/dockerfile-patch.test.ts @@ -125,6 +125,48 @@ describe("dockerfile patch helpers", () => { ); }); + 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( diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index 32003a8f07..d6a37b801f 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -221,15 +221,18 @@ export function patchStagedDockerfile( ); if (options.wslDashboardExposure !== undefined) { const wslDashboardExposureArg = /^ARG NEMOCLAW_WSL_DASHBOARD_EXPOSURE=.*$/m; - if (!wslDashboardExposureArg.test(dockerfile)) { + 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.", ); } - dockerfile = dockerfile.replace( - wslDashboardExposureArg, - `ARG NEMOCLAW_WSL_DASHBOARD_EXPOSURE=${options.wslDashboardExposure ? "1" : "0"}`, - ); + if (hasWslDashboardExposureArg) { + dockerfile = dockerfile.replace( + wslDashboardExposureArg, + `ARG NEMOCLAW_WSL_DASHBOARD_EXPOSURE=${options.wslDashboardExposure ? "1" : "0"}`, + ); + } } const remoteDashboardBind = remoteDashboardBindContract.patchRequestedRemoteDashboardBindContract( dockerfile, From baba0bd1235dda8720de7218b1eeff48b9113ca6 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 11 Jul 2026 16:57:07 -0700 Subject: [PATCH 45/46] fix(connect): honor requested dashboard bind --- src/lib/actions/sandbox/forward-recovery.ts | 8 +++- test/process-recovery.test.ts | 51 +++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/lib/actions/sandbox/forward-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index ebdd423354..6d296ab78d 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -100,7 +100,13 @@ 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( diff --git a/test/process-recovery.test.ts b/test/process-recovery.test.ts index ab563c92d2..ee403496ce 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) : []; + if (args[0] === "forward" && args[1] === "start") forwardStarted = true; + 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"); From 95b9d348d609807bf74c65c08d09ac63033eab83 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 11 Jul 2026 16:59:24 -0700 Subject: [PATCH 46/46] test(connect): keep remote recovery setup linear --- test/process-recovery.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/process-recovery.test.ts b/test/process-recovery.test.ts index ee403496ce..4c18045f1d 100644 --- a/test/process-recovery.test.ts +++ b/test/process-recovery.test.ts @@ -211,7 +211,7 @@ beta 127.0.0.1 18789 12345 running`; .spyOn(openshellRuntime, "runOpenshell") .mockImplementation((rawArgs: unknown) => { const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; - if (args[0] === "forward" && args[1] === "start") forwardStarted = true; + forwardStarted ||= args[0] === "forward" && args[1] === "start"; return { status: 0 } as never; });