Skip to content

fix(credentials): clean up empty legacy credentials.json on upgrade - #3119

Merged
ericksoa merged 4 commits into
mainfrom
fix/issue-3105-cleanup-empty-legacy-credentials
May 7, 2026
Merged

fix(credentials): clean up empty legacy credentials.json on upgrade#3119
ericksoa merged 4 commits into
mainfrom
fix/issue-3105-cleanup-empty-legacy-credentials

Conversation

@laitingsheng

@laitingsheng laitingsheng commented May 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Securely remove a stale ~/.nemoclaw/credentials.json left on host disk after upgrading from pre-gateway versions when the file has nothing to migrate (empty {}, only unknown keys, or only blank values). Closes the security-hygiene gap where cleartext API keys could linger indefinitely after the gateway-only migration in #2554.

Related Issue

Closes #3105

Changes

  • Add removeLegacyCredentialsFileIfEmpty() in src/lib/credentials.ts that re-inspects the legacy file under the same symlink-rejection / O_NOFOLLOW / size-cap guards used by stageLegacyCredentialsToEnv() and secureUnlinks it iff zero allowlisted keys carry a non-empty string value.
  • Add a new src/lib/host-artifact-cleanup.ts module exposing cleanupStaleHostFiles() — a tiny runner over a STALE_FILES const list. Today it has one entry (the legacy credentials file); future stale-file leftovers can be added by appending another entry without touching onboard.ts. Cleaner-level errors are isolated so a single failure can't abort onboard completion.
  • Wire cleanupStaleHostFiles() into the onboard success path immediately after the existing migrate/warn branches, so a fresh post-upgrade onboard sweeps any leftover artifacts in one uniform pass.
  • Tests cover the empty-file regression, unknown-keys-only, blank-value-only, real-credential preservation, missing file, symlink refusal, corrupt JSON, and zero-fill-before-unlink for defence-in-depth, plus end-to-end coverage of the runner.

Type of Change

  • Code change (feature, bug fix, or refactor)
  • Code change with doc updates
  • Doc only (prose changes, no code sample modifications)
  • Doc only (includes code sample changes)

Verification

  • npx prek run --all-files passes
  • npm test passes
  • Tests added or updated for new or changed behavior
  • No secrets, API keys, or credentials committed
  • Docs updated for user-facing behavior changes
  • make docs builds without warnings (doc changes only)
  • Doc pages follow the style guide (doc changes only)
  • New doc pages include SPDX header and frontmatter (new pages only)

Signed-off-by: Tinson Lai tinsonl@nvidia.com

Summary by CodeRabbit

  • Improvements

    • Enhanced post-upgrade cleanup of legacy host artifacts with stricter safety checks
    • Credential files are now removed only when verified empty/unusable
    • Cleanup runs during onboarding finalization to sweep stale host files
  • Tests

    • Added comprehensive tests covering cleanup behavior, safety, idempotence, symlink protection, and recovery scenarios

Pre-gateway NemoClaw versions wrote credentials to ~/.nemoclaw/credentials.json.
The current onboard completion path migrates and securely unlinks that file,
but only when stageLegacyCredentialsToEnv() returned at least one staged key.
On upgrades from older versions where the file was already empty ({}), or held
only keys outside KNOWN_CREDENTIAL_ENV_KEYS, or only blank/whitespace values,
the stage call returned [] and the file was silently left on disk indefinitely.

Add removeLegacyCredentialsFileIfEmpty() that re-inspects the legacy file
under the same symlink/O_NOFOLLOW/size guards as the migration path and
secureUnlink()s it iff zero allowlisted keys carry a non-empty string value.
Wire it into a new tiny host-artifact-cleanup module (cleanupStaleHostFiles)
that runs at the end of the onboard success path, so future stale-file
leftovers can be added by appending one entry without touching onboard.ts.

Tests cover empty {}, unknown-keys-only, blank-value-only, real-credential
preserved, missing file, symlink-refusal, corrupt-JSON, and zero-fill-on-
unlink for defence-in-depth, plus end-to-end coverage of the runner.

Signed-off-by: Tinson Lai <tinsonl@nvidia.com>
@coderabbitai

