diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index f4a94d099e0..7d821bf9cbf 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -8,7 +8,7 @@ "test/channels-add-preset.test.ts": 1871, "test/generate-openclaw-config.test.ts": 1989, "test/install-preflight.test.ts": 4207, - "test/nemoclaw-start.test.ts": 5162, + "test/nemoclaw-start.test.ts": 5160, "test/onboard-messaging.test.ts": 2062, "test/onboard-selection.test.ts": 6888, "test/onboard.test.ts": 4774, diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 34459d84658..5412e2cda4f 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -61,20 +61,51 @@ unset _EARLY_DASHBOARD_PORT_RAW _EARLY_DASHBOARD_PORT _EARLY_DASHBOARD_PORT_VALI # ── Early stderr/stdout capture ────────────────────────────────── # Capture all entrypoint output to /tmp/nemoclaw-start.log so that if -# the script crashes before touch /tmp/gateway.log (e.g., a Landlock +# the script crashes before gateway log setup (e.g., a Landlock # read failure), the output is still available for diagnostics. # The log is written in append mode and also forwarded to the original # stderr/stdout via tee so openshell sandbox create can still stream it. # SECURITY: restrict permissions before writing — startup diagnostics may # include dashboard URLs, but auth tokens must stay redacted in logs. +_nemoclaw_safe_replace_tmp_file() { + local target="$1" + local mode="$2" + local owner="${3:-}" + local chmod_policy="${4:-required}" + local dir base tmp + dir="$(dirname "$target")" + base="$(basename "$target")" + tmp="$(mktemp "${dir}/.${base}.tmp.XXXXXX")" || return 1 + + if ! cat >"$tmp"; then + rm -f "$tmp" 2>/dev/null || true + return 1 + fi + if [ -n "$owner" ] && ! chown "$owner" "$tmp"; then + rm -f "$tmp" 2>/dev/null || true + return 1 + fi + if [ "$chmod_policy" = "best-effort" ]; then + chmod "$mode" "$tmp" 2>/dev/null || true + elif ! chmod "$mode" "$tmp"; then + rm -f "$tmp" 2>/dev/null || true + return 1 + fi + if ! mv -f "$tmp" "$target"; then + rm -f "$tmp" 2>/dev/null || true + return 1 + fi +} + +_nemoclaw_safe_create_tmp_file() { + _nemoclaw_safe_replace_tmp_file "$@" "$_START_LOG" - chown root:root "$_START_LOG" - chmod 600 "$_START_LOG" + _nemoclaw_safe_create_tmp_file "$_START_LOG" 600 root:root else - : >"$_START_LOG" - chmod 600 "$_START_LOG" 2>/dev/null || true + _nemoclaw_safe_create_tmp_file "$_START_LOG" 600 "" best-effort fi exec 3>&1 exec 4>&2 @@ -215,7 +246,7 @@ NEMOCLAW_CMD=("$@") # before their `openclaw gateway run` invocation. # Best-effort: a write failure must never block startup. mark_in_container_gateway() { - : >/tmp/nemoclaw-gateway-local 2>/dev/null || true + _nemoclaw_safe_create_tmp_file /tmp/nemoclaw-gateway-local 600 "" best-effort 2>/dev/null || true } # Record the PID of the live in-container gateway so the Docker HEALTHCHECK @@ -225,7 +256,7 @@ mark_in_container_gateway() { # is tracked and a window where the gateway is down reads as unhealthy. # Best-effort: a write failure must never block startup. record_gateway_pid() { - printf '%s\n' "${1:-}" >/tmp/nemoclaw-gateway.pid 2>/dev/null || true + printf '%s\n' "${1:-}" | _nemoclaw_safe_replace_tmp_file /tmp/nemoclaw-gateway.pid 600 "" best-effort 2>/dev/null || true } _chat_ui_url_port() { @@ -3754,14 +3785,10 @@ if [ "$(id -u)" -ne 0 ]; then # In non-root mode, detach gateway stdout/stderr from the sandbox-create # stream so openshell sandbox create can return once the container is ready. - # TODO(#2277-P2): migrate to shared emit_restricted_log() helper - touch /tmp/gateway.log - chmod 644 /tmp/gateway.log + _nemoclaw_safe_create_tmp_file /tmp/gateway.log 644 # Separate log for auto-pair in non-root mode as well. - # TODO(#2277-P2): migrate to shared emit_restricted_log() helper - touch /tmp/auto-pair.log - chmod 600 /tmp/auto-pair.log + _nemoclaw_safe_create_tmp_file /tmp/auto-pair.log 600 prepare_plugin_refresh_log || exit 1 @@ -3890,16 +3917,10 @@ fi # Gateway log: owned by gateway user, world-readable for diagnostics. # The sandbox user can read but not truncate/overwrite (not owner, sticky /tmp). -# TODO(#2277-P2): migrate to shared emit_restricted_log() helper -touch /tmp/gateway.log -chown gateway:gateway /tmp/gateway.log -chmod 644 /tmp/gateway.log +_nemoclaw_safe_create_tmp_file /tmp/gateway.log 644 gateway:gateway # Separate log for auto-pair so sandbox user can write to it -# TODO(#2277-P2): migrate to shared emit_restricted_log() helper -touch /tmp/auto-pair.log -chown sandbox:sandbox /tmp/auto-pair.log -chmod 600 /tmp/auto-pair.log +_nemoclaw_safe_create_tmp_file /tmp/auto-pair.log 600 sandbox:sandbox prepare_plugin_refresh_log || exit 1 diff --git a/src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts b/src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts new file mode 100644 index 00000000000..0f22a8a9126 --- /dev/null +++ b/src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts @@ -0,0 +1,70 @@ +// 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 { beforeEach, describe, expect, it, vi } from "vitest"; + +const captureSandboxSshConfig = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawnSync: vi.fn() }; +}); + +vi.mock("../../adapters/openshell/runtime", () => ({ + captureOpenshell: vi.fn(), + captureOpenshellForStatus: vi.fn(), + captureSandboxSshConfig, + getOpenshellBinary: vi.fn(() => "openshell"), + isCommandTimeout: vi.fn(() => false), + runOpenshell: vi.fn(), +})); + +vi.mock("../../runner", () => ({ + ROOT: "/repo", + shellQuote: (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`, +})); + +import { executeSandboxCommand } from "./process-recovery"; + +describe("executeSandboxCommand temp SSH config", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses an mkdtemp-backed SSH config file and removes the temp directory", () => { + captureSandboxSshConfig.mockReturnValue({ + status: 0, + output: "Host openshell-alpha\n HostName 127.0.0.1\n", + }); + vi.mocked(spawnSync).mockReturnValue({ + status: 0, + stdout: "ok\n", + stderr: "", + pid: 1234, + output: [], + signal: null, + }); + + const result = executeSandboxCommand("alpha", "echo ok"); + + expect(result).toEqual({ status: 0, stdout: "ok", stderr: "" }); + const sshArgs = vi.mocked(spawnSync).mock.calls[0]?.[1] as string[]; + const configFile = sshArgs[sshArgs.indexOf("-F") + 1]; + const configDir = path.dirname(configFile); + expect(configDir).not.toBe(os.tmpdir()); + expect(path.basename(configDir)).toMatch(/^nemoclaw-ssh-/); + expect(path.basename(configFile)).toBe("ssh_config"); + expect(fs.existsSync(configDir)).toBe(false); + }); + + it("returns null without creating an SSH process when config capture fails", () => { + captureSandboxSshConfig.mockReturnValue({ status: 1, output: "" }); + + expect(executeSandboxCommand("alpha", "echo ok")).toBeNull(); + expect(spawnSync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/actions/sandbox/process-recovery.ts b/src/lib/actions/sandbox/process-recovery.ts index 907cce37c0b..871daee87a0 100644 --- a/src/lib/actions/sandbox/process-recovery.ts +++ b/src/lib/actions/sandbox/process-recovery.ts @@ -2,9 +2,6 @@ // 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 { captureOpenshell, captureOpenshellForStatus, @@ -19,6 +16,7 @@ import { G, R } from "../../cli/terminal-style"; import { DASHBOARD_PORT } from "../../core/ports"; import { sleepSeconds, waitUntil } from "../../core/wait"; import { ROOT, shellQuote } from "../../runner"; +import { createTempSshConfig } from "../../sandbox/temp-ssh-config"; import * as registry from "../../state/registry"; import { parseForwardList } from "../../state/sandbox-session"; import { classifyForwardHealthWithReachability, isLocalForwardReachable } from "./forward-health"; @@ -97,14 +95,13 @@ export function executeSandboxCommand( if (sshConfigResult.status !== 0) return null; if (!sshConfigResult.output.trim()) return null; - const tmpFile = path.join(os.tmpdir(), `nemoclaw-ssh-${process.pid}-${Date.now()}.conf`); - fs.writeFileSync(tmpFile, sshConfigResult.output, { mode: 0o600 }); + const tmpSshConfig = createTempSshConfig(sshConfigResult.output, "nemoclaw-ssh-"); try { const result = spawnSync( "ssh", [ "-F", - tmpFile, + tmpSshConfig.file, "-o", "StrictHostKeyChecking=no", "-o", @@ -126,11 +123,7 @@ export function executeSandboxCommand( } catch { return null; } finally { - try { - fs.unlinkSync(tmpFile); - } catch { - /* ignore */ - } + tmpSshConfig.cleanup(); } } diff --git a/src/lib/actions/sandbox/skill-install.test.ts b/src/lib/actions/sandbox/skill-install.test.ts index 7c9c3cb7e24..d3c83a862f5 100644 --- a/src/lib/actions/sandbox/skill-install.test.ts +++ b/src/lib/actions/sandbox/skill-install.test.ts @@ -57,6 +57,14 @@ function restoreExitCode(previousExitCode: typeof process.exitCode): void { process.exitCode = previousExitCode; } +function expectTempSshConfigCleanedUp(configFile: string): void { + const configDir = path.dirname(configFile); + expect(configDir).not.toBe(os.tmpdir()); + expect(path.basename(configDir)).toMatch(/^nemoclaw-ssh-skill-/); + expect(path.basename(configFile)).toBe("ssh_config"); + expect(fs.existsSync(configDir)).toBe(false); +} + describe("sandbox skill action orchestration", () => { let previousExitCode: typeof process.exitCode; @@ -181,6 +189,7 @@ describe("sandbox skill action orchestration", () => { ); expect(log).toHaveBeenCalledWith(expect.stringContaining("Skill 'demo-skill' removed")); expect(fs.existsSync(tempConfig)).toBe(false); + expectTempSshConfigCleanedUp(tempConfig); expect(process.exitCode).toBeUndefined(); }); @@ -216,6 +225,7 @@ describe("sandbox skill action orchestration", () => { ); expect(log).toHaveBeenCalledWith(expect.stringContaining("Skill 'demo-skill' installed")); expect(fs.existsSync(tempConfig)).toBe(false); + expectTempSshConfigCleanedUp(tempConfig); expect(process.exitCode).toBeUndefined(); }); }); diff --git a/src/lib/actions/sandbox/skill-install.ts b/src/lib/actions/sandbox/skill-install.ts index c570ff785eb..3eb9d547257 100644 --- a/src/lib/actions/sandbox/skill-install.ts +++ b/src/lib/actions/sandbox/skill-install.ts @@ -2,13 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { captureSandboxSshConfig } from "../../adapters/openshell/runtime"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; import * as agentRuntime from "../../agent/runtime"; import { CLI_NAME } from "../../cli/branding"; import { D, G, R, YW } from "../../cli/terminal-style"; +import { createTempSshConfig } from "../../sandbox/temp-ssh-config"; import * as skillInstall from "../../skill-install"; import { ensureLiveSandboxOrExit } from "./gateway-state"; @@ -123,14 +123,10 @@ export async function removeSandboxSkill( process.exit(1); } - const tmpSshConfig = path.join( - os.tmpdir(), - `nemoclaw-ssh-skill-${process.pid}-${Date.now()}.conf`, - ); - fs.writeFileSync(tmpSshConfig, sshConfigResult.output, { mode: 0o600 }); + const tmpSshConfig = createTempSshConfig(sshConfigResult.output, "nemoclaw-ssh-skill-"); try { - const ctx = { configFile: tmpSshConfig, sandboxName }; + const ctx = { configFile: tmpSshConfig.file, sandboxName }; const existsCheck = skillInstall.checkExisting(ctx, paths); if (existsCheck === null) { @@ -165,11 +161,7 @@ export async function removeSandboxSkill( return; } } finally { - try { - fs.unlinkSync(tmpSshConfig); - } catch { - /* ignore */ - } + tmpSshConfig.cleanup(); } } @@ -291,14 +283,10 @@ export async function installSandboxSkill( process.exit(1); } - const tmpSshConfig = path.join( - os.tmpdir(), - `nemoclaw-ssh-skill-${process.pid}-${Date.now()}.conf`, - ); - fs.writeFileSync(tmpSshConfig, sshConfigResult.output, { mode: 0o600 }); + const tmpSshConfig = createTempSshConfig(sshConfigResult.output, "nemoclaw-ssh-skill-"); try { - const ctx = { configFile: tmpSshConfig, sandboxName }; + const ctx = { configFile: tmpSshConfig.file, sandboxName }; // 5. Check if skill already exists (update vs fresh install). This probe is // advisory for install only: stale SSH config files and transient remote @@ -349,10 +337,6 @@ export async function installSandboxSkill( process.exit(1); } } finally { - try { - fs.unlinkSync(tmpSshConfig); - } catch { - /* ignore */ - } + tmpSshConfig.cleanup(); } } diff --git a/src/lib/sandbox/temp-ssh-config.test.ts b/src/lib/sandbox/temp-ssh-config.test.ts new file mode 100644 index 00000000000..98c085e6289 --- /dev/null +++ b/src/lib/sandbox/temp-ssh-config.test.ts @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createTempSshConfig } from "./temp-ssh-config.js"; + +describe("createTempSshConfig", () => { + let tmpRoot: string; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-temp-ssh-test-")); + vi.spyOn(os, "tmpdir").mockReturnValue(tmpRoot); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it("writes the SSH config inside a private mkdtemp directory and cleans it up", () => { + const temp = createTempSshConfig("Host openshell-alpha\n", "nemoclaw-ssh-test-"); + const expectedParentPrefix = path.join(tmpRoot, "nemoclaw-ssh-test-"); + + expect(temp.dir).not.toBe(tmpRoot); + expect(temp.dir.startsWith(expectedParentPrefix)).toBe(true); + expect(temp.file).toBe(path.join(temp.dir, "ssh_config")); + expect(fs.readFileSync(temp.file, "utf-8")).toBe("Host openshell-alpha\n"); + expect((fs.statSync(temp.file).mode & 0o777).toString(8)).toBe("600"); + + temp.cleanup(); + + expect(fs.existsSync(temp.dir)).toBe(false); + }); + + it("removes the private directory when writing the SSH config fails", () => { + vi.spyOn(fs, "writeFileSync").mockImplementationOnce(() => { + throw new Error("write failed"); + }); + + expect(() => createTempSshConfig("Host openshell-alpha\n", "nemoclaw-ssh-fail-")).toThrow( + "write failed", + ); + + expect(fs.readdirSync(tmpRoot)).toEqual([]); + }); +}); diff --git a/src/lib/sandbox/temp-ssh-config.ts b/src/lib/sandbox/temp-ssh-config.ts new file mode 100644 index 00000000000..a66942e8594 --- /dev/null +++ b/src/lib/sandbox/temp-ssh-config.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export type TempSshConfig = { + dir: string; + file: string; + cleanup: () => void; +}; + +function removeTempDir(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + /* best effort */ + } +} + +export function createTempSshConfig(contents: string, prefix: string): TempSshConfig { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const file = path.join(dir, "ssh_config"); + try { + fs.writeFileSync(file, contents, { mode: 0o600 }); + } catch (error) { + removeTempDir(dir); + throw error; + } + + return { + dir, + file, + cleanup: () => { + removeTempDir(dir); + }, + }; +} diff --git a/src/lib/sandbox/version.test.ts b/src/lib/sandbox/version.test.ts index 4370b5b89e7..0df468d200b 100644 --- a/src/lib/sandbox/version.test.ts +++ b/src/lib/sandbox/version.test.ts @@ -1,9 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock heavy dependencies that pull in the full module graph @@ -140,6 +140,13 @@ describe("checkAgentVersion", () => { "test-sb", { ignoreError: true, timeout: OPENSHELL_PROBE_TIMEOUT_MS }, ); + const sshArgs = vi.mocked(spawnSync).mock.calls[0]?.[1] as string[]; + const configFile = sshArgs[sshArgs.indexOf("-F") + 1]; + const configDir = dirname(configFile); + expect(configDir).not.toBe(tmpdir()); + expect(basename(configDir)).toMatch(/^nemoclaw-ver-/); + expect(basename(configFile)).toBe("ssh_config"); + expect(existsSync(configDir)).toBe(false); // Should have cached the version in registry const updated = registry.getSandbox("test-sb"); diff --git a/src/lib/sandbox/version.ts b/src/lib/sandbox/version.ts index c5a0aecc022..1b21d4f6679 100644 --- a/src/lib/sandbox/version.ts +++ b/src/lib/sandbox/version.ts @@ -9,9 +9,6 @@ // Slow: SSH exec into sandbox, run version_command, cache result in registry import { spawnSync } from "child_process"; -import fs from "fs"; -import os from "os"; -import path from "path"; import { captureSandboxSshConfigCommand, @@ -22,6 +19,7 @@ import { resolveOpenshell } from "../adapters/openshell/resolve.js"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts.js"; import { loadAgent } from "../agent/defs.js"; import * as registry from "../state/registry.js"; +import { createTempSshConfig } from "./temp-ssh-config.js"; export interface VersionCheckResult { sandboxVersion: string | null; @@ -65,14 +63,13 @@ export function probeAgentVersion(sandboxName: string): string | null { if (sshConfigResult.status !== 0) return null; if (!sshConfigResult.output.trim()) return null; - const tmpFile = path.join(os.tmpdir(), `nemoclaw-ver-${process.pid}-${Date.now()}.conf`); - fs.writeFileSync(tmpFile, sshConfigResult.output, { mode: 0o600 }); + const tmpSshConfig = createTempSshConfig(sshConfigResult.output, "nemoclaw-ver-"); try { const result = spawnSync( "ssh", [ "-F", - tmpFile, + tmpSshConfig.file, "-o", "StrictHostKeyChecking=no", "-o", @@ -91,11 +88,7 @@ export function probeAgentVersion(sandboxName: string): string | null { } catch { return null; } finally { - try { - fs.unlinkSync(tmpFile); - } catch { - /* ignore */ - } + tmpSshConfig.cleanup(); } } diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 47caf4686c1..c64767b8841 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -32,6 +32,7 @@ import type { AgentStateFile } from "../agent/defs.js"; import { loadAgent } from "../agent/defs.js"; import { isRecord, type UnknownRecord } from "../core/json-types.js"; import { shellQuote } from "../runner.js"; +import { createTempSshConfig } from "../sandbox/temp-ssh-config.js"; import { isSensitiveFile, sanitizeConfigFile } from "../security/credential-filter.js"; import { buildOpenClawConfigRestoreInputFromSandbox, @@ -477,12 +478,6 @@ function getSshConfig(sandboxName: string): string | null { return result.output; } -function writeTempSshConfig(sshConfig: string): string { - const tmpFile = path.join(os.tmpdir(), `nemoclaw-state-${process.pid}-${Date.now()}.conf`); - writeFileSync(tmpFile, sshConfig, { mode: 0o600 }); - return tmpFile; -} - function sshArgs(configFile: string, sandboxName: string): string[] { return [ "-F", @@ -1135,7 +1130,8 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = } _log(`SSH config obtained (${sshConfig.length} bytes)`); - const configFile = writeTempSshConfig(sshConfig); + const tempSshConfig = createTempSshConfig(sshConfig, "nemoclaw-state-"); + const configFile = tempSshConfig.file; try { if (stateDirs.length > 0) { // Build tar command that only includes existing directories. @@ -1359,7 +1355,7 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = } } finally { try { - require("node:fs").unlinkSync(configFile); + tempSshConfig.cleanup(); } catch { /* ignore */ } @@ -1464,7 +1460,8 @@ export function restoreSandboxState(sandboxName: string, backupPath: string): Re }; } - const configFile = writeTempSshConfig(sshConfig); + const tempSshConfig = createTempSshConfig(sshConfig, "nemoclaw-state-"); + const configFile = tempSshConfig.file; try { if (localDirs.length > 0) { // Upload via tar pipe @@ -1601,7 +1598,7 @@ export function restoreSandboxState(sandboxName: string, backupPath: string): Re } } finally { try { - require("node:fs").unlinkSync(configFile); + tempSshConfig.cleanup(); } catch { /* ignore */ } diff --git a/test/gateway-pid-recording.test.ts b/test/gateway-pid-recording.test.ts index fbdd3066600..e1ad64bdf03 100644 --- a/test/gateway-pid-recording.test.ts +++ b/test/gateway-pid-recording.test.ts @@ -25,6 +25,15 @@ function extractFunction(src: string, name: string): string { return src.slice(start, end + 2); } +function safeTmpHelpers(src: string): string { + const start = src.indexOf("_nemoclaw_safe_replace_tmp_file() {"); + const end = src.indexOf("_START_LOG=", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Expected safe temp helpers in scripts/nemoclaw-start.sh"); + } + return src.slice(start, end); +} + describe("nemoclaw-start gateway PID recording for HEALTHCHECK (#4952)", () => { it("record_gateway_pid writes the gateway PID to the file the HEALTHCHECK reads", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); @@ -35,12 +44,18 @@ describe("nemoclaw-start gateway PID recording for HEALTHCHECK (#4952)", () => { "/tmp/nemoclaw-gateway.pid", pidPath, ); - const script = ["set -euo pipefail", fn, 'record_gateway_pid "12345"'].join("\n"); + const script = [ + "set -euo pipefail", + safeTmpHelpers(src), + fn, + 'record_gateway_pid "12345"', + ].join("\n"); const result = spawnSync("bash", ["-c", script], { encoding: "utf-8", timeout: 5000 }); expect(result.status).toBe(0); expect(fs.readFileSync(pidPath, "utf-8").trim()).toBe("12345"); + expect((fs.statSync(pidPath).mode & 0o777).toString(8)).toBe("600"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -54,7 +69,12 @@ describe("nemoclaw-start gateway PID recording for HEALTHCHECK (#4952)", () => { "/tmp/nemoclaw-gateway.pid", "/nonexistent-dir/nemoclaw-gateway.pid", ); - const script = ["set -euo pipefail", fn, 'record_gateway_pid "12345"'].join("\n"); + const script = [ + "set -euo pipefail", + safeTmpHelpers(src), + fn, + 'record_gateway_pid "12345"', + ].join("\n"); const result = spawnSync("bash", ["-c", script], { encoding: "utf-8", timeout: 5000 }); diff --git a/test/nemoclaw-start-gateway-marker.test.ts b/test/nemoclaw-start-gateway-marker.test.ts index 968f2635f06..2540a5c7f2c 100644 --- a/test/nemoclaw-start-gateway-marker.test.ts +++ b/test/nemoclaw-start-gateway-marker.test.ts @@ -43,6 +43,15 @@ function extractShellFunctionFromSource(src, name) { throw new Error(`Expected closing brace for ${name} in scripts/nemoclaw-start.sh`); } +function safeTmpHelpers(src: string): string { + const start = src.indexOf("_nemoclaw_safe_replace_tmp_file() {"); + const end = src.indexOf("_START_LOG=", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Expected safe temp helpers in scripts/nemoclaw-start.sh"); + } + return src.slice(start, end); +} + describe("nemoclaw-start in-container gateway healthcheck marker (#4503, #4710)", () => { // #4503/#4710: the Docker HEALTHCHECK reports healthy on curl-exit-7 only // when the /tmp/nemoclaw-gateway-local marker is ABSENT (gateway delivered @@ -93,6 +102,7 @@ describe("nemoclaw-start in-container gateway healthcheck marker (#4503, #4710)" const script = [ "#!/usr/bin/env bash", "set -euo pipefail", + safeTmpHelpers(src), markFn.replaceAll("/tmp/nemoclaw-gateway-local", markerPath), 'nohup() { "$@"; }', // macOS runners still use Bash 3.2; keep the simulated prefix @@ -191,6 +201,7 @@ describe("nemoclaw-start in-container gateway healthcheck marker (#4503, #4710)" const script = [ "#!/usr/bin/env bash", "set -euo pipefail", + safeTmpHelpers(src), markFn.replaceAll("/tmp/nemoclaw-gateway-local", markerPath), 'nohup() { "$@"; }', 'OPENCLAW="$(command -v openclaw)"', @@ -235,6 +246,7 @@ describe("nemoclaw-start in-container gateway healthcheck marker (#4503, #4710)" const script = [ "#!/usr/bin/env bash", "set -euo pipefail", + safeTmpHelpers(src), fnSrc, "mark_in_container_gateway", "mark_in_container_gateway", // second call must be a no-op @@ -242,8 +254,9 @@ describe("nemoclaw-start in-container gateway healthcheck marker (#4503, #4710)" const result = spawnSync("bash", ["-c", script], { encoding: "utf-8", timeout: 5000 }); expect(result.status).toBe(0); expect(fs.existsSync(markerPath)).toBe(true); - // file must be empty (`:` redirected to it, not appended) + // file must be empty, not appended to across idempotent calls expect(fs.statSync(markerPath).size).toBe(0); + expect((fs.statSync(markerPath).mode & 0o777).toString(8)).toBe("600"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } diff --git a/test/nemoclaw-start-safe-tmp.test.ts b/test/nemoclaw-start-safe-tmp.test.ts new file mode 100644 index 00000000000..e9c2d257daa --- /dev/null +++ b/test/nemoclaw-start-safe-tmp.test.ts @@ -0,0 +1,51 @@ +// @ts-nocheck +// 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, expect, it } from "vitest"; + +const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); + +function safeTmpHelpers(src: string): string { + const start = src.indexOf("_nemoclaw_safe_replace_tmp_file() {"); + const end = src.indexOf("_START_LOG=", start); + if (start === -1 || end === -1 || end <= start) throw new Error("Expected safe temp helpers"); + return src.slice(start, end); +} + +describe("nemoclaw-start safe tmp file creation", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + it("creates fixed runtime paths through the safe helper with the requested modes", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-start-safe-tmp-")); + const gatewayLog = path.join(tmpDir, "gateway.log"); + const autoPairLog = path.join(tmpDir, "auto-pair.log"); + const pidFile = path.join(tmpDir, "nemoclaw-gateway.pid"); + + try { + const script = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + safeTmpHelpers(src), + `_nemoclaw_safe_create_tmp_file ${JSON.stringify(gatewayLog)} 644`, + `_nemoclaw_safe_create_tmp_file ${JSON.stringify(autoPairLog)} 600`, + `printf '%s\\n' 12345 | _nemoclaw_safe_replace_tmp_file ${JSON.stringify(pidFile)} 600 "" best-effort`, + ].join("\n"); + + const result = spawnSync("bash", ["-c", script], { encoding: "utf-8", timeout: 5000 }); + + expect(result.status).toBe(0); + expect((fs.statSync(gatewayLog).mode & 0o777).toString(8)).toBe("644"); + expect((fs.statSync(autoPairLog).mode & 0o777).toString(8)).toBe("600"); + expect((fs.statSync(pidFile).mode & 0o777).toString(8)).toBe("600"); + expect(fs.readFileSync(pidFile, "utf-8")).toBe("12345\n"); + expect(fs.readdirSync(tmpDir).filter((entry) => entry.includes(".tmp."))).toEqual([]); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index ad3b1ac4fa9..fb1fb1d4f80 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -2422,10 +2422,7 @@ describe("nemoclaw-start gateway launch signal handling", () => { "start_auto_pair() { sleep 30 & AUTO_PAIR_PID=$!; }", "start_plugin_registry_refresh() { :; }", "cleanup_on_signal() { :; }; record_gateway_pid() { :; }", // record_gateway_pid: #4952 - extractShellFunctionFromSource(src, "mark_in_container_gateway").replaceAll( - "/tmp/nemoclaw-gateway-local", - markerPath, - ), + `mark_in_container_gateway() { : > ${JSON.stringify(markerPath)}; }`, "STEP_DOWN_PREFIX_SANDBOX=(gosu sandbox)", "STEP_DOWN_PREFIX_GATEWAY=(gosu gateway)", launchBlock(kind, gatewayLog), @@ -3797,6 +3794,7 @@ describe("Telegram diagnostics (#2766)", () => { `_CIAO_GUARD_SCRIPT=${JSON.stringify(path.join(tmpDir, "ciao-guard.js"))}`, `validate_nemoclaw_tmp_permissions() { validate_tmp_permissions ${JSON.stringify(preloadPath)}; }`, "NEMOCLAW_CMD=()", + '_nemoclaw_safe_create_tmp_file() { : > "$1"; chmod "$2" "$1"; }', preGatewaySetupBlock(kind, gatewayLog, autoPairLog), ].join("\n"), { mode: 0o700 },