fix(onboarding): mask credential inputs in interactive wizard prompts

This commit is contained in:
anurag-bg-neu
2026-05-03 08:23:51 -04:00
parent 9772ce6ce9
commit a3db64c265
14 changed files with 230 additions and 23 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
- Plugins/catalog: preserve ClawHub install specs when generating the packaged channel catalog so future storepack-first channel plugins keep their remote source instead of becoming npm-only. Thanks @vincentkoc.
- 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.
- 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.
- CLI/logs: announce `--follow` recovery with a `[logs] gateway reconnected` notice once a poll succeeds after a transient outage, and emit JSON `notice` records in `--json` mode for both the retry warning and the reconnect transition, so live monitoring scripts can react to the recovery. Carries forward #75059. (#75372) Thanks @romneyda.

View File

@@ -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" }),
);
});
});

View File

@@ -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 {

View File

@@ -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 }),
);
}
}

View File

@@ -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) });
}

View File

@@ -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) ?? "";

View File

@@ -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);

View File

@@ -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`,

View File

@@ -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),

View File

@@ -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 = {

View File

@@ -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");
});
});

View File

@@ -117,12 +117,14 @@ class WizardSessionPrompter implements WizardPrompter {
initialValue?: string;
placeholder?: string;
validate?: (value: string) => string | undefined;
sensitive?: boolean;
}): Promise<string> {
const res = await this.prompt({
type: "text",
message: params.message,
initialValue: params.initialValue,
placeholder: params.placeholder,
sensitive: params.sensitive,
executor: "client",
});
const value =

View File

@@ -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<unknown>) => {
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");

View File

@@ -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,
}),
);
}