Skip to content

fix(cli): add config-io module for safe config file I/O and preset validation#1370

Open
cv wants to merge 4 commits intomainfrom
cv/config-io-preset-validation
Open

fix(cli): add config-io module for safe config file I/O and preset validation#1370
cv wants to merge 4 commits intomainfrom
cv/config-io-preset-validation

Conversation

@cv
Copy link
Copy Markdown
Contributor

@cv cv commented Apr 2, 2026

Summary

  • New src/lib/config-io.ts: atomic JSON read/write (temp + rename), EACCES error handling with ConfigPermissionError and user-facing remediation hints
  • Refactor credentials.js to use readConfigFile/writeConfigFile (removes manual fs + chmod)
  • Refactor registry.js to use readConfigFile/writeConfigFile (removes manual atomic write)
  • Add validatePreset() to policies.js — warns when a preset lacks binaries: section (All policy presets and default telegram/discord policies return 403 due to missing binaries section #676)
  • Co-located tests for config-io (10 tests)

Supersedes the config-io and preset validation parts of #782 (without the runner.js redaction, which landed separately in #1246). Fixes #692, #606.

Test plan

  • 716 CLI tests pass
  • Coverage ratchet passes
  • Registry atomic write test updated to match new error format

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Enhanced config file I/O with improved error handling for permission issues and user-facing remediation guidance.
  • Tests

    • Added comprehensive test suite for config file operations including atomicity, permission verification, and error scenarios.
  • Refactor

    • Consolidated file I/O logic across credential and registry management modules for improved consistency and maintainability.

…lidation

Add src/lib/config-io.ts with atomic JSON read/write (temp + rename),
EACCES error handling with user-facing remediation hints, and directory
permission enforcement.

- Refactor credentials.js to use readConfigFile/writeConfigFile
- Refactor registry.js to use readConfigFile/writeConfigFile
- Add validatePreset() to policies.js (warns on missing binaries section)
- ConfigPermissionError with actionable remediation (sudo chown / rm)
- Co-located tests for config-io module

Fixes #692, #606. Supersedes the config-io and preset validation parts
of #782 (without the runner.js redaction, which landed separately in
#1246).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 2, 2026

📝 Walkthrough

Walkthrough

Created new config-io module for safe JSON file I/O with explicit permission error handling and atomic writes. Refactored credentials and registry modules to delegate file operations. Added preset validation to policies. Includes comprehensive test suite.

Changes

Cohort / File(s) Summary
Config I/O Implementation
src/lib/config-io.ts, bin/lib/config-io.js
Introduced core config-io module with ConfigPermissionError, ensureConfigDir(), writeConfigFile(), and readConfigFile() for safe JSON I/O with permission-aware error handling and atomic writes. Added CommonJS re-export shim.
Config I/O Tests
src/lib/config-io.test.ts
Added comprehensive Vitest suite covering directory creation with 0o700 permissions, atomic write semantics, default fallbacks for missing/corrupt files, temporary file cleanup, and ConfigPermissionError properties including remediation guidance.
Refactored File I/O
bin/lib/credentials.js, bin/lib/registry.js
Delegated file reading and writing to config-io helpers; removed inline fs and JSON.parse logic with blanket error handling; replaced atomic write implementation in registry with config-io wrapper.
Policy Validation
bin/lib/policies.js
Added validatePreset() function to verify presence of binaries: section in preset YAML; integrated validation into applyPreset() with console warning on missing sections.
Test Updates
test/registry.test.js
Updated error expectation in atomic write failure test from "EACCES" to "Cannot write config file" to reflect new error wrapping behavior.

Sequence Diagram(s)

sequenceDiagram
    participant App as Application
    participant CIO as config-io
    participant FS as Filesystem
    
    App->>CIO: writeConfigFile(path, data)
    CIO->>CIO: ensureConfigDir(parent)
    CIO->>FS: mkdir -p (0o700)
    FS-->>CIO: success or EACCES
    alt Permission Denied
        CIO->>CIO: throw ConfigPermissionError<br/>(with remediation)
        CIO-->>App: ConfigPermissionError
    else Success
        CIO->>FS: write to temp file (0o600)
        FS-->>CIO: fd
        CIO->>FS: renameSync(temp, target)
        FS-->>CIO: success or EACCES
        alt Rename Failed
            CIO->>FS: unlink temp file
            CIO->>CIO: throw ConfigPermissionError
            CIO-->>App: ConfigPermissionError
        else Success
            CIO-->>App: void (success)
        end
    end
Loading
sequenceDiagram
    participant App as Application
    participant CIO as config-io
    participant FS as Filesystem
    
    App->>CIO: readConfigFile(path, default)
    CIO->>FS: stat/access file
    FS-->>CIO: exists, readable, or error
    alt EACCES
        CIO->>CIO: throw ConfigPermissionError
        CIO-->>App: ConfigPermissionError
    else File Not Found
        CIO-->>App: return default
    else File Exists
        CIO->>FS: readFileSync(path)
        FS-->>CIO: content
        CIO->>CIO: JSON.parse(content)
        alt Parse Error
            CIO-->>App: return default
        else Parse Success
            CIO-->>App: return parsed<T>
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A rabbit's ode to safer files:

New config-io hops into view,
With permissions handled oh so true,
No more permission blues to chase,
When directories own their space!
Atomic writes and remediation too,
Our config files now safer through and through! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(cli): add config-io module for safe config file I/O and preset validation' accurately summarizes the main changes: adding a new config-io module for safe file I/O and preset validation.
Linked Issues check ✅ Passed The PR implements ConfigPermissionError for EACCES handling with remediation guidance, atomic writes, and permission enforcement in config-io module, directly addressing issue #692's requirement for graceful permission error handling and actionable guidance.
Out of Scope Changes check ✅ Passed All changes align with PR objectives: config-io module implementation, refactoring of credentials/registry/policies to use it, adding preset validation, and test coverage. No extraneous modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cv/config-io-preset-validation

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@bin/lib/policies.js`:
- Around line 65-73: The current check in validatePreset using
presetContent.includes("binaries:") can be fooled by comments or scalars;
instead parse the YAML and detect a real top-level key. Replace the substring
check in validatePreset with a YAML parse (e.g., yaml.parse / js-yaml.safeLoad)
of presetContent and then check for Object.prototype.hasOwnProperty.call(parsed,
"binaries") (and that parsed.binaries is not undefined/null); if parsing fails,
log a warning and treat the preset as missing the binaries section so the
original warning is emitted for presetName.

In `@src/lib/config-io.ts`:
- Around line 29-41: The current buildRemediation() message assumes sudo is
available; update it to include non-sudo fallback actions so environments
without sudo get actionable guidance. In the buildRemediation() function
(reference: nemoclawDir and HOME usage) add alternative suggestions such as
recreating the config under the current user's home (e.g., remove or move the
directory if writable), instructing to remove the directory without sudo when
the user owns it (e.g., rm -rf $HOME/.nemoclaw), and advising to relocate or
initialize config in a user-writable path (for example creating a new config
under $HOME or specifying an alternative CONFIG_HOME), so the error message
covers both sudo and non-sudo environments. Ensure the text clearly
distinguishes when sudo is required vs when the non-sudo command applies.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b236f71c-5208-4de3-9a9b-e2b0d59af3a1

📥 Commits

Reviewing files that changed from the base of the PR and between 86b3dee and 38424c6.

📒 Files selected for processing (7)
  • bin/lib/config-io.js
  • bin/lib/credentials.js
  • bin/lib/policies.js
  • bin/lib/registry.js
  • src/lib/config-io.test.ts
  • src/lib/config-io.ts
  • test/registry.test.js

Comment on lines +65 to +73
function validatePreset(presetContent, presetName) {
if (!presetContent.includes("binaries:")) {
console.warn(
` Warning: preset '${presetName}' has no binaries section — ` +
`this will cause 403 errors in the sandbox (ref: #676)`,
);
return false;
}
return true;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Detect the actual YAML key, not a raw substring.

