Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/lib/actions/sandbox/connect-flow-dcode-probe-preamble.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
7 changes: 3 additions & 4 deletions src/lib/actions/sandbox/connect-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 }),
Expand Down
97 changes: 90 additions & 7 deletions src/lib/actions/sandbox/connect-inference-route-probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
});

Expand All @@ -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'");
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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,
Expand Down
53 changes: 20 additions & 33 deletions src/lib/actions/sandbox/connect-inference-route-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,37 +27,25 @@ 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 = [
INFERENCE_ROUTE_CA_FROM_ENV,
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.
Expand All @@ -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];

Expand All @@ -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;
Expand Down
Loading