From 24f33f8ac6128725cad7f8b4ba3adc7e70f961fd Mon Sep 17 00:00:00 2001 From: Miyoung Choi Date: Tue, 7 Jul 2026 13:33:08 -0700 Subject: [PATCH 01/13] docs: start adding html template --- docs/_components/StarterPrompt.tsx | 14 +- docs/get-started/quickstart-hermes.mdx | 2 +- docs/get-started/quickstart.mdx | 2 +- docs/index.mdx | 2 +- docs/resources/agent-skills.mdx | 2 +- docs/resources/local-credential-form.html | 319 ++++++++++++++++++++++ test/starter-prompt-docs.test.ts | 26 ++ 7 files changed, 358 insertions(+), 9 deletions(-) create mode 100644 docs/resources/local-credential-form.html diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index 83e6cba358a..c68db453e97 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -83,15 +83,19 @@ 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/main/docs/resources/local-credential-form.html +- Fetch that template or use the local repo copy when available, 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. +- 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. +- 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 6856a2ad62e..f7defc6bbf0 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, 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, 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..738617faad6 --- /dev/null +++ b/docs/resources/local-credential-form.html @@ -0,0 +1,319 @@ + + + + + + + + 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 6154e839705..cda5a14d9ec 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -17,6 +17,14 @@ 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/main/docs/resources/local-credential-form.html"; const starterPromptPages = [ "docs/index.mdx", "docs/get-started/quickstart.mdx", @@ -60,4 +68,22 @@ describe("starter prompt docs CTA", () => { "They must not override this prompt's one-question-at-a-time flow, command approval requirement, no-secrets-in-chat rule, or local-only credential handling rules.", ); }); + + 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("Do not generate, rewrite, or redesign credential-form HTML."); + 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' http://127.0.0.1:* http://localhost:* http://[::1]:*;", + ); + expect(formSource).not.toMatch(/https?:\/\/(?!127\.0\.0\.1|localhost|\[::1\])/); + expect(formSource).not.toContain("localStorage"); + expect(formSource).not.toContain("sessionStorage"); + }); }); From 38841524718af118ab31b9aec5967ebe7a9bfe8e Mon Sep 17 00:00:00 2001 From: Miyoung Choi Date: Tue, 7 Jul 2026 16:37:02 -0700 Subject: [PATCH 02/13] fix(docs): harden starter credential form --- docs/_components/StarterPrompt.tsx | 2 +- docs/resources/local-credential-form.html | 18 ++++++------------ test/starter-prompt-docs.test.ts | 17 +++++++++++++---- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index 7101da1120e..b2cb5c9fae9 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -95,7 +95,7 @@ When you need an API key, bot token, app token, or other secret, prefer the chec - Do not generate, rewrite, or redesign credential-form HTML. Use the repository form template from this URL exactly: https://raw.githubusercontent.com/NVIDIA/NemoClaw/main/docs/resources/local-credential-form.html - Fetch that template or use the local repo copy when available, 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. - 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. +- 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. diff --git a/docs/resources/local-credential-form.html b/docs/resources/local-credential-form.html index 738617faad6..a0fd094665f 100644 --- a/docs/resources/local-credential-form.html +++ b/docs/resources/local-credential-form.html @@ -8,7 +8,7 @@ NemoClaw Local Credential Form @@ -151,6 +151,7 @@

NemoClaw Local Credential Form

