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
95 changes: 92 additions & 3 deletions bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -69,6 +74,7 @@ const GLOBAL_COMMANDS = new Set([
"status",
"debug",
"uninstall",
"credentials",
"help",
"--help",
"-h",
Expand Down Expand Up @@ -878,6 +884,84 @@ 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 <subcommand>");
console.log("");
console.log(" Subcommands:");
console.log(" list List stored credential keys (values are not printed)");
console.log(" reset <KEY> [--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];
// Validate that <KEY> 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 <KEY> [--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 <KEY> [--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.
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({
Expand Down Expand Up @@ -1082,7 +1166,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;

Expand Down Expand Up @@ -1137,7 +1220,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]: `,
);
Expand Down Expand Up @@ -1228,6 +1310,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 <KEY> Remove a stored credential so onboard re-prompts

Cleanup:
nemoclaw uninstall [flags] Run uninstall.sh (local first, curl fallback)

Expand Down Expand Up @@ -1284,6 +1370,9 @@ const [cmd, ...args] = process.argv.slice(2);
case "uninstall":
uninstall(args);
break;
case "credentials":
await credentialsCommand(args);
break;
case "list":
await listSandboxes();
break;
Expand Down
19 changes: 18 additions & 1 deletion src/lib/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
return new Promise((resolve, reject) => {
const input = process.stdin;
Expand Down Expand Up @@ -118,7 +132,10 @@ export function promptSecret(question: string): Promise<string> {
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;
}

Expand Down
35 changes: 35 additions & 0 deletions test/credentials.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading