From 727398f41a858ffe4e177bb7c7452d31ef165551 Mon Sep 17 00:00:00 2001 From: ANURAG BHEEMAPPA GNANAMURTHY Date: Sun, 3 May 2026 10:33:58 -0400 Subject: [PATCH] fix(onboarding): mask token/credential inputs in CLI wizard prompts (#76693) Summary: - The PR adds `sensitive` support to wizard text prompts, routes sensitive Clack prompts through `password()`, ... preserves existing gateway secrets through masked-preview confirms, and adds tests plus a changelog entry. - Reproducibility: yes. Source inspection shows current main routes onboarding credential entry through visibl ... y provides a concrete Windows PowerShell `openclaw onboard --install-daemon` reproduction with screenshots. Automerge notes: - No ClawSweeper repair was needed after automerge opt-in. Validation: - ClawSweeper review passed for head a3db64c265e6057123df63adf2905c833f30f570. - Required merge gates passed before the squash merge. Prepared head SHA: a3db64c265e6057123df63adf2905c833f30f570 Review: https://github.com/openclaw/openclaw/pull/76693#issuecomment-4366253531 Co-authored-by: anurag-bg-neu --- CHANGELOG.md | 1 + src/commands/onboard-remote.test.ts | 76 +++++++++++++++++++++++ src/commands/onboard-remote.ts | 51 ++++++++++----- src/commands/onboard-search.test.ts | 2 +- src/commands/onboard-skills.ts | 1 + src/flows/search-setup.ts | 1 + src/plugins/provider-auth-input.ts | 1 + src/plugins/provider-self-hosted-setup.ts | 1 + src/wizard/clack-prompter.ts | 9 +++ src/wizard/prompts.ts | 3 + src/wizard/session.test.ts | 23 +++++++ src/wizard/session.ts | 2 + src/wizard/setup.gateway-config.test.ts | 50 +++++++++++++++ src/wizard/setup.gateway-config.ts | 32 +++++++--- 14 files changed, 230 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34bc081683d..d18915ed2d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai - Plugins/catalog: pin bare npm specs from prerelease external channel catalog entries to the catalog entry version, so beta catalogs do not silently install the latest stable package. - Plugins/update: treat catalog-matched official npm updates and OpenClaw-authored externalized-bundled npm bridges as trusted official installs so launch-code plugins can update or migrate out of the bundled tree without scanner false positives. Thanks @vincentkoc. - Plugins/onboarding: fall back from ClawHub to npm only for missing package/version errors, keeping integrity and verification failures fail-closed during storepack rollout. Thanks @vincentkoc. +- CLI/onboarding: mask credential inputs (model-auth provider API keys, gateway tokens and passwords, web-search provider keys, and skill env-var values) in the interactive `openclaw onboard` wizard so pasted secrets no longer echo into terminal scrollback, `Start-Transcript` logs, or screenshots; existing tokens/passwords are preserved through a masked-preview confirm step before the sensitive prompt. Thanks @anurag-bg-neu. - Control UI/Talk: fix Talk (OpenAI Realtime WebRTC) CORS failure by stripping server-side-only attribution headers (`originator`, `version`, `User-Agent`) from browser offer headers; `api.openai.com/v1/realtime/calls` only allows `authorization` and `content-type` in its CORS preflight, so forwarding these headers caused the browser SDP exchange to fail. Fixes #76435. Thanks @hclsys. - Chat delivery: make `/verbose on|full|off` changes affect subsequent tool-use chat bubbles again, including channels with draft preview tool progress enabled, while preserving one-shot verbose directives. - CLI/logs: auto-reconnect `openclaw logs --follow` on transient gateway disconnects (WebSocket close, timeout, connection drop) with bounded exponential backoff (up to 8 retries, capped at 30 s) and stderr retry warnings, while still exiting immediately on non-recoverable auth or configuration errors. Fixes #74782. (#75059) Thanks @shashank-poola. diff --git a/src/commands/onboard-remote.test.ts b/src/commands/onboard-remote.test.ts index a67989ef228..9827771fded 100644 --- a/src/commands/onboard-remote.test.ts +++ b/src/commands/onboard-remote.test.ts @@ -357,4 +357,80 @@ describe("promptRemoteGatewayConfig", () => { id: "OPENCLAW_GATEWAY_TOKEN", }); }); + + it("keeps an existing remote gateway token when user confirms via masked-preview prompt", async () => { + const text: WizardPrompter["text"] = vi.fn(async (params) => { + if (params.message === "Gateway WebSocket URL") { + return "wss://remote.example.com:18789"; + } + return ""; + }) as WizardPrompter["text"]; + + const select: WizardPrompter["select"] = vi.fn(async (params) => { + if (params.message === "Gateway auth") { + return "token" as never; + } + if (params.message === "How do you want to provide this gateway token?") { + return "plaintext" as never; + } + return (params.options[0]?.value ?? "") as never; + }); + + const confirm: WizardPrompter["confirm"] = vi.fn(async (params) => { + if (params.message.startsWith("Use existing gateway token")) { + return true; + } + return false; + }); + + const cfg = { + gateway: { remote: { token: "preexisting-remote-token" } }, + } as OpenClawConfig; + const prompter = createPrompter({ confirm, select, text }); + + const next = await promptRemoteGatewayConfig(cfg, prompter); + + expect(next.gateway?.remote?.token).toBe("preexisting-remote-token"); + expect(text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Gateway token" }), + ); + }); + + it("keeps an existing remote gateway password when user confirms via masked-preview prompt", async () => { + const text: WizardPrompter["text"] = vi.fn(async (params) => { + if (params.message === "Gateway WebSocket URL") { + return "wss://remote.example.com:18789"; + } + return ""; + }) as WizardPrompter["text"]; + + const select: WizardPrompter["select"] = vi.fn(async (params) => { + if (params.message === "Gateway auth") { + return "password" as never; + } + if (params.message === "How do you want to provide this gateway password?") { + return "plaintext" as never; + } + return (params.options[0]?.value ?? "") as never; + }); + + const confirm: WizardPrompter["confirm"] = vi.fn(async (params) => { + if (params.message.startsWith("Use existing gateway password")) { + return true; + } + return false; + }); + + const cfg = { + gateway: { remote: { password: "preexisting-remote-password" } }, + } as OpenClawConfig; + const prompter = createPrompter({ confirm, select, text }); + + const next = await promptRemoteGatewayConfig(cfg, prompter); + + expect(next.gateway?.remote?.password).toBe("preexisting-remote-password"); + expect(text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Gateway password" }), + ); + }); }); diff --git a/src/commands/onboard-remote.ts b/src/commands/onboard-remote.ts index d8740e09fca..1e1e0ce9e89 100644 --- a/src/commands/onboard-remote.ts +++ b/src/commands/onboard-remote.ts @@ -9,6 +9,7 @@ import { import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js"; import { resolveSecretInputModeForEnvSelection } from "../plugins/provider-auth-mode.js"; import { promptSecretRefForSetup } from "../plugins/provider-auth-ref.js"; +import { maskApiKey } from "../utils/mask-api-key.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { detectBinary } from "./onboard-helpers.js"; import type { SecretInputMode } from "./onboard-types.js"; @@ -193,13 +194,24 @@ export async function promptRemoteGatewayConfig( }); token = resolved.ref; } else { - token = ( - await prompter.text({ - message: "Gateway token", - initialValue: typeof token === "string" ? token : undefined, - validate: (value) => (value?.trim() ? undefined : "Required"), - }) - ).trim(); + const existingToken = typeof token === "string" ? token : undefined; + if ( + existingToken && + (await prompter.confirm({ + message: `Use existing gateway token (${maskApiKey(existingToken)})?`, + initialValue: true, + })) + ) { + token = existingToken; + } else { + token = ( + await prompter.text({ + message: "Gateway token", + validate: (value) => (value?.trim() ? undefined : "Required"), + sensitive: true, + }) + ).trim(); + } } password = undefined; } else if (authChoice === "password") { @@ -225,13 +237,24 @@ export async function promptRemoteGatewayConfig( }); password = resolved.ref; } else { - password = ( - await prompter.text({ - message: "Gateway password", - initialValue: typeof password === "string" ? password : undefined, - validate: (value) => (value?.trim() ? undefined : "Required"), - }) - ).trim(); + const existingPassword = typeof password === "string" ? password : undefined; + if ( + existingPassword && + (await prompter.confirm({ + message: `Use existing gateway password (${maskApiKey(existingPassword)})?`, + initialValue: true, + })) + ) { + password = existingPassword; + } else { + password = ( + await prompter.text({ + message: "Gateway password", + validate: (value) => (value?.trim() ? undefined : "Required"), + sensitive: true, + }) + ).trim(); + } } token = undefined; } else { diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index de8465edba3..24ac3a2e9ce 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -338,7 +338,7 @@ describe("setupSearch", () => { expect(result.plugins?.entries?.[entry.pluginId]?.enabled).toBe(true); if (entry.textMessage) { expect(prompter.text).toHaveBeenCalledWith( - expect.objectContaining({ message: entry.textMessage }), + expect.objectContaining({ message: entry.textMessage, sensitive: true }), ); } } diff --git a/src/commands/onboard-skills.ts b/src/commands/onboard-skills.ts index c806911898d..125c48c3a03 100644 --- a/src/commands/onboard-skills.ts +++ b/src/commands/onboard-skills.ts @@ -212,6 +212,7 @@ export async function setupSkills( const apiKey = await prompter.text({ message: `Enter ${skill.primaryEnv}`, validate: (value) => (value?.trim() ? undefined : "Required"), + sensitive: true, }); next = upsertSkillEntry(next, skill.skillKey, { apiKey: normalizeSecretInput(apiKey) }); } diff --git a/src/flows/search-setup.ts b/src/flows/search-setup.ts index 6ae4834f63e..b245d69bfc2 100644 --- a/src/flows/search-setup.ts +++ b/src/flows/search-setup.ts @@ -536,6 +536,7 @@ export async function runSearchSetupFlow( ? `${credentialLabel} (leave blank to use env var)` : credentialLabel, placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder, + sensitive: true, }); const key = normalizeOptionalString(keyInput) ?? ""; diff --git a/src/plugins/provider-auth-input.ts b/src/plugins/provider-auth-input.ts index 9a587a720b8..058f3c74ab7 100644 --- a/src/plugins/provider-auth-input.ts +++ b/src/plugins/provider-auth-input.ts @@ -216,6 +216,7 @@ export async function ensureApiKeyFromEnvOrPrompt(params: { message: params.promptMessage, placeholder: "API key", validate: params.validate, + sensitive: true, }); const apiKey = params.normalize(key ?? ""); await params.setCredential(apiKey, selectedMode); diff --git a/src/plugins/provider-self-hosted-setup.ts b/src/plugins/provider-self-hosted-setup.ts index 0e23748e4b8..b79bbdf4722 100644 --- a/src/plugins/provider-self-hosted-setup.ts +++ b/src/plugins/provider-self-hosted-setup.ts @@ -242,6 +242,7 @@ export async function promptAndConfigureOpenAICompatibleSelfHostedProvider( message: `${params.providerLabel} API key`, placeholder: "sk-... (or any non-empty string)", validate: (value) => (value?.trim() ? undefined : "Required"), + sensitive: true, }); const modelIdRaw = await params.prompter.text({ message: `${params.providerLabel} model`, diff --git a/src/wizard/clack-prompter.ts b/src/wizard/clack-prompter.ts index d251bb54930..62b045fa64a 100644 --- a/src/wizard/clack-prompter.ts +++ b/src/wizard/clack-prompter.ts @@ -8,6 +8,7 @@ import { multiselect, type Option, outro, + password, select, spinner, text, @@ -118,6 +119,14 @@ export function createClackPrompter(): WizardPrompter { }, text: async (params) => { const validate = params.validate; + if (params.sensitive) { + return guardCancel( + await password({ + message: stylePromptMessage(params.message), + validate: validate ? (value) => validate(value ?? "") : undefined, + }), + ); + } return guardCancel( await text({ message: stylePromptMessage(params.message), diff --git a/src/wizard/prompts.ts b/src/wizard/prompts.ts index 4a469a6672b..84f177a658e 100644 --- a/src/wizard/prompts.ts +++ b/src/wizard/prompts.ts @@ -23,6 +23,9 @@ export type WizardTextParams = { initialValue?: string; placeholder?: string; validate?: (value: string) => string | undefined; + // Render as a masked input. The entered value is never echoed to the + // terminal — keeps secrets out of scrollback, transcripts, and screenshots. + sensitive?: boolean; }; export type WizardConfirmParams = { diff --git a/src/wizard/session.test.ts b/src/wizard/session.test.ts index 9356a8911bd..6be3d4ea0d3 100644 --- a/src/wizard/session.test.ts +++ b/src/wizard/session.test.ts @@ -109,4 +109,27 @@ describe("WizardSession", () => { expect(done.done).toBe(true); expect(done.status).toBe("done"); }); + + test("forwards sensitive flag to the emitted text step", async () => { + const session = new WizardSession(async (prompter) => { + await prompter.text({ message: "API key", sensitive: true }); + await prompter.text({ message: "Username" }); + }); + + const sensitiveStep = (await session.next()).step; + expect(sensitiveStep?.type).toBe("text"); + expect(sensitiveStep?.sensitive).toBe(true); + if (!sensitiveStep) { + throw new Error("expected sensitive step"); + } + await session.answer(sensitiveStep.id, "fake-key-aa11"); + + const plainStep = (await session.next()).step; + expect(plainStep?.type).toBe("text"); + expect(plainStep?.sensitive).toBeUndefined(); + if (!plainStep) { + throw new Error("expected plain step"); + } + await session.answer(plainStep.id, "alice"); + }); }); diff --git a/src/wizard/session.ts b/src/wizard/session.ts index e356918fd0d..e0bf638e487 100644 --- a/src/wizard/session.ts +++ b/src/wizard/session.ts @@ -117,12 +117,14 @@ class WizardSessionPrompter implements WizardPrompter { initialValue?: string; placeholder?: string; validate?: (value: string) => string | undefined; + sensitive?: boolean; }): Promise { const res = await this.prompt({ type: "text", message: params.message, initialValue: params.initialValue, placeholder: params.placeholder, + sensitive: params.sensitive, executor: "client", }); const value = diff --git a/src/wizard/setup.gateway-config.test.ts b/src/wizard/setup.gateway-config.test.ts index c85fb692802..59c0bd0064a 100644 --- a/src/wizard/setup.gateway-config.test.ts +++ b/src/wizard/setup.gateway-config.test.ts @@ -121,6 +121,56 @@ describe("configureGatewayForSetup", () => { } }); + it("keeps OPENCLAW_GATEWAY_TOKEN in advanced flow when user confirms keeping existing", async () => { + const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; + process.env.OPENCLAW_GATEWAY_TOKEN = "advanced-env-token"; + mocks.randomToken.mockReturnValue("should-not-be-used"); + mocks.randomToken.mockClear(); + + try { + const selectQueue = ["loopback", "token", "off"]; + const select = vi.fn(async (params: WizardSelectParams) => { + const next = selectQueue.shift(); + if (next !== undefined) { + return next; + } + return params.initialValue ?? params.options[0]?.value; + }) as unknown as WizardPrompter["select"]; + const text = vi.fn(async () => "18789") as unknown as WizardPrompter["text"]; + const confirm = vi.fn(async () => true); + const prompter = buildWizardPrompter({ select, text, confirm }); + + const result = await configureGatewayForSetup({ + flow: "advanced", + baseConfig: {}, + nextConfig: {}, + localPort: 18789, + quickstartGateway: { + hasExisting: false, + port: 18789, + bind: "loopback", + authMode: "token", + tailscaleMode: "off", + token: undefined, + password: undefined, + customBindHost: undefined, + tailscaleResetOnExit: false, + }, + prompter, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + }); + + expect(result.settings.gatewayToken).toBe("advanced-env-token"); + expect(mocks.randomToken).not.toHaveBeenCalled(); + } finally { + if (prevToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; + } + } + }); + it("enables insecure local control ui auth for fresh quickstart loopback setups", async () => { mocks.randomToken.mockReturnValue("generated-token"); diff --git a/src/wizard/setup.gateway-config.ts b/src/wizard/setup.gateway-config.ts index f7c617a4e2e..7536ec1e686 100644 --- a/src/wizard/setup.gateway-config.ts +++ b/src/wizard/setup.gateway-config.ts @@ -23,6 +23,7 @@ import { resolveSecretInputModeForEnvSelection } from "../plugins/provider-auth- import { promptSecretRefForSetup } from "../plugins/provider-auth-ref.js"; import type { RuntimeEnv } from "../runtime.js"; import { validateIPv4AddressInput } from "../shared/net/ipv4.js"; +import { maskApiKey } from "../utils/mask-api-key.js"; import type { WizardPrompter } from "./prompts.js"; import { resolveSetupSecretInputString } from "./setup.secret-input.js"; import type { @@ -209,14 +210,28 @@ export async function configureGatewayForSetup( randomToken(); gatewayTokenInput = gatewayToken; } else { - const tokenInput = await prompter.text({ - message: "Gateway token (blank to generate)", - placeholder: "Needed for multi-machine or non-loopback access", - initialValue: - quickstartTokenString ?? - normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN) ?? - "", - }); + const existingToken = + quickstartTokenString ?? normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN); + let tokenInput: string | undefined; + if (existingToken) { + const keep = await prompter.confirm({ + message: `Use existing gateway token (${maskApiKey(existingToken)})?`, + initialValue: true, + }); + tokenInput = keep + ? existingToken + : await prompter.text({ + message: "Gateway token (blank to generate)", + placeholder: "Needed for multi-machine or non-loopback access", + sensitive: true, + }); + } else { + tokenInput = await prompter.text({ + message: "Gateway token (blank to generate)", + placeholder: "Needed for multi-machine or non-loopback access", + sensitive: true, + }); + } gatewayToken = normalizeGatewayTokenInput(tokenInput) || randomToken(); gatewayTokenInput = gatewayToken; } @@ -252,6 +267,7 @@ export async function configureGatewayForSetup( await prompter.text({ message: "Gateway password", validate: validateGatewayPasswordInput, + sensitive: true, }), ); }