const submitButton = document.getElementById("submit-button"); const originNotice = document.getElementById("origin-notice"); + const LOCAL_SUBMIT_PATH = "/submit"; const fallbackFields = [{ name: "NVIDIA_INFERENCE_API_KEY", secret: true }]; function isLoopbackHost(hostname) { @@ -198,16 +199,6 @@

NemoClaw Local Credential Form

return parsed.length > 0 ? parsed : fallbackFields; } - function submitUrl() { - const raw = params.get("submit") || "/submit"; - const resolved = new URL(raw, window.location.href); - const sameOrigin = resolved.origin === window.location.origin; - if (sameOrigin || isLoopbackHost(resolved.hostname)) { - return resolved.toString(); - } - throw new Error("The submit URL must stay on this local helper or another loopback URL."); - } - function renderFields(fields) { fieldsElement.replaceChildren(); for (const field of fields) { @@ -287,7 +278,10 @@

NemoClaw Local Credential Form

const payload = { fields, values }; try { - const response = await fetch(submitUrl(), { + if (!isLoopbackHost(window.location.hostname)) { + throw new Error("This form only submits when served from a loopback helper."); + } + const response = await fetch(LOCAL_SUBMIT_PATH, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index 686817df08e..b5e86ba15c4 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -37,6 +37,10 @@ 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)); +} + describe("starter prompt docs CTA", () => { it("keeps the button and manual fallback on one shared prompt source (#5048)", () => { const promptSource = fs.readFileSync(starterPromptSource, "utf8"); @@ -80,10 +84,15 @@ describe("starter prompt docs CTA", () => { 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' http://127.0.0.1:* http://localhost:* http://[::1]:*;", - ); - expect(formSource).not.toMatch(/https?:\/\/(?!127\.0\.0\.1|localhost|\[::1\])/); + expect(formSource).toContain("connect-src 'self';"); + expect(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"); }); From 1e0ab5177a783860ffd5298ed91f9572465b2df5 Mon Sep 17 00:00:00 2001 From: Miyoung Choi Date: Tue, 7 Jul 2026 16:40:20 -0700 Subject: [PATCH 03/13] docs: pin starter credential form template --- docs/_components/StarterPrompt.tsx | 2 +- test/starter-prompt-docs.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index b2cb5c9fae9..ff559a30060 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -92,7 +92,7 @@ Instead, collect the required choices from me first, one clickable selection at 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/main/docs/resources/local-credential-form.html +- Do not generate, rewrite, or redesign credential-form HTML. Use the repository form template from this URL exactly: https://raw.githubusercontent.com/NVIDIA/NemoClaw/38841524718af118ab31b9aec5967ebe7a9bfe8e/docs/resources/local-credential-form.html - Fetch that template or use the local repo copy when available, 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. - 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. diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index b5e86ba15c4..aa8fabadf1f 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -24,7 +24,7 @@ const localCredentialFormSource = path.join( "local-credential-form.html", ); const localCredentialFormUrl = - "https://raw.githubusercontent.com/NVIDIA/NemoClaw/main/docs/resources/local-credential-form.html"; + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/38841524718af118ab31b9aec5967ebe7a9bfe8e/docs/resources/local-credential-form.html"; const starterPromptPages = [ "docs/index.mdx", "docs/get-started/quickstart.mdx", @@ -79,6 +79,8 @@ describe("starter prompt docs CTA", () => { const formSource = fs.readFileSync(localCredentialFormSource, "utf8"); expect(promptSource).toContain(localCredentialFormUrl); + expect(localCredentialFormUrl).toMatch(/\/[0-9a-f]{40}\//); + expect(localCredentialFormUrl).not.toContain("/main/"); expect(promptSource).toContain("Do not generate, rewrite, or redesign credential-form HTML."); expect(promptSource).toContain("serve it from a helper bound to \\`127.0.0.1\\`"); expect(promptSource).toContain("?fields=NVIDIA_INFERENCE_API_KEY:secret"); From f11ed0cadfc215cbedcb9bd25da290f9436df1e2 Mon Sep 17 00:00:00 2001 From: Miyoung Choi Date: Tue, 7 Jul 2026 17:05:48 -0700 Subject: [PATCH 04/13] fix(docs): validate local credential form behavior --- docs/_components/StarterPrompt.tsx | 2 +- docs/resources/local-credential-form.html | 71 +++++- test/starter-prompt-docs.test.ts | 269 ++++++++++++++++++++++ 3 files changed, 334 insertions(+), 8 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index ff559a30060..1c3349c70b5 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -93,7 +93,7 @@ When you need an API key, bot token, app token, or other secret, prefer the chec - 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/38841524718af118ab31b9aec5967ebe7a9bfe8e/docs/resources/local-credential-form.html -- Fetch that template or use the local repo copy when available, 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. +- Fetch that template or use the local repo copy when available, verify its SHA-256 digest is \`83b79b6420ff2c9e82c95da6eb714d51e118325514df911d8ebf5d4327ebda79\`, 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. - 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. diff --git a/docs/resources/local-credential-form.html b/docs/resources/local-credential-form.html index a0fd094665f..59be170ad3d 100644 --- a/docs/resources/local-credential-form.html +++ b/docs/resources/local-credential-form.html @@ -8,7 +8,7 @@ NemoClaw Local Credential Form @@ -152,7 +152,6 @@

NemoClaw Local Credential Form

const originNotice = document.getElementById("origin-notice"); const LOCAL_SUBMIT_PATH = "/submit"; - const fallbackFields = [{ name: "NVIDIA_INFERENCE_API_KEY", secret: true }]; function isLoopbackHost(hostname) { return ( @@ -194,9 +193,24 @@

NemoClaw Local Credential Form

?.split(",") .map((field) => field.trim()) .filter(Boolean); - const specs = repeatedSpecs.length > 0 ? repeatedSpecs : combinedSpecs; - const parsed = specs?.map(parseFieldSpec).filter(Boolean) ?? []; - return parsed.length > 0 ? parsed : fallbackFields; + const specs = repeatedSpecs.length > 0 ? repeatedSpecs : (combinedSpecs ?? []); + const fields = []; + const rejected = []; + + for (const spec of specs) { + const parsed = parseFieldSpec(spec); + if (parsed) { + fields.push(parsed); + } else { + rejected.push(spec || ""); + } + } + + return { + fields, + missing: specs.length === 0, + rejected, + }; } function renderFields(fields) { @@ -259,8 +273,51 @@

NemoClaw Local Credential Form

resultElement.replaceChildren(notice); } - const fields = configuredFields(); + function applyFieldConfigurationNotice(configuration) { + if (configuration.missing) { + submitButton.disabled = true; + showNotice({ + warning: true, + paragraphs: [ + "Credential fields are not configured.", + "Ask your coding agent to reopen this form with a fields query such as ?fields=NVIDIA_INFERENCE_API_KEY:secret.", + ], + }); + return; + } + + if (configuration.rejected.length > 0) { + const paragraphs = [ + "Some credential field specs were rejected.", + `Rejected specs: ${configuration.rejected.join(", ")}`, + "Ask your coding agent to fix the field configuration before continuing.", + ]; + if (configuration.fields.length === 0) { + submitButton.disabled = true; + } + showNotice({ warning: true, paragraphs }); + } + } + + function userFacingSubmitError(error) { + console.error("Local credential helper submission failed:", error); + const message = error instanceof Error ? error.message : String(error); + if (message.includes("Failed to fetch") || message.includes("NetworkError")) { + return "Helper server is not responding. Ask your coding agent to restart the local credential form."; + } + if (message.startsWith("Local helper returned HTTP")) { + return `${message}. Ask your coding agent to check the local helper and reopen the credential form.`; + } + if (message.startsWith("This form only submits")) { + return message; + } + return "The local helper could not accept the credentials. Ask your coding agent to restart the local credential form."; + } + + const fieldConfiguration = configuredFields(); + const fields = fieldConfiguration.fields; renderFields(fields); + applyFieldConfigurationNotice(fieldConfiguration); if (!isLoopbackHost(window.location.hostname)) { submitButton.disabled = true; @@ -301,7 +358,7 @@

NemoClaw Local Credential Form

showNotice({ warning: true, paragraphs: [ - error.message, + userFacingSubmitError(error), "No values were accepted. Keep this page open and ask your coding agent to fix the local helper.", ], }); diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index aa8fabadf1f..45186123841 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); @@ -25,6 +27,12 @@ const localCredentialFormSource = path.join( ); const localCredentialFormUrl = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/38841524718af118ab31b9aec5967ebe7a9bfe8e/docs/resources/local-credential-form.html"; +const localCredentialFormSha256 = [ + "83b79b6420ff2c9e", + "82c95da6eb714d51", + "e118325514df911d", + "8ebf5d4327ebda79", +].join(""); const starterPromptPages = [ "docs/index.mdx", "docs/get-started/quickstart.mdx", @@ -41,6 +49,195 @@ function urlsIn(content: string): URL[] { return Array.from(content.matchAll(/https?:\/\/[^\s"'<>;]+/g), ([match]) => new URL(match)); } +function extractTagContent(content: string, tagName: "script" | "style"): string { + const match = content.match(new RegExp(`<${tagName}>([\\s\\S]*?)`)); + if (!match) { + throw new Error(`Missing <${tagName}> block`); + } + return match[1]; +} + +function sha256Source(content: string): string { + return `'sha256-${createHash("sha256").update(content).digest("base64")}'`; +} + +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"; + if (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 { + const element = this.elements.get(id); + if (!element) { + throw new Error(`Missing fake element ${id}`); + } + return element; + } + + createElement(tagName: string): FakeElement { + return new FakeElement(tagName); + } +} + +class FakeFormData { + readonly entriesList: Array<[string, string]> = []; + + constructor(form: FakeElement) { + const visit = (element: FakeElement) => { + if (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"); + if (!listener) { + throw new Error("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"); @@ -79,6 +276,8 @@ describe("starter prompt docs CTA", () => { 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.toContain("/main/"); expect(promptSource).toContain("Do not generate, rewrite, or redesign credential-form HTML."); @@ -87,6 +286,13 @@ describe("starter prompt docs CTA", () => { 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( + `style-src ${sha256Source(extractTagContent(formSource, "style"))};`, + ); + expect(formSource).toContain( + `script-src ${sha256Source(extractTagContent(formSource, "script"))};`, + ); expect(formSource).not.toContain("frame-ancestors"); expect(promptSource).toContain("Content-Security-Policy: frame-ancestors 'none'"); expect(formSource).toContain('const LOCAL_SUBMIT_PATH = "/submit";'); @@ -99,6 +305,69 @@ describe("starter prompt docs CTA", () => { expect(formSource).not.toContain("sessionStorage"); }); + it("warns and disables submit when credential fields are missing or invalid (#5048)", () => { + 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(false); + expect(invalid.fieldsElement.children.map((child) => child.textContent)).toContain( + "Valid Name", + ); + expect(invalid.resultElement.allText()).toContain("Rejected specs: bad-name:secret"); + + 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"); + }); + + it("submits only to the loopback helper and redacts secret values (#5048)", async () => { + 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); + + 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"); From 7aaa8d80fa70619fde9156df4c7a7d37ddc575dd Mon Sep 17 00:00:00 2001 From: Miyoung Choi Date: Tue, 7 Jul 2026 17:07:33 -0700 Subject: [PATCH 05/13] docs: pin validated credential form template --- docs/_components/StarterPrompt.tsx | 2 +- test/starter-prompt-docs.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index 1c3349c70b5..4769ac86f6e 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -92,7 +92,7 @@ Instead, collect the required choices from me first, one clickable selection at 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/38841524718af118ab31b9aec5967ebe7a9bfe8e/docs/resources/local-credential-form.html +- Do not generate, rewrite, or redesign credential-form HTML. Use the repository form template from this URL exactly: https://raw.githubusercontent.com/NVIDIA/NemoClaw/f11ed0cadfc215cbedcb9bd25da290f9436df1e2/docs/resources/local-credential-form.html - Fetch that template or use the local repo copy when available, verify its SHA-256 digest is \`83b79b6420ff2c9e82c95da6eb714d51e118325514df911d8ebf5d4327ebda79\`, 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. - 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. diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index 45186123841..67543d43023 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -26,7 +26,7 @@ const localCredentialFormSource = path.join( "local-credential-form.html", ); const localCredentialFormUrl = - "https://raw.githubusercontent.com/NVIDIA/NemoClaw/38841524718af118ab31b9aec5967ebe7a9bfe8e/docs/resources/local-credential-form.html"; + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/f11ed0cadfc215cbedcb9bd25da290f9436df1e2/docs/resources/local-credential-form.html"; const localCredentialFormSha256 = [ "83b79b6420ff2c9e", "82c95da6eb714d51", From da908448f5fd328e18aff9d8d088bc9dcf7edb04 Mon Sep 17 00:00:00 2001 From: Miyoung Choi Date: Tue, 7 Jul 2026 17:12:19 -0700 Subject: [PATCH 06/13] test(docs): keep credential form tests linear --- test/starter-prompt-docs.test.ts | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index 67543d43023..13d3edf84af 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -49,11 +49,14 @@ 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]*?)`)); - if (!match) { - throw new Error(`Missing <${tagName}> block`); - } + const match = + content.match(new RegExp(`<${tagName}>([\\s\\S]*?)`)) ?? + fail(`Missing <${tagName}> block`); return match[1]; } @@ -123,9 +126,7 @@ class FakeElement { selector === "input[data-secret='true']" && element.tagName === "input" && element.dataset.secret === "true"; - if (matchesSecretInput) { - result.push(element); - } + matchesSecretInput && result.push(element); for (const child of element.children) { visit(child); } @@ -161,11 +162,7 @@ class FakeDocument { } getElementById(id: string): FakeElement { - const element = this.elements.get(id); - if (!element) { - throw new Error(`Missing fake element ${id}`); - } - return element; + return this.elements.get(id) ?? fail(`Missing fake element ${id}`); } createElement(tagName: string): FakeElement { @@ -178,9 +175,9 @@ class FakeFormData { constructor(form: FakeElement) { const visit = (element: FakeElement) => { - if (element.tagName === "input" && element.name) { + element.tagName === "input" && + element.name && this.entriesList.push([element.name, element.value]); - } for (const child of element.children) { visit(child); } @@ -228,10 +225,7 @@ function runCredentialForm(url: string, fetchImpl = async () => ({ ok: true, sta originNotice: document.getElementById("origin-notice"), resultElement: document.getElementById("result"), submit: async () => { - const listener = form.listeners.get("submit"); - if (!listener) { - throw new Error("Missing submit listener"); - } + const listener = form.listeners.get("submit") ?? fail("Missing submit listener"); await listener({ preventDefault: () => undefined }); }, submitButton: document.getElementById("submit-button"), From b3f9a545ee5754603fcbceee9ed12f821ac93d1f Mon Sep 17 00:00:00 2001 From: Miyoung Choi Date: Tue, 7 Jul 2026 17:15:12 -0700 Subject: [PATCH 07/13] fix(docs): fail closed on invalid credential fields --- docs/_components/StarterPrompt.tsx | 2 +- docs/resources/local-credential-form.html | 23 ++++++++++++++++------- test/starter-prompt-docs.test.ts | 17 +++++++++++------ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index 4769ac86f6e..f9882252352 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -93,7 +93,7 @@ When you need an API key, bot token, app token, or other secret, prefer the chec - 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/f11ed0cadfc215cbedcb9bd25da290f9436df1e2/docs/resources/local-credential-form.html -- Fetch that template or use the local repo copy when available, verify its SHA-256 digest is \`83b79b6420ff2c9e82c95da6eb714d51e118325514df911d8ebf5d4327ebda79\`, 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. +- Fetch that template or use the local repo copy when available, verify its SHA-256 digest is \`93ce24d100c4448fc3b206365148cb6c774d84a0f88aae57f5ccdb16be8a3b7d\`, 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. - 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. diff --git a/docs/resources/local-credential-form.html b/docs/resources/local-credential-form.html index 59be170ad3d..30d5e3d83ed 100644 --- a/docs/resources/local-credential-form.html +++ b/docs/resources/local-credential-form.html @@ -8,7 +8,7 @@ NemoClaw Local Credential Form @@ -292,13 +292,20 @@

NemoClaw Local Credential Form

`Rejected specs: ${configuration.rejected.join(", ")}`, "Ask your coding agent to fix the field configuration before continuing.", ]; - if (configuration.fields.length === 0) { - submitButton.disabled = true; - } + submitButton.disabled = true; showNotice({ warning: true, paragraphs }); } } + function canSubmitCredentialForm() { + return ( + isLoopbackHost(window.location.hostname) && + fields.length > 0 && + fieldConfiguration.rejected.length === 0 && + !fieldConfiguration.missing + ); + } + function userFacingSubmitError(error) { console.error("Local credential helper submission failed:", error); const message = error instanceof Error ? error.message : String(error); @@ -335,8 +342,10 @@

NemoClaw Local Credential Form

const payload = { fields, values }; try { - if (!isLoopbackHost(window.location.hostname)) { - throw new Error("This form only submits when served from a loopback helper."); + if (!canSubmitCredentialForm()) { + throw new Error( + "This form only submits from loopback with a valid credential field configuration.", + ); } const response = await fetch(LOCAL_SUBMIT_PATH, { method: "POST", @@ -362,7 +371,7 @@

NemoClaw Local Credential Form

"No values were accepted. Keep this page open and ask your coding agent to fix the local helper.", ], }); - submitButton.disabled = false; + submitButton.disabled = !canSubmitCredentialForm(); } }); diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index 13d3edf84af..c9211d657fe 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -28,10 +28,10 @@ const localCredentialFormSource = path.join( const localCredentialFormUrl = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/f11ed0cadfc215cbedcb9bd25da290f9436df1e2/docs/resources/local-credential-form.html"; const localCredentialFormSha256 = [ - "83b79b6420ff2c9e", - "82c95da6eb714d51", - "e118325514df911d", - "8ebf5d4327ebda79", + "93ce24d100c4448f", + "c3b206365148cb6c", + "774d84a0f88aae57", + "f5ccdb16be8a3b7d", ].join(""); const starterPromptPages = [ "docs/index.mdx", @@ -299,7 +299,7 @@ describe("starter prompt docs CTA", () => { expect(formSource).not.toContain("sessionStorage"); }); - it("warns and disables submit when credential fields are missing or invalid (#5048)", () => { + 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); @@ -308,11 +308,13 @@ describe("starter prompt docs CTA", () => { 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(false); + 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", @@ -351,6 +353,9 @@ describe("starter prompt docs CTA", () => { ); 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", From 0ba83c67ef836192ea614f7186078da941fab0c9 Mon Sep 17 00:00:00 2001 From: Miyoung Choi Date: Tue, 7 Jul 2026 17:17:13 -0700 Subject: [PATCH 08/13] docs: pin fail-closed credential form template --- docs/_components/StarterPrompt.tsx | 2 +- test/starter-prompt-docs.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index f9882252352..c796b8e031d 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -92,7 +92,7 @@ Instead, collect the required choices from me first, one clickable selection at 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/f11ed0cadfc215cbedcb9bd25da290f9436df1e2/docs/resources/local-credential-form.html +- Do not generate, rewrite, or redesign credential-form HTML. Use the repository form template from this URL exactly: https://raw.githubusercontent.com/NVIDIA/NemoClaw/b3f9a545ee5754603fcbceee9ed12f821ac93d1f/docs/resources/local-credential-form.html - Fetch that template or use the local repo copy when available, verify its SHA-256 digest is \`93ce24d100c4448fc3b206365148cb6c774d84a0f88aae57f5ccdb16be8a3b7d\`, 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. - 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. diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index c9211d657fe..b0c44d4732f 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -26,7 +26,7 @@ const localCredentialFormSource = path.join( "local-credential-form.html", ); const localCredentialFormUrl = - "https://raw.githubusercontent.com/NVIDIA/NemoClaw/f11ed0cadfc215cbedcb9bd25da290f9436df1e2/docs/resources/local-credential-form.html"; + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/b3f9a545ee5754603fcbceee9ed12f821ac93d1f/docs/resources/local-credential-form.html"; const localCredentialFormSha256 = [ "93ce24d100c4448f", "c3b206365148cb6c", From 23dc96f4c60693ec47d09d442c2149743f7ede11 Mon Sep 17 00:00:00 2001 From: Miyoung Choi Date: Tue, 7 Jul 2026 17:35:52 -0700 Subject: [PATCH 09/13] docs: use main credential form URL --- docs/_components/StarterPrompt.tsx | 4 +-- docs/resources/local-credential-form.html | 1 + test/starter-prompt-docs.test.ts | 36 ++++++++++++++++++----- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index c796b8e031d..1ebacd8c07f 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -92,8 +92,8 @@ Instead, collect the required choices from me first, one clickable selection at 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/b3f9a545ee5754603fcbceee9ed12f821ac93d1f/docs/resources/local-credential-form.html -- Fetch that template or use the local repo copy when available, verify its SHA-256 digest is \`93ce24d100c4448fc3b206365148cb6c774d84a0f88aae57f5ccdb16be8a3b7d\`, 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. +- Do not generate, rewrite, or redesign credential-form HTML. Use the repository form template from this URL exactly: https://raw.githubusercontent.com/NVIDIA/NemoClaw/main/docs/resources/local-credential-form.html +- Fetch that template or use the local repo copy when available, verify its SHA-256 digest is \`57068851991cfa3e547827e564d1c842243a84c89d3dd14d5900c81d1f7b740b\`, 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. - 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. diff --git a/docs/resources/local-credential-form.html b/docs/resources/local-credential-form.html index 30d5e3d83ed..b106f61f9f7 100644 --- a/docs/resources/local-credential-form.html +++ b/docs/resources/local-credential-form.html @@ -6,6 +6,7 @@ + (); @@ -272,8 +291,7 @@ describe("starter prompt docs CTA", () => { 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.toContain("/main/"); + expect(localCredentialFormUrl).toContain("/main/"); expect(promptSource).toContain("Do not generate, rewrite, or redesign credential-form HTML."); expect(promptSource).toContain("serve it from a helper bound to \\`127.0.0.1\\`"); expect(promptSource).toContain("?fields=NVIDIA_INFERENCE_API_KEY:secret"); @@ -281,13 +299,15 @@ describe("starter prompt docs CTA", () => { 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(formSource).not.toContain("frame-ancestors"); + 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"); From 20dd8657ab4e1b2c9e21fb252750c9f6471a3ebc Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 17:59:37 -0700 Subject: [PATCH 10/13] fix(docs): pin credential form to immutable commit --- docs/_components/StarterPrompt.tsx | 2 +- test/starter-prompt-docs.test.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index 1ebacd8c07f..ab889a9fe51 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -92,7 +92,7 @@ Instead, collect the required choices from me first, one clickable selection at 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/main/docs/resources/local-credential-form.html +- Do not generate, rewrite, or redesign credential-form HTML. Use the repository form template from this URL exactly: https://raw.githubusercontent.com/NVIDIA/NemoClaw/23dc96f4c60693ec47d09d442c2149743f7ede11/docs/resources/local-credential-form.html - Fetch that template or use the local repo copy when available, verify its SHA-256 digest is \`57068851991cfa3e547827e564d1c842243a84c89d3dd14d5900c81d1f7b740b\`, 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. - 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. diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index 46524763a75..106b5b483b3 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -26,7 +26,8 @@ const localCredentialFormSource = path.join( "local-credential-form.html", ); const localCredentialFormUrl = - "https://raw.githubusercontent.com/NVIDIA/NemoClaw/main/docs/resources/local-credential-form.html"; + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/23dc96f4c60693ec47d09d442c2149743f7ede11/docs/resources/local-credential-form.html"; +// Keep the digest split so gitleaks does not misclassify this test fixture as an API key. const localCredentialFormSha256 = [ "57068851991cfa3e", "547827e564d1c842", @@ -291,7 +292,8 @@ describe("starter prompt docs CTA", () => { expect(promptSource).toContain(localCredentialFormUrl); expect(promptSource).toContain(localCredentialFormSha256); expect(createHash("sha256").update(formSource).digest("hex")).toBe(localCredentialFormSha256); - expect(localCredentialFormUrl).toContain("/main/"); + 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("serve it from a helper bound to \\`127.0.0.1\\`"); expect(promptSource).toContain("?fields=NVIDIA_INFERENCE_API_KEY:secret"); From 98a14c9bcbde4df3b6067f7fbc2dc11885c3a0fb Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 19:17:26 -0700 Subject: [PATCH 11/13] fix(docs): document credential form trust boundary --- docs/_components/StarterPrompt.tsx | 1 + test/starter-prompt-docs.test.ts | 10 +++------- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index ab889a9fe51..f0e1f8c1e37 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -94,6 +94,7 @@ When you need an API key, bot token, app token, or other secret, prefer the chec - 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/23dc96f4c60693ec47d09d442c2149743f7ede11/docs/resources/local-credential-form.html - Fetch that template or use the local repo copy when available, verify its SHA-256 digest is \`57068851991cfa3e547827e564d1c842243a84c89d3dd14d5900c81d1f7b740b\`, 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. diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index 106b5b483b3..eab5a2821da 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -27,13 +27,8 @@ const localCredentialFormSource = path.join( ); const localCredentialFormUrl = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/23dc96f4c60693ec47d09d442c2149743f7ede11/docs/resources/local-credential-form.html"; -// Keep the digest split so gitleaks does not misclassify this test fixture as an API key. -const localCredentialFormSha256 = [ - "57068851991cfa3e", - "547827e564d1c842", - "243a84c89d3dd14d", - "5900c81d1f7b740b", -].join(""); +const localCredentialFormSha256 = + "57068851991cfa3e547827e564d1c842243a84c89d3dd14d5900c81d1f7b740b"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormScriptCspHash = [ "'sha256-gMKcS2M/", "PaFPwkPUPGDZ9haV", @@ -295,6 +290,7 @@ describe("starter prompt docs CTA", () => { 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"); From c9aac7dc12bacdaa4d38af552b893021049ee836 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 19:23:23 -0700 Subject: [PATCH 12/13] fix(docs): reject ambiguous credential fields --- docs/resources/local-credential-form.html | 51 ++++++++++++++++++----- test/starter-prompt-docs.test.ts | 36 ++++++++++++++-- 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/docs/resources/local-credential-form.html b/docs/resources/local-credential-form.html index b106f61f9f7..e4998091dc3 100644 --- a/docs/resources/local-credential-form.html +++ b/docs/resources/local-credential-form.html @@ -9,7 +9,7 @@ NemoClaw Local Credential Form @@ -176,31 +176,60 @@

NemoClaw Local Credential Form

} function parseFieldSpec(spec) { - const [name = "", type = "secret"] = spec.split(":"); + const parts = spec.split(":"); + if (parts.length !== 2) { + return null; + } + + const [name, type] = parts; const normalizedName = name.trim().toUpperCase(); - if (!isValidFieldName(normalizedName)) { + const normalizedType = type.trim().toLowerCase(); + if ( + !isValidFieldName(normalizedName) || + (normalizedType !== "secret" && normalizedType !== "text") + ) { return null; } return { name: normalizedName, - secret: type.trim().toLowerCase() !== "text", + secret: normalizedType === "secret", }; } function configuredFields() { + // The query string is untrusted helper input. Ambiguous or malformed schemas disable submit. + const hasRepeatedSpecs = params.has("field"); + const hasCombinedSpecs = params.has("fields"); + if (hasRepeatedSpecs && hasCombinedSpecs) { + return { + fields: [], + missing: false, + rejected: ["field and fields cannot be combined"], + }; + } + const repeatedSpecs = params.getAll("field"); - const combinedSpecs = params - .get("fields") - ?.split(",") - .map((field) => field.trim()) - .filter(Boolean); - const specs = repeatedSpecs.length > 0 ? repeatedSpecs : (combinedSpecs ?? []); + const combinedValues = params.getAll("fields"); + if (combinedValues.length > 1) { + return { + fields: [], + missing: false, + rejected: ["fields cannot be repeated"], + }; + } + + const combinedSpecs = hasCombinedSpecs + ? (combinedValues[0] ?? "").split(",").map((field) => field.trim()) + : []; + const specs = hasRepeatedSpecs ? repeatedSpecs : combinedSpecs; const fields = []; const rejected = []; + const fieldNames = new Set(); for (const spec of specs) { const parsed = parseFieldSpec(spec); - if (parsed) { + if (parsed && !fieldNames.has(parsed.name)) { + fieldNames.add(parsed.name); fields.push(parsed); } else { rejected.push(spec || ""); diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index eab5a2821da..00b90a02c5b 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -30,10 +30,10 @@ const localCredentialFormUrl = const localCredentialFormSha256 = "57068851991cfa3e547827e564d1c842243a84c89d3dd14d5900c81d1f7b740b"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormScriptCspHash = [ - "'sha256-gMKcS2M/", - "PaFPwkPUPGDZ9haV", - "wA7m+QRLgf2wZT1", - "73ls='", + "'sha256-7knX1kPQ", + "ir4x3z0uoR2GmEi9", + "hb0+82UEW2o9BzJD", + "520='", ].join(""); const localCredentialFormStyleCspHash = [ "'sha256-W4wSJyrm", @@ -340,9 +340,37 @@ describe("starter prompt docs CTA", () => { 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", ); From 3b79a0f8dd1ee1efbfdbe6f481fd6c310d63b2ce Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 19:24:27 -0700 Subject: [PATCH 13/13] fix(docs): pin strict credential form --- docs/_components/StarterPrompt.tsx | 4 ++-- test/starter-prompt-docs.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index f0e1f8c1e37..b625069ae8a 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -92,8 +92,8 @@ Instead, collect the required choices from me first, one clickable selection at 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/23dc96f4c60693ec47d09d442c2149743f7ede11/docs/resources/local-credential-form.html -- Fetch that template or use the local repo copy when available, verify its SHA-256 digest is \`57068851991cfa3e547827e564d1c842243a84c89d3dd14d5900c81d1f7b740b\`, 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. +- 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. diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index 00b90a02c5b..31f8f4b6183 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -26,9 +26,9 @@ const localCredentialFormSource = path.join( "local-credential-form.html", ); const localCredentialFormUrl = - "https://raw.githubusercontent.com/NVIDIA/NemoClaw/23dc96f4c60693ec47d09d442c2149743f7ede11/docs/resources/local-credential-form.html"; + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/c9aac7dc12bacdaa4d38af552b893021049ee836/docs/resources/local-credential-form.html"; const localCredentialFormSha256 = - "57068851991cfa3e547827e564d1c842243a84c89d3dd14d5900c81d1f7b740b"; // gitleaks:allow -- checked-in SHA-256 fixture + "cc746703ab514cf33d7131915f16e8dc19346b26a4d953c5125be81449d6e6f6"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormScriptCspHash = [ "'sha256-7knX1kPQ", "ir4x3z0uoR2GmEi9",