feat: enable rich setup controls in custodian chat (#114631)

* feat(protocol): carry the awaited wizard step on the chat result

* fix(system-agent): strip sensitive wizard prefill from chat results

* fix(wizard): keep setup secrets server-side

* test(wizard): preserve prompt mock typing

* fix(ui): add reveal toggle to wizard secrets

* refactor(ui): adopt Carapace sensitive input

* fix(ui): hide revealed sensitive input mask

* test(twitch): cover environment-only setup

* feat(custodian): render rich wizard steps

* fix(custodian): validate wizard text replies

* feat(custodian): submit typed wizard answers

* refactor(gateway): isolate custodian chat turns

* fix(gateway): accept session engine adapter

* fix(ui): narrow wizard control values

* refactor: simplify rich wizard answer flow

* fix(custodian): recover evicted wizard sessions

* docs: note custodian rich setup controls

* test(gateway): split wizard answer coverage
This commit is contained in:
Jesse Merhi
2026-08-03 22:18:27 +10:00
committed by GitHub
parent a8efce28ee
commit 68be0e3378
53 changed files with 2398 additions and 224 deletions
+1
View File
@@ -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
@@ -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
}
}
+54
View File
@@ -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",
@@ -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({
+49
View File
@@ -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,
+2
View File
@@ -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",
+79 -8
View File
@@ -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<NonNullable<typeof twitchSetupWizard.finalize>>[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(
+39 -16
View File
@@ -112,11 +112,10 @@ async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise<void> {
export async function promptToken(
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
envToken: string | undefined,
): Promise<string> {
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<string | undefined> {
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);
+3 -1
View File
@@ -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
@@ -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);
});
});
@@ -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,
@@ -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({
@@ -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<typeof WizardStartParamsSchema>;
export type WizardAnswer = Static<typeof WizardAnswerSchema>;
export type WizardNextParams = Static<typeof WizardNextParamsSchema>;
export type WizardCancelParams = Static<typeof WizardCancelParamsSchema>;
export type WizardStatusParams = Static<typeof WizardStatusParamsSchema>;
+3 -1
View File
@@ -120,12 +120,14 @@ export type ChannelSetupWizardCredential = {
}) => OpenClawConfig | Promise<OpenClawConfig>;
};
/** 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;
+28 -13
View File
@@ -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) {
@@ -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[];
@@ -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" },
});
});
});
@@ -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<ReturnType<SystemAgentChatEngine["handle"]>>;
type SystemAgentChatEngineInput = Pick<SystemAgentChatEngine, "answerWizard" | "handle">;
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<SystemAgentChatReply | undefined> {
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 } : {}),
};
}
@@ -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<typeof vi.fn>;
handle: ReturnType<typeof vi.fn>;
seedHistory: ReturnType<typeof vi.fn>;
historyLength: ReturnType<typeof vi.fn>;
@@ -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<string, SystemAgentChatSession>([["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({
+51 -40
View File
@@ -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<ReturnType<SystemAgentChatEngine["handle"]>>;
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);
});
});
},
+81
View File
@@ -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<GatewayRequestHandlerOptions["context"]>["wizardRunner"],
) {
const wizardSessions = new Map();
return {
wizardSessions,
wizardRunner,
findRunningWizard: () => undefined,
purgeWizardSession: (sessionId: string) => wizardSessions.delete(sessionId),
};
}
function readSuccessfulResponse(respond: ReturnType<typeof vi.fn>): Record<string, unknown> {
expect(respond).toHaveBeenCalledOnce();
const [ok, result] = respond.mock.calls[0] ?? [];
expect(ok).toBe(true);
expect(result).toBeDefined();
return result as Record<string, unknown>;
}
async function invokeWizard(
method: "wizard.start" | "wizard.next",
params: Record<string, unknown>,
context: ReturnType<typeof createWizardContext>,
): Promise<Record<string, unknown>> {
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();
}
});
});
+19 -4
View File
@@ -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<T extends { step?: WizardStep }>(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)) {
+287
View File
@@ -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<void>;
/** Undefined means no step awaits an answer when the reply is built. */
step: Record<string, unknown> | 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: "<redacted secret>" });
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 } = {},
) {
+101 -16
View File
@@ -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 "<redacted secret>";
}
if (step.type === "text") {
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value);
}
return "<wizard answer>";
}
if (step.type === "confirm") {
return typeof value === "boolean" ? (value ? "Yes" : "No") : "<wizard answer>";
}
if (step.type === "select") {
return (
step.options?.find((option) => Object.is(option.value, value))?.label ?? "<wizard answer>"
);
}
if (step.type === "multiselect") {
if (!Array.isArray(value)) {
return "<wizard answer>";
}
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(", ")
: "<wizard answer>";
}
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<SystemAgentChatReply> {
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 ? "<redacted secret>" : redactSensitiveCommandText(text),
);
}
private async answerWizardSerialized(answer: WizardAnswer): Promise<SystemAgentChatReply> {
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 ? "<redacted secret>" : 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 } : {}),
};
}
+4 -1
View File
@@ -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://<gateway-host>{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",
+4 -1
View File
@@ -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://<gateway-host>{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 tokenoauth:...",
refreshTokenInputPrompt: "Twitch Refresh Token",
refreshTokenKeep: "Refresh token 已配置。保留当前值?",
refreshTokenPrompt: "启用自动 token 刷新?(需要 client secret 和 refresh token",
setupTitle: "Twitch 设置",
},
+4 -1
View File
@@ -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://<gateway-host>{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 tokenoauth:...",
refreshTokenInputPrompt: "Twitch Refresh Token",
refreshTokenKeep: "Refresh token 已設定。保留目前值?",
refreshTokenPrompt: "啟用自動 token 更新?(需要 client secret 和 refresh token",
setupTitle: "Twitch 設定",
},
+10
View File
@@ -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 = {
@@ -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;
@@ -59,6 +59,23 @@ function controlsHtml() {
`;
}
function revealedSensitiveInputHtml() {
return `
<span
class="oc-sensitive-input"
data-sensitive-input
data-sensitive-mask-ready="true"
data-revealed="true"
>
<span class="oc-sensitive-mask" data-sensitive-mask hidden>
<span data-sensitive-mask-text>*******************************</span>
</span>
<input type="text" value="fake-client-secret-for-ui-proof" />
<button class="oc-sensitive-toggle" type="button" aria-label="Hide value"></button>
</span>
`;
}
function mediaDeviceRowsHtml() {
return `
<main style="width: 100%; max-width: 900px">
@@ -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(
`<!doctype html><html data-theme-mode="light"><head><style>${readUiCss()}</style></head><body>${revealedSensitiveInputHtml()}</body></html>`,
);
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();
+92
View File
@@ -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<HTMLInputElement>("[data-sensitive-value]");
const mask = container.querySelector<HTMLElement>("[data-sensitive-mask]");
const maskText = container.querySelector<HTMLElement>("[data-sensitive-mask-text]");
const toggle = container.querySelector<HTMLButtonElement>(".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<HTMLInputElement>("[data-sensitive-value]");
const mask = container.querySelector<HTMLElement>(".oc-sensitive-mask");
const toggle = container.querySelector<HTMLButtonElement>(".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();
});
});
+101
View File
@@ -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<HTMLElement>("[data-sensitive-input]");
const maskText = control?.querySelector<HTMLElement>("[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`
<span
class=${className}
data-sensitive-input
data-sensitive-mask-ready="true"
data-revealed=${String(props.revealed)}
>
<span
class="oc-sensitive-mask"
aria-hidden="true"
data-sensitive-mask
?hidden=${props.revealed}
>
<span
data-sensitive-mask-text
.textContent=${props.revealed ? "" : maskedValue(props.value)}
></span>
</span>
<input
id=${props.id}
class=${props.inputClassName ?? nothing}
name=${props.name ?? nothing}
type=${props.revealed ? "text" : "password"}
autocomplete="off"
spellcheck="false"
placeholder=${props.placeholder ?? ""}
.value=${props.value}
?disabled=${props.disabled}
data-sensitive-value
@input=${handleInput}
@change=${handleMaskSync}
@focus=${handleMaskSync}
@scroll=${handleMaskSync}
/>
<openclaw-tooltip .content=${visibilityLabel}>
<button
type="button"
class="oc-sensitive-toggle"
aria-label=${visibilityLabel}
aria-controls=${props.id}
aria-pressed=${String(props.revealed)}
data-sensitive-icon=${props.revealed ? "eye-off" : "eye"}
?disabled=${props.disabled}
@click=${props.onToggle}
>
${props.revealed ? icons.eyeOff : icons.eye}
</button>
</openclaw-tooltip>
</span>
`;
}
+37 -21
View File
@@ -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<WizardStep["options"]>[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`<label class="wizard-step__option">
<input
type=${props.step.type === "select" ? "radio" : "checkbox"}
name=${props.step.type === "select" ? "wizard-option" : nothing}
name=${props.step.type === "select" ? `${props.inputId}-option` : nothing}
.checked=${checked}
?disabled=${props.busy}
@change=${(event: Event) => {
@@ -156,9 +159,7 @@ function renderContinueStep(props: WizardStepControlsProps) {
</a>`
: nothing}
${renderDeviceCode(step)}
${renderAnswerButton(props, t("modelSetup.wizard.continue"), () =>
props.onAnswer(undefined, false),
)}
${renderAnswerButton(props, t("modelSetup.wizard.continue"), () => props.onAnswer(undefined))}
`;
}
@@ -174,15 +175,43 @@ function renderProgressStep(props: WizardStepControlsProps) {
function renderTextStep(props: WizardStepControlsProps) {
const step = props.step;
const value = typeof props.value === "string" ? props.value : "";
const input =
step.sensitive && props.onToggleSensitiveVisibility
? renderSensitiveInput({
id: props.inputId,
name: "wizard-text",
value,
revealed: props.sensitiveRevealed === true,
revealLabel: t("configForm.revealValue"),
hideLabel: t("configForm.hideValue"),
inputClassName: "input",
placeholder: step.placeholder,
disabled: props.busy,
onInput: props.onValueChange,
onToggle: props.onToggleSensitiveVisibility,
})
: html`<input
id=${props.inputId}
class="input"
name="wizard-text"
type=${step.sensitive ? "password" : "text"}
autocomplete=${step.sensitive ? "off" : "on"}
placeholder=${step.placeholder ?? ""}
.value=${value}
?disabled=${props.busy}
@input=${(event: Event) =>
props.presentation !== "channels" &&
props.onValueChange((event.currentTarget as HTMLInputElement).value)}
/>`;
return html`
<form
class="wizard-step__form"
@submit=${(event: Event) => {
event.preventDefault();
const input = (event.currentTarget as HTMLFormElement).elements.namedItem(
const formInput = (event.currentTarget as HTMLFormElement).elements.namedItem(
"wizard-text",
) as HTMLInputElement | null;
props.onAnswer(props.presentation === "channels" ? (input?.value ?? "") : value);
props.onAnswer(props.presentation === "channels" ? (formInput?.value ?? "") : value);
}}
>
${step.message
@@ -190,20 +219,7 @@ function renderTextStep(props: WizardStepControlsProps) {
<label for=${props.inputId}>${step.message}</label>
</div>`
: nothing}
<input
id=${props.inputId}
class="input"
name="wizard-text"
type=${step.sensitive ? "password" : "text"}
autocomplete=${step.sensitive ? "off" : "on"}
placeholder=${step.placeholder ?? ""}
.value=${value}
?disabled=${props.busy}
@input=${(event: Event) =>
props.presentation !== "channels" &&
props.onValueChange((event.currentTarget as HTMLInputElement).value)}
/>
${renderAnswerButton(props, t("modelSetup.wizard.submit"))}
${input} ${renderAnswerButton(props, t("modelSetup.wizard.submit"))}
</form>
`;
}
@@ -303,6 +303,127 @@ describeControlUiE2e("Control UI custodian event nudge mocked Gateway E2E", () =
}
});
it("renders rich wizard controls and sends typed answers", async () => {
const context = await browser.newContext({
colorScheme: "dark",
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
featureMethods: ["chat.metadata", "chat.startup", "openclaw.chat"],
methodResponses: {
"openclaw.chat": {
sessionId: "e2e-rich-wizard",
reply: "Choose a channel.",
action: "none",
wizardInputPending: true,
step: {
id: "channel",
type: "select",
message: "Which channel?",
options: ["Discord", "Slack", "Telegram", "WhatsApp", "Twitch"].map((label) => ({
label,
value: label.toLowerCase(),
})),
},
},
},
});
try {
await page.goto(`${server.baseUrl}custodian`);
await page.getByLabel("Twitch").waitFor();
expect(await page.locator("openclaw-option-card").count()).toBe(0);
expect(await page.locator(".agent-chat__composer-shell").count()).toBe(0);
await gateway.setMethodResponse("openclaw.chat", {
sessionId: "e2e-rich-wizard",
reply: "Choose features.",
action: "none",
wizardInputPending: true,
step: {
id: "features",
type: "multiselect",
message: "Which features?",
options: [
{ label: "Chat", value: "chat" },
{ label: "Moderation", value: "moderation" },
{ label: "Announcements", value: "announcements" },
],
},
});
await page.getByLabel("Twitch").check();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByLabel("Announcements").waitFor();
await gateway.setMethodResponse("openclaw.chat", {
sessionId: "e2e-rich-wizard",
reply: "Enter the secret.",
action: "none",
sensitive: true,
wizardInputPending: true,
step: {
id: "secret",
type: "text",
message: "Twitch client secret",
sensitive: true,
},
});
await page.getByLabel("Chat").check();
await page.getByLabel("Announcements").check();
await page.getByRole("button", { name: "Continue" }).click();
const secretInput = page.getByRole("textbox", {
name: "Twitch client secret",
});
await secretInput.waitFor();
expect(await secretInput.getAttribute("type")).toBe("password");
await page.getByRole("button", { name: "Reveal value" }).click();
expect(await secretInput.getAttribute("type")).toBe("text");
await page.getByRole("button", { name: "Hide value" }).click();
expect(await secretInput.getAttribute("type")).toBe("password");
await gateway.setMethodResponse("openclaw.chat", {
sessionId: "e2e-rich-wizard",
reply: "Setup complete.",
action: "none",
});
await secretInput.fill("fake-client-secret");
await page.getByRole("button", { name: "Submit" }).click();
await page.getByText("Setup complete.").waitFor();
const requests = await gateway.getRequests("openclaw.chat");
expect(requests.map((request) => request.params)).toEqual([
expect.objectContaining({ sessionId: expect.any(String) }),
expect.objectContaining({
wizardAnswer: { stepId: "channel", value: "twitch" },
}),
expect.objectContaining({
wizardAnswer: { stepId: "features", value: ["chat", "announcements"] },
}),
expect.objectContaining({
wizardAnswer: { stepId: "secret", value: "fake-client-secret" },
}),
]);
expect(
requests
.slice(1)
.every(
(request) =>
typeof request.params === "object" &&
request.params !== null &&
!Object.hasOwn(request.params, "message"),
),
).toBe(true);
expect(await page.getByText("Sensitive reply sent").count()).toBe(1);
expect(await page.getByText("fake-client-secret").count()).toBe(0);
expect(await page.locator(".agent-chat__composer-shell").count()).toBe(1);
} finally {
await context.close();
}
});
it("stays silent during onboarding", async () => {
const context = await browser.newContext({
locale: "en-US",
+4
View File
@@ -675,6 +675,8 @@ class ChannelsPage extends OpenClawLightDomElement {
selectedChannel: this.selectedChannel,
wizard: this.wizardHost.state,
wizardMultiselect: this.wizardHost.multiselect,
wizardTextValue: this.wizardHost.textValue,
wizardSecretVisible: this.wizardHost.secretVisible,
setupBlockedByDirtyConfig: this.wizardHost.blockedByDirtyConfig,
onShowDetail: (channelId) => {
this.selectedChannel = channelId;
@@ -685,6 +687,8 @@ class ChannelsPage extends OpenClawLightDomElement {
onStartSetup: (channelId) => this.wizardHost.startSetup(channelId),
onWizardAnswer: (value) => this.wizardHost.answer(value),
onWizardToggleMultiselect: (value) => this.wizardHost.toggleMultiselect(value),
onWizardTextInput: (value) => this.wizardHost.setTextValue(value),
onWizardToggleSecretVisibility: () => this.wizardHost.toggleSecretVisibility(),
onWizardClose: () => this.wizardHost.close(),
onRefresh: (probe) => void context.channels.refresh(probe),
onPairingRefresh: () => void context.channels.refreshPairing(),
@@ -70,12 +70,16 @@ function createProps(overrides: Partial<ChannelsProps> = {}): ChannelsProps {
selectedChannel: null,
wizard: { phase: "idle" },
wizardMultiselect: [],
wizardTextValue: "",
wizardSecretVisible: false,
setupBlockedByDirtyConfig: false,
onShowDetail: () => undefined,
onCloseDetail: () => undefined,
onStartSetup: () => undefined,
onWizardAnswer: () => undefined,
onWizardToggleMultiselect: () => undefined,
onWizardTextInput: () => undefined,
onWizardToggleSecretVisibility: () => undefined,
onWizardClose: () => undefined,
onRefresh: () => undefined,
onPairingRefresh: () => undefined,
+4
View File
@@ -51,12 +51,16 @@ function createProps(snapshot: ChannelsProps["snapshot"]): ChannelsProps {
selectedChannel: null,
wizard: { phase: "idle" },
wizardMultiselect: [],
wizardTextValue: "",
wizardSecretVisible: false,
setupBlockedByDirtyConfig: false,
onShowDetail: () => {},
onCloseDetail: () => {},
onStartSetup: () => {},
onWizardAnswer: () => {},
onWizardToggleMultiselect: () => {},
onWizardTextInput: () => {},
onWizardToggleSecretVisibility: () => {},
onWizardClose: () => {},
onRefresh: () => {},
onPairingRefresh: () => {},
+4
View File
@@ -117,6 +117,10 @@ export function renderChannels(props: ChannelsProps) {
channelLabel: (channelId) => resolveChannelLabel(props.snapshot, channelId),
multiselectValues: props.wizardMultiselect,
onToggleMultiselect: props.onWizardToggleMultiselect,
textValue: props.wizardTextValue,
secretVisible: props.wizardSecretVisible,
onTextInput: props.onWizardTextInput,
onToggleSecretVisibility: props.onWizardToggleSecretVisibility,
onAnswer: props.onWizardAnswer,
onClose: props.onWizardClose,
whatsappQrDataUrl: props.whatsappQrDataUrl,
+4
View File
@@ -60,12 +60,16 @@ export type ChannelsProps = {
selectedChannel: string | null;
wizard: ChannelWizardState;
wizardMultiselect: readonly unknown[];
wizardTextValue: string;
wizardSecretVisible: boolean;
setupBlockedByDirtyConfig: boolean;
onShowDetail: (channelId: string) => void;
onCloseDetail: () => void;
onStartSetup: (channelId: string | null) => void;
onWizardAnswer: (value: unknown) => void;
onWizardToggleMultiselect: (value: unknown) => void;
onWizardTextInput: (value: string) => void;
onWizardToggleSecretVisibility: () => void;
onWizardClose: () => void;
onRefresh: (probe: boolean) => void;
onPairingRefresh: () => void;
+24 -2
View File
@@ -1,5 +1,5 @@
// Page-side host for the channel setup wizard: owns the RPC controller,
// per-step multiselect state, dirty-config guarding, and completion effects
// per-step form state, dirty-config guarding, and completion effects
// (config resync + WhatsApp QR handoff) so the page element stays thin.
import type { ApplicationContext } from "../../app/context.ts";
import { t } from "../../i18n/index.ts";
@@ -14,8 +14,11 @@ type WizardHostDeps = {
export class ChannelWizardHost {
multiselect: unknown[] = [];
textValue = "";
secretVisible = false;
blockedByDirtyConfig = false;
private multiselectStepId: string | null = null;
private textStepId: string | null = null;
private lastPhase = "idle";
private readonly controller: ChannelWizardController;
@@ -78,8 +81,17 @@ export class ChannelWizardHost {
this.deps.requestUpdate();
}
setTextValue(value: string): void {
this.textValue = value;
}
toggleSecretVisibility(): void {
this.secretVisible = !this.secretVisible;
this.deps.requestUpdate();
}
private handleControllerChange(): void {
// Pending multiselect toggles survive busy re-renders but reset per step.
// Pending input state survives unrelated page re-renders but resets per step.
const wizard = this.controller.state;
const stepId = wizard.phase === "step" ? wizard.step.id : null;
if (stepId !== this.multiselectStepId) {
@@ -89,6 +101,16 @@ export class ChannelWizardHost {
? [...wizard.step.initialValue]
: [];
}
if (stepId !== this.textStepId) {
this.textStepId = stepId;
this.textValue =
wizard.phase === "step" &&
wizard.step.type === "text" &&
typeof wizard.step.initialValue === "string"
? wizard.step.initialValue
: "";
this.secretVisible = false;
}
if (wizard.phase === "done" && this.lastPhase !== "done") {
void this.handleCompleted(wizard.accounts);
}
+23 -8
View File
@@ -6,7 +6,11 @@ import { i18n } from "../../i18n/index.ts";
import type { ChannelWizardStep } from "./wizard-controller.ts";
import { renderChannelWizard } from "./wizard-view.ts";
function renderStep(step: ChannelWizardStep, busy = true) {
function renderStep(
step: ChannelWizardStep,
busy = true,
textValue = typeof step.initialValue === "string" ? step.initialValue : "",
) {
const container = document.createElement("div");
const onAnswer = vi.fn();
const onClose = vi.fn();
@@ -25,6 +29,10 @@ function renderStep(step: ChannelWizardStep, busy = true) {
channelLabel: (channelId) => channelId,
multiselectValues: ["alpha"],
onToggleMultiselect,
textValue,
secretVisible: false,
onTextInput: vi.fn(),
onToggleSecretVisibility: vi.fn(),
onAnswer,
onClose,
whatsappQrDataUrl: null,
@@ -136,16 +144,23 @@ describe("renderChannelWizard busy controls", () => {
});
it("disables text editing and submission while a step is running", () => {
const text = renderStep({
id: "text",
type: "text",
message: "Enter a value",
initialValue: "original",
});
const text = renderStep(
{
id: "text",
type: "text",
message: "Enter a value",
sensitive: true,
},
true,
"replacement",
);
const input = text.container.querySelector<HTMLInputElement>('input[name="wizard-text"]');
const submit = text.container.querySelector<HTMLButtonElement>('button[type="submit"]');
const toggle = text.container.querySelector<HTMLButtonElement>(".oc-sensitive-toggle");
expect(input?.disabled).toBe(true);
expect(input?.value).toBe("original");
expect(input?.type).toBe("password");
expect(input?.value).toBe("replacement");
expect(toggle?.disabled).toBe(true);
expect(submit?.disabled).toBe(true);
submit?.click();
expect(text.onAnswer).not.toHaveBeenCalled();
+78
View File
@@ -45,6 +45,10 @@ describe("renderChannelWizard", () => {
channelLabel: (channelId) => channelId,
multiselectValues: [],
onToggleMultiselect: vi.fn(),
textValue: "",
secretVisible: false,
onTextInput: vi.fn(),
onToggleSecretVisibility: vi.fn(),
onAnswer: vi.fn(),
onClose: vi.fn(),
whatsappQrDataUrl: null,
@@ -64,9 +68,79 @@ describe("renderChannelWizard", () => {
expect(label?.textContent).toBe("New Matrix account id");
expect(input?.type).toBe(expectedType);
expect(input?.labels).toContain(label);
if (sensitive) {
expect(container.querySelector(".oc-sensitive-toggle")).not.toBeNull();
} else {
expect(container.querySelector(".oc-sensitive-toggle")).toBeNull();
}
},
);
it("reveals only the replacement value entered in a sensitive step", () => {
const container = document.createElement("div");
const onTextInput = vi.fn();
const onToggleSecretVisibility = vi.fn();
document.body.append(container);
const renderSensitiveStep = (secretVisible: boolean, textValue: string) =>
render(
renderChannelWizard({
wizard: {
phase: "step",
channel: "twitch",
step: {
id: "client-secret",
type: "text",
message: "Twitch Client Secret",
sensitive: true,
},
stepIndex: 1,
busy: false,
validationError: null,
},
channelLabel: (channelId) => channelId,
multiselectValues: [],
onToggleMultiselect: vi.fn(),
textValue,
secretVisible,
onTextInput,
onToggleSecretVisibility,
onAnswer: vi.fn(),
onClose: vi.fn(),
whatsappQrDataUrl: null,
whatsappMessage: null,
whatsappConnected: null,
whatsappBusy: false,
onWhatsAppStart: vi.fn(),
onWhatsAppWait: vi.fn(),
}),
container,
);
renderSensitiveStep(false, "");
const hiddenInput = container.querySelector<HTMLInputElement>("#channel-wizard-text-input");
const toggle = container.querySelector<HTMLButtonElement>(".oc-sensitive-toggle");
expect(hiddenInput?.type).toBe("password");
expect(hiddenInput?.value).toBe("");
expect(toggle?.getAttribute("aria-label")).toBe("Reveal value");
expect(toggle?.dataset.sensitiveIcon).toBe("eye");
if (hiddenInput) {
hiddenInput.value = "new-secret";
hiddenInput.dispatchEvent(new Event("input", { bubbles: true }));
}
toggle?.click();
expect(onTextInput).toHaveBeenCalledWith("new-secret");
expect(onToggleSecretVisibility).toHaveBeenCalledOnce();
renderSensitiveStep(true, "new-secret");
const revealedInput = container.querySelector<HTMLInputElement>("#channel-wizard-text-input");
const hideToggle = container.querySelector<HTMLButtonElement>(".oc-sensitive-toggle");
expect(revealedInput?.type).toBe("text");
expect(revealedInput?.value).toBe("new-secret");
expect(hideToggle?.getAttribute("aria-label")).toBe("Hide value");
expect(hideToggle?.getAttribute("aria-pressed")).toBe("true");
expect(hideToggle?.dataset.sensitiveIcon).toBe("eye-off");
});
it("copies setup text through the plain-HTTP clipboard fallback", async () => {
vi.stubGlobal("navigator", {});
let copiedText: string | undefined;
@@ -94,6 +168,10 @@ describe("renderChannelWizard", () => {
channelLabel: (channelId) => channelId,
multiselectValues: [],
onToggleMultiselect: vi.fn(),
textValue: "",
secretVisible: false,
onTextInput: vi.fn(),
onToggleSecretVisibility: vi.fn(),
onAnswer: vi.fn(),
onClose: vi.fn(),
whatsappQrDataUrl: null,
+16 -2
View File
@@ -16,6 +16,10 @@ type ChannelWizardViewProps = {
// Pending multiselect toggles live in page state so re-renders keep them.
multiselectValues: readonly unknown[];
onToggleMultiselect: (value: unknown) => void;
textValue: string;
secretVisible: boolean;
onTextInput: (value: string) => void;
onToggleSecretVisibility: () => void;
onAnswer: (value: unknown) => void;
onClose: () => void;
// WhatsApp QR linking phase (wizard done + channel === whatsapp).
@@ -84,13 +88,23 @@ function renderStepBody(step: ChannelWizardStep, props: ChannelWizardViewProps)
}
return renderWizardStepControls({
step,
value: step.type === "multiselect" ? props.multiselectValues : step.initialValue,
value:
step.type === "multiselect"
? props.multiselectValues
: step.type === "text"
? props.textValue
: step.initialValue,
busy: stepIsBusy(props),
inputId: "channel-wizard-text-input",
presentation: "channels",
answerLabel: t("channels.setup.continue"),
onValueChange: props.onToggleMultiselect,
sensitiveRevealed: props.secretVisible,
onValueChange:
step.type === "text"
? (value) => props.onTextInput(typeof value === "string" ? value : "")
: props.onToggleMultiselect,
onAnswer: props.onAnswer,
onToggleSensitiveVisibility: props.onToggleSecretVisibility,
});
}
@@ -59,6 +59,60 @@ describe("custodian page session lifecycle", () => {
expect(page.textContent).toContain("started a fresh session");
});
it("starts fresh after the gateway evicts a typed wizard session", async () => {
const request = vi
.fn()
.mockResolvedValueOnce({
sessionId: "evicted-wizard-session",
reply: "Choose a channel.",
action: "none",
wizardInputPending: true,
step: {
id: "channel",
type: "select",
message: "Which channel?",
options: [
{ label: "Slack", value: "slack" },
{ label: "Twitch", value: "twitch" },
],
},
})
.mockRejectedValueOnce(
new GatewayProtocolRequestError({
code: "INVALID_REQUEST",
message: "No active OpenClaw chat session is awaiting that wizard answer.",
details: buildSystemAgentSessionInvalidatedErrorDetails(),
}),
)
.mockResolvedValueOnce({
sessionId: "replacement-session",
reply: "Fresh session ready.",
action: "none",
});
const { context } = createContext(request);
const { page } = await mountPage(context);
await waitForFast(() =>
expect(page.querySelectorAll('.custodian__wizard-step input[type="radio"]')).toHaveLength(2),
);
page
.querySelectorAll<HTMLInputElement>('.custodian__wizard-step input[type="radio"]')[1]!
.click();
await page.updateComplete;
page.querySelector<HTMLButtonElement>(".custodian__wizard-step .btn.primary")!.click();
await waitForFast(() => expect(request).toHaveBeenCalledTimes(3));
expect(request.mock.calls[1]?.[1]).toMatchObject({
sessionId: "evicted-wizard-session",
wizardAnswer: { stepId: "channel", value: "twitch" },
});
expect(request.mock.calls[2]?.[1]).not.toHaveProperty("message");
expect(request.mock.calls[2]?.[1]).not.toHaveProperty("wizardAnswer");
expect(request.mock.calls[2]?.[1]?.sessionId).not.toBe("evicted-wizard-session");
await waitForFast(() => expect(page.textContent).toContain("Fresh session ready."));
expect(page.querySelector(".custodian__wizard-step")).toBeNull();
});
it("keeps the live session after an error that does not invalidate it", async () => {
const request = vi
.fn()
+123 -1
View File
@@ -70,7 +70,7 @@ describe("custodian page", () => {
await page.updateComplete;
expect(request.mock.calls[0]?.[0]).toBe("openclaw.chat");
expect(request.mock.calls[0]?.[1]).toMatchObject({ welcomeVariant: "onboarding" });
// The engine receives the parseable reply text; the transcript shows the label.
// LLM-authored option cards remain chat messages; wizard controls use wizardAnswer below.
expect(request.mock.calls[1]?.[1]).toMatchObject({
welcomeVariant: "onboarding",
message: "connect whatsapp",
@@ -80,6 +80,128 @@ describe("custodian page", () => {
expect(connectOption.disabled).toBe(true);
});
it("renders and answers rich select, multiselect, and sensitive text wizard steps", async () => {
const request = vi
.fn()
.mockResolvedValueOnce({
sessionId: "rich-wizard-session",
reply: "Choose a channel.",
action: "none",
wizardInputPending: true,
step: {
id: "channel",
type: "select",
message: "Which channel?",
options: ["Discord", "Slack", "Telegram", "WhatsApp", "Twitch"].map((label) => ({
label,
value: label.toLowerCase(),
})),
},
})
.mockResolvedValueOnce({
sessionId: "rich-wizard-session",
reply: "Choose features.",
action: "none",
wizardInputPending: true,
step: {
id: "features",
type: "multiselect",
message: "Which features?",
options: [
{ label: "Chat", value: "chat" },
{ label: "Moderation", value: "moderation" },
{ label: "Announcements", value: "announcements" },
],
},
})
.mockResolvedValueOnce({
sessionId: "rich-wizard-session",
reply: "Enter the secret.",
action: "none",
sensitive: true,
wizardInputPending: true,
step: {
id: "secret",
type: "text",
message: "Twitch client secret",
sensitive: true,
},
})
.mockResolvedValueOnce({
sessionId: "rich-wizard-session",
reply: "Setup complete.",
action: "none",
});
const { context } = createContext(request);
const { page } = await mountPage(context);
await waitForFast(() =>
expect(page.querySelectorAll('.custodian__wizard-step input[type="radio"]')).toHaveLength(5),
);
expect(page.querySelector("openclaw-option-card")).toBeNull();
expect(page.querySelector(".agent-chat__composer-shell")).toBeNull();
page
.querySelectorAll<HTMLInputElement>('.custodian__wizard-step input[type="radio"]')[4]!
.click();
await page.updateComplete;
page.querySelector<HTMLButtonElement>(".custodian__wizard-step .btn.primary")!.click();
await waitForFast(() => expect(request).toHaveBeenCalledTimes(2));
await waitForFast(() =>
expect(page.querySelectorAll('.custodian__wizard-step input[type="checkbox"]')).toHaveLength(
3,
),
);
expect(request.mock.calls[1]?.[1]).toMatchObject({
wizardAnswer: { stepId: "channel", value: "twitch" },
});
expect(request.mock.calls[1]?.[1]).not.toHaveProperty("message");
page
.querySelectorAll<HTMLInputElement>('.custodian__wizard-step input[type="checkbox"]')[0]!
.click();
await page.updateComplete;
page
.querySelectorAll<HTMLInputElement>('.custodian__wizard-step input[type="checkbox"]')[2]!
.click();
await page.updateComplete;
page.querySelector<HTMLButtonElement>(".custodian__wizard-step .btn.primary")!.click();
await waitForFast(() => expect(request).toHaveBeenCalledTimes(3));
const secretInput = await waitForFast(() => {
const input = page.querySelector<HTMLInputElement>("#custodian-wizard-input-5");
expect(input).not.toBeNull();
return input!;
});
expect(request.mock.calls[2]?.[1]).toMatchObject({
wizardAnswer: { stepId: "features", value: ["chat", "announcements"] },
});
expect(secretInput.type).toBe("password");
const revealSecret = page.querySelector<HTMLButtonElement>(
'.custodian__wizard-step button[aria-label="Reveal value"]',
);
expect(revealSecret).not.toBeNull();
revealSecret!.click();
await page.updateComplete;
const revealedInput = page.querySelector<HTMLInputElement>("#custodian-wizard-input-5")!;
expect(revealedInput.type).toBe("text");
revealedInput.value = "fake-client-secret";
revealedInput.dispatchEvent(new Event("input", { bubbles: true }));
await page.updateComplete;
page.querySelector<HTMLButtonElement>(".custodian__wizard-step .btn.primary")!.click();
await waitForFast(() => expect(request).toHaveBeenCalledTimes(4));
await waitForFast(() => expect(page.textContent).toContain("Setup complete."));
expect(request.mock.calls[3]?.[1]).toMatchObject({
wizardAnswer: { stepId: "secret", value: "fake-client-secret" },
});
expect(request.mock.calls[3]?.[1]).not.toHaveProperty("message");
expect(page.textContent).toContain("Twitch");
expect(page.textContent).toContain("Chat, Announcements");
expect(page.textContent).toContain("Sensitive reply sent");
expect(page.textContent).not.toContain("fake-client-secret");
expect(page.querySelector(".agent-chat__composer-shell")).not.toBeNull();
});
it("collapses an empty transcript around a blocking startup error", async () => {
const request = vi
.fn()
@@ -4,12 +4,14 @@ import {
type SystemAgentChatResult,
} from "@openclaw/gateway-protocol";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { WizardStep } from "../../api/types.ts";
import { selectApplicationSession } from "../../app/agent-selection.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { t } from "../../i18n/index.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import { buildAgentMainSessionKey, normalizeAgentId } from "../../lib/sessions/session-key.ts";
import { pathForCustodianAgentHandoff } from "./custodian-navigation.ts";
import { custodianWizardSubmission, initialCustodianWizardValue } from "./custodian-wizard-step.ts";
import * as eventNudgeState from "./event-nudge.ts";
import {
custodianChatParams,
@@ -30,6 +32,10 @@ import {
const SYSTEM_AGENT_CHAT_TIMEOUT_MS = 190_000;
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
function hasCustodianUserInput(params: SystemAgentChatParams): boolean {
return params.message !== undefined || params.wizardAnswer !== undefined;
}
type StoreListener = () => void;
type ConfiguredInferenceState = "unresolved" | "required" | "ready";
type CustodianSetupIssue = "missing" | "unavailable";
@@ -41,6 +47,8 @@ export class CustodianSessionStore {
sending = false;
sensitive = false;
wizardInputPending = false;
wizardValue: unknown;
wizardSecretVisible = false;
questionReplyUncertain = false;
error: string | null = null;
setupIssue: CustodianSetupIssue | null = null;
@@ -118,6 +126,16 @@ export class CustodianSessionStore {
this.emit();
}
setWizardValue(value: unknown): void {
this.wizardValue = value;
this.emit();
}
toggleWizardSecretVisibility(): void {
this.wizardSecretVisible = !this.wizardSecretVisible;
this.emit();
}
hasRealUserTurn(): boolean {
return this.messages.some((message) => message.role === "user");
}
@@ -137,7 +155,7 @@ export class CustodianSessionStore {
}
canRetry(): boolean {
return this.retryParams !== null && this.retryParams.message === undefined;
return this.retryParams !== null && !hasCustodianUserInput(this.retryParams);
}
get setupRequired(): boolean {
@@ -147,7 +165,7 @@ export class CustodianSessionStore {
retry(): void {
const client = this.activeClient;
const params = this.retryParams;
if (client && params && params.message === undefined && this.chatAvailable && !this.sending) {
if (client && params && !hasCustodianUserInput(params) && this.chatAvailable && !this.sending) {
void this.initializeSession(client, params);
}
}
@@ -160,15 +178,32 @@ export class CustodianSessionStore {
// Trim decides emptiness only; sensitive values may carry meaningful whitespace.
const message = this.sensitive ? text : text.trim();
const client = this.activeClient;
const questionState = [this.answeredQuestions, this.questionReplyUncertain] as const;
if (questionReply) {
this.questionReplyUncertain = true;
}
if (!message.trim() || !client || !this.chatAvailable || this.sending || this.setupRequired) {
this.emit();
return "rejected";
}
const displayText = this.sensitive ? t("custodian.sensitiveReply") : (display ?? message);
return await this.sendUserTurn(
client,
{
sessionId: this.sessionId,
...custodianChatParams(this.variant, message),
},
displayText,
questionReply,
);
}
private async sendUserTurn(
client: GatewayBrowserClient,
params: SystemAgentChatParams,
displayText: string,
questionReply: boolean,
): Promise<eventNudgeState.CustodianSendOutcome> {
const questionState = [this.answeredQuestions, this.questionReplyUncertain] as const;
if (questionReply) {
this.questionReplyUncertain = true;
}
this.abandonedTurnOutcomeUnknown = false;
this.answeredQuestions = retireCustodianQuestions(this.messages, this.answeredQuestions);
this.messages = [
@@ -179,14 +214,12 @@ export class CustodianSessionStore {
text: displayText,
at: Date.now(),
question: null,
step: null,
},
];
this.input = "";
this.emit();
const reply = this.requestReply(client, {
sessionId: this.sessionId,
...custodianChatParams(this.variant, message),
});
const reply = this.requestReply(client, params);
const replyEpoch = this.requestEpoch;
const outcome = await reply;
if (questionReply && this.requestEpoch === replyEpoch) {
@@ -263,6 +296,25 @@ export class CustodianSessionStore {
void this.send(option?.reply ?? label, label, true);
}
answerWizardStep(message: CustodianMessage, value: unknown): void {
if (!message.step || !this.wizardInputPending) {
return;
}
const submission = custodianWizardSubmission(message.step, value);
const client = this.activeClient;
if (!submission || !client || !this.chatAvailable || this.sending || this.setupRequired) {
this.emit();
return;
}
const displayText = message.step.sensitive ? t("custodian.sensitiveReply") : submission.display;
void this.sendUserTurn(
client,
{ sessionId: this.sessionId, wizardAnswer: submission.answer },
displayText,
true,
);
}
exitSetup(): void {
this.context?.navigate("chat");
}
@@ -323,6 +375,8 @@ export class CustodianSessionStore {
this.answeredQuestions = retireCustodianQuestions(this.messages, this.answeredQuestions);
this.retryParams = null;
this.input = "";
this.wizardValue = undefined;
this.wizardSecretVisible = false;
this.sensitive = this.wizardInputPending = this.questionReplyUncertain = false;
this.error = null;
this.setupIssue = null;
@@ -495,11 +549,17 @@ export class CustodianSessionStore {
this.error = null;
this.setupIssue = null;
this.input = "";
this.wizardValue = undefined;
this.wizardSecretVisible = false;
this.sensitive = this.wizardInputPending = this.questionReplyUncertain = false;
this.earlierBoundaryAfterId = null;
}
private appendAssistant(reply: string, question: CustodianStructuredQuestion | null): void {
private appendAssistant(
reply: string,
question: CustodianStructuredQuestion | null,
step: WizardStep | null,
): void {
this.messages = [
...this.messages,
{
@@ -508,6 +568,7 @@ export class CustodianSessionStore {
text: reply,
at: Date.now(),
question,
step,
},
];
}
@@ -524,7 +585,7 @@ export class CustodianSessionStore {
let delivery: eventNudgeState.CustodianSendDelivery = "unsent";
this.sending = true;
this.error = null;
if (params.message !== undefined) {
if (hasCustodianUserInput(params)) {
this.setupIssue = null;
}
this.retryParams = params;
@@ -543,10 +604,13 @@ export class CustodianSessionStore {
this.wizardInputPending = result.wizardInputPending === true;
this.retryParams = null;
this.setupIssue = null;
const question = parseCustodianQuestion(result.question);
const step = result.step ?? null;
const question = step ? null : parseCustodianQuestion(result.question);
this.wizardValue = step ? initialCustodianWizardValue(step) : undefined;
this.wizardSecretVisible = false;
const silentReply = SILENT_REPLY_PATTERN.test(result.reply);
if (!silentReply || question) {
this.appendAssistant(silentReply ? "" : result.reply, question);
if (!silentReply || question || step) {
this.appendAssistant(silentReply ? "" : result.reply, question, step);
}
if (result.action === "open-agent") {
let sessionKey = context.gateway.snapshot.sessionKey?.trim();
@@ -589,13 +653,13 @@ export class CustodianSessionStore {
? "missing"
: "unavailable"
: null;
if (params.message !== undefined && isCustodianSessionInvalidatedError(error)) {
if (hasCustodianUserInput(params) && isCustodianSessionInvalidatedError(error)) {
// Retained transcript rows are display context only; the next turn needs a fresh id.
this.rotateVolatileSession(client, this.currentSessionVariant());
this.error = t("custodian.sessionRestarted", { error: custodianErrorMessage(error) });
}
}
if (params.message !== undefined && this.retryParams === params) {
if (hasCustodianUserInput(params) && this.retryParams === params) {
// User turns have no idempotency key and are never replayed after an ambiguous failure.
this.retryParams = null;
}
+61 -50
View File
@@ -133,6 +133,9 @@ class CustodianSurface extends OpenClawLightDomElement {
`;
}
const emptyError = store.messages.length === 0 && store.error !== null && !store.sending;
const activeWizardMessage = store.wizardInputPending
? store.messages.findLast((message) => message.step !== null)
: undefined;
return html`
<section
class="custodian-surface ${this.compact ? "custodian-surface--panel" : ""} ${emptyError
@@ -172,6 +175,13 @@ class CustodianSurface extends OpenClawLightDomElement {
store.sending || !store.chatAvailable || store.answeredQuestions.has(questionKey),
onSelect: (label) => store.answerQuestion(message, label),
onSkip: () => void store.dismissQuestion(message),
showWizardStep: message === activeWizardMessage,
wizardValue: store.wizardValue,
wizardDisabled: store.sending || !store.chatAvailable,
wizardSecretVisible: store.wizardSecretVisible,
onWizardValueChange: (value) => store.setWizardValue(value),
onWizardAnswer: (value) => store.answerWizardStep(message, value),
onToggleWizardSecretVisibility: () => store.toggleWizardSecretVisibility(),
});
})}
${store.sending
@@ -204,60 +214,61 @@ class CustodianSurface extends OpenClawLightDomElement {
</div>
${this.historyContent}
<div class="agent-chat__composer-shell">
<div class="agent-chat__input">
<div class="agent-chat__composer-input-row">
<div class="agent-chat__composer-combobox">
${store.sensitive
? html`<input
type="password"
.value=${store.input}
autocomplete="off"
placeholder=${t("custodian.sensitivePlaceholder")}
aria-label=${t("custodian.sensitivePlaceholder")}
?disabled=${!store.activeClient ||
${activeWizardMessage
? nothing
: html`<div class="agent-chat__composer-shell">
<div class="agent-chat__input">
<div class="agent-chat__composer-input-row">
<div class="agent-chat__composer-combobox">
${store.sensitive
? html`<input
type="password"
.value=${store.input}
autocomplete="off"
placeholder=${t("custodian.sensitivePlaceholder")}
aria-label=${t("custodian.sensitivePlaceholder")}
?disabled=${!store.activeClient ||
!store.chatAvailable ||
store.sending ||
store.setupRequired}
@input=${(event: Event) =>
store.setInput((event.target as HTMLInputElement).value)}
@keydown=${(event: KeyboardEvent) => this.handleComposerKeydown(event)}
/>`
: html`<textarea
rows="1"
.value=${store.input}
autocomplete="on"
placeholder=${t("custodian.placeholder")}
aria-label=${t("custodian.placeholder")}
?disabled=${!store.activeClient ||
!store.chatAvailable ||
store.sending ||
store.setupRequired}
@input=${(event: Event) =>
store.setInput((event.target as HTMLTextAreaElement).value)}
@keydown=${(event: KeyboardEvent) => this.handleComposerKeydown(event)}
></textarea>`}
</div>
<div class="agent-chat__composer-actions">
<button
class="chat-send-btn"
type="button"
aria-label=${t("custodian.send")}
?disabled=${!store.input.trim() ||
!store.activeClient ||
!store.chatAvailable ||
store.sending ||
store.setupRequired}
@input=${(event: Event) =>
store.setInput((event.target as HTMLInputElement).value)}
@keydown=${(event: KeyboardEvent) => this.handleComposerKeydown(event)}
/>`
: html`<textarea
rows="1"
.value=${store.input}
autocomplete="on"
placeholder=${t("custodian.placeholder")}
aria-label=${t("custodian.placeholder")}
?disabled=${!store.activeClient ||
!store.chatAvailable ||
store.sending ||
store.setupRequired}
@input=${(event: Event) =>
store.setInput((event.target as HTMLTextAreaElement).value)}
@keydown=${(event: KeyboardEvent) => this.handleComposerKeydown(event)}
></textarea>`}
@click=${() => void store.send()}
>
${icons.arrowUp}
<span class="agent-chat__control-label">${t("custodian.send")}</span>
</button>
</div>
</div>
</div>
<div class="agent-chat__composer-actions">
<button
class="chat-send-btn"
type="button"
aria-label=${t("custodian.send")}
?disabled=${!store.input.trim() ||
!store.activeClient ||
!store.chatAvailable ||
store.sending ||
store.setupRequired}
@click=${() => void store.send()}
>
${icons.arrowUp}
<span class="agent-chat__control-label">${t("custodian.send")}</span>
</button>
</div>
</div>
</div>
</div>
</div>`}
</section>
`;
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import type { WizardStep } from "../../api/types.ts";
import { custodianWizardSubmission, initialCustodianWizardValue } from "./custodian-wizard-step.ts";
const options = [
{ label: "Discord", value: "discord" },
{ label: "Slack", value: "slack" },
{ label: "Twitch", value: "twitch" },
];
function step(patch: Partial<WizardStep>): WizardStep {
return { id: "step", type: "select", options, ...patch };
}
describe("Custodian rich wizard answers", () => {
it("preserves typed select and multiselect values", () => {
expect(custodianWizardSubmission(step({}), "twitch")).toEqual({
answer: { stepId: "step", value: "twitch" },
display: "Twitch",
});
expect(custodianWizardSubmission(step({ type: "multiselect" }), ["discord", "twitch"])).toEqual(
{
answer: { stepId: "step", value: ["discord", "twitch"] },
display: "Discord, Twitch",
},
);
expect(custodianWizardSubmission(step({ type: "multiselect" }), [])).toEqual({
answer: { stepId: "step", value: [] },
display: "none",
});
});
it("builds confirm, text, and continue submissions", () => {
expect(custodianWizardSubmission(step({ type: "confirm" }), true)).toEqual({
answer: { stepId: "step", value: true },
display: "Yes",
});
expect(custodianWizardSubmission(step({ type: "text" }), "secret")).toEqual({
answer: { stepId: "step", value: "secret" },
display: "secret",
});
expect(custodianWizardSubmission(step({ type: "action" }), undefined)).toEqual({
answer: { stepId: "step" },
display: "Continue",
});
});
it("copies multiselect defaults and rejects values outside the step", () => {
const initialValue = ["discord"];
const value = initialCustodianWizardValue(
step({ type: "multiselect", initialValue }),
) as unknown[];
value.push("twitch");
expect(initialValue).toEqual(["discord"]);
expect(custodianWizardSubmission(step({}), "unknown")).toBeNull();
expect(custodianWizardSubmission(step({ type: "text" }), { secret: true })).toBeNull();
expect(custodianWizardSubmission(step({ type: "multiselect" }), ["unknown"])).toBeNull();
});
});
@@ -0,0 +1,62 @@
import type { WizardAnswer } from "@openclaw/gateway-protocol";
import type { WizardStep } from "../../api/types.ts";
import { t } from "../../i18n/index.ts";
type CustodianWizardSubmission = {
answer: WizardAnswer;
display: string;
};
function findOption(step: WizardStep, value: unknown) {
return step.options?.find((option) => Object.is(option.value, value));
}
/** Build the typed answer sent by a client rendering the current wizard step. */
export function custodianWizardSubmission(
step: WizardStep,
value: unknown,
): CustodianWizardSubmission | null {
if (step.type === "note" || step.type === "action" || step.type === "progress") {
return { answer: { stepId: step.id }, display: t("common.continue") };
}
if (step.type === "text") {
return typeof value === "string"
? { answer: { stepId: step.id, value }, display: value }
: null;
}
if (step.type === "confirm") {
if (typeof value !== "boolean") {
return null;
}
return {
answer: { stepId: step.id, value },
display: t(value ? "common.yes" : "common.no"),
};
}
if (step.type === "select") {
const option = findOption(step, value);
return option ? { answer: { stepId: step.id, value }, display: option.label } : null;
}
if (!Array.isArray(value)) {
return null;
}
if (value.length === 0) {
return { answer: { stepId: step.id, value: [] }, display: t("common.none") };
}
const labels = value.map((entry) => findOption(step, entry)?.label);
if (!labels.every((label): label is string => label !== undefined)) {
return null;
}
return {
answer: { stepId: step.id, value },
display: labels.join(", "),
};
}
export function initialCustodianWizardValue(step: WizardStep): unknown {
return step.type === "multiselect"
? Array.isArray(step.initialValue)
? [...step.initialValue]
: []
: step.initialValue;
}
+32
View File
@@ -4,6 +4,8 @@ import type {
} from "@openclaw/gateway-protocol";
import { html, nothing } from "lit";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { WizardStep } from "../../api/types.ts";
import { renderWizardStepControls } from "../../components/wizard-step-controls.ts";
import { t } from "../../i18n/index.ts";
import type { MessageGroup } from "../../lib/chat/chat-types.ts";
import { renderChatDivider } from "../chat/components/chat-divider.ts";
@@ -19,6 +21,7 @@ export type CustodianMessage = {
text: string;
at: number;
question: CustodianStructuredQuestion | null;
step: WizardStep | null;
};
export function hasUnresolvedCustodianQuestion(
@@ -121,6 +124,7 @@ export function createCustodianTranscriptMessages(
: turn.text,
at: turn.at,
question: null,
step: null,
}));
return { messages, nextMessageId };
}
@@ -142,10 +146,18 @@ export function renderCustodianTranscriptEntry(params: {
assistantAvatar: string;
showQuestion: boolean;
questionDisabled: boolean;
showWizardStep: boolean;
wizardValue: unknown;
wizardDisabled: boolean;
wizardSecretVisible: boolean;
onSelect: (label: string) => void;
onSkip: () => void;
onWizardValueChange: (value: unknown) => void;
onWizardAnswer: (value: unknown) => void;
onToggleWizardSecretVisibility: () => void;
}) {
const question = params.message.question;
const step = params.message.step;
return html`
${params.message.text
? renderMessageGroup(toCustodianMessageGroup(params.message), {
@@ -164,5 +176,25 @@ export function renderCustodianTranscriptEntry(params: {
onSkip: params.onSkip,
})
: nothing}
${params.showWizardStep && step
? html`<section
class="custodian__wizard-step"
aria-label=${step.title ?? step.message ?? "Setup"}
>
${step.title
? html`<strong class="custodian__wizard-title">${step.title}</strong>`
: nothing}
${renderWizardStepControls({
step,
value: params.wizardValue,
busy: params.wizardDisabled,
inputId: `custodian-wizard-input-${params.message.id}`,
sensitiveRevealed: params.wizardSecretVisible,
onValueChange: params.onWizardValueChange,
onAnswer: params.onWizardAnswer,
onToggleSensitiveVisibility: params.onToggleWizardSecretVisibility,
})}
</section>`
: nothing}
`;
}
+14
View File
@@ -294,6 +294,20 @@
line-height: 1.4;
}
.channels-wizard__text,
.channels-wizard__secret {
width: 100%;
margin-top: 10px;
}
.channels-wizard__text {
display: flex;
}
.channels-wizard__text > .input {
min-height: 44px;
}
.channels-wizard__options {
display: grid;
gap: var(--space-2);
+130
View File
@@ -1128,6 +1128,136 @@ openclaw-session-owner-chip {
box-shadow: var(--focus-ring);
}
/* Carapace Sensitive Input adapted to the Control UI token contract. */
.oc-sensitive-input {
position: relative;
display: flex;
width: 100%;
min-width: 0;
min-height: 44px;
align-items: stretch;
border: 1px solid var(--input);
border-radius: var(--radius-md);
background: var(--card);
box-shadow: inset 0 1px 0 var(--card-highlight);
transition:
border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.oc-sensitive-input:hover:not(:has(input:disabled)) {
border-color: var(--border-strong);
}
.oc-sensitive-input:focus-within {
border-color: var(--ring);
box-shadow: var(--focus-ring);
}
.oc-sensitive-input > input {
width: 100%;
min-width: 0;
padding: 8px 12px;
border: 0;
border-radius: var(--radius-md) 0 0 var(--radius-md);
outline: none;
background: transparent;
color: var(--text);
box-shadow: none;
}
.oc-sensitive-input > input:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.oc-sensitive-input > input::placeholder {
color: var(--muted);
}
.oc-sensitive-mask {
position: absolute;
z-index: 1;
inset: 0 44px 0 0;
display: flex;
overflow: hidden;
align-items: center;
padding: 0 12px;
color: var(--text);
font-family: var(--font-mono);
font-size: 13px;
letter-spacing: 0.08em;
line-height: 1;
pointer-events: none;
white-space: nowrap;
}
.oc-sensitive-mask[hidden] {
display: none;
}
.oc-sensitive-mask > span {
display: block;
will-change: transform;
}
.oc-sensitive-input[data-sensitive-mask-ready="true"][data-revealed="false"] > input {
color: transparent;
caret-color: var(--text);
}
.oc-sensitive-input[data-sensitive-mask-ready="true"][data-revealed="false"] > input::selection {
color: transparent;
}
.oc-sensitive-toggle {
position: relative;
z-index: 2;
display: inline-grid;
width: 44px;
min-width: 44px;
height: 100%;
min-height: 44px;
place-items: center;
padding: 0;
border: 0;
border-left: 1px solid var(--input);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
background: transparent;
color: var(--muted);
cursor: pointer;
touch-action: manipulation;
}
.oc-sensitive-toggle:hover:not(:disabled) {
color: var(--text);
}
.oc-sensitive-toggle:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.oc-sensitive-toggle:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -3px;
}
.oc-sensitive-toggle svg {
width: 16px;
height: 16px;
}
@media (forced-colors: active) {
.oc-sensitive-mask {
display: none;
}
.oc-sensitive-input[data-sensitive-mask-ready="true"][data-revealed="false"] > input {
color: CanvasText;
}
}
.field select {
appearance: none;
padding-right: 36px;
+15
View File
@@ -256,6 +256,21 @@ openclaw-custodian-page {
margin: -12px 16px 14px 46px;
}
.custodian__wizard-step {
display: grid;
gap: 12px;
margin: -12px 16px 14px 46px;
padding: 16px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-elevated);
}
.custodian__wizard-title {
color: var(--text-strong);
font-size: 14px;
}
.custodian__thinking {
display: flex;
flex-direction: row;