mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
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 heada3db64c265. - Required merge gates passed before the squash merge. Prepared head SHA:a3db64c265Review: https://github.com/openclaw/openclaw/pull/76693#issuecomment-4366253531 Co-authored-by: anurag-bg-neu <bheemappagnanamurt.a@northeastern.edu>
This commit is contained in:
committed by
GitHub
parent
0e4d28aa9e
commit
727398f41a
@@ -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.
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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) ?? "";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user