diff --git a/Dockerfile.base b/Dockerfile.base index 224879013d..b96754c334 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -68,6 +68,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates=20250419 \ iproute2=6.15.0-1 \ iptables=1.8.11-2 \ + nftables=1.1.3-1 \ libcap2-bin=1:2.75-10+deb13u1+b1 \ procps=2:4.0.4-9 \ e2fsprogs=1.47.2-3+b11 \ diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 02ba8dd7e9..a94daeeaca 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -50,6 +50,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates=20250419 \ iproute2=6.15.0-1 \ iptables=1.8.11-2 \ + nftables=1.1.3-1 \ libcap2-bin=1:2.75-10+deb13u1+b1 \ procps=2:4.0.4-9 \ e2fsprogs=1.47.2-3+b11 \ diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index d89c5a29d2..4fa8f07ba3 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -22,6 +22,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates=20250419 \ iproute2=6.15.0-1 \ iptables=1.8.11-2 \ + nftables=1.1.3-1 \ libcap2-bin=1:2.75-10+deb13u1+b1 \ procps=2:4.0.4-9 \ e2fsprogs=1.47.2-3+b11 \ diff --git a/src/lib/onboard/docker-gpu-patch-clone.test.ts b/src/lib/onboard/docker-gpu-patch-clone.test.ts index a823986393..8ad39b3016 100644 --- a/src/lib/onboard/docker-gpu-patch-clone.test.ts +++ b/src/lib/onboard/docker-gpu-patch-clone.test.ts @@ -32,8 +32,8 @@ describe("Docker GPU clone envelope", () => { "openshell.ai/sandbox-name=alpha", "--volume", "/host:/container:rw", - "--tmpfs", - "/tmp/nemoclaw-exact-main-driver-config:noexec,size=16777216,mode=1777", + "--mount", + "type=tmpfs,dst=/tmp/nemoclaw-exact-main-driver-config,tmpfs-size=16777216,tmpfs-mode=1777", "--network", "openshell-docker", "--network-alias", diff --git a/src/lib/onboard/docker-gpu-patch-clone.ts b/src/lib/onboard/docker-gpu-patch-clone.ts index e6da25d159..a3a1a50941 100644 --- a/src/lib/onboard/docker-gpu-patch-clone.ts +++ b/src/lib/onboard/docker-gpu-patch-clone.ts @@ -16,6 +16,7 @@ const GPU_ENV_KEYS = new Set([ "NVIDIA_REQUIRE_CUDA", "NVIDIA_DISABLE_REQUIRE", ]); +const DOCKER_DEFAULT_TMPFS_OPTIONS = new Set(["noexec", "nosuid", "nodev"]); type DockerStructuredMount = NonNullable< NonNullable["Mounts"] >[number]; @@ -156,13 +157,18 @@ function dockerTmpfsMountValue(mount: DockerStructuredMount): string { if (!target.startsWith("/")) { throw new Error("Docker structured mount target must be an absolute container path."); } - const options: string[] = []; - if (optionalMountBoolean(mount.ReadOnly, "ReadOnly")) options.push("ro"); + const values = [`type=tmpfs`, `dst=${target}`]; + if (optionalMountBoolean(mount.ReadOnly, "ReadOnly")) values.push("readonly"); for (const parts of mount.TmpfsOptions?.Options ?? []) { - if (!Array.isArray(parts) || parts.length < 1 || parts.length > 2) { - throw new Error("Docker tmpfs mount options must contain one or two values."); + if (!Array.isArray(parts) || parts.length !== 1) { + throw new Error("Docker structured tmpfs options must contain exactly one value."); + } + const option = mountValue(parts[0], "tmpfs option"); + if (!DOCKER_DEFAULT_TMPFS_OPTIONS.has(option)) { + throw new Error( + `Docker structured tmpfs option '${option}' cannot be preserved during recreation.`, + ); } - options.push(parts.map((part) => mountValue(part, "tmpfs option")).join("=")); } const sizeBytes = mount.TmpfsOptions?.SizeBytes; @@ -170,16 +176,16 @@ function dockerTmpfsMountValue(mount: DockerStructuredMount): string { if (!Number.isSafeInteger(sizeBytes) || sizeBytes <= 0) { throw new Error("Docker tmpfs mount size must be a positive safe integer."); } - options.push(`size=${sizeBytes}`); + values.push(`tmpfs-size=${sizeBytes}`); } const mode = mount.TmpfsOptions?.Mode; if (mode !== undefined && mode !== null) { if (!Number.isSafeInteger(mode) || mode < 0 || mode > 0o7777) { throw new Error("Docker tmpfs mount mode must be a valid non-negative file mode."); } - options.push(`mode=${mode.toString(8)}`); + values.push(`tmpfs-mode=${mode.toString(8)}`); } - return options.length > 0 ? `${target}:${options.join(",")}` : target; + return values.join(","); } function dockerVolumeMountValue(mount: DockerStructuredMount): string { @@ -215,9 +221,10 @@ function dockerStructuredMountArgs(inspect: DockerContainerInspect): string[] { for (const mount of inspect.HostConfig?.Mounts ?? []) { switch (mount.Type) { case "tmpfs": - // The --tmpfs form preserves OpenShell's arbitrary TmpfsOptions.Options, - // such as noexec, in addition to the structured size and mode fields. - args.push("--tmpfs", dockerTmpfsMountValue(mount)); + // Docker applies noexec, nosuid, and nodev to tmpfs mounts by default. + // Keep the structured representation so Docker inspect and later + // recreation retain size and mode alongside that security posture. + args.push("--mount", dockerTmpfsMountValue(mount)); break; case "volume": args.push("--mount", dockerVolumeMountValue(mount)); diff --git a/src/lib/onboard/docker-gpu-patch-validation.test.ts b/src/lib/onboard/docker-gpu-patch-validation.test.ts index b80e2834fd..1ee255e7d4 100644 --- a/src/lib/onboard/docker-gpu-patch-validation.test.ts +++ b/src/lib/onboard/docker-gpu-patch-validation.test.ts @@ -181,6 +181,50 @@ describe("Docker GPU startup command validation (#6110)", () => { expect(dockerRunDetached).not.toHaveBeenCalled(); }); + it("rejects non-default structured tmpfs options before touching the original container", () => { + const inspect = inspectFixture(); + inspect.HostConfig!.Mounts = [ + { + Type: "tmpfs", + Target: "/tmp/sandbox", + TmpfsOptions: { Options: [["exec"]] }, + }, + ]; + const dockerStop = vi.fn(() => ({ status: 0 })); + const dockerRename = vi.fn(() => ({ status: 0 })); + const dockerRunDetached = vi.fn(() => ({ status: 0, stdout: "new-container-id\n" })); + + expect(() => + recreateOpenShellDockerSandboxWithGpu( + { + sandboxName: "alpha", + timeoutSecs: 1, + openshellSandboxCommand: ["env", "nemoclaw-start"], + }, + { + dockerCapture: vi.fn((args: readonly string[]) => + args[0] === "ps" + ? "old-container-id\n" + : args[0] === "inspect" + ? JSON.stringify([inspect]) + : "", + ), + detectSandboxFallbackDns: vi.fn(() => null), + dockerRun: vi.fn(() => ({ status: 0, stdout: "probe-id\n" })), + dockerRunDetached, + dockerRename, + dockerRm: vi.fn(() => ({ status: 0 })), + dockerStop, + readDir: vi.fn(() => null), + readFile: vi.fn(() => null), + }, + ), + ).toThrow("Docker structured tmpfs option 'exec' cannot be preserved during recreation"); + expect(dockerStop).not.toHaveBeenCalled(); + expect(dockerRename).not.toHaveBeenCalled(); + expect(dockerRunDetached).not.toHaveBeenCalled(); + }); + it.each([ ";", "&&", diff --git a/test/e2e/live/mcp-bridge-onboard-env.ts b/test/e2e/live/mcp-bridge-onboard-env.ts index 1ef3ef1e9a..6b210bd8e0 100644 --- a/test/e2e/live/mcp-bridge-onboard-env.ts +++ b/test/e2e/live/mcp-bridge-onboard-env.ts @@ -10,24 +10,34 @@ const EXACT_MAIN_OVERLAY_KEYS = new Set([ "NEMOCLAW_OPENSHELL_SANDBOX_BIN", ]); -export function buildMcpBridgeOnboardEnv(options: { - agent: "openclaw" | "hermes" | "langchain-deepagents-code"; +export function buildMcpBridgeExactMainEnv(options: { baseEnv?: NodeJS.ProcessEnv; - compatibleKey: string; - compatibleModel: string; - endpointUrl: string; envOverlay?: NodeJS.ProcessEnv; - sandboxName: string; }): NodeJS.ProcessEnv { const envOverlay = options.envOverlay ?? {}; for (const key of Object.keys(envOverlay)) { if (!EXACT_MAIN_OVERLAY_KEYS.has(key)) { - throw new Error(`MCP exact-main onboarding does not allow env overlay key '${key}'`); + throw new Error(`MCP exact-main command does not allow env overlay key '${key}'`); } } return { ...buildAvailabilityProbeEnv(options.baseEnv), + ...envOverlay, + }; +} + +export function buildMcpBridgeOnboardEnv(options: { + agent: "openclaw" | "hermes" | "langchain-deepagents-code"; + baseEnv?: NodeJS.ProcessEnv; + compatibleKey: string; + compatibleModel: string; + endpointUrl: string; + envOverlay?: NodeJS.ProcessEnv; + sandboxName: string; +}): NodeJS.ProcessEnv { + return { + ...buildMcpBridgeExactMainEnv(options), COMPATIBLE_API_KEY: options.compatibleKey, NVIDIA_INFERENCE_API_KEY: options.compatibleKey, NEMOCLAW_AGENT: options.agent, @@ -38,6 +48,5 @@ export function buildMcpBridgeOnboardEnv(options: { NEMOCLAW_PROVIDER: "custom", NEMOCLAW_SANDBOX_NAME: options.sandboxName, NEMOCLAW_RECREATE_SANDBOX: "1", - ...envOverlay, }; } diff --git a/test/e2e/live/mcp-bridge.test.ts b/test/e2e/live/mcp-bridge.test.ts index 2a217dc77a..0fcc16823b 100644 --- a/test/e2e/live/mcp-bridge.test.ts +++ b/test/e2e/live/mcp-bridge.test.ts @@ -28,7 +28,7 @@ import { assertHermesInspectionRejectsUnmanagedFields, assertHermesRemovalSurvivesGatewayRestart, } from "./mcp-bridge-hermes-lifecycle.ts"; -import { buildMcpBridgeOnboardEnv } from "./mcp-bridge-onboard-env.ts"; +import { buildMcpBridgeExactMainEnv, buildMcpBridgeOnboardEnv } from "./mcp-bridge-onboard-env.ts"; import { retryAfterHermesRestartTransportFailure } from "./mcp-bridge-reliability.ts"; import { buildMcpDnsRebindingProbeScript, @@ -827,11 +827,12 @@ async function rebuildWithoutMcpHostSecret( host: HostCliClient, sandboxName: string, artifactPrefix: string, + envOverlay: NodeJS.ProcessEnv = {}, ): Promise { const rebuild = await host.nemoclaw([sandboxName, "rebuild", "--yes"], { artifactName: `${artifactPrefix}-rebuild-with-provider-backed-mcp`, env: { - ...buildAvailabilityProbeEnv(), + ...buildMcpBridgeExactMainEnv({ envOverlay }), COMPATIBLE_API_KEY: COMPATIBLE_KEY, NVIDIA_INFERENCE_API_KEY: COMPATIBLE_KEY, }, @@ -1439,7 +1440,12 @@ mcpBridgeShardTest("deepagents")( [HOST_SECRET, ROTATED_HOST_SECRET], "deepagents-assert-secrets-absent-after-rotation", ); - await rebuildWithoutMcpHostSecret(host, DEEPAGENTS_SANDBOX_NAME, "deepagents"); + await rebuildWithoutMcpHostSecret( + host, + DEEPAGENTS_SANDBOX_NAME, + "deepagents", + exactMainProof.envOverlay, + ); await exactMainProof.afterRebuild(); await assertDeepAgentsConfig(sandbox, DEEPAGENTS_SANDBOX_NAME, mcpUrl); await assertSecretAbsentFromSandbox( diff --git a/test/e2e/live/openshell-exact-main-driver-config.ts b/test/e2e/live/openshell-exact-main-driver-config.ts index e73e073e93..fdd716a9aa 100644 --- a/test/e2e/live/openshell-exact-main-driver-config.ts +++ b/test/e2e/live/openshell-exact-main-driver-config.ts @@ -91,6 +91,12 @@ type DockerMount = { Type?: unknown; }; +type ConfiguredDockerMount = { + Target?: unknown; + TmpfsOptions?: unknown; + Type?: unknown; +}; + type RuntimeSnapshot = { bridgeAddress: string; config: ParsedGatewayConfig; @@ -345,6 +351,34 @@ async function assertRuntimeMounts( phase: string, ): Promise { const components = proof.components!; + const configuredMounts = await inspectJson( + host, + containerId, + "{{json .HostConfig.Mounts}}", + `exact-main-driver-configured-mounts-${phase}`, + ); + expect(Array.isArray(configuredMounts), "Docker HostConfig.Mounts must be structured").toBe(true); + expect(configuredMounts?.find((mount) => mount.Target === EXACT_MAIN_TMPFS_TARGET)).toMatchObject( + { + Type: "tmpfs", + Target: EXACT_MAIN_TMPFS_TARGET, + TmpfsOptions: { + Mode: EXACT_MAIN_TMPFS_MOUNT.mode, + SizeBytes: EXACT_MAIN_TMPFS_MOUNT.size_bytes, + }, + }, + ); + const configuredTmpfs = await inspectJson( + host, + containerId, + "{{json .HostConfig.Tmpfs}}", + `exact-main-driver-configured-tmpfs-${phase}`, + ); + expect( + configuredTmpfs === null || + (isRecord(configuredTmpfs) && !Object.hasOwn(configuredTmpfs, EXACT_MAIN_TMPFS_TARGET)), + "the reviewed tmpfs must not use Docker's legacy HostConfig.Tmpfs path", + ).toBe(true); const mounts = await inspectJson( host, containerId, diff --git a/test/e2e/live/openshell-exact-main-exec.ts b/test/e2e/live/openshell-exact-main-exec.ts index d4414ead56..7fb8f961f8 100644 --- a/test/e2e/live/openshell-exact-main-exec.ts +++ b/test/e2e/live/openshell-exact-main-exec.ts @@ -42,8 +42,9 @@ proc = matches[0] status = {} for line in (proc / "status").read_text(encoding="utf-8").splitlines(): name, separator, value = line.partition(":") - if separator: - status[name] = value.strip().split()[0] + fields = value.strip().split() + if separator and fields: + status[name] = fields[0] cap_bnd = status.get("CapBnd", "") if not cap_bnd or set(cap_bnd) != {"0"}: raise SystemExit(f"entrypoint retained capability bounding set {cap_bnd or ''}") @@ -69,8 +70,9 @@ tls_names = {${SUPERVISOR_TLS_ENV_NAMES.map((name) => JSON.stringify(name)).join status = {} for line in Path("/proc/self/status").read_text(encoding="utf-8").splitlines(): name, separator, value = line.partition(":") - if separator: - status[name] = value.strip().split()[0] + fields = value.strip().split() + if separator and fields: + status[name] = fields[0] cap_bnd = status.get("CapBnd", "") if not cap_bnd or set(cap_bnd) != {"0"}: raise SystemExit(f"exec child retained capability bounding set {cap_bnd or ''}") diff --git a/test/e2e/live/openshell-exact-main-runtime-contracts.ts b/test/e2e/live/openshell-exact-main-runtime-contracts.ts index a5d5cbc342..931a9df0f9 100644 --- a/test/e2e/live/openshell-exact-main-runtime-contracts.ts +++ b/test/e2e/live/openshell-exact-main-runtime-contracts.ts @@ -32,7 +32,7 @@ type JsonRecord = Record; export interface ExactMainPolicyStatus { activeVersion: number; - configRevision?: number; + configRevision?: string; hash: string; policySource?: string; sandbox: string; @@ -73,15 +73,22 @@ export function parseExactMainPolicyStatus(raw: string): ExactMainPolicyStatus { if (!isRecord(parsed)) throw new Error("exact-main policy status must be a JSON object"); const configRevision = parsed.config_revision; const policySource = parsed.policy_source; - if (configRevision !== undefined && !Number.isSafeInteger(configRevision)) { - throw new Error("exact-main policy config_revision must be an integer when present"); + let exactConfigRevision: string | undefined; + if (configRevision !== undefined) { + const matches = [...raw.matchAll(/"config_revision"\s*:\s*(0|[1-9][0-9]*)(?=\s*[,}])/gu)]; + if (matches.length !== 1 || matches[0][1] === undefined) { + throw new Error( + "exact-main policy config_revision must be one non-negative JSON integer when present", + ); + } + exactConfigRevision = matches[0][1]; } if (policySource !== undefined && typeof policySource !== "string") { throw new Error("exact-main policy policy_source must be a string when present"); } return { activeVersion: requiredInteger(parsed, "active_version"), - ...(configRevision === undefined ? {} : { configRevision: Number(configRevision) }), + ...(exactConfigRevision === undefined ? {} : { configRevision: exactConfigRevision }), hash: requiredString(parsed, "hash"), ...(policySource === undefined ? {} : { policySource }), sandbox: requiredString(parsed, "sandbox"), @@ -97,13 +104,6 @@ function containsObject(value: unknown, predicate: (candidate: JsonRecord) => bo return Object.values(value).some((entry) => containsObject(entry, predicate)); } -function containsScalar(value: unknown, expected: string | number): boolean { - if (value === expected) return true; - if (Array.isArray(value)) return value.some((entry) => containsScalar(entry, expected)); - if (!isRecord(value)) return false; - return Object.values(value).some((entry) => containsScalar(entry, expected)); -} - function hasVerdict(rule: JsonRecord, verdict: "accept" | "reject"): boolean { return containsObject(rule, (candidate) => Object.hasOwn(candidate, verdict)); } @@ -125,11 +125,17 @@ function nftRuleMatchesReject( family: "ipv4" | "ipv6", protocol: "tcp" | "udp", ): boolean { + const rejectType = family === "ipv4" ? "icmp" : "icmpv6"; return ( hasVerdict(rule, "reject") && - hasMatch(rule, { key: "nfproto" }, family) && hasMatch(rule, { key: "l4proto" }, protocol) && - containsScalar(rule, "port-unreachable") + containsObject( + rule, + (candidate) => + isRecord(candidate.reject) && + candidate.reject.type === rejectType && + candidate.reject.expr === "port-unreachable", + ) ); } @@ -276,9 +282,21 @@ connect_status() { fi printf '%s\n' "$status" } -connect_status > "$first_result" +connect_until_allowed() { + result_path=$1 + allowed_status=000 + allowed_attempt=0 + while [ "$allowed_attempt" -lt 50 ]; do + allowed_attempt=$((allowed_attempt + 1)) + allowed_status=$(connect_status) + [ "$allowed_status" = 200 ] && break + sleep 0.1 + done + printf '%s\n' "$allowed_status" > "$result_path" +} +connect_until_allowed "$first_result" IFS= read -r _ < "$trigger" -connect_status > "$second_result"`; +connect_until_allowed "$second_result"`; const LIVE_EXE_ONESHOT_SCRIPT = String.raw`set -eu endpoint_host=$1 @@ -340,7 +358,7 @@ export function buildExactMainLiveExeIdentityScript(mcpUrl: string): string { "}", 'wait_for_result "$first"', "old_first=$(tr -d '\\r\\n' < \"$first\")", - '[ "$old_first" = 200 ]', + '[ "$old_first" = 200 ] || { printf \'expected initial live-exe CONNECT 200, got %s\\n\' "$old_first" >&2; exit 1; }', 'replacement="$root/live-bash.next"', 'cp "$live" "$replacement"', "printf '\\nNEMOCLAW_EXACT_MAIN_REPLACEMENT\\n' >> \"$replacement\"", @@ -353,7 +371,7 @@ export function buildExactMainLiveExeIdentityScript(mcpUrl: string): string { "printf 'go\\n' > \"$trigger\"", 'wait_for_result "$second"', "old_second=$(tr -d '\\r\\n' < \"$second\")", - '[ "$old_second" = 200 ]', + '[ "$old_second" = 200 ] || { printf \'expected retained live-exe CONNECT 200, got %s\\n\' "$old_second" >&2; exit 1; }', 'wait "$old_pid"', 'old_pid=""', `new_status=$("$live" -c "$oneshot_script" exact-main-new ${shellQuote(endpoint.hostname)} ${endpointPort} | tr -d '\\r\\n')`, @@ -363,9 +381,7 @@ export function buildExactMainLiveExeIdentityScript(mcpUrl: string): string { "printf 'NEMOCLAW_LIVE_EXE_OLD_LINK=%s\\n' \"$old_exe\"", "printf 'NEMOCLAW_LIVE_EXE_OLD_HASH=%s\\n' \"$old_hash\"", "printf 'NEMOCLAW_LIVE_EXE_NEW_HASH=%s\\n' \"$new_hash\"", - '[ "$old_first" = 200 ]', - '[ "$old_second" = 200 ]', - '[ "$new_status" = 403 ]', + '[ "$new_status" = 403 ] || { printf \'expected replacement live-exe CONNECT 403, got %s\\n\' "$new_status" >&2; exit 1; }', ].join("\n"); } @@ -420,7 +436,7 @@ while [ ! -s "$ready" ]; do done cat "$ready"`; -const DIRECT_BYPASS_PROBE_CODE = String.raw`import errno +export const DIRECT_BYPASS_PROBE_CODE = String.raw`import errno import json import socket import sys @@ -464,7 +480,8 @@ tcp_error = result["tcp"]["error"] or {} udp_error = result["udp"]["error"] or {} if result["tcp"]["connected"] or result["udp"]["echoed"]: raise SystemExit(1) -if tcp_error.get("errno") != errno.ECONNREFUSED or udp_error.get("errno") != errno.ECONNREFUSED: +denial_errnos = {errno.ECONNREFUSED, errno.EPERM} +if tcp_error.get("errno") not in denial_errnos or udp_error.get("errno") not in denial_errnos: raise SystemExit(1) if result["tcp"]["elapsedMs"] >= 2500 or result["udp"]["elapsedMs"] >= 2500: raise SystemExit(1)`; @@ -494,20 +511,42 @@ async function findSandboxContainer( return ids[0] ?? ""; } +export function buildExactMainNftInspectionScript(): string { + return [ + "nft_path=", + "for candidate in /usr/sbin/nft /sbin/nft /usr/bin/nft; do", + ' if [ -x "$candidate" ]; then nft_path="$candidate"; break; fi', + "done", + "[ -n \"$nft_path\" ] || { printf 'nft executable not found\\n' >&2; exit 127; }", + "active_namespace_count=0", + "namespace_path=", + "for namespace_candidate in /var/run/netns/sandbox-*; do", + ' [ -e "$namespace_candidate" ] || continue', + " namespace=${namespace_candidate##*/}", + ' [ -n "$(ip netns pids "$namespace" 2>/dev/null)" ] || continue', + " active_namespace_count=$((active_namespace_count + 1))", + ' namespace_path="$namespace_candidate"', + "done", + 'if [ "$active_namespace_count" -gt 1 ]; then', + " printf 'expected at most one active sandbox netns, got %s\\n' \"$active_namespace_count\" >&2", + " ip netns list >&2 || true", + " exit 1", + "fi", + 'if [ "$active_namespace_count" -eq 1 ]; then', + ' exec nsenter --net="$namespace_path" -- "$nft_path" -j list table inet openshell_bypass', + "fi", + 'exec "$nft_path" -j list table inet openshell_bypass', + ].join("\n"); +} + async function inspectNftRules( host: HostCliClient, containerId: string, artifactName: string, ): Promise<{ inspection: ExactMainNftInspection; raw: string }> { - const script = [ - "set -- /var/run/netns/sandbox-*", - '[ "$#" -eq 1 ] && [ -e "$1" ] || { printf \'expected exactly one sandbox netns\\n\' >&2; exit 1; }', - "namespace=${1##*/}", - 'exec ip netns exec "$namespace" nft -j list table inet openshell_bypass', - ].join("\n"); const result = await host.command( "docker", - ["exec", "--user", "0", containerId, "sh", "-ceu", script], + ["exec", "--user", "0", containerId, "sh", "-ceu", buildExactMainNftInspectionScript()], { artifactName, env: buildAvailabilityProbeEnv(), timeoutMs: 30_000 }, ); expectExitZero(result, "inspect exact-main nft bypass rules"); diff --git a/test/e2e/support/mcp-bridge-onboard-env.test.ts b/test/e2e/support/mcp-bridge-onboard-env.test.ts index abc1008074..b3b04464f2 100644 --- a/test/e2e/support/mcp-bridge-onboard-env.test.ts +++ b/test/e2e/support/mcp-bridge-onboard-env.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it } from "vitest"; -import { buildMcpBridgeOnboardEnv } from "../live/mcp-bridge-onboard-env.ts"; +import { + buildMcpBridgeExactMainEnv, + buildMcpBridgeOnboardEnv, +} from "../live/mcp-bridge-onboard-env.ts"; const ONBOARD_OPTIONS = { agent: "langchain-deepagents-code" as const, @@ -15,6 +18,29 @@ const ONBOARD_OPTIONS = { }; describe("MCP bridge onboarding environment", () => { + it("restores exact-main OpenShell overrides after child environment sanitization", () => { + const env = buildMcpBridgeExactMainEnv({ + baseEnv: { + HOME: "/tmp/home", + PATH: "/usr/bin", + NEMOCLAW_OPENSHELL_BIN: "/dropped/openshell", + }, + envOverlay: { + PATH: "/tmp/exact-main:/usr/bin", + NEMOCLAW_OPENSHELL_BIN: "/tmp/exact-main/openshell", + NEMOCLAW_OPENSHELL_GATEWAY_BIN: "/usr/local/bin/openshell-gateway", + NEMOCLAW_OPENSHELL_SANDBOX_BIN: "/usr/local/bin/openshell-sandbox", + }, + }); + + expect(env).toMatchObject({ + PATH: "/tmp/exact-main:/usr/bin", + NEMOCLAW_OPENSHELL_BIN: "/tmp/exact-main/openshell", + NEMOCLAW_OPENSHELL_GATEWAY_BIN: "/usr/local/bin/openshell-gateway", + NEMOCLAW_OPENSHELL_SANDBOX_BIN: "/usr/local/bin/openshell-sandbox", + }); + }); + it("passes only exact-main OpenShell overrides after fixed onboarding values", () => { const env = buildMcpBridgeOnboardEnv({ ...ONBOARD_OPTIONS, diff --git a/test/e2e/support/openshell-exact-main-child-contracts.test.ts b/test/e2e/support/openshell-exact-main-child-contracts.test.ts index 6e2e28d3bf..a624ecbfeb 100644 --- a/test/e2e/support/openshell-exact-main-child-contracts.test.ts +++ b/test/e2e/support/openshell-exact-main-child-contracts.test.ts @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; @@ -61,6 +64,7 @@ describe("OpenShell exact-main child contracts", () => { { encoding: "utf8" }, ); expect(compiled.status, compiled.stderr).toBe(0); + expect(source).not.toContain("value.strip().split()[0]"); } const parsed = spawnSync("bash", ["-n"], { encoding: "utf8", @@ -69,6 +73,28 @@ describe("OpenShell exact-main child contracts", () => { expect(parsed.status, parsed.stderr).toBe(0); }); + it("parses proc status entries with an empty value", () => { + const directory = mkdtempSync(path.join(tmpdir(), "nemoclaw-proc-status-")); + const statusPath = path.join(directory, "status"); + try { + writeFileSync(statusPath, "Name:\tpython3\nGroups:\t\nCapBnd:\t0000000000000000\n", "utf8"); + const source = EXEC_CHILD_PROBE.replace( + 'Path("/proc/self/status")', + `Path(${JSON.stringify(statusPath)})`, + ); + const executed = spawnSync("python3", ["-c", source], { encoding: "utf8" }); + + expect(executed.status, executed.stderr).toBe(0); + expect(JSON.parse(executed.stdout)).toEqual({ + capBnd: "0000000000000000", + supervisorTlsEnvNames: [], + surface: "exec", + }); + } finally { + rmSync(directory, { force: true, recursive: true }); + } + }); + it("proves entrypoint, exec, and forced-TTY connect children independently", async () => { const { command, host, nemoclaw } = fakeHost(); command diff --git a/test/e2e/support/openshell-exact-main-runtime-contracts.test.ts b/test/e2e/support/openshell-exact-main-runtime-contracts.test.ts index fe3ee384e6..649a142d46 100644 --- a/test/e2e/support/openshell-exact-main-runtime-contracts.test.ts +++ b/test/e2e/support/openshell-exact-main-runtime-contracts.test.ts @@ -7,6 +7,8 @@ import { describe, expect, it } from "vitest"; import { buildExactMainLiveExeIdentityScript, + buildExactMainNftInspectionScript, + DIRECT_BYPASS_PROBE_CODE, inspectExactMainNftRuleset, parseExactMainPolicyStatus, } from "../live/openshell-exact-main-runtime-contracts.ts"; @@ -20,7 +22,6 @@ function rejectRule(family: "ipv4" | "ipv6", protocol: "tcp" | "udp") { rule: { chain: "output", expr: [ - match({ meta: { key: "nfproto" } }, family), match({ meta: { key: "l4proto" } }, protocol), { reject: { @@ -95,7 +96,7 @@ describe("OpenShell exact-main policy, nft, and process-identity proof helpers", ), ).toEqual({ activeVersion: 17, - configRevision: 23, + configRevision: "23", hash: "sha256:effective", policySource: "sandbox", sandbox: "e2e-mcp-dcode", @@ -115,6 +116,14 @@ describe("OpenShell exact-main policy, nft, and process-identity proof helpers", ).toMatchObject({ activeVersion: 17, status: "loaded", version: 17 }); }); + it("preserves OpenShell unsigned 64-bit config revisions as exact decimal strings", () => { + const status = parseExactMainPolicyStatus( + '{"active_version":2,"config_revision":7692118364955054884,"hash":"sha256:effective","policy_source":"sandbox","sandbox":"e2e-mcp-dcode","status":"effective","version":2}', + ); + + expect(status.configRevision).toBe("7692118364955054884"); + }); + it("requires the policy-accept chain, proxy/loopback accepts, and all four L4 rejects", () => { expect(inspectExactMainNftRuleset(JSON.stringify(completeRuleset()))).toEqual({ chainPolicy: "accept", @@ -125,6 +134,27 @@ describe("OpenShell exact-main policy, nft, and process-identity proof helpers", }); }); + it("inspects nft rules in either supported OpenShell network topology", () => { + const script = buildExactMainNftInspectionScript(); + const syntax = spawnSync("sh", ["-n"], { encoding: "utf8", input: script }); + + expect(syntax.status, syntax.stderr).toBe(0); + expect(script).toContain("/usr/sbin/nft /sbin/nft /usr/bin/nft"); + expect(script).toContain("nft executable not found"); + expect(script).toContain("/var/run/netns/sandbox-*"); + expect(script).toContain('ip netns pids "$namespace" 2>/dev/null'); + expect(script).toContain('nsenter --net="$namespace_path" -- "$nft_path"'); + expect(script).toContain('exec "$nft_path" -j list table inet openshell_bypass'); + expect(script).toContain('if [ "$active_namespace_count" -gt 1 ]'); + }); + + it("accepts immediate refused and permission-denied bypass outcomes", () => { + expect(DIRECT_BYPASS_PROBE_CODE).toContain("denial_errnos = {errno.ECONNREFUSED, errno.EPERM}"); + expect(DIRECT_BYPASS_PROBE_CODE).toContain( + 'tcp_error.get("errno") not in denial_errnos or udp_error.get("errno") not in denial_errnos', + ); + }); + it("rejects the feasible sequential-install partial state instead of calling it green", () => { const partial = completeRuleset(); const entries = partial.nftables as Array>; @@ -143,6 +173,11 @@ describe("OpenShell exact-main policy, nft, and process-identity proof helpers", expect(script).toContain("NEMOCLAW_LIVE_EXE_OLD_FIRST=%s"); expect(script).toContain("NEMOCLAW_LIVE_EXE_OLD_SECOND=%s"); expect(script).toContain("NEMOCLAW_LIVE_EXE_NEW=%s"); - expect(script).toContain('[ "$new_status" = 403 ]'); + expect(script).toContain('while [ "$allowed_attempt" -lt 50 ]'); + expect(script).toContain('[ "$allowed_status" = 200 ] && break'); + expect(script).toContain('connect_until_allowed "$first_result"'); + expect(script).toContain('connect_until_allowed "$second_result"'); + expect(script).toContain("expected initial live-exe CONNECT 200"); + expect(script).toContain("expected replacement live-exe CONNECT 403"); }); }); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 69301702e9..ef6d176da3 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -21,6 +21,12 @@ const DOCKERFILE_BASE = path.join(ROOT, "Dockerfile.base"); const DOCKERFILE_SANDBOX = path.join(ROOT, "test", "Dockerfile.sandbox"); const HERMES_DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); const HERMES_DOCKERFILE_BASE = path.join(ROOT, "agents", "hermes", "Dockerfile.base"); +const DEEPAGENTS_DOCKERFILE_BASE = path.join( + ROOT, + "agents", + "langchain-deepagents-code", + "Dockerfile.base", +); function dockerRunCommandBetween( dockerfile: string, @@ -1026,6 +1032,20 @@ describe("sandbox provisioning: unified .openclaw layout (#2227)", () => { }); describe("sandbox provisioning: base runtime tools", () => { + it.each([ + ["OpenClaw", DOCKERFILE_BASE], + ["Hermes", HERMES_DOCKERFILE_BASE], + ["Deep Agents Code", DEEPAGENTS_DOCKERFILE_BASE], + ])("installs pinned nftables for OpenShell bypass enforcement in %s", (_agent, file) => { + const dockerfile = fs.readFileSync(file, "utf-8"); + const aptInstall = dockerfile.match( + /^RUN apt-get update && apt-get install -y --no-install-recommends \\\n(?:.*\\\n)*.*$/m, + )?.[0]; + + expect(aptInstall).toBeDefined(); + expect(aptInstall).toContain("nftables=1.1.3-1"); + }); + it("base apt layer requests procps, e2fsprogs, and the SFTP server", () => { const dockerfile = fs.readFileSync(DOCKERFILE_BASE, "utf-8"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-apt-"));