presetContent.includes("binaries:") also matches comments, descriptions, or other scalar text, so a preset can still lack the real binaries section and skip the warning. Please parse the preset or at least anchor the check to a YAML key.

Suggested fix
 function validatePreset(presetContent, presetName) {
-  if (!presetContent.includes("binaries:")) {
+  let parsed = null;
+  try {
+    parsed = YAML.parse(presetContent);
+  } catch {
+    parsed = null;
+  }
+  if (
+    !parsed ||
+    typeof parsed !== "object" ||
+    !Object.prototype.hasOwnProperty.call(parsed, "binaries")
+  ) {
     console.warn(
       `  Warning: preset '${presetName}' has no binaries section — ` +
         `this will cause 403 errors in the sandbox (ref: `#676`)`,
     );
     return false;
   }
   return true;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/lib/policies.js` around lines 65 - 73, The current check in
validatePreset using presetContent.includes("binaries:") can be fooled by
comments or scalars; instead parse the YAML and detect a real top-level key.
Replace the substring check in validatePreset with a YAML parse (e.g.,
yaml.parse / js-yaml.safeLoad) of presetContent and then check for
Object.prototype.hasOwnProperty.call(parsed, "binaries") (and that
parsed.binaries is not undefined/null); if parsing fails, log a warning and
treat the preset as missing the binaries section so the original warning is
emitted for presetName.

Comment on lines +8 to 9
const { readConfigFile, writeConfigFile } = require("./config-io");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Lock acquisition still bypasses the new config permission handling.

save() now wraps the final file write, but every mutating path reaches acquireLock() first. Permission failures creating ~/.nemoclaw or ${REGISTRY_FILE}.lock still escape as plain fs errors, so the new remediation never reaches the main registerSandbox / updateSandbox / removeSandbox / setDefault flows that issue #692 targets.

Also applies to: 124-129

Comment on lines +29 to +41
function buildRemediation(): string {
const home = process.env.HOME || os.homedir();
const nemoclawDir = path.join(home, ".nemoclaw");
return [
" To fix, run one of:",
"",
` sudo chown -R $(whoami) ${nemoclawDir}`,
` # or, if the directory was created by another user:`,
` sudo rm -rf ${nemoclawDir} && nemoclaw onboard`,
"",
" This usually happens when NemoClaw was first run with sudo",
" or the config directory was created by a different user.",
].join("\n");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remediation still assumes sudo exists.

Issue #692 explicitly includes environments where sudo is unavailable, but both suggested fixes here still require it. In those installs the new ConfigPermissionError is still not actionable. Please add a non-sudo fallback, e.g. recreating config under a user-writable HOME or config directory.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/config-io.ts` around lines 29 - 41, The current buildRemediation()
message assumes sudo is available; update it to include non-sudo fallback
actions so environments without sudo get actionable guidance. In the
buildRemediation() function (reference: nemoclawDir and HOME usage) add
alternative suggestions such as recreating the config under the current user's
home (e.g., remove or move the directory if writable), instructing to remove the
directory without sudo when the user owns it (e.g., rm -rf $HOME/.nemoclaw), and
advising to relocate or initialize config in a user-writable path (for example
creating a new config under $HOME or specifying an alternative CONFIG_HOME), so
the error message covers both sudo and non-sudo environments. Ensure the text
clearly distinguishes when sudo is required vs when the non-sudo command
applies.

cv added a commit that referenced this pull request Apr 2, 2026
Convert the last 3 blocked-by-#782 CJS modules to TypeScript:

- credentials.js → src/lib/credentials.ts
- registry.js → src/lib/registry.ts
- policies.js → src/lib/policies.ts

716 CLI tests pass. Coverage ratchet passes.
Depends on #1370 (config-io module). Relates to #924.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@ericksoa ericksoa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice consolidation — the deduplication of atomic-write logic from credentials.js and registry.js into a shared module is clean, and the ConfigPermissionError with remediation hints is a real UX win for #692.

A few items worth considering before merge (inline comments below).

this.remediation = remediation;
if (cause) this.cause = cause;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old credentials.js had fs.chmodSync(dir, 0o700) to enforce permissions on every write, even for pre-existing directories. mkdirSync only applies mode at creation time — if ~/.nemoclaw already exists at 0o755, it stays that way. Would it make sense to add a chmodSync after the mkdirSync to preserve the old hardening behavior?

dir,
err as Error,
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: existsSync + readFileSync has a small TOCTOU window. Since the catch already handles errors, could this just call readFileSync directly and catch ENOENT alongside corrupt JSON? Simpler and avoids the race.


const fs = require("fs");
const path = require("path");
const { readConfigFile, writeConfigFile } = require("./config-io");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

acquireLock() does its own fs.mkdirSync(path.dirname(REGISTRY_FILE), ...) before save()/load() are ever reached. If ~/.nemoclaw is root-owned, users hit a plain fs EACCES error without the new remediation. Would it make sense to use ensureConfigDir here too?

return false;
}
return true;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validatePreset returns false on missing binaries but applyPreset ignores the return value and continues applying the preset. If the point of #676 is preventing 403 errors, should this bail out early when validation fails?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

EACCES: permission denied on /sandbox/.openclaw/identity/device-auth.json — curl install on Linux (no sudo in sandbox)

2 participants