coderabbitai Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Adds a safety-first post-upgrade stale-file sweep: a new credentials helper that conditionally and securely removes legacy credentials.json only when it contains no usable credential values, a registry-driven stale-file cleaner, and an onboarding call to run the sweep during finalization.

Changes

Legacy Credentials Cleanup

Layer / File(s) Summary
Data / API
src/lib/credentials.ts
Removed removeLegacyCredentialsFile() and added removeLegacyCredentialsFileIfEmpty(): boolean exported helper that returns true when it securely removed the legacy file.
Core Implementation
src/lib/credentials.ts
Implements symlink/path rejection, opens file with O_NOFOLLOW, enforces a max size, parses JSON object shape (non-array), checks allowlisted KNOWN_CREDENTIAL_ENV_KEYS for any non-blank string values, zero-fills-before-unlink defense, and verifies unlink by checking for ENOENT before returning true.
Cleanup Infrastructure
src/lib/host-artifact-cleanup.ts
New module with StaleHostFile type, STALE_FILES registry (contains the credentials cleanup entry), and exported cleanupStaleHostFiles() that iterates entries, calls removers, logs outcomes, and continues on errors.
Onboarding Wiring
src/lib/onboard.ts
Imports and invokes cleanupStaleHostFiles() during onboarding finalization after the legacy-credentials migration/removal gate and before deployment verification/dashboard printing.
Tests / Validation
test/credentials.test.ts, test/host-artifact-cleanup.test.ts
Added extensive tests for removeLegacyCredentialsFileIfEmpty (empty file, unknown-only keys, blank values, idempotence, symlink safety, crash-recovery, zero-fill unlink defense) and tests for cleanupStaleHostFiles() behaviors and logging. Updated module type-guard to require the new function.

Sequence Diagram

sequenceDiagram
    participant Onboard as onboard.ts
    participant Cleaner as cleanupStaleHostFiles()
    participant Creds as removeLegacyCredentialsFileIfEmpty()
    participant FS as Filesystem

    Onboard->>Cleaner: finalize onboarding
    Cleaner->>Creds: attempt removal of legacy credentials.json
    Creds->>FS: open with O_NOFOLLOW / reject symlinked ancestor paths
    FS-->>Creds: open/stat result
    alt unsafe (symlink/path reject or open failure)
        Creds-->>Cleaner: false (skip)
        Cleaner->>Cleaner: log skip/error
    else safe regular file
        Creds->>FS: stat size
        FS-->>Creds: size result
        alt size > cap or parse error
            Creds-->>Cleaner: false (skip)
            Cleaner->>Cleaner: log skip
        else size OK and JSON object
            Creds->>Creds: check KNOWN_CREDENTIAL_ENV_KEYS for non-blank strings
            alt usable credentials present
                Creds-->>Cleaner: false (preserve)
                Cleaner->>Cleaner: log preserved
            else empty/unknown-only
                Creds->>FS: zero-fill file then unlink
                FS-->>Creds: removed
                Creds-->>Cleaner: true (removed)
                Cleaner->>Cleaner: log removal
            end
        end
    end
    Cleaner-->>Onboard: cleanup complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰
I sniff the old file under moonlit code,
If nothing of value in its hollow showed,
I zero its tracks and hop away light,
Leaving the meadow tidy and bright.
Hooray for safe hops and restful night!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% 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 accurately and concisely summarizes the main change: securely cleaning up empty legacy credentials.json during upgrade.
Linked Issues check ✅ Passed The PR fully addresses issue #3105 by implementing secure deletion of the legacy credentials.json file when empty, using symlink-rejection and O_NOFOLLOW guards, with comprehensive test coverage for edge cases.
Out of Scope Changes check ✅ Passed All changes are within scope: new credentials cleanup function, host-artifact-cleanup module, onboard integration, and tests directly address the legacy file removal requirement from issue #3105.

✏️ 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 fix/issue-3105-cleanup-empty-legacy-credentials

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

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/lib/onboard.ts (1)

9488-9493: Run targeted onboarding E2Es for this path change.

