Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
38424c6
fix(cli): add config-io module for safe config file I/O and preset va…
cv Apr 2, 2026
da477ba
Merge branch 'main' into cv/config-io-preset-validation
cv Apr 2, 2026
6d68a7d
Merge branch 'main' into cv/config-io-preset-validation
ericksoa Apr 3, 2026
1fa5d60
Merge branch 'main' into cv/config-io-preset-validation
ericksoa Apr 3, 2026
45f3d93
Merge branch 'main' into cv/config-io-preset-validation
cv Apr 3, 2026
a63e957
fix: address review feedback on config-io module
cv Apr 3, 2026
e500519
Merge branch 'main' into cv/config-io-preset-validation
cv Apr 4, 2026
d03d091
Merge branch 'main' into cv/config-io-preset-validation
cv Apr 4, 2026
4156a14
Merge branch 'main' into cv/config-io-preset-validation
cv Apr 5, 2026
dab07f5
Merge branch 'main' into cv/config-io-preset-validation
cv Apr 5, 2026
2419725
Merge branch 'main' into cv/config-io-preset-validation
cv Apr 6, 2026
064fab3
chore: merge origin/main into cv/config-io-preset-validation
cv Apr 8, 2026
50007fb
fix(cli): narrow salvage PR to config-io hardening
cv Apr 8, 2026
6a96f67
Merge branch 'main' into cv/config-io-preset-validation
cv Apr 9, 2026
147ab9e
Merge branch 'main' into cv/config-io-preset-validation
cv Apr 9, 2026
76460b3
Merge branch 'main' into cv/config-io-preset-validation
cv Apr 9, 2026
a5f34fb
Merge branch 'main' into cv/config-io-preset-validation
cv Apr 10, 2026
d826a88
Merge branch 'main' into cv/config-io-preset-validation
cv Apr 10, 2026
6aa81c6
Merge branch 'main' into cv/config-io-preset-validation
cv Apr 11, 2026
aeb3e32
Merge branch 'main' into cv/config-io-preset-validation
cv Apr 13, 2026
5244cb4
Merge branch 'main' into cv/config-io-preset-validation
ericksoa Apr 14, 2026
2d597a4
Merge branch 'main' into cv/config-io-preset-validation
ericksoa Apr 14, 2026
68c2bc4
Merge branch 'main' into cv/config-io-preset-validation
cv Apr 14, 2026
f8ec863
Merge branch 'main' into cv/config-io-preset-validation
prekshivyas Apr 14, 2026
e2cfa3c
Merge branch 'main' into cv/config-io-preset-validation
cv Apr 14, 2026
2322fe3
fix(cli): add non-sudo config recovery guidance
cv Apr 14, 2026
21e66bf
Merge branch 'main' into cv/config-io-preset-validation
ericksoa Apr 14, 2026
8534dfd
Merge branch 'main' into cv/config-io-preset-validation
ericksoa Apr 15, 2026
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
47 changes: 38 additions & 9 deletions src/lib/config-io.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,20 @@ afterEach(() => {
});

