From 8b3adec47022c3a57a27dad06ac3cb8e39692783 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 12 Jun 2026 02:24:36 +0000 Subject: [PATCH 1/7] fix(sandbox): self-heal recovery preload guards Signed-off-by: Tinson Lai --- src/lib/agent/runtime.test.ts | 114 ++++++++++++++++++++++++++++++++++ src/lib/agent/runtime.ts | 14 +++++ 2 files changed, 128 insertions(+) diff --git a/src/lib/agent/runtime.test.ts b/src/lib/agent/runtime.test.ts index 839afad4f1..b4de5a7c02 100644 --- a/src/lib/agent/runtime.test.ts +++ b/src/lib/agent/runtime.test.ts @@ -1,6 +1,10 @@ // 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, it, expect } from "vitest"; // Import from compiled dist/ so coverage is attributed correctly. import { @@ -227,6 +231,116 @@ describe("buildRecoveryScript", () => { expect(script).toContain("or ciao preload"); }); + it("self-installs the on-disk preload --require entries before the guard check fires", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + expect(script).not.toBeNull(); + expect(script).toContain( + 'if [ -r /tmp/nemoclaw-sandbox-safety-net.js ]; then case "${NODE_OPTIONS:-}" in *nemoclaw-sandbox-safety-net*) ;; *) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /tmp/nemoclaw-sandbox-safety-net.js" ;; esac; fi;', + ); + expect(script).toContain( + 'if [ -r /tmp/nemoclaw-ciao-network-guard.js ]; then case "${NODE_OPTIONS:-}" in *nemoclaw-ciao-network-guard*) ;; *) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /tmp/nemoclaw-ciao-network-guard.js" ;; esac; fi;', + ); + const selfHealSafetyIdx = script!.indexOf( + "if [ -r /tmp/nemoclaw-sandbox-safety-net.js ]", + ); + const selfHealCiaoIdx = script!.indexOf("if [ -r /tmp/nemoclaw-ciao-network-guard.js ]"); + const sourceIdx = script!.indexOf("then . /tmp/nemoclaw-proxy-env.sh"); + const guardCheckIdx = script!.indexOf('if [ "$_PE_MISSING" = "0" ]'); + expect(sourceIdx).toBeGreaterThanOrEqual(0); + expect(selfHealSafetyIdx).toBeGreaterThan(sourceIdx); + expect(selfHealCiaoIdx).toBeGreaterThan(sourceIdx); + expect(selfHealSafetyIdx).toBeLessThan(guardCheckIdx); + expect(selfHealCiaoIdx).toBeLessThan(guardCheckIdx); + }); + + it("keeps the refusal as the final invariant when preload files are absent", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + expect(script).toContain("refusing unguarded gateway relaunch"); + const selfHealIdx = script!.indexOf("if [ -r /tmp/nemoclaw-sandbox-safety-net.js ]"); + const refusalIdx = script!.indexOf("refusing unguarded gateway relaunch"); + expect(selfHealIdx).toBeGreaterThanOrEqual(0); + expect(refusalIdx).toBeGreaterThan(selfHealIdx); + }); + + it("self-installs preload --require entries in the OpenClaw recovery script as well", () => { + const script = buildOpenClawRecoveryScript(18789); + expect(script).toContain( + 'if [ -r /tmp/nemoclaw-sandbox-safety-net.js ]; then case "${NODE_OPTIONS:-}" in *nemoclaw-sandbox-safety-net*) ;; *) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /tmp/nemoclaw-sandbox-safety-net.js" ;; esac; fi;', + ); + expect(script).toContain( + 'if [ -r /tmp/nemoclaw-ciao-network-guard.js ]; then case "${NODE_OPTIONS:-}" in *nemoclaw-ciao-network-guard*) ;; *) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /tmp/nemoclaw-ciao-network-guard.js" ;; esac; fi;', + ); + const selfHealIdx = script.indexOf("if [ -r /tmp/nemoclaw-sandbox-safety-net.js ]"); + const guardCheckIdx = script.indexOf('if [ "$_PE_MISSING" = "0" ]'); + const refusalIdx = script.indexOf("refusing unguarded gateway relaunch"); + expect(selfHealIdx).toBeGreaterThanOrEqual(0); + expect(selfHealIdx).toBeLessThan(guardCheckIdx); + expect(guardCheckIdx).toBeLessThan(refusalIdx); + }); + + it("guards each self-heal block against duplicate --require entries", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + expect(script).toContain('case "${NODE_OPTIONS:-}" in *nemoclaw-sandbox-safety-net*) ;;'); + expect(script).toContain('case "${NODE_OPTIONS:-}" in *nemoclaw-ciao-network-guard*) ;;'); + }); + + it("self-heal lines install both --require preloads into NODE_OPTIONS under bash", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + expect(script).not.toBeNull(); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preload-self-heal-")); + try { + const safetyNet = path.join(tmp, "nemoclaw-sandbox-safety-net.js"); + const ciao = path.join(tmp, "nemoclaw-ciao-network-guard.js"); + fs.writeFileSync(safetyNet, "// stub\n"); + fs.writeFileSync(ciao, "// stub\n"); + const selfHealRe = /if \[ -r \/tmp\/nemoclaw-(?:sandbox-safety-net|ciao-network-guard)\.js \].+?esac; fi;/g; + const matches = script!.match(selfHealRe); + expect(matches?.length ?? 0).toBeGreaterThanOrEqual(2); + const inlineSelfHeal = (matches ?? []) + .map((s) => + s + .replaceAll("/tmp/nemoclaw-sandbox-safety-net.js", safetyNet) + .replaceAll("/tmp/nemoclaw-ciao-network-guard.js", ciao), + ) + .join(" "); + const probe = `${inlineSelfHeal} printf '%s' "$NODE_OPTIONS"`; + const result = spawnSync("bash", ["-c", probe], { encoding: "utf-8", timeout: 5000 }); + expect(result.status).toBe(0); + expect(result.stdout).toContain(`--require ${safetyNet}`); + expect(result.stdout).toContain(`--require ${ciao}`); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("self-heal lines do not duplicate --require when NODE_OPTIONS already carries the markers", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + expect(script).not.toBeNull(); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preload-self-heal-idem-")); + try { + const safetyNet = path.join(tmp, "nemoclaw-sandbox-safety-net.js"); + const ciao = path.join(tmp, "nemoclaw-ciao-network-guard.js"); + fs.writeFileSync(safetyNet, "// stub\n"); + fs.writeFileSync(ciao, "// stub\n"); + const selfHealRe = /if \[ -r \/tmp\/nemoclaw-(?:sandbox-safety-net|ciao-network-guard)\.js \].+?esac; fi;/g; + const matches = script!.match(selfHealRe) ?? []; + const inlineSelfHeal = matches + .map((s) => + s + .replaceAll("/tmp/nemoclaw-sandbox-safety-net.js", safetyNet) + .replaceAll("/tmp/nemoclaw-ciao-network-guard.js", ciao), + ) + .join(" "); + const seedNodeOptions = `--require ${safetyNet} --require ${ciao}`; + const probe = `export NODE_OPTIONS=${JSON.stringify(seedNodeOptions)}; ${inlineSelfHeal} printf '%s' "$NODE_OPTIONS"`; + const result = spawnSync("bash", ["-c", probe], { encoding: "utf-8", timeout: 5000 }); + expect(result.status).toBe(0); + expect(result.stdout).toBe(seedNodeOptions); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("stops stale launcher and gateway processes before relaunch", () => { const script = buildRecoveryScript(minimalAgent, 19000); expect(script).toContain( diff --git a/src/lib/agent/runtime.ts b/src/lib/agent/runtime.ts index ef79716e78..ebcf6e62dd 100644 --- a/src/lib/agent/runtime.ts +++ b/src/lib/agent/runtime.ts @@ -133,6 +133,18 @@ function buildGatewayLogSetup(includeAutoPairLog = false, logOwnerUser?: string) return lines; } +const GATEWAY_PRELOAD_GUARDS: ReadonlyArray<{ marker: string; path: string }> = [ + { marker: "nemoclaw-sandbox-safety-net", path: "/tmp/nemoclaw-sandbox-safety-net.js" }, + { marker: "nemoclaw-ciao-network-guard", path: "/tmp/nemoclaw-ciao-network-guard.js" }, +]; + +function buildGatewayPreloadSelfHealLines(): string[] { + return GATEWAY_PRELOAD_GUARDS.map( + ({ marker, path }) => + `if [ -r ${path} ]; then case "\${NODE_OPTIONS:-}" in *${marker}*) ;; *) export NODE_OPTIONS="\${NODE_OPTIONS:+\$NODE_OPTIONS }--require ${path}" ;; esac; fi;`, + ); +} + function buildGatewayLogSelection(): string { return '_GATEWAY_LOG=/tmp/gateway.log; if ! : >> "$_GATEWAY_LOG" 2>/dev/null; then _GATEWAY_LOG=/tmp/gateway-recovery.log; : >> "$_GATEWAY_LOG" 2>/dev/null || true; fi;'; } @@ -195,6 +207,7 @@ export function buildOpenClawRecoveryScript(port: number): string { return [ "if [ -r /tmp/nemoclaw-proxy-env.sh ]; then . /tmp/nemoclaw-proxy-env.sh; _PE_MISSING=0; else _PE_MISSING=1; fi;", "[ -f ~/.bashrc ] && . ~/.bashrc;", + ...buildGatewayPreloadSelfHealLines(), 'if [ "$_PE_MISSING" = "0" ]; then case "${NODE_OPTIONS:-}" in *nemoclaw-sandbox-safety-net*) _SN_MISSING=0 ;; *) _SN_MISSING=1 ;; esac; case "${NODE_OPTIONS:-}" in *nemoclaw-ciao-network-guard*) _CIAO_MISSING=0 ;; *) _CIAO_MISSING=1 ;; esac; if [ "$_SN_MISSING" = "0" ] && [ "$_CIAO_MISSING" = "0" ]; then _GUARDS_MISSING=0; else _GUARDS_MISSING=1; fi; else _GUARDS_MISSING=0; fi;', `_GW_CODE=$(curl -so /dev/null -w '%{http_code}' --max-time 3 http://127.0.0.1:${port}/health 2>/dev/null || echo 000); case "$_GW_CODE" in 200|401) echo ALREADY_RUNNING; exit 0 ;; esac;`, "rm -rf /tmp/openclaw-*/gateway.*.lock 2>/dev/null;", @@ -275,6 +288,7 @@ export function buildRecoveryScript( 'if [ -n "$_GATEWAY_PROC_PATTERN" ]; then pkill -TERM -f "$_GATEWAY_PROC_PATTERN" 2>/dev/null || true; for _i in 1 2 3 4 5; do pgrep -f "$_GATEWAY_PROC_PATTERN" >/dev/null 2>&1 || break; sleep 1; done; pkill -KILL -f "$_GATEWAY_PROC_PATTERN" 2>/dev/null || true; for _i in 1 2 3 4 5; do pgrep -f "$_GATEWAY_PROC_PATTERN" >/dev/null 2>&1 || break; sleep 1; done; if pgrep -f "$_GATEWAY_PROC_PATTERN" >/dev/null 2>&1; then echo GATEWAY_STALE_PROCESSES; exit 1; fi; fi;', ...validationSteps, "if [ -r /tmp/nemoclaw-proxy-env.sh ]; then . /tmp/nemoclaw-proxy-env.sh; _PE_MISSING=0; else _PE_MISSING=1; fi;", + ...buildGatewayPreloadSelfHealLines(), 'if [ "$_PE_MISSING" = "0" ]; then case "${NODE_OPTIONS:-}" in *nemoclaw-sandbox-safety-net*) _SN_MISSING=0 ;; *) _SN_MISSING=1 ;; esac; case "${NODE_OPTIONS:-}" in *nemoclaw-ciao-network-guard*) _CIAO_MISSING=0 ;; *) _CIAO_MISSING=1 ;; esac; if [ "$_SN_MISSING" = "0" ] && [ "$_CIAO_MISSING" = "0" ]; then _GUARDS_MISSING=0; else _GUARDS_MISSING=1; fi; else _GUARDS_MISSING=0; fi;', '[ "$_PE_MISSING" = "1" ] && { _W="[gateway-recovery] WARNING: /tmp/nemoclaw-proxy-env.sh missing - gateway launching without library guards (#2478)"; echo "$_W" >&2; echo "$_W" >> "$_GATEWAY_LOG"; };', '[ "$_PE_MISSING" = "0" ] && [ "$_GUARDS_MISSING" = "1" ] && { _E="[gateway-recovery] ERROR: /tmp/nemoclaw-proxy-env.sh present but NODE_OPTIONS missing safety-net preload or ciao preload - refusing unguarded gateway relaunch (#2478)"; echo "$_E" >&2; echo "$_E" >> "$_GATEWAY_LOG"; exit 1; };', From c01dfe2c78e3928294a95842b71c0e00487c3aa4 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 12 Jun 2026 02:37:37 +0000 Subject: [PATCH 2/7] style(test): apply biome formatting to recovery preload tests Signed-off-by: Tinson Lai --- src/lib/agent/runtime.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/agent/runtime.test.ts b/src/lib/agent/runtime.test.ts index b4de5a7c02..69241cd647 100644 --- a/src/lib/agent/runtime.test.ts +++ b/src/lib/agent/runtime.test.ts @@ -240,9 +240,7 @@ describe("buildRecoveryScript", () => { expect(script).toContain( 'if [ -r /tmp/nemoclaw-ciao-network-guard.js ]; then case "${NODE_OPTIONS:-}" in *nemoclaw-ciao-network-guard*) ;; *) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /tmp/nemoclaw-ciao-network-guard.js" ;; esac; fi;', ); - const selfHealSafetyIdx = script!.indexOf( - "if [ -r /tmp/nemoclaw-sandbox-safety-net.js ]", - ); + const selfHealSafetyIdx = script!.indexOf("if [ -r /tmp/nemoclaw-sandbox-safety-net.js ]"); const selfHealCiaoIdx = script!.indexOf("if [ -r /tmp/nemoclaw-ciao-network-guard.js ]"); const sourceIdx = script!.indexOf("then . /tmp/nemoclaw-proxy-env.sh"); const guardCheckIdx = script!.indexOf('if [ "$_PE_MISSING" = "0" ]'); @@ -293,7 +291,8 @@ describe("buildRecoveryScript", () => { const ciao = path.join(tmp, "nemoclaw-ciao-network-guard.js"); fs.writeFileSync(safetyNet, "// stub\n"); fs.writeFileSync(ciao, "// stub\n"); - const selfHealRe = /if \[ -r \/tmp\/nemoclaw-(?:sandbox-safety-net|ciao-network-guard)\.js \].+?esac; fi;/g; + const selfHealRe = + /if \[ -r \/tmp\/nemoclaw-(?:sandbox-safety-net|ciao-network-guard)\.js \].+?esac; fi;/g; const matches = script!.match(selfHealRe); expect(matches?.length ?? 0).toBeGreaterThanOrEqual(2); const inlineSelfHeal = (matches ?? []) @@ -322,7 +321,8 @@ describe("buildRecoveryScript", () => { const ciao = path.join(tmp, "nemoclaw-ciao-network-guard.js"); fs.writeFileSync(safetyNet, "// stub\n"); fs.writeFileSync(ciao, "// stub\n"); - const selfHealRe = /if \[ -r \/tmp\/nemoclaw-(?:sandbox-safety-net|ciao-network-guard)\.js \].+?esac; fi;/g; + const selfHealRe = + /if \[ -r \/tmp\/nemoclaw-(?:sandbox-safety-net|ciao-network-guard)\.js \].+?esac; fi;/g; const matches = script!.match(selfHealRe) ?? []; const inlineSelfHeal = matches .map((s) => From 487397808c709e0641f950e1bf95bb9661fe8639 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 12 Jun 2026 04:11:05 +0000 Subject: [PATCH 3/7] fix(sandbox): require provenance before grafting preloads into NODE_OPTIONS Signed-off-by: Tinson Lai --- .../agent/runtime-recovery-preload.test.ts | 332 ++++++++++++++++++ src/lib/agent/runtime.test.ts | 114 ------ src/lib/agent/runtime.ts | 94 ++++- 3 files changed, 420 insertions(+), 120 deletions(-) create mode 100644 src/lib/agent/runtime-recovery-preload.test.ts diff --git a/src/lib/agent/runtime-recovery-preload.test.ts b/src/lib/agent/runtime-recovery-preload.test.ts new file mode 100644 index 0000000000..b7e7c79b36 --- /dev/null +++ b/src/lib/agent/runtime-recovery-preload.test.ts @@ -0,0 +1,332 @@ +// 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, it, expect } from "vitest"; +import { + buildOpenClawRecoveryScript, + buildRecoveryScript, +} from "../../../dist/lib/agent/runtime"; +import type { AgentDefinition } from "./defs"; + +function makeAgent(overrides: Partial = {}): AgentDefinition { + return { + name: "test-agent", + displayName: "Test Agent", + binary_path: "/usr/local/bin/test-agent", + gateway_command: "test-agent gateway run", + healthProbe: { url: "http://127.0.0.1:19000/", port: 19000, timeout_seconds: 5 }, + forwardPort: 19000, + dashboard: { kind: "ui", label: "UI", path: "/", healthPath: "/health", auth: "url_token" }, + configPaths: { + dir: "/tmp/agent", + configFile: "/tmp/agent/config.yaml", + envFile: null, + format: "yaml", + }, + inferenceProviderOptions: [], + stateDirs: [], + stateFiles: [], + versionCommand: "test-agent --version", + expectedVersion: null, + hasDevicePairing: false, + phoneHomeHosts: [], + messagingPlatforms: [], + dockerfileBasePath: null, + dockerfilePath: null, + startScriptPath: null, + policyAdditionsPath: null, + policyPermissivePath: null, + pluginDir: null, + legacyPaths: null, + agentDir: "/tmp/agent", + manifestPath: "/tmp/agent/manifest.yaml", + ...overrides, + }; +} + +const minimalAgent = makeAgent(); + +const PRELOAD_BASENAMES = ["sandbox-safety-net", "ciao-network-guard"] as const; +const SELF_HEAL_RE = + /if \[ "\$_PE_MISSING" = "0" \]; then .+?_nemoclaw_install_recovery_preload \/tmp\/nemoclaw-ciao-network-guard\.js \/usr\/local\/lib\/nemoclaw\/preloads\/ciao-network-guard\.js \|\| true; fi;/; + +interface Fixture { + dir: string; + tmpDir: string; + sourceDir: string; + tmpPaths: Record<(typeof PRELOAD_BASENAMES)[number], string>; + sourcePaths: Record<(typeof PRELOAD_BASENAMES)[number], string>; + selfHeal: string; +} + +function makeFixture(script: string): Fixture { + const match = script.match(SELF_HEAL_RE); + expect(match, "self-heal block not found in script").toBeTruthy(); + const block = match![0]; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-self-heal-")); + const tmpDir = path.join(dir, "tmp"); + const sourceDir = path.join(dir, "src"); + fs.mkdirSync(tmpDir); + fs.mkdirSync(sourceDir); + const tmpPaths: Record = {}; + const sourcePaths: Record = {}; + for (const base of PRELOAD_BASENAMES) { + tmpPaths[base] = path.join(tmpDir, `nemoclaw-${base}.js`); + sourcePaths[base] = path.join(sourceDir, `${base}.js`); + } + let rewritten = block; + for (const base of PRELOAD_BASENAMES) { + rewritten = rewritten + .replaceAll(`/tmp/nemoclaw-${base}.js`, tmpPaths[base]) + .replaceAll(`/usr/local/lib/nemoclaw/preloads/${base}.js`, sourcePaths[base]); + } + return { + dir, + tmpDir, + sourceDir, + tmpPaths: tmpPaths as Fixture["tmpPaths"], + sourcePaths: sourcePaths as Fixture["sourcePaths"], + selfHeal: rewritten, + }; +} + +function cleanFixture(fx: Fixture): void { + fs.rmSync(fx.dir, { recursive: true, force: true }); +} + +function writeSource(fx: Fixture): void { + for (const base of PRELOAD_BASENAMES) { + fs.writeFileSync(fx.sourcePaths[base], `// trusted source for ${base}\n`); + } +} + +function runProbe( + fx: Fixture, + options: { peMissing?: "0" | "1"; seedNodeOptions?: string } = {}, +): { status: number | null; stdout: string; stderr: string } { + const peMissing = options.peMissing ?? "0"; + const seed = options.seedNodeOptions ?? ""; + const probe = [ + `_GATEWAY_LOG=${JSON.stringify(path.join(fx.dir, "gateway.log"))};`, + `_PE_MISSING=${peMissing};`, + seed ? `export NODE_OPTIONS=${JSON.stringify(seed)};` : "export NODE_OPTIONS='';", + fx.selfHeal, + `printf '%s' "$NODE_OPTIONS"`, + ].join(" "); + const result = spawnSync("bash", ["-c", probe], { + encoding: "utf-8", + timeout: 10_000, + env: { ...process.env, NODE_OPTIONS: "" }, + }); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +describe("gateway recovery preload self-heal (#5253)", () => { + describe("generated script shape", () => { + it("defines the installer shell function", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + expect(script).toContain("_nemoclaw_install_recovery_preload() {"); + }); + + it("gates the whole self-heal block on _PE_MISSING=0", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + expect(script).toMatch(SELF_HEAL_RE); + }); + + it("references the immutable /usr/local/lib/nemoclaw/preloads source paths", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + expect(script).toContain("/usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js"); + expect(script).toContain("/usr/local/lib/nemoclaw/preloads/ciao-network-guard.js"); + }); + + it("orders self-heal after proxy-env source and before the guard refusal", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + expect(script).not.toBeNull(); + const sourceIdx = script!.indexOf("then . /tmp/nemoclaw-proxy-env.sh"); + const selfHealIdx = script!.indexOf('if [ "$_PE_MISSING" = "0" ]; then _nemoclaw_install'); + const guardIdx = script!.indexOf("_GUARDS_MISSING=1"); + const refusalIdx = script!.indexOf("refusing unguarded gateway relaunch"); + expect(sourceIdx).toBeGreaterThanOrEqual(0); + expect(selfHealIdx).toBeGreaterThan(sourceIdx); + expect(guardIdx).toBeGreaterThan(selfHealIdx); + expect(refusalIdx).toBeGreaterThan(guardIdx); + }); + + it("orders self-heal correctly in the OpenClaw recovery script as well", () => { + const script = buildOpenClawRecoveryScript(18789); + const sourceIdx = script.indexOf("then . /tmp/nemoclaw-proxy-env.sh"); + const selfHealIdx = script.indexOf('if [ "$_PE_MISSING" = "0" ]; then _nemoclaw_install'); + const refusalIdx = script.indexOf("refusing unguarded gateway relaunch"); + expect(sourceIdx).toBeGreaterThanOrEqual(0); + expect(selfHealIdx).toBeGreaterThan(sourceIdx); + expect(refusalIdx).toBeGreaterThan(selfHealIdx); + }); + + it("refuses to copy from /tmp file via [ -r ] alone — installer validates provenance", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + expect(script).toContain("is a symlink - refusing preload install"); + expect(script).toContain("has unsafe mode="); + expect(script).toContain("owner=$owner (expected root)"); + }); + }); + + describe("behavioural — install from trusted source", () => { + it("regenerates both /tmp preloads when missing, given trusted source files", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + const fx = makeFixture(script!); + try { + writeSource(fx); + const result = runProbe(fx); + expect(result.status, result.stderr).toBe(0); + for (const base of PRELOAD_BASENAMES) { + expect(fs.existsSync(fx.tmpPaths[base])).toBe(true); + expect(result.stdout).toContain(`--require ${fx.tmpPaths[base]}`); + const stat = fs.statSync(fx.tmpPaths[base]); + const mode = (stat.mode & 0o777).toString(8); + expect(mode).toBe("444"); + } + } finally { + cleanFixture(fx); + } + }); + + it("reuses a pre-existing /tmp preload that already has mode 444", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + const fx = makeFixture(script!); + try { + writeSource(fx); + for (const base of PRELOAD_BASENAMES) { + fs.writeFileSync(fx.tmpPaths[base], "// already staged\n"); + fs.chmodSync(fx.tmpPaths[base], 0o444); + } + const beforeMtimes = PRELOAD_BASENAMES.map( + (base) => fs.statSync(fx.tmpPaths[base]).mtimeMs, + ); + const result = runProbe(fx); + expect(result.status, result.stderr).toBe(0); + for (const base of PRELOAD_BASENAMES) { + expect(result.stdout).toContain(`--require ${fx.tmpPaths[base]}`); + } + const afterMtimes = PRELOAD_BASENAMES.map( + (base) => fs.statSync(fx.tmpPaths[base]).mtimeMs, + ); + expect(afterMtimes).toEqual(beforeMtimes); + for (const base of PRELOAD_BASENAMES) { + expect(fs.readFileSync(fx.tmpPaths[base], "utf-8")).toBe("// already staged\n"); + } + } finally { + cleanFixture(fx); + } + }); + }); + + describe("behavioural — provenance refusals", () => { + it("refuses a symlinked /tmp preload and does not graft it into NODE_OPTIONS", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + const fx = makeFixture(script!); + try { + writeSource(fx); + const decoy = path.join(fx.dir, "attacker.js"); + fs.writeFileSync(decoy, "// attacker payload\n"); + fs.symlinkSync(decoy, fx.tmpPaths["sandbox-safety-net"]); + fs.writeFileSync(fx.tmpPaths["ciao-network-guard"], "// staged\n"); + fs.chmodSync(fx.tmpPaths["ciao-network-guard"], 0o444); + const result = runProbe(fx); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).not.toContain(fx.tmpPaths["sandbox-safety-net"]); + expect(result.stdout).toContain(`--require ${fx.tmpPaths["ciao-network-guard"]}`); + expect(result.stderr).toContain("is a symlink - refusing preload install"); + } finally { + cleanFixture(fx); + } + }); + + it("refuses a /tmp preload whose mode is not 444", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + const fx = makeFixture(script!); + try { + writeSource(fx); + fs.writeFileSync(fx.tmpPaths["sandbox-safety-net"], "// tampered\n"); + fs.chmodSync(fx.tmpPaths["sandbox-safety-net"], 0o666); + fs.writeFileSync(fx.tmpPaths["ciao-network-guard"], "// staged\n"); + fs.chmodSync(fx.tmpPaths["ciao-network-guard"], 0o444); + const result = runProbe(fx); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).not.toContain(fx.tmpPaths["sandbox-safety-net"]); + expect(result.stdout).toContain(`--require ${fx.tmpPaths["ciao-network-guard"]}`); + expect(result.stderr).toContain("has unsafe mode=666"); + } finally { + cleanFixture(fx); + } + }); + + it("warns and skips when both the /tmp copy and the trusted source are missing", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + const fx = makeFixture(script!); + try { + const result = runProbe(fx); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("missing - cannot self-heal"); + const log = fs.readFileSync(path.join(fx.dir, "gateway.log"), "utf-8"); + expect(log).toContain("missing - cannot self-heal"); + } finally { + cleanFixture(fx); + } + }); + }); + + describe("behavioural — NODE_OPTIONS handling", () => { + it("treats a marker substring without --require as not-yet-installed and adds it", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + const fx = makeFixture(script!); + try { + writeSource(fx); + const result = runProbe(fx, { seedNodeOptions: "nemoclaw-sandbox-safety-net" }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain(`--require ${fx.tmpPaths["sandbox-safety-net"]}`); + expect(result.stdout).toContain(`--require ${fx.tmpPaths["ciao-network-guard"]}`); + } finally { + cleanFixture(fx); + } + }); + + it("does not duplicate --require entries that are already present", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + const fx = makeFixture(script!); + try { + writeSource(fx); + for (const base of PRELOAD_BASENAMES) { + fs.writeFileSync(fx.tmpPaths[base], "// staged\n"); + fs.chmodSync(fx.tmpPaths[base], 0o444); + } + const seed = `--require ${fx.tmpPaths["sandbox-safety-net"]} --require ${fx.tmpPaths["ciao-network-guard"]}`; + const result = runProbe(fx, { seedNodeOptions: seed }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe(seed); + } finally { + cleanFixture(fx); + } + }); + + it("skips the whole self-heal block when _PE_MISSING=1", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + const fx = makeFixture(script!); + try { + writeSource(fx); + const result = runProbe(fx, { peMissing: "1" }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe(""); + for (const base of PRELOAD_BASENAMES) { + expect(fs.existsSync(fx.tmpPaths[base])).toBe(false); + } + } finally { + cleanFixture(fx); + } + }); + }); +}); diff --git a/src/lib/agent/runtime.test.ts b/src/lib/agent/runtime.test.ts index 69241cd647..839afad4f1 100644 --- a/src/lib/agent/runtime.test.ts +++ b/src/lib/agent/runtime.test.ts @@ -1,10 +1,6 @@ // 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, it, expect } from "vitest"; // Import from compiled dist/ so coverage is attributed correctly. import { @@ -231,116 +227,6 @@ describe("buildRecoveryScript", () => { expect(script).toContain("or ciao preload"); }); - it("self-installs the on-disk preload --require entries before the guard check fires", () => { - const script = buildRecoveryScript(minimalAgent, 19000); - expect(script).not.toBeNull(); - expect(script).toContain( - 'if [ -r /tmp/nemoclaw-sandbox-safety-net.js ]; then case "${NODE_OPTIONS:-}" in *nemoclaw-sandbox-safety-net*) ;; *) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /tmp/nemoclaw-sandbox-safety-net.js" ;; esac; fi;', - ); - expect(script).toContain( - 'if [ -r /tmp/nemoclaw-ciao-network-guard.js ]; then case "${NODE_OPTIONS:-}" in *nemoclaw-ciao-network-guard*) ;; *) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /tmp/nemoclaw-ciao-network-guard.js" ;; esac; fi;', - ); - const selfHealSafetyIdx = script!.indexOf("if [ -r /tmp/nemoclaw-sandbox-safety-net.js ]"); - const selfHealCiaoIdx = script!.indexOf("if [ -r /tmp/nemoclaw-ciao-network-guard.js ]"); - const sourceIdx = script!.indexOf("then . /tmp/nemoclaw-proxy-env.sh"); - const guardCheckIdx = script!.indexOf('if [ "$_PE_MISSING" = "0" ]'); - expect(sourceIdx).toBeGreaterThanOrEqual(0); - expect(selfHealSafetyIdx).toBeGreaterThan(sourceIdx); - expect(selfHealCiaoIdx).toBeGreaterThan(sourceIdx); - expect(selfHealSafetyIdx).toBeLessThan(guardCheckIdx); - expect(selfHealCiaoIdx).toBeLessThan(guardCheckIdx); - }); - - it("keeps the refusal as the final invariant when preload files are absent", () => { - const script = buildRecoveryScript(minimalAgent, 19000); - expect(script).toContain("refusing unguarded gateway relaunch"); - const selfHealIdx = script!.indexOf("if [ -r /tmp/nemoclaw-sandbox-safety-net.js ]"); - const refusalIdx = script!.indexOf("refusing unguarded gateway relaunch"); - expect(selfHealIdx).toBeGreaterThanOrEqual(0); - expect(refusalIdx).toBeGreaterThan(selfHealIdx); - }); - - it("self-installs preload --require entries in the OpenClaw recovery script as well", () => { - const script = buildOpenClawRecoveryScript(18789); - expect(script).toContain( - 'if [ -r /tmp/nemoclaw-sandbox-safety-net.js ]; then case "${NODE_OPTIONS:-}" in *nemoclaw-sandbox-safety-net*) ;; *) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /tmp/nemoclaw-sandbox-safety-net.js" ;; esac; fi;', - ); - expect(script).toContain( - 'if [ -r /tmp/nemoclaw-ciao-network-guard.js ]; then case "${NODE_OPTIONS:-}" in *nemoclaw-ciao-network-guard*) ;; *) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /tmp/nemoclaw-ciao-network-guard.js" ;; esac; fi;', - ); - const selfHealIdx = script.indexOf("if [ -r /tmp/nemoclaw-sandbox-safety-net.js ]"); - const guardCheckIdx = script.indexOf('if [ "$_PE_MISSING" = "0" ]'); - const refusalIdx = script.indexOf("refusing unguarded gateway relaunch"); - expect(selfHealIdx).toBeGreaterThanOrEqual(0); - expect(selfHealIdx).toBeLessThan(guardCheckIdx); - expect(guardCheckIdx).toBeLessThan(refusalIdx); - }); - - it("guards each self-heal block against duplicate --require entries", () => { - const script = buildRecoveryScript(minimalAgent, 19000); - expect(script).toContain('case "${NODE_OPTIONS:-}" in *nemoclaw-sandbox-safety-net*) ;;'); - expect(script).toContain('case "${NODE_OPTIONS:-}" in *nemoclaw-ciao-network-guard*) ;;'); - }); - - it("self-heal lines install both --require preloads into NODE_OPTIONS under bash", () => { - const script = buildRecoveryScript(minimalAgent, 19000); - expect(script).not.toBeNull(); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preload-self-heal-")); - try { - const safetyNet = path.join(tmp, "nemoclaw-sandbox-safety-net.js"); - const ciao = path.join(tmp, "nemoclaw-ciao-network-guard.js"); - fs.writeFileSync(safetyNet, "// stub\n"); - fs.writeFileSync(ciao, "// stub\n"); - const selfHealRe = - /if \[ -r \/tmp\/nemoclaw-(?:sandbox-safety-net|ciao-network-guard)\.js \].+?esac; fi;/g; - const matches = script!.match(selfHealRe); - expect(matches?.length ?? 0).toBeGreaterThanOrEqual(2); - const inlineSelfHeal = (matches ?? []) - .map((s) => - s - .replaceAll("/tmp/nemoclaw-sandbox-safety-net.js", safetyNet) - .replaceAll("/tmp/nemoclaw-ciao-network-guard.js", ciao), - ) - .join(" "); - const probe = `${inlineSelfHeal} printf '%s' "$NODE_OPTIONS"`; - const result = spawnSync("bash", ["-c", probe], { encoding: "utf-8", timeout: 5000 }); - expect(result.status).toBe(0); - expect(result.stdout).toContain(`--require ${safetyNet}`); - expect(result.stdout).toContain(`--require ${ciao}`); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("self-heal lines do not duplicate --require when NODE_OPTIONS already carries the markers", () => { - const script = buildRecoveryScript(minimalAgent, 19000); - expect(script).not.toBeNull(); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preload-self-heal-idem-")); - try { - const safetyNet = path.join(tmp, "nemoclaw-sandbox-safety-net.js"); - const ciao = path.join(tmp, "nemoclaw-ciao-network-guard.js"); - fs.writeFileSync(safetyNet, "// stub\n"); - fs.writeFileSync(ciao, "// stub\n"); - const selfHealRe = - /if \[ -r \/tmp\/nemoclaw-(?:sandbox-safety-net|ciao-network-guard)\.js \].+?esac; fi;/g; - const matches = script!.match(selfHealRe) ?? []; - const inlineSelfHeal = matches - .map((s) => - s - .replaceAll("/tmp/nemoclaw-sandbox-safety-net.js", safetyNet) - .replaceAll("/tmp/nemoclaw-ciao-network-guard.js", ciao), - ) - .join(" "); - const seedNodeOptions = `--require ${safetyNet} --require ${ciao}`; - const probe = `export NODE_OPTIONS=${JSON.stringify(seedNodeOptions)}; ${inlineSelfHeal} printf '%s' "$NODE_OPTIONS"`; - const result = spawnSync("bash", ["-c", probe], { encoding: "utf-8", timeout: 5000 }); - expect(result.status).toBe(0); - expect(result.stdout).toBe(seedNodeOptions); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - it("stops stale launcher and gateway processes before relaunch", () => { const script = buildRecoveryScript(minimalAgent, 19000); expect(script).toContain( diff --git a/src/lib/agent/runtime.ts b/src/lib/agent/runtime.ts index ebcf6e62dd..dd3716d7ed 100644 --- a/src/lib/agent/runtime.ts +++ b/src/lib/agent/runtime.ts @@ -133,16 +133,98 @@ function buildGatewayLogSetup(includeAutoPairLog = false, logOwnerUser?: string) return lines; } -const GATEWAY_PRELOAD_GUARDS: ReadonlyArray<{ marker: string; path: string }> = [ - { marker: "nemoclaw-sandbox-safety-net", path: "/tmp/nemoclaw-sandbox-safety-net.js" }, - { marker: "nemoclaw-ciao-network-guard", path: "/tmp/nemoclaw-ciao-network-guard.js" }, +// Trust-boundary preload modules. The container image ships the immutable +// originals under /usr/local/lib/nemoclaw/preloads/; the sandbox entrypoint +// stages working copies into /tmp via emit_sandbox_sourced_file() with mode +// 444 (and root:root when launched as root) so the sandbox user can source +// them via NODE_OPTIONS but cannot tamper with the bytes that the Node +// process will load. +const GATEWAY_PRELOAD_GUARDS: ReadonlyArray<{ + tmpPath: string; + sourcePath: string; +}> = [ + { + tmpPath: "/tmp/nemoclaw-sandbox-safety-net.js", + sourcePath: "/usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js", + }, + { + tmpPath: "/tmp/nemoclaw-ciao-network-guard.js", + sourcePath: "/usr/local/lib/nemoclaw/preloads/ciao-network-guard.js", + }, ]; +// Self-heal NODE_OPTIONS at recovery time for sandboxes that were onboarded +// by an older entrypoint which did not emit the --require preload lines into +// /tmp/nemoclaw-proxy-env.sh. The whole block is gated on _PE_MISSING="0" +// (proxy-env was sourced successfully) so that the legacy "proxy-env missing +// → launching without library guards" warning still reflects reality. +// +// Provenance is enforced before any path joins NODE_OPTIONS — the staged +// /tmp/.js must be a regular non-symlink with mode 444 (and root- +// owned when recovery runs as uid 0), matching what emit_sandbox_sourced_file +// writes in scripts/lib/sandbox-init.sh. If the staged copy is missing the +// installer recreates it from the immutable /usr/local/lib/nemoclaw/preloads/ +// source using the same atomic-stage-and-rename pattern as the entrypoint. +// +// Every failure mode (symlink, wrong mode, wrong owner, source absent, copy +// failed) skips the entry — it never grafts an untrusted file into +// NODE_OPTIONS. The trailing "refusing unguarded gateway relaunch" invariant +// at the end of the script still fires when provenance gates anything off. function buildGatewayPreloadSelfHealLines(): string[] { - return GATEWAY_PRELOAD_GUARDS.map( - ({ marker, path }) => - `if [ -r ${path} ]; then case "\${NODE_OPTIONS:-}" in *${marker}*) ;; *) export NODE_OPTIONS="\${NODE_OPTIONS:+\$NODE_OPTIONS }--require ${path}" ;; esac; fi;`, + const installer = [ + "_nemoclaw_install_recovery_preload() {", + 'local tmp="$1"; local src="$2"; local dir base stage perms owner _msg;', + 'if [ ! -e "$tmp" ]; then', + 'if [ ! -r "$src" ]; then', + '_msg="[gateway-recovery] WARNING: $src missing - cannot self-heal $tmp";', + 'echo "$_msg" >&2; [ -n "${_GATEWAY_LOG:-}" ] && echo "$_msg" >> "$_GATEWAY_LOG" 2>/dev/null;', + "return 1;", + "fi;", + 'dir="$(dirname -- "$tmp")"; base="$(basename -- "$tmp")";', + 'stage="$(mktemp -- "${dir}/.${base}.tmp.XXXXXX")" || return 1;', + 'if ! cp -- "$src" "$stage"; then rm -f -- "$stage"; return 1; fi;', + 'if [ "$(id -u)" -eq 0 ] && ! chown root:root "$stage"; then rm -f -- "$stage"; return 1; fi;', + 'if ! chmod 444 "$stage"; then rm -f -- "$stage"; return 1; fi;', + 'if ! mv -f -- "$stage" "$tmp"; then rm -f -- "$stage"; return 1; fi;', + "fi;", + 'if [ -L "$tmp" ]; then', + '_msg="[gateway-recovery] ERROR: $tmp is a symlink - refusing preload install";', + 'echo "$_msg" >&2; [ -n "${_GATEWAY_LOG:-}" ] && echo "$_msg" >> "$_GATEWAY_LOG" 2>/dev/null;', + "return 1;", + "fi;", + 'if [ ! -f "$tmp" ]; then', + '_msg="[gateway-recovery] ERROR: $tmp is not a regular file - refusing preload install";', + 'echo "$_msg" >&2; [ -n "${_GATEWAY_LOG:-}" ] && echo "$_msg" >> "$_GATEWAY_LOG" 2>/dev/null;', + "return 1;", + "fi;", + 'perms="$(stat -c %a -- "$tmp" 2>/dev/null || stat -f %Lp -- "$tmp" 2>/dev/null || echo unknown)";', + 'if [ "$perms" != "444" ]; then', + '_msg="[gateway-recovery] ERROR: $tmp has unsafe mode=$perms (expected 444) - refusing preload install";', + 'echo "$_msg" >&2; [ -n "${_GATEWAY_LOG:-}" ] && echo "$_msg" >> "$_GATEWAY_LOG" 2>/dev/null;', + "return 1;", + "fi;", + 'if [ "$(id -u)" -eq 0 ]; then', + 'owner="$(stat -c %U -- "$tmp" 2>/dev/null || stat -f %Su -- "$tmp" 2>/dev/null || echo unknown)";', + 'if [ "$owner" != "root" ]; then', + '_msg="[gateway-recovery] ERROR: $tmp owner=$owner (expected root) - refusing preload install";', + 'echo "$_msg" >&2; [ -n "${_GATEWAY_LOG:-}" ] && echo "$_msg" >> "$_GATEWAY_LOG" 2>/dev/null;', + "return 1;", + "fi;", + "fi;", + 'case "${NODE_OPTIONS:-}" in', + '*"--require $tmp"*) ;;', + '*) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $tmp" ;;', + "esac;", + "return 0;", + "};", + ].join(" "); + + const calls = GATEWAY_PRELOAD_GUARDS.map( + ({ tmpPath, sourcePath }) => + `_nemoclaw_install_recovery_preload ${tmpPath} ${sourcePath} || true;`, ); + + return ['if [ "$_PE_MISSING" = "0" ]; then', installer, ...calls, "fi;"]; } function buildGatewayLogSelection(): string { From d91698ea69c44b626b230cffac85d1d0ae124ed6 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 12 Jun 2026 04:34:04 +0000 Subject: [PATCH 4/7] refactor(sandbox): extract gateway recovery preload self-heal builder Signed-off-by: Tinson Lai --- .../agent/runtime-recovery-preload.test.ts | 9 +- src/lib/agent/runtime-recovery-preload.ts | 102 ++++++++++++++++++ src/lib/agent/runtime.ts | 95 +--------------- 3 files changed, 105 insertions(+), 101 deletions(-) create mode 100644 src/lib/agent/runtime-recovery-preload.ts diff --git a/src/lib/agent/runtime-recovery-preload.test.ts b/src/lib/agent/runtime-recovery-preload.test.ts index b7e7c79b36..e094cb4e26 100644 --- a/src/lib/agent/runtime-recovery-preload.test.ts +++ b/src/lib/agent/runtime-recovery-preload.test.ts @@ -6,10 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, it, expect } from "vitest"; -import { - buildOpenClawRecoveryScript, - buildRecoveryScript, -} from "../../../dist/lib/agent/runtime"; +import { buildOpenClawRecoveryScript, buildRecoveryScript } from "../../../dist/lib/agent/runtime"; import type { AgentDefinition } from "./defs"; function makeAgent(overrides: Partial = {}): AgentDefinition { @@ -211,9 +208,7 @@ describe("gateway recovery preload self-heal (#5253)", () => { for (const base of PRELOAD_BASENAMES) { expect(result.stdout).toContain(`--require ${fx.tmpPaths[base]}`); } - const afterMtimes = PRELOAD_BASENAMES.map( - (base) => fs.statSync(fx.tmpPaths[base]).mtimeMs, - ); + const afterMtimes = PRELOAD_BASENAMES.map((base) => fs.statSync(fx.tmpPaths[base]).mtimeMs); expect(afterMtimes).toEqual(beforeMtimes); for (const base of PRELOAD_BASENAMES) { expect(fs.readFileSync(fx.tmpPaths[base], "utf-8")).toBe("// already staged\n"); diff --git a/src/lib/agent/runtime-recovery-preload.ts b/src/lib/agent/runtime-recovery-preload.ts new file mode 100644 index 0000000000..852e0f2a89 --- /dev/null +++ b/src/lib/agent/runtime-recovery-preload.ts @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Gateway recovery preload self-heal — generates the shell installer that +// restores the safety-net / ciao --require entries in NODE_OPTIONS when a +// legacy sandbox's proxy-env.sh was emitted before the entrypoint started +// emitting those guards. Lifted out of runtime.ts to keep the recovery +// monolith focused on the surrounding control flow. + +// Trust-boundary preload modules. The container image ships the immutable +// originals under /usr/local/lib/nemoclaw/preloads/; the sandbox entrypoint +// stages working copies into /tmp via emit_sandbox_sourced_file() with mode +// 444 (and root:root when launched as root) so the sandbox user can source +// them via NODE_OPTIONS but cannot tamper with the bytes that the Node +// process will load. +export const GATEWAY_PRELOAD_GUARDS: ReadonlyArray<{ + tmpPath: string; + sourcePath: string; +}> = [ + { + tmpPath: "/tmp/nemoclaw-sandbox-safety-net.js", + sourcePath: "/usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js", + }, + { + tmpPath: "/tmp/nemoclaw-ciao-network-guard.js", + sourcePath: "/usr/local/lib/nemoclaw/preloads/ciao-network-guard.js", + }, +]; + +// Self-heal NODE_OPTIONS at recovery time for sandboxes that were onboarded +// by an older entrypoint which did not emit the --require preload lines into +// /tmp/nemoclaw-proxy-env.sh. The whole block is gated on _PE_MISSING="0" +// (proxy-env was sourced successfully) so that the legacy "proxy-env missing +// → launching without library guards" warning still reflects reality. +// +// Provenance is enforced before any path joins NODE_OPTIONS — the staged +// /tmp/.js must be a regular non-symlink with mode 444 (and root- +// owned when recovery runs as uid 0), matching what emit_sandbox_sourced_file +// writes in scripts/lib/sandbox-init.sh. If the staged copy is missing the +// installer recreates it from the immutable /usr/local/lib/nemoclaw/preloads/ +// source using the same atomic-stage-and-rename pattern as the entrypoint. +// +// Every failure mode (symlink, wrong mode, wrong owner, source absent, copy +// failed) skips the entry — it never grafts an untrusted file into +// NODE_OPTIONS. The trailing "refusing unguarded gateway relaunch" invariant +// at the end of the script still fires when provenance gates anything off. +export function buildGatewayPreloadSelfHealLines(): string[] { + const installer = [ + "_nemoclaw_install_recovery_preload() {", + 'local tmp="$1"; local src="$2"; local dir base stage perms owner _msg;', + 'if [ ! -e "$tmp" ]; then', + 'if [ ! -r "$src" ]; then', + '_msg="[gateway-recovery] WARNING: $src missing - cannot self-heal $tmp";', + 'echo "$_msg" >&2; [ -n "${_GATEWAY_LOG:-}" ] && echo "$_msg" >> "$_GATEWAY_LOG" 2>/dev/null;', + "return 1;", + "fi;", + 'dir="$(dirname -- "$tmp")"; base="$(basename -- "$tmp")";', + 'stage="$(mktemp -- "${dir}/.${base}.tmp.XXXXXX")" || return 1;', + 'if ! cp -- "$src" "$stage"; then rm -f -- "$stage"; return 1; fi;', + 'if [ "$(id -u)" -eq 0 ] && ! chown root:root "$stage"; then rm -f -- "$stage"; return 1; fi;', + 'if ! chmod 444 "$stage"; then rm -f -- "$stage"; return 1; fi;', + 'if ! mv -f -- "$stage" "$tmp"; then rm -f -- "$stage"; return 1; fi;', + "fi;", + 'if [ -L "$tmp" ]; then', + '_msg="[gateway-recovery] ERROR: $tmp is a symlink - refusing preload install";', + 'echo "$_msg" >&2; [ -n "${_GATEWAY_LOG:-}" ] && echo "$_msg" >> "$_GATEWAY_LOG" 2>/dev/null;', + "return 1;", + "fi;", + 'if [ ! -f "$tmp" ]; then', + '_msg="[gateway-recovery] ERROR: $tmp is not a regular file - refusing preload install";', + 'echo "$_msg" >&2; [ -n "${_GATEWAY_LOG:-}" ] && echo "$_msg" >> "$_GATEWAY_LOG" 2>/dev/null;', + "return 1;", + "fi;", + 'perms="$(stat -c %a -- "$tmp" 2>/dev/null || stat -f %Lp -- "$tmp" 2>/dev/null || echo unknown)";', + 'if [ "$perms" != "444" ]; then', + '_msg="[gateway-recovery] ERROR: $tmp has unsafe mode=$perms (expected 444) - refusing preload install";', + 'echo "$_msg" >&2; [ -n "${_GATEWAY_LOG:-}" ] && echo "$_msg" >> "$_GATEWAY_LOG" 2>/dev/null;', + "return 1;", + "fi;", + 'if [ "$(id -u)" -eq 0 ]; then', + 'owner="$(stat -c %U -- "$tmp" 2>/dev/null || stat -f %Su -- "$tmp" 2>/dev/null || echo unknown)";', + 'if [ "$owner" != "root" ]; then', + '_msg="[gateway-recovery] ERROR: $tmp owner=$owner (expected root) - refusing preload install";', + 'echo "$_msg" >&2; [ -n "${_GATEWAY_LOG:-}" ] && echo "$_msg" >> "$_GATEWAY_LOG" 2>/dev/null;', + "return 1;", + "fi;", + "fi;", + 'case "${NODE_OPTIONS:-}" in', + '*"--require $tmp"*) ;;', + '*) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $tmp" ;;', + "esac;", + "return 0;", + "};", + ].join(" "); + + const calls = GATEWAY_PRELOAD_GUARDS.map( + ({ tmpPath, sourcePath }) => + `_nemoclaw_install_recovery_preload ${tmpPath} ${sourcePath} || true;`, + ); + + return ['if [ "$_PE_MISSING" = "0" ]; then', installer, ...calls, "fi;"]; +} diff --git a/src/lib/agent/runtime.ts b/src/lib/agent/runtime.ts index dd3716d7ed..552518cd88 100644 --- a/src/lib/agent/runtime.ts +++ b/src/lib/agent/runtime.ts @@ -16,6 +16,7 @@ import { buildHermesEnvFileBoundaryGuard, buildHermesRuntimeEnvBoundaryGuard, } from "./hermes-recovery-boundary"; +import { buildGatewayPreloadSelfHealLines } from "./runtime-recovery-preload"; /** * Resolve the agent for a sandbox. Checks the per-sandbox registry first @@ -133,100 +134,6 @@ function buildGatewayLogSetup(includeAutoPairLog = false, logOwnerUser?: string) return lines; } -// Trust-boundary preload modules. The container image ships the immutable -// originals under /usr/local/lib/nemoclaw/preloads/; the sandbox entrypoint -// stages working copies into /tmp via emit_sandbox_sourced_file() with mode -// 444 (and root:root when launched as root) so the sandbox user can source -// them via NODE_OPTIONS but cannot tamper with the bytes that the Node -// process will load. -const GATEWAY_PRELOAD_GUARDS: ReadonlyArray<{ - tmpPath: string; - sourcePath: string; -}> = [ - { - tmpPath: "/tmp/nemoclaw-sandbox-safety-net.js", - sourcePath: "/usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js", - }, - { - tmpPath: "/tmp/nemoclaw-ciao-network-guard.js", - sourcePath: "/usr/local/lib/nemoclaw/preloads/ciao-network-guard.js", - }, -]; - -// Self-heal NODE_OPTIONS at recovery time for sandboxes that were onboarded -// by an older entrypoint which did not emit the --require preload lines into -// /tmp/nemoclaw-proxy-env.sh. The whole block is gated on _PE_MISSING="0" -// (proxy-env was sourced successfully) so that the legacy "proxy-env missing -// → launching without library guards" warning still reflects reality. -// -// Provenance is enforced before any path joins NODE_OPTIONS — the staged -// /tmp/.js must be a regular non-symlink with mode 444 (and root- -// owned when recovery runs as uid 0), matching what emit_sandbox_sourced_file -// writes in scripts/lib/sandbox-init.sh. If the staged copy is missing the -// installer recreates it from the immutable /usr/local/lib/nemoclaw/preloads/ -// source using the same atomic-stage-and-rename pattern as the entrypoint. -// -// Every failure mode (symlink, wrong mode, wrong owner, source absent, copy -// failed) skips the entry — it never grafts an untrusted file into -// NODE_OPTIONS. The trailing "refusing unguarded gateway relaunch" invariant -// at the end of the script still fires when provenance gates anything off. -function buildGatewayPreloadSelfHealLines(): string[] { - const installer = [ - "_nemoclaw_install_recovery_preload() {", - 'local tmp="$1"; local src="$2"; local dir base stage perms owner _msg;', - 'if [ ! -e "$tmp" ]; then', - 'if [ ! -r "$src" ]; then', - '_msg="[gateway-recovery] WARNING: $src missing - cannot self-heal $tmp";', - 'echo "$_msg" >&2; [ -n "${_GATEWAY_LOG:-}" ] && echo "$_msg" >> "$_GATEWAY_LOG" 2>/dev/null;', - "return 1;", - "fi;", - 'dir="$(dirname -- "$tmp")"; base="$(basename -- "$tmp")";', - 'stage="$(mktemp -- "${dir}/.${base}.tmp.XXXXXX")" || return 1;', - 'if ! cp -- "$src" "$stage"; then rm -f -- "$stage"; return 1; fi;', - 'if [ "$(id -u)" -eq 0 ] && ! chown root:root "$stage"; then rm -f -- "$stage"; return 1; fi;', - 'if ! chmod 444 "$stage"; then rm -f -- "$stage"; return 1; fi;', - 'if ! mv -f -- "$stage" "$tmp"; then rm -f -- "$stage"; return 1; fi;', - "fi;", - 'if [ -L "$tmp" ]; then', - '_msg="[gateway-recovery] ERROR: $tmp is a symlink - refusing preload install";', - 'echo "$_msg" >&2; [ -n "${_GATEWAY_LOG:-}" ] && echo "$_msg" >> "$_GATEWAY_LOG" 2>/dev/null;', - "return 1;", - "fi;", - 'if [ ! -f "$tmp" ]; then', - '_msg="[gateway-recovery] ERROR: $tmp is not a regular file - refusing preload install";', - 'echo "$_msg" >&2; [ -n "${_GATEWAY_LOG:-}" ] && echo "$_msg" >> "$_GATEWAY_LOG" 2>/dev/null;', - "return 1;", - "fi;", - 'perms="$(stat -c %a -- "$tmp" 2>/dev/null || stat -f %Lp -- "$tmp" 2>/dev/null || echo unknown)";', - 'if [ "$perms" != "444" ]; then', - '_msg="[gateway-recovery] ERROR: $tmp has unsafe mode=$perms (expected 444) - refusing preload install";', - 'echo "$_msg" >&2; [ -n "${_GATEWAY_LOG:-}" ] && echo "$_msg" >> "$_GATEWAY_LOG" 2>/dev/null;', - "return 1;", - "fi;", - 'if [ "$(id -u)" -eq 0 ]; then', - 'owner="$(stat -c %U -- "$tmp" 2>/dev/null || stat -f %Su -- "$tmp" 2>/dev/null || echo unknown)";', - 'if [ "$owner" != "root" ]; then', - '_msg="[gateway-recovery] ERROR: $tmp owner=$owner (expected root) - refusing preload install";', - 'echo "$_msg" >&2; [ -n "${_GATEWAY_LOG:-}" ] && echo "$_msg" >> "$_GATEWAY_LOG" 2>/dev/null;', - "return 1;", - "fi;", - "fi;", - 'case "${NODE_OPTIONS:-}" in', - '*"--require $tmp"*) ;;', - '*) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $tmp" ;;', - "esac;", - "return 0;", - "};", - ].join(" "); - - const calls = GATEWAY_PRELOAD_GUARDS.map( - ({ tmpPath, sourcePath }) => - `_nemoclaw_install_recovery_preload ${tmpPath} ${sourcePath} || true;`, - ); - - return ['if [ "$_PE_MISSING" = "0" ]; then', installer, ...calls, "fi;"]; -} - function buildGatewayLogSelection(): string { return '_GATEWAY_LOG=/tmp/gateway.log; if ! : >> "$_GATEWAY_LOG" 2>/dev/null; then _GATEWAY_LOG=/tmp/gateway-recovery.log; : >> "$_GATEWAY_LOG" 2>/dev/null || true; fi;'; } From 00fdcaa2b37cd404c73b4b70b814f037dfb91127 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 12 Jun 2026 04:56:21 +0000 Subject: [PATCH 5/7] refactor(sandbox): hoist _PE_MISSING gate to recovery splice sites Signed-off-by: Tinson Lai --- src/lib/agent/runtime-recovery-preload.ts | 15 ++++++++------- src/lib/agent/runtime.ts | 4 ++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/lib/agent/runtime-recovery-preload.ts b/src/lib/agent/runtime-recovery-preload.ts index 852e0f2a89..ba2b7ff152 100644 --- a/src/lib/agent/runtime-recovery-preload.ts +++ b/src/lib/agent/runtime-recovery-preload.ts @@ -27,11 +27,11 @@ export const GATEWAY_PRELOAD_GUARDS: ReadonlyArray<{ }, ]; -// Self-heal NODE_OPTIONS at recovery time for sandboxes that were onboarded -// by an older entrypoint which did not emit the --require preload lines into -// /tmp/nemoclaw-proxy-env.sh. The whole block is gated on _PE_MISSING="0" -// (proxy-env was sourced successfully) so that the legacy "proxy-env missing -// → launching without library guards" warning still reflects reality. +// Build the installer-and-call lines for the recovery self-heal. Callers MUST +// wrap the returned lines in `if [ "$_PE_MISSING" = "0" ]; then … fi;` so the +// installer only runs when /tmp/nemoclaw-proxy-env.sh was sourced cleanly — +// otherwise the existing "missing - launching without library guards" warning +// would no longer reflect reality. // // Provenance is enforced before any path joins NODE_OPTIONS — the staged // /tmp/.js must be a regular non-symlink with mode 444 (and root- @@ -43,7 +43,8 @@ export const GATEWAY_PRELOAD_GUARDS: ReadonlyArray<{ // Every failure mode (symlink, wrong mode, wrong owner, source absent, copy // failed) skips the entry — it never grafts an untrusted file into // NODE_OPTIONS. The trailing "refusing unguarded gateway relaunch" invariant -// at the end of the script still fires when provenance gates anything off. +// at the end of the recovery script still fires when provenance gates +// anything off. export function buildGatewayPreloadSelfHealLines(): string[] { const installer = [ "_nemoclaw_install_recovery_preload() {", @@ -98,5 +99,5 @@ export function buildGatewayPreloadSelfHealLines(): string[] { `_nemoclaw_install_recovery_preload ${tmpPath} ${sourcePath} || true;`, ); - return ['if [ "$_PE_MISSING" = "0" ]; then', installer, ...calls, "fi;"]; + return [installer, ...calls]; } diff --git a/src/lib/agent/runtime.ts b/src/lib/agent/runtime.ts index 552518cd88..d92a0113d5 100644 --- a/src/lib/agent/runtime.ts +++ b/src/lib/agent/runtime.ts @@ -196,7 +196,9 @@ export function buildOpenClawRecoveryScript(port: number): string { return [ "if [ -r /tmp/nemoclaw-proxy-env.sh ]; then . /tmp/nemoclaw-proxy-env.sh; _PE_MISSING=0; else _PE_MISSING=1; fi;", "[ -f ~/.bashrc ] && . ~/.bashrc;", + 'if [ "$_PE_MISSING" = "0" ]; then', ...buildGatewayPreloadSelfHealLines(), + "fi;", 'if [ "$_PE_MISSING" = "0" ]; then case "${NODE_OPTIONS:-}" in *nemoclaw-sandbox-safety-net*) _SN_MISSING=0 ;; *) _SN_MISSING=1 ;; esac; case "${NODE_OPTIONS:-}" in *nemoclaw-ciao-network-guard*) _CIAO_MISSING=0 ;; *) _CIAO_MISSING=1 ;; esac; if [ "$_SN_MISSING" = "0" ] && [ "$_CIAO_MISSING" = "0" ]; then _GUARDS_MISSING=0; else _GUARDS_MISSING=1; fi; else _GUARDS_MISSING=0; fi;', `_GW_CODE=$(curl -so /dev/null -w '%{http_code}' --max-time 3 http://127.0.0.1:${port}/health 2>/dev/null || echo 000); case "$_GW_CODE" in 200|401) echo ALREADY_RUNNING; exit 0 ;; esac;`, "rm -rf /tmp/openclaw-*/gateway.*.lock 2>/dev/null;", @@ -277,7 +279,9 @@ export function buildRecoveryScript( 'if [ -n "$_GATEWAY_PROC_PATTERN" ]; then pkill -TERM -f "$_GATEWAY_PROC_PATTERN" 2>/dev/null || true; for _i in 1 2 3 4 5; do pgrep -f "$_GATEWAY_PROC_PATTERN" >/dev/null 2>&1 || break; sleep 1; done; pkill -KILL -f "$_GATEWAY_PROC_PATTERN" 2>/dev/null || true; for _i in 1 2 3 4 5; do pgrep -f "$_GATEWAY_PROC_PATTERN" >/dev/null 2>&1 || break; sleep 1; done; if pgrep -f "$_GATEWAY_PROC_PATTERN" >/dev/null 2>&1; then echo GATEWAY_STALE_PROCESSES; exit 1; fi; fi;', ...validationSteps, "if [ -r /tmp/nemoclaw-proxy-env.sh ]; then . /tmp/nemoclaw-proxy-env.sh; _PE_MISSING=0; else _PE_MISSING=1; fi;", + 'if [ "$_PE_MISSING" = "0" ]; then', ...buildGatewayPreloadSelfHealLines(), + "fi;", 'if [ "$_PE_MISSING" = "0" ]; then case "${NODE_OPTIONS:-}" in *nemoclaw-sandbox-safety-net*) _SN_MISSING=0 ;; *) _SN_MISSING=1 ;; esac; case "${NODE_OPTIONS:-}" in *nemoclaw-ciao-network-guard*) _CIAO_MISSING=0 ;; *) _CIAO_MISSING=1 ;; esac; if [ "$_SN_MISSING" = "0" ] && [ "$_CIAO_MISSING" = "0" ]; then _GUARDS_MISSING=0; else _GUARDS_MISSING=1; fi; else _GUARDS_MISSING=0; fi;', '[ "$_PE_MISSING" = "1" ] && { _W="[gateway-recovery] WARNING: /tmp/nemoclaw-proxy-env.sh missing - gateway launching without library guards (#2478)"; echo "$_W" >&2; echo "$_W" >> "$_GATEWAY_LOG"; };', '[ "$_PE_MISSING" = "0" ] && [ "$_GUARDS_MISSING" = "1" ] && { _E="[gateway-recovery] ERROR: /tmp/nemoclaw-proxy-env.sh present but NODE_OPTIONS missing safety-net preload or ciao preload - refusing unguarded gateway relaunch (#2478)"; echo "$_E" >&2; echo "$_E" >> "$_GATEWAY_LOG"; exit 1; };', From 4e2e94cc7186520422b85d22d20e849f490e6d0e Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 12 Jun 2026 05:14:24 +0000 Subject: [PATCH 6/7] fix(sandbox): pin recovery guard check to trusted --require path Signed-off-by: Tinson Lai --- .../agent/runtime-recovery-preload.test.ts | 22 +++++++++++++++++++ src/lib/agent/runtime.ts | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/lib/agent/runtime-recovery-preload.test.ts b/src/lib/agent/runtime-recovery-preload.test.ts index e094cb4e26..c7e1811eb8 100644 --- a/src/lib/agent/runtime-recovery-preload.test.ts +++ b/src/lib/agent/runtime-recovery-preload.test.ts @@ -169,6 +169,28 @@ describe("gateway recovery preload self-heal (#5253)", () => { expect(script).toContain("has unsafe mode="); expect(script).toContain("owner=$owner (expected root)"); }); + + it("final guard check matches the trusted --require path, not just the marker substring", () => { + const script = buildRecoveryScript(minimalAgent, 19000); + expect(script).toContain( + '*"--require /tmp/nemoclaw-sandbox-safety-net.js"*) _SN_MISSING=0 ;;', + ); + expect(script).toContain( + '*"--require /tmp/nemoclaw-ciao-network-guard.js"*) _CIAO_MISSING=0 ;;', + ); + expect(script).not.toContain("*nemoclaw-sandbox-safety-net*) _SN_MISSING=0 ;;"); + expect(script).not.toContain("*nemoclaw-ciao-network-guard*) _CIAO_MISSING=0 ;;"); + }); + + it("OpenClaw recovery also pins the guard check to the trusted --require path", () => { + const script = buildOpenClawRecoveryScript(18789); + expect(script).toContain( + '*"--require /tmp/nemoclaw-sandbox-safety-net.js"*) _SN_MISSING=0 ;;', + ); + expect(script).toContain( + '*"--require /tmp/nemoclaw-ciao-network-guard.js"*) _CIAO_MISSING=0 ;;', + ); + }); }); describe("behavioural — install from trusted source", () => { diff --git a/src/lib/agent/runtime.ts b/src/lib/agent/runtime.ts index d92a0113d5..823a478e45 100644 --- a/src/lib/agent/runtime.ts +++ b/src/lib/agent/runtime.ts @@ -199,7 +199,7 @@ export function buildOpenClawRecoveryScript(port: number): string { 'if [ "$_PE_MISSING" = "0" ]; then', ...buildGatewayPreloadSelfHealLines(), "fi;", - 'if [ "$_PE_MISSING" = "0" ]; then case "${NODE_OPTIONS:-}" in *nemoclaw-sandbox-safety-net*) _SN_MISSING=0 ;; *) _SN_MISSING=1 ;; esac; case "${NODE_OPTIONS:-}" in *nemoclaw-ciao-network-guard*) _CIAO_MISSING=0 ;; *) _CIAO_MISSING=1 ;; esac; if [ "$_SN_MISSING" = "0" ] && [ "$_CIAO_MISSING" = "0" ]; then _GUARDS_MISSING=0; else _GUARDS_MISSING=1; fi; else _GUARDS_MISSING=0; fi;', + 'if [ "$_PE_MISSING" = "0" ]; then case "${NODE_OPTIONS:-}" in *"--require /tmp/nemoclaw-sandbox-safety-net.js"*) _SN_MISSING=0 ;; *) _SN_MISSING=1 ;; esac; case "${NODE_OPTIONS:-}" in *"--require /tmp/nemoclaw-ciao-network-guard.js"*) _CIAO_MISSING=0 ;; *) _CIAO_MISSING=1 ;; esac; if [ "$_SN_MISSING" = "0" ] && [ "$_CIAO_MISSING" = "0" ]; then _GUARDS_MISSING=0; else _GUARDS_MISSING=1; fi; else _GUARDS_MISSING=0; fi;', `_GW_CODE=$(curl -so /dev/null -w '%{http_code}' --max-time 3 http://127.0.0.1:${port}/health 2>/dev/null || echo 000); case "$_GW_CODE" in 200|401) echo ALREADY_RUNNING; exit 0 ;; esac;`, "rm -rf /tmp/openclaw-*/gateway.*.lock 2>/dev/null;", ...buildGatewayLogSetup(true, "gateway"), @@ -282,7 +282,7 @@ export function buildRecoveryScript( 'if [ "$_PE_MISSING" = "0" ]; then', ...buildGatewayPreloadSelfHealLines(), "fi;", - 'if [ "$_PE_MISSING" = "0" ]; then case "${NODE_OPTIONS:-}" in *nemoclaw-sandbox-safety-net*) _SN_MISSING=0 ;; *) _SN_MISSING=1 ;; esac; case "${NODE_OPTIONS:-}" in *nemoclaw-ciao-network-guard*) _CIAO_MISSING=0 ;; *) _CIAO_MISSING=1 ;; esac; if [ "$_SN_MISSING" = "0" ] && [ "$_CIAO_MISSING" = "0" ]; then _GUARDS_MISSING=0; else _GUARDS_MISSING=1; fi; else _GUARDS_MISSING=0; fi;', + 'if [ "$_PE_MISSING" = "0" ]; then case "${NODE_OPTIONS:-}" in *"--require /tmp/nemoclaw-sandbox-safety-net.js"*) _SN_MISSING=0 ;; *) _SN_MISSING=1 ;; esac; case "${NODE_OPTIONS:-}" in *"--require /tmp/nemoclaw-ciao-network-guard.js"*) _CIAO_MISSING=0 ;; *) _CIAO_MISSING=1 ;; esac; if [ "$_SN_MISSING" = "0" ] && [ "$_CIAO_MISSING" = "0" ]; then _GUARDS_MISSING=0; else _GUARDS_MISSING=1; fi; else _GUARDS_MISSING=0; fi;', '[ "$_PE_MISSING" = "1" ] && { _W="[gateway-recovery] WARNING: /tmp/nemoclaw-proxy-env.sh missing - gateway launching without library guards (#2478)"; echo "$_W" >&2; echo "$_W" >> "$_GATEWAY_LOG"; };', '[ "$_PE_MISSING" = "0" ] && [ "$_GUARDS_MISSING" = "1" ] && { _E="[gateway-recovery] ERROR: /tmp/nemoclaw-proxy-env.sh present but NODE_OPTIONS missing safety-net preload or ciao preload - refusing unguarded gateway relaunch (#2478)"; echo "$_E" >&2; echo "$_E" >> "$_GATEWAY_LOG"; exit 1; };', ...(isHermes ? [buildHermesRuntimeEnvBoundaryGuard()] : []), From b362700847e16a0753caf4362b8f9dd5ea8d0e9e Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 12 Jun 2026 05:43:12 +0000 Subject: [PATCH 7/7] test(sandbox): align hermes secret-boundary fixtures with exact --require guard Signed-off-by: Tinson Lai --- .../agent/runtime-hermes-secret-boundary-behavioural.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/agent/runtime-hermes-secret-boundary-behavioural.test.ts b/src/lib/agent/runtime-hermes-secret-boundary-behavioural.test.ts index 7947b9a81b..14bba8abaf 100644 --- a/src/lib/agent/runtime-hermes-secret-boundary-behavioural.test.ts +++ b/src/lib/agent/runtime-hermes-secret-boundary-behavioural.test.ts @@ -382,7 +382,7 @@ describe("Hermes secret-boundary guard — full recovery script behaviour", () = const proxyEnvFile = path.join(harness.tmp, "nemoclaw-proxy-env.sh"); fs.writeFileSync( proxyEnvFile, - "export NODE_OPTIONS='--require=nemoclaw-sandbox-safety-net --require=nemoclaw-ciao-network-guard'\n", + "export NODE_OPTIONS='--require /tmp/nemoclaw-sandbox-safety-net.js --require /tmp/nemoclaw-ciao-network-guard.js'\n", ); writeStub(harness.stubsDir, "python3", `${SHARED_PYTHON_STUB_BY_MODE}\n`); stubBaselineUtilities(harness.stubsDir, harness.pkillLog, harness.hermesLaunchMarker); @@ -457,7 +457,7 @@ describe("Hermes secret-boundary guard — full recovery script behaviour", () = fs.writeFileSync( proxyEnvFile, [ - "export NODE_OPTIONS='--require=nemoclaw-sandbox-safety-net --require=nemoclaw-ciao-network-guard'", + "export NODE_OPTIONS='--require /tmp/nemoclaw-sandbox-safety-net.js --require /tmp/nemoclaw-ciao-network-guard.js'", "export TELEGRAM_BOT_TOKEN=1234567890:AAExample-RawSecretValueHere", "", ].join("\n"),