Skip to content

Commit 727398f

Browse files
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 a3db64c. - Required merge gates passed before the squash merge. Prepared head SHA: a3db64c Review: #76693 (comment) Co-authored-by: anurag-bg-neu <[email protected]>
1 parent 0e4d28a commit 727398f

14 files changed

Lines changed: 230 additions & 23 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
4747
- 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.
4848
- 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.
4949
- 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.
50+
- 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.
5051
- 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.
5152
- 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.
5253
- 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.

src/commands/onboard-remote.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,4 +357,80 @@ describe("promptRemoteGatewayConfig", () => {
357357
id: "OPENCLAW_GATEWAY_TOKEN",
358358
});
359359
});
360+
361+
it("keeps an existing remote gateway token when user confirms via masked-preview prompt", async () => {
362+
const text: WizardPrompter["text"] = vi.fn(async (params) => {
363+
if (params.message === "Gateway WebSocket URL") {
364+
return "wss://remote.example.com:18789";
365+
}
366+
return "";
367+
}) as WizardPrompter["text"];
368+
369+
const select: WizardPrompter["select"] = vi.fn(async (params) => {
370+
if (params.message === "Gateway auth") {
371+
return "token" as never;
372+
}
373+
if (params.message === "How do you want to provide this gateway token?") {
374+
return "plaintext" as never;
375+
}
376+
return (params.options[0]?.value ?? "") as never;
377+
});
378+
379+
const confirm: WizardPrompter["confirm"] = vi.fn(async (params) => {
380+
if (params.message.startsWith("Use existing gateway token")) {
381+
return true;
382+
}
383+
return false;
384+
});
385+
386+
const cfg = {
387+
gateway: { remote: { token: "preexisting-remote-token" } },
388+
} as OpenClawConfig;
389+
const prompter = createPrompter({ confirm, select, text });
390+
391+
const next = await promptRemoteGatewayConfig(cfg, prompter);
392+
393+
expect(next.gateway?.remote?.token).toBe("preexisting-remote-token");
394+
expect(text).not.toHaveBeenCalledWith(
395+
expect.objectContaining({ message: "Gateway token" }),
396+
);
397+
});
398+
399+
it("keeps an existing remote gateway password when user confirms via masked-preview prompt", async () => {
400+
const text: WizardPrompter["text"] = vi.fn(async (params) => {
401+
if (params.message === "Gateway WebSocket URL") {
402+
return "wss://remote.example.com:18789";
403+
}
404+
return "";
405+
}) as WizardPrompter["text"];
406+
407+
const select: WizardPrompter["select"] = vi.fn(async (params) => {
408+
if (params.message === "Gateway auth") {
409+
return "password" as never;
410+
}
411+
if (params.message === "How do you want to provide this gateway password?") {
412+
return "plaintext" as never;
413+
}
414+
return (params.options[0]?.value ?? "") as never;
415+
});
416+
417+
const confirm: WizardPrompter["confirm"] = vi.fn(async (params) => {
418+
if (params.message.startsWith("Use existing gateway password")) {
419+
return true;
420+
}
421+
return false;
422+
});
423+
424+
const cfg = {
425+
gateway: { remote: { password: "preexisting-remote-password" } },
426+
} as OpenClawConfig;
427+
const prompter = createPrompter({ confirm, select, text });
428+
429+
const next = await promptRemoteGatewayConfig(cfg, prompter);
430+
431+
expect(next.gateway?.remote?.password).toBe("preexisting-remote-password");
432+
expect(text).not.toHaveBeenCalledWith(
433+
expect.objectContaining({ message: "Gateway password" }),
434+
);
435+
});
360436
});