describe("config-io", () => {
it("creates config directories recursively", () => {
it("creates config directories recursively with mode 0o700", () => {
const dir = path.join(makeTempDir(), "a", "b", "c");
ensureConfigDir(dir);
expect(fs.existsSync(dir)).toBe(true);
expect(fs.statSync(dir).mode & 0o777).toBe(0o700);
});

it("tightens pre-existing weak directory permissions to 0o700", () => {
const dir = path.join(makeTempDir(), "config");
fs.mkdirSync(dir, { mode: 0o755 });

ensureConfigDir(dir);

expect(fs.statSync(dir).mode & 0o777).toBe(0o700);
});

it("returns the fallback when the config file is missing", () => {
Expand All @@ -49,11 +59,13 @@ describe("config-io", () => {
it("writes and reads JSON atomically", () => {
const dir = makeTempDir();
const file = path.join(dir, "config.json");
writeConfigFile(file, { token: "abc", nested: { enabled: true } });
const data = { token: "abc", nested: { enabled: true } };

expect(readConfigFile(file, null)).toEqual({ token: "abc", nested: { enabled: true } });
const leftovers = fs.readdirSync(dir).filter((name) => name.includes(".tmp."));
expect(leftovers).toEqual([]);
writeConfigFile(file, data);

expect(readConfigFile(file, null)).toEqual(data);
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
expect(fs.readdirSync(dir).filter((name) => name.includes(".tmp."))).toEqual([]);
});

it("cleans up temp files when rename fails", () => {
Expand All @@ -68,11 +80,10 @@ describe("config-io", () => {
} finally {
fs.renameSync = originalRename;
}
const leftovers = fs.readdirSync(dir).filter((name) => name.includes(".tmp."));
expect(leftovers).toEqual([]);
expect(fs.readdirSync(dir).filter((name) => name.includes(".tmp."))).toEqual([]);
});

it("wraps permission errors with a user-facing error", () => {
it("wraps permission errors with sudo and non-sudo remediation guidance", () => {
const dir = makeTempDir();
const file = path.join(dir, "config.json");
const originalWrite = fs.writeFileSync;
Expand All @@ -81,9 +92,27 @@ describe("config-io", () => {
};
try {
expect(() => writeConfigFile(file, { ok: true })).toThrow(ConfigPermissionError);
expect(() => writeConfigFile(file, { ok: true })).toThrow(/HOME points to a user-owned directory/);
expect(() => writeConfigFile(file, { ok: true })).toThrow(/sudo chown/);
expect(() => writeConfigFile(file, { ok: true })).toThrow(/\bmv\b/);
expect(() => writeConfigFile(file, { ok: true })).toThrow(/HOME=/);
} finally {
fs.writeFileSync = originalWrite;
}
});

it("supports both rich and legacy constructor forms", () => {
const rich = new ConfigPermissionError("test error", "/some/path");
expect(rich.name).toBe("ConfigPermissionError");
expect(rich.code).toBe("EACCES");
expect(rich.configPath).toBe("/some/path");
expect(rich.filePath).toBe("/some/path");
expect(rich.message).toContain("test error");
expect(rich.remediation).toContain("sudo chown");
expect(rich.remediation).toContain("mv ");
expect(rich.remediation).toContain("HOME=");

const legacy = new ConfigPermissionError("/other/path", "write");
expect(legacy.filePath).toBe("/other/path");
expect(legacy.message).toContain("Cannot write config file");
});
});
108 changes: 90 additions & 18 deletions src/lib/config-io.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,39 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Safe config file I/O with permission-aware errors and atomic writes.

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

export class ConfigPermissionError extends Error {
filePath: string;
import { shellQuote } from "./runner";

constructor(filePath: string, action: "read" | "write" | "create directory") {
super(
`Cannot ${action} config file at ${filePath}. ` +
"Check that HOME points to a user-owned directory and that ~/.nemoclaw is writable.",
);
this.name = "ConfigPermissionError";
this.filePath = filePath;
}
function buildRemediation(): string {
const home = process.env.HOME || os.homedir();
const nemoclawDir = path.join(home, ".nemoclaw");
const backupDir = `${nemoclawDir}.backup.${process.pid}`;
const recoveryHome = path.join(os.tmpdir(), `nemoclaw-home-${process.getuid?.() ?? "user"}`);

return [
" To fix, try one of these recovery paths:",
"",
" # If you can use sudo, repair the existing config directory:",
` sudo chown -R $(whoami) ${shellQuote(nemoclawDir)}`,
" # or recreate it if it was created by another user:",
` sudo rm -rf ${shellQuote(nemoclawDir)} && nemoclaw onboard`,
"",
" # If sudo is unavailable, move the bad config aside from a writable HOME:",
` mv ${shellQuote(nemoclawDir)} ${shellQuote(backupDir)} && nemoclaw onboard`,
" # or, if you already own the directory, remove it without sudo:",
` rm -rf ${shellQuote(nemoclawDir)} && nemoclaw onboard`,
"",
" # If HOME itself is not writable, start NemoClaw with a writable HOME:",
` mkdir -p ${shellQuote(recoveryHome)} && HOME=${shellQuote(recoveryHome)} nemoclaw onboard`,
"",
" This usually happens when NemoClaw was first run with sudo",
" or the config directory was created by a different user.",
].join("\n");
}

function isPermissionError(error: unknown): error is NodeJS.ErrnoException {
Expand All @@ -26,34 +45,87 @@ function isPermissionError(error: unknown): error is NodeJS.ErrnoException {
);
}

export class ConfigPermissionError extends Error {
code = "EACCES";
configPath: string;
filePath: string;
remediation: string;

constructor(filePath: string, action: "read" | "write" | "create directory");
constructor(message: string, configPath: string, cause?: Error);
constructor(messageOrPath: string, configPathOrAction: string, cause?: Error) {
const action =
configPathOrAction === "read" ||
configPathOrAction === "write" ||
configPathOrAction === "create directory"
? configPathOrAction
: null;

const configPath = action ? messageOrPath : configPathOrAction;
const message = action
? action === "create directory"
? `Cannot create config directory: ${configPath}`
: `Cannot ${action} config file: ${configPath}`
: messageOrPath;

const remediation = buildRemediation();
super(`${message}\n\n${remediation}`);
this.name = "ConfigPermissionError";
this.configPath = configPath;
this.filePath = configPath;
this.remediation = remediation;
if (cause) {
this.cause = cause;
}
}
}

export function ensureConfigDir(dirPath: string): void {
try {
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });

const stat = fs.statSync(dirPath);
if ((stat.mode & 0o077) !== 0) {
fs.chmodSync(dirPath, 0o700);
}
} catch (error) {
if (isPermissionError(error)) {
throw new ConfigPermissionError(`Cannot create config directory: ${dirPath}`, dirPath, error as Error);
}
throw error;
}

try {
fs.accessSync(dirPath, fs.constants.W_OK);
} catch (error) {
if (isPermissionError(error)) {
throw new ConfigPermissionError(dirPath, "create directory");
throw new ConfigPermissionError(
`Config directory exists but is not writable: ${dirPath}`,
dirPath,
error as Error,
);
}
Comment thread
cv marked this conversation as resolved.
throw error;
}
}

export function readConfigFile<T>(filePath: string, fallback: T): T {
try {
if (!fs.existsSync(filePath)) {
return fallback;
}
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch (error) {
if (isPermissionError(error)) {
throw new ConfigPermissionError(filePath, "read");
throw new ConfigPermissionError(`Cannot read config file: ${filePath}`, filePath, error as Error);
}
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return fallback;
}
return fallback;
}
}

export function writeConfigFile(filePath: string, data: unknown): void {
const dir = path.dirname(filePath);
ensureConfigDir(dir);
const dirPath = path.dirname(filePath);
ensureConfigDir(dirPath);

const tmpFile = `${filePath}.tmp.${process.pid}`;
try {
Expand All @@ -66,7 +138,7 @@ export function writeConfigFile(filePath: string, data: unknown): void {
/* best effort */
}
if (isPermissionError(error)) {
throw new ConfigPermissionError(filePath, "write");
throw new ConfigPermissionError(`Cannot write config file: ${filePath}`, filePath, error as Error);
}
throw error;
}
Expand Down
4 changes: 2 additions & 2 deletions src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import fs from "node:fs";
import path from "node:path";

import { readConfigFile, writeConfigFile } from "./config-io";
import { ensureConfigDir, readConfigFile, writeConfigFile } from "./config-io";

export interface SandboxEntry {
name: string;
Expand Down Expand Up @@ -34,7 +34,7 @@ export const LOCK_MAX_RETRIES = 120;

/** Acquire an advisory lock using mkdir (atomic on POSIX). */
export function acquireLock(): void {
fs.mkdirSync(path.dirname(REGISTRY_FILE), { recursive: true, mode: 0o700 });
ensureConfigDir(path.dirname(REGISTRY_FILE));
const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
try {
Expand Down
Loading