Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
14 changes: 11 additions & 3 deletions nemoclaw/src/blueprint/state.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, it, expect, beforeEach, vi } from "vitest";
import type fs from "node:fs";
import { homedir } from "node:os";
import { loadState, saveState, clearState, type NemoClawState } from "./state.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearState, loadState, type NemoClawState, saveState } from "./state.js";

const store = new Map<string, string>();

Expand All @@ -22,6 +22,12 @@ vi.mock("node:fs", async (importOriginal) => {
writeFileSync: (p: string, data: string) => {
store.set(p, data);
},
renameSync: (from: string, to: string) => {
const content = store.get(from);
if (content === undefined) throw new Error(`ENOENT: ${from}`);
store.set(to, content);
store.delete(from);
},
};
});

Expand Down Expand Up @@ -170,10 +176,12 @@ describe("blueprint/state", () => {
expect(loaded.lastRunId).toBeNull();
});

it("does nothing when no file exists", () => {
it("creates blank state when no file exists", () => {
expect(() => {
clearState();
}).not.toThrow();
expect(store.has(STATE_PATH)).toBe(true);
expect(JSON.parse(store.get(STATE_PATH) || "{}").lastAction).toBeNull();
});
});
});
17 changes: 11 additions & 6 deletions nemoclaw/src/blueprint/state.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

Expand Down Expand Up @@ -144,17 +145,21 @@ export function loadState(): NemoClawState {
}
}

function writeStateFile(state: NemoClawState): void {
const finalPath = statePath();
const tmpPath = `${finalPath}.${process.pid}.${randomUUID()}.tmp`;
writeFileSync(tmpPath, JSON.stringify(state, null, 2), { mode: 0o600 });
renameSync(tmpPath, finalPath);
}

export function saveState(state: NemoClawState): void {
ensureStateDir();
state.updatedAt = new Date().toISOString();
state.createdAt ??= state.updatedAt;
writeFileSync(statePath(), JSON.stringify(state, null, 2));
writeStateFile(state);
}

