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
29 changes: 25 additions & 4 deletions nemoclaw/src/blueprint/state.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// 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>();
const writes: Array<{ path: string; options: unknown }> = [];
const renames: Array<{ from: string; to: string }> = [];

vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof fs>();
Expand All @@ -19,9 +21,17 @@ vi.mock("node:fs", async (importOriginal) => {
if (content === undefined) throw new Error(`ENOENT: ${p}`);
return content;
},
writeFileSync: (p: string, data: string) => {
writeFileSync: (p: string, data: string, options?: unknown) => {
writes.push({ path: p, options });
store.set(p, data);
},
renameSync: (from: string, to: string) => {
renames.push({ from, to });
const content = store.get(from);
if (content === undefined) throw new Error(`ENOENT: ${from}`);
store.set(to, content);
store.delete(from);
},
};
});

Expand All @@ -30,6 +40,8 @@ const STATE_PATH = `${homedir()}/.nemoclaw/state/nemoclaw.json`;
describe("blueprint/state", () => {
beforeEach(() => {
store.clear();
writes.length = 0;
renames.length = 0;
});

describe("loadState", () => {
Expand Down Expand Up @@ -170,10 +182,19 @@ 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);
const write = writes.at(-1);
const rename = renames.at(-1);
expect(write?.path.startsWith(`${STATE_PATH}.${process.pid}.`)).toBe(true);
expect(write?.path.endsWith(".tmp")).toBe(true);
expect(write?.options).toMatchObject({ mode: 0o600 });
expect(rename).toEqual({ from: write?.path, to: STATE_PATH });
expect(store.has(write?.path || "")).toBe(false);
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
Loading
Loading