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"), 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..c7e1811eb8 --- /dev/null +++ b/src/lib/agent/runtime-recovery-preload.test.ts @@ -0,0 +1,349 @@ +// 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)"); + }); + + 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", () => { + 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-recovery-preload.ts b/src/lib/agent/runtime-recovery-preload.ts new file mode 100644 index 0000000000..ba2b7ff152 --- /dev/null +++ b/src/lib/agent/runtime-recovery-preload.ts @@ -0,0 +1,103 @@ +// 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", + }, +]; + +// 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- +// 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 recovery 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 [installer, ...calls]; +} diff --git a/src/lib/agent/runtime.ts b/src/lib/agent/runtime.ts index ef79716e78..823a478e45 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 @@ -195,7 +196,10 @@ 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 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', + ...buildGatewayPreloadSelfHealLines(), + "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"), @@ -275,7 +279,10 @@ 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 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', + ...buildGatewayPreloadSelfHealLines(), + "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()] : []),