diff --git a/src/lib/actions/sandbox/connect-flow-dcode-probe-preamble.test.ts b/src/lib/actions/sandbox/connect-flow-dcode-probe-preamble.test.ts new file mode 100644 index 00000000000..2bd1a2d963c --- /dev/null +++ b/src/lib/actions/sandbox/connect-flow-dcode-probe-preamble.test.ts @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; + +import { + connectModulePath, + createConnectHarness, + requireDist, +} from "../../../../test/support/connect-flow-test-harness"; + +describe("connectSandbox DCode probe preamble boundary", () => { + let exitSpy: MockInstance; + const originalStdoutIsTty = process.stdout.isTTY; + + beforeEach(() => { + process.env.NEMOCLAW_TEST_NO_SLEEP = "1"; + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number | string | null) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: originalStdoutIsTty, + }); + delete process.env.NEMOCLAW_TEST_NO_SLEEP; + delete require.cache[requireDist.resolve(connectModulePath)]; + }); + + it.each([ + "OK 200\nBROKEN 000", + "BROKEN 503\nOK 200", + ])("rejects login-shell preamble evidence without repair or SSH (%s) (#6192)", async (output) => { + const harness = createConnectHarness({ + agentName: "langchain-deepagents-code", + registryEntry: { + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + }, + inferenceGetOutput: + "Gateway inference:\n Provider: nvidia-prod\n Model: nvidia/nemotron-3-super-120b-a12b\n", + inferenceProbeResponses: [output], + sessionAgent: { name: "langchain-deepagents-code" }, + }); + + await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)"); + + expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); + expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); + expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); + expect(harness.spawnSyncSpy).not.toHaveBeenCalled(); + expect(harness.errorSpy.mock.calls.flat().join("\n")).toContain( + "did not return a trusted result", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/lib/actions/sandbox/connect-flow.test.ts b/src/lib/actions/sandbox/connect-flow.test.ts index 63613749200..71f2200d2d8 100644 --- a/src/lib/actions/sandbox/connect-flow.test.ts +++ b/src/lib/actions/sandbox/connect-flow.test.ts @@ -237,7 +237,7 @@ describe("connectSandbox flow", () => { expect(exitSpy).toHaveBeenCalledWith(0); }); - it("runs the dcode inference route probe through its login-shell proxy contract (#6191)", async () => { + it("runs the DCode route probe through its managed runtime boundary (#6191)", async () => { const harness = createConnectHarness({ agentName: "langchain-deepagents-code", sessionAgent: { @@ -280,10 +280,9 @@ describe("connectSandbox flow", () => { "--name", "alpha", "--", - "sh", + "/usr/local/bin/nemoclaw-start", + "/bin/sh", "-c", - expect.stringContaining('bash -lc "$1" "$CA_BUNDLE"'), - "nemoclaw-ca-capture", expect.stringContaining("/usr/bin/curl"), ], expect.objectContaining({ ignoreError: true }), diff --git a/src/lib/actions/sandbox/connect-inference-route-probe.test.ts b/src/lib/actions/sandbox/connect-inference-route-probe.test.ts index 0db847992f1..69fe86e861f 100644 --- a/src/lib/actions/sandbox/connect-inference-route-probe.test.ts +++ b/src/lib/actions/sandbox/connect-inference-route-probe.test.ts @@ -2,6 +2,9 @@ // 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 { @@ -12,17 +15,25 @@ import { } from "./connect-inference-route-probe"; describe("sandbox connect inference route probe argv", () => { - it("uses the dcode login-shell proxy contract without inherited proxy variables (#6191)", () => { + it("uses the managed DCode proxy boundary without a login shell (#6191)", () => { const args = buildSandboxInferenceRouteProbeArgs("deep-code", { name: "langchain-deepagents-code", }); - expect(args.slice(0, 7)).toEqual(["sandbox", "exec", "--name", "deep-code", "--", "sh", "-c"]); - expect(args.at(-3)).toContain('bash -lc "$1" "$CA_BUNDLE"'); - expect(args.at(-3)).toContain("-u HTTPS_PROXY"); - expect(args.at(-2)).toBe("nemoclaw-ca-capture"); - expect(args.at(-1)).toContain('CA_BUNDLE="$0"'); + expect(args.slice(0, 8)).toEqual([ + "sandbox", + "exec", + "--name", + "deep-code", + "--", + "/usr/local/bin/nemoclaw-start", + "/bin/sh", + "-c", + ]); expect(args.at(-1)).toContain("https://inference.local/v1/models"); + expect(args).not.toContain("bash"); + expect(args.join(" ")).not.toContain("3>&1"); + expect(args.join(" ")).not.toContain("/tmp/nemoclaw-proxy-env.sh"); expect(args.every((arg) => !/[\r\n]/.test(arg))).toBe(true); }); @@ -47,7 +58,7 @@ describe("sandbox connect inference route probe argv", () => { const args = buildSandboxInferenceRouteProbeArgs("alpha", { name: "openclaw" }); const script = args.at(-1) ?? ""; - expect(script).toContain("/usr/bin/curl -s -o /dev/null"); + expect(script).toContain("/usr/bin/curl -q -s -o /dev/null"); expect(script).toContain('CA_BUNDLE="${CURL_CA_BUNDLE:-${SSL_CERT_FILE:-}}"'); expect(script).toContain('--cacert "$CA_BUNDLE"'); expect(script).toContain("printf 'UNAVAILABLE OpenShell CA bundle missing or unreadable'"); @@ -74,6 +85,65 @@ describe("sandbox connect inference route probe argv", () => { parseSandboxInferenceRouteProbeResult({ status: result.status, output: result.stdout }), ).toMatchObject({ healthy: false, broken: false, httpStatus: 0 }); }); + + it.each([ + "OK 200", + "BROKEN 503", + ])("does not run hostile DCode startup or curl config for a %s spoof (#6192)", (spoof) => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-probe-")); + const profileMarker = path.join(home, "profile-ran"); + try { + const caBundle = path.join(home, "openshell-ca.pem"); + const profile = path.join(home, ".bash_profile"); + const launcher = path.join(home, "nemoclaw-start"); + const curlConfigMarker = path.join(home, "curl-config-ran"); + fs.writeFileSync(caBundle, "test CA boundary", "utf8"); + fs.writeFileSync( + profile, + `printf '%s' ${JSON.stringify(spoof)} >&3; printf ran > ${JSON.stringify(profileMarker)}; exit 0`, + ); + fs.writeFileSync( + path.join(home, ".curlrc"), + `trace-ascii = ${JSON.stringify(curlConfigMarker)}\n`, + ); + fs.writeFileSync(launcher, '#!/bin/bash -p\nset -eu\nunset BASH_ENV ENV\nexec "$@"\n', { + mode: 0o755, + }); + const args = buildSandboxInferenceRouteProbeArgs("deep-code", { + name: "langchain-deepagents-code", + }); + const command = args.slice(5); + command[0] = launcher; + + const result = spawnSync(command[0], command.slice(1), { + encoding: "utf8", + env: { + ...process.env, + ALL_PROXY: "", + BASH_ENV: profile, + CURL_CA_BUNDLE: caBundle, + ENV: profile, + HOME: home, + HTTP_PROXY: "http://127.0.0.1:9", + HTTPS_PROXY: "http://127.0.0.1:9", + NO_PROXY: "", + SSL_CERT_FILE: "", + all_proxy: "", + http_proxy: "http://127.0.0.1:9", + https_proxy: "http://127.0.0.1:9", + no_proxy: "", + }, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("BROKEN 000"); + expect(result.stdout).not.toContain(spoof); + expect(fs.existsSync(profileMarker)).toBe(false); + expect(fs.existsSync(curlConfigMarker)).toBe(false); + } finally { + fs.rmSync(home, { force: true, recursive: true }); + } + }); }); describe("sandbox inference route probe result", () => { @@ -125,6 +195,19 @@ describe("sandbox inference route probe result", () => { ).toMatchObject({ healthy: false, broken: false, httpStatus: 0 }); }); + it.each([ + "OK 200\nBROKEN 000", + "BROKEN 503\nOK 200", + "[stdout] OK 200\nBROKEN 000", + "[stdout] BROKEN 503\nOK 200", + ])("does not trust login-shell preamble output (%s) (#6192)", (output) => { + expect(parseSandboxInferenceRouteProbeResult({ status: 0, output })).toMatchObject({ + healthy: false, + broken: false, + httpStatus: 0, + }); + }); + it("fails closed when malformed output claims an unhealthy status is OK (#6192)", () => { expect(parseSandboxInferenceRouteProbeResult({ status: 0, output: "OK 503" })).toMatchObject({ healthy: false, diff --git a/src/lib/actions/sandbox/connect-inference-route-probe.ts b/src/lib/actions/sandbox/connect-inference-route-probe.ts index 6ac5d477225..2dd7417b5b0 100644 --- a/src/lib/actions/sandbox/connect-inference-route-probe.ts +++ b/src/lib/actions/sandbox/connect-inference-route-probe.ts @@ -27,7 +27,7 @@ const INFERENCE_ROUTE_CA_FROM_ENV = 'CA_BUNDLE="${CURL_CA_BUNDLE:-${SSL_CERT_FIL const INFERENCE_ROUTE_CA_VALIDATION = '[ -n "$CA_BUNDLE" ] && [ -f "$CA_BUNDLE" ] && [ -r "$CA_BUNDLE" ] || { printf \'UNAVAILABLE OpenShell CA bundle missing or unreadable\'; exit 1; }'; const INFERENCE_ROUTE_PROBE_CORE_SCRIPT = [ - "HTTP_CODE=$(/usr/bin/curl -s -o /dev/null -w '%{http_code}' --cacert \"$CA_BUNDLE\" --connect-timeout 3 --max-time 8 https://inference.local/v1/models 2>/dev/null) || HTTP_CODE=000", + "HTTP_CODE=$(/usr/bin/curl -q -s -o /dev/null -w '%{http_code}' --cacert \"$CA_BUNDLE\" --connect-timeout 3 --max-time 8 https://inference.local/v1/models 2>/dev/null) || HTTP_CODE=000", 'case "$HTTP_CODE" in [2-4][0-9][0-9]) printf \'OK %s\' "$HTTP_CODE" ;; *) printf \'BROKEN %s\' "$HTTP_CODE" ;; esac', ].join("; "); export const INFERENCE_ROUTE_PROBE_SCRIPT = [ @@ -35,29 +35,17 @@ export const INFERENCE_ROUTE_PROBE_SCRIPT = [ INFERENCE_ROUTE_CA_VALIDATION, INFERENCE_ROUTE_PROBE_CORE_SCRIPT, ].join("; "); -const INFERENCE_ROUTE_PROBE_FROM_ARG0_SCRIPT = [ - 'CA_BUNDLE="$0"', - INFERENCE_ROUTE_PROBE_CORE_SCRIPT, -].join("; "); - -const PROXY_ENV_KEYS = [ - "HTTP_PROXY", - "HTTPS_PROXY", - "http_proxy", - "https_proxy", - "NO_PROXY", - "no_proxy", - "ALL_PROXY", - "all_proxy", -] as const; - -const DCODE_INFERENCE_ROUTE_PROBE_WRAPPER = [ - INFERENCE_ROUTE_CA_FROM_ENV, - INFERENCE_ROUTE_CA_VALIDATION, - // bash -lc receives CA_BUNDLE as argv[0], so the inner script reads the - // exact OpenShell-injected CA path from $0 after the login shell loads. - `exec env ${PROXY_ENV_KEYS.map((key) => `-u ${key}`).join(" ")} HOME=/sandbox bash -lc "$1" "$CA_BUNDLE"`, -].join("; "); +// Invalid state: a DCode login shell runs sandbox-user startup files before the +// probe, so every inherited output descriptor is attacker-writable evidence. +// Source boundary: the image-baked launcher reconstructs the managed proxy from +// root-owned, mode-0444 files and execs a command without loading user profiles. +// Source-fix constraint: raw OpenShell exec does not inherit the entrypoint's +// trusted proxy contract, while a login shell cannot provide an output trust +// boundary. Regression: hostile-profile tests assert that no startup file or +// inherited descriptor can emit probe evidence. Removal condition: use a raw +// probe only when OpenShell provides the same trusted proxy environment to every +// sandbox exec process without shell startup. +const DCODE_MANAGED_RUNTIME_LAUNCHER = "/usr/local/bin/nemoclaw-start"; /** * Classify a route result that is already known not to be healthy. @@ -75,15 +63,12 @@ export function buildSandboxInferenceRouteProbeArgs( const command = agent?.name === "langchain-deepagents-code" ? [ - // Capture OpenShell's trusted CA before the login shell sources the - // DCode runtime environment. The login shell still reconstructs the - // proxy contract from /tmp/nemoclaw-proxy-env.sh after inherited - // proxy variables are cleared. - "sh", + // The trusted launcher ignores ambient proxy overrides and does not + // source sandbox-user startup files before executing this probe. + DCODE_MANAGED_RUNTIME_LAUNCHER, + "/bin/sh", "-c", - DCODE_INFERENCE_ROUTE_PROBE_WRAPPER, - "nemoclaw-ca-capture", - INFERENCE_ROUTE_PROBE_FROM_ARG0_SCRIPT, + INFERENCE_ROUTE_PROBE_SCRIPT, ] : ["sh", "-c", INFERENCE_ROUTE_PROBE_SCRIPT]; @@ -98,7 +83,9 @@ export function parseSandboxInferenceRouteProbeResult( // Some OpenShell releases frame child stdout for humans. Normalize only the // two known frame prefixes at the beginning of the captured output. const detail = rawDetail.replace(/^(?:\[stdout\]|stdout:)\s*/i, ""); - const match = /^(OK|BROKEN)\s+([0-9]{3})\b/.exec(detail); + // A trusted probe emits one result line. Reject preambles or extra lines so + // shell startup output can never be mistaken for the authoritative result. + const match = /^(OK|BROKEN)\s+([0-9]{3})\b[^\r\n]*$/.exec(detail); const httpStatus = match ? Number.parseInt(match[2], 10) : 0; const isReachableHttpStatus = httpStatus >= 200 && httpStatus < 500; const commandSucceeded = result.status === 0;