From 73d19d9375dcf8228f101a71aabafde3843d1277 Mon Sep 17 00:00:00 2001 From: latenighthackathon Date: Tue, 7 Apr 2026 23:02:44 -0500 Subject: [PATCH 1/2] feat(cli): add 'nemoclaw credentials' command for resetting stored keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user enters an invalid API key during onboarding it gets saved to ~/.nemoclaw/credentials.json. On subsequent 'nemoclaw onboard' runs, ensureApiKey() and ensureNamedCredential() see the stored value and skip the prompt entirely, leaving the user stuck reusing the same bad key with no documented escape hatch other than hand-editing the JSON. Add a 'credentials' global subcommand: nemoclaw credentials list - list stored keys (no values) nemoclaw credentials reset - remove a stored credential nemoclaw credentials reset --yes - skip confirmation prompt After 'reset', the next 'nemoclaw onboard' run re-prompts for the key. Values are never printed by 'list' or by any error path. The existence check uses listCredentialKeys() only — getCredential() falls back to process.env, which would let an env-only key pass the check even though there is nothing on disk to delete. Adds three new tests covering deleteCredential and listCredentialKeys (round-trip with file-mode assertions, missing-file, and listing). Closes #1568 Signed-off-by: latenighthackathon --- bin/nemoclaw.js | 85 ++++++++++++++++++++++++++++++++++++++-- src/lib/credentials.ts | 19 ++++++++- test/credentials.test.js | 35 +++++++++++++++++ 3 files changed, 135 insertions(+), 4 deletions(-) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 2565f0fc2..2b7597a99 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -31,7 +31,12 @@ const { } = require("./lib/runner"); const { resolveOpenshell } = require("./lib/resolve-openshell"); const { startGatewayForRecovery } = require("./lib/onboard"); -const { getCredential } = require("./lib/credentials"); +const { + getCredential, + deleteCredential, + listCredentialKeys, + prompt: askPrompt, +} = require("./lib/credentials"); const registry = require("./lib/registry"); const nim = require("./lib/nim"); const policies = require("./lib/policies"); @@ -69,6 +74,7 @@ const GLOBAL_COMMANDS = new Set([ "status", "debug", "uninstall", + "credentials", "help", "--help", "-h", @@ -878,6 +884,74 @@ function uninstall(args) { }); } +async function credentialsCommand(args) { + const sub = args[0]; + if (!sub || sub === "help" || sub === "--help" || sub === "-h") { + console.log(""); + console.log(" Usage: nemoclaw credentials "); + console.log(""); + console.log(" Subcommands:"); + console.log(" list List stored credential keys (values are not printed)"); + console.log(" reset [--yes] Remove a stored credential so onboard re-prompts"); + console.log(""); + console.log(" Stored at ~/.nemoclaw/credentials.json (mode 600)"); + console.log(""); + return; + } + + if (sub === "list") { + const keys = listCredentialKeys(); + if (keys.length === 0) { + console.log(" No stored credentials."); + return; + } + console.log(" Stored credentials:"); + for (const k of keys) { + console.log(` ${k}`); + } + return; + } + + if (sub === "reset") { + const key = args[1]; + if (!key) { + console.error(" Usage: nemoclaw credentials reset [--yes]"); + console.error(" Run 'nemoclaw credentials list' to see stored keys."); + process.exit(1); + } + // Only consult the persisted credentials file — getCredential() falls back + // to process.env, which would let an env-only key pass this check even + // though there is nothing on disk to delete. + if (!listCredentialKeys().includes(key)) { + console.error(` No stored credential found for '${key}'.`); + process.exit(1); + } + const skipPrompt = args.includes("--yes") || args.includes("-y"); + if (!skipPrompt) { + const answer = (await askPrompt(` Remove stored credential '${key}'? [y/N]: `)) + .trim() + .toLowerCase(); + if (answer !== "y" && answer !== "yes") { + console.log(" Cancelled."); + return; + } + } + const removed = deleteCredential(key); + if (removed) { + console.log(` Removed '${key}' from ~/.nemoclaw/credentials.json`); + console.log(" Re-run 'nemoclaw onboard' to enter a new value."); + } else { + console.error(` No stored credential found for '${key}'.`); + process.exit(1); + } + return; + } + + console.error(` Unknown credentials subcommand: ${sub}`); + console.error(" Run 'nemoclaw credentials help' for usage."); + process.exit(1); +} + function showStatus() { const { showStatus: showServiceStatus } = require("./lib/services"); showStatusCommand({ @@ -1082,7 +1156,6 @@ async function sandboxPolicyAdd(sandboxName, args = []) { const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); - const { prompt: askPrompt } = require("./lib/credentials"); const answer = await policies.selectFromList(allPresets, { applied }); if (!answer) return; @@ -1137,7 +1210,6 @@ function cleanupSandboxServices(sandboxName) { async function sandboxDestroy(sandboxName, args = []) { const skipConfirm = args.includes("--yes") || args.includes("--force"); if (!skipConfirm) { - const { prompt: askPrompt } = require("./lib/credentials"); const answer = await askPrompt( ` ${YW}Destroy sandbox '${sandboxName}'?${R} This cannot be undone. [y/N]: `, ); @@ -1228,6 +1300,10 @@ function help() { nemoclaw debug [--quick] Collect diagnostics for bug reports nemoclaw debug --output FILE Save diagnostics tarball for GitHub issues + ${G}Credentials:${R} + nemoclaw credentials list List stored credential keys + nemoclaw credentials reset Remove a stored credential so onboard re-prompts + Cleanup: nemoclaw uninstall [flags] Run uninstall.sh (local first, curl fallback) @@ -1284,6 +1360,9 @@ const [cmd, ...args] = process.argv.slice(2); case "uninstall": uninstall(args); break; + case "credentials": + await credentialsCommand(args); + break; case "list": await listSandboxes(); break; diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index 01770eb7a..a23d3646b 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -86,6 +86,20 @@ export function getCredential(key: string): string | null { return value || null; } +export function deleteCredential(key: string): boolean { + const file = getCredsFile(); + if (!fs.existsSync(file)) return false; + const creds = loadCredentials(); + if (!Object.prototype.hasOwnProperty.call(creds, key)) return false; + delete creds[key]; + writeConfigFile(file, creds); + return true; +} + +export function listCredentialKeys(): string[] { + return Object.keys(loadCredentials()).sort(); +} + export function promptSecret(question: string): Promise { return new Promise((resolve, reject) => { const input = process.stdin; @@ -118,7 +132,10 @@ export function promptSecret(question: string): Promise { const ch = text[i]; if (ch === "\u0003") { - finish(reject as (value: string | Error) => void, Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" })); + finish( + reject as (value: string | Error) => void, + Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" }), + ); return; } diff --git a/test/credentials.test.js b/test/credentials.test.js index e66dcb265..09a1f9cb7 100644 --- a/test/credentials.test.js +++ b/test/credentials.test.js @@ -71,6 +71,41 @@ describe("credential prompts", () => { expect(credentials.getCredential("EMPTY_VALUE")).toBe(null); }); + it("deleteCredential removes a stored key and leaves the file mode intact", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-creds-")); + const credentials = await importCredentialsModule(home); + + credentials.saveCredential("NVIDIA_API_KEY", "nvapi-bad-key"); + credentials.saveCredential("OTHER_KEY", "other-value"); + const credsFile = path.join(home, ".nemoclaw", "credentials.json"); + expect(fs.statSync(credsFile).mode & 0o777).toBe(0o600); + expect(credentials.listCredentialKeys()).toEqual(["NVIDIA_API_KEY", "OTHER_KEY"]); + + expect(credentials.deleteCredential("NVIDIA_API_KEY")).toBe(true); + expect(fs.statSync(credsFile).mode & 0o777).toBe(0o600); + expect(credentials.getCredential("NVIDIA_API_KEY")).toBe(null); + expect(credentials.listCredentialKeys()).toEqual(["OTHER_KEY"]); + expect(credentials.getCredential("OTHER_KEY")).toBe("other-value"); + + // Removing the same key twice is a no-op that returns false. + expect(credentials.deleteCredential("NVIDIA_API_KEY")).toBe(false); + }); + + it("deleteCredential returns false when no credentials file exists", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-creds-")); + const credentials = await importCredentialsModule(home); + expect(credentials.deleteCredential("ANYTHING")).toBe(false); + }); + + it("listCredentialKeys returns sorted key names without exposing values", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-creds-")); + const credentials = await importCredentialsModule(home); + expect(credentials.listCredentialKeys()).toEqual([]); + credentials.saveCredential("ZETA", "z"); + credentials.saveCredential("ALPHA", "a"); + expect(credentials.listCredentialKeys()).toEqual(["ALPHA", "ZETA"]); + }); + it("exits cleanly when answers are staged through a pipe", () => { const script = ` set -euo pipefail From caa998d5e725f03405c91c052004811672d9b9b2 Mon Sep 17 00:00:00 2001 From: latenighthackathon Date: Wed, 8 Apr 2026 19:13:02 -0500 Subject: [PATCH 2/2] fix(cli): reject flag-as-key and unknown args in credentials reset 'nemoclaw credentials reset --yes' (with no key) previously treated '--yes' as and reported 'No stored credential found for --yes'. Validate that is a non-flag positional, and reject any trailing arguments other than --yes / -y so scripted use stays predictable. Addresses CodeRabbit feedback on #1597. Signed-off-by: latenighthackathon --- bin/nemoclaw.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 2b7597a99..dfc1a921d 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -914,11 +914,21 @@ async function credentialsCommand(args) { if (sub === "reset") { const key = args[1]; - if (!key) { + // Validate that is a real positional argument, not a flag like + // `--yes` that the user passed without a key. Without this guard, the + // missing-key path would mistakenly look up '--yes' as a credential. + if (!key || key.startsWith("-")) { console.error(" Usage: nemoclaw credentials reset [--yes]"); console.error(" Run 'nemoclaw credentials list' to see stored keys."); process.exit(1); } + // Reject unknown trailing arguments to keep scripted use predictable. + const extraArgs = args.slice(2).filter((arg) => arg !== "--yes" && arg !== "-y"); + if (extraArgs.length > 0) { + console.error(` Unknown argument(s) for credentials reset: ${extraArgs.join(", ")}`); + console.error(" Usage: nemoclaw credentials reset [--yes]"); + process.exit(1); + } // Only consult the persisted credentials file — getCredential() falls back // to process.env, which would let an env-only key pass this check even // though there is nothing on disk to delete.