Given this executes in the core onboarding finalization path, I recommend running the listed onboarding-related nightly jobs before merge (cloud-e2e, sandbox-operations-e2e, rebuild-openclaw-e2e, messaging-compatible-endpoint-e2e, hermes-discord-e2e).

As per coding guidelines, src/lib/onboard.ts changes should be validated with the recommended E2E set.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/onboard.ts` around lines 9488 - 9493, This change adds a cleanup call
in the onboarding finalization path (cleanupStaleHostFiles()), so before merging
run the targeted onboarding E2E suites to validate behavior: execute cloud-e2e,
sandbox-operations-e2e, rebuild-openclaw-e2e, messaging-compatible-endpoint-e2e,
and hermes-discord-e2e against the onboarding flow to ensure
cleanupStaleHostFiles() doesn't regress registrations, credentials handling, or
downstream integrations; if any test fails, investigate the onboarding
finalization sequence around the cleanupStaleHostFiles() invocation and adjust
guards or ordering accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/credentials.ts`:
- Around line 431-435: Before calling JSON.parse(raw), detect zero-byte or
whitespace-only legacy files by checking raw.trim() length; if empty, treat the
file as having no migratable payload (skip JSON.parse) and return the same
success/cleanup signal you use for stale artifacts so the cleanup can delete it.
Update the logic surrounding JSON.parse(raw) and the variables parsed/raw so
whitespace-only files don't throw and get stuck.
- Around line 451-452: The code returns true unconditionally after calling
secureUnlink(legacyFile) which swallows failures, causing
cleanupStaleHostFiles() to report removal even if the file remains; modify the
logic so that after calling secureUnlink(legacyFile) you verify the file's
absence (e.g., stat or exists check and treat ENOENT as success) and only return
true if the file is actually gone (or change secureUnlink to return a boolean
indicating removal and use that result). Ensure you reference secureUnlink and
cleanupStaleHostFiles and base the success return on the post-unlink existence
check or the new secureUnlink return value for legacyFile.

---

Nitpick comments:
In `@src/lib/onboard.ts`:
- Around line 9488-9493: This change adds a cleanup call in the onboarding
finalization path (cleanupStaleHostFiles()), so before merging run the targeted
onboarding E2E suites to validate behavior: execute cloud-e2e,
sandbox-operations-e2e, rebuild-openclaw-e2e, messaging-compatible-endpoint-e2e,
and hermes-discord-e2e against the onboarding flow to ensure
cleanupStaleHostFiles() doesn't regress registrations, credentials handling, or
downstream integrations; if any test fails, investigate the onboarding
finalization sequence around the cleanupStaleHostFiles() invocation and adjust
guards or ordering accordingly.
🪄 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: Enterprise

Run ID: 7c02ee8e-8ae6-4d37-a95a-c5a6cfe9e8ed

📥 Commits

Reviewing files that changed from the base of the PR and between 7b2cacf and d0ec779.

📒 Files selected for processing (5)
  • src/lib/credentials.ts
  • src/lib/host-artifact-cleanup.ts
  • src/lib/onboard.ts
  • test/credentials.test.ts
  • test/host-artifact-cleanup.test.ts

Comment thread src/lib/credentials.ts Outdated
Comment thread src/lib/credentials.ts Outdated
Address two CodeRabbit findings on the legacy credentials cleanup helper:

1. A 0-byte or whitespace-only credentials.json now matches the empty-{}
   path instead of throwing inside JSON.parse and getting stuck on disk.
   Pre-gateway versions or partial writes can produce these shapes; they
   carry no migratable payload, so fall through to the unlink.

2. secureUnlink is best-effort and swallows errors. Verify the file is
   actually gone with lstatSync (treating ENOENT as success) before
   returning true — otherwise the runner would log "Removed stale ..."
   on a permission-denied unlink, misleading the user.

Add regression tests for both edge cases.

Signed-off-by: Tinson Lai <tinsonl@nvidia.com>
…p-empty-legacy-credentials