export function clearState(): void {
ensureStateDir();
const path = statePath();
if (existsSync(path)) {
writeFileSync(path, JSON.stringify(blankState(), null, 2));
}
writeStateFile(blankState());
}
14 changes: 6 additions & 8 deletions src/lib/actions/sandbox/gateway-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,14 +457,12 @@ export async function ensureLiveSandboxOrExit(
if (lookup.state === "identity_drift") {
console.error(" Gateway SSH identity changed after restart — clearing stale host keys...");
const knownHostsPath = path.join(os.homedir(), ".ssh", "known_hosts");
if (fs.existsSync(knownHostsPath)) {
try {
const kh = fs.readFileSync(knownHostsPath, "utf8");
const cleaned = pruneKnownHostsEntries(kh);
if (cleaned !== kh) fs.writeFileSync(knownHostsPath, cleaned);
} catch {
/* best-effort cleanup */
}
try {
const kh = fs.readFileSync(knownHostsPath, "utf8");
const cleaned = pruneKnownHostsEntries(kh);
if (cleaned !== kh) fs.writeFileSync(knownHostsPath, cleaned);
} catch {
/* best-effort cleanup */
}
const retry = await getReconciledSandboxGatewayState(sandboxName);
if (retry.state === "present") {
Expand Down
77 changes: 68 additions & 9 deletions src/lib/actions/uninstall/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import os from "node:os";

import { defaultUninstallPaths } from "../../domain/uninstall/paths";
import { buildUninstallPlan, type UninstallPlan, type UninstallPlanOptions } from "../../domain/uninstall/plan";
import {
buildUninstallPlan,
type UninstallPlan,
type UninstallPlanOptions,
} from "../../domain/uninstall/plan";
import { classifyNemoclawShim, type ShimClassification } from "../../domain/uninstall/shims";

export interface FileSystemDeps {
closeSync?: typeof fs.closeSync;
fstatSync?: typeof fs.fstatSync;
lstatSync?: typeof fs.lstatSync;
openSync?: typeof fs.openSync;
readFileSync?: typeof fs.readFileSync;
}

Expand All @@ -17,29 +25,80 @@ export interface HostUninstallPlanOptions extends Omit<UninstallPlanOptions, "sh
fs?: FileSystemDeps;
}

export function classifyShimPath(shimPath: string, deps: FileSystemDeps = {}): ShimClassification {
const lstatSync = deps.lstatSync ?? fs.lstatSync;
const readFileSync = deps.readFileSync ?? fs.readFileSync;
function errnoCode(error: unknown): string | undefined {
return error && typeof error === "object" ? (error as { code?: string }).code : undefined;
}

function classifyShimPathByMetadata(
shimPath: string,
lstatSync: typeof fs.lstatSync,
): ShimClassification {
try {
const stat = lstatSync(shimPath);
const isFile = stat.isFile();
return classifyNemoclawShim({
contents: isFile ? String(readFileSync(shimPath, "utf-8")) : undefined,
exists: true,
isFile,
isFile: stat.isFile(),
isSymlink: stat.isSymbolicLink(),
});
} catch (error) {
const code = error && typeof error === "object" ? (error as { code?: string }).code : undefined;
if (errnoCode(error) === "ENOENT") {
return classifyNemoclawShim({ exists: false, isFile: false, isSymlink: false });
}
throw error;
}
}

function resolveUninstallHome(envHome: string | undefined): string {
return envHome || os.homedir();
}

export function classifyShimPath(shimPath: string, deps: FileSystemDeps = {}): ShimClassification {
const lstatSync = deps.lstatSync ?? fs.lstatSync;
const openSync = deps.openSync ?? fs.openSync;
const fstatSync = deps.fstatSync ?? fs.fstatSync;
const readFileSync = deps.readFileSync ?? fs.readFileSync;
const closeSync = deps.closeSync ?? fs.closeSync;
const noFollowFlag =
typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : undefined;
if (noFollowFlag === undefined) {
return classifyShimPathByMetadata(shimPath, lstatSync);
}
const nonblockFlag = typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0;
try {
const fd = openSync(shimPath, fs.constants.O_RDONLY | noFollowFlag | nonblockFlag);
try {
const fdStat = fstatSync(fd);
return classifyNemoclawShim({
contents: fdStat.isFile() ? String(readFileSync(fd, "utf-8")) : undefined,
exists: true,
isFile: fdStat.isFile(),
isSymlink: false,
});
} finally {
closeSync(fd);
}
} catch (error) {
const code = errnoCode(error);
if (code === "ENOENT") {
return classifyNemoclawShim({ exists: false, isFile: false, isSymlink: false });
}
if (
code === "ELOOP" ||
code === "EISDIR" ||
code === "EACCES" ||
code === "EPERM" ||
code === "ENXIO" ||
code === "ENODEV" ||
code === "ENOTSUP"
) {
return classifyShimPathByMetadata(shimPath, lstatSync);
}
throw error;
}
}

export function buildHostUninstallPlan(options: HostUninstallPlanOptions): UninstallPlan {
const home = options.env.HOME || "/tmp";
const home = resolveUninstallHome(options.env.HOME);
const paths = defaultUninstallPaths({
home,
tmpDir: options.env.TMPDIR,
Expand Down
5 changes: 5 additions & 0 deletions src/lib/actions/uninstall/run-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ describe("uninstall run plan", () => {
env: { HOME: "/home/test", TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv,
fs: {
lstatSync: (() => ({ isFile: () => false, isSymbolicLink: () => true })) as never,
openSync: (() => {
const error = new Error("symlink") as NodeJS.ErrnoException;
error.code = "ELOOP";
throw error;
}) as never,
},
},
);
Expand Down
24 changes: 20 additions & 4 deletions src/lib/actions/uninstall/run-plan.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { spawnSync, type SpawnSyncOptions, type SpawnSyncReturns } from "node:child_process";
import { type SpawnSyncOptions, type SpawnSyncReturns, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { dockerSpawnSync } from "../../adapters/docker/exec";
import { getAgentBranding, type AgentBranding } from "../../cli/branding";
import { type AgentBranding, getAgentBranding } from "../../cli/branding";
import { sleepMs } from "../../core/wait";
import { defaultUninstallPaths, NEMOCLAW_OLLAMA_MODELS, NEMOCLAW_PROVIDERS, type UninstallPaths } from "../../domain/uninstall/paths";
import { buildUninstallPlan, type UninstallPlan } from "../../domain/uninstall/plan";
Expand Down Expand Up @@ -65,7 +65,23 @@ function defaultRunDocker(args: string[], options: SpawnSyncOptions = {}): RunRe
}

function defaultCommandExists(command: string, env: NodeJS.ProcessEnv): boolean {
return defaultRun("sh", ["-c", `command -v ${JSON.stringify(command)} >/dev/null 2>&1`], { env }).status === 0;
if (!command || command.includes("\0")) return false;
const hasPathSeparator = command.includes(path.sep) || command.includes(path.posix.sep) || command.includes(path.win32.sep);
const candidates = hasPathSeparator
? [command]
: String(env.PATH || "")
.split(path.delimiter)
.filter(Boolean)
.map((entry) => path.join(entry, command));
for (const candidate of candidates) {
try {
fs.accessSync(candidate, fs.constants.X_OK);
return true;
} catch {
// Try the next PATH entry.
}
}
return false;
}

function defaultReadLine(env: NodeJS.ProcessEnv): string | null {
Expand Down Expand Up @@ -738,7 +754,7 @@ function executePlan(

export function buildRunPlan(options: UninstallRunOptions, deps: UninstallRunDeps = {}): { paths: UninstallPaths; plan: UninstallPlan } {
const env = { ...process.env, ...(deps.env ?? {}) };
const home = env.HOME || os.tmpdir();
const home = env.HOME || os.homedir();
const paths = defaultUninstallPaths({
home,
repoRoot: path.resolve(__dirname, "..", "..", ".."),
Expand Down
74 changes: 74 additions & 0 deletions src/lib/adapters/http/probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,80 @@ describe("http-probe helpers", () => {
expect(result.curlStatus).toBe(-110);
expect(result.message).toContain("ETIMEDOUT");
});

it("rejects non-http probe URLs before spawning curl", () => {
let spawned = false;
const result = runCurlProbe(["-sS", "file:///etc/passwd"], {
spawnSyncImpl: () => {
spawned = true;
throw new Error("should not spawn");
},
});

expect(spawned).toBe(false);
expect(result.ok).toBe(false);
expect(result.message).toContain("curl probe URL must use http or https");
});

it("rejects curl probe request bodies that read from local files", () => {
let spawned = false;
const result = runCurlProbe(["-sS", "--data-binary", "@/etc/passwd", "https://example.test/models"], {
spawnSyncImpl: () => {
spawned = true;
throw new Error("should not spawn");
},
});

expect(spawned).toBe(false);
expect(result.ok).toBe(false);
expect(result.message).toContain("must not read request data from a file");
});

it.each([
["--upload-file", ["--upload-file", "/etc/passwd"]],
["-T", ["-T/etc/passwd"]],
["--netrc", ["--netrc"]],
["--data-urlencode", ["--data-urlencode", "payload@/etc/passwd"]],
["-K", ["-K/etc/passwd"]],
["-b", ["-b/etc/passwd"]],
])("rejects file-reading curl option %s before spawning", (_label, args) => {
let spawned = false;
const result = runCurlProbe(["-sS", ...args, "https://example.test/models"], {
spawnSyncImpl: () => {
spawned = true;
throw new Error("should not spawn");
},
});

expect(spawned).toBe(false);
expect(result.ok).toBe(false);
});

it("allows explicit trusted curl config files", () => {
const configPath = path.join(os.tmpdir(), "nemoclaw-trusted-curl.conf");
let spawnedArgs: readonly string[] = [];
const result = runCurlProbe(["-sS", "--config", configPath, "https://example.test/models"], {
trustedConfigFiles: [configPath],
spawnSyncImpl: (_command, args) => {
spawnedArgs = args;
const outputPath = args[args.indexOf("-o") + 1];
if (typeof outputPath === "string") {
fs.writeFileSync(outputPath, "{}");
}
return {
pid: 1,
output: [],
stdout: "200",
stderr: "",
status: 0,
signal: null,
};
},
});

expect(result.ok).toBe(true);
expect(spawnedArgs).toContain(configPath);
});
});

describe("runChatCompletionsStreamingProbe", () => {
Expand Down
Loading
Loading