diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index 2285ee4d26e..b625069ae8a 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -89,15 +89,20 @@ Instead, collect the required choices from me first, one clickable selection at ## Handle Tokens Securely and Visually -When you need an API key, bot token, app token, or other secret, prefer a local visual credential form instead of chat. - -- Ask permission before creating a local credential form. -- Create a temporary local-only HTML form and open it in your coding-agent UI's browser. Bind any helper server to \`127.0.0.1\` on a random local port. Do not use external scripts, analytics, CDNs, or network resources. -- Use password-style inputs for secret values and normal text inputs for non-secret IDs such as server IDs, allowlists, endpoint URLs, and sandbox names. +When you need an API key, bot token, app token, or other secret, prefer the checked-in NemoClaw local credential form instead of chat. + +- Ask permission before opening a local credential form. +- Do not generate, rewrite, or redesign credential-form HTML. Use the repository form template from this URL exactly: https://raw.githubusercontent.com/NVIDIA/NemoClaw/c9aac7dc12bacdaa4d38af552b893021049ee836/docs/resources/local-credential-form.html +- Fetch that template or use the local repo copy when available, verify its SHA-256 digest is \`cc746703ab514cf33d7131915f16e8dc19346b26a4d953c5125be81449d6e6f6\`, write the exact bytes into a private temporary directory, then serve it from a helper bound to \`127.0.0.1\` on a random local port. +- Treat that immutable URL and digest as one reviewed trust boundary. Stop if verification fails; do not substitute a different URL, template, or digest. +- Open the served loopback URL, not the raw GitHub URL, in your coding-agent UI's browser. Configure fields with query parameters such as \`?fields=NVIDIA_INFERENCE_API_KEY:secret\` or \`?fields=NEMOCLAW_ENDPOINT_URL:text,NEMOCLAW_MODEL:text,COMPATIBLE_API_KEY:secret\`. +- Implement only the tiny loopback helper around the template: serve the HTML file, accept its \`POST /submit\` JSON payload, keep submitted values in memory, and expose no external network listener. When serving the HTML response, include the HTTP header \`Content-Security-Policy: frame-ancestors 'none'\` because browsers do not enforce that directive from a meta tag. +- Use \`:secret\` fields for secret values and \`:text\` fields for non-secret IDs such as server IDs, allowlists, endpoint URLs, and sandbox names. - Keep submitted secrets only in memory long enough to run the approved command. Do not print them, write them to logs, commit them, or paste them into chat. - If you must write a temporary file for the helper, use a private temporary directory, restrict permissions when possible, and delete it immediately after use. - Show me a redacted summary before running commands, such as \`TELEGRAM_BOT_TOKEN=********\`, and ask permission to continue. - After the command finishes, shut down the local helper and delete the temporary HTML file. +- If your environment cannot serve a loopback helper, use the local terminal, a secure secret prompt, or the local app prompt that needs the credential. Do not fall back to generated form HTML. Use this provider mapping for non-interactive setup: diff --git a/docs/get-started/quickstart-hermes.mdx b/docs/get-started/quickstart-hermes.mdx index 1d394da60d7..f3f38f6a28e 100644 --- a/docs/get-started/quickstart-hermes.mdx +++ b/docs/get-started/quickstart-hermes.mdx @@ -28,7 +28,7 @@ The first Hermes build can take several minutes because NemoClaw builds the Herm Copy the starter prompt into Cursor, Claude Code, Codex, Copilot, or another local coding agent when you want the assistant to install NemoClaw with you. The prompt points your agent to [AI Agent Docs](../resources/agent-skills), this quickstart, the Markdown docs, and the optional `nemoclaw-user-guide` skill. -It also asks your agent to confirm Hermes as the selected agent before it builds the install or onboard command. +It also asks your agent to confirm Hermes as the selected agent before it builds the install or onboard command, and to reuse the checked-in local credential form for secrets. diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 5a98ebf47ca..40327dc4c13 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -24,7 +24,7 @@ Review the [Prerequisites](prerequisites) before following this guide. Copy the starter prompt into Cursor, Claude Code, Codex, Copilot, or another local coding agent when you want the assistant to install NemoClaw with you. The prompt points your agent to [AI Agent Docs](../resources/agent-skills), this quickstart, the Markdown docs, and the optional `nemoclaw-user-guide` skill. -It also tells your agent to collect choices before launching interactive commands and to handle credentials outside the chat transcript. +It also tells your agent to collect choices before launching interactive commands and to reuse the checked-in local credential form for secrets. diff --git a/docs/index.mdx b/docs/index.mdx index 4c74dcfdfd5..715fd285b7b 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -45,7 +45,7 @@ Install NemoClaw and run the onboard wizard to get started. ### From Your Coding Agent Copy the starter prompt and paste it into your local coding agent, such as Cursor, Claude Code, Codex, Copilot, or another assistant that can run local commands with your approval. -The prompt tells your agent to use NemoClaw skills when available, bootstrap the docs-routing skill when it is missing, fetch the Markdown docs, ask whether you want OpenClaw, Hermes, or Deep Agents, collect choices one question at a time, and avoid asking you to paste secrets into chat. +The prompt tells your agent to use NemoClaw skills when available, bootstrap the docs-routing skill when it is missing, fetch the Markdown docs, ask whether you want OpenClaw, Hermes, or Deep Agents, collect choices one question at a time, and reuse the checked-in local credential form instead of asking you to paste secrets into chat. diff --git a/docs/resources/agent-skills.mdx b/docs/resources/agent-skills.mdx index f94f7fc5073..da13e871bcf 100644 --- a/docs/resources/agent-skills.mdx +++ b/docs/resources/agent-skills.mdx @@ -20,7 +20,7 @@ Use this page when you want your agent to help with installation, inference conf ## Give Your Agent the Starter Prompt The fastest path is to copy the starter prompt from the NemoClaw home page and paste it into your local coding agent. -The prompt tells the agent to use NemoClaw skills when available, bootstrap the docs-routing skill when missing, use the Markdown docs, ask one question at a time, run commands only with permission, and handle credentials safely. +The prompt tells the agent to use NemoClaw skills when available, bootstrap the docs-routing skill when missing, use the Markdown docs, ask one question at a time, run commands only with permission, and reuse the checked-in local credential form for secrets. NemoClaw keeps the prompt text in a shared docs source so the copy button and manual fallback render the same content. diff --git a/docs/resources/local-credential-form.html b/docs/resources/local-credential-form.html new file mode 100644 index 00000000000..e4998091dc3 --- /dev/null +++ b/docs/resources/local-credential-form.html @@ -0,0 +1,409 @@ + + + + + + + + + NemoClaw Local Credential Form + + + +
+

