diff --git a/CHANGELOG.md b/CHANGELOG.md index 260255abd777..b082ec07227b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai - **Gateway TTS playback:** add an operator-scoped `tts.speak` RPC that returns configured-provider speech as inline whole-clip audio for remote clients. (#100708, #100770) - **Workboard dispatch cap:** add a request-scoped `--max-starts` override while preserving the default cap, sequential starts, and one-card-per-owner guard. (#100174) Thanks @souvikDevloper. - **Plugin install provenance warnings:** require explicit `--force` acknowledgement for arbitrary executable plugin sources in CLI and chat installs, keep trusted ClawHub, bundled, official-catalog, and tracked-update flows frictionless, and restrict Crestodian installs to trusted sources. (#102197) Thanks @jesse-merhi. +- **Custodian rich setup controls:** render the Gateway's sanitized wizard steps as native selects, multiselects, text fields, and masked secret inputs while preserving text-only chat compatibility. (#114631) Thanks @jesse-merhi. ### Fixes diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 5eb9d086b09e..4e54818e7068 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -9297,6 +9297,7 @@ public struct ConfigSchemaLookupResult: Codable, Sendable { public struct SystemAgentChatParams: Codable, Sendable { public let sessionid: String public let message: String? + public let wizardanswer: [String: AnyCodable]? public let welcomevariant: AnyCodable? public let reset: Bool? public let context: [String: AnyCodable]? @@ -9305,6 +9306,7 @@ public struct SystemAgentChatParams: Codable, Sendable { public init( sessionid: String, message: String? = nil, + wizardanswer: [String: AnyCodable]? = nil, welcomevariant: AnyCodable? = nil, reset: Bool? = nil, context: [String: AnyCodable]? = nil, @@ -9312,6 +9314,7 @@ public struct SystemAgentChatParams: Codable, Sendable { { self.sessionid = sessionid self.message = message + self.wizardanswer = wizardanswer self.welcomevariant = welcomevariant self.reset = reset self.context = context @@ -9321,6 +9324,7 @@ public struct SystemAgentChatParams: Codable, Sendable { private enum CodingKeys: String, CodingKey { case sessionid = "sessionId" case message + case wizardanswer = "wizardAnswer" case welcomevariant = "welcomeVariant" case reset case context @@ -9339,6 +9343,7 @@ public struct SystemAgentChatResult: Codable, Sendable { public let needsapproval: Bool? public let proposalid: String? public let question: [String: AnyCodable]? + public let step: WizardStep? public init( sessionid: String, @@ -9350,7 +9355,8 @@ public struct SystemAgentChatResult: Codable, Sendable { agentid: String? = nil, needsapproval: Bool? = nil, proposalid: String? = nil, - question: [String: AnyCodable]? = nil) + question: [String: AnyCodable]? = nil, + step: WizardStep? = nil) { self.sessionid = sessionid self.reply = reply @@ -9362,6 +9368,7 @@ public struct SystemAgentChatResult: Codable, Sendable { self.needsapproval = needsapproval self.proposalid = proposalid self.question = question + self.step = step } private enum CodingKeys: String, CodingKey { @@ -9375,6 +9382,7 @@ public struct SystemAgentChatResult: Codable, Sendable { case needsapproval = "needsApproval" case proposalid = "proposalId" case question + case step } } diff --git a/extensions/synology-chat/src/core.test.ts b/extensions/synology-chat/src/core.test.ts index e5b0dea0df39..7ccd82f711da 100644 --- a/extensions/synology-chat/src/core.test.ts +++ b/extensions/synology-chat/src/core.test.ts @@ -146,6 +146,60 @@ describe("synology-chat core", () => { ); }); + it("never sends an existing token-bearing incoming URL back through setup prompts", async () => { + const existingIncomingUrl = + "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&token=existing-secret"; + const replacementIncomingUrl = + "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&token=replacement"; + const text = vi.fn(async ({ message }: { message: string }) => { + if (message === "Incoming webhook URL") { + return replacementIncomingUrl; + } + if (message === "Outgoing webhook path (optional)") { + return ""; + } + throw new Error(`Unexpected prompt: ${message}`); + }); + const confirm = vi.fn(async ({ message }: { message: string }) => { + if (message === "Synology Chat webhook token already configured. Keep it?") { + return true; + } + if (message.startsWith("Incoming webhook URL")) { + return false; + } + throw new Error(`Unexpected confirmation: ${message}`); + }); + const prompter = createTestWizardPrompter({ + text: text as WizardPrompter["text"], + confirm, + }); + + const result = await runSetupWizardConfigure({ + configure: synologyChatConfigure, + cfg: { + channels: { + "synology-chat": { + enabled: true, + token: "existing-outgoing-token", + incomingUrl: existingIncomingUrl, + }, + }, + } as OpenClawConfig, + prompter, + options: { secretInputMode: "plaintext" as const }, + }); + + expect(result.cfg.channels?.["synology-chat"]?.incomingUrl).toBe(replacementIncomingUrl); + expect(JSON.stringify({ confirms: confirm.mock.calls, texts: text.mock.calls })).not.toContain( + existingIncomingUrl, + ); + const urlPrompt = text.mock.calls.find( + ([args]) => args.message === "Incoming webhook URL", + )?.[0]; + expect(urlPrompt).toMatchObject({ sensitive: true }); + expect(urlPrompt).not.toHaveProperty("initialValue"); + }); + it("records allowed user ids when setup forces allowFrom", async () => { const prompter = createSynologySetupPrompter({ allowedUserIds: "123456, synology-chat:789012", diff --git a/extensions/synology-chat/src/setup-surface.ts b/extensions/synology-chat/src/setup-surface.ts index 1c76818b8c33..ee28adf9c519 100644 --- a/extensions/synology-chat/src/setup-surface.ts +++ b/extensions/synology-chat/src/setup-surface.ts @@ -294,8 +294,9 @@ export const synologyChatSetupWizard: ChannelSetupWizard = { t("wizard.synologyChat.incomingWebhookHelpUseUrl"), t("wizard.synologyChat.incomingWebhookHelpReplies"), ], + sensitive: true, currentValue: ({ cfg, accountId }) => getRawAccountConfig(cfg, accountId).incomingUrl?.trim(), - keepPrompt: (value) => t("wizard.synologyChat.incomingWebhookKeep", { value }), + keepPrompt: t("wizard.synologyChat.incomingWebhookKeep"), validate: ({ value }) => validateWebhookUrl(value), applySet: async ({ cfg, accountId, value }) => patchSynologyChatAccountConfig({ diff --git a/extensions/tlon/src/core.test.ts b/extensions/tlon/src/core.test.ts index cf4ec5824e8a..02aec3ad67af 100644 --- a/extensions/tlon/src/core.test.ts +++ b/extensions/tlon/src/core.test.ts @@ -205,6 +205,55 @@ describe("tlon core", () => { expect(result.cfg.channels?.tlon?.network?.dangerouslyAllowPrivateNetwork).toBe(false); }); + it("never sends an existing login code back through setup prompts", async () => { + const existingCode = "lidlut-existing-secret-code"; + const text = vi.fn(async ({ message }: { message: string }) => { + if (message === "Login code") { + return "lidlut-replacement-code"; + } + throw new Error(`Unexpected prompt: ${message}`); + }); + const confirm = vi.fn(async ({ message }: { message: string }) => { + if (message.startsWith("Ship name") || message.startsWith("Ship URL")) { + return true; + } + if (message.startsWith("Login code")) { + return false; + } + if (message === "Enable auto-discovery of group channels?") { + return true; + } + return false; + }); + const prompter = createTestWizardPrompter({ + text: text as WizardPrompter["text"], + confirm, + }); + + const result = await runSetupWizardConfigure({ + configure: tlonConfigure, + cfg: { + channels: { + tlon: { + ship: "~sampel-palnet", + url: "https://urbit.example.com", + code: existingCode, + }, + }, + } as OpenClawConfig, + prompter, + options: {}, + }); + + expect(result.cfg.channels?.tlon?.code).toBe("lidlut-replacement-code"); + expect(JSON.stringify({ confirms: confirm.mock.calls, texts: text.mock.calls })).not.toContain( + existingCode, + ); + const codePrompt = text.mock.calls.find(([args]) => args.message === "Login code")?.[0]; + expect(codePrompt).toMatchObject({ sensitive: true }); + expect(codePrompt).not.toHaveProperty("initialValue"); + }); + it("resolves dm targets to normalized ships", () => { expect(resolveTlonOutboundTarget("dm/sampel-palnet")).toEqual({ ok: true, diff --git a/extensions/tlon/src/setup-core.ts b/extensions/tlon/src/setup-core.ts index f7187452c640..afa776b66ca8 100644 --- a/extensions/tlon/src/setup-core.ts +++ b/extensions/tlon/src/setup-core.ts @@ -113,6 +113,8 @@ export function createTlonSetupWizardBase(params: TlonSetupWizardBaseParams): Ch inputKey: "code", message: t("wizard.tlon.loginCodePrompt"), placeholder: "lidlut-tabwed-pillex-ridrup", + sensitive: true, + keepPrompt: t("wizard.tlon.loginCodeKeep"), currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).code ?? undefined, validate: ({ value }) => normalizeStringifiedOptionalString(value) ? undefined : "Required", diff --git a/extensions/twitch/src/setup-surface.test.ts b/extensions/twitch/src/setup-surface.test.ts index 74d65d6c67d4..716549af72f9 100644 --- a/extensions/twitch/src/setup-surface.test.ts +++ b/extensions/twitch/src/setup-surface.test.ts @@ -43,10 +43,16 @@ const mockAccount: TwitchAccountConfig = { clientId: "test-client-id", channel: "#testchannel", }; +const mockRefreshAccount: TwitchAccountConfig = { + ...mockAccount, + clientSecret: "existing-secret", + refreshToken: "existing-refresh", +}; function requireFirstTextPromptArgs(): { message?: string; initialValue?: string; + sensitive?: boolean; validate?: (value: string) => string | undefined; } { const [call] = mockPromptText.mock.calls; @@ -56,6 +62,7 @@ function requireFirstTextPromptArgs(): { return call[0] as { message?: string; initialValue?: string; + sensitive?: boolean; validate?: (value: string) => string | undefined; }; } @@ -78,7 +85,7 @@ describe("setup surface helpers", () => { it("should return existing token when user confirms to keep it", async () => { mockPromptConfirm.mockResolvedValue(true); - const result = await promptToken(mockPrompter, mockAccount, undefined); + const result = await promptToken(mockPrompter, mockAccount); expect(result).toBe("oauth:test123"); expect(mockPromptConfirm).toHaveBeenCalledWith({ @@ -88,8 +95,7 @@ describe("setup surface helpers", () => { expect(mockPromptText).not.toHaveBeenCalled(); }); - it("should validate token format", async () => { - // Set up mocks - user doesn't want to keep existing token + it("should use a sensitive prompt when replacing a configured token", async () => { mockPromptConfirm.mockResolvedValueOnce(false); // Track how many times promptText is called @@ -106,11 +112,15 @@ describe("setup surface helpers", () => { }); // Call promptToken - const result = await promptToken(mockPrompter, mockAccount, undefined); + const result = await promptToken(mockPrompter, mockAccount); // Verify promptText was called expect(promptTextCallCount).toBe(1); expect(result).toBe("oauth:test123"); + expect(requireFirstTextPromptArgs()).toMatchObject({ + sensitive: true, + }); + expect(requireFirstTextPromptArgs()).not.toHaveProperty("initialValue"); // Test the validate function if (!capturedValidate) { @@ -180,18 +190,42 @@ describe("setup surface helpers", () => { it("should prompt for credentials when user accepts", async () => { mockPromptConfirm - .mockResolvedValueOnce(true) // First call: useRefresh - .mockResolvedValueOnce("secret123") // clientSecret - .mockResolvedValueOnce("refresh123"); // refreshToken + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); mockPromptText.mockResolvedValueOnce("secret123").mockResolvedValueOnce("refresh123"); - const result = await promptRefreshTokenSetup(mockPrompter, null); + const result = await promptRefreshTokenSetup(mockPrompter, mockRefreshAccount); expect(result).toEqual({ clientSecret: "secret123", refreshToken: "refresh123", }); + expect(mockPromptText).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + sensitive: true, + }), + ); + expect(mockPromptText.mock.calls[0]?.[0]).not.toHaveProperty("initialValue"); + expect(mockPromptText).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + sensitive: true, + }), + ); + expect(mockPromptText.mock.calls[1]?.[0]).not.toHaveProperty("initialValue"); + }); + + it("should keep existing credentials without opening masked replacement prompts", async () => { + mockPromptConfirm.mockResolvedValue(true); + + await expect(promptRefreshTokenSetup(mockPrompter, mockRefreshAccount)).resolves.toEqual({ + clientSecret: "existing-secret", + refreshToken: "existing-refresh", + }); + expect(mockPromptText).not.toHaveBeenCalled(); }); }); @@ -333,6 +367,18 @@ describe("setup surface helpers", () => { describe("setup wizard account routing", () => { type FinalizeArgs = Parameters>[0]; + async function finalizeDefaultTwitchSetup(cfg: FinalizeArgs["cfg"]) { + return await twitchSetupWizard.finalize?.({ + cfg, + accountId: "default", + credentialValues: {}, + runtime: {} as FinalizeArgs["runtime"], + prompter: mockPrompter, + options: {}, + forceAllowFrom: false, + }); + } + async function finalizeTwitchSetupForAccount(cfg: FinalizeArgs["cfg"]) { return await twitchSetupWizard.finalize?.({ cfg, @@ -345,6 +391,31 @@ describe("setup surface helpers", () => { }); } + it("uses an environment-only token without sending it to wizard prompts", async () => { + const envToken = "oauth:environment-only"; + process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = envToken; + mockPromptConfirm.mockReset().mockResolvedValueOnce(true as never); + mockPromptText + .mockReset() + .mockResolvedValueOnce("env-bot" as never) + .mockResolvedValueOnce("env-client" as never); + + const result = await finalizeDefaultTwitchSetup({}); + + expect(result?.cfg?.channels?.twitch?.accounts?.default).toMatchObject({ + username: "env-bot", + accessToken: envToken, + clientId: "env-client", + }); + expect(mockPromptConfirm).toHaveBeenCalledWith({ + message: "Twitch env var OPENCLAW_TWITCH_ACCESS_TOKEN detected. Use env token?", + initialValue: true, + }); + expect(mockPromptText).toHaveBeenCalledTimes(2); + expect(JSON.stringify(mockPromptConfirm.mock.calls)).not.toContain(envToken); + expect(JSON.stringify(mockPromptText.mock.calls)).not.toContain(envToken); + }); + it("rejects reserved account ids before using them as config keys", () => { expect(() => setTwitchAccount( diff --git a/extensions/twitch/src/setup-surface.ts b/extensions/twitch/src/setup-surface.ts index 21c4fa92a0b3..b1ea7775b01f 100644 --- a/extensions/twitch/src/setup-surface.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -112,11 +112,10 @@ async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise { export async function promptToken( prompter: WizardPrompter, account: TwitchAccountConfig | null, - envToken: string | undefined, ): Promise { const existingToken = account?.accessToken ?? ""; - if (existingToken && !envToken) { + if (existingToken) { const keepToken = await prompter.confirm({ message: t("wizard.twitch.accessTokenKeep"), initialValue: true, @@ -129,7 +128,7 @@ export async function promptToken( return ( await prompter.text({ message: t("wizard.twitch.oauthTokenPrompt"), - initialValue: envToken ?? "", + sensitive: true, validate: (value) => { const raw = value?.trim() ?? ""; if (!raw) { @@ -191,6 +190,30 @@ export async function promptChannelName( ); } +async function promptRefreshCredential(params: { + prompter: WizardPrompter; + existingValue: string | undefined; + keepMessage: string; + inputMessage: string; +}): Promise { + const existingValue = params.existingValue?.trim(); + if (existingValue) { + const keep = await params.prompter.confirm({ + message: params.keepMessage, + initialValue: true, + }); + if (keep) { + return existingValue; + } + } + const value = await params.prompter.text({ + message: params.inputMessage, + sensitive: true, + validate: (input) => (input?.trim() ? undefined : "Required"), + }); + return value.trim() || undefined; +} + export async function promptRefreshTokenSetup( prompter: WizardPrompter, account: TwitchAccountConfig | null, @@ -204,18 +227,18 @@ export async function promptRefreshTokenSetup( return {}; } - const clientSecret = - (await promptRequiredTwitchAccountValue( - prompter, - t("wizard.twitch.clientSecretPrompt"), - account?.clientSecret, - )) || undefined; - const refreshToken = - (await promptRequiredTwitchAccountValue( - prompter, - t("wizard.twitch.refreshTokenInputPrompt"), - account?.refreshToken, - )) || undefined; + const clientSecret = await promptRefreshCredential({ + prompter, + existingValue: account?.clientSecret, + keepMessage: t("wizard.twitch.clientSecretKeep"), + inputMessage: t("wizard.twitch.clientSecretPrompt"), + }); + const refreshToken = await promptRefreshCredential({ + prompter, + existingValue: account?.refreshToken, + keepMessage: t("wizard.twitch.refreshTokenKeep"), + inputMessage: t("wizard.twitch.refreshTokenInputPrompt"), + }); return { clientSecret, refreshToken }; } @@ -466,7 +489,7 @@ export const twitchSetupWizard: ChannelSetupWizard = { } const username = await promptUsername(prompter, account); - const token = await promptToken(prompter, account, envToken); + const token = await promptToken(prompter, account); const clientId = await promptClientId(prompter, account); const channelName = await promptChannelName(prompter, account); const { clientSecret, refreshToken } = await promptRefreshTokenSetup(prompter, account); diff --git a/packages/gateway-protocol/CHANGELOG.md b/packages/gateway-protocol/CHANGELOG.md index 494060b0f27b..5340db811e21 100644 --- a/packages/gateway-protocol/CHANGELOG.md +++ b/packages/gateway-protocol/CHANGELOG.md @@ -11,6 +11,7 @@ version and the additive schema surface. Dates are authoring dates (2026). - Rename structured-question item `id` to `questionId` and flatten keyed answer arrays. - Slim worker and session-catalog payloads to the active wire contract. - Remove dead protocol surfaces and add since-vintage metadata to retained schemas and methods. +- Add optional `step` on `SystemAgentChatResult` carrying the full awaited wizard step. ## Protocol v4 (current) @@ -126,7 +127,8 @@ Enhancement-only month (no new schema modules): - Add cron event triggers via polled condition-watcher scripts (#101195) and native mobile Automations parity (#106355). - Add system-agent conversational onboarding (#99935); rename `crestodian.*` methods to - `openclaw.chat` / `openclaw.setup.*` (2026-07-14, `a6a0716`). + `openclaw.chat` / `openclaw.setup.*` (2026-07-14, `a6a0716`); add typed hosted-wizard + steps and answers to `openclaw.chat` (#114631). - Add typed structured questions / `ask_user` with live option cards (#109922, #110242) and the questions schema module. - Add ui-command / screen-tool Control UI layout control and capability-gated diff --git a/packages/gateway-protocol/src/openclaw.schema.test.ts b/packages/gateway-protocol/src/openclaw.schema.test.ts new file mode 100644 index 000000000000..96b1513aec79 --- /dev/null +++ b/packages/gateway-protocol/src/openclaw.schema.test.ts @@ -0,0 +1,131 @@ +// Gateway Protocol tests cover openclaw.schema behavior. +import { Compile } from "typebox/compile"; +import { describe, expect, it } from "vitest"; +import { SystemAgentChatResultSchema } from "./schema/openclaw.js"; +import type { WizardStep } from "./schema/wizard.js"; + +/** + * The chat result carries the awaited wizard step verbatim so control-capable + * clients can render it. Every step type has to survive the wire, including the + * fields the card-shaped `question` projection drops. + */ +describe("SystemAgentChatResultSchema", () => { + const validate = Compile(SystemAgentChatResultSchema); + const base = { sessionId: "chat-1", reply: "Bot token", action: "none" }; + + const steps: Array<{ name: string; step: WizardStep }> = [ + { + // No initialValue: the schema still permits one (wizard.start/next carry + // prefill for editable prompts), but the chat engine strips it from a + // sensitive step before serializing, so this is the shape that ships here. + name: "sensitive text carrying placeholder but no prefilled secret", + step: { + id: "step-text", + type: "text", + message: "Bot token", + placeholder: "123:abc", + sensitive: true, + executor: "client", + }, + }, + { + name: "non-sensitive text carrying a prefilled value", + step: { + id: "step-text-prefill", + type: "text", + message: "Display name", + initialValue: "openclaw-bot", + executor: "client", + }, + }, + { + name: "select with options", + step: { + id: "step-select", + type: "select", + message: "DM mode", + options: [ + { value: "alpha", label: "Alpha", hint: "First" }, + { value: "beta", label: "Beta" }, + ], + initialValue: "beta", + executor: "client", + }, + }, + { + name: "multiselect with options", + step: { + id: "step-multiselect", + type: "multiselect", + message: "Features", + options: [ + { value: "alerts", label: "Alerts" }, + { value: "logs", label: "Logs" }, + ], + initialValue: ["alerts"], + executor: "client", + }, + }, + { + name: "confirm", + step: { + id: "step-confirm", + type: "confirm", + message: "Enable delegated auth?", + initialValue: false, + executor: "client", + }, + }, + { + // The engine auto-answers notes, so this shape only reaches clients on the + // wizard methods; the chat result still has to accept it losslessly. + name: "note carrying a device code and an external URL", + step: { + id: "step-note", + type: "note", + title: "Sign in", + message: "Enter this one-time code on the provider's sign-in page.", + format: "plain", + externalUrl: "https://example.com/auth", + deviceCode: { + code: "ABCD-EFGH", + expiresInMinutes: 15, + message: "Never share this code.", + }, + executor: "client", + }, + }, + { + name: "progress", + step: { + id: "step-progress", + type: "progress", + message: "Linking your account", + executor: "gateway", + }, + }, + { + name: "action executed by the client", + step: { + id: "step-action", + type: "action", + title: "Authorize", + message: "Approve the app in your browser.", + externalUrl: "https://example.com/authorize", + executor: "client", + }, + }, + ]; + + it.each(steps)("accepts a chat result carrying a $name step", ({ step }) => { + expect(validate.Check({ ...base, step })).toBe(true); + }); + + it("stays optional for replies with no awaited step", () => { + expect(validate.Check(base)).toBe(true); + }); + + it("rejects a step outside the wizard step contract", () => { + expect(validate.Check({ ...base, step: { id: "step-bogus", type: "freeform" } })).toBe(false); + }); +}); diff --git a/packages/gateway-protocol/src/schema/openclaw.test.ts b/packages/gateway-protocol/src/schema/openclaw.test.ts index ca0cd76d790e..171ae394a36c 100644 --- a/packages/gateway-protocol/src/schema/openclaw.test.ts +++ b/packages/gateway-protocol/src/schema/openclaw.test.ts @@ -23,6 +23,21 @@ describe("OpenClaw chat params protocol", () => { ).toBe(true); }); + it("accepts a typed wizard answer and rejects unknown answer fields", () => { + expect( + validateSystemAgentChatParams({ + sessionId: "session-1", + wizardAnswer: { stepId: "channel", value: "twitch" }, + }), + ).toBe(true); + expect( + validateSystemAgentChatParams({ + sessionId: "session-1", + wizardAnswer: { stepId: "channel", value: "twitch", display: "Twitch" }, + }), + ).toBe(false); + }); + it("rejects unsafe page ids and unknown context fields", () => { expect(validateSystemAgentChatParams({ ...base, context: { page: "channels?tab=all" } })).toBe( false, diff --git a/packages/gateway-protocol/src/schema/openclaw.ts b/packages/gateway-protocol/src/schema/openclaw.ts index b5dc01ebb8b3..d0d5c9553847 100644 --- a/packages/gateway-protocol/src/schema/openclaw.ts +++ b/packages/gateway-protocol/src/schema/openclaw.ts @@ -3,7 +3,7 @@ import type { Static } from "typebox"; import { Type } from "typebox"; import { closedObject } from "./closed-object.js"; import { NonEmptyString } from "./primitives.js"; -import { WizardStartResultSchema } from "./wizard.js"; +import { WizardAnswerSchema, WizardStartResultSchema, WizardStepSchema } from "./wizard.js"; /** * OpenClaw chat lets clients (macOS app onboarding, future UIs) hold the @@ -13,7 +13,10 @@ import { WizardStartResultSchema } from "./wizard.js"; */ export const SystemAgentChatParamsSchema = closedObject({ sessionId: NonEmptyString, + /** Free-text input for conversational and text-only clients. */ message: Type.Optional(Type.String()), + /** Typed answer from a client rendering the current `WizardStep`. */ + wizardAnswer: Type.Optional(WizardAnswerSchema), /** Seeds a purpose-specific first greeting for a fresh conversation. */ welcomeVariant: Type.Optional( Type.Union([Type.Literal("onboarding"), Type.Literal("new-agent")]), @@ -90,6 +93,11 @@ export const SystemAgentChatResultSchema = closedObject({ needsApproval: Type.Optional(Type.Boolean()), proposalId: Type.Optional(NonEmptyString), question: Type.Optional(SystemAgentChatQuestionSchema), + /** + * The awaited wizard step in full. `question` above is a lossy card projection + * of the same step, so control-capable clients render this instead. + */ + step: Type.Optional(WizardStepSchema), }); export const SystemAgentChatHistoryParamsSchema = closedObject({ diff --git a/packages/gateway-protocol/src/schema/wizard.ts b/packages/gateway-protocol/src/schema/wizard.ts index 6f803155335d..71aca05dd9f6 100644 --- a/packages/gateway-protocol/src/schema/wizard.ts +++ b/packages/gateway-protocol/src/schema/wizard.ts @@ -25,7 +25,7 @@ export const WizardStartParamsSchema = closedObject({ }); /** Client answer payload for the current wizard step. */ -const WizardAnswerSchema = closedObject({ +export const WizardAnswerSchema = closedObject({ stepId: NonEmptyString, value: Type.Optional(Type.Unknown()), }); @@ -122,6 +122,7 @@ export const WizardStatusResultSchema = closedObject({ // Wire types derive directly from local schema consts so public d.ts graphs never // pull in the ProtocolSchemas registry. export type WizardStartParams = Static; +export type WizardAnswer = Static; export type WizardNextParams = Static; export type WizardCancelParams = Static; export type WizardStatusParams = Static; diff --git a/src/channels/plugins/setup-wizard-types.ts b/src/channels/plugins/setup-wizard-types.ts index f4a6c91c51a0..35c2b6ef1e9f 100644 --- a/src/channels/plugins/setup-wizard-types.ts +++ b/src/channels/plugins/setup-wizard-types.ts @@ -120,12 +120,14 @@ export type ChannelSetupWizardCredential = { }) => OpenClawConfig | Promise; }; -/** Declarative non-secret text step that can depend on resolved credentials. */ +/** Declarative text step that can depend on resolved credentials. */ export type ChannelSetupWizardTextInput = { /** Plugin-owned key written into the runtime setup input. */ inputKey: string; message: string; placeholder?: string; + /** Mask input and keep any configured value server-side. */ + sensitive?: boolean; required?: boolean; applyEmptyValue?: boolean; helpTitle?: string; diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts index 08218003995a..916b43d7b442 100644 --- a/src/channels/plugins/setup-wizard.ts +++ b/src/channels/plugins/setup-wizard.ts @@ -232,6 +232,21 @@ async function applyWizardTextInputValue(params: { }).cfg; } +function resolveTextInputKeepMessage( + input: ChannelSetupWizardTextInput, + currentValue: string, +): string { + if (input.sensitive === true) { + // Never pass a configured secret to plugin-owned presentation code. + return typeof input.keepPrompt === "string" + ? input.keepPrompt + : `${input.message} already configured. Keep it?`; + } + return typeof input.keepPrompt === "function" + ? input.keepPrompt(currentValue) + : (input.keepPrompt ?? `${input.message} set (${currentValue}). Keep it?`); +} + export function buildChannelSetupWizardAdapterFromSetupWizard(params: { plugin: ChannelSetupWizardPlugin; wizard: ChannelSetupWizard; @@ -511,11 +526,7 @@ export function buildChannelSetupWizardAdapterFromSetupWizard(params: { if (currentValue && textInput.confirmCurrentValue !== false) { const keep = await prompter.confirm({ - message: - typeof textInput.keepPrompt === "function" - ? textInput.keepPrompt(currentValue) - : (textInput.keepPrompt ?? - `${textInput.message} set (${currentValue}). Keep it?`), + message: resolveTextInputKeepMessage(textInput, currentValue), initialValue: true, }); if (keep) { @@ -533,17 +544,21 @@ export function buildChannelSetupWizardAdapterFromSetupWizard(params: { } } - const initialValue = normalizeOptionalString( - (await textInput.initialValue?.({ - cfg: next, - accountId, - credentialValues, - })) ?? currentValue, - ); + const initialValue = + textInput.sensitive === true + ? undefined + : normalizeOptionalString( + (await textInput.initialValue?.({ + cfg: next, + accountId, + credentialValues, + })) ?? currentValue, + ); const rawValue = await prompter.text({ message: textInput.message, - initialValue, placeholder: textInput.placeholder, + ...(textInput.sensitive === true ? {} : { initialValue }), + ...(textInput.sensitive === true ? { sensitive: true } : {}), validate: (value) => { const trimmed = normalizeOptionalString(value) ?? ""; if (!trimmed && textInput.required !== false) { diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 541292f2012a..5a4d48a4b0eb 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -1,6 +1,7 @@ import type { SessionApprovalReplay, SystemAgentChatQuestion, + WizardAnswer, } from "../../../packages/gateway-protocol/src/index.js"; // Shared server-method types define the client, context, response, and handler // contracts used by every gateway RPC method module. @@ -136,6 +137,12 @@ type GatewaySystemAgentSession = { sensitive?: boolean; question?: SystemAgentChatQuestion; }>; + answerWizard: (answer: WizardAnswer) => Promise<{ + text: string; + action: "none" | "exit" | "open-tui" | "open-setup"; + sensitive?: boolean; + question?: SystemAgentChatQuestion; + }>; seedHistory: (turns: readonly SystemAgentHistoryTurn[]) => void; historyLength: () => number; historySince: (index: number) => SystemAgentHistoryTurn[]; diff --git a/src/gateway/server-methods/system-agent-chat-turn.test.ts b/src/gateway/server-methods/system-agent-chat-turn.test.ts new file mode 100644 index 000000000000..f9ffeab6bb67 --- /dev/null +++ b/src/gateway/server-methods/system-agent-chat-turn.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildSystemAgentChatResult, + getSystemAgentChatInputError, + runSystemAgentChatInput, +} from "./system-agent-chat-turn.js"; + +function makeEngine() { + const handle = vi.fn(); + const answerWizard = vi.fn(); + return { + answerWizard, + handle, + engine: { answerWizard, handle }, + }; +} + +describe("system-agent chat input", () => { + it.each([ + { + input: { + sessionId: "s1", + message: "5", + wizardAnswer: { stepId: "channel", value: "twitch" }, + }, + error: "Send either message or wizardAnswer, not both.", + }, + { + input: { + sessionId: "s1", + wizardAnswer: { stepId: "secret", value: "not-forwarded" }, + delegation: { agentId: "main", sessionKey: "agent:main:main" }, + }, + error: "Delegated OpenClaw sessions cannot submit structured wizard answers.", + }, + { + input: { + sessionId: "s1", + wizardAnswer: { stepId: "channel", value: "twitch" }, + reset: true, + }, + error: "A wizard answer cannot reset its OpenClaw chat session.", + }, + ])("rejects invalid mixed input: $error", ({ input, error }) => { + expect(getSystemAgentChatInputError(input)).toBe(error); + }); + + it("routes a structured wizard answer through the typed engine seam", async () => { + const { engine, answerWizard, handle } = makeEngine(); + answerWizard.mockResolvedValue({ text: "Next step.", action: "none" }); + + await expect( + runSystemAgentChatInput({ + engine, + input: { + sessionId: "s1", + wizardAnswer: { stepId: "channel", value: "twitch" }, + }, + }), + ).resolves.toEqual({ text: "Next step.", action: "none" }); + + expect(answerWizard).toHaveBeenCalledWith({ stepId: "channel", value: "twitch" }); + expect(handle).not.toHaveBeenCalled(); + }); + + it("preserves the enriched wizard step in the gateway result", () => { + expect( + buildSystemAgentChatResult({ + sessionId: "s1", + reply: { + text: "Choose a channel.", + action: "none", + step: { + id: "channel", + type: "select", + message: "Channel", + options: [{ label: "Twitch", value: "twitch" }], + }, + }, + }), + ).toMatchObject({ + sessionId: "s1", + reply: "Choose a channel.", + action: "none", + step: { id: "channel", type: "select" }, + }); + }); +}); diff --git a/src/gateway/server-methods/system-agent-chat-turn.ts b/src/gateway/server-methods/system-agent-chat-turn.ts new file mode 100644 index 000000000000..643f308470ea --- /dev/null +++ b/src/gateway/server-methods/system-agent-chat-turn.ts @@ -0,0 +1,71 @@ +import type { + SystemAgentChatParams, + SystemAgentChatResult, +} from "../../../packages/gateway-protocol/src/index.js"; +import type { SystemAgentChatEngine } from "../../system-agent/chat-engine.js"; + +type SystemAgentChatReply = Awaited>; +type SystemAgentChatEngineInput = Pick; + +export function getSystemAgentChatInputError(params: SystemAgentChatParams): string | undefined { + if (params.message !== undefined && params.wizardAnswer !== undefined) { + return "Send either message or wizardAnswer, not both."; + } + if (params.wizardAnswer !== undefined && params.delegation !== undefined) { + return "Delegated OpenClaw sessions cannot submit structured wizard answers."; + } + if (params.wizardAnswer !== undefined && params.reset === true) { + return "A wizard answer cannot reset its OpenClaw chat session."; + } + return undefined; +} + +export async function runSystemAgentChatInput(params: { + engine: SystemAgentChatEngineInput; + input: SystemAgentChatParams; +}): Promise { + if (params.input.wizardAnswer !== undefined) { + return await params.engine.answerWizard(params.input.wizardAnswer); + } + if (params.input.message === undefined) { + return undefined; + } + return params.input.delegation === undefined && params.input.context + ? await params.engine.handle(params.input.message, { uiContext: params.input.context }) + : await params.engine.handle(params.input.message); +} + +export function buildSystemAgentChatResult(params: { + sessionId: string; + reply: SystemAgentChatReply; + proposalId?: string; +}): SystemAgentChatResult { + const action = + params.reply.action === "open-tui" + ? "open-agent" + : params.reply.action === "open-setup" + ? "none" + : params.reply.action; + return { + sessionId: params.sessionId, + reply: + params.reply.text || + (action === "open-agent" + ? "Setup here is done — continue with your agent." + : "Nothing to change."), + action, + ...(action === "open-agent" && params.reply.agentDraft + ? { agentDraft: params.reply.agentDraft } + : {}), + ...(action === "open-agent" && + params.reply.handoff?.kind === "open-tui" && + params.reply.handoff.agentId + ? { agentId: params.reply.handoff.agentId } + : {}), + ...(params.reply.sensitive === true ? { sensitive: true } : {}), + ...(params.reply.wizardInputPending === true ? { wizardInputPending: true } : {}), + ...(params.reply.question ? { question: params.reply.question } : {}), + ...(params.reply.step ? { step: params.reply.step } : {}), + ...(params.proposalId ? { needsApproval: true, proposalId: params.proposalId } : {}), + }; +} diff --git a/src/gateway/server-methods/system-agent-session-ownership.test.ts b/src/gateway/server-methods/system-agent-session-ownership.test.ts index 0bc162be5427..b61aadebc7fc 100644 --- a/src/gateway/server-methods/system-agent-session-ownership.test.ts +++ b/src/gateway/server-methods/system-agent-session-ownership.test.ts @@ -3,6 +3,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetCommandQueueStateForTest } from "../../process/command-queue.test-support.js"; +import { SystemAgentWizardAnswerError } from "../../system-agent/chat-engine.js"; import { systemAgentHandlers, type SystemAgentChatSession } from "./system-agent.js"; import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js"; @@ -10,6 +11,11 @@ const setupInferenceMocks = vi.hoisted(() => ({ verifySetupInference: vi.fn() }) const delegatedInferenceMocks = vi.hoisted(() => ({ verifySystemAgentInferenceWithFallback: vi.fn(), })); +const transcriptStoreMocks = vi.hoisted(() => ({ + appendTranscriptReset: vi.fn(), + appendTranscriptTurn: vi.fn(), + readTranscriptTail: vi.fn(() => []), +})); vi.mock("../../system-agent/setup-inference.js", () => ({ verifySetupInference: setupInferenceMocks.verifySetupInference, @@ -18,11 +24,7 @@ vi.mock("../../system-agent/inference-fallback.js", () => ({ verifySystemAgentInferenceWithFallback: delegatedInferenceMocks.verifySystemAgentInferenceWithFallback, })); -vi.mock("../../system-agent/transcript-store.js", () => ({ - appendTranscriptReset: vi.fn(), - appendTranscriptTurn: vi.fn(), - readTranscriptTail: vi.fn(() => []), -})); +vi.mock("../../system-agent/transcript-store.js", () => transcriptStoreMocks); // Ownership tests exercise fresh-session creation; keep the caretaker greeting // deterministic so identity behavior is the only variable under test. vi.mock("../../system-agent/greeting.js", () => ({ @@ -38,6 +40,7 @@ vi.mock("../../system-agent/greeting.js", () => ({ })); type FakeEngine = { + answerWizard: ReturnType; handle: ReturnType; seedHistory: ReturnType; historyLength: ReturnType; @@ -51,6 +54,9 @@ type FakeEngine = { function makeEngine(): FakeEngine { return { + answerWizard: vi.fn(async () => { + throw new SystemAgentWizardAnswerError("No hosted wizard is awaiting an answer."); + }), handle: vi.fn(async () => ({ text: "did the thing", action: "none" })), seedHistory: vi.fn(), historyLength: vi.fn(() => 0), @@ -65,13 +71,17 @@ function makeEngine(): FakeEngine { const createdEngines = vi.hoisted(() => [] as FakeEngine[]); -vi.mock("../../system-agent/chat-engine.js", () => ({ - SystemAgentChatEngine: function FakeSystemAgentChatEngine(this: FakeEngine) { - const engine = makeEngine(); - createdEngines.push(engine); - Object.assign(this, engine); - }, -})); +vi.mock("../../system-agent/chat-engine.js", () => { + class FakeSystemAgentWizardAnswerError extends Error {} + return { + SystemAgentWizardAnswerError: FakeSystemAgentWizardAnswerError, + SystemAgentChatEngine: function FakeSystemAgentChatEngine(this: FakeEngine) { + const engine = makeEngine(); + createdEngines.push(engine); + Object.assign(this, engine); + }, + }; +}); vi.mock("../../system-agent/overview.js", () => ({ formatSystemAgentStartupMessage: vi.fn(() => "welcome text"), })); @@ -315,6 +325,35 @@ describe("openclaw.chat session responses", () => { expect(call.payload).toMatchObject({ reply: "did the thing", action: "none" }); }); + it("rejects a structured answer without an active chat session", async () => { + const call = await callChat(makeContext(new Map()), { + sessionId: "missing", + wizardAnswer: { stepId: "channel", value: "twitch" }, + }); + + expect(call).toMatchObject({ + ok: false, + error: { + code: "INVALID_REQUEST", + details: { code: "system_agent_session_invalidated" }, + }, + }); + expect(setupInferenceMocks.verifySetupInference).not.toHaveBeenCalled(); + }); + + it("rejects a structured answer when the active session has no hosted wizard", async () => { + const engine = makeEngine(); + const sessions = new Map([["s1", seededSession({ engine })]]); + + const call = await callChat(makeContext(sessions), { + sessionId: "s1", + wizardAnswer: { stepId: "stale", value: "twitch" }, + }); + + expect(call).toMatchObject({ ok: false, error: { code: "INVALID_REQUEST" } }); + expect(transcriptStoreMocks.appendTranscriptTurn).not.toHaveBeenCalled(); + }); + it("forwards sensitive-input metadata", async () => { const engine = makeEngine(); engine.handle.mockResolvedValue({ diff --git a/src/gateway/server-methods/system-agent.ts b/src/gateway/server-methods/system-agent.ts index 7f2b26998a90..cfc72e904013 100644 --- a/src/gateway/server-methods/system-agent.ts +++ b/src/gateway/server-methods/system-agent.ts @@ -23,7 +23,10 @@ import { enqueueCommandInLane, setCommandLaneConcurrency } from "../../process/c import { runWithGatewayIndependentRootWorkContinuation } from "../../process/gateway-work-admission.js"; import { CommandLane } from "../../process/lanes.js"; import { defaultRuntime } from "../../runtime.js"; -import { SystemAgentChatEngine } from "../../system-agent/chat-engine.js"; +import { + SystemAgentChatEngine, + SystemAgentWizardAnswerError, +} from "../../system-agent/chat-engine.js"; import { resolveSystemAgentDelegationKey } from "../../system-agent/delegation-session.js"; import { acknowledgeSystemAgentGreetingDelivery, @@ -48,6 +51,11 @@ import { listVisiblePendingApprovalRequests, } from "./approval-shared.js"; import { sanitizeSystemAgentChatParams } from "./system-agent-chat-params.js"; +import { + buildSystemAgentChatResult, + getSystemAgentChatInputError, + runSystemAgentChatInput, +} from "./system-agent-chat-turn.js"; import type { GatewayClient, GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -503,6 +511,11 @@ export const systemAgentHandlers: GatewayRequestHandlers = { if (!assertValidParams(params, validateSystemAgentChatParams, "openclaw.chat", respond)) { return; } + const inputError = getSystemAgentChatInputError(params); + if (inputError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, inputError)); + return; + } await runSystemAgentGatewayTask(async () => { const sessions = context.systemAgentSessions; const sessionId = params.sessionId; @@ -543,8 +556,22 @@ export const systemAgentHandlers: GatewayRequestHandlers = { await existing?.engine.dispose(); } let session = sessions.get(sessionId); + if (params.wizardAnswer !== undefined && !session) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "No active OpenClaw chat session is awaiting that wizard answer.", + { details: buildSystemAgentSessionInvalidatedErrorDetails() }, + ), + ); + return; + } let greetingAuditSequence: number | undefined; - const welcomeOnly = params.message === undefined || !params.message.trim(); + const welcomeOnly = + params.wizardAnswer === undefined && + (params.message === undefined || !params.message.trim()); if (!session) { const inference = params.delegation ? await import("../../system-agent/inference-fallback.js").then( @@ -654,7 +681,10 @@ export const systemAgentHandlers: GatewayRequestHandlers = { } session.lastUsedAt = Date.now(); // Inline check (not `welcomeOnly`) so TS narrows params.message below. - if (params.message === undefined || !params.message.trim()) { + if ( + params.wizardAnswer === undefined && + (params.message === undefined || !params.message.trim()) + ) { respond( true, { @@ -671,12 +701,25 @@ export const systemAgentHandlers: GatewayRequestHandlers = { const historyStart = session.engine.historyLength(); let reply: Awaited>; try { - reply = - params.delegation === undefined && params.context - ? await session.engine.handle(params.message, { uiContext: params.context }) - : await session.engine.handle(params.message); + const turnReply = await runSystemAgentChatInput({ + engine: session.engine, + input: params, + }); + if (!turnReply) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "OpenClaw chat input is missing."), + ); + return; + } + reply = turnReply; } catch (error) { persistEngineHistory(session.engine, historyStart); + if (error instanceof SystemAgentWizardAnswerError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message)); + return; + } if (!isSystemAgentInferenceUnavailableError(error)) { throw error; } @@ -702,14 +745,6 @@ export const systemAgentHandlers: GatewayRequestHandlers = { return; } persistEngineHistory(session.engine, historyStart); - // The TUI-only "open-tui" handoff becomes a client-visible "open-agent" - // signal: the app should move the user to their normal agent chat. - const action = - reply.action === "open-tui" - ? "open-agent" - : reply.action === "open-setup" - ? "none" - : reply.action; const delegation = params.delegation; let proposalId: string | undefined; if (delegation) { @@ -725,31 +760,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = { }); } } - respond( - true, - { - sessionId, - reply: - reply.text || - (action === "open-agent" - ? "Setup here is done — continue with your agent." - : "Nothing to change."), - action, - ...(action === "open-agent" && reply.agentDraft - ? { agentDraft: reply.agentDraft } - : {}), - ...(action === "open-agent" && - reply.handoff?.kind === "open-tui" && - reply.handoff.agentId - ? { agentId: reply.handoff.agentId } - : {}), - ...(reply.sensitive === true ? { sensitive: true } : {}), - ...(reply.wizardInputPending === true ? { wizardInputPending: true } : {}), - ...(reply.question ? { question: reply.question } : {}), - ...(proposalId ? { needsApproval: true, proposalId } : {}), - }, - undefined, - ); + respond(true, buildSystemAgentChatResult({ sessionId, reply, proposalId }), undefined); }); }); }, diff --git a/src/gateway/server-methods/wizard.test.ts b/src/gateway/server-methods/wizard.test.ts index 3653f960d28f..4b5f0346ddb0 100644 --- a/src/gateway/server-methods/wizard.test.ts +++ b/src/gateway/server-methods/wizard.test.ts @@ -8,6 +8,37 @@ import { createWizardSessionTracker } from "../server-wizard-sessions.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; import { type SetupWizardRunner, wizardHandlers } from "./wizard.js"; +function createWizardContext( + wizardRunner: NonNullable["wizardRunner"], +) { + const wizardSessions = new Map(); + return { + wizardSessions, + wizardRunner, + findRunningWizard: () => undefined, + purgeWizardSession: (sessionId: string) => wizardSessions.delete(sessionId), + }; +} + +function readSuccessfulResponse(respond: ReturnType): Record { + expect(respond).toHaveBeenCalledOnce(); + const [ok, result] = respond.mock.calls[0] ?? []; + expect(ok).toBe(true); + expect(result).toBeDefined(); + return result as Record; +} + +async function invokeWizard( + method: "wizard.start" | "wizard.next", + params: Record, + context: ReturnType, +): Promise> { + const respond = vi.fn(); + const handler = expectDefined(wizardHandlers[method], `wizardHandlers[${method}] test invariant`); + await handler({ params, respond, context } as never); + return readSuccessfulResponse(respond); +} + describe("wizard session lookup", () => { it.each([ { method: "wizard.next", params: { sessionId: "expired" } }, @@ -211,3 +242,53 @@ describe("wizard setup ownership", () => { } }); }); + +describe("wizard step serialization", () => { + it("strips a sensitive initial value from wizard.start", async () => { + const context = createWizardContext(async (_opts, _runtime, prompter) => { + await prompter.text({ + message: "Bot token", + sensitive: true, + initialValue: "123456:REAL-SECRET", + }); + }); + const result = await invokeWizard("wizard.start", {}, context); + expect(result.step).toMatchObject({ sensitive: true }); + expect(result.step).not.toHaveProperty("initialValue"); + for (const session of context.wizardSessions.values()) { + session.cancel(); + } + }); + + it("keeps a plain default but strips the next sensitive one from wizard.next", async () => { + const context = createWizardContext(async (_opts, _runtime, prompter) => { + await prompter.text({ + message: "Display name", + initialValue: "OpenClaw", + }); + await prompter.text({ + message: "Bot token", + sensitive: true, + initialValue: "123456:REAL-SECRET", + }); + }); + const startResult = await invokeWizard("wizard.start", {}, context); + expect(startResult.step).toMatchObject({ initialValue: "OpenClaw" }); + const sessionId = startResult.sessionId; + expect(typeof sessionId).toBe("string"); + + const params = { + sessionId, + answer: { + stepId: (startResult.step as { id: string }).id, + value: "Renamed", + }, + }; + const nextResult = await invokeWizard("wizard.next", params, context); + expect(nextResult.step).toMatchObject({ sensitive: true }); + expect(nextResult.step).not.toHaveProperty("initialValue"); + for (const session of context.wizardSessions.values()) { + session.cancel(); + } + }); +}); diff --git a/src/gateway/server-methods/wizard.ts b/src/gateway/server-methods/wizard.ts index 2979338a8caf..ba2561e885a8 100644 --- a/src/gateway/server-methods/wizard.ts +++ b/src/gateway/server-methods/wizard.ts @@ -14,7 +14,11 @@ import { import type { OnboardOptions } from "../../commands/onboard-types.js"; import { createNonExitingRuntime, ExitError, type RuntimeEnv } from "../../runtime.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; -import { WizardSession } from "../../wizard/session.js"; +import { + sanitizeWizardStepForClient, + WizardSession, + type WizardStep, +} from "../../wizard/session.js"; import { formatForLog } from "../ws-log.js"; import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -65,6 +69,10 @@ function readWizardStatus(session: WizardSession) { }; } +function sanitizeWizardResultForClient(result: T): T { + return result.step ? { ...result, step: sanitizeWizardStepForClient(result.step) } : result; +} + /** Resolves a live wizard session or sends the public not-found error. */ function findWizardSessionOrRespond(params: { context: GatewayRequestContext; @@ -135,7 +143,7 @@ export const wizardHandlers: GatewayRequestHandlers = { // clients get a clean not-found response for stale session ids. context.purgeWizardSession(sessionId); } - respond(true, { sessionId, ...result }, undefined); + respond(true, { sessionId, ...sanitizeWizardResultForClient(result) }, undefined); }, "wizard.next": async ({ params, respond, context }) => { if (!assertValidParams(params, validateWizardNextParams, "wizard.next", respond)) { @@ -155,7 +163,14 @@ export const wizardHandlers: GatewayRequestHandlers = { try { const validationError = await session.answer(answer.stepId ?? "", answer.value); if (validationError) { - respond(true, { ...(await session.next()), error: validationError }, undefined); + respond( + true, + { + ...sanitizeWizardResultForClient(await session.next()), + error: validationError, + }, + undefined, + ); return; } } catch (err) { @@ -169,7 +184,7 @@ export const wizardHandlers: GatewayRequestHandlers = { // wizard.start's immediate-completion path. context.purgeWizardSession(sessionId); } - respond(true, result, undefined); + respond(true, sanitizeWizardResultForClient(result), undefined); }, "wizard.cancel": ({ params, respond, context }) => { if (!assertValidParams(params, validateWizardCancelParams, "wizard.cancel", respond)) { diff --git a/src/system-agent/chat-engine.test.ts b/src/system-agent/chat-engine.test.ts index a542bd0ab49d..b6f1c177fa7c 100644 --- a/src/system-agent/chat-engine.test.ts +++ b/src/system-agent/chat-engine.test.ts @@ -17,6 +17,7 @@ import { runSystemAgentTurnWithDeps } from "./agent-turn.test-support.js"; import { classifySystemAgentApprovalText } from "./approval-intent.js"; import { SystemAgentChatEngine as RuntimeSystemAgentChatEngine, + SystemAgentWizardAnswerError, type SystemAgentChatEngineOptions, } from "./chat-engine.js"; import { SystemAgentInferenceUnavailableError } from "./inference-error.js"; @@ -3263,6 +3264,292 @@ describe("OpenClaw agent loop backends", () => { }); }); +describe("OpenClaw chat wizard step payload", () => { + // `action` is missing on purpose: no production path or prompter method emits + // a step of that type, so it is unreachable through this seam. The protocol + // round-trip test in packages/gateway-protocol covers it instead. + const cases: Array<{ + name: string; + run: (prompter: WizardPrompter) => Promise; + /** Undefined means no step awaits an answer when the reply is built. */ + step: Record | undefined; + }> = [ + { + name: "text", + // openUrl binds to the next created step, so this proves the fields the + // card projection drops (placeholder/initialValue/sensitive/externalUrl). + // It is optional on the prompter contract; a prompter without it would + // fail the step assertion below on the missing externalUrl. + run: async (prompter) => { + await prompter.openUrl?.("https://example.com/auth"); + await prompter.text({ + message: "Bot token", + initialValue: "seed-token", + placeholder: "123:abc", + sensitive: true, + }); + }, + // initialValue is absent on purpose: the prompt below seeds one, but a + // sensitive step's prefilled value is the secret and must not cross to + // chat-result consumers. Everything else survives verbatim. + step: { + id: expect.any(String), + type: "text", + message: "Bot token", + placeholder: "123:abc", + sensitive: true, + executor: "client", + externalUrl: "https://example.com/auth", + }, + }, + { + name: "select", + run: async (prompter) => { + // Option values avoid "telegram" so tryAutoSelectChannel cannot answer + // this step for us and null the bridge's awaited step. + await prompter.select({ + message: "DM mode", + options: [ + { value: "alpha", label: "Alpha", hint: "First" }, + { value: "beta", label: "Beta" }, + ], + initialValue: "beta", + }); + }, + step: { + id: expect.any(String), + type: "select", + message: "DM mode", + options: [ + { value: "alpha", label: "Alpha", hint: "First" }, + { value: "beta", label: "Beta" }, + ], + initialValue: "beta", + executor: "client", + }, + }, + { + name: "confirm", + run: async (prompter) => { + await prompter.confirm({ message: "Enable delegated auth?", initialValue: false }); + }, + step: { + id: expect.any(String), + type: "confirm", + message: "Enable delegated auth?", + initialValue: false, + executor: "client", + }, + }, + { + name: "multiselect", + run: async (prompter) => { + await prompter.multiselect({ + message: "Features", + options: [ + { value: "alerts", label: "Alerts" }, + { value: "logs", label: "Logs" }, + ], + }); + }, + step: { + id: expect.any(String), + type: "multiselect", + message: "Features", + options: [ + { value: "alerts", label: "Alerts" }, + { value: "logs", label: "Logs" }, + ], + executor: "client", + }, + }, + { + // Informational steps are auto-answered by the pump before the reply is + // built, so they render as prose with no control. Absent `step` here is + // the contract, not a gap. + name: "note", + run: async (prompter) => { + await prompter.note("Open the provider console first."); + }, + step: undefined, + }, + { + name: "progress", + run: async (prompter) => { + prompter.progress("Linking your account"); + }, + step: undefined, + }, + ]; + + it.each(cases)("carries the awaited $name step on the chat reply", async ({ run, step }) => { + useTempStateDir(); + const engine = new SystemAgentChatEngine({ + surface: "gateway", + runAgentTurn: async () => null, + planWithAssistant: async () => null, + deps: { loadOverview: fakeOverviewLoader() }, + runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => { + await run(prompter); + }, + }); + + const reply = await engine.handle("connect telegram"); + + if (step) { + expect(reply.step).toEqual(step); + } else { + expect(reply.step).toBeUndefined(); + } + }); + + it("strips a sensitive step's prefilled value but keeps a plain one", async () => { + useTempStateDir(); + const makeEngine = (sensitive: boolean) => + new SystemAgentChatEngine({ + surface: "gateway", + runAgentTurn: async () => null, + planWithAssistant: async () => null, + deps: { loadOverview: fakeOverviewLoader() }, + runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => { + await prompter.text({ + message: "Bot token", + initialValue: "123456:REAL-SECRET", + ...(sensitive ? { sensitive: true } : {}), + }); + }, + }); + + const secret = await makeEngine(true).handle("connect telegram"); + expect(secret.step?.sensitive).toBe(true); + expect(secret.step).not.toHaveProperty("initialValue"); + expect(JSON.stringify(secret)).not.toContain("REAL-SECRET"); + + // Redaction is scoped to sensitive steps; ordinary prefill still reaches + // clients, otherwise every edit-in-place prompt would lose its default. + const plain = await makeEngine(false).handle("connect telegram"); + expect(plain.step?.initialValue).toBe("123456:REAL-SECRET"); + }); + + it("omits the wizard step outside an awaiting hosted wizard", async () => { + useTempStateDir(); + const engine = new SystemAgentChatEngine({ + surface: "gateway", + runAgentTurn: async () => ({ text: "*click* Everything looks healthy." }), + planWithAssistant: async () => null, + deps: { loadOverview: fakeOverviewLoader() }, + runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => { + await prompter.text({ message: "Bot token" }); + }, + }); + + const ordinary = await engine.handle("how is my setup looking?"); + expect(ordinary.step).toBeUndefined(); + + const awaiting = await engine.handle("connect telegram"); + expect(awaiting.step?.type).toBe("text"); + + const done = await engine.handle("123:abc"); + expect(done.text).toContain("telegram is configured"); + expect(done.step).toBeUndefined(); + }); + + it("submits a typed answer directly and records the server-owned option label", async () => { + useTempStateDir(); + let selected: unknown; + const engine = new SystemAgentChatEngine({ + surface: "gateway", + runAgentTurn: async () => null, + planWithAssistant: async () => null, + deps: { loadOverview: fakeOverviewLoader() }, + runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => { + selected = await prompter.select({ + message: "Choose one", + options: [ + { value: "alpha", label: "Alpha" }, + { value: "beta", label: "Beta" }, + ], + }); + }, + }); + + const prompt = await engine.handle("connect telegram"); + const stepId = expectDefined(prompt.step?.id, "expected an active wizard step"); + await engine.answerWizard({ stepId, value: "beta" }); + + expect(selected).toBe("beta"); + expect(engine.historySince(0)).toContainEqual({ role: "user", text: "Beta" }); + }); + + it("rejects a stale structured answer without changing the active step", async () => { + useTempStateDir(); + const engine = new SystemAgentChatEngine({ + surface: "gateway", + runAgentTurn: async () => null, + planWithAssistant: async () => null, + deps: { loadOverview: fakeOverviewLoader() }, + runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => { + await prompter.text({ message: "Bot token" }); + }, + }); + + const prompt = await engine.handle("connect telegram"); + await expect( + engine.answerWizard({ stepId: "stale-step", value: "ignored" }), + ).rejects.toBeInstanceOf(SystemAgentWizardAnswerError); + const stepId = expectDefined(prompt.step?.id, "expected an active wizard step"); + const done = await engine.answerWizard({ stepId, value: "123:abc" }); + + expect(done.step).toBeUndefined(); + expect(JSON.stringify(engine.historySince(0))).not.toContain("ignored"); + }); + + it("redacts a sensitive structured answer from engine history", async () => { + useTempStateDir(); + const engine = new SystemAgentChatEngine({ + surface: "gateway", + runAgentTurn: async () => null, + planWithAssistant: async () => null, + deps: { loadOverview: fakeOverviewLoader() }, + runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => { + await prompter.text({ message: "Bot token", sensitive: true }); + }, + }); + + const prompt = await engine.handle("connect telegram"); + const stepId = expectDefined(prompt.step?.id, "expected an active wizard step"); + await engine.answerWizard({ stepId, value: "raw-secret-value" }); + + expect(engine.historySince(0)).toContainEqual({ role: "user", text: "" }); + expect(JSON.stringify(engine.historySince(0))).not.toContain("raw-secret-value"); + }); + + it("keeps the numbered text grammar for text-only wizard clients", async () => { + useTempStateDir(); + let selected: unknown; + const engine = new SystemAgentChatEngine({ + surface: "gateway", + runAgentTurn: async () => null, + planWithAssistant: async () => null, + deps: { loadOverview: fakeOverviewLoader() }, + runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => { + selected = await prompter.select({ + message: "Choose one", + options: [ + { value: "alpha", label: "Alpha" }, + { value: "beta", label: "Beta" }, + ], + }); + }, + }); + + await engine.handle("connect telegram"); + await engine.handle("2"); + + expect(selected).toBe("beta"); + }); +}); + function fakeOverviewLoader( overrides: { defaultModel?: string; claudeFound?: boolean; codexFound?: boolean } = {}, ) { diff --git a/src/system-agent/chat-engine.ts b/src/system-agent/chat-engine.ts index db8fff76174e..b5305a77aa98 100644 --- a/src/system-agent/chat-engine.ts +++ b/src/system-agent/chat-engine.ts @@ -1,10 +1,18 @@ // OpenClaw chat engine: transport-agnostic conversation over typed operations. -import type { SystemAgentChatQuestion } from "../../packages/gateway-protocol/src/index.js"; +import type { + SystemAgentChatQuestion, + WizardAnswer, +} from "../../packages/gateway-protocol/src/index.js"; import { isSensitiveConfigPath } from "../config/sensitive-paths.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { RuntimeEnv } from "../runtime.js"; -import { WizardSession, wizardStepAwaitsInput, type WizardStep } from "../wizard/session.js"; +import { + sanitizeWizardStepForClient, + WizardSession, + wizardStepAwaitsInput, + type WizardStep, +} from "../wizard/session.js"; import type { MemoryImportProviderOutcome, SetupMemoryImportOutcome, @@ -132,6 +140,8 @@ type SystemAgentChatReply = { handoff?: SystemAgentOperation; /** Structured choice mirroring the awaited wizard step for card-capable clients. */ question?: SystemAgentChatQuestion; + /** The awaited wizard step in full; `question` is its lossy card projection. */ + step?: WizardStep; }; type WizardPrompterLike = import("../wizard/prompts.js").WizardPrompter; @@ -599,6 +609,48 @@ function parseWizardAnswer(step: WizardStep, text: string): { value: unknown } | return { value: step.type === "action" ? true : undefined }; } +function formatStructuredWizardAnswerForHistory(step: WizardStep, value: unknown): string { + if (step.sensitive === true) { + return ""; + } + if (step.type === "text") { + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + return String(value); + } + return ""; + } + if (step.type === "confirm") { + return typeof value === "boolean" ? (value ? "Yes" : "No") : ""; + } + if (step.type === "select") { + return ( + step.options?.find((option) => Object.is(option.value, value))?.label ?? "" + ); + } + if (step.type === "multiselect") { + if (!Array.isArray(value)) { + return ""; + } + if (value.length === 0) { + return "None"; + } + const labels = value.map( + (entry) => step.options?.find((option) => Object.is(option.value, entry))?.label, + ); + return labels.every((label): label is string => label !== undefined) + ? labels.join(", ") + : ""; + } + return "Continue"; +} + +export class SystemAgentWizardAnswerError extends Error {} + function formatOperationError(error: unknown): string { const message = error instanceof Error ? error.message : String(error); return `That did not go through: ${message}`; @@ -736,6 +788,12 @@ export class SystemAgentChatEngine { return await turn; } + async answerWizard(answer: WizardAnswer): Promise { + const turn = this.turnQueue.then(() => this.answerWizardSerialized(answer)); + this.turnQueue = turn.catch(() => undefined); + return await turn; + } + private async handleSerialized( text: string, options?: SystemAgentChatTurnOptions, @@ -744,30 +802,57 @@ export class SystemAgentChatEngine { // Snapshot before resolving: wizard answers to sensitive steps (tokens, // passwords) must never enter the AI-visible history. const sensitiveTurn = this.wizardBridge?.step?.sensitive === true; - const resolved = await this.resolveTurn(text, options); + const reply = await this.resolveTurn(text, options); + return this.completeTurn( + reply, + sensitiveTurn ? "" : redactSensitiveCommandText(text), + ); + } + + private async answerWizardSerialized(answer: WizardAnswer): Promise { + await this.requireVerifiedInference(); + const bridge = this.wizardBridge; + const step = bridge?.step; + if (!bridge || !step) { + throw new SystemAgentWizardAnswerError("No hosted wizard is awaiting an answer."); + } + if (answer.stepId !== step.id) { + throw new SystemAgentWizardAnswerError("The hosted wizard answer targets a stale step."); + } + const validationError = await bridge.session.answer(step.id, answer.value); + const text = validationError + ? [validationError, renderWizardStep(step)].join("\n\n") + : await this.pumpWizardBridge(); + return this.completeTurn( + { text, action: "none" }, + formatStructuredWizardAnswerForHistory(step, answer.value), + ); + } + + private completeTurn(reply: SystemAgentChatReply, userHistoryText: string): SystemAgentChatReply { // The hint belongs to the outgoing message, not to each rendered step: one // turn can concatenate several auto-answered notes, and a wizard that just // ended must not offer a cancel that can no longer happen. const awaitedStep = this.wizardBridge?.step; - const reply: SystemAgentChatReply = - resolved.text && awaitedStep && wizardStepAwaitsInput(awaitedStep) - ? { ...resolved, text: `${resolved.text}\n${WIZARD_CANCEL_HINT}` } - : resolved; - this.history.push({ - role: "user", - text: sensitiveTurn ? "" : redactSensitiveCommandText(text), - }); - if (reply.text) { - this.history.push({ role: "assistant", text: reply.text }); + const completedReply: SystemAgentChatReply = + reply.text && awaitedStep && wizardStepAwaitsInput(awaitedStep) + ? { ...reply, text: `${reply.text}\n${WIZARD_CANCEL_HINT}` } + : reply; + this.history.push({ role: "user", text: userHistoryText }); + if (completedReply.text) { + this.history.push({ role: "assistant", text: completedReply.text }); } // While a hosted wizard awaits a step, every turn routes to it, so the // awaited step is always the question this reply asks. - const question = wizardStepChatQuestion(this.wizardBridge?.step ?? null); + const step = this.wizardBridge?.step ?? null; + const question = wizardStepChatQuestion(step); + const clientStep = step ? sanitizeWizardStepForClient(step) : null; return { - ...reply, - ...(this.wizardBridge?.step?.sensitive === true ? { sensitive: true } : {}), + ...completedReply, + ...(step?.sensitive === true ? { sensitive: true } : {}), ...(this.wizardBridge ? { wizardInputPending: true } : {}), ...(question ? { question } : {}), + ...(clientStep ? { step: clientStep } : {}), }; } diff --git a/src/wizard/i18n/locales/en.ts b/src/wizard/i18n/locales/en.ts index 5f9ac1258c39..2a7b8e8d83ab 100644 --- a/src/wizard/i18n/locales/en.ts +++ b/src/wizard/i18n/locales/en.ts @@ -899,6 +899,7 @@ export const en = { helpNeedsUrlCode: "You need your Urbit ship URL and login code.", helpPrivateNetwork: "If your ship URL is on a private network (LAN/localhost), you must explicitly allow it during setup.", + loginCodeKeep: "Login code already configured. Keep it?", loginCodePrompt: "Login code", privateNetworkPrompt: "Ship URL looks like a private/internal host. Allow private network access? (SSRF risk)", @@ -921,7 +922,7 @@ export const en = { helpPointWebhook: "3) Point the outgoing webhook to https://{path}", incomingWebhookHelpReplies: "This is the URL OpenClaw uses to send replies back to Chat.", incomingWebhookHelpUseUrl: "Use the incoming webhook URL from Synology Chat integrations.", - incomingWebhookKeep: "Incoming webhook URL set ({value}). Keep it?", + incomingWebhookKeep: "Incoming webhook URL already configured. Keep it?", incomingWebhookTitle: "Synology Chat incoming webhook", incomingWebhookUrlPrompt: "Incoming webhook URL", multipleEntries: "Multiple entries: comma-separated.", @@ -1014,6 +1015,7 @@ export const en = { botUsernamePrompt: "Twitch bot username", channelJoinPrompt: "Channel to join", clientIdPrompt: "Twitch Client ID", + clientSecretKeep: "Client secret already configured. Keep it?", clientSecretPrompt: "Twitch Client Secret (for token refresh)", envPrompt: "Twitch env var OPENCLAW_TWITCH_ACCESS_TOKEN detected. Use env token?", helpCopyToken: "3. Copy the token (starts with 'oauth:') and Client ID", @@ -1024,6 +1026,7 @@ export const en = { helpTokenTools: " Use https://twitchtokengenerator.com/ or https://twitchapps.com/tmi/", oauthTokenPrompt: "Twitch OAuth token (oauth:...)", refreshTokenInputPrompt: "Twitch Refresh Token", + refreshTokenKeep: "Refresh token already configured. Keep it?", refreshTokenPrompt: "Enable automatic token refresh (requires client secret and refresh token)?", setupTitle: "Twitch setup", diff --git a/src/wizard/i18n/locales/zh-CN.ts b/src/wizard/i18n/locales/zh-CN.ts index 07b713660eae..e03455673d1d 100644 --- a/src/wizard/i18n/locales/zh-CN.ts +++ b/src/wizard/i18n/locales/zh-CN.ts @@ -871,6 +871,7 @@ export const zh_CN = { helpExampleUrl: "URL 示例:https://your-ship-host", helpNeedsUrlCode: "需要你的 Urbit ship URL 和登录码。", helpPrivateNetwork: "如果 ship URL 位于私有网络(LAN/localhost),设置时必须明确允许。", + loginCodeKeep: "登录码已配置。保留当前值?", loginCodePrompt: "登录码", privateNetworkPrompt: "Ship URL 看起来是私有/内部 host。允许私有网络访问?(SSRF 风险)", restrictDmsPrompt: "使用允许列表限制 DM?", @@ -892,7 +893,7 @@ export const zh_CN = { helpPointWebhook: "3) 将 outgoing webhook 指向 https://{path}", incomingWebhookHelpReplies: "这是 OpenClaw 用来向 Chat 发送回复的 URL。", incomingWebhookHelpUseUrl: "使用 Synology Chat 集成里的 incoming webhook URL。", - incomingWebhookKeep: "Incoming webhook URL 已设置({value})。保留?", + incomingWebhookKeep: "Incoming webhook URL 已配置。保留当前值?", incomingWebhookTitle: "Synology Chat incoming webhook", incomingWebhookUrlPrompt: "Incoming webhook URL", multipleEntries: "多个条目请用逗号分隔。", @@ -982,6 +983,7 @@ export const zh_CN = { botUsernamePrompt: "Twitch bot 用户名", channelJoinPrompt: "要加入的频道", clientIdPrompt: "Twitch Client ID", + clientSecretKeep: "Client secret 已配置。保留当前值?", clientSecretPrompt: "Twitch Client Secret(用于 token 刷新)", envPrompt: "检测到 Twitch 环境变量 OPENCLAW_TWITCH_ACCESS_TOKEN。使用环境 token?", helpCopyToken: "3. 复制 token(以 'oauth:' 开头)和 Client ID", @@ -992,6 +994,7 @@ export const zh_CN = { helpTokenTools: " 可使用 https://twitchtokengenerator.com/ 或 https://twitchapps.com/tmi/", oauthTokenPrompt: "Twitch OAuth token(oauth:...)", refreshTokenInputPrompt: "Twitch Refresh Token", + refreshTokenKeep: "Refresh token 已配置。保留当前值?", refreshTokenPrompt: "启用自动 token 刷新?(需要 client secret 和 refresh token)", setupTitle: "Twitch 设置", }, diff --git a/src/wizard/i18n/locales/zh-TW.ts b/src/wizard/i18n/locales/zh-TW.ts index cb397aae6739..fb541dd217e1 100644 --- a/src/wizard/i18n/locales/zh-TW.ts +++ b/src/wizard/i18n/locales/zh-TW.ts @@ -872,6 +872,7 @@ export const zh_TW = { helpExampleUrl: "URL 範例:https://your-ship-host", helpNeedsUrlCode: "需要你的 Urbit ship URL 和登入碼。", helpPrivateNetwork: "如果 ship URL 位於私有網路(LAN/localhost),設定時必須明確允許。", + loginCodeKeep: "登入碼已設定。保留目前值?", loginCodePrompt: "登入碼", privateNetworkPrompt: "Ship URL 看起來是私有/內部 host。允許私有網路存取?(SSRF 風險)", restrictDmsPrompt: "使用允許清單限制 DM?", @@ -893,7 +894,7 @@ export const zh_TW = { helpPointWebhook: "3) 將 outgoing webhook 指向 https://{path}", incomingWebhookHelpReplies: "這是 OpenClaw 用來向 Chat 傳送回覆的 URL。", incomingWebhookHelpUseUrl: "使用 Synology Chat 整合裡的 incoming webhook URL。", - incomingWebhookKeep: "Incoming webhook URL 已設定({value})。保留?", + incomingWebhookKeep: "Incoming webhook URL 已設定。保留目前值?", incomingWebhookTitle: "Synology Chat incoming webhook", incomingWebhookUrlPrompt: "Incoming webhook URL", multipleEntries: "多個項目請用逗號分隔。", @@ -983,6 +984,7 @@ export const zh_TW = { botUsernamePrompt: "Twitch bot 使用者名稱", channelJoinPrompt: "要加入的頻道", clientIdPrompt: "Twitch Client ID", + clientSecretKeep: "Client secret 已設定。保留目前值?", clientSecretPrompt: "Twitch Client Secret(用於 token 更新)", envPrompt: "偵測到 Twitch 環境變數 OPENCLAW_TWITCH_ACCESS_TOKEN。使用環境 token?", helpCopyToken: "3. 複製 token(以 'oauth:' 開頭)和 Client ID", @@ -993,6 +995,7 @@ export const zh_TW = { helpTokenTools: " 可使用 https://twitchtokengenerator.com/ 或 https://twitchapps.com/tmi/", oauthTokenPrompt: "Twitch OAuth token(oauth:...)", refreshTokenInputPrompt: "Twitch Refresh Token", + refreshTokenKeep: "Refresh token 已設定。保留目前值?", refreshTokenPrompt: "啟用自動 token 更新?(需要 client secret 和 refresh token)", setupTitle: "Twitch 設定", }, diff --git a/src/wizard/session.ts b/src/wizard/session.ts index 185d1f3b5e52..b2c96367b80c 100644 --- a/src/wizard/session.ts +++ b/src/wizard/session.ts @@ -35,6 +35,16 @@ export function wizardStepAwaitsInput(step: WizardStep): boolean { return unhandledRequirement; } +/** Remove secret prefill before a wizard step crosses a client boundary. */ +export function sanitizeWizardStepForClient(step: WizardStep): WizardStep { + if (step.sensitive !== true || step.initialValue === undefined) { + return step; + } + const safe = { ...step }; + delete safe.initialValue; + return safe; +} + type WizardSessionStatus = "running" | "done" | "cancelled" | "error"; type WizardNextResult = { diff --git a/ui/src/components/custodian/custodian-panel.test.ts b/ui/src/components/custodian/custodian-panel.test.ts index bceb7540c936..1c13100e91be 100644 --- a/ui/src/components/custodian/custodian-panel.test.ts +++ b/ui/src/components/custodian/custodian-panel.test.ts @@ -62,8 +62,8 @@ describe("custodian panel", () => { store.connect(context, "caretaker"); await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); store.messages = [ - { id: 1, role: "assistant", text: "Ready.", at: 1, question: null }, - { id: 2, role: "user", text: "Check this system", at: 2, question: null }, + { id: 1, role: "assistant", text: "Ready.", at: 1, question: null, step: null }, + { id: 2, role: "user", text: "Check this system", at: 2, question: null, step: null }, ]; panel.suppressed = false; @@ -110,7 +110,9 @@ describe("custodian panel", () => { const { context, panel, request, store } = await mountPanel(); store.connect(context, "caretaker"); await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); - store.messages = [{ id: 1, role: "user", text: "Check this system", at: 1, question: null }]; + store.messages = [ + { id: 1, role: "user", text: "Check this system", at: 1, question: null, step: null }, + ]; panel.available = false; panel.suppressed = false; panel.minimizeRequestId = 1; @@ -127,8 +129,15 @@ describe("custodian panel", () => { store.connect(context, "onboarding"); await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); store.messages = [ - { id: 1, role: "assistant", text: "Set up your system", at: 1, question: null }, - { id: 2, role: "user", text: "Continue setup", at: 2, question: null }, + { + id: 1, + role: "assistant", + text: "Set up your system", + at: 1, + question: null, + step: null, + }, + { id: 2, role: "user", text: "Continue setup", at: 2, question: null, step: null }, ]; panel.suppressed = false; diff --git a/ui/src/components/form-controls.browser.test.ts b/ui/src/components/form-controls.browser.test.ts index 2e0353e4d87b..bb61a4ac73b6 100644 --- a/ui/src/components/form-controls.browser.test.ts +++ b/ui/src/components/form-controls.browser.test.ts @@ -59,6 +59,23 @@ function controlsHtml() { `; } +function revealedSensitiveInputHtml() { + return ` + + + + + + `; +} + function mediaDeviceRowsHtml() { return `
@@ -130,6 +147,25 @@ afterAll(async () => { await browser?.close().catch(() => {}); }); +describeBrowserLayout("sensitive input visibility", () => { + it("removes the mask layer from layout when the value is revealed", async () => { + const page = await desktopContext.newPage(); + try { + await page.setContent( + `${revealedSensitiveInputHtml()}`, + ); + + const state = await page.locator("[data-sensitive-mask]").evaluate((mask) => ({ + hidden: (mask as HTMLElement).hidden, + display: getComputedStyle(mask).display, + })); + expect(state).toEqual({ hidden: true, display: "none" }); + } finally { + await page.close().catch(() => {}); + } + }); +}); + describeBrowserLayout("settings media device controls", () => { it("keeps paired selectors the same width across device labels and viewports", async () => { const page = await desktopContext.newPage(); diff --git a/ui/src/components/sensitive-input.test.ts b/ui/src/components/sensitive-input.test.ts new file mode 100644 index 000000000000..d42ee9019803 --- /dev/null +++ b/ui/src/components/sensitive-input.test.ts @@ -0,0 +1,92 @@ +/* @vitest-environment jsdom */ + +import { nothing, render } from "lit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderSensitiveInput } from "./sensitive-input.ts"; + +describe("renderSensitiveInput", () => { + afterEach(() => { + for (const container of document.body.querySelectorAll("div")) { + render(nothing, container); + } + document.body.replaceChildren(); + }); + + it("conceals the value and keeps the Carapace mask in sync", () => { + const container = document.createElement("div"); + const onInput = vi.fn(); + document.body.append(container); + render( + renderSensitiveInput({ + id: "secret", + name: "secret", + value: "secret", + revealed: false, + revealLabel: "Show API key", + hideLabel: "Hide API key", + onInput, + onToggle: vi.fn(), + }), + container, + ); + + const input = container.querySelector("[data-sensitive-value]"); + const mask = container.querySelector("[data-sensitive-mask]"); + const maskText = container.querySelector("[data-sensitive-mask-text]"); + const toggle = container.querySelector(".oc-sensitive-toggle"); + expect(input?.type).toBe("password"); + expect(mask?.hidden).toBe(false); + expect(maskText?.textContent).toBe("******"); + expect(toggle?.dataset.sensitiveIcon).toBe("eye"); + expect(toggle?.getAttribute("aria-label")).toBe("Show API key"); + expect(toggle?.getAttribute("aria-pressed")).toBe("false"); + + if (input) { + input.value = "longer-key"; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.scrollLeft = 12; + input.dispatchEvent(new Event("scroll")); + } + expect(onInput).toHaveBeenCalledWith("longer-key"); + expect(maskText?.textContent).toBe("**********"); + expect(maskText?.style.transform).toBe("translateX(-12px)"); + + if (input) { + input.value = "👨‍👩‍👧‍👦x"; + input.dispatchEvent(new Event("input", { bubbles: true })); + } + expect(maskText?.textContent).toBe("**"); + }); + + it("reveals the value and presents the hide action", () => { + const container = document.createElement("div"); + const onToggle = vi.fn(); + document.body.append(container); + render( + renderSensitiveInput({ + id: "secret", + value: "secret", + revealed: true, + revealLabel: "Show API key", + hideLabel: "Hide API key", + disabled: false, + onInput: vi.fn(), + onToggle, + }), + container, + ); + + const input = container.querySelector("[data-sensitive-value]"); + const mask = container.querySelector(".oc-sensitive-mask"); + const toggle = container.querySelector(".oc-sensitive-toggle"); + expect(input?.type).toBe("text"); + expect(input?.value).toBe("secret"); + expect(mask?.hidden).toBe(true); + expect(toggle?.dataset.sensitiveIcon).toBe("eye-off"); + expect(toggle?.getAttribute("aria-label")).toBe("Hide API key"); + expect(toggle?.getAttribute("aria-pressed")).toBe("true"); + + toggle?.click(); + expect(onToggle).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/src/components/sensitive-input.ts b/ui/src/components/sensitive-input.ts new file mode 100644 index 000000000000..2a78fb92d5bb --- /dev/null +++ b/ui/src/components/sensitive-input.ts @@ -0,0 +1,101 @@ +// Control UI adapter for Carapace's framework-neutral Sensitive Input pattern. +import { html, nothing, type TemplateResult } from "lit"; +import { icons } from "./icons.ts"; +import "./tooltip.ts"; + +type SensitiveInputProps = { + id: string; + name?: string; + value: string; + revealed: boolean; + revealLabel: string; + hideLabel: string; + className?: string; + inputClassName?: string; + placeholder?: string; + disabled?: boolean; + onInput: (value: string) => void; + onToggle: () => void; +}; + +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +function maskedValue(value: string): string { + return "*".repeat(Array.from(graphemeSegmenter.segment(value)).length); +} + +function syncMask(input: HTMLInputElement): void { + const control = input.closest("[data-sensitive-input]"); + const maskText = control?.querySelector("[data-sensitive-mask-text]"); + if (!maskText) { + return; + } + maskText.textContent = maskedValue(input.value); + maskText.style.transform = `translateX(${-input.scrollLeft}px)`; +} + +export function renderSensitiveInput(props: SensitiveInputProps): TemplateResult { + const visibilityLabel = props.revealed ? props.hideLabel : props.revealLabel; + const className = props.className + ? `oc-sensitive-input ${props.className}` + : "oc-sensitive-input"; + const handleInput = (event: Event) => { + const input = event.currentTarget as HTMLInputElement; + syncMask(input); + props.onInput(input.value); + }; + const handleMaskSync = (event: Event) => { + syncMask(event.currentTarget as HTMLInputElement); + }; + + return html` + + + + + + + + `; +} diff --git a/ui/src/components/wizard-step-controls.ts b/ui/src/components/wizard-step-controls.ts index 4e49dea3dff5..cd81dfb03c1a 100644 --- a/ui/src/components/wizard-step-controls.ts +++ b/ui/src/components/wizard-step-controls.ts @@ -2,6 +2,7 @@ import { html, nothing, type TemplateResult } from "lit"; import type { WizardStep } from "../api/types.ts"; import { t } from "../i18n/index.ts"; import { copyToClipboard } from "../lib/clipboard.ts"; +import { renderSensitiveInput } from "./sensitive-input.ts"; import "../styles/wizard-step-controls.css"; type WizardStepOption = NonNullable[number]; @@ -16,10 +17,12 @@ type WizardStepControlsProps = { // so two step controls in one document cannot capture each other's label. inputId: string; onValueChange: (value: unknown) => void; - onAnswer: (value: unknown, includeValue?: boolean) => void; + onAnswer: (value: unknown) => void; presentation?: "channels"; answerLabel?: string; confirmAffirmativeLabel?: string; + sensitiveRevealed?: boolean; + onToggleSensitiveVisibility?: () => void; }; function stepClass(props: WizardStepControlsProps, name: string): string { @@ -129,7 +132,7 @@ function renderOption( return html`