# Conflicts:
#	src/lib/onboard.ts

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/credentials.ts`:
- Around line 415-459: You are currently validating the file by inode (using fd
and stat) but then unconditionally call secureUnlink(legacyFile) after closing
fd, which can delete a different file if the path was replaced; instead preserve
and compare the original inode before deleting: capture the original stat (the
existing stat variable from fstatSync(fd)), close the fd, then before calling
secureUnlink(legacyFile) re-stat the pathname (fs.statSync(legacyFile)) and
compare key identifiers (dev and ino) to the original stat — only call
secureUnlink when they match, otherwise abort and do not unlink; reference the
existing symbols legacyFile, fd, stat (from fstatSync), and secureUnlink in your
change.
🪄 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: Enterprise

Run ID: ef57adc5-73c6-49e5-9ba7-af4f635375a9

📥 Commits

Reviewing files that changed from the base of the PR and between 0a089df and a6561ad.

📒 Files selected for processing (2)
  • src/lib/credentials.ts
  • src/lib/onboard.ts

Comment thread src/lib/credentials.ts
Comment on lines +415 to +459
let raw: string;
try {
const stat = fs.fstatSync(fd);
if (!stat.isFile()) return false;
if (stat.size > LEGACY_CREDS_FILE_MAX_BYTES) return false;
raw = fs.readFileSync(fd, "utf-8");
} catch {
return false;
} finally {
try {
fs.closeSync(fd);
} catch {
/* fd already closed; ignore */
}
}

// A 0-byte or whitespace-only file is functionally identical to an
// empty {} — there's no migratable payload, so skip JSON.parse (which
// would throw on the empty input) and fall through to the unlink.
if (raw.trim() !== "") {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return false;
}

if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return false;
}

const allowed = new Set<string>(KNOWN_CREDENTIAL_ENV_KEYS);
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (!allowed.has(key)) continue;
if (typeof value !== "string") continue;
if (normalizeCredentialValue(value)) {
return false;
}
}
}

// secureUnlink is best-effort and swallows errors. Verify the file is
// actually gone before claiming a successful removal — otherwise the
// runner would log "Removed stale ..." on a permission-denied unlink.
secureUnlink(legacyFile);

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 | 🏗️ Heavy lift

Don't inspect one inode and delete another.

The emptiness check is done on the fd-backed inode you read here, but Line 459 deletes by pathname after that fd has been closed. If another process replaces credentials.json in that window, this can classify inode A as empty and then wipe inode B instead — including a newly written real credential file. Please keep the validated inode pinned through cleanup, or abort when the current path no longer matches the inode you inspected.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/credentials.ts` around lines 415 - 459, You are currently validating
the file by inode (using fd and stat) but then unconditionally call
secureUnlink(legacyFile) after closing fd, which can delete a different file if
the path was replaced; instead preserve and compare the original inode before
deleting: capture the original stat (the existing stat variable from
fstatSync(fd)), close the fd, then before calling secureUnlink(legacyFile)
re-stat the pathname (fs.statSync(legacyFile)) and compare key identifiers (dev
and ino) to the original stat — only call secureUnlink when they match,
otherwise abort and do not unlink; reference the existing symbols legacyFile,
fd, stat (from fstatSync), and secureUnlink in your change.

@ericksoa ericksoa left a comment

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.

Reviewed and fixed on top of the PR branch. Resolved the current-main conflict by keeping the stale host-file cleanup, current ./state/registry import, and main's post-deployment verification before printDashboard(). CodeRabbit is clean on head a6561ad and all PR checks are green. Local validation passed: npm run build:cli, npm run typecheck:cli, npm run source-shape:check, git diff --check, focused credential/host-artifact cleanup tests, and the legacy credentials onboard test.

@ericksoa
ericksoa merged commit 0940e32 into main May 7, 2026
19 checks passed
@ericksoa
ericksoa deleted the fix/issue-3105-cleanup-empty-legacy-credentials branch May 7, 2026 00:43
@wscurran wscurran added the bug-fix PR fixes a bug or regression label Jun 8, 2026
@wscurran wscurran added the NV QA Bugs found by the NVIDIA QA Team label Jun 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug-fix PR fixes a bug or regression NV QA Bugs found by the NVIDIA QA Team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[macOS][Security] Upgrade from pre-v0.0.30 leaves stale credentials.json on host disk — gateway-only migration does not clean up legacy file

3 participants