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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ci/test-file-size-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
65 changes: 43 additions & 22 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "$@" </dev/null
}

_START_LOG="/tmp/nemoclaw-start.log"
if [ "$(id -u)" -eq 0 ]; then
: >"$_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
Expand Down Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
70 changes: 70 additions & 0 deletions src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("node:child_process")>();
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();
});
});
15 changes: 4 additions & 11 deletions src/lib/actions/sandbox/process-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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",
Expand All @@ -126,11 +123,7 @@ export function executeSandboxCommand(
} catch {
return null;
} finally {
try {
fs.unlinkSync(tmpFile);
} catch {
/* ignore */
}
tmpSshConfig.cleanup();
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/lib/actions/sandbox/skill-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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();
});
});
30 changes: 7 additions & 23 deletions src/lib/actions/sandbox/skill-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -165,11 +161,7 @@ export async function removeSandboxSkill(
return;
}
} finally {
try {
fs.unlinkSync(tmpSshConfig);
} catch {
/* ignore */
}
tmpSshConfig.cleanup();
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -349,10 +337,6 @@ export async function installSandboxSkill(
process.exit(1);
}
} finally {
try {
fs.unlinkSync(tmpSshConfig);
} catch {
/* ignore */
}
tmpSshConfig.cleanup();
}
}
50 changes: 50 additions & 0 deletions src/lib/sandbox/temp-ssh-config.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading