mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 token(oauth:...)",
|
||||
refreshTokenInputPrompt: "Twitch Refresh Token",
|
||||
refreshTokenKeep: "Refresh token 已配置。保留当前值?",
|
||||
refreshTokenPrompt: "启用自动 token 刷新?(需要 client secret 和 refresh token)",
|
||||
setupTitle: "Twitch 设置",
|
||||
},
|
||||
|
||||
@@ -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 token(oauth:...)",
|
||||
refreshTokenInputPrompt: "Twitch Refresh Token",
|
||||
refreshTokenKeep: "Refresh token 已設定。保留目前值?",
|
||||
refreshTokenPrompt: "啟用自動 token 更新?(需要 client secret 和 refresh token)",
|
||||
setupTitle: "Twitch 設定",
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user