NemoClaw Local Credential Form

+

+ Enter credentials here only when your coding agent opened this page from a local + 127.0.0.1 helper server. +

+
+ This page sends values only to the loopback helper that served it. +
+
+
+ +
+
+
+ + + diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index e7cd30ec366..31f8f4b6183 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -2,8 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import { createHash } from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import vm from "node:vm"; import { describe, expect, it } from "vitest"; const __filename = fileURLToPath(import.meta.url); @@ -17,6 +19,28 @@ const starterPromptButtonSource = path.join( "_components", "StarterPromptButton.tsx", ); +const localCredentialFormSource = path.join( + repoRoot, + "docs", + "resources", + "local-credential-form.html", +); +const localCredentialFormUrl = + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/c9aac7dc12bacdaa4d38af552b893021049ee836/docs/resources/local-credential-form.html"; +const localCredentialFormSha256 = + "cc746703ab514cf33d7131915f16e8dc19346b26a4d953c5125be81449d6e6f6"; // gitleaks:allow -- checked-in SHA-256 fixture +const localCredentialFormScriptCspHash = [ + "'sha256-7knX1kPQ", + "ir4x3z0uoR2GmEi9", + "hb0+82UEW2o9BzJD", + "520='", +].join(""); +const localCredentialFormStyleCspHash = [ + "'sha256-W4wSJyrm", + "RXSCgQSjhVRZBhE", + "msaHh6dbUj9ZlKh", + "xipME='", +].join(""); const starterPromptPages = [ "docs/index.mdx", "docs/get-started/quickstart.mdx", @@ -29,6 +53,200 @@ function read(relativePath: string): string { return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); } +function urlsIn(content: string): URL[] { + return Array.from(content.matchAll(/https?:\/\/[^\s"'<>;]+/g), ([match]) => new URL(match)); +} + +function fail(message: string): never { + throw new Error(message); +} + +function extractTagContent(content: string, tagName: "script" | "style"): string { + const match = + content.match(new RegExp(`<${tagName}>([\\s\\S]*?)`)) ?? + fail(`Missing <${tagName}> block`); + return match[1]; +} + +function sha256Source(content: string): string { + return `'sha256-${createHash("sha256").update(content).digest("base64")}'`; +} + +function cspMetaContent(content: string): string { + return ( + content.match(/http-equiv="Content-Security-Policy"[\s\S]*?content="([^"]+)"/)?.[1] ?? + fail("Missing Content-Security-Policy meta content") + ); +} + +class FakeClassList { + readonly values = new Set(); + + add(value: string): void { + this.values.add(value); + } + + has(value: string): boolean { + return this.values.has(value); + } +} + +class FakeElement { + readonly attributes = new Map(); + readonly children: FakeElement[] = []; + readonly classList = new FakeClassList(); + readonly dataset: Record = {}; + readonly style: Record = {}; + readonly listeners = new Map< + string, + (event: { preventDefault: () => void }) => Promise | void + >(); + autocomplete = ""; + className = ""; + disabled = false; + id = ""; + name = ""; + required = false; + spellcheck = true; + textContent = ""; + type = ""; + value = ""; + + constructor(readonly tagName: string) {} + + append(...elements: FakeElement[]): void { + this.children.push(...elements); + } + + replaceChildren(...elements: FakeElement[]): void { + this.children.splice(0, this.children.length, ...elements); + this.textContent = ""; + } + + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + addEventListener( + name: string, + listener: (event: { preventDefault: () => void }) => Promise | void, + ): void { + this.listeners.set(name, listener); + } + + querySelectorAll(selector: string): FakeElement[] { + const result: FakeElement[] = []; + const visit = (element: FakeElement) => { + const matchesSecretInput = + selector === "input[data-secret='true']" && + element.tagName === "input" && + element.dataset.secret === "true"; + matchesSecretInput && result.push(element); + for (const child of element.children) { + visit(child); + } + }; + visit(this); + return result; + } + + allText(): string { + return [this.textContent, ...this.children.map((child) => child.allText())].join(""); + } +} + +class FakeDocument { + readonly elements = new Map(); + + constructor() { + for (const [id, tagName] of [ + ["fields", "div"], + ["credential-form", "form"], + ["result", "section"], + ["submit-button", "button"], + ["origin-notice", "div"], + ] as const) { + const element = new FakeElement(tagName); + element.id = id; + this.elements.set(id, element); + } + this.getElementById("credential-form").append( + this.getElementById("fields"), + this.getElementById("submit-button"), + ); + } + + getElementById(id: string): FakeElement { + return this.elements.get(id) ?? fail(`Missing fake element ${id}`); + } + + createElement(tagName: string): FakeElement { + return new FakeElement(tagName); + } +} + +class FakeFormData { + readonly entriesList: Array<[string, string]> = []; + + constructor(form: FakeElement) { + const visit = (element: FakeElement) => { + element.tagName === "input" && + element.name && + this.entriesList.push([element.name, element.value]); + for (const child of element.children) { + visit(child); + } + }; + visit(form); + } + + entries(): ArrayIterator<[string, string]> { + return this.entriesList.values(); + } +} + +function runCredentialForm(url: string, fetchImpl = async () => ({ ok: true, status: 200 })) { + const formSource = fs.readFileSync(localCredentialFormSource, "utf8"); + const script = extractTagContent(formSource, "script"); + const parsedUrl = new URL(url); + const document = new FakeDocument(); + const fetchCalls: Array<{ url: string; init?: unknown }> = []; + const context = { + console: { error: () => undefined }, + document, + Error, + fetch: async (target: string, init?: unknown) => { + fetchCalls.push({ url: target, init }); + return fetchImpl(); + }, + FormData: FakeFormData, + URLSearchParams, + window: { + location: { + hostname: parsedUrl.hostname, + href: parsedUrl.href, + search: parsedUrl.search, + }, + }, + }; + vm.runInNewContext(script, context); + + const form = document.getElementById("credential-form"); + return { + document, + fetchCalls, + fieldsElement: document.getElementById("fields"), + form, + originNotice: document.getElementById("origin-notice"), + resultElement: document.getElementById("result"), + submit: async () => { + const listener = form.listeners.get("submit") ?? fail("Missing submit listener"); + await listener({ preventDefault: () => undefined }); + }, + submitButton: document.getElementById("submit-button"), + }; +} + describe("starter prompt docs CTA", () => { it("keeps the button and manual fallback on one shared prompt source (#5048)", () => { const promptSource = fs.readFileSync(starterPromptSource, "utf8"); @@ -62,6 +280,139 @@ describe("starter prompt docs CTA", () => { ); }); + it("pins local credential capture to the checked-in form template (#5048)", () => { + const promptSource = fs.readFileSync(starterPromptSource, "utf8"); + const formSource = fs.readFileSync(localCredentialFormSource, "utf8"); + + expect(promptSource).toContain(localCredentialFormUrl); + expect(promptSource).toContain(localCredentialFormSha256); + expect(createHash("sha256").update(formSource).digest("hex")).toBe(localCredentialFormSha256); + expect(localCredentialFormUrl).toMatch(/\/[0-9a-f]{40}\//); + expect(localCredentialFormUrl).not.toMatch(/\/(?:main|master)\//); + expect(promptSource).toContain("Do not generate, rewrite, or redesign credential-form HTML."); + expect(promptSource).toContain("immutable URL and digest as one reviewed trust boundary"); + expect(promptSource).toContain("serve it from a helper bound to \\`127.0.0.1\\`"); + expect(promptSource).toContain("?fields=NVIDIA_INFERENCE_API_KEY:secret"); + expect(formSource).toContain("NemoClaw Local Credential Form"); + expect(formSource).toContain("Content-Security-Policy"); + expect(formSource).toContain("connect-src 'self';"); + expect(formSource).not.toContain("'unsafe-inline'"); + expect(formSource).toContain(`script-src ${localCredentialFormScriptCspHash};`); + expect(formSource).toContain(`style-src ${localCredentialFormStyleCspHash};`); + expect(formSource).toContain( + `style-src ${sha256Source(extractTagContent(formSource, "style"))};`, + ); + expect(formSource).toContain( + `script-src ${sha256Source(extractTagContent(formSource, "script"))};`, + ); + expect(cspMetaContent(formSource)).not.toContain("frame-ancestors"); + expect(promptSource).toContain("Content-Security-Policy: frame-ancestors 'none'"); + expect(formSource).toContain('const LOCAL_SUBMIT_PATH = "/submit";'); + expect(formSource).toContain("fetch(LOCAL_SUBMIT_PATH"); + expect(formSource).not.toContain('params.get("submit")'); + for (const url of urlsIn(formSource)) { + expect(["127.0.0.1", "localhost", "[::1]"], url.href).toContain(url.hostname); + } + expect(formSource).not.toContain("localStorage"); + expect(formSource).not.toContain("sessionStorage"); + }); + + it("warns and disables submit when credential fields are missing or invalid (#5048)", async () => { + const missing = runCredentialForm("http://127.0.0.1:4123/local-credential-form.html"); + expect(missing.submitButton.disabled).toBe(true); + expect(missing.fieldsElement.children).toHaveLength(0); + expect(missing.resultElement.allText()).toContain("Credential fields are not configured."); + + const invalid = runCredentialForm( + "http://127.0.0.1:4123/local-credential-form.html?fields=bad-name:secret,VALID_NAME:text", + ); + expect(invalid.submitButton.disabled).toBe(true); + expect(invalid.fieldsElement.children.map((child) => child.textContent)).toContain( + "Valid Name", + ); + expect(invalid.resultElement.allText()).toContain("Rejected specs: bad-name:secret"); + await invalid.submit(); + expect(invalid.fetchCalls).toHaveLength(0); + + const allInvalid = runCredentialForm( + "http://127.0.0.1:4123/local-credential-form.html?fields=bad-name:secret", + ); + expect(allInvalid.submitButton.disabled).toBe(true); + expect(allInvalid.fieldsElement.children).toHaveLength(0); + expect(allInvalid.resultElement.allText()).toContain("Rejected specs: bad-name:secret"); + + for (const malformedUrl of [ + "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN", + "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:unknown", + "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:text:extra", + "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:secret,SECRET_TOKEN:text", + "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:secret,", + "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:secret&fields=PUBLIC_ID:text", + "http://127.0.0.1:4123/local-credential-form.html?field=SECRET_TOKEN:secret&fields=PUBLIC_ID:text", + ]) { + const malformed = runCredentialForm(malformedUrl); + expect(malformed.submitButton.disabled, malformedUrl).toBe(true); + expect(malformed.resultElement.allText(), malformedUrl).toContain("rejected"); + await malformed.submit(); + expect(malformed.fetchCalls, malformedUrl).toHaveLength(0); + } + }); + + it("submits only to the loopback helper and redacts secret values (#5048)", async () => { + const repeated = runCredentialForm( + "http://127.0.0.1:4123/local-credential-form.html?field=SECRET_TOKEN:secret&field=PUBLIC_ID:text", + ); + const repeatedInputs = repeated.fieldsElement.children.filter( + (child) => child.tagName === "input", + ); + expect(repeatedInputs.map(({ name, type }) => [name, type])).toEqual([ + ["SECRET_TOKEN", "password"], + ["PUBLIC_ID", "text"], + ]); + expect(repeated.submitButton.disabled).toBe(false); + + const rendered = runCredentialForm( + "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:secret,PUBLIC_ID:text&submit=http://127.0.0.1:9/capture", + ); + const inputs = rendered.fieldsElement.children.filter((child) => child.tagName === "input"); + const secretInput = inputs.find((input) => input.name === "SECRET_TOKEN"); + const textInput = inputs.find((input) => input.name === "PUBLIC_ID"); + expect(secretInput?.type).toBe("password"); + expect(textInput?.type).toBe("text"); + + secretInput!.value = "super-secret"; + textInput!.value = "public-id"; + await rendered.submit(); + + expect(rendered.fetchCalls).toHaveLength(1); + expect(rendered.fetchCalls[0]?.url).toBe("/submit"); + expect(secretInput?.value).toBe(""); + expect(textInput?.value).toBe("public-id"); + expect(rendered.resultElement.allText()).toContain("SECRET_TOKEN=********"); + expect(rendered.resultElement.allText()).toContain("PUBLIC_ID=public-id"); + expect(rendered.resultElement.allText()).not.toContain("super-secret"); + }); + + it("disables submit outside loopback and shows helper-friendly failures (#5048)", async () => { + const nonLoopback = runCredentialForm( + "https://example.com/local-credential-form.html?fields=SECRET_TOKEN:secret", + ); + expect(nonLoopback.submitButton.disabled).toBe(true); + expect(nonLoopback.originNotice.classList.has("warning")).toBe(true); + await nonLoopback.submit(); + expect(nonLoopback.submitButton.disabled).toBe(true); + expect(nonLoopback.fetchCalls).toHaveLength(0); + + const helperFailure = runCredentialForm( + "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:secret", + async () => ({ ok: false, status: 500 }), + ); + await helperFailure.submit(); + expect(helperFailure.resultElement.allText()).toContain( + "Ask your coding agent to check the local helper and reopen the credential form.", + ); + }); + it("keeps Deep Agents as a selectable starter prompt option (#5048)", () => { const promptSource = fs.readFileSync(starterPromptSource, "utf8");