src/commands/onboard-remote.ts

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js";
1010
import { resolveSecretInputModeForEnvSelection } from "../plugins/provider-auth-mode.js";
1111
import { promptSecretRefForSetup } from "../plugins/provider-auth-ref.js";
12+
import { maskApiKey } from "../utils/mask-api-key.js";
1213
import type { WizardPrompter } from "../wizard/prompts.js";
1314
import { detectBinary } from "./onboard-helpers.js";
1415
import type { SecretInputMode } from "./onboard-types.js";
@@ -193,13 +194,24 @@ export async function promptRemoteGatewayConfig(
193194
});
194195
token = resolved.ref;
195196
} else {
196-
token = (
197-
await prompter.text({
198-
message: "Gateway token",
199-
initialValue: typeof token === "string" ? token : undefined,
200-
validate: (value) => (value?.trim() ? undefined : "Required"),
201-
})
202-
).trim();
197+
const existingToken = typeof token === "string" ? token : undefined;
198+
if (
199+
existingToken &&
200+
(await prompter.confirm({
201+
message: `Use existing gateway token (${maskApiKey(existingToken)})?`,
202+
initialValue: true,
203+
}))
204+
) {
205+
token = existingToken;
206+
} else {
207+
token = (
208+
await prompter.text({
209+
message: "Gateway token",
210+
validate: (value) => (value?.trim() ? undefined : "Required"),
211+
sensitive: true,
212+
})
213+
).trim();
214+
}
203215
}
204216
password = undefined;
205217
} else if (authChoice === "password") {
@@ -225,13 +237,24 @@ export async function promptRemoteGatewayConfig(
225237
});
226238
password = resolved.ref;
227239
} else {
228-
password = (
229-
await prompter.text({
230-
message: "Gateway password",
231-
initialValue: typeof password === "string" ? password : undefined,
232-
validate: (value) => (value?.trim() ? undefined : "Required"),
233-
})
234-
).trim();
240+
const existingPassword = typeof password === "string" ? password : undefined;
241+
if (
242+
existingPassword &&
243+
(await prompter.confirm({
244+
message: `Use existing gateway password (${maskApiKey(existingPassword)})?`,
245+
initialValue: true,
246+
}))
247+
) {
248+
password = existingPassword;
249+
} else {
250+
password = (
251+
await prompter.text({
252+
message: "Gateway password",
253+
validate: (value) => (value?.trim() ? undefined : "Required"),
254+
sensitive: true,
255+
})
256+
).trim();
257+
}
235258
}
236259
token = undefined;
237260
} else {

src/commands/onboard-search.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ describe("setupSearch", () => {
338338
expect(result.plugins?.entries?.[entry.pluginId]?.enabled).toBe(true);
339339
if (entry.textMessage) {
340340
expect(prompter.text).toHaveBeenCalledWith(
341-
expect.objectContaining({ message: entry.textMessage }),
341+
expect.objectContaining({ message: entry.textMessage, sensitive: true }),
342342
);
343343
}
344344
}

src/commands/onboard-skills.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ export async function setupSkills(
212212
const apiKey = await prompter.text({
213213
message: `Enter ${skill.primaryEnv}`,
214214
validate: (value) => (value?.trim() ? undefined : "Required"),
215+
sensitive: true,
215216
});
216217
next = upsertSkillEntry(next, skill.skillKey, { apiKey: normalizeSecretInput(apiKey) });
217218
}

src/flows/search-setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,7 @@ export async function runSearchSetupFlow(
536536
? `${credentialLabel} (leave blank to use env var)`
537537
: credentialLabel,
538538
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
539+
sensitive: true,
539540
});
540541

541542
const key = normalizeOptionalString(keyInput) ?? "";

src/plugins/provider-auth-input.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ export async function ensureApiKeyFromEnvOrPrompt(params: {
216216
message: params.promptMessage,
217217
placeholder: "API key",
218218
validate: params.validate,
219+
sensitive: true,
219220
});
220221
const apiKey = params.normalize(key ?? "");
221222
await params.setCredential(apiKey, selectedMode);

src/plugins/provider-self-hosted-setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ export async function promptAndConfigureOpenAICompatibleSelfHostedProvider(
242242
message: `${params.providerLabel} API key`,
243243
placeholder: "sk-... (or any non-empty string)",
244244
validate: (value) => (value?.trim() ? undefined : "Required"),
245+
sensitive: true,
245246
});
246247
const modelIdRaw = await params.prompter.text({
247248
message: `${params.providerLabel} model`,

src/wizard/clack-prompter.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
multiselect,
99
type Option,
1010
outro,
11+
password,
1112
select,
1213
spinner,
1314
text,
@@ -118,6 +119,14 @@ export function createClackPrompter(): WizardPrompter {
118119
},
119120
text: async (params) => {
120121
const validate = params.validate;
122+
if (params.sensitive) {
123+
return guardCancel(
124+
await password({
125+
message: stylePromptMessage(params.message),
126+
validate: validate ? (value) => validate(value ?? "") : undefined,
127+
}),
128+
);
129+
}
121130
return guardCancel(
122131
await text({
123132
message: stylePromptMessage(params.message),

src/wizard/prompts.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export type WizardTextParams = {
2323
initialValue?: string;
2424
placeholder?: string;
2525
validate?: (value: string) => string | undefined;
26+
// Render as a masked input. The entered value is never echoed to the
27+
// terminal — keeps secrets out of scrollback, transcripts, and screenshots.
28+
sensitive?: boolean;
2629
};
2730

2831
export type WizardConfirmParams = {

0 commit comments

Comments
 (0)