From c283f87ab06c713960a84a6e94c0a338d0e0189a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:34:56 +0100 Subject: [PATCH 01/81] refactor: clarify strict loopback proxy audit rules --- src/security/audit.test.ts | 52 +++++++++++++++++++------------------- src/security/audit.ts | 15 +++++------ 2 files changed, 32 insertions(+), 35 deletions(-) diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index c8703341ccb..02060d49d7b 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -974,6 +974,20 @@ describe("security audit", () => { }); it("scores X-Real-IP fallback risk by gateway exposure", async () => { + const trustedProxyCfg = (trustedProxies: string[]): OpenClawConfig => ({ + gateway: { + bind: "loopback", + allowRealIpFallback: true, + trustedProxies, + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + }, + }); + const cases: Array<{ name: string; cfg: OpenClawConfig; @@ -1011,36 +1025,22 @@ describe("security audit", () => { }, { name: "loopback trusted-proxy with loopback-only proxies", - cfg: { - gateway: { - bind: "loopback", - allowRealIpFallback: true, - trustedProxies: ["127.0.0.1"], - auth: { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-forwarded-user", - }, - }, - }, - }, + cfg: trustedProxyCfg(["127.0.0.1"]), expectedSeverity: "warn", }, { name: "loopback trusted-proxy with non-loopback proxy range", - cfg: { - gateway: { - bind: "loopback", - allowRealIpFallback: true, - trustedProxies: ["127.0.0.1", "10.0.0.0/8"], - auth: { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-forwarded-user", - }, - }, - }, - }, + cfg: trustedProxyCfg(["127.0.0.1", "10.0.0.0/8"]), + expectedSeverity: "critical", + }, + { + name: "loopback trusted-proxy with 127.0.0.2", + cfg: trustedProxyCfg(["127.0.0.2"]), + expectedSeverity: "critical", + }, + { + name: "loopback trusted-proxy with 127.0.0.0/8 range", + cfg: trustedProxyCfg(["127.0.0.0/8"]), expectedSeverity: "critical", }, ]; diff --git a/src/security/audit.ts b/src/security/audit.ts index c02191cf32e..651fb619b25 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -9,7 +9,6 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; -import { isLoopbackAddress } from "../gateway/net.js"; import { resolveGatewayProbeAuth } from "../gateway/probe-auth.js"; import { probeGateway } from "../gateway/probe.js"; import { collectChannelSecurityFindings } from "./audit-channel.js"; @@ -340,7 +339,7 @@ function collectGatewayConfigFindings( if (allowRealIpFallback) { const hasNonLoopbackTrustedProxy = trustedProxies.some( - (proxy) => !isLoopbackOnlyTrustedProxyEntry(proxy), + (proxy) => !isStrictLoopbackTrustedProxyEntry(proxy), ); const exposed = bind !== "loopback" || (auth.mode === "trusted-proxy" && hasNonLoopbackTrustedProxy); @@ -508,13 +507,15 @@ function collectGatewayConfigFindings( return findings; } -function isLoopbackOnlyTrustedProxyEntry(entry: string): boolean { +// Keep this stricter than isLoopbackAddress on purpose: this check is for +// trust boundaries, so only explicit localhost proxy hops are treated as local. +function isStrictLoopbackTrustedProxyEntry(entry: string): boolean { const candidate = entry.trim(); if (!candidate) { return false; } if (!candidate.includes("/")) { - return isLoopbackAddress(candidate); + return candidate === "127.0.0.1" || candidate.toLowerCase() === "::1"; } const [rawIp, rawPrefix] = candidate.split("/", 2); @@ -527,11 +528,7 @@ function isLoopbackOnlyTrustedProxyEntry(entry: string): boolean { return false; } if (ipVersion === 4) { - if (prefix < 8 || prefix > 32) { - return false; - } - const firstOctet = Number.parseInt(rawIp.trim().split(".")[0] ?? "", 10); - return firstOctet === 127; + return rawIp.trim() === "127.0.0.1" && prefix === 32; } if (ipVersion === 6) { return prefix === 128 && rawIp.trim().toLowerCase() === "::1"; From 38f02c7a32f3e58efffde8aef71c7a5ee3c467e7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:35:41 +0100 Subject: [PATCH 02/81] fix(session): resolve agent session path with configured sessions dir Co-authored-by: David Rudduck --- CHANGELOG.md | 1 + src/commands/agent.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89021c87fae..97ad0412d9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/Sessions: pass the configured sessions directory when resolving transcript paths in `agentCommand`, so custom `session.store` locations resume sessions reliably. Thanks @davidrudduck. - Gateway/Chat UI: strip inline reply/audio directive tags from non-streaming final webchat broadcasts (including `chat.inject`) while preserving empty-string message content when tags are the entire reply. (#23298) Thanks @SidQin-cyber. - Gateway/Restart: fix restart-loop edge cases by keeping `openclaw.mjs -> dist/entry.js` bootstrap detection explicit, reacquiring the gateway lock for in-process restart fallback paths, and tightening restart-loop regression coverage. (#23416) Thanks @jeffwnli. - Signal/Monitor: treat user-initiated abort shutdowns as clean exits when auto-started `signal-cli` is terminated, while still surfacing unexpected daemon exits as startup/runtime failures. (#23379) Thanks @frankekn. diff --git a/src/commands/agent.ts b/src/commands/agent.ts index a4ceb01c4bf..576124bd81c 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -512,6 +512,7 @@ export async function agentCommand( } let sessionFile = resolveSessionFilePath(sessionId, sessionEntry, { agentId: sessionAgentId, + sessionsDir: path.dirname(storePath), }); if (sessionStore && sessionKey) { const threadIdFromSessionKey = parseSessionThreadInfo(sessionKey).threadId; From 273932850868116a16fb89b3aa9fa9e2d69b6eaf Mon Sep 17 00:00:00 2001 From: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Date: Sun, 22 Feb 2026 06:46:11 -0400 Subject: [PATCH 03/81] fix(telegram): classify undici fetch errors as recoverable for retry (#16699) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 67b5bce44f7014c8cbefc00eed0731e61d6300b9 Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + docs/channels/telegram.md | 19 +++++++++++++++++++ src/telegram/monitor.test.ts | 8 ++++++-- src/telegram/network-errors.test.ts | 17 +++++++++++++++-- src/telegram/network-errors.ts | 13 ++++++++----- 5 files changed, 49 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ad0412d9d..ea5223314f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Plugins/CLI: make `openclaw plugins enable` and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl. - Memory/QMD: add optional `memory.qmd.mcporter` search routing so QMD `query/search/vsearch` can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07. - Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:]]`, `[[audio_as_voice]]`) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces. +- Telegram/Retry: classify undici `TypeError: fetch failed` as recoverable in both polling and send retry paths so transient fetch failures no longer fail fast. (#16699) thanks @Glucksberg. - BlueBubbles/DM history: restore DM backfill context with account-scoped rolling history, bounded backfill retries, and safer history payload limits. (#20302) Thanks @Ryan-Haines. - BlueBubbles/Webhooks: accept inbound/reaction webhook payloads when BlueBubbles omits `handle` but provides DM `chatGuid`, and harden payload extraction for array/string-wrapped message bodies so valid webhook events no longer get rejected as unparseable. (#23275) Thanks @toph31. - Security/Audit: add `openclaw security audit` finding `gateway.nodes.allow_commands_dangerous` for risky `gateway.nodes.allowCommands` overrides, with severity upgraded to critical on remote gateway exposure. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 8676bce4e97..3867224fc7a 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -670,6 +670,25 @@ openclaw message send --channel telegram --target @name --message "hi" - Node 22+ + custom fetch/proxy can trigger immediate abort behavior if AbortSignal types mismatch. - Some hosts resolve `api.telegram.org` to IPv6 first; broken IPv6 egress can cause intermittent Telegram API failures. + - If logs include `TypeError: fetch failed` or `Network request for 'getUpdates' failed!`, OpenClaw now retries these as recoverable network errors. + - On VPS hosts with unstable direct egress/TLS, route Telegram API calls through `channels.telegram.proxy`: + +```yaml +channels: + telegram: + proxy: socks5://user:pass@proxy-host:1080 +``` + + - If DNS/IPv6 selection is unstable, force Node family selection behavior explicitly: + +```yaml +channels: + telegram: + network: + autoSelectFamily: false +``` + + - Environment override (temporary): set `OPENCLAW_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY=1`. - Validate DNS answers: ```bash diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts index 7c836e1b4ac..ff12faaa217 100644 --- a/src/telegram/monitor.test.ts +++ b/src/telegram/monitor.test.ts @@ -169,8 +169,12 @@ describe("monitorTelegramProvider (grammY)", () => { expect(api.sendMessage).not.toHaveBeenCalled(); }); - it("retries on recoverable network errors", async () => { - const networkError = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }); + it("retries on recoverable undici fetch errors", async () => { + const networkError = Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect timeout"), { + code: "UND_ERR_CONNECT_TIMEOUT", + }), + }); runSpy .mockImplementationOnce(() => ({ task: () => Promise.reject(networkError), diff --git a/src/telegram/network-errors.test.ts b/src/telegram/network-errors.test.ts index c435320bd54..b92081a8284 100644 --- a/src/telegram/network-errors.test.ts +++ b/src/telegram/network-errors.test.ts @@ -30,12 +30,25 @@ describe("isRecoverableTelegramNetworkError", () => { expect(isRecoverableTelegramNetworkError(new Error("Undici: socket failure"))).toBe(true); }); - it("skips message matches for send context", () => { + it("treats undici fetch failed errors as recoverable in send context", () => { const err = new TypeError("fetch failed"); - expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(false); + expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(true); + expect( + isRecoverableTelegramNetworkError(new Error("TypeError: fetch failed"), { context: "send" }), + ).toBe(true); expect(isRecoverableTelegramNetworkError(err, { context: "polling" })).toBe(true); }); + it("skips broad message matches for send context", () => { + const networkRequestErr = new Error("Network request for 'sendMessage' failed!"); + expect(isRecoverableTelegramNetworkError(networkRequestErr, { context: "send" })).toBe(false); + expect(isRecoverableTelegramNetworkError(networkRequestErr, { context: "polling" })).toBe(true); + + const undiciSnippetErr = new Error("Undici: socket failure"); + expect(isRecoverableTelegramNetworkError(undiciSnippetErr, { context: "send" })).toBe(false); + expect(isRecoverableTelegramNetworkError(undiciSnippetErr, { context: "polling" })).toBe(true); + }); + it("returns false for unrelated errors", () => { expect(isRecoverableTelegramNetworkError(new Error("invalid token"))).toBe(false); }); diff --git a/src/telegram/network-errors.ts b/src/telegram/network-errors.ts index 75c22ea7fa5..177ef00d646 100644 --- a/src/telegram/network-errors.ts +++ b/src/telegram/network-errors.ts @@ -27,9 +27,9 @@ const RECOVERABLE_ERROR_NAMES = new Set([ "BodyTimeoutError", ]); +const ALWAYS_RECOVERABLE_MESSAGES = new Set(["fetch failed", "typeerror: fetch failed"]); + const RECOVERABLE_MESSAGE_SNIPPETS = [ - "fetch failed", - "typeerror: fetch failed", "undici", "network error", "network request", @@ -138,9 +138,12 @@ export function isRecoverableTelegramNetworkError( return true; } - if (allowMessageMatch) { - const message = formatErrorMessage(candidate).toLowerCase(); - if (message && RECOVERABLE_MESSAGE_SNIPPETS.some((snippet) => message.includes(snippet))) { + const message = formatErrorMessage(candidate).trim().toLowerCase(); + if (message && ALWAYS_RECOVERABLE_MESSAGES.has(message)) { + return true; + } + if (allowMessageMatch && message) { + if (RECOVERABLE_MESSAGE_SNIPPETS.some((snippet) => message.includes(snippet))) { return true; } } From 56f01bc4930bafa924c1757f696ad95892a162fc Mon Sep 17 00:00:00 2001 From: echoVic Date: Sun, 22 Feb 2026 18:12:04 +0800 Subject: [PATCH 04/81] fix(config): add missing comment field to BindingsSchema Strict validation (added in d1e9490f9) rejects the legitimate 'comment' field on bindings. This field is used for annotations in config files. Changes: - BindingsSchema: added comment: z.string().optional() - AgentBinding type: added comment?: string Fixes #23385 --- src/config/types.agents.ts | 1 + src/config/zod-schema.agents.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index 2816d33a726..478e14e526b 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -72,6 +72,7 @@ export type AgentsConfig = { export type AgentBinding = { agentId: string; + comment?: string; match: { channel: string; accountId?: string; diff --git a/src/config/zod-schema.agents.ts b/src/config/zod-schema.agents.ts index 704d1752ca5..c7c921a5e5a 100644 --- a/src/config/zod-schema.agents.ts +++ b/src/config/zod-schema.agents.ts @@ -16,6 +16,7 @@ export const BindingsSchema = z z .object({ agentId: z.string(), + comment: z.string().optional(), match: z .object({ channel: z.string(), From 812bf7c8e18559bdef80e11d43a1643aeae83ac6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:46:34 +0100 Subject: [PATCH 05/81] fix: add bindings comment regression test (#23458) (thanks @echoVic) --- CHANGELOG.md | 1 + ...fig-detection.accepts-imessage-dmpolicy.e2e.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea5223314f7..c9b420a9112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,7 @@ Docs: https://docs.openclaw.ai - Cron: persist `delivered` state in cron job records so delivery failures remain visible in status and logs. (#19174) Thanks @simonemacario. - Config/Doctor: only repair the OAuth credentials directory when affected channels are configured, avoiding fresh-install noise. - Config/Channels: whitelist `channels.modelByChannel` in config validation and exclude it from plugin auto-enable channel detection so model overrides no longer trigger `unknown channel id` validation errors or bogus `modelByChannel` plugin enables. (#23412) Thanks @ProspectOre. +- Config/Bindings: allow optional `bindings[].comment` in strict config validation so annotated binding entries no longer fail load. (#23458) Thanks @echoVic. - Usage/Pricing: correct MiniMax M2.5 pricing defaults to fix inflated cost reporting. (#22755) Thanks @miloudbelarebia. - Gateway/Daemon: verify gateway health after daemon restart. - Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam. diff --git a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.e2e.test.ts b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.e2e.test.ts index 7d2a54ddb74..e4a5ddcfdba 100644 --- a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.e2e.test.ts +++ b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.e2e.test.ts @@ -363,6 +363,16 @@ describe("legacy config detection", () => { expectedValue: "work", }); }); + it("accepts bindings[].comment on load", () => { + expectValidConfigValue({ + config: { + bindings: [{ agentId: "main", comment: "primary route", match: { channel: "telegram" } }], + }, + readValue: (config) => + (config as { bindings?: Array<{ comment?: string }> }).bindings?.[0]?.comment, + expectedValue: "primary route", + }); + }); it("rejects session.sendPolicy.rules[].match.provider on load", async () => { await withTempHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); From 888b6bc9483518d535f1ff36408da8868c22ea24 Mon Sep 17 00:00:00 2001 From: echoVic Date: Sun, 22 Feb 2026 18:12:40 +0800 Subject: [PATCH 06/81] fix(bluebubbles): treat null privateApiStatus as disabled, not enabled Bug: privateApiStatus cache expires after 10 minutes, returning null. The check '!== false' treats null as truthy, causing 500 errors when trying to use Private API features that aren't actually available. Root cause: In JavaScript, null !== false evaluates to true. Fix: Changed all checks from '!== false' to '=== true', so null (cache expired/unknown) is treated as disabled (safe default). Files changed: - extensions/bluebubbles/src/send.ts (line 376) - extensions/bluebubbles/src/monitor-processing.ts (line 423) - extensions/bluebubbles/src/attachments.ts (lines 210, 220) Fixes #23393 --- extensions/bluebubbles/src/attachments.ts | 4 ++-- extensions/bluebubbles/src/monitor-processing.ts | 2 +- extensions/bluebubbles/src/send.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 48331f21571..5d5841c8295 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -207,7 +207,7 @@ export async function sendBlueBubblesAttachment(params: { addField("chatGuid", chatGuid); addField("name", filename); addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`); - if (privateApiStatus !== false) { + if (privateApiStatus === true) { addField("method", "private-api"); } @@ -217,7 +217,7 @@ export async function sendBlueBubblesAttachment(params: { } const trimmedReplyTo = replyToMessageGuid?.trim(); - if (trimmedReplyTo && privateApiStatus !== false) { + if (trimmedReplyTo && privateApiStatus === true) { addField("selectedMessageGuid", trimmedReplyTo); addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0"); } diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 4ae113d935f..8f58c7ab552 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -420,7 +420,7 @@ export async function processMessage( target: WebhookTarget, ): Promise { const { account, config, runtime, core, statusSink } = target; - const privateApiEnabled = getCachedBlueBubblesPrivateApiStatus(account.accountId) !== false; + const privateApiEnabled = getCachedBlueBubblesPrivateApiStatus(account.accountId) === true; const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid); const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup; diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index c5614062f51..62644ca9d53 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -373,7 +373,7 @@ export async function sendMessageBlueBubbles( const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim()); const wantsEffect = Boolean(effectId); const needsPrivateApi = wantsReplyThread || wantsEffect; - const canUsePrivateApi = needsPrivateApi && privateApiStatus !== false; + const canUsePrivateApi = needsPrivateApi && privateApiStatus === true; if (wantsEffect && privateApiStatus === false) { throw new Error( "BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.", @@ -395,7 +395,7 @@ export async function sendMessageBlueBubbles( } // Add message effects support - if (effectId) { + if (effectId && canUsePrivateApi) { payload.effectId = effectId; } From 37f12eb7eee30d2f5a76b0b41810dd7922fc982b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:47:17 +0100 Subject: [PATCH 07/81] fix: align BlueBubbles private-api null fallback + warning (#23459) (thanks @echoVic) --- CHANGELOG.md | 1 + extensions/bluebubbles/src/send.test.ts | 30 +++++++++++++++++++++++++ extensions/bluebubbles/src/send.ts | 11 +++++++++ 3 files changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b420a9112..b810b006527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai - Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:]]`, `[[audio_as_voice]]`) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces. - Telegram/Retry: classify undici `TypeError: fetch failed` as recoverable in both polling and send retry paths so transient fetch failures no longer fail fast. (#16699) thanks @Glucksberg. - BlueBubbles/DM history: restore DM backfill context with account-scoped rolling history, bounded backfill retries, and safer history payload limits. (#20302) Thanks @Ryan-Haines. +- BlueBubbles/Private API cache: treat unknown (`null`) private-API cache status as disabled for send/attachment/reply flows to avoid stale-cache 500s, and log a warning when reply/effect features are requested while capability is unknown. (#23459) Thanks @echoVic. - BlueBubbles/Webhooks: accept inbound/reaction webhook payloads when BlueBubbles omits `handle` but provides DM `chatGuid`, and harden payload extraction for array/string-wrapped message bodies so valid webhook events no longer get rejected as unparseable. (#23275) Thanks @toph31. - Security/Audit: add `openclaw security audit` finding `gateway.nodes.allow_commands_dangerous` for risky `gateway.nodes.allowCommands` overrides, with severity upgraded to critical on remote gateway exposure. - Gateway/Control plane: reduce cross-client write limiter contention by adding `connId` fallback keying when device ID and client IP are both unavailable. diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index c1bcafe29cb..7a2edeaf850 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -527,6 +527,7 @@ describe("send", () => { }); it("uses private-api when reply metadata is present", async () => { + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(true); mockResolvedHandleTarget(); mockSendResponse({ data: { guid: "msg-uuid-124" } }); @@ -568,6 +569,7 @@ describe("send", () => { }); it("normalizes effect names and uses private-api for effects", async () => { + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(true); mockResolvedHandleTarget(); mockSendResponse({ data: { guid: "msg-uuid-125" } }); @@ -586,6 +588,34 @@ describe("send", () => { expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink"); }); + it("warns and downgrades private-api features when status is unknown", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-uuid-unknown" } }); + + try { + const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", { + serverUrl: "http://localhost:1234", + password: "test", + replyToMessageGuid: "reply-guid-123", + effectId: "invisible ink", + }); + + expect(result.messageId).toBe("msg-uuid-unknown"); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]?.[0]).toContain("Private API status unknown"); + + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBeUndefined(); + expect(body.selectedMessageGuid).toBeUndefined(); + expect(body.partIndex).toBeUndefined(); + expect(body.effectId).toBeUndefined(); + } finally { + warnSpy.mockRestore(); + } + }); + it("sends message with chat_guid target directly", async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 62644ca9d53..1530d1702c2 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -379,6 +379,17 @@ export async function sendMessageBlueBubbles( "BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.", ); } + if (needsPrivateApi && privateApiStatus === null) { + const requested = [ + wantsReplyThread ? "reply threading" : null, + wantsEffect ? "message effects" : null, + ] + .filter(Boolean) + .join(" + "); + console.warn( + `[bluebubbles] Private API status unknown; sending without ${requested}. Run a status probe to restore private-api features.`, + ); + } const payload: Record = { chatGuid, tempGuid: crypto.randomUUID(), From b77e53da67c6b0051a27dee53da7618f7faeb171 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 12:00:54 +0100 Subject: [PATCH 08/81] refactor(session): centralize transcript path option resolution --- src/auto-reply/reply/agent-runner.ts | 7 +++- .../reply/commands-export-session.ts | 10 +++--- src/commands/agent.e2e.test.ts | 32 +++++++++++++++++++ src/commands/agent.ts | 11 ++++--- src/commands/doctor-state-integrity.ts | 12 ++++--- 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 4fe94914ff6..b00dcd969f8 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -8,6 +8,7 @@ import { hasNonzeroUsage } from "../../agents/usage.js"; import { resolveAgentIdFromSessionKey, resolveSessionFilePath, + resolveSessionFilePathOptions, resolveSessionTranscriptPath, type SessionEntry, updateSessionStore, @@ -324,7 +325,11 @@ export async function runReplyAgent(params: { defaultRuntime.error(buildLogMessage(nextSessionId)); if (cleanupTranscripts && prevSessionId) { const transcriptCandidates = new Set(); - const resolved = resolveSessionFilePath(prevSessionId, prevEntry, { agentId }); + const resolved = resolveSessionFilePath( + prevSessionId, + prevEntry, + resolveSessionFilePathOptions({ agentId, storePath }), + ); if (resolved) { transcriptCandidates.add(resolved); } diff --git a/src/auto-reply/reply/commands-export-session.ts b/src/auto-reply/reply/commands-export-session.ts index 10d039741aa..5b560e4f269 100644 --- a/src/auto-reply/reply/commands-export-session.ts +++ b/src/auto-reply/reply/commands-export-session.ts @@ -6,6 +6,7 @@ import { SessionManager } from "@mariozechner/pi-coding-agent"; import { resolveDefaultSessionStorePath, resolveSessionFilePath, + resolveSessionFilePathOptions, } from "../../config/sessions/paths.js"; import { loadSessionStore } from "../../config/sessions/store.js"; import type { SessionEntry } from "../../config/sessions/types.js"; @@ -126,10 +127,11 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro let sessionFile: string; try { - sessionFile = resolveSessionFilePath(entry.sessionId, entry, { - agentId: params.agentId, - sessionsDir: path.dirname(storePath), - }); + sessionFile = resolveSessionFilePath( + entry.sessionId, + entry, + resolveSessionFilePathOptions({ agentId: params.agentId, storePath }), + ); } catch (err) { return { text: `❌ Failed to resolve session file: ${err instanceof Error ? err.message : String(err)}`, diff --git a/src/commands/agent.e2e.test.ts b/src/commands/agent.e2e.test.ts index 56c24571c4e..3d885617a75 100644 --- a/src/commands/agent.e2e.test.ts +++ b/src/commands/agent.e2e.test.ts @@ -5,10 +5,12 @@ import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import "../cron/isolated-agent.mocks.js"; import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import * as cliRunnerModule from "../agents/cli-runner.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import type { OpenClawConfig } from "../config/config.js"; import * as configModule from "../config/config.js"; +import * as sessionsModule from "../config/sessions.js"; import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createPluginRuntime } from "../plugins/runtime/index.js"; @@ -25,6 +27,7 @@ const runtime: RuntimeEnv = { }; const configSpy = vi.spyOn(configModule, "loadConfig"); +const runCliAgentSpy = vi.spyOn(cliRunnerModule, "runCliAgent"); async function withTempHome(fn: (home: string) => Promise): Promise { return withTempHomeBase(fn, { prefix: "openclaw-agent-" }); @@ -64,6 +67,13 @@ function writeSessionStoreSeed( beforeEach(() => { vi.clearAllMocks(); + runCliAgentSpy.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + } as never); vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ payloads: [{ text: "ok" }], meta: { @@ -131,6 +141,28 @@ describe("agentCommand", () => { }); }); + it("resolves resumed session transcript path from custom session store directory", async () => { + await withTempHome(async (home) => { + const customStoreDir = path.join(home, "custom-state"); + const store = path.join(customStoreDir, "sessions.json"); + writeSessionStoreSeed(store, {}); + mockConfig(home, store); + const resolveSessionFilePathSpy = vi.spyOn(sessionsModule, "resolveSessionFilePath"); + + await agentCommand({ message: "resume me", sessionId: "session-custom-123" }, runtime); + + const matchingCall = resolveSessionFilePathSpy.mock.calls.find( + (call) => call[0] === "session-custom-123", + ); + expect(matchingCall?.[2]).toEqual( + expect.objectContaining({ + agentId: "main", + sessionsDir: customStoreDir, + }), + ); + }); + }); + it("does not duplicate agent events from embedded runs", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 576124bd81c..314b2948b0c 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import { listAgentIds, resolveAgentDir, @@ -45,6 +44,7 @@ import { resolveAndPersistSessionFile, resolveAgentIdFromSessionKey, resolveSessionFilePath, + resolveSessionFilePathOptions, resolveSessionTranscriptPath, type SessionEntry, updateSessionStore, @@ -510,10 +510,11 @@ export async function agentCommand( }); } } - let sessionFile = resolveSessionFilePath(sessionId, sessionEntry, { + const sessionPathOpts = resolveSessionFilePathOptions({ agentId: sessionAgentId, - sessionsDir: path.dirname(storePath), + storePath, }); + let sessionFile = resolveSessionFilePath(sessionId, sessionEntry, sessionPathOpts); if (sessionStore && sessionKey) { const threadIdFromSessionKey = parseSessionThreadInfo(sessionKey).threadId; const fallbackSessionFile = !sessionEntry?.sessionFile @@ -529,8 +530,8 @@ export async function agentCommand( sessionStore, storePath, sessionEntry, - agentId: sessionAgentId, - sessionsDir: path.dirname(storePath), + agentId: sessionPathOpts?.agentId, + sessionsDir: sessionPathOpts?.sessionsDir, fallbackSessionFile, }); sessionFile = resolvedSessionFile.sessionFile; diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index a62fcfb3108..d5beae1cec6 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -8,6 +8,7 @@ import { loadSessionStore, resolveMainSessionKey, resolveSessionFilePath, + resolveSessionFilePathOptions, resolveSessionTranscriptsDirForAgent, resolveStorePath, } from "../config/sessions.js"; @@ -386,6 +387,7 @@ export async function noteStateIntegrity( } const store = loadSessionStore(storePath); + const sessionPathOpts = resolveSessionFilePathOptions({ agentId, storePath }); const entries = Object.entries(store).filter(([, entry]) => entry && typeof entry === "object"); if (entries.length > 0) { const recent = entries @@ -401,9 +403,7 @@ export async function noteStateIntegrity( if (!sessionId) { return false; } - const transcriptPath = resolveSessionFilePath(sessionId, entry, { - agentId, - }); + const transcriptPath = resolveSessionFilePath(sessionId, entry, sessionPathOpts); return !existsFile(transcriptPath); }); if (missing.length > 0) { @@ -415,7 +415,11 @@ export async function noteStateIntegrity( const mainKey = resolveMainSessionKey(cfg); const mainEntry = store[mainKey]; if (mainEntry?.sessionId) { - const transcriptPath = resolveSessionFilePath(mainEntry.sessionId, mainEntry, { agentId }); + const transcriptPath = resolveSessionFilePath( + mainEntry.sessionId, + mainEntry, + sessionPathOpts, + ); if (!existsFile(transcriptPath)) { warnings.push( `- Main session transcript missing (${shortenHomePath(transcriptPath)}). History will appear to reset.`, From 6f7e5f92c3e5bb256b4d93f61d334add52dd30db Mon Sep 17 00:00:00 2001 From: Yuzuru Suzuki Date: Sun, 22 Feb 2026 20:06:18 +0900 Subject: [PATCH 09/81] fix: add operator.read and operator.write to default CLI scopes (#22582) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 8569fc88c970e75934617c200ebfe117e9d5ae88 Co-authored-by: YuzuruS <1485195+YuzuruS@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift | 2 +- apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift | 7 +++++++ apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift | 2 +- .../Sources/OpenClawKit/GatewayChannel.swift | 10 +++++++++- src/gateway/call.test.ts | 8 +++++++- src/gateway/method-scopes.ts | 2 ++ src/gateway/server.auth.e2e.test.ts | 8 +++++++- ui/src/ui/gateway.ts | 9 ++++++++- 9 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b810b006527..e422d7639a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai - Gateway/Pairing: treat operator.admin pairing tokens as satisfying operator.write requests so legacy devices stop looping through scope-upgrade prompts introduced in 2026.2.19. (#23125, #23006) Thanks @vignesh07. - Gateway/Pairing: treat `operator.admin` as satisfying other `operator.*` scope checks during device-auth verification so local CLI/TUI sessions stop entering pairing-required loops for pairing/approval-scoped commands. (#22062, #22193, #21191) Thanks @Botaccess, @jhartshorn, and @ctbritt. - Gateway/Pairing: preserve existing approved token scopes when processing repair pairings that omit `scopes`, preventing empty-scope token regressions on reconnecting clients. (#21906) Thanks @paki81. +- Gateway/Scopes: include `operator.read` and `operator.write` in default operator connect scope bundles across CLI, Control UI, and macOS clients so write-scoped announce/sub-agent follow-up calls no longer hit `pairing required` disconnects on loopback gateways. (#22582) thanks @YuzuruS. - Plugins/CLI: make `openclaw plugins enable` and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl. - Memory/QMD: add optional `memory.qmd.mcporter` search routing so QMD `query/search/vsearch` can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07. - Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:]]`, `[[audio_as_voice]]`) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces. diff --git a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift index 0989164a01e..151b7fdda94 100644 --- a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift @@ -15,7 +15,7 @@ struct ConnectOptions { var clientMode: String = "ui" var displayName: String? var role: String = "operator" - var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"] + var scopes: [String] = defaultOperatorConnectScopes var help: Bool = false static func parse(_ args: [String]) -> ConnectOptions { diff --git a/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift b/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift new file mode 100644 index 00000000000..479c176d5d8 --- /dev/null +++ b/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift @@ -0,0 +1,7 @@ +let defaultOperatorConnectScopes: [String] = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", +] diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift index 2d36bac3c49..ebe3e8ae626 100644 --- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift @@ -251,7 +251,7 @@ actor GatewayWizardClient { let clientMode = "ui" let role = "operator" // Explicit scopes; gateway no longer defaults empty scopes to admin. - let scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"] + let scopes = defaultOperatorConnectScopes let client: [String: ProtoAnyCodable] = [ "id": ProtoAnyCodable(clientId), "displayName": ProtoAnyCodable(Host.current().localizedName ?? "OpenClaw macOS Wizard CLI"), diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 1aa1b5ae385..30935df79d4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -127,6 +127,14 @@ private enum ConnectChallengeError: Error { case timeout } +private let defaultOperatorConnectScopes: [String] = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", +] + public actor GatewayChannelActor { private let logger = Logger(subsystem: "ai.openclaw", category: "gateway") private var task: WebSocketTaskBox? @@ -318,7 +326,7 @@ public actor GatewayChannelActor { let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier let options = self.connectOptions ?? GatewayConnectOptions( role: "operator", - scopes: ["operator.admin", "operator.approvals", "operator.pairing"], + scopes: defaultOperatorConnectScopes, caps: [], commands: [], permissions: [:], diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index ab07d3357fa..2bc4d4ddc77 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -206,7 +206,13 @@ describe("callGateway url resolution", () => { { label: "keeps legacy admin scopes for explicit CLI callers", call: () => callGatewayCli({ method: "health" }), - expectedScopes: ["operator.admin", "operator.approvals", "operator.pairing"], + expectedScopes: [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", + ], }, ])("scope selection: $label", async ({ call, expectedScopes }) => { setLocalLoopbackGatewayConfig(); diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 1fd9377ead6..20629c3d1c0 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -13,6 +13,8 @@ export type OperatorScope = export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [ ADMIN_SCOPE, + READ_SCOPE, + WRITE_SCOPE, APPROVALS_SCOPE, PAIRING_SCOPE, ]; diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 20680cb62f3..23b4b29f33b 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -873,7 +873,13 @@ describe("gateway server auth/connect", () => { const { randomUUID } = await import("node:crypto"); const os = await import("node:os"); const path = await import("node:path"); - const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; + const scopes = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", + ]; const { device } = await createSignedDevice({ token: "secret", scopes, diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 27f212c2434..ef2c418a014 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -61,6 +61,13 @@ export type GatewayBrowserClientOptions = { // 4008 = application-defined code (browser rejects 1008 "Policy Violation") const CONNECT_FAILED_CLOSE_CODE = 4008; +const DEFAULT_OPERATOR_CONNECT_SCOPES = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", +]; export class GatewayBrowserClient { private ws: WebSocket | null = null; @@ -145,7 +152,7 @@ export class GatewayBrowserClient { // Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled. const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle; - const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; + const scopes = DEFAULT_OPERATOR_CONNECT_SCOPES; const role = "operator"; let deviceIdentity: Awaited> | null = null; let canFallbackToShared = false; From 296b3f49ef7581f6ff5efac93ca692d41f1977fc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 12:08:08 +0100 Subject: [PATCH 10/81] refactor(bluebubbles): centralize private-api status handling --- .../bluebubbles/src/attachments.test.ts | 45 ++++++++++++- extensions/bluebubbles/src/attachments.ts | 16 +++-- .../bluebubbles/src/monitor-processing.ts | 4 +- extensions/bluebubbles/src/probe.ts | 8 +++ extensions/bluebubbles/src/runtime.ts | 18 +++++ extensions/bluebubbles/src/send.test.ts | 34 ++++++++-- extensions/bluebubbles/src/send.ts | 65 ++++++++++++++----- extensions/bluebubbles/src/test-harness.ts | 31 ++++++++- 8 files changed, 186 insertions(+), 35 deletions(-) diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 47f6e6d03cc..17060229930 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -4,7 +4,12 @@ import "./test-mocks.js"; import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { setBlueBubblesRuntime } from "./runtime.js"; -import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; +import { + BLUE_BUBBLES_PRIVATE_API_STATUS, + installBlueBubblesFetchTestHooks, + mockBlueBubblesPrivateApiStatus, + mockBlueBubblesPrivateApiStatusOnce, +} from "./test-harness.js"; import type { BlueBubblesAttachment } from "./types.js"; const mockFetch = vi.fn(); @@ -278,7 +283,10 @@ describe("sendBlueBubblesAttachment", () => { fetchRemoteMediaMock.mockClear(); setBlueBubblesRuntime(runtimeStub); vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset(); - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null); + mockBlueBubblesPrivateApiStatus( + vi.mocked(getCachedBlueBubblesPrivateApiStatus), + BLUE_BUBBLES_PRIVATE_API_STATUS.unknown, + ); }); afterEach(() => { @@ -381,7 +389,10 @@ describe("sendBlueBubblesAttachment", () => { }); it("downgrades attachment reply threading when private API is disabled", async () => { - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); + mockBlueBubblesPrivateApiStatusOnce( + vi.mocked(getCachedBlueBubblesPrivateApiStatus), + BLUE_BUBBLES_PRIVATE_API_STATUS.disabled, + ); mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })), @@ -402,4 +413,32 @@ describe("sendBlueBubblesAttachment", () => { expect(bodyText).not.toContain('name="selectedMessageGuid"'); expect(bodyText).not.toContain('name="partIndex"'); }); + + it("warns and downgrades attachment reply threading when private API status is unknown", async () => { + const runtimeLog = vi.fn(); + setBlueBubblesRuntime({ + ...runtimeStub, + log: runtimeLog, + } as unknown as PluginRuntime); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ messageId: "msg-5" })), + }); + + await sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "photo.jpg", + contentType: "image/jpeg", + replyToMessageGuid: "reply-guid-unknown", + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }); + + expect(runtimeLog).toHaveBeenCalledTimes(1); + expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown"); + const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; + const bodyText = decodeBody(body); + expect(bodyText).not.toContain('name="selectedMessageGuid"'); + expect(bodyText).not.toContain('name="partIndex"'); + }); }); diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 5d5841c8295..3b8850f2154 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -3,9 +3,12 @@ import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; import { postMultipartFormData } from "./multipart.js"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { + getCachedBlueBubblesPrivateApiStatus, + isBlueBubblesPrivateApiStatusEnabled, +} from "./probe.js"; import { resolveRequestUrl } from "./request-url.js"; -import { getBlueBubblesRuntime } from "./runtime.js"; +import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js"; import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; import { resolveChatGuidForTarget } from "./send.js"; import { @@ -139,6 +142,7 @@ export async function sendBlueBubblesAttachment(params: { contentType = contentType?.trim() || undefined; const { baseUrl, password, accountId } = resolveAccount(opts); const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); + const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus); // Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage). const isAudioMessage = wantsVoice; @@ -207,7 +211,7 @@ export async function sendBlueBubblesAttachment(params: { addField("chatGuid", chatGuid); addField("name", filename); addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`); - if (privateApiStatus === true) { + if (privateApiEnabled) { addField("method", "private-api"); } @@ -217,9 +221,13 @@ export async function sendBlueBubblesAttachment(params: { } const trimmedReplyTo = replyToMessageGuid?.trim(); - if (trimmedReplyTo && privateApiStatus === true) { + if (trimmedReplyTo && privateApiEnabled) { addField("selectedMessageGuid", trimmedReplyTo); addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0"); + } else if (trimmedReplyTo && privateApiStatus === null) { + warnBlueBubbles( + "Private API status unknown; sending attachment without reply threading metadata. Run a status probe to restore private-api reply features.", + ); } // Add optional caption diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 8f58c7ab552..67fb50a78c6 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -39,7 +39,7 @@ import type { BlueBubblesRuntimeEnv, WebhookTarget, } from "./monitor-shared.js"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { isBlueBubblesPrivateApiEnabled } from "./probe.js"; import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js"; @@ -420,7 +420,7 @@ export async function processMessage( target: WebhookTarget, ): Promise { const { account, config, runtime, core, statusSink } = target; - const privateApiEnabled = getCachedBlueBubblesPrivateApiStatus(account.accountId) === true; + const privateApiEnabled = isBlueBubblesPrivateApiEnabled(account.accountId); const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid); const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup; diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts index e60c47dc643..5ee95a26821 100644 --- a/extensions/bluebubbles/src/probe.ts +++ b/extensions/bluebubbles/src/probe.ts @@ -96,6 +96,14 @@ export function getCachedBlueBubblesPrivateApiStatus(accountId?: string): boolea return info.private_api; } +export function isBlueBubblesPrivateApiStatusEnabled(status: boolean | null): boolean { + return status === true; +} + +export function isBlueBubblesPrivateApiEnabled(accountId?: string): boolean { + return isBlueBubblesPrivateApiStatusEnabled(getCachedBlueBubblesPrivateApiStatus(accountId)); +} + /** * Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number. */ diff --git a/extensions/bluebubbles/src/runtime.ts b/extensions/bluebubbles/src/runtime.ts index 2f183c74e4d..439e62d2503 100644 --- a/extensions/bluebubbles/src/runtime.ts +++ b/extensions/bluebubbles/src/runtime.ts @@ -6,9 +6,27 @@ export function setBlueBubblesRuntime(next: PluginRuntime): void { runtime = next; } +export function clearBlueBubblesRuntime(): void { + runtime = null; +} + +export function tryGetBlueBubblesRuntime(): PluginRuntime | null { + return runtime; +} + export function getBlueBubblesRuntime(): PluginRuntime { if (!runtime) { throw new Error("BlueBubbles runtime not initialized"); } return runtime; } + +export function warnBlueBubbles(message: string): void { + const formatted = `[bluebubbles] ${message}`; + const log = runtime?.log; + if (typeof log === "function") { + log(formatted); + return; + } + console.warn(formatted); +} diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 7a2edeaf850..9872372641e 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -1,15 +1,22 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; import "./test-mocks.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js"; import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js"; -import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; +import { + BLUE_BUBBLES_PRIVATE_API_STATUS, + installBlueBubblesFetchTestHooks, + mockBlueBubblesPrivateApiStatusOnce, +} from "./test-harness.js"; import type { BlueBubblesSendTarget } from "./types.js"; const mockFetch = vi.fn(); +const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus); installBlueBubblesFetchTestHooks({ mockFetch, - privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus), + privateApiStatusMock, }); function mockResolvedHandleTarget( @@ -527,7 +534,10 @@ describe("send", () => { }); it("uses private-api when reply metadata is present", async () => { - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(true); + mockBlueBubblesPrivateApiStatusOnce( + privateApiStatusMock, + BLUE_BUBBLES_PRIVATE_API_STATUS.enabled, + ); mockResolvedHandleTarget(); mockSendResponse({ data: { guid: "msg-uuid-124" } }); @@ -549,7 +559,10 @@ describe("send", () => { }); it("downgrades threaded reply to plain send when private API is disabled", async () => { - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); + mockBlueBubblesPrivateApiStatusOnce( + privateApiStatusMock, + BLUE_BUBBLES_PRIVATE_API_STATUS.disabled, + ); mockResolvedHandleTarget(); mockSendResponse({ data: { guid: "msg-uuid-plain" } }); @@ -569,7 +582,10 @@ describe("send", () => { }); it("normalizes effect names and uses private-api for effects", async () => { - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(true); + mockBlueBubblesPrivateApiStatusOnce( + privateApiStatusMock, + BLUE_BUBBLES_PRIVATE_API_STATUS.enabled, + ); mockResolvedHandleTarget(); mockSendResponse({ data: { guid: "msg-uuid-125" } }); @@ -589,6 +605,8 @@ describe("send", () => { }); it("warns and downgrades private-api features when status is unknown", async () => { + const runtimeLog = vi.fn(); + setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); mockResolvedHandleTarget(); mockSendResponse({ data: { guid: "msg-uuid-unknown" } }); @@ -602,8 +620,9 @@ describe("send", () => { }); expect(result.messageId).toBe("msg-uuid-unknown"); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0]?.[0]).toContain("Private API status unknown"); + expect(runtimeLog).toHaveBeenCalledTimes(1); + expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown"); + expect(warnSpy).not.toHaveBeenCalled(); const sendCall = mockFetch.mock.calls[1]; const body = JSON.parse(sendCall[1].body); @@ -612,6 +631,7 @@ describe("send", () => { expect(body.partIndex).toBeUndefined(); expect(body.effectId).toBeUndefined(); } finally { + clearBlueBubblesRuntime(); warnSpy.mockRestore(); } }); diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 1530d1702c2..4719fb416f8 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -2,7 +2,11 @@ import crypto from "node:crypto"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { stripMarkdown } from "openclaw/plugin-sdk"; import { resolveBlueBubblesAccount } from "./accounts.js"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { + getCachedBlueBubblesPrivateApiStatus, + isBlueBubblesPrivateApiStatusEnabled, +} from "./probe.js"; +import { warnBlueBubbles } from "./runtime.js"; import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js"; import { @@ -71,6 +75,38 @@ function resolveEffectId(raw?: string): string | undefined { return raw; } +type PrivateApiDecision = { + canUsePrivateApi: boolean; + throwEffectDisabledError: boolean; + warningMessage?: string; +}; + +function resolvePrivateApiDecision(params: { + privateApiStatus: boolean | null; + wantsReplyThread: boolean; + wantsEffect: boolean; +}): PrivateApiDecision { + const { privateApiStatus, wantsReplyThread, wantsEffect } = params; + const needsPrivateApi = wantsReplyThread || wantsEffect; + const canUsePrivateApi = + needsPrivateApi && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus); + const throwEffectDisabledError = wantsEffect && privateApiStatus === false; + if (!needsPrivateApi || privateApiStatus !== null) { + return { canUsePrivateApi, throwEffectDisabledError }; + } + const requested = [ + wantsReplyThread ? "reply threading" : null, + wantsEffect ? "message effects" : null, + ] + .filter(Boolean) + .join(" + "); + return { + canUsePrivateApi, + throwEffectDisabledError, + warningMessage: `Private API status unknown; sending without ${requested}. Run a status probe to restore private-api features.`, + }; +} + type BlueBubblesChatRecord = Record; function extractChatGuid(chat: BlueBubblesChatRecord): string | null { @@ -372,41 +408,36 @@ export async function sendMessageBlueBubbles( const effectId = resolveEffectId(opts.effectId); const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim()); const wantsEffect = Boolean(effectId); - const needsPrivateApi = wantsReplyThread || wantsEffect; - const canUsePrivateApi = needsPrivateApi && privateApiStatus === true; - if (wantsEffect && privateApiStatus === false) { + const privateApiDecision = resolvePrivateApiDecision({ + privateApiStatus, + wantsReplyThread, + wantsEffect, + }); + if (privateApiDecision.throwEffectDisabledError) { throw new Error( "BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.", ); } - if (needsPrivateApi && privateApiStatus === null) { - const requested = [ - wantsReplyThread ? "reply threading" : null, - wantsEffect ? "message effects" : null, - ] - .filter(Boolean) - .join(" + "); - console.warn( - `[bluebubbles] Private API status unknown; sending without ${requested}. Run a status probe to restore private-api features.`, - ); + if (privateApiDecision.warningMessage) { + warnBlueBubbles(privateApiDecision.warningMessage); } const payload: Record = { chatGuid, tempGuid: crypto.randomUUID(), message: strippedText, }; - if (canUsePrivateApi) { + if (privateApiDecision.canUsePrivateApi) { payload.method = "private-api"; } // Add reply threading support - if (wantsReplyThread && canUsePrivateApi) { + if (wantsReplyThread && privateApiDecision.canUsePrivateApi) { payload.selectedMessageGuid = opts.replyToMessageGuid; payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0; } // Add message effects support - if (effectId && canUsePrivateApi) { + if (effectId && privateApiDecision.canUsePrivateApi) { payload.effectId = effectId; } diff --git a/extensions/bluebubbles/src/test-harness.ts b/extensions/bluebubbles/src/test-harness.ts index 627b04197ba..7c6938a9681 100644 --- a/extensions/bluebubbles/src/test-harness.ts +++ b/extensions/bluebubbles/src/test-harness.ts @@ -1,6 +1,31 @@ import type { Mock } from "vitest"; import { afterEach, beforeEach, vi } from "vitest"; +export const BLUE_BUBBLES_PRIVATE_API_STATUS = { + enabled: true as const, + disabled: false as const, + unknown: null as const, +}; + +type BlueBubblesPrivateApiStatusMock = { + mockReturnValue: (value: boolean | null) => unknown; + mockReturnValueOnce: (value: boolean | null) => unknown; +}; + +export function mockBlueBubblesPrivateApiStatus( + mock: Pick, + value: boolean | null, +) { + mock.mockReturnValue(value); +} + +export function mockBlueBubblesPrivateApiStatusOnce( + mock: Pick, + value: boolean | null, +) { + mock.mockReturnValueOnce(value); +} + export function resolveBlueBubblesAccountFromConfig(params: { cfg?: { channels?: { bluebubbles?: Record } }; accountId?: string; @@ -26,7 +51,9 @@ type BlueBubblesProbeMockModule = { export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule { return { - getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null), + getCachedBlueBubblesPrivateApiStatus: vi + .fn() + .mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown), }; } @@ -41,7 +68,7 @@ export function installBlueBubblesFetchTestHooks(params: { vi.stubGlobal("fetch", params.mockFetch); params.mockFetch.mockReset(); params.privateApiStatusMock.mockReset(); - params.privateApiStatusMock.mockReturnValue(null); + params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown); }); afterEach(() => { From 8e0096561822a963a90f210c903997da3077bae7 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 22 Feb 2026 16:39:02 +0530 Subject: [PATCH 11/81] test: use real SubsystemLogger in directive-tags test --- src/gateway/server-methods/chat.directive-tags.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 9b8e0a2d5c7..4c760cbd37c 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; import type { GatewayRequestContext } from "./types.js"; const mockState = vi.hoisted(() => ({ @@ -108,10 +109,7 @@ function createChatContext(): Pick< removeChatRun: vi.fn(), dedupe: new Map(), registerToolEventRecipient: vi.fn(), - logGateway: { - warn: vi.fn(), - debug: vi.fn(), - } as GatewayRequestContext["logGateway"], + logGateway: createSubsystemLogger("gateway/server-methods/chat.directive-tags.test"), }; } From fc60f4923afd98bd3fbe3e9c9f25c7662273355e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:16:17 +0000 Subject: [PATCH 12/81] refactor(auth-choice): unify api-key resolution flows --- src/commands/auth-choice.apply-helpers.ts | 153 +++ .../auth-choice.apply.api-providers.ts | 1066 ++++++----------- src/commands/auth-choice.apply.huggingface.ts | 68 +- src/commands/auth-choice.apply.minimax.ts | 151 ++- 4 files changed, 622 insertions(+), 816 deletions(-) diff --git a/src/commands/auth-choice.apply-helpers.ts b/src/commands/auth-choice.apply-helpers.ts index 8a10d830eec..8e7e0853567 100644 --- a/src/commands/auth-choice.apply-helpers.ts +++ b/src/commands/auth-choice.apply-helpers.ts @@ -1,4 +1,8 @@ +import { resolveEnvApiKey } from "../agents/model-auth.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { formatApiKeyPreview } from "./auth-choice.api-key.js"; import type { ApplyAuthChoiceParams } from "./auth-choice.apply.js"; +import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; export function createAuthChoiceAgentModelNoter( params: ApplyAuthChoiceParams, @@ -13,3 +17,152 @@ export function createAuthChoiceAgentModelNoter( ); }; } + +export interface ApplyAuthChoiceModelState { + config: ApplyAuthChoiceParams["config"]; + agentModelOverride: string | undefined; +} + +export function createAuthChoiceModelStateBridge(bindings: { + getConfig: () => ApplyAuthChoiceParams["config"]; + setConfig: (config: ApplyAuthChoiceParams["config"]) => void; + getAgentModelOverride: () => string | undefined; + setAgentModelOverride: (model: string | undefined) => void; +}): ApplyAuthChoiceModelState { + return { + get config() { + return bindings.getConfig(); + }, + set config(config) { + bindings.setConfig(config); + }, + get agentModelOverride() { + return bindings.getAgentModelOverride(); + }, + set agentModelOverride(model) { + bindings.setAgentModelOverride(model); + }, + }; +} + +export function createAuthChoiceDefaultModelApplier( + params: ApplyAuthChoiceParams, + state: ApplyAuthChoiceModelState, +): ( + options: Omit< + Parameters[0], + "config" | "setDefaultModel" | "noteAgentModel" | "prompter" + >, +) => Promise { + const noteAgentModel = createAuthChoiceAgentModelNoter(params); + + return async (options) => { + const applied = await applyDefaultModelChoice({ + config: state.config, + setDefaultModel: params.setDefaultModel, + noteAgentModel, + prompter: params.prompter, + ...options, + }); + state.config = applied.config; + state.agentModelOverride = applied.agentModelOverride ?? state.agentModelOverride; + }; +} + +export function normalizeTokenProviderInput( + tokenProvider: string | null | undefined, +): string | undefined { + const normalized = String(tokenProvider ?? "") + .trim() + .toLowerCase(); + return normalized || undefined; +} + +export async function maybeApplyApiKeyFromOption(params: { + token: string | undefined; + tokenProvider: string | undefined; + expectedProviders: string[]; + normalize: (value: string) => string; + setCredential: (apiKey: string) => Promise; +}): Promise { + const tokenProvider = normalizeTokenProviderInput(params.tokenProvider); + const expectedProviders = params.expectedProviders + .map((provider) => normalizeTokenProviderInput(provider)) + .filter((provider): provider is string => Boolean(provider)); + if (!params.token || !tokenProvider || !expectedProviders.includes(tokenProvider)) { + return undefined; + } + const apiKey = params.normalize(params.token); + await params.setCredential(apiKey); + return apiKey; +} + +export async function ensureApiKeyFromOptionEnvOrPrompt(params: { + token: string | undefined; + tokenProvider: string | undefined; + expectedProviders: string[]; + provider: string; + envLabel: string; + promptMessage: string; + normalize: (value: string) => string; + validate: (value: string) => string | undefined; + prompter: WizardPrompter; + setCredential: (apiKey: string) => Promise; + noteMessage?: string; + noteTitle?: string; +}): Promise { + const optionApiKey = await maybeApplyApiKeyFromOption({ + token: params.token, + tokenProvider: params.tokenProvider, + expectedProviders: params.expectedProviders, + normalize: params.normalize, + setCredential: params.setCredential, + }); + if (optionApiKey) { + return optionApiKey; + } + + if (params.noteMessage) { + await params.prompter.note(params.noteMessage, params.noteTitle); + } + + return await ensureApiKeyFromEnvOrPrompt({ + provider: params.provider, + envLabel: params.envLabel, + promptMessage: params.promptMessage, + normalize: params.normalize, + validate: params.validate, + prompter: params.prompter, + setCredential: params.setCredential, + }); +} + +export async function ensureApiKeyFromEnvOrPrompt(params: { + provider: string; + envLabel: string; + promptMessage: string; + normalize: (value: string) => string; + validate: (value: string) => string | undefined; + prompter: WizardPrompter; + setCredential: (apiKey: string) => Promise; +}): Promise { + const envKey = resolveEnvApiKey(params.provider); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing ${params.envLabel} (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await params.setCredential(envKey.apiKey); + return envKey.apiKey; + } + } + + const key = await params.prompter.text({ + message: params.promptMessage, + validate: params.validate, + }); + const apiKey = params.normalize(String(key ?? "")); + await params.setCredential(apiKey); + return apiKey; +} diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index dd574b988fd..430e32650a1 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -5,11 +5,16 @@ import { normalizeApiKeyInput, validateApiKeyInput, } from "./auth-choice.api-key.js"; -import { createAuthChoiceAgentModelNoter } from "./auth-choice.apply-helpers.js"; +import { + createAuthChoiceAgentModelNoter, + createAuthChoiceDefaultModelApplier, + createAuthChoiceModelStateBridge, + ensureApiKeyFromOptionEnvOrPrompt, + normalizeTokenProviderInput, +} from "./auth-choice.apply-helpers.js"; import { applyAuthChoiceHuggingface } from "./auth-choice.apply.huggingface.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyAuthChoiceOpenRouter } from "./auth-choice.apply.openrouter.js"; -import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; import { applyGoogleGeminiModelDefault, GOOGLE_GEMINI_DEFAULT_MODEL, @@ -67,86 +72,300 @@ import { setZaiApiKey, ZAI_DEFAULT_MODEL_REF, } from "./onboard-auth.js"; +import type { AuthChoice } from "./onboard-types.js"; import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js"; import { detectZaiEndpoint } from "./zai-endpoint-detect.js"; +const API_KEY_TOKEN_PROVIDER_AUTH_CHOICE: Record = { + openrouter: "openrouter-api-key", + litellm: "litellm-api-key", + "vercel-ai-gateway": "ai-gateway-api-key", + "cloudflare-ai-gateway": "cloudflare-ai-gateway-api-key", + moonshot: "moonshot-api-key", + "kimi-code": "kimi-code-api-key", + "kimi-coding": "kimi-code-api-key", + google: "gemini-api-key", + zai: "zai-api-key", + xiaomi: "xiaomi-api-key", + synthetic: "synthetic-api-key", + venice: "venice-api-key", + together: "together-api-key", + huggingface: "huggingface-api-key", + opencode: "opencode-zen", + qianfan: "qianfan-api-key", +}; + +const ZAI_AUTH_CHOICE_ENDPOINT: Partial< + Record +> = { + "zai-coding-global": "coding-global", + "zai-coding-cn": "coding-cn", + "zai-global": "global", + "zai-cn": "cn", +}; + +type ApiKeyProviderConfigApplier = ( + config: ApplyAuthChoiceParams["config"], +) => ApplyAuthChoiceParams["config"]; + +type SimpleApiKeyProviderFlow = { + provider: Parameters[0]["provider"]; + profileId: string; + expectedProviders: string[]; + envLabel: string; + promptMessage: string; + setCredential: (apiKey: string, agentDir?: string) => void | Promise; + defaultModel: string; + applyDefaultConfig: ApiKeyProviderConfigApplier; + applyProviderConfig: ApiKeyProviderConfigApplier; + tokenProvider?: string; + normalize?: (value: string) => string; + validate?: (value: string) => string | undefined; + noteDefault?: string; + noteMessage?: string; + noteTitle?: string; +}; + +const SIMPLE_API_KEY_PROVIDER_FLOWS: Partial> = { + "ai-gateway-api-key": { + provider: "vercel-ai-gateway", + profileId: "vercel-ai-gateway:default", + expectedProviders: ["vercel-ai-gateway"], + envLabel: "AI_GATEWAY_API_KEY", + promptMessage: "Enter Vercel AI Gateway API key", + setCredential: setVercelAiGatewayApiKey, + defaultModel: VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, + applyDefaultConfig: applyVercelAiGatewayConfig, + applyProviderConfig: applyVercelAiGatewayProviderConfig, + noteDefault: VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, + }, + "moonshot-api-key": { + provider: "moonshot", + profileId: "moonshot:default", + expectedProviders: ["moonshot"], + envLabel: "MOONSHOT_API_KEY", + promptMessage: "Enter Moonshot API key", + setCredential: setMoonshotApiKey, + defaultModel: MOONSHOT_DEFAULT_MODEL_REF, + applyDefaultConfig: applyMoonshotConfig, + applyProviderConfig: applyMoonshotProviderConfig, + }, + "moonshot-api-key-cn": { + provider: "moonshot", + profileId: "moonshot:default", + expectedProviders: ["moonshot"], + envLabel: "MOONSHOT_API_KEY", + promptMessage: "Enter Moonshot API key (.cn)", + setCredential: setMoonshotApiKey, + defaultModel: MOONSHOT_DEFAULT_MODEL_REF, + applyDefaultConfig: applyMoonshotConfigCn, + applyProviderConfig: applyMoonshotProviderConfigCn, + }, + "kimi-code-api-key": { + provider: "kimi-coding", + profileId: "kimi-coding:default", + expectedProviders: ["kimi-code", "kimi-coding"], + envLabel: "KIMI_API_KEY", + promptMessage: "Enter Kimi Coding API key", + setCredential: setKimiCodingApiKey, + defaultModel: KIMI_CODING_MODEL_REF, + applyDefaultConfig: applyKimiCodeConfig, + applyProviderConfig: applyKimiCodeProviderConfig, + noteDefault: KIMI_CODING_MODEL_REF, + noteMessage: [ + "Kimi Coding uses a dedicated endpoint and API key.", + "Get your API key at: https://www.kimi.com/code/en", + ].join("\n"), + noteTitle: "Kimi Coding", + }, + "xiaomi-api-key": { + provider: "xiaomi", + profileId: "xiaomi:default", + expectedProviders: ["xiaomi"], + envLabel: "XIAOMI_API_KEY", + promptMessage: "Enter Xiaomi API key", + setCredential: setXiaomiApiKey, + defaultModel: XIAOMI_DEFAULT_MODEL_REF, + applyDefaultConfig: applyXiaomiConfig, + applyProviderConfig: applyXiaomiProviderConfig, + noteDefault: XIAOMI_DEFAULT_MODEL_REF, + }, + "venice-api-key": { + provider: "venice", + profileId: "venice:default", + expectedProviders: ["venice"], + envLabel: "VENICE_API_KEY", + promptMessage: "Enter Venice AI API key", + setCredential: setVeniceApiKey, + defaultModel: VENICE_DEFAULT_MODEL_REF, + applyDefaultConfig: applyVeniceConfig, + applyProviderConfig: applyVeniceProviderConfig, + noteDefault: VENICE_DEFAULT_MODEL_REF, + noteMessage: [ + "Venice AI provides privacy-focused inference with uncensored models.", + "Get your API key at: https://venice.ai/settings/api", + "Supports 'private' (fully private) and 'anonymized' (proxy) modes.", + ].join("\n"), + noteTitle: "Venice AI", + }, + "opencode-zen": { + provider: "opencode", + profileId: "opencode:default", + expectedProviders: ["opencode"], + envLabel: "OPENCODE_API_KEY", + promptMessage: "Enter OpenCode Zen API key", + setCredential: setOpencodeZenApiKey, + defaultModel: OPENCODE_ZEN_DEFAULT_MODEL, + applyDefaultConfig: applyOpencodeZenConfig, + applyProviderConfig: applyOpencodeZenProviderConfig, + noteDefault: OPENCODE_ZEN_DEFAULT_MODEL, + noteMessage: [ + "OpenCode Zen provides access to Claude, GPT, Gemini, and more models.", + "Get your API key at: https://opencode.ai/auth", + "OpenCode Zen bills per request. Check your OpenCode dashboard for details.", + ].join("\n"), + noteTitle: "OpenCode Zen", + }, + "together-api-key": { + provider: "together", + profileId: "together:default", + expectedProviders: ["together"], + envLabel: "TOGETHER_API_KEY", + promptMessage: "Enter Together AI API key", + setCredential: setTogetherApiKey, + defaultModel: TOGETHER_DEFAULT_MODEL_REF, + applyDefaultConfig: applyTogetherConfig, + applyProviderConfig: applyTogetherProviderConfig, + noteDefault: TOGETHER_DEFAULT_MODEL_REF, + noteMessage: [ + "Together AI provides access to leading open-source models including Llama, DeepSeek, Qwen, and more.", + "Get your API key at: https://api.together.xyz/settings/api-keys", + ].join("\n"), + noteTitle: "Together AI", + }, + "qianfan-api-key": { + provider: "qianfan", + profileId: "qianfan:default", + expectedProviders: ["qianfan"], + envLabel: "QIANFAN_API_KEY", + promptMessage: "Enter QIANFAN API key", + setCredential: setQianfanApiKey, + defaultModel: QIANFAN_DEFAULT_MODEL_REF, + applyDefaultConfig: applyQianfanConfig, + applyProviderConfig: applyQianfanProviderConfig, + noteDefault: QIANFAN_DEFAULT_MODEL_REF, + noteMessage: [ + "Get your API key at: https://console.bce.baidu.com/qianfan/ais/console/apiKey", + "API key format: bce-v3/ALTAK-...", + ].join("\n"), + noteTitle: "QIANFAN", + }, + "synthetic-api-key": { + provider: "synthetic", + profileId: "synthetic:default", + expectedProviders: ["synthetic"], + envLabel: "SYNTHETIC_API_KEY", + promptMessage: "Enter Synthetic API key", + setCredential: setSyntheticApiKey, + defaultModel: SYNTHETIC_DEFAULT_MODEL_REF, + applyDefaultConfig: applySyntheticConfig, + applyProviderConfig: applySyntheticProviderConfig, + normalize: (value) => String(value ?? "").trim(), + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }, +}; + export async function applyAuthChoiceApiProviders( params: ApplyAuthChoiceParams, ): Promise { let nextConfig = params.config; let agentModelOverride: string | undefined; const noteAgentModel = createAuthChoiceAgentModelNoter(params); + const applyProviderDefaultModel = createAuthChoiceDefaultModelApplier( + params, + createAuthChoiceModelStateBridge({ + getConfig: () => nextConfig, + setConfig: (config) => (nextConfig = config), + getAgentModelOverride: () => agentModelOverride, + setAgentModelOverride: (model) => (agentModelOverride = model), + }), + ); let authChoice = params.authChoice; - if ( - authChoice === "apiKey" && - params.opts?.tokenProvider && - params.opts.tokenProvider !== "anthropic" && - params.opts.tokenProvider !== "openai" - ) { - if (params.opts.tokenProvider === "openrouter") { - authChoice = "openrouter-api-key"; - } else if (params.opts.tokenProvider === "litellm") { - authChoice = "litellm-api-key"; - } else if (params.opts.tokenProvider === "vercel-ai-gateway") { - authChoice = "ai-gateway-api-key"; - } else if (params.opts.tokenProvider === "cloudflare-ai-gateway") { - authChoice = "cloudflare-ai-gateway-api-key"; - } else if (params.opts.tokenProvider === "moonshot") { - authChoice = "moonshot-api-key"; - } else if ( - params.opts.tokenProvider === "kimi-code" || - params.opts.tokenProvider === "kimi-coding" - ) { - authChoice = "kimi-code-api-key"; - } else if (params.opts.tokenProvider === "google") { - authChoice = "gemini-api-key"; - } else if (params.opts.tokenProvider === "zai") { - authChoice = "zai-api-key"; - } else if (params.opts.tokenProvider === "xiaomi") { - authChoice = "xiaomi-api-key"; - } else if (params.opts.tokenProvider === "synthetic") { - authChoice = "synthetic-api-key"; - } else if (params.opts.tokenProvider === "venice") { - authChoice = "venice-api-key"; - } else if (params.opts.tokenProvider === "together") { - authChoice = "together-api-key"; - } else if (params.opts.tokenProvider === "huggingface") { - authChoice = "huggingface-api-key"; - } else if (params.opts.tokenProvider === "opencode") { - authChoice = "opencode-zen"; - } else if (params.opts.tokenProvider === "qianfan") { - authChoice = "qianfan-api-key"; + const normalizedTokenProvider = normalizeTokenProviderInput(params.opts?.tokenProvider); + if (authChoice === "apiKey" && params.opts?.tokenProvider) { + if (normalizedTokenProvider !== "anthropic" && normalizedTokenProvider !== "openai") { + authChoice = API_KEY_TOKEN_PROVIDER_AUTH_CHOICE[normalizedTokenProvider ?? ""] ?? authChoice; } } - async function ensureMoonshotApiKeyCredential(promptMessage: string): Promise { - let hasCredential = false; + async function applyApiKeyProviderWithDefaultModel({ + provider, + profileId, + expectedProviders, + envLabel, + promptMessage, + setCredential, + defaultModel, + applyDefaultConfig, + applyProviderConfig, + noteMessage, + noteTitle, + tokenProvider = normalizedTokenProvider, + normalize = normalizeApiKeyInput, + validate = validateApiKeyInput, + noteDefault = defaultModel, + }: { + provider: Parameters[0]["provider"]; + profileId: string; + expectedProviders: string[]; + envLabel: string; + promptMessage: string; + setCredential: (apiKey: string) => void | Promise; + defaultModel: string; + applyDefaultConfig: ( + config: ApplyAuthChoiceParams["config"], + ) => ApplyAuthChoiceParams["config"]; + applyProviderConfig: ( + config: ApplyAuthChoiceParams["config"], + ) => ApplyAuthChoiceParams["config"]; + noteMessage?: string; + noteTitle?: string; + tokenProvider?: string; + normalize?: (value: string) => string; + validate?: (value: string) => string | undefined; + noteDefault?: string; + }): Promise { + await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.token, + provider, + tokenProvider, + expectedProviders, + envLabel, + promptMessage, + setCredential: async (apiKey) => { + await setCredential(apiKey); + }, + noteMessage, + noteTitle, + normalize, + validate, + prompter: params.prompter, + }); - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "moonshot") { - await setMoonshotApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider, + mode: "api_key", + }); + await applyProviderDefaultModel({ + defaultModel, + applyDefaultConfig, + applyProviderConfig, + noteDefault, + }); - const envKey = resolveEnvApiKey("moonshot"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing MOONSHOT_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setMoonshotApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - - if (!hasCredential) { - const key = await params.prompter.text({ - message: promptMessage, - validate: validateApiKeyInput, - }); - await setMoonshotApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } + return { config: nextConfig, agentModelOverride }; } if (authChoice === "openrouter-api-key") { @@ -159,41 +378,30 @@ export async function applyAuthChoiceApiProviders( const existingProfileId = profileOrder.find((profileId) => Boolean(store.profiles[profileId])); const existingCred = existingProfileId ? store.profiles[existingProfileId] : undefined; let profileId = "litellm:default"; - let hasCredential = false; - - if (existingProfileId && existingCred?.type === "api_key") { + let hasCredential = Boolean(existingProfileId && existingCred?.type === "api_key"); + if (hasCredential && existingProfileId) { profileId = existingProfileId; - hasCredential = true; - } - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "litellm") { - await setLitellmApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; } + if (!hasCredential) { - await params.prompter.note( - "LiteLLM provides a unified API to 100+ LLM providers.\nGet your API key from your LiteLLM proxy or https://litellm.ai\nDefault proxy runs on http://localhost:4000", - "LiteLLM", - ); - const envKey = resolveEnvApiKey("litellm"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing LITELLM_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setLitellmApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter LiteLLM API key", - validate: validateApiKeyInput, - }); - await setLitellmApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - hasCredential = true; - } + await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.token, + tokenProvider: normalizedTokenProvider, + expectedProviders: ["litellm"], + provider: "litellm", + envLabel: "LITELLM_API_KEY", + promptMessage: "Enter LiteLLM API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey) => setLitellmApiKey(apiKey, params.agentDir), + noteMessage: + "LiteLLM provides a unified API to 100+ LLM providers.\nGet your API key from your LiteLLM proxy or https://litellm.ai\nDefault proxy runs on http://localhost:4000", + noteTitle: "LiteLLM", + }); + hasCredential = true; } + if (hasCredential) { nextConfig = applyAuthProfileConfig(nextConfig, { profileId, @@ -201,75 +409,38 @@ export async function applyAuthChoiceApiProviders( mode: "api_key", }); } - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, + await applyProviderDefaultModel({ defaultModel: LITELLM_DEFAULT_MODEL_REF, applyDefaultConfig: applyLitellmConfig, applyProviderConfig: applyLitellmProviderConfig, noteDefault: LITELLM_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; return { config: nextConfig, agentModelOverride }; } - if (authChoice === "ai-gateway-api-key") { - let hasCredential = false; - - if ( - !hasCredential && - params.opts?.token && - params.opts?.tokenProvider === "vercel-ai-gateway" - ) { - await setVercelAiGatewayApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - const envKey = resolveEnvApiKey("vercel-ai-gateway"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing AI_GATEWAY_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setVercelAiGatewayApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Vercel AI Gateway API key", - validate: validateApiKeyInput, - }); - await setVercelAiGatewayApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "vercel-ai-gateway:default", - provider: "vercel-ai-gateway", - mode: "api_key", + const simpleApiKeyProviderFlow = SIMPLE_API_KEY_PROVIDER_FLOWS[authChoice]; + if (simpleApiKeyProviderFlow) { + return await applyApiKeyProviderWithDefaultModel({ + provider: simpleApiKeyProviderFlow.provider, + profileId: simpleApiKeyProviderFlow.profileId, + expectedProviders: simpleApiKeyProviderFlow.expectedProviders, + envLabel: simpleApiKeyProviderFlow.envLabel, + promptMessage: simpleApiKeyProviderFlow.promptMessage, + setCredential: async (apiKey) => + simpleApiKeyProviderFlow.setCredential(apiKey, params.agentDir), + defaultModel: simpleApiKeyProviderFlow.defaultModel, + applyDefaultConfig: simpleApiKeyProviderFlow.applyDefaultConfig, + applyProviderConfig: simpleApiKeyProviderFlow.applyProviderConfig, + noteDefault: simpleApiKeyProviderFlow.noteDefault, + noteMessage: simpleApiKeyProviderFlow.noteMessage, + noteTitle: simpleApiKeyProviderFlow.noteTitle, + tokenProvider: simpleApiKeyProviderFlow.tokenProvider, + normalize: simpleApiKeyProviderFlow.normalize, + validate: simpleApiKeyProviderFlow.validate, }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, - applyDefaultConfig: applyVercelAiGatewayConfig, - applyProviderConfig: applyVercelAiGatewayProviderConfig, - noteDefault: VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; } if (authChoice === "cloudflare-ai-gateway-api-key") { - let hasCredential = false; let accountId = params.opts?.cloudflareAiGatewayAccountId?.trim() ?? ""; let gatewayId = params.opts?.cloudflareAiGatewayGatewayId?.trim() ?? ""; @@ -291,215 +462,73 @@ export async function applyAuthChoiceApiProviders( }; const optsApiKey = normalizeApiKeyInput(params.opts?.cloudflareAiGatewayApiKey ?? ""); - if (!hasCredential && accountId && gatewayId && optsApiKey) { - await setCloudflareAiGatewayConfig(accountId, gatewayId, optsApiKey, params.agentDir); - hasCredential = true; + let resolvedApiKey = ""; + if (accountId && gatewayId && optsApiKey) { + resolvedApiKey = optsApiKey; } const envKey = resolveEnvApiKey("cloudflare-ai-gateway"); - if (!hasCredential && envKey) { + if (!resolvedApiKey && envKey) { const useExisting = await params.prompter.confirm({ message: `Use existing CLOUDFLARE_AI_GATEWAY_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, initialValue: true, }); if (useExisting) { await ensureAccountGateway(); - await setCloudflareAiGatewayConfig( - accountId, - gatewayId, - normalizeApiKeyInput(envKey.apiKey), - params.agentDir, - ); - hasCredential = true; + resolvedApiKey = normalizeApiKeyInput(envKey.apiKey); } } - if (!hasCredential && optsApiKey) { + if (!resolvedApiKey && optsApiKey) { await ensureAccountGateway(); - await setCloudflareAiGatewayConfig(accountId, gatewayId, optsApiKey, params.agentDir); - hasCredential = true; + resolvedApiKey = optsApiKey; } - if (!hasCredential) { + if (!resolvedApiKey) { await ensureAccountGateway(); const key = await params.prompter.text({ message: "Enter Cloudflare AI Gateway API key", validate: validateApiKeyInput, }); - await setCloudflareAiGatewayConfig( - accountId, - gatewayId, - normalizeApiKeyInput(String(key ?? "")), - params.agentDir, - ); - hasCredential = true; + resolvedApiKey = normalizeApiKeyInput(String(key ?? "")); } - if (hasCredential) { - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "cloudflare-ai-gateway:default", - provider: "cloudflare-ai-gateway", - mode: "api_key", - }); - } - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, - applyDefaultConfig: (cfg) => - applyCloudflareAiGatewayConfig(cfg, { - accountId: accountId || params.opts?.cloudflareAiGatewayAccountId, - gatewayId: gatewayId || params.opts?.cloudflareAiGatewayGatewayId, - }), - applyProviderConfig: (cfg) => - applyCloudflareAiGatewayProviderConfig(cfg, { - accountId: accountId || params.opts?.cloudflareAiGatewayAccountId, - gatewayId: gatewayId || params.opts?.cloudflareAiGatewayGatewayId, - }), - noteDefault: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - - if (authChoice === "moonshot-api-key") { - await ensureMoonshotApiKeyCredential("Enter Moonshot API key"); + await setCloudflareAiGatewayConfig(accountId, gatewayId, resolvedApiKey, params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "moonshot:default", - provider: "moonshot", + profileId: "cloudflare-ai-gateway:default", + provider: "cloudflare-ai-gateway", mode: "api_key", }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: MOONSHOT_DEFAULT_MODEL_REF, - applyDefaultConfig: applyMoonshotConfig, - applyProviderConfig: applyMoonshotProviderConfig, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - - if (authChoice === "moonshot-api-key-cn") { - await ensureMoonshotApiKeyCredential("Enter Moonshot API key (.cn)"); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "moonshot:default", - provider: "moonshot", - mode: "api_key", + await applyProviderDefaultModel({ + defaultModel: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, + applyDefaultConfig: (cfg) => + applyCloudflareAiGatewayConfig(cfg, { + accountId: accountId || params.opts?.cloudflareAiGatewayAccountId, + gatewayId: gatewayId || params.opts?.cloudflareAiGatewayGatewayId, + }), + applyProviderConfig: (cfg) => + applyCloudflareAiGatewayProviderConfig(cfg, { + accountId: accountId || params.opts?.cloudflareAiGatewayAccountId, + gatewayId: gatewayId || params.opts?.cloudflareAiGatewayGatewayId, + }), + noteDefault: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: MOONSHOT_DEFAULT_MODEL_REF, - applyDefaultConfig: applyMoonshotConfigCn, - applyProviderConfig: applyMoonshotProviderConfigCn, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - - if (authChoice === "kimi-code-api-key") { - let hasCredential = false; - const tokenProvider = params.opts?.tokenProvider?.trim().toLowerCase(); - if ( - !hasCredential && - params.opts?.token && - (tokenProvider === "kimi-code" || tokenProvider === "kimi-coding") - ) { - await setKimiCodingApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - if (!hasCredential) { - await params.prompter.note( - [ - "Kimi Coding uses a dedicated endpoint and API key.", - "Get your API key at: https://www.kimi.com/code/en", - ].join("\n"), - "Kimi Coding", - ); - } - const envKey = resolveEnvApiKey("kimi-coding"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing KIMI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setKimiCodingApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Kimi Coding API key", - validate: validateApiKeyInput, - }); - await setKimiCodingApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "kimi-coding:default", - provider: "kimi-coding", - mode: "api_key", - }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: KIMI_CODING_MODEL_REF, - applyDefaultConfig: applyKimiCodeConfig, - applyProviderConfig: applyKimiCodeProviderConfig, - noteDefault: KIMI_CODING_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } return { config: nextConfig, agentModelOverride }; } if (authChoice === "gemini-api-key") { - let hasCredential = false; - - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "google") { - await setGeminiApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - const envKey = resolveEnvApiKey("google"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing GEMINI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setGeminiApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Gemini API key", - validate: validateApiKeyInput, - }); - await setGeminiApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } + await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.token, + provider: "google", + tokenProvider: normalizedTokenProvider, + expectedProviders: ["google"], + envLabel: "GEMINI_API_KEY", + promptMessage: "Enter Gemini API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey) => setGeminiApiKey(apiKey, params.agentDir), + }); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "google:default", provider: "google", @@ -528,47 +557,20 @@ export async function applyAuthChoiceApiProviders( authChoice === "zai-global" || authChoice === "zai-cn" ) { - let endpoint: "global" | "cn" | "coding-global" | "coding-cn" | undefined; - if (authChoice === "zai-coding-global") { - endpoint = "coding-global"; - } else if (authChoice === "zai-coding-cn") { - endpoint = "coding-cn"; - } else if (authChoice === "zai-global") { - endpoint = "global"; - } else if (authChoice === "zai-cn") { - endpoint = "cn"; - } + let endpoint = ZAI_AUTH_CHOICE_ENDPOINT[authChoice]; - // Input API key - let hasCredential = false; - let apiKey = ""; - - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "zai") { - apiKey = normalizeApiKeyInput(params.opts.token); - await setZaiApiKey(apiKey, params.agentDir); - hasCredential = true; - } - - const envKey = resolveEnvApiKey("zai"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing ZAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - apiKey = envKey.apiKey; - await setZaiApiKey(apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Z.AI API key", - validate: validateApiKeyInput, - }); - apiKey = normalizeApiKeyInput(String(key ?? "")); - await setZaiApiKey(apiKey, params.agentDir); - } + const apiKey = await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.token, + provider: "zai", + tokenProvider: normalizedTokenProvider, + expectedProviders: ["zai"], + envLabel: "ZAI_API_KEY", + promptMessage: "Enter Z.AI API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey) => setZaiApiKey(apiKey, params.agentDir), + }); // zai-api-key: auto-detect endpoint + choose a working default model. let modelIdOverride: string | undefined; @@ -615,9 +617,7 @@ export async function applyAuthChoiceApiProviders( }); const defaultModel = modelIdOverride ? `zai/${modelIdOverride}` : ZAI_DEFAULT_MODEL_REF; - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, + await applyProviderDefaultModel({ defaultModel, applyDefaultConfig: (config) => applyZaiConfig(config, { @@ -630,328 +630,14 @@ export async function applyAuthChoiceApiProviders( ...(modelIdOverride ? { modelId: modelIdOverride } : {}), }), noteDefault: defaultModel, - noteAgentModel, - prompter: params.prompter, }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; return { config: nextConfig, agentModelOverride }; } - if (authChoice === "xiaomi-api-key") { - let hasCredential = false; - - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "xiaomi") { - await setXiaomiApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - const envKey = resolveEnvApiKey("xiaomi"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing XIAOMI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setXiaomiApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Xiaomi API key", - validate: validateApiKeyInput, - }); - await setXiaomiApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "xiaomi:default", - provider: "xiaomi", - mode: "api_key", - }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: XIAOMI_DEFAULT_MODEL_REF, - applyDefaultConfig: applyXiaomiConfig, - applyProviderConfig: applyXiaomiProviderConfig, - noteDefault: XIAOMI_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - - if (authChoice === "synthetic-api-key") { - if (params.opts?.token && params.opts?.tokenProvider === "synthetic") { - await setSyntheticApiKey(String(params.opts.token ?? "").trim(), params.agentDir); - } else { - const key = await params.prompter.text({ - message: "Enter Synthetic API key", - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - await setSyntheticApiKey(String(key ?? "").trim(), params.agentDir); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "synthetic:default", - provider: "synthetic", - mode: "api_key", - }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: SYNTHETIC_DEFAULT_MODEL_REF, - applyDefaultConfig: applySyntheticConfig, - applyProviderConfig: applySyntheticProviderConfig, - noteDefault: SYNTHETIC_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - - if (authChoice === "venice-api-key") { - let hasCredential = false; - - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "venice") { - await setVeniceApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - if (!hasCredential) { - await params.prompter.note( - [ - "Venice AI provides privacy-focused inference with uncensored models.", - "Get your API key at: https://venice.ai/settings/api", - "Supports 'private' (fully private) and 'anonymized' (proxy) modes.", - ].join("\n"), - "Venice AI", - ); - } - - const envKey = resolveEnvApiKey("venice"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing VENICE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setVeniceApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Venice AI API key", - validate: validateApiKeyInput, - }); - await setVeniceApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "venice:default", - provider: "venice", - mode: "api_key", - }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: VENICE_DEFAULT_MODEL_REF, - applyDefaultConfig: applyVeniceConfig, - applyProviderConfig: applyVeniceProviderConfig, - noteDefault: VENICE_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - - if (authChoice === "opencode-zen") { - let hasCredential = false; - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "opencode") { - await setOpencodeZenApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - if (!hasCredential) { - await params.prompter.note( - [ - "OpenCode Zen provides access to Claude, GPT, Gemini, and more models.", - "Get your API key at: https://opencode.ai/auth", - "OpenCode Zen bills per request. Check your OpenCode dashboard for details.", - ].join("\n"), - "OpenCode Zen", - ); - } - const envKey = resolveEnvApiKey("opencode"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing OPENCODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setOpencodeZenApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter OpenCode Zen API key", - validate: validateApiKeyInput, - }); - await setOpencodeZenApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "opencode:default", - provider: "opencode", - mode: "api_key", - }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: OPENCODE_ZEN_DEFAULT_MODEL, - applyDefaultConfig: applyOpencodeZenConfig, - applyProviderConfig: applyOpencodeZenProviderConfig, - noteDefault: OPENCODE_ZEN_DEFAULT_MODEL, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - - if (authChoice === "together-api-key") { - let hasCredential = false; - - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "together") { - await setTogetherApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - if (!hasCredential) { - await params.prompter.note( - [ - "Together AI provides access to leading open-source models including Llama, DeepSeek, Qwen, and more.", - "Get your API key at: https://api.together.xyz/settings/api-keys", - ].join("\n"), - "Together AI", - ); - } - - const envKey = resolveEnvApiKey("together"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing TOGETHER_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setTogetherApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Together AI API key", - validate: validateApiKeyInput, - }); - await setTogetherApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "together:default", - provider: "together", - mode: "api_key", - }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: TOGETHER_DEFAULT_MODEL_REF, - applyDefaultConfig: applyTogetherConfig, - applyProviderConfig: applyTogetherProviderConfig, - noteDefault: TOGETHER_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - if (authChoice === "huggingface-api-key") { return applyAuthChoiceHuggingface({ ...params, authChoice }); } - if (authChoice === "qianfan-api-key") { - let hasCredential = false; - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "qianfan") { - setQianfanApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - if (!hasCredential) { - await params.prompter.note( - [ - "Get your API key at: https://console.bce.baidu.com/qianfan/ais/console/apiKey", - "API key format: bce-v3/ALTAK-...", - ].join("\n"), - "QIANFAN", - ); - } - const envKey = resolveEnvApiKey("qianfan"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing QIANFAN_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - setQianfanApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter QIANFAN API key", - validate: validateApiKeyInput, - }); - setQianfanApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "qianfan:default", - provider: "qianfan", - mode: "api_key", - }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: QIANFAN_DEFAULT_MODEL_REF, - applyDefaultConfig: applyQianfanConfig, - applyProviderConfig: applyQianfanProviderConfig, - noteDefault: QIANFAN_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - return null; } diff --git a/src/commands/auth-choice.apply.huggingface.ts b/src/commands/auth-choice.apply.huggingface.ts index c1210921b7b..3f4c980879f 100644 --- a/src/commands/auth-choice.apply.huggingface.ts +++ b/src/commands/auth-choice.apply.huggingface.ts @@ -2,13 +2,11 @@ import { discoverHuggingfaceModels, isHuggingfacePolicyLocked, } from "../agents/huggingface-models.js"; -import { resolveEnvApiKey } from "../agents/model-auth.js"; +import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; import { - formatApiKeyPreview, - normalizeApiKeyInput, - validateApiKeyInput, -} from "./auth-choice.api-key.js"; -import { createAuthChoiceAgentModelNoter } from "./auth-choice.apply-helpers.js"; + createAuthChoiceAgentModelNoter, + ensureApiKeyFromOptionEnvOrPrompt, +} from "./auth-choice.apply-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; import { ensureModelAllowlistEntry } from "./model-allowlist.js"; @@ -30,47 +28,23 @@ export async function applyAuthChoiceHuggingface( let agentModelOverride: string | undefined; const noteAgentModel = createAuthChoiceAgentModelNoter(params); - let hasCredential = false; - let hfKey = ""; - - if (!hasCredential && params.opts?.token && params.opts.tokenProvider === "huggingface") { - hfKey = normalizeApiKeyInput(params.opts.token); - await setHuggingfaceApiKey(hfKey, params.agentDir); - hasCredential = true; - } - - if (!hasCredential) { - await params.prompter.note( - [ - "Hugging Face Inference Providers offer OpenAI-compatible chat completions.", - "Create a token at: https://huggingface.co/settings/tokens (fine-grained, 'Make calls to Inference Providers').", - ].join("\n"), - "Hugging Face", - ); - } - - if (!hasCredential) { - const envKey = resolveEnvApiKey("huggingface"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing Hugging Face token (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - hfKey = envKey.apiKey; - await setHuggingfaceApiKey(hfKey, params.agentDir); - hasCredential = true; - } - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Hugging Face API key (HF token)", - validate: validateApiKeyInput, - }); - hfKey = normalizeApiKeyInput(String(key ?? "")); - await setHuggingfaceApiKey(hfKey, params.agentDir); - } + const hfKey = await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.token, + tokenProvider: params.opts?.tokenProvider, + expectedProviders: ["huggingface"], + provider: "huggingface", + envLabel: "Hugging Face token", + promptMessage: "Enter Hugging Face API key (HF token)", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey) => setHuggingfaceApiKey(apiKey, params.agentDir), + noteMessage: [ + "Hugging Face Inference Providers offer OpenAI-compatible chat completions.", + "Create a token at: https://huggingface.co/settings/tokens (fine-grained, 'Make calls to Inference Providers').", + ].join("\n"), + noteTitle: "Hugging Face", + }); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "huggingface:default", provider: "huggingface", diff --git a/src/commands/auth-choice.apply.minimax.ts b/src/commands/auth-choice.apply.minimax.ts index 5afd52b21c6..d7c99ff8f0d 100644 --- a/src/commands/auth-choice.apply.minimax.ts +++ b/src/commands/auth-choice.apply.minimax.ts @@ -1,13 +1,11 @@ -import { resolveEnvApiKey } from "../agents/model-auth.js"; +import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; import { - formatApiKeyPreview, - normalizeApiKeyInput, - validateApiKeyInput, -} from "./auth-choice.api-key.js"; -import { createAuthChoiceAgentModelNoter } from "./auth-choice.apply-helpers.js"; + createAuthChoiceDefaultModelApplier, + createAuthChoiceModelStateBridge, + ensureApiKeyFromOptionEnvOrPrompt, +} from "./auth-choice.apply-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js"; -import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; import { applyAuthProfileConfig, applyMinimaxApiConfig, @@ -24,31 +22,64 @@ export async function applyAuthChoiceMiniMax( ): Promise { let nextConfig = params.config; let agentModelOverride: string | undefined; + const applyProviderDefaultModel = createAuthChoiceDefaultModelApplier( + params, + createAuthChoiceModelStateBridge({ + getConfig: () => nextConfig, + setConfig: (config) => (nextConfig = config), + getAgentModelOverride: () => agentModelOverride, + setAgentModelOverride: (model) => (agentModelOverride = model), + }), + ); const ensureMinimaxApiKey = async (opts: { profileId: string; promptMessage: string; }): Promise => { - let hasCredential = false; - const envKey = resolveEnvApiKey("minimax"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing MINIMAX_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setMinimaxApiKey(envKey.apiKey, params.agentDir, opts.profileId); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: opts.promptMessage, - validate: validateApiKeyInput, - }); - await setMinimaxApiKey(normalizeApiKeyInput(String(key)), params.agentDir, opts.profileId); - } + await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.token, + tokenProvider: params.opts?.tokenProvider, + expectedProviders: ["minimax", "minimax-cn"], + provider: "minimax", + envLabel: "MINIMAX_API_KEY", + promptMessage: opts.promptMessage, + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey) => setMinimaxApiKey(apiKey, params.agentDir, opts.profileId), + }); + }; + const applyMinimaxApiVariant = async (opts: { + profileId: string; + provider: "minimax" | "minimax-cn"; + promptMessage: string; + modelRefPrefix: "minimax" | "minimax-cn"; + modelId: string; + applyDefaultConfig: ( + config: ApplyAuthChoiceParams["config"], + modelId: string, + ) => ApplyAuthChoiceParams["config"]; + applyProviderConfig: ( + config: ApplyAuthChoiceParams["config"], + modelId: string, + ) => ApplyAuthChoiceParams["config"]; + }): Promise => { + await ensureMinimaxApiKey({ + profileId: opts.profileId, + promptMessage: opts.promptMessage, + }); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: opts.profileId, + provider: opts.provider, + mode: "api_key", + }); + const modelRef = `${opts.modelRefPrefix}/${opts.modelId}`; + await applyProviderDefaultModel({ + defaultModel: modelRef, + applyDefaultConfig: (config) => opts.applyDefaultConfig(config, opts.modelId), + applyProviderConfig: (config) => opts.applyProviderConfig(config, opts.modelId), + }); + return { config: nextConfig, agentModelOverride }; }; - const noteAgentModel = createAuthChoiceAgentModelNoter(params); if (params.authChoice === "minimax-portal") { // Let user choose between Global/CN endpoints const endpoint = await params.prompter.select({ @@ -73,74 +104,36 @@ export async function applyAuthChoiceMiniMax( params.authChoice === "minimax-api" || params.authChoice === "minimax-api-lightning" ) { - const modelId = - params.authChoice === "minimax-api-lightning" ? "MiniMax-M2.5-Lightning" : "MiniMax-M2.5"; - await ensureMinimaxApiKey({ - profileId: "minimax:default", - promptMessage: "Enter MiniMax API key", - }); - nextConfig = applyAuthProfileConfig(nextConfig, { + return await applyMinimaxApiVariant({ profileId: "minimax:default", provider: "minimax", - mode: "api_key", + promptMessage: "Enter MiniMax API key", + modelRefPrefix: "minimax", + modelId: + params.authChoice === "minimax-api-lightning" ? "MiniMax-M2.5-Lightning" : "MiniMax-M2.5", + applyDefaultConfig: applyMinimaxApiConfig, + applyProviderConfig: applyMinimaxApiProviderConfig, }); - { - const modelRef = `minimax/${modelId}`; - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: modelRef, - applyDefaultConfig: (config) => applyMinimaxApiConfig(config, modelId), - applyProviderConfig: (config) => applyMinimaxApiProviderConfig(config, modelId), - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; } if (params.authChoice === "minimax-api-key-cn") { - const modelId = "MiniMax-M2.5"; - await ensureMinimaxApiKey({ - profileId: "minimax-cn:default", - promptMessage: "Enter MiniMax China API key", - }); - nextConfig = applyAuthProfileConfig(nextConfig, { + return await applyMinimaxApiVariant({ profileId: "minimax-cn:default", provider: "minimax-cn", - mode: "api_key", + promptMessage: "Enter MiniMax China API key", + modelRefPrefix: "minimax-cn", + modelId: "MiniMax-M2.5", + applyDefaultConfig: applyMinimaxApiConfigCn, + applyProviderConfig: applyMinimaxApiProviderConfigCn, }); - { - const modelRef = `minimax-cn/${modelId}`; - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: modelRef, - applyDefaultConfig: (config) => applyMinimaxApiConfigCn(config, modelId), - applyProviderConfig: (config) => applyMinimaxApiProviderConfigCn(config, modelId), - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; } if (params.authChoice === "minimax") { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, + await applyProviderDefaultModel({ defaultModel: "lmstudio/minimax-m2.1-gs32", applyDefaultConfig: applyMinimaxConfig, applyProviderConfig: applyMinimaxProviderConfig, - noteAgentModel, - prompter: params.prompter, }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; return { config: nextConfig, agentModelOverride }; } From 11546b11771dce31bf625aaf8582b68c99cd621b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:16:23 +0000 Subject: [PATCH 13/81] test(auth-choice): expand api provider dedupe coverage --- .../auth-choice.apply-helpers.test.ts | 208 ++++++++++ .../auth-choice.apply.huggingface.test.ts | 33 ++ .../auth-choice.apply.minimax.test.ts | 160 +++++++ src/commands/auth-choice.e2e.test.ts | 391 +++++++++++++++++- 4 files changed, 790 insertions(+), 2 deletions(-) create mode 100644 src/commands/auth-choice.apply-helpers.test.ts create mode 100644 src/commands/auth-choice.apply.minimax.test.ts diff --git a/src/commands/auth-choice.apply-helpers.test.ts b/src/commands/auth-choice.apply-helpers.test.ts new file mode 100644 index 00000000000..0318a3a417a --- /dev/null +++ b/src/commands/auth-choice.apply-helpers.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { + ensureApiKeyFromOptionEnvOrPrompt, + ensureApiKeyFromEnvOrPrompt, + maybeApplyApiKeyFromOption, + normalizeTokenProviderInput, +} from "./auth-choice.apply-helpers.js"; + +const ORIGINAL_MINIMAX_API_KEY = process.env.MINIMAX_API_KEY; +const ORIGINAL_MINIMAX_OAUTH_TOKEN = process.env.MINIMAX_OAUTH_TOKEN; + +function restoreMinimaxEnv(): void { + if (ORIGINAL_MINIMAX_API_KEY === undefined) { + delete process.env.MINIMAX_API_KEY; + } else { + process.env.MINIMAX_API_KEY = ORIGINAL_MINIMAX_API_KEY; + } + if (ORIGINAL_MINIMAX_OAUTH_TOKEN === undefined) { + delete process.env.MINIMAX_OAUTH_TOKEN; + } else { + process.env.MINIMAX_OAUTH_TOKEN = ORIGINAL_MINIMAX_OAUTH_TOKEN; + } +} + +function createPrompter(params?: { + confirm?: WizardPrompter["confirm"]; + note?: WizardPrompter["note"]; + text?: WizardPrompter["text"]; +}): WizardPrompter { + return { + confirm: params?.confirm ?? (vi.fn(async () => true) as WizardPrompter["confirm"]), + note: params?.note ?? (vi.fn(async () => undefined) as WizardPrompter["note"]), + text: params?.text ?? (vi.fn(async () => "prompt-key") as WizardPrompter["text"]), + } as unknown as WizardPrompter; +} + +afterEach(() => { + restoreMinimaxEnv(); + vi.restoreAllMocks(); +}); + +describe("normalizeTokenProviderInput", () => { + it("trims and lowercases non-empty values", () => { + expect(normalizeTokenProviderInput(" HuGgInGfAcE ")).toBe("huggingface"); + expect(normalizeTokenProviderInput("")).toBeUndefined(); + }); +}); + +describe("maybeApplyApiKeyFromOption", () => { + it("stores normalized token when provider matches", async () => { + const setCredential = vi.fn(async () => undefined); + + const result = await maybeApplyApiKeyFromOption({ + token: " opt-key ", + tokenProvider: "huggingface", + expectedProviders: ["huggingface"], + normalize: (value) => value.trim(), + setCredential, + }); + + expect(result).toBe("opt-key"); + expect(setCredential).toHaveBeenCalledWith("opt-key"); + }); + + it("matches provider with whitespace/case normalization", async () => { + const setCredential = vi.fn(async () => undefined); + + const result = await maybeApplyApiKeyFromOption({ + token: " opt-key ", + tokenProvider: " HuGgInGfAcE ", + expectedProviders: ["huggingface"], + normalize: (value) => value.trim(), + setCredential, + }); + + expect(result).toBe("opt-key"); + expect(setCredential).toHaveBeenCalledWith("opt-key"); + }); + + it("skips when provider does not match", async () => { + const setCredential = vi.fn(async () => undefined); + + const result = await maybeApplyApiKeyFromOption({ + token: "opt-key", + tokenProvider: "openai", + expectedProviders: ["huggingface"], + normalize: (value) => value.trim(), + setCredential, + }); + + expect(result).toBeUndefined(); + expect(setCredential).not.toHaveBeenCalled(); + }); +}); + +describe("ensureApiKeyFromEnvOrPrompt", () => { + it("uses env credential when user confirms", async () => { + process.env.MINIMAX_API_KEY = "env-key"; + delete process.env.MINIMAX_OAUTH_TOKEN; + + const confirm = vi.fn(async () => true); + const text = vi.fn(async () => "prompt-key"); + const setCredential = vi.fn(async () => undefined); + + const result = await ensureApiKeyFromEnvOrPrompt({ + provider: "minimax", + envLabel: "MINIMAX_API_KEY", + promptMessage: "Enter key", + normalize: (value) => value.trim(), + validate: () => undefined, + prompter: createPrompter({ confirm, text }), + setCredential, + }); + + expect(result).toBe("env-key"); + expect(setCredential).toHaveBeenCalledWith("env-key"); + expect(text).not.toHaveBeenCalled(); + }); + + it("falls back to prompt when env is declined", async () => { + process.env.MINIMAX_API_KEY = "env-key"; + delete process.env.MINIMAX_OAUTH_TOKEN; + + const confirm = vi.fn(async () => false); + const text = vi.fn(async () => " prompted-key "); + const setCredential = vi.fn(async () => undefined); + + const result = await ensureApiKeyFromEnvOrPrompt({ + provider: "minimax", + envLabel: "MINIMAX_API_KEY", + promptMessage: "Enter key", + normalize: (value) => value.trim(), + validate: () => undefined, + prompter: createPrompter({ confirm, text }), + setCredential, + }); + + expect(result).toBe("prompted-key"); + expect(setCredential).toHaveBeenCalledWith("prompted-key"); + expect(text).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Enter key", + }), + ); + }); +}); + +describe("ensureApiKeyFromOptionEnvOrPrompt", () => { + it("uses opts token and skips note/env/prompt", async () => { + const confirm = vi.fn(async () => true); + const note = vi.fn(async () => undefined); + const text = vi.fn(async () => "prompt-key"); + const setCredential = vi.fn(async () => undefined); + + const result = await ensureApiKeyFromOptionEnvOrPrompt({ + token: " opts-key ", + tokenProvider: " HUGGINGFACE ", + expectedProviders: ["huggingface"], + provider: "huggingface", + envLabel: "HF_TOKEN", + promptMessage: "Enter key", + normalize: (value) => value.trim(), + validate: () => undefined, + prompter: createPrompter({ confirm, note, text }), + setCredential, + noteMessage: "Hugging Face note", + noteTitle: "Hugging Face", + }); + + expect(result).toBe("opts-key"); + expect(setCredential).toHaveBeenCalledWith("opts-key"); + expect(note).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect(text).not.toHaveBeenCalled(); + }); + + it("falls back to env flow and shows note when opts provider does not match", async () => { + delete process.env.MINIMAX_OAUTH_TOKEN; + process.env.MINIMAX_API_KEY = "env-key"; + + const confirm = vi.fn(async () => true); + const note = vi.fn(async () => undefined); + const text = vi.fn(async () => "prompt-key"); + const setCredential = vi.fn(async () => undefined); + + const result = await ensureApiKeyFromOptionEnvOrPrompt({ + token: "opts-key", + tokenProvider: "openai", + expectedProviders: ["minimax"], + provider: "minimax", + envLabel: "MINIMAX_API_KEY", + promptMessage: "Enter key", + normalize: (value) => value.trim(), + validate: () => undefined, + prompter: createPrompter({ confirm, note, text }), + setCredential, + noteMessage: "MiniMax note", + noteTitle: "MiniMax", + }); + + expect(result).toBe("env-key"); + expect(note).toHaveBeenCalledWith("MiniMax note", "MiniMax"); + expect(confirm).toHaveBeenCalled(); + expect(text).not.toHaveBeenCalled(); + expect(setCredential).toHaveBeenCalledWith("env-key"); + }); +}); diff --git a/src/commands/auth-choice.apply.huggingface.test.ts b/src/commands/auth-choice.apply.huggingface.test.ts index 7cf1ebc96d6..4090b5473fc 100644 --- a/src/commands/auth-choice.apply.huggingface.test.ts +++ b/src/commands/auth-choice.apply.huggingface.test.ts @@ -127,4 +127,37 @@ describe("applyAuthChoiceHuggingface", () => { const parsed = await readAuthProfiles(agentDir); expect(parsed.profiles?.["huggingface:default"]?.key).toBe("hf-opts-token"); }); + + it("accepts mixed-case tokenProvider from opts without prompting", async () => { + const agentDir = await setupTempState(); + delete process.env.HF_TOKEN; + delete process.env.HUGGINGFACE_HUB_TOKEN; + + const text = vi.fn().mockResolvedValue("hf-text-token"); + const select: WizardPrompter["select"] = vi.fn( + async (params) => params.options?.[0]?.value as never, + ); + const confirm = vi.fn(async () => true); + const prompter = createHuggingfacePrompter({ text, select, confirm }); + const runtime = createExitThrowingRuntime(); + + const result = await applyAuthChoiceHuggingface({ + authChoice: "huggingface-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: true, + opts: { + tokenProvider: " HuGgInGfAcE ", + token: "hf-opts-mixed", + }, + }); + + expect(result).not.toBeNull(); + expect(confirm).not.toHaveBeenCalled(); + expect(text).not.toHaveBeenCalled(); + + const parsed = await readAuthProfiles(agentDir); + expect(parsed.profiles?.["huggingface:default"]?.key).toBe("hf-opts-mixed"); + }); }); diff --git a/src/commands/auth-choice.apply.minimax.test.ts b/src/commands/auth-choice.apply.minimax.test.ts new file mode 100644 index 00000000000..ba17cd4766d --- /dev/null +++ b/src/commands/auth-choice.apply.minimax.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js"; +import { + createAuthTestLifecycle, + createExitThrowingRuntime, + createWizardPrompter, + readAuthProfilesForAgent, + setupAuthTestEnv, +} from "./test-wizard-helpers.js"; + +function createMinimaxPrompter( + params: { + text?: WizardPrompter["text"]; + confirm?: WizardPrompter["confirm"]; + select?: WizardPrompter["select"]; + } = {}, +): WizardPrompter { + return createWizardPrompter( + { + text: params.text, + confirm: params.confirm, + select: params.select, + }, + { defaultSelect: "oauth" }, + ); +} + +describe("applyAuthChoiceMiniMax", () => { + const lifecycle = createAuthTestLifecycle([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "MINIMAX_API_KEY", + "MINIMAX_OAUTH_TOKEN", + ]); + + async function setupTempState() { + const env = await setupAuthTestEnv("openclaw-minimax-"); + lifecycle.setStateDir(env.stateDir); + return env.agentDir; + } + + async function readAuthProfiles(agentDir: string) { + return await readAuthProfilesForAgent<{ + profiles?: Record; + }>(agentDir); + } + + afterEach(async () => { + await lifecycle.cleanup(); + }); + + it("returns null for unrelated authChoice", async () => { + const result = await applyAuthChoiceMiniMax({ + authChoice: "openrouter-api-key", + config: {}, + prompter: createMinimaxPrompter(), + runtime: createExitThrowingRuntime(), + setDefaultModel: true, + }); + + expect(result).toBeNull(); + }); + + it("uses opts token for minimax-api without prompt", async () => { + const agentDir = await setupTempState(); + delete process.env.MINIMAX_API_KEY; + delete process.env.MINIMAX_OAUTH_TOKEN; + + const text = vi.fn(async () => "should-not-be-used"); + const confirm = vi.fn(async () => true); + + const result = await applyAuthChoiceMiniMax({ + authChoice: "minimax-api", + config: {}, + prompter: createMinimaxPrompter({ text, confirm }), + runtime: createExitThrowingRuntime(), + setDefaultModel: true, + opts: { + tokenProvider: "minimax", + token: "mm-opts-token", + }, + }); + + expect(result).not.toBeNull(); + expect(result?.config.auth?.profiles?.["minimax:default"]).toMatchObject({ + provider: "minimax", + mode: "api_key", + }); + expect(result?.config.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.5"); + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + + const parsed = await readAuthProfiles(agentDir); + expect(parsed.profiles?.["minimax:default"]?.key).toBe("mm-opts-token"); + }); + + it("uses env token for minimax-api-key-cn when confirmed", async () => { + const agentDir = await setupTempState(); + process.env.MINIMAX_API_KEY = "mm-env-token"; + delete process.env.MINIMAX_OAUTH_TOKEN; + + const text = vi.fn(async () => "should-not-be-used"); + const confirm = vi.fn(async () => true); + + const result = await applyAuthChoiceMiniMax({ + authChoice: "minimax-api-key-cn", + config: {}, + prompter: createMinimaxPrompter({ text, confirm }), + runtime: createExitThrowingRuntime(), + setDefaultModel: true, + }); + + expect(result).not.toBeNull(); + expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({ + provider: "minimax-cn", + mode: "api_key", + }); + expect(result?.config.agents?.defaults?.model?.primary).toBe("minimax-cn/MiniMax-M2.5"); + expect(text).not.toHaveBeenCalled(); + expect(confirm).toHaveBeenCalled(); + + const parsed = await readAuthProfiles(agentDir); + expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe("mm-env-token"); + }); + + it("uses opts token for minimax-api-key-cn with trimmed/case-insensitive tokenProvider", async () => { + const agentDir = await setupTempState(); + delete process.env.MINIMAX_API_KEY; + delete process.env.MINIMAX_OAUTH_TOKEN; + + const text = vi.fn(async () => "should-not-be-used"); + const confirm = vi.fn(async () => true); + + const result = await applyAuthChoiceMiniMax({ + authChoice: "minimax-api-key-cn", + config: {}, + prompter: createMinimaxPrompter({ text, confirm }), + runtime: createExitThrowingRuntime(), + setDefaultModel: true, + opts: { + tokenProvider: " MINIMAX-CN ", + token: "mm-cn-opts-token", + }, + }); + + expect(result).not.toBeNull(); + expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({ + provider: "minimax-cn", + mode: "api_key", + }); + expect(result?.config.agents?.defaults?.model?.primary).toBe("minimax-cn/MiniMax-M2.5"); + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + + const parsed = await readAuthProfiles(agentDir); + expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe("mm-cn-opts-token"); + }); +}); diff --git a/src/commands/auth-choice.e2e.test.ts b/src/commands/auth-choice.e2e.test.ts index 0c7481a335e..d3fd20bef66 100644 --- a/src/commands/auth-choice.e2e.test.ts +++ b/src/commands/auth-choice.e2e.test.ts @@ -3,6 +3,7 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js"; +import { GOOGLE_GEMINI_DEFAULT_MODEL } from "./google-gemini-model-default.js"; import { MINIMAX_CN_API_BASE_URL, ZAI_CODING_CN_BASE_URL, @@ -19,6 +20,8 @@ import { setupAuthTestEnv, } from "./test-wizard-helpers.js"; +type DetectZaiEndpoint = typeof import("./zai-endpoint-detect.js").detectZaiEndpoint; + vi.mock("../providers/github-copilot-auth.js", () => ({ githubCopilotLoginCommand: vi.fn(async () => {}), })); @@ -35,6 +38,11 @@ vi.mock("../plugins/providers.js", () => ({ resolvePluginProviders, })); +const detectZaiEndpoint = vi.hoisted(() => vi.fn(async () => null)); +vi.mock("./zai-endpoint-detect.js", () => ({ + detectZaiEndpoint, +})); + type StoredAuthProfile = { key?: string; access?: string; @@ -57,6 +65,15 @@ describe("applyAuthChoice", () => { "LITELLM_API_KEY", "AI_GATEWAY_API_KEY", "CLOUDFLARE_AI_GATEWAY_API_KEY", + "MOONSHOT_API_KEY", + "KIMI_API_KEY", + "GEMINI_API_KEY", + "XIAOMI_API_KEY", + "VENICE_API_KEY", + "OPENCODE_API_KEY", + "TOGETHER_API_KEY", + "QIANFAN_API_KEY", + "SYNTHETIC_API_KEY", "SSH_TTY", "CHUTES_CLIENT_ID", ]); @@ -101,8 +118,10 @@ describe("applyAuthChoice", () => { afterEach(async () => { vi.unstubAllGlobals(); - resolvePluginProviders.mockClear(); - loginOpenAICodexOAuth.mockClear(); + resolvePluginProviders.mockReset(); + detectZaiEndpoint.mockReset(); + detectZaiEndpoint.mockResolvedValue(null); + loginOpenAICodexOAuth.mockReset(); loginOpenAICodexOAuth.mockResolvedValue(null); await lifecycle.cleanup(); }); @@ -319,6 +338,38 @@ describe("applyAuthChoice", () => { expect(result.config.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_GLOBAL_BASE_URL); }); + it("uses detected Z.AI endpoint without prompting for endpoint selection", async () => { + await setupTempState(); + detectZaiEndpoint.mockResolvedValueOnce({ + endpoint: "coding-global", + modelId: "glm-4.5", + baseUrl: ZAI_CODING_GLOBAL_BASE_URL, + note: "Detected coding-global endpoint", + }); + + const text = vi.fn().mockResolvedValue("zai-detected-key"); + const select = vi.fn(async () => "default"); + const { prompter, runtime } = createApiKeyPromptHarness({ + select: select as WizardPrompter["select"], + text, + }); + + const result = await applyAuthChoice({ + authChoice: "zai-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(detectZaiEndpoint).toHaveBeenCalledWith({ apiKey: "zai-detected-key" }); + expect(select).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Select Z.AI endpoint" }), + ); + expect(result.config.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_GLOBAL_BASE_URL); + expect(result.config.agents?.defaults?.model?.primary).toBe("zai/glm-4.5"); + }); + it("maps apiKey + tokenProvider=huggingface to huggingface-api-key flow", async () => { await setupTempState(); delete process.env.HF_TOKEN; @@ -349,6 +400,309 @@ describe("applyAuthChoice", () => { expect((await readAuthProfile("huggingface:default"))?.key).toBe("hf-token-provider-test"); }); + + it("maps apiKey + tokenProvider=together to together-api-key flow", async () => { + await setupTempState(); + + const text = vi.fn().mockResolvedValue("should-not-be-used"); + const confirm = vi.fn(async () => false); + const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); + + const result = await applyAuthChoice({ + authChoice: "apiKey", + config: {}, + prompter, + runtime, + setDefaultModel: true, + opts: { + tokenProvider: " ToGeThEr ", + token: "sk-together-token-provider-test", + }, + }); + + expect(result.config.auth?.profiles?.["together:default"]).toMatchObject({ + provider: "together", + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary).toMatch(/^together\/.+/); + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect((await readAuthProfile("together:default"))?.key).toBe( + "sk-together-token-provider-test", + ); + }); + + it("maps apiKey + tokenProvider=KIMI-CODING (case-insensitive) to kimi-code-api-key flow", async () => { + await setupTempState(); + + const text = vi.fn().mockResolvedValue("should-not-be-used"); + const confirm = vi.fn(async () => false); + const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); + + const result = await applyAuthChoice({ + authChoice: "apiKey", + config: {}, + prompter, + runtime, + setDefaultModel: true, + opts: { + tokenProvider: "KIMI-CODING", + token: "sk-kimi-token-provider-test", + }, + }); + + expect(result.config.auth?.profiles?.["kimi-coding:default"]).toMatchObject({ + provider: "kimi-coding", + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary).toMatch(/^kimi-coding\/.+/); + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect((await readAuthProfile("kimi-coding:default"))?.key).toBe("sk-kimi-token-provider-test"); + }); + + it("maps apiKey + tokenProvider= GOOGLE (case-insensitive/trimmed) to gemini-api-key flow", async () => { + await setupTempState(); + + const text = vi.fn().mockResolvedValue("should-not-be-used"); + const confirm = vi.fn(async () => false); + const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); + + const result = await applyAuthChoice({ + authChoice: "apiKey", + config: {}, + prompter, + runtime, + setDefaultModel: true, + opts: { + tokenProvider: " GOOGLE ", + token: "sk-gemini-token-provider-test", + }, + }); + + expect(result.config.auth?.profiles?.["google:default"]).toMatchObject({ + provider: "google", + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary).toBe(GOOGLE_GEMINI_DEFAULT_MODEL); + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect((await readAuthProfile("google:default"))?.key).toBe("sk-gemini-token-provider-test"); + }); + + it("maps apiKey + tokenProvider= LITELLM (case-insensitive/trimmed) to litellm-api-key flow", async () => { + await setupTempState(); + + const text = vi.fn().mockResolvedValue("should-not-be-used"); + const confirm = vi.fn(async () => false); + const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); + + const result = await applyAuthChoice({ + authChoice: "apiKey", + config: {}, + prompter, + runtime, + setDefaultModel: true, + opts: { + tokenProvider: " LITELLM ", + token: "sk-litellm-token-provider-test", + }, + }); + + expect(result.config.auth?.profiles?.["litellm:default"]).toMatchObject({ + provider: "litellm", + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary).toMatch(/^litellm\/.+/); + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect((await readAuthProfile("litellm:default"))?.key).toBe("sk-litellm-token-provider-test"); + }); + + it.each([ + { + authChoice: "moonshot-api-key", + tokenProvider: "moonshot", + profileId: "moonshot:default", + provider: "moonshot", + modelPrefix: "moonshot/", + }, + { + authChoice: "kimi-code-api-key", + tokenProvider: "kimi-code", + profileId: "kimi-coding:default", + provider: "kimi-coding", + modelPrefix: "kimi-coding/", + }, + { + authChoice: "xiaomi-api-key", + tokenProvider: "xiaomi", + profileId: "xiaomi:default", + provider: "xiaomi", + modelPrefix: "xiaomi/", + }, + { + authChoice: "venice-api-key", + tokenProvider: "venice", + profileId: "venice:default", + provider: "venice", + modelPrefix: "venice/", + }, + { + authChoice: "opencode-zen", + tokenProvider: "opencode", + profileId: "opencode:default", + provider: "opencode", + modelPrefix: "opencode/", + }, + { + authChoice: "together-api-key", + tokenProvider: "together", + profileId: "together:default", + provider: "together", + modelPrefix: "together/", + }, + { + authChoice: "qianfan-api-key", + tokenProvider: "qianfan", + profileId: "qianfan:default", + provider: "qianfan", + modelPrefix: "qianfan/", + }, + { + authChoice: "synthetic-api-key", + tokenProvider: "synthetic", + profileId: "synthetic:default", + provider: "synthetic", + modelPrefix: "synthetic/", + }, + ] as const)( + "uses opts token for $authChoice without prompting", + async ({ authChoice, tokenProvider, profileId, provider, modelPrefix }) => { + await setupTempState(); + + const text = vi.fn(); + const confirm = vi.fn(async () => false); + const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); + const token = `sk-${tokenProvider}-test`; + + const result = await applyAuthChoice({ + authChoice, + config: {}, + prompter, + runtime, + setDefaultModel: true, + opts: { + tokenProvider, + token, + }, + }); + + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect(result.config.auth?.profiles?.[profileId]).toMatchObject({ + provider, + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary?.startsWith(modelPrefix)).toBe(true); + expect((await readAuthProfile(profileId))?.key).toBe(token); + }, + ); + + it("uses opts token for Gemini and keeps global default model when setDefaultModel=false", async () => { + await setupTempState(); + + const text = vi.fn(); + const confirm = vi.fn(async () => false); + const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); + + const result = await applyAuthChoice({ + authChoice: "gemini-api-key", + config: { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } } }, + prompter, + runtime, + setDefaultModel: false, + opts: { + tokenProvider: "google", + token: "sk-gemini-test", + }, + }); + + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect(result.config.auth?.profiles?.["google:default"]).toMatchObject({ + provider: "google", + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary).toBe("openai/gpt-4o-mini"); + expect(result.agentModelOverride).toBe(GOOGLE_GEMINI_DEFAULT_MODEL); + expect((await readAuthProfile("google:default"))?.key).toBe("sk-gemini-test"); + }); + + it("prompts for Venice API key and shows the Venice note when no token is provided", async () => { + await setupTempState(); + process.env.VENICE_API_KEY = ""; + + const note = vi.fn(async () => {}); + const text = vi.fn(async () => "sk-venice-manual"); + const prompter = createPrompter({ note, text }); + const runtime = createExitThrowingRuntime(); + + const result = await applyAuthChoice({ + authChoice: "venice-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(note).toHaveBeenCalledWith( + expect.stringContaining("privacy-focused inference"), + "Venice AI", + ); + expect(text).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Enter Venice AI API key", + }), + ); + expect(result.config.auth?.profiles?.["venice:default"]).toMatchObject({ + provider: "venice", + mode: "api_key", + }); + expect((await readAuthProfile("venice:default"))?.key).toBe("sk-venice-manual"); + }); + + it("uses existing SYNTHETIC_API_KEY when selecting synthetic-api-key", async () => { + await setupTempState(); + process.env.SYNTHETIC_API_KEY = "sk-synthetic-env"; + + const text = vi.fn(); + const confirm = vi.fn(async () => true); + const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); + + const result = await applyAuthChoice({ + authChoice: "synthetic-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("SYNTHETIC_API_KEY"), + }), + ); + expect(text).not.toHaveBeenCalled(); + expect(result.config.auth?.profiles?.["synthetic:default"]).toMatchObject({ + provider: "synthetic", + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary).toMatch(/^synthetic\/.+/); + + expect((await readAuthProfile("synthetic:default"))?.key).toBe("sk-synthetic-env"); + }); + it("does not override the global default model when selecting xai-api-key without setDefaultModel", async () => { await setupTempState(); @@ -654,6 +1008,39 @@ describe("applyAuthChoice", () => { delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; }); + it("uses explicit Cloudflare account/gateway/api key opts without extra prompts", async () => { + await setupTempState(); + + const text = vi.fn(); + const confirm = vi.fn(async () => false); + const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); + + const result = await applyAuthChoice({ + authChoice: "cloudflare-ai-gateway-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: true, + opts: { + cloudflareAiGatewayAccountId: "acc-direct", + cloudflareAiGatewayGatewayId: "gw-direct", + cloudflareAiGatewayApiKey: "cf-direct-key", + }, + }); + + expect(confirm).not.toHaveBeenCalled(); + expect(text).not.toHaveBeenCalled(); + expect(result.config.auth?.profiles?.["cloudflare-ai-gateway:default"]).toMatchObject({ + provider: "cloudflare-ai-gateway", + mode: "api_key", + }); + expect((await readAuthProfile("cloudflare-ai-gateway:default"))?.key).toBe("cf-direct-key"); + expect((await readAuthProfile("cloudflare-ai-gateway:default"))?.metadata).toEqual({ + accountId: "acc-direct", + gatewayId: "gw-direct", + }); + }); + it("writes Chutes OAuth credentials when selecting chutes (remote/manual)", async () => { await setupTempState(); process.env.SSH_TTY = "1"; From 3700151ec07b714f179504fab6aaf430f04f7442 Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Sun, 22 Feb 2026 00:51:40 -0700 Subject: [PATCH 14/81] Channels: fail closed when Slack/Discord config is missing --- docs/channels/discord.md | 2 +- docs/channels/slack.md | 2 +- .../monitor/provider.group-policy.test.ts | 29 ++++++++++++++ src/discord/monitor/provider.ts | 37 +++++++++++++---- .../monitor/provider.group-policy.test.ts | 29 ++++++++++++++ src/slack/monitor/provider.ts | 40 +++++++++++++++---- 6 files changed, 121 insertions(+), 18 deletions(-) create mode 100644 src/discord/monitor/provider.group-policy.test.ts create mode 100644 src/slack/monitor/provider.group-policy.test.ts diff --git a/docs/channels/discord.md b/docs/channels/discord.md index d725b5c2edd..6cdd3aa410c 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -425,7 +425,7 @@ Example: } ``` - If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="open"` (with a warning in logs). + If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="allowlist"` (with a warning in logs). diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 0d0bba3cb27..13c53b02459 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -165,7 +165,7 @@ For actions/directory reads, user token can be preferred when configured. For wr Channel allowlist lives under `channels.slack.channels`. - Runtime note: if `channels.slack` is completely missing (env-only setup) and `channels.defaults.groupPolicy` is unset, runtime falls back to `groupPolicy="open"` and logs a warning. + Runtime note: if `channels.slack` is completely missing (env-only setup) and `channels.defaults.groupPolicy` is unset, runtime falls back to `groupPolicy="allowlist"` and logs a warning. Name/ID resolution: diff --git a/src/discord/monitor/provider.group-policy.test.ts b/src/discord/monitor/provider.group-policy.test.ts new file mode 100644 index 00000000000..50a3377f806 --- /dev/null +++ b/src/discord/monitor/provider.group-policy.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { __testing } from "./provider.js"; + +describe("resolveDiscordRuntimeGroupPolicy", () => { + it("fails closed when channels.discord is missing and no defaults are set", () => { + const resolved = __testing.resolveDiscordRuntimeGroupPolicy({ + providerConfigPresent: false, + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); + + it("keeps open default when channels.discord is configured", () => { + const resolved = __testing.resolveDiscordRuntimeGroupPolicy({ + providerConfigPresent: true, + }); + expect(resolved.groupPolicy).toBe("open"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); + + it("respects explicit provider policy", () => { + const resolved = __testing.resolveDiscordRuntimeGroupPolicy({ + providerConfigPresent: false, + groupPolicy: "disabled", + }); + expect(resolved.groupPolicy).toBe("disabled"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); +}); diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index ff16a262145..bfe8880098d 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -21,6 +21,7 @@ import { } from "../../config/commands.js"; import type { OpenClawConfig, ReplyToMode } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; +import type { GroupPolicy } from "../../config/types.base.js"; import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createDiscordRetryRunner } from "../../infra/retry-policy.js"; @@ -170,6 +171,25 @@ function dedupeSkillCommandsForDiscord( return deduped; } +function resolveDiscordRuntimeGroupPolicy(params: { + providerConfigPresent: boolean; + groupPolicy?: GroupPolicy; + defaultGroupPolicy?: GroupPolicy; +}): { + groupPolicy: GroupPolicy; + providerMissingFallbackApplied: boolean; +} { + const groupPolicy = + params.groupPolicy ?? + params.defaultGroupPolicy ?? + (params.providerConfigPresent ? "open" : "allowlist"); + const providerMissingFallbackApplied = + !params.providerConfigPresent && + params.groupPolicy === undefined && + params.defaultGroupPolicy === undefined; + return { groupPolicy, providerMissingFallbackApplied }; +} + async function deployDiscordCommands(params: { client: Client; runtime: RuntimeEnv; @@ -253,16 +273,16 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const dmConfig = discordCfg.dm; let guildEntries = discordCfg.guilds; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = discordCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; - if ( - discordCfg.groupPolicy === undefined && - discordCfg.guilds === undefined && - defaultGroupPolicy === undefined && - groupPolicy === "open" - ) { + const providerConfigPresent = cfg.channels?.discord !== undefined; + const { groupPolicy, providerMissingFallbackApplied } = resolveDiscordRuntimeGroupPolicy({ + providerConfigPresent, + groupPolicy: discordCfg.groupPolicy, + defaultGroupPolicy, + }); + if (providerMissingFallbackApplied) { runtime.log?.( warn( - 'discord: groupPolicy defaults to "open" when channels.discord is missing; set channels.discord.groupPolicy (or channels.defaults.groupPolicy) or add channels.discord.guilds to restrict access.', + 'discord: channels.discord is missing; defaulting groupPolicy to "allowlist" (guild messages blocked until explicitly configured).', ), ); } @@ -622,6 +642,7 @@ async function clearDiscordNativeCommands(params: { export const __testing = { createDiscordGatewayPlugin, dedupeSkillCommandsForDiscord, + resolveDiscordRuntimeGroupPolicy, resolveDiscordRestFetch, resolveThreadBindingsEnabled, }; diff --git a/src/slack/monitor/provider.group-policy.test.ts b/src/slack/monitor/provider.group-policy.test.ts new file mode 100644 index 00000000000..43bc8dfec54 --- /dev/null +++ b/src/slack/monitor/provider.group-policy.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { __testing } from "./provider.js"; + +describe("resolveSlackRuntimeGroupPolicy", () => { + it("fails closed when channels.slack is missing and no defaults are set", () => { + const resolved = __testing.resolveSlackRuntimeGroupPolicy({ + providerConfigPresent: false, + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); + + it("keeps open default when channels.slack is configured", () => { + const resolved = __testing.resolveSlackRuntimeGroupPolicy({ + providerConfigPresent: true, + }); + expect(resolved.groupPolicy).toBe("open"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); + + it("respects explicit global defaults", () => { + const resolved = __testing.resolveSlackRuntimeGroupPolicy({ + providerConfigPresent: false, + defaultGroupPolicy: "open", + }); + expect(resolved.groupPolicy).toBe("open"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); +}); diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 248728751e6..4d9d50331a9 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -11,6 +11,7 @@ import { } from "../../channels/allowlists/resolve-utils.js"; import { loadConfig } from "../../config/config.js"; import type { SessionScope } from "../../config/sessions.js"; +import type { GroupPolicy } from "../../config/types.base.js"; import { warn } from "../../globals.js"; import { installRequestBodyLimitGuard } from "../../infra/http-body.js"; import { normalizeMainKey } from "../../routing/session-key.js"; @@ -41,6 +42,25 @@ const { App, HTTPReceiver } = slackBolt; const SLACK_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; const SLACK_WEBHOOK_BODY_TIMEOUT_MS = 30_000; +function resolveSlackRuntimeGroupPolicy(params: { + providerConfigPresent: boolean; + groupPolicy?: GroupPolicy; + defaultGroupPolicy?: GroupPolicy; +}): { + groupPolicy: GroupPolicy; + providerMissingFallbackApplied: boolean; +} { + const groupPolicy = + params.groupPolicy ?? + params.defaultGroupPolicy ?? + (params.providerConfigPresent ? "open" : "allowlist"); + const providerMissingFallbackApplied = + !params.providerConfigPresent && + params.groupPolicy === undefined && + params.defaultGroupPolicy === undefined; + return { groupPolicy, providerMissingFallbackApplied }; +} + function parseApiAppIdFromAppToken(raw?: string) { const token = raw?.trim(); if (!token) { @@ -99,16 +119,16 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const groupDmChannels = dmConfig?.groupChannels; let channelsConfig = slackCfg.channels; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = slackCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; - if ( - slackCfg.groupPolicy === undefined && - slackCfg.channels === undefined && - defaultGroupPolicy === undefined && - groupPolicy === "open" - ) { + const providerConfigPresent = cfg.channels?.slack !== undefined; + const { groupPolicy, providerMissingFallbackApplied } = resolveSlackRuntimeGroupPolicy({ + providerConfigPresent, + groupPolicy: slackCfg.groupPolicy, + defaultGroupPolicy, + }); + if (providerMissingFallbackApplied) { runtime.log?.( warn( - 'slack: groupPolicy defaults to "open" when channels.slack is missing; set channels.slack.groupPolicy (or channels.defaults.groupPolicy) or add channels.slack.channels to restrict access.', + 'slack: channels.slack is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', ), ); } @@ -363,3 +383,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { await app.stop().catch(() => undefined); } } + +export const __testing = { + resolveSlackRuntimeGroupPolicy, +}; From 78c3c2a542964dceeae01e9cecc4b47e1772f615 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 12:18:13 +0100 Subject: [PATCH 15/81] fix: stabilize flaky tests and sanitize directive-only chat tags --- src/agents/subagent-announce.format.test.ts | 1 + src/cron/service.issue-regressions.test.ts | 13 +++++- .../chat.directive-tags.test.ts | 34 ++++----------- .../chat.inject.parentid.e2e.test.ts | 37 ++++++---------- .../server-methods/chat.test-helpers.ts | 42 +++++++++++++++++++ src/gateway/server-methods/chat.ts | 28 +++---------- src/process/exec.test.ts | 6 +-- src/utils/directive-tags.test.ts | 36 +++++++++++++++- src/utils/directive-tags.ts | 41 ++++++++++++++++++ 9 files changed, 161 insertions(+), 77 deletions(-) create mode 100644 src/gateway/server-methods/chat.test-helpers.ts diff --git a/src/agents/subagent-announce.format.test.ts b/src/agents/subagent-announce.format.test.ts index e93c97389f0..a612e9fca02 100644 --- a/src/agents/subagent-announce.format.test.ts +++ b/src/agents/subagent-announce.format.test.ts @@ -1430,6 +1430,7 @@ describe("subagent announce formatting", () => { requesterSessionKey: "agent:main:subagent:orchestrator", requesterDisplayKey: "agent:main:subagent:orchestrator", ...defaultOutcomeAnnounce, + timeoutMs: 100, }); expect(didAnnounce).toBe(true); diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index 4e8c9d6f1e7..4a8fa8fc5b5 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -824,6 +824,8 @@ describe("Cron issue regressions", () => { let now = dueAt; let activeRuns = 0; let peakActiveRuns = 0; + const startedRunIds = new Set(); + const bothRunsStarted = createDeferred(); const firstRun = createDeferred<{ status: "ok"; summary: string }>(); const secondRun = createDeferred<{ status: "ok"; summary: string }>(); const state = createCronServiceState({ @@ -837,6 +839,10 @@ describe("Cron issue regressions", () => { runIsolatedAgentJob: vi.fn(async (params: { job: { id: string } }) => { activeRuns += 1; peakActiveRuns = Math.max(peakActiveRuns, activeRuns); + startedRunIds.add(params.job.id); + if (startedRunIds.size === 2) { + bothRunsStarted.resolve(); + } try { const result = params.job.id === first.id ? await firstRun.promise : await secondRun.promise; @@ -849,7 +855,12 @@ describe("Cron issue regressions", () => { }); const timerPromise = onTimer(state); - await new Promise((resolve) => setTimeout(resolve, 20)); + await Promise.race([ + bothRunsStarted.promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("timed out waiting for concurrent cron runs")), 1_000), + ), + ]); expect(peakActiveRuns).toBe(2); diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 4c760cbd37c..9c705f0682a 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -1,9 +1,6 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { createMockSessionEntry, createTranscriptFixtureSync } from "./chat.test-helpers.js"; import type { GatewayRequestContext } from "./types.js"; const mockState = vi.hoisted(() => ({ @@ -16,15 +13,11 @@ vi.mock("../session-utils.js", async (importOriginal) => { const original = await importOriginal(); return { ...original, - loadSessionEntry: () => ({ - cfg: {}, - storePath: path.join(path.dirname(mockState.transcriptPath), "sessions.json"), - entry: { + loadSessionEntry: () => + createMockSessionEntry({ + transcriptPath: mockState.transcriptPath, sessionId: mockState.sessionId, - sessionFile: mockState.transcriptPath, - }, - canonicalKey: "main", - }), + }), }; }); @@ -48,19 +41,10 @@ vi.mock("../../auto-reply/dispatch.js", () => ({ const { chatHandlers } = await import("./chat.js"); function createTranscriptFixture(prefix: string) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - const transcriptPath = path.join(dir, "sess.jsonl"); - fs.writeFileSync( - transcriptPath, - `${JSON.stringify({ - type: "session", - version: CURRENT_SESSION_VERSION, - id: mockState.sessionId, - timestamp: new Date(0).toISOString(), - cwd: "/tmp", - })}\n`, - "utf-8", - ); + const { transcriptPath } = createTranscriptFixtureSync({ + prefix, + sessionId: mockState.sessionId, + }); mockState.transcriptPath = transcriptPath; } diff --git a/src/gateway/server-methods/chat.inject.parentid.e2e.test.ts b/src/gateway/server-methods/chat.inject.parentid.e2e.test.ts index 2d04e1cb9c4..b25cbc3fb74 100644 --- a/src/gateway/server-methods/chat.inject.parentid.e2e.test.ts +++ b/src/gateway/server-methods/chat.inject.parentid.e2e.test.ts @@ -1,41 +1,28 @@ import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; +import { createMockSessionEntry, createTranscriptFixtureSync } from "./chat.test-helpers.js"; import type { GatewayRequestContext } from "./types.js"; // Guardrail: Ensure gateway "injected" assistant transcript messages are appended via SessionManager, // so they are attached to the current leaf with a `parentId` and do not sever compaction history. describe("gateway chat.inject transcript writes", () => { it("appends a Pi session entry that includes parentId", async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-chat-inject-")); - const transcriptPath = path.join(dir, "sess.jsonl"); - - // Minimal Pi session header so SessionManager can open/append safely. - fs.writeFileSync( - transcriptPath, - `${JSON.stringify({ - type: "session", - version: CURRENT_SESSION_VERSION, - id: "sess-1", - timestamp: new Date(0).toISOString(), - cwd: "/tmp", - })}\n`, - "utf-8", - ); + const sessionId = "sess-1"; + const { transcriptPath } = createTranscriptFixtureSync({ + prefix: "openclaw-chat-inject-", + sessionId, + }); vi.doMock("../session-utils.js", async (importOriginal) => { const original = await importOriginal(); return { ...original, - loadSessionEntry: () => ({ - storePath: path.join(dir, "sessions.json"), - entry: { - sessionId: "sess-1", - sessionFile: transcriptPath, - }, - }), + loadSessionEntry: () => + createMockSessionEntry({ + transcriptPath, + sessionId, + canonicalKey: "k1", + }), }; }); diff --git a/src/gateway/server-methods/chat.test-helpers.ts b/src/gateway/server-methods/chat.test-helpers.ts new file mode 100644 index 00000000000..c8a772dbf13 --- /dev/null +++ b/src/gateway/server-methods/chat.test-helpers.ts @@ -0,0 +1,42 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; + +export function createTranscriptFixtureSync(params: { + prefix: string; + sessionId: string; + fileName?: string; +}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), params.prefix)); + const transcriptPath = path.join(dir, params.fileName ?? "sess.jsonl"); + fs.writeFileSync( + transcriptPath, + `${JSON.stringify({ + type: "session", + version: CURRENT_SESSION_VERSION, + id: params.sessionId, + timestamp: new Date(0).toISOString(), + cwd: "/tmp", + })}\n`, + "utf-8", + ); + return { dir, transcriptPath }; +} + +export function createMockSessionEntry(params: { + transcriptPath: string; + sessionId: string; + canonicalKey?: string; + cfg?: Record; +}) { + return { + cfg: params.cfg ?? {}, + storePath: path.join(path.dirname(params.transcriptPath), "sessions.json"), + entry: { + sessionId: params.sessionId, + sessionFile: params.transcriptPath, + }, + canonicalKey: params.canonicalKey ?? "main", + }; +} diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 088f791d65e..c2605065500 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -10,7 +10,10 @@ import type { MsgContext } from "../../auto-reply/templating.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { resolveSessionFilePath } from "../../config/sessions.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; -import { stripInlineDirectiveTagsForDisplay } from "../../utils/directive-tags.js"; +import { + stripInlineDirectiveTagsForDisplay, + stripInlineDirectiveTagsFromMessageForDisplay, +} from "../../utils/directive-tags.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import { abortChatRunById, @@ -527,25 +530,6 @@ function nextChatSeq(context: { agentRunSeq: Map }, runId: strin return next; } -function stripMessageDirectiveTags( - message: Record | undefined, -): Record | undefined { - if (!message) { - return message; - } - const content = message.content; - if (!Array.isArray(content)) { - return message; - } - const cleaned = content.map((part: Record) => { - if (part.type === "text" && typeof part.text === "string") { - return { ...part, text: stripInlineDirectiveTagsForDisplay(part.text).text }; - } - return part; - }); - return { ...message, content: cleaned }; -} - function broadcastChatFinal(params: { context: Pick; runId: string; @@ -558,7 +542,7 @@ function broadcastChatFinal(params: { sessionKey: params.sessionKey, seq, state: "final" as const, - message: stripMessageDirectiveTags(params.message), + message: stripInlineDirectiveTagsFromMessageForDisplay(params.message), }; params.context.broadcast("chat", payload); params.context.nodeSendToSession(params.sessionKey, "chat", payload); @@ -1089,7 +1073,7 @@ export const chatHandlers: GatewayRequestHandlers = { sessionKey: rawSessionKey, seq: 0, state: "final" as const, - message: stripMessageDirectiveTags(appended.message), + message: stripInlineDirectiveTagsFromMessageForDisplay(appended.message), }; context.broadcast("chat", chatPayload); context.nodeSendToSession(rawSessionKey, "chat", chatPayload); diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 549b067696b..2ecebd74e86 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -51,11 +51,11 @@ describe("runCommandWithTimeout", () => { [ process.execPath, "-e", - 'process.stdout.write("."); setTimeout(() => process.stdout.write("."), 30); setTimeout(() => process.exit(0), 60);', + 'process.stdout.write(".\\n"); const interval = setInterval(() => process.stdout.write(".\\n"), 1800); setTimeout(() => { clearInterval(interval); process.exit(0); }, 9000);', ], { - timeoutMs: 1_000, - noOutputTimeoutMs: 500, + timeoutMs: 15_000, + noOutputTimeoutMs: 6_000, }, ); diff --git a/src/utils/directive-tags.test.ts b/src/utils/directive-tags.test.ts index 29fcb3021ee..21b042b22b0 100644 --- a/src/utils/directive-tags.test.ts +++ b/src/utils/directive-tags.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "vitest"; -import { stripInlineDirectiveTagsForDisplay } from "./directive-tags.js"; +import { + stripInlineDirectiveTagsForDisplay, + stripInlineDirectiveTagsFromMessageForDisplay, +} from "./directive-tags.js"; describe("stripInlineDirectiveTagsForDisplay", () => { test("removes reply and audio directives", () => { @@ -23,3 +26,34 @@ describe("stripInlineDirectiveTagsForDisplay", () => { expect(result.text).toBe(input); }); }); + +describe("stripInlineDirectiveTagsFromMessageForDisplay", () => { + test("strips inline directives from text content blocks", () => { + const input = { + role: "assistant", + content: [{ type: "text", text: "hello [[reply_to_current]] world [[audio_as_voice]]" }], + }; + const result = stripInlineDirectiveTagsFromMessageForDisplay(input); + expect(result).toBeDefined(); + expect(result?.content).toEqual([{ type: "text", text: "hello world " }]); + }); + + test("preserves empty-string text when directives are entire content", () => { + const input = { + role: "assistant", + content: [{ type: "text", text: "[[reply_to_current]]" }], + }; + const result = stripInlineDirectiveTagsFromMessageForDisplay(input); + expect(result).toBeDefined(); + expect(result?.content).toEqual([{ type: "text", text: "" }]); + }); + + test("returns original message when content is not an array", () => { + const input = { + role: "assistant", + content: "plain text", + }; + const result = stripInlineDirectiveTagsFromMessageForDisplay(input); + expect(result).toEqual(input); + }); +}); diff --git a/src/utils/directive-tags.ts b/src/utils/directive-tags.ts index b49a10f2faf..97c31d46698 100644 --- a/src/utils/directive-tags.ts +++ b/src/utils/directive-tags.ts @@ -29,6 +29,17 @@ type StripInlineDirectiveTagsResult = { changed: boolean; }; +type MessageTextPart = { + type: "text"; + text: string; +} & Record; + +type MessagePart = Record | null | undefined; + +export type DisplayMessageWithContent = { + content?: unknown; +} & Record; + export function stripInlineDirectiveTagsForDisplay(text: string): StripInlineDirectiveTagsResult { if (!text) { return { text, changed: false }; @@ -41,6 +52,36 @@ export function stripInlineDirectiveTagsForDisplay(text: string): StripInlineDir }; } +function isMessageTextPart(part: MessagePart): part is MessageTextPart { + return Boolean(part) && part?.type === "text" && typeof part.text === "string"; +} + +/** + * Strips inline directive tags from message text blocks while preserving message shape. + * Empty post-strip text stays empty-string to preserve caller semantics. + */ +export function stripInlineDirectiveTagsFromMessageForDisplay( + message: DisplayMessageWithContent | undefined, +): DisplayMessageWithContent | undefined { + if (!message) { + return message; + } + if (!Array.isArray(message.content)) { + return message; + } + const cleaned = message.content.map((part) => { + if (!part || typeof part !== "object") { + return part; + } + const record = part as MessagePart; + if (!isMessageTextPart(record)) { + return part; + } + return { ...record, text: stripInlineDirectiveTagsForDisplay(record.text).text }; + }); + return { ...message, content: cleaned }; +} + export function parseInlineDirectives( text?: string, options: InlineDirectiveParseOptions = {}, From 777817392da383544d7feeb99f645afc869a039d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 12:17:44 +0100 Subject: [PATCH 16/81] fix: fail closed missing provider group policy across message channels (#23367) (thanks @bmendonca3) --- CHANGELOG.md | 1 + docs/channels/discord.md | 2 +- docs/channels/groups.md | 1 + docs/channels/imessage.md | 1 + docs/channels/line.md | 1 + docs/channels/matrix.md | 1 + docs/channels/mattermost.md | 1 + docs/channels/signal.md | 1 + docs/channels/slack.md | 2 +- docs/channels/telegram.md | 1 + docs/channels/whatsapp.md | 2 +- extensions/discord/src/channel.ts | 9 ++++- extensions/feishu/src/bot.ts | 19 +++++++++- extensions/feishu/src/channel.ts | 9 ++++- extensions/googlechat/src/channel.ts | 9 ++++- extensions/googlechat/src/monitor.ts | 18 ++++++++- extensions/imessage/src/channel.ts | 9 ++++- extensions/irc/src/channel.ts | 9 ++++- extensions/irc/src/inbound.ts | 16 +++++++- extensions/line/src/channel.ts | 9 ++++- extensions/matrix/src/channel.ts | 9 ++++- extensions/matrix/src/matrix/monitor/index.ts | 22 ++++++++++- extensions/mattermost/src/channel.ts | 9 ++++- .../mattermost/src/mattermost/monitor.ts | 18 +++++++-- extensions/msteams/src/channel.ts | 9 ++++- extensions/nextcloud-talk/src/channel.ts | 10 ++++- extensions/nextcloud-talk/src/inbound.ts | 28 +++++++++++--- extensions/signal/src/channel.ts | 9 ++++- extensions/slack/src/channel.ts | 9 ++++- extensions/telegram/src/channel.ts | 9 ++++- extensions/whatsapp/src/channel.ts | 9 ++++- extensions/zalouser/src/monitor.ts | 16 +++++++- extensions/zalouser/src/onboarding.ts | 2 +- src/discord/monitor/message-handler.ts | 9 ++++- src/discord/monitor/native-command.ts | 10 ++++- .../monitor/provider.group-policy.test.ts | 9 +++++ src/discord/monitor/provider.ts | 29 +++++++------- src/imessage/monitor/monitor-provider.ts | 38 ++++++++++++++++++- src/line/bot-handlers.ts | 17 ++++++++- src/plugin-sdk/index.ts | 4 ++ src/signal/monitor.ts | 14 ++++++- .../monitor/provider.group-policy.test.ts | 6 +-- src/slack/monitor/provider.ts | 17 ++++----- src/telegram/group-access.ts | 29 ++++++++++---- src/web/inbound/access-control.ts | 33 +++++++++++++++- 45 files changed, 420 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e422d7639a8..166d7cf22b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Channels/Security: fail closed on missing provider group policy config by defaulting runtime group policy to `allowlist` (instead of inheriting `channels.defaults.groupPolicy`) when `channels.` is absent across message channels, and align runtime + security warnings/docs to the same fallback behavior (Slack, Discord, iMessage, Telegram, WhatsApp, Signal, LINE, Matrix, Mattermost, Google Chat, IRC, Nextcloud Talk, Feishu, and Zalo user flows; plus Discord message/native-command paths). (#23367) Thanks @bmendonca3. - CLI/Sessions: pass the configured sessions directory when resolving transcript paths in `agentCommand`, so custom `session.store` locations resume sessions reliably. Thanks @davidrudduck. - Gateway/Chat UI: strip inline reply/audio directive tags from non-streaming final webchat broadcasts (including `chat.inject`) while preserving empty-string message content when tags are the entire reply. (#23298) Thanks @SidQin-cyber. - Gateway/Restart: fix restart-loop edge cases by keeping `openclaw.mjs -> dist/entry.js` bootstrap detection explicit, reacquiring the gateway lock for in-process restart fallback paths, and tightening restart-loop regression coverage. (#23416) Thanks @jeffwnli. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 6cdd3aa410c..334c6d78ee5 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -425,7 +425,7 @@ Example: } ``` - If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="allowlist"` (with a warning in logs). + If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="allowlist"` (with a warning in logs), even if `channels.defaults.groupPolicy` is `open`. diff --git a/docs/channels/groups.md b/docs/channels/groups.md index 6bd278846c5..00118c546b5 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -190,6 +190,7 @@ Notes: - Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`). - Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive. - Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked. +- Runtime safety: when a provider block is completely missing (`channels.` absent), group policy falls back to a fail-closed mode (typically `allowlist`) instead of inheriting `channels.defaults.groupPolicy`. Quick mental model (evaluation order for group messages): diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index d7a1b633597..5720da1714a 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -158,6 +158,7 @@ imsg send "test" Group sender allowlist: `channels.imessage.groupAllowFrom`. Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks fall back to `allowFrom` when available. + Runtime note: if `channels.imessage` is completely missing, runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set). Mention gating for groups: diff --git a/docs/channels/line.md b/docs/channels/line.md index d32e683fbeb..b87cbd3f5fb 100644 --- a/docs/channels/line.md +++ b/docs/channels/line.md @@ -118,6 +118,7 @@ Allowlists and policies: - `channels.line.groupPolicy`: `allowlist | open | disabled` - `channels.line.groupAllowFrom`: allowlisted LINE user IDs for groups - Per-group overrides: `channels.line.groups..allowFrom` +- Runtime note: if `channels.line` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). LINE IDs are case-sensitive. Valid IDs look like: diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 04205d94971..9bb56d1ddb7 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -195,6 +195,7 @@ Notes: ## Rooms (groups) - Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset. +- Runtime note: if `channels.matrix` is completely missing, runtime falls back to `groupPolicy="allowlist"` for room checks (even if `channels.defaults.groupPolicy` is set). - Allowlist rooms with `channels.matrix.groups` (room IDs or aliases; names are resolved to IDs when directory search finds a single exact match): ```json5 diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index fa0d9393e0f..350fa8429c4 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -103,6 +103,7 @@ Notes: - Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated). - Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`). - Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated). +- Runtime note: if `channels.mattermost` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). ## Targets for outbound delivery diff --git a/docs/channels/signal.md b/docs/channels/signal.md index 60bb5f7ce92..b216af120ce 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -195,6 +195,7 @@ Groups: - `channels.signal.groupPolicy = open | allowlist | disabled`. - `channels.signal.groupAllowFrom` controls who can trigger in groups when `allowlist` is set. +- Runtime note: if `channels.signal` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). ## How it works (behavior) diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 13c53b02459..4a1bda6990b 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -165,7 +165,7 @@ For actions/directory reads, user token can be preferred when configured. For wr Channel allowlist lives under `channels.slack.channels`. - Runtime note: if `channels.slack` is completely missing (env-only setup) and `channels.defaults.groupPolicy` is unset, runtime falls back to `groupPolicy="allowlist"` and logs a warning. + Runtime note: if `channels.slack` is completely missing (env-only setup), runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set). Name/ID resolution: diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 3867224fc7a..138b2b255d8 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -148,6 +148,7 @@ curl "https://api.telegram.org/bot/getUpdates" `groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`. `groupAllowFrom` entries must be numeric Telegram user IDs. + Runtime note: if `channels.telegram` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group policy evaluation (even if `channels.defaults.groupPolicy` is set). Example: allow any member in one specific group: diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index a6fb427bdc2..d92dfda9c75 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -171,7 +171,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch - if `groupAllowFrom` is unset, runtime falls back to `allowFrom` when available - sender allowlists are evaluated before mention/reply activation - Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is effectively `open`. + Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is `allowlist` (with a warning log), even if `channels.defaults.groupPolicy` is set. diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 7556f14e154..9922062c4c4 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -22,6 +22,7 @@ import { resolveDefaultDiscordAccountId, resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, + resolveRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelMessageActionAdapter, type ChannelPlugin, @@ -131,7 +132,13 @@ export const discordPlugin: ChannelPlugin = { collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.discord !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); const guildEntries = account.config.guilds ?? {}; const guildsConfigured = Object.keys(guildEntries).length > 0; const channelAllowlistConfigured = guildsConfigured; diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 14d9219193a..7922997c7d5 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -2,10 +2,11 @@ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk"; import { buildAgentMediaPayload, buildPendingHistoryContextFromMap, - recordPendingHistoryEntryIfEnabled, clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, + recordPendingHistoryEntryIfEnabled, + resolveRuntimeGroupPolicy, } from "openclaw/plugin-sdk"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; @@ -77,6 +78,7 @@ const senderNameCache = new Map(); // Key: appId or "default", Value: timestamp of last notification const permissionErrorNotifiedAt = new Map(); const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes +const groupPolicyFallbackWarningShown = new Set(); type SenderNameResult = { name?: string; @@ -563,7 +565,20 @@ export async function handleFeishuMessage(params: { const useAccessGroups = cfg.commands?.useAccessGroups !== false; if (isGroup) { - const groupPolicy = feishuCfg?.groupPolicy ?? "open"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.feishu !== undefined, + groupPolicy: feishuCfg?.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); + if (providerMissingFallbackApplied && !groupPolicyFallbackWarningShown.has(account.accountId)) { + groupPolicyFallbackWarningShown.add(account.accountId); + log( + 'feishu: channels.feishu is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', + ); + } const groupAllowFrom = feishuCfg?.groupAllowFrom ?? []; // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 98a622cdf46..c1f29be85e5 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -4,6 +4,7 @@ import { createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, + resolveRuntimeGroupPolicy, } from "openclaw/plugin-sdk"; import { resolveFeishuAccount, @@ -227,7 +228,13 @@ export const feishuPlugin: ChannelPlugin = { const defaultGroupPolicy = ( cfg.channels as Record | undefined )?.defaults?.groupPolicy; - const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.feishu !== undefined, + groupPolicy: feishuCfg?.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") return []; return [ `- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`, diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 8022add55ca..9cd9bd182aa 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -11,6 +11,7 @@ import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, resolveGoogleChatGroupRequireMention, + resolveRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelDock, type ChannelMessageActionAdapter, @@ -199,7 +200,13 @@ export const googlechatPlugin: ChannelPlugin = { collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.googlechat !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy === "open") { warnings.push( `- Google Chat spaces: groupPolicy="open" allows any space to trigger (mention-gated). Set channels.googlechat.groupPolicy="allowlist" and configure channels.googlechat.groups.`, diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index cee54005886..8889ec8d5f5 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -5,6 +5,7 @@ import { readJsonBodyWithLimit, registerWebhookTarget, rejectNonPostWebhookRequest, + resolveRuntimeGroupPolicy, resolveSingleWebhookTargetAsync, resolveWebhookPath, resolveWebhookTargets, @@ -67,6 +68,7 @@ function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, } const warnedDeprecatedUsersEmailAllowFrom = new Set(); +const warnedMissingProviderGroupPolicy = new Set(); function warnDeprecatedUsersEmailEntries( core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, @@ -427,7 +429,21 @@ async function processMessageWithPipeline(params: { } const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + providerConfigPresent: config.channels?.googlechat !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); + if (providerMissingFallbackApplied && !warnedMissingProviderGroupPolicy.has(account.accountId)) { + warnedMissingProviderGroupPolicy.add(account.accountId); + logVerbose( + core, + runtime, + 'googlechat: channels.googlechat is missing; defaulting groupPolicy to "allowlist" (space messages blocked until explicitly configured).', + ); + } const groupConfigResolved = resolveGroupConfig({ groupId: spaceId, groupName: space.displayName ?? null, diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 00696414f23..aacc3246d25 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -18,6 +18,7 @@ import { resolveIMessageAccount, resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, + resolveRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, type ResolvedIMessageAccount, @@ -98,7 +99,13 @@ export const imessagePlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.imessage !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 024f379c3d0..18bcece05ad 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -4,6 +4,7 @@ import { formatPairingApproveHint, getChatChannelMeta, PAIRING_APPROVED_MESSAGE, + resolveRuntimeGroupPolicy, setAccountEnabledInConfigSection, deleteAccountFromConfigSection, type ChannelPlugin, @@ -135,7 +136,13 @@ export const ircPlugin: ChannelPlugin = { collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.irc !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy === "open") { warnings.push( '- IRC channels: groupPolicy="open" allows all channels and senders (mention-gated). Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups.', diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index abd523ed17c..eb6daeff611 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -2,6 +2,7 @@ import { createReplyPrefixOptions, logInboundDrop, resolveControlCommandGate, + resolveRuntimeGroupPolicy, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; @@ -19,6 +20,7 @@ import { sendMessageIrc } from "./send.js"; import type { CoreConfig, IrcInboundMessage } from "./types.js"; const CHANNEL_ID = "irc" as const; +const warnedMissingProviderGroupPolicy = new Set(); const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -85,7 +87,19 @@ export async function handleIrcInbound(params: { const dmPolicy = account.config.dmPolicy ?? "pairing"; const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + providerConfigPresent: config.channels?.irc !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); + if (providerMissingFallbackApplied && !warnedMissingProviderGroupPolicy.has(account.accountId)) { + warnedMissingProviderGroupPolicy.add(account.accountId); + runtime.log?.( + 'irc: channels.irc is missing; defaulting groupPolicy to "allowlist" (channel messages blocked until explicitly configured).', + ); + } const configAllowFrom = normalizeIrcAllowlist(account.config.allowFrom); const configGroupAllowFrom = normalizeIrcAllowlist(account.config.groupAllowFrom); diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index cc30264e1e1..f5c72cf81b4 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -3,6 +3,7 @@ import { DEFAULT_ACCOUNT_ID, LineConfigSchema, processLineMessage, + resolveRuntimeGroupPolicy, type ChannelPlugin, type ChannelStatusIssue, type OpenClawConfig, @@ -163,7 +164,13 @@ export const linePlugin: ChannelPlugin = { collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = (cfg.channels?.defaults as { groupPolicy?: string } | undefined) ?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.line !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 3cd699f252c..75e4b464660 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -6,6 +6,7 @@ import { formatPairingApproveHint, normalizeAccountId, PAIRING_APPROVED_MESSAGE, + resolveRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk"; @@ -170,7 +171,13 @@ export const matrixPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: (cfg as CoreConfig).channels?.matrix !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index df6d87fad48..91648498936 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -1,5 +1,10 @@ import { format } from "node:util"; -import { mergeAllowlist, summarizeMapping, type RuntimeEnv } from "openclaw/plugin-sdk"; +import { + mergeAllowlist, + resolveRuntimeGroupPolicy, + summarizeMapping, + type RuntimeEnv, +} from "openclaw/plugin-sdk"; import { resolveMatrixTargets } from "../../resolve-targets.js"; import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig, ReplyToMode } from "../../types.js"; @@ -243,7 +248,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg); const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicyRaw = accountConfig.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy( + { + providerConfigPresent: cfg.channels?.matrix !== undefined, + groupPolicy: accountConfig.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }, + ); + if (providerMissingFallbackApplied) { + logVerboseMessage( + 'matrix: channels.matrix is missing; defaulting groupPolicy to "allowlist" (room messages blocked until explicitly configured).', + ); + } const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw; const replyToMode = opts.replyToMode ?? accountConfig.replyToMode ?? "off"; const threadReplies = accountConfig.threadReplies ?? "inbound"; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 3935d5f205e..55e189b55de 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -6,6 +6,7 @@ import { formatPairingApproveHint, migrateBaseNameToDefaultAccount, normalizeAccountId, + resolveRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelMessageActionAdapter, type ChannelMessageActionName, @@ -229,7 +230,13 @@ export const mattermostPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.mattermost !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index b2c921b155d..81777f213e4 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -16,6 +16,7 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntryIfEnabled, resolveControlCommandGate, + resolveRuntimeGroupPolicy, resolveChannelMediaMaxBytes, type HistoryEntry, } from "openclaw/plugin-sdk"; @@ -242,6 +243,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT, ); const channelHistories = new Map(); + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.mattermost !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); + if (providerMissingFallbackApplied) { + logVerboseMessage( + 'mattermost: channels.mattermost is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', + ); + } const fetchWithAuth: FetchLike = (input, init) => { const headers = new Headers(init?.headers); @@ -375,8 +389,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} senderId; const rawText = post.message?.trim() || ""; const dmPolicy = account.config.dmPolicy ?? "pairing"; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []); const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []); const storeAllowFrom = normalizeAllowList( @@ -887,8 +899,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } } } else if (kind) { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; if (groupPolicy === "disabled") { logVerboseMessage(`mattermost: drop reaction (groupPolicy=disabled channel=${channelId})`); return; diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index d7e9b3088e8..9e35450d77a 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -6,6 +6,7 @@ import { DEFAULT_ACCOUNT_ID, MSTeamsConfigSchema, PAIRING_APPROVED_MESSAGE, + resolveRuntimeGroupPolicy, } from "openclaw/plugin-sdk"; import { listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryPeersLive } from "./directory-live.js"; import { msteamsOnboardingAdapter } from "./onboarding.js"; @@ -128,7 +129,13 @@ export const msteamsPlugin: ChannelPlugin = { security: { collectWarnings: ({ cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.msteams !== undefined, + groupPolicy: cfg.channels?.msteams?.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 7471d70dab0..3b7769013f8 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -5,6 +5,7 @@ import { deleteAccountFromConfigSection, formatPairingApproveHint, normalizeAccountId, + resolveRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, type OpenClawConfig, @@ -129,7 +130,14 @@ export const nextcloudTalkPlugin: ChannelPlugin = }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: + (cfg.channels as Record | undefined)?.["nextcloud-talk"] !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 642e010b06d..149bff15818 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -2,6 +2,7 @@ import { createReplyPrefixOptions, logInboundDrop, resolveControlCommandGate, + resolveRuntimeGroupPolicy, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; @@ -20,6 +21,7 @@ import { sendMessageNextcloudTalk } from "./send.js"; import type { CoreConfig, GroupPolicy, NextcloudTalkInboundMessage } from "./types.js"; const CHANNEL_ID = "nextcloud-talk" as const; +const warnedMissingProviderGroupPolicy = new Set(); async function deliverNextcloudTalkReply(params: { payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; @@ -84,12 +86,26 @@ export async function handleNextcloudTalkInbound(params: { statusSink?.({ lastInboundAt: message.timestamp }); const dmPolicy = account.config.dmPolicy ?? "pairing"; - const defaultGroupPolicy = (config.channels as Record | undefined)?.defaults as - | { groupPolicy?: string } - | undefined; - const groupPolicy = (account.config.groupPolicy ?? - defaultGroupPolicy?.groupPolicy ?? - "allowlist") as GroupPolicy; + const defaultGroupPolicy = ( + (config.channels as Record | undefined)?.defaults as + | { groupPolicy?: string } + | undefined + )?.groupPolicy as GroupPolicy | undefined; + const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + providerConfigPresent: + ((config.channels as Record | undefined)?.["nextcloud-talk"] ?? + undefined) !== undefined, + groupPolicy: account.config.groupPolicy as GroupPolicy | undefined, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); + if (providerMissingFallbackApplied && !warnedMissingProviderGroupPolicy.has(account.accountId)) { + warnedMissingProviderGroupPolicy.add(account.accountId); + runtime.log?.( + 'nextcloud-talk: channels.nextcloud-talk is missing; defaulting groupPolicy to "allowlist" (room messages blocked until explicitly configured).', + ); + } const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom); const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom); diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 2d627eeb9a6..db309b5a09d 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -17,6 +17,7 @@ import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, resolveDefaultSignalAccountId, + resolveRuntimeGroupPolicy, resolveSignalAccount, setAccountEnabledInConfigSection, signalOnboardingAdapter, @@ -124,7 +125,13 @@ export const signalPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.signal !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 891dd6a590c..8eda437cfed 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -19,6 +19,7 @@ import { resolveDefaultSlackAccountId, resolveSlackAccount, resolveSlackReplyToMode, + resolveRuntimeGroupPolicy, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, buildSlackThreadingToolContext, @@ -151,7 +152,13 @@ export const slackPlugin: ChannelPlugin = { collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.slack !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); const channelAllowlistConfigured = Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index a26dd956a6a..858e6405e55 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -17,6 +17,7 @@ import { parseTelegramReplyToMessageId, parseTelegramThreadId, resolveDefaultTelegramAccountId, + resolveRuntimeGroupPolicy, resolveTelegramAccount, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, @@ -196,7 +197,13 @@ export const telegramPlugin: ChannelPlugin { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.telegram !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index d19359630b1..8796dcc14b6 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -19,6 +19,7 @@ import { readStringParam, resolveDefaultWhatsAppAccountId, resolveWhatsAppOutboundTarget, + resolveRuntimeGroupPolicy, resolveWhatsAppAccount, resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupToolPolicy, @@ -143,7 +144,13 @@ export const whatsappPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.whatsapp !== undefined, + groupPolicy: account.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index c55a76a147d..6d723e0513b 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plu import { createReplyPrefixOptions, mergeAllowlist, + resolveRuntimeGroupPolicy, resolveSenderCommandAuthorization, summarizeMapping, } from "openclaw/plugin-sdk"; @@ -178,7 +179,20 @@ async function processMessage( const chatId = threadId; const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; + const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + providerConfigPresent: config.channels?.zalouser !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); + if (providerMissingFallbackApplied) { + logVerbose( + core, + runtime, + 'zalouser: channels.zalouser is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', + ); + } const groups = account.config.groups ?? {}; if (isGroup) { if (groupPolicy === "disabled") { diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/onboarding.ts index 03750e1101e..23df4ce42de 100644 --- a/extensions/zalouser/src/onboarding.ts +++ b/extensions/zalouser/src/onboarding.ts @@ -447,7 +447,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { const accessConfig = await promptChannelAccessConfig({ prompter, label: "Zalo groups", - currentPolicy: account.config.groupPolicy ?? "open", + currentPolicy: account.config.groupPolicy ?? "allowlist", currentEntries: Object.keys(account.config.groups ?? {}), placeholder: "Family, Work, 123456789", updatePrompt: Boolean(account.config.groups), diff --git a/src/discord/monitor/message-handler.ts b/src/discord/monitor/message-handler.ts index aceae950d70..8beae2e6277 100644 --- a/src/discord/monitor/message-handler.ts +++ b/src/discord/monitor/message-handler.ts @@ -4,6 +4,7 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../auto-reply/inbound-debounce.js"; +import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import { danger } from "../../globals.js"; import type { DiscordMessageEvent, DiscordMessageHandler } from "./listeners.js"; import { preflightDiscordMessage } from "./message-handler.preflight.js"; @@ -23,7 +24,13 @@ type DiscordMessageHandlerParams = Omit< export function createDiscordMessageHandler( params: DiscordMessageHandlerParams, ): DiscordMessageHandler { - const groupPolicy = params.discordConfig?.groupPolicy ?? "open"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: params.cfg.channels?.discord !== undefined, + groupPolicy: params.discordConfig?.groupPolicy, + defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); const ackReactionScope = params.cfg.messages?.ackReactionScope ?? "group-mentions"; const debounceMs = resolveInboundDebounceMs({ cfg: params.cfg, channel: "discord" }); diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index cc45838c3c9..9ab2c5c3a4c 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -39,6 +39,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import type { OpenClawConfig, loadConfig } from "../../config/config.js"; +import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; @@ -1329,8 +1330,15 @@ async function dispatchDiscordCommandInteraction(params: { const channelAllowlistConfigured = Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0; const channelAllowed = channelConfig?.allowed !== false; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.discord !== undefined, + groupPolicy: discordConfig?.groupPolicy, + defaultGroupPolicy: cfg.channels?.defaults?.groupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); const allowByPolicy = isDiscordGroupAllowedByPolicy({ - groupPolicy: discordConfig?.groupPolicy ?? "open", + groupPolicy, guildAllowlisted: Boolean(guildInfo), channelAllowlistConfigured, channelAllowed, diff --git a/src/discord/monitor/provider.group-policy.test.ts b/src/discord/monitor/provider.group-policy.test.ts index 50a3377f806..48d4f67614a 100644 --- a/src/discord/monitor/provider.group-policy.test.ts +++ b/src/discord/monitor/provider.group-policy.test.ts @@ -26,4 +26,13 @@ describe("resolveDiscordRuntimeGroupPolicy", () => { expect(resolved.groupPolicy).toBe("disabled"); expect(resolved.providerMissingFallbackApplied).toBe(false); }); + + it("ignores explicit global defaults when provider config is missing", () => { + const resolved = __testing.resolveDiscordRuntimeGroupPolicy({ + providerConfigPresent: false, + defaultGroupPolicy: "open", + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); }); diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index bfe8880098d..cea9303f0da 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -21,6 +21,7 @@ import { } from "../../config/commands.js"; import type { OpenClawConfig, ReplyToMode } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; +import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import type { GroupPolicy } from "../../config/types.base.js"; import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; @@ -179,15 +180,13 @@ function resolveDiscordRuntimeGroupPolicy(params: { groupPolicy: GroupPolicy; providerMissingFallbackApplied: boolean; } { - const groupPolicy = - params.groupPolicy ?? - params.defaultGroupPolicy ?? - (params.providerConfigPresent ? "open" : "allowlist"); - const providerMissingFallbackApplied = - !params.providerConfigPresent && - params.groupPolicy === undefined && - params.defaultGroupPolicy === undefined; - return { groupPolicy, providerMissingFallbackApplied }; + return resolveRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); } async function deployDiscordCommands(params: { @@ -265,20 +264,22 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime(); - const discordCfg = account.config; + const rawDiscordCfg = account.config; const discordRootThreadBindings = cfg.channels?.discord?.threadBindings; const discordAccountThreadBindings = cfg.channels?.discord?.accounts?.[account.accountId]?.threadBindings; - const discordRestFetch = resolveDiscordRestFetch(discordCfg.proxy, runtime); - const dmConfig = discordCfg.dm; - let guildEntries = discordCfg.guilds; + const discordRestFetch = resolveDiscordRestFetch(rawDiscordCfg.proxy, runtime); + const dmConfig = rawDiscordCfg.dm; + let guildEntries = rawDiscordCfg.guilds; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; const providerConfigPresent = cfg.channels?.discord !== undefined; const { groupPolicy, providerMissingFallbackApplied } = resolveDiscordRuntimeGroupPolicy({ providerConfigPresent, - groupPolicy: discordCfg.groupPolicy, + groupPolicy: rawDiscordCfg.groupPolicy, defaultGroupPolicy, }); + const discordCfg = + rawDiscordCfg.groupPolicy === groupPolicy ? rawDiscordCfg : { ...rawDiscordCfg, groupPolicy }; if (providerMissingFallbackApplied) { runtime.log?.( warn( diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 375ada6ac4b..2a114e8465e 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -16,8 +16,10 @@ import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.j import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; import { loadConfig } from "../../config/config.js"; +import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; -import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; +import type { GroupPolicy } from "../../config/types.base.js"; +import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js"; import { normalizeScpRemoteHost } from "../../infra/scp-host.js"; import { waitForTransportReady } from "../../infra/transport-ready.js"; import { mediaKindFromMime } from "../../media/constants.js"; @@ -120,6 +122,23 @@ class SentMessageCache { } } +function resolveIMessageRuntimeGroupPolicy(params: { + providerConfigPresent: boolean; + groupPolicy?: GroupPolicy; + defaultGroupPolicy?: GroupPolicy; +}): { + groupPolicy: GroupPolicy; + providerMissingFallbackApplied: boolean; +} { + return resolveRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); +} + export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise { const runtime = resolveRuntime(opts); const cfg = opts.config ?? loadConfig(); @@ -144,7 +163,18 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P (imessageCfg.allowFrom && imessageCfg.allowFrom.length > 0 ? imessageCfg.allowFrom : []), ); const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = imessageCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; + const { groupPolicy, providerMissingFallbackApplied } = resolveIMessageRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.imessage !== undefined, + groupPolicy: imessageCfg.groupPolicy, + defaultGroupPolicy, + }); + if (providerMissingFallbackApplied) { + runtime.log?.( + warn( + 'imessage: channels.imessage is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', + ), + ); + } const dmPolicy = imessageCfg.dmPolicy ?? "pairing"; const includeAttachments = opts.includeAttachments ?? imessageCfg.includeAttachments ?? false; const mediaMaxBytes = (opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024; @@ -508,3 +538,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P await client.stop(); } } + +export const __testing = { + resolveIMessageRuntimeGroupPolicy, +}; diff --git a/src/line/bot-handlers.ts b/src/line/bot-handlers.ts index 206a4d185cb..096d7fcc188 100644 --- a/src/line/bot-handlers.ts +++ b/src/line/bot-handlers.ts @@ -8,6 +8,7 @@ import type { PostbackEvent, } from "@line/bot-sdk"; import type { OpenClawConfig } from "../config/config.js"; +import { resolveRuntimeGroupPolicy } from "../config/runtime-group-policy.js"; import { danger, logVerbose } from "../globals.js"; import { resolvePairingIdLabel } from "../pairing/pairing-labels.js"; import { buildPairingReply } from "../pairing/pairing-messages.js"; @@ -40,6 +41,8 @@ export interface LineHandlerContext { processMessage: (ctx: LineInboundContext) => Promise; } +let lineGroupPolicyFallbackWarned = false; + function resolveLineGroupConfig(params: { config: ResolvedLineAccount["config"]; groupId?: string; @@ -133,7 +136,19 @@ async function shouldProcessLineEvent( dmPolicy, }); const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.line !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); + if (providerMissingFallbackApplied && !lineGroupPolicyFallbackWarned) { + lineGroupPolicyFallbackWarned = true; + logVerbose( + 'line: channels.line is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', + ); + } if (isGroup) { if (groupConfig?.enabled === false) { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index a3f58c034cc..07e3c63d7f6 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -132,6 +132,10 @@ export type { MSTeamsReplyStyle, MSTeamsTeamConfig, } from "../config/types.js"; +export { + resolveRuntimeGroupPolicy, + type RuntimeGroupPolicyResolution, +} from "../config/runtime-group-policy.js"; export { DiscordConfigSchema, GoogleChatConfigSchema, diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 0d4d72ee58e..c9bc8dcb219 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -3,6 +3,7 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/re import type { ReplyPayload } from "../auto-reply/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; +import { resolveRuntimeGroupPolicy } from "../config/runtime-group-policy.js"; import type { SignalReactionNotificationMode } from "../config/types.js"; import { waitForTransportReady } from "../infra/transport-ready.js"; import { saveMediaBuffer } from "../media/store.js"; @@ -345,7 +346,18 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi : []), ); const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = accountInfo.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.signal !== undefined, + groupPolicy: accountInfo.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); + if (providerMissingFallbackApplied) { + runtime.log?.( + 'signal: channels.signal is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', + ); + } const reactionMode = accountInfo.config.reactionNotifications ?? "own"; const reactionAllowlist = normalizeAllowList(accountInfo.config.reactionAllowlist); const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024; diff --git a/src/slack/monitor/provider.group-policy.test.ts b/src/slack/monitor/provider.group-policy.test.ts index 43bc8dfec54..29478d13e7a 100644 --- a/src/slack/monitor/provider.group-policy.test.ts +++ b/src/slack/monitor/provider.group-policy.test.ts @@ -18,12 +18,12 @@ describe("resolveSlackRuntimeGroupPolicy", () => { expect(resolved.providerMissingFallbackApplied).toBe(false); }); - it("respects explicit global defaults", () => { + it("ignores explicit global defaults when provider config is missing", () => { const resolved = __testing.resolveSlackRuntimeGroupPolicy({ providerConfigPresent: false, defaultGroupPolicy: "open", }); - expect(resolved.groupPolicy).toBe("open"); - expect(resolved.providerMissingFallbackApplied).toBe(false); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); }); }); diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 4d9d50331a9..1d52d561036 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -10,6 +10,7 @@ import { summarizeMapping, } from "../../channels/allowlists/resolve-utils.js"; import { loadConfig } from "../../config/config.js"; +import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import type { SessionScope } from "../../config/sessions.js"; import type { GroupPolicy } from "../../config/types.base.js"; import { warn } from "../../globals.js"; @@ -50,15 +51,13 @@ function resolveSlackRuntimeGroupPolicy(params: { groupPolicy: GroupPolicy; providerMissingFallbackApplied: boolean; } { - const groupPolicy = - params.groupPolicy ?? - params.defaultGroupPolicy ?? - (params.providerConfigPresent ? "open" : "allowlist"); - const providerMissingFallbackApplied = - !params.providerConfigPresent && - params.groupPolicy === undefined && - params.defaultGroupPolicy === undefined; - return { groupPolicy, providerMissingFallbackApplied }; + return resolveRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); } function parseApiAppIdFromAppToken(raw?: string) { diff --git a/src/telegram/group-access.ts b/src/telegram/group-access.ts index 02375218171..571457d3b65 100644 --- a/src/telegram/group-access.ts +++ b/src/telegram/group-access.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js"; +import { resolveRuntimeGroupPolicy } from "../config/runtime-group-policy.js"; import type { TelegramAccountConfig, TelegramGroupConfig, @@ -72,6 +73,19 @@ export type TelegramGroupPolicyAccessResult = groupPolicy: "open" | "disabled" | "allowlist"; }; +export const resolveTelegramRuntimeGroupPolicy = (params: { + providerConfigPresent: boolean; + groupPolicy?: TelegramAccountConfig["groupPolicy"]; + defaultGroupPolicy?: TelegramAccountConfig["groupPolicy"]; +}) => + resolveRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); + export const evaluateTelegramGroupPolicyAccess = (params: { isGroup: boolean; chatId: string | number; @@ -90,20 +104,21 @@ export const evaluateTelegramGroupPolicyAccess = (params: { requireSenderForAllowlistAuthorization: boolean; checkChatAllowlist: boolean; }): TelegramGroupPolicyAccessResult => { + const { groupPolicy: runtimeFallbackPolicy } = resolveTelegramRuntimeGroupPolicy({ + providerConfigPresent: params.cfg.channels?.telegram !== undefined, + groupPolicy: params.telegramCfg.groupPolicy, + defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy, + }); const fallbackPolicy = - firstDefined( - params.telegramCfg.groupPolicy, - params.cfg.channels?.defaults?.groupPolicy, - "open", - ) ?? "open"; + firstDefined(params.telegramCfg.groupPolicy, params.cfg.channels?.defaults?.groupPolicy) ?? + runtimeFallbackPolicy; const groupPolicy = params.useTopicAndGroupOverrides ? (firstDefined( params.topicConfig?.groupPolicy, params.groupConfig?.groupPolicy, params.telegramCfg.groupPolicy, params.cfg.channels?.defaults?.groupPolicy, - "open", - ) ?? "open") + ) ?? runtimeFallbackPolicy) : fallbackPolicy; if (!params.isGroup || !params.enforcePolicy) { diff --git a/src/web/inbound/access-control.ts b/src/web/inbound/access-control.ts index a7c2601e2b3..5f5737f3a2b 100644 --- a/src/web/inbound/access-control.ts +++ b/src/web/inbound/access-control.ts @@ -1,4 +1,5 @@ import { loadConfig } from "../../config/config.js"; +import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import { logVerbose } from "../../globals.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { @@ -17,6 +18,23 @@ export type InboundAccessControlResult = { const PAIRING_REPLY_HISTORY_GRACE_MS = 30_000; +function resolveWhatsAppRuntimeGroupPolicy(params: { + providerConfigPresent: boolean; + groupPolicy?: "open" | "allowlist" | "disabled"; + defaultGroupPolicy?: "open" | "allowlist" | "disabled"; +}): { + groupPolicy: "open" | "allowlist" | "disabled"; + providerMissingFallbackApplied: boolean; +} { + return resolveRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); +} + export async function checkInboundAccessControl(params: { accountId: string; from: string; @@ -82,7 +100,16 @@ export async function checkInboundAccessControl(params: { // - "disabled": block all group messages entirely // - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.groupPolicy ?? defaultGroupPolicy ?? "open"; + const { groupPolicy, providerMissingFallbackApplied } = resolveWhatsAppRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.whatsapp !== undefined, + groupPolicy: account.groupPolicy, + defaultGroupPolicy, + }); + if (providerMissingFallbackApplied) { + logVerbose( + 'whatsapp: channels.whatsapp is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', + ); + } if (params.group && groupPolicy === "disabled") { logVerbose("Blocked group message (groupPolicy: disabled)"); return { @@ -191,3 +218,7 @@ export async function checkInboundAccessControl(params: { resolvedAccountId: account.accountId, }; } + +export const __testing = { + resolveWhatsAppRuntimeGroupPolicy, +}; From 42f62821db6a103be20e69d3543abebaa608a175 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 12:18:20 +0100 Subject: [PATCH 17/81] fix: include shared runtime group-policy helper and coverage (#23367) (thanks @bmendonca3) --- src/config/runtime-group-policy.test.ts | 32 +++++++++++++++++++ src/config/runtime-group-policy.ts | 23 +++++++++++++ .../monitor/provider.group-policy.test.ts | 29 +++++++++++++++++ .../group-access.group-policy.test.ts | 29 +++++++++++++++++ .../access-control.group-policy.test.ts | 29 +++++++++++++++++ 5 files changed, 142 insertions(+) create mode 100644 src/config/runtime-group-policy.test.ts create mode 100644 src/config/runtime-group-policy.ts create mode 100644 src/imessage/monitor/provider.group-policy.test.ts create mode 100644 src/telegram/group-access.group-policy.test.ts create mode 100644 src/web/inbound/access-control.group-policy.test.ts diff --git a/src/config/runtime-group-policy.test.ts b/src/config/runtime-group-policy.test.ts new file mode 100644 index 00000000000..f49acda5cad --- /dev/null +++ b/src/config/runtime-group-policy.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { resolveRuntimeGroupPolicy } from "./runtime-group-policy.js"; + +describe("resolveRuntimeGroupPolicy", () => { + it("fails closed when provider config is missing and no defaults are set", () => { + const resolved = resolveRuntimeGroupPolicy({ + providerConfigPresent: false, + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); + + it("keeps configured fallback when provider config is present", () => { + const resolved = resolveRuntimeGroupPolicy({ + providerConfigPresent: true, + configuredFallbackPolicy: "open", + }); + expect(resolved.groupPolicy).toBe("open"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); + + it("ignores global defaults when provider config is missing", () => { + const resolved = resolveRuntimeGroupPolicy({ + providerConfigPresent: false, + defaultGroupPolicy: "disabled", + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); +}); diff --git a/src/config/runtime-group-policy.ts b/src/config/runtime-group-policy.ts new file mode 100644 index 00000000000..12be2c2f8b9 --- /dev/null +++ b/src/config/runtime-group-policy.ts @@ -0,0 +1,23 @@ +import type { GroupPolicy } from "./types.base.js"; + +export type RuntimeGroupPolicyResolution = { + groupPolicy: GroupPolicy; + providerMissingFallbackApplied: boolean; +}; + +export function resolveRuntimeGroupPolicy(params: { + providerConfigPresent: boolean; + groupPolicy?: GroupPolicy; + defaultGroupPolicy?: GroupPolicy; + configuredFallbackPolicy?: GroupPolicy; + missingProviderFallbackPolicy?: GroupPolicy; +}): RuntimeGroupPolicyResolution { + const configuredFallbackPolicy = params.configuredFallbackPolicy ?? "open"; + const missingProviderFallbackPolicy = params.missingProviderFallbackPolicy ?? "allowlist"; + const groupPolicy = params.providerConfigPresent + ? (params.groupPolicy ?? params.defaultGroupPolicy ?? configuredFallbackPolicy) + : (params.groupPolicy ?? missingProviderFallbackPolicy); + const providerMissingFallbackApplied = + !params.providerConfigPresent && params.groupPolicy === undefined; + return { groupPolicy, providerMissingFallbackApplied }; +} diff --git a/src/imessage/monitor/provider.group-policy.test.ts b/src/imessage/monitor/provider.group-policy.test.ts new file mode 100644 index 00000000000..c28d7c10b4b --- /dev/null +++ b/src/imessage/monitor/provider.group-policy.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { __testing } from "./monitor-provider.js"; + +describe("resolveIMessageRuntimeGroupPolicy", () => { + it("fails closed when channels.imessage is missing and no defaults are set", () => { + const resolved = __testing.resolveIMessageRuntimeGroupPolicy({ + providerConfigPresent: false, + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); + + it("keeps open fallback when channels.imessage is configured", () => { + const resolved = __testing.resolveIMessageRuntimeGroupPolicy({ + providerConfigPresent: true, + }); + expect(resolved.groupPolicy).toBe("open"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); + + it("ignores explicit global defaults when provider config is missing", () => { + const resolved = __testing.resolveIMessageRuntimeGroupPolicy({ + providerConfigPresent: false, + defaultGroupPolicy: "disabled", + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); +}); diff --git a/src/telegram/group-access.group-policy.test.ts b/src/telegram/group-access.group-policy.test.ts new file mode 100644 index 00000000000..9374230e1b1 --- /dev/null +++ b/src/telegram/group-access.group-policy.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { resolveTelegramRuntimeGroupPolicy } from "./group-access.js"; + +describe("resolveTelegramRuntimeGroupPolicy", () => { + it("fails closed when channels.telegram is missing and no defaults are set", () => { + const resolved = resolveTelegramRuntimeGroupPolicy({ + providerConfigPresent: false, + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); + + it("keeps open fallback when channels.telegram is configured", () => { + const resolved = resolveTelegramRuntimeGroupPolicy({ + providerConfigPresent: true, + }); + expect(resolved.groupPolicy).toBe("open"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); + + it("ignores explicit defaults when provider config is missing", () => { + const resolved = resolveTelegramRuntimeGroupPolicy({ + providerConfigPresent: false, + defaultGroupPolicy: "disabled", + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); +}); diff --git a/src/web/inbound/access-control.group-policy.test.ts b/src/web/inbound/access-control.group-policy.test.ts new file mode 100644 index 00000000000..8419a1e5d7a --- /dev/null +++ b/src/web/inbound/access-control.group-policy.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { __testing } from "./access-control.js"; + +describe("resolveWhatsAppRuntimeGroupPolicy", () => { + it("fails closed when channels.whatsapp is missing and no defaults are set", () => { + const resolved = __testing.resolveWhatsAppRuntimeGroupPolicy({ + providerConfigPresent: false, + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); + + it("keeps open fallback when channels.whatsapp is configured", () => { + const resolved = __testing.resolveWhatsAppRuntimeGroupPolicy({ + providerConfigPresent: true, + }); + expect(resolved.groupPolicy).toBe("open"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); + + it("ignores explicit default policy when provider config is missing", () => { + const resolved = __testing.resolveWhatsAppRuntimeGroupPolicy({ + providerConfigPresent: false, + defaultGroupPolicy: "disabled", + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); +}); From bf52273a5834fca983e97a0c13101db4a683b0cb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 12:20:44 +0100 Subject: [PATCH 18/81] test: harden flaky timeout-sensitive tests --- src/process/child-process-bridge.test.ts | 4 ++-- src/security/temp-path-guard.test.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/process/child-process-bridge.test.ts b/src/process/child-process-bridge.test.ts index 771b629654e..04ef5715c2e 100644 --- a/src/process/child-process-bridge.test.ts +++ b/src/process/child-process-bridge.test.ts @@ -4,8 +4,8 @@ import process from "node:process"; import { afterEach, describe, expect, it } from "vitest"; import { attachChildProcessBridge } from "./child-process-bridge.js"; -const CHILD_READY_TIMEOUT_MS = 2_000; -const CHILD_EXIT_TIMEOUT_MS = 3_000; +const CHILD_READY_TIMEOUT_MS = 10_000; +const CHILD_EXIT_TIMEOUT_MS = 10_000; function waitForLine( stream: NodeJS.ReadableStream, diff --git a/src/security/temp-path-guard.test.ts b/src/security/temp-path-guard.test.ts index 8fa99feba2a..e1b5b47287d 100644 --- a/src/security/temp-path-guard.test.ts +++ b/src/security/temp-path-guard.test.ts @@ -44,7 +44,14 @@ function isDynamicTemplateSegment(node: ts.Expression): boolean { return ts.isTemplateExpression(node); } +function mightContainDynamicTmpdirJoin(source: string): boolean { + return source.includes("path.join") && source.includes("os.tmpdir") && source.includes("`"); +} + function hasDynamicTmpdirJoin(source: string, filePath = "fixture.ts"): boolean { + if (!mightContainDynamicTmpdirJoin(source)) { + return false; + } const sourceFile = ts.createSourceFile( filePath, source, @@ -146,5 +153,5 @@ describe("temp path guard", () => { } expect(offenders).toEqual([]); - }); + }, 240_000); }); From 9176571ec11cf37ce97b407f106dfebaeddc1729 Mon Sep 17 00:00:00 2001 From: echoVic Date: Sun, 22 Feb 2026 18:11:22 +0800 Subject: [PATCH 19/81] fix(gemini): sanitize thoughtSignatures for native Google provider Native Google Gemini provider was accumulating 2K-8K tokens of Base64 thoughtSignature blobs per turn, causing premature context overflow. The sanitizer was only enabled for OpenRouter Gemini, not native Google. Fixes #23392 --- src/agents/transcript-policy.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 20c58a1f869..0458c3d1a24 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -110,9 +110,8 @@ export function resolveTranscriptPolicy(params: { ? "strict" : undefined; const repairToolUseResultPairing = isGoogle || isAnthropic; - const sanitizeThoughtSignatures = isOpenRouterGemini - ? { allowBase64Only: true, includeCamelCase: true } - : undefined; + const sanitizeThoughtSignatures = + isOpenRouterGemini || isGoogle ? { allowBase64Only: true, includeCamelCase: true } : undefined; const sanitizeThinkingSignatures = isAntigravityClaudeModel; return { From 401106b963e43a4dac87fe9e36bd6faddaaf32cf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 12:22:38 +0100 Subject: [PATCH 20/81] fix: harden flaky tests and cover native google thought signatures (#23457) (thanks @echoVic) --- CHANGELOG.md | 1 + ...unner.google-sanitize-thinking.e2e.test.ts | 66 +++++++++++++++++++ src/agents/pi-embedded-runner.test.ts | 2 +- src/agents/sessions-spawn-hooks.test.ts | 8 ++- src/agents/transcript-policy.test.ts | 4 ++ src/cron/service.issue-regressions.test.ts | 18 ++++- src/process/exec.test.ts | 6 +- src/process/supervisor/supervisor.test.ts | 14 ++-- src/security/temp-path-guard.test.ts | 4 ++ 9 files changed, 110 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 166d7cf22b7..3abdeb157cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - TUI/Status: request immediate renders after setting `sending`/`waiting` activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness. - TUI/Input: arm Ctrl+C exit timing when clearing non-empty composer text and add a SIGINT fallback path so double Ctrl+C exits remain responsive during active runs instead of requiring an extra press or appearing stuck. (#23407) Thanks @tinybluedev. - Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane. +- Agents/Google: sanitize non-base64 `thought_signature`/`thoughtSignature` values from assistant replay transcripts for native Google Gemini requests while preserving valid signatures and tool-call order. (#23457) Thanks @echoVic. - Agents/Transcripts: validate assistant tool-call names (syntax/length + registered tool allowlist) before transcript persistence and during replay sanitization so malformed failover tool names no longer poison sessions with repeated provider HTTP 400 errors. (#23324) Thanks @johnsantry. - Agents/Compaction: strip stale assistant usage snapshots from pre-compaction turns when replaying history after a compaction summary so context-token estimation no longer reuses pre-compaction totals and immediately re-triggers destructive follow-up compactions. (#19127) Thanks @tedwatson. - Agents/Replies: emit a default completion acknowledgement (`✅ Done.`) when runs execute tools successfully but return no final assistant text, preventing silent no-reply turns after tool-only completions. (#22834) Thanks @Oldshue. diff --git a/src/agents/pi-embedded-runner.google-sanitize-thinking.e2e.test.ts b/src/agents/pi-embedded-runner.google-sanitize-thinking.e2e.test.ts index f716ff32a76..93266a0230d 100644 --- a/src/agents/pi-embedded-runner.google-sanitize-thinking.e2e.test.ts +++ b/src/agents/pi-embedded-runner.google-sanitize-thinking.e2e.test.ts @@ -231,6 +231,72 @@ describe("sanitizeSessionHistory (google thinking)", () => { ]); }); + it("strips non-base64 thought signatures for native Google Gemini", async () => { + const sessionManager = SessionManager.inMemory(); + const input = [ + { + role: "user", + content: "hi", + }, + { + role: "assistant", + content: [ + { type: "text", text: "hello", thought_signature: "msg_abc123" }, + { type: "thinking", thinking: "ok", thought_signature: "c2ln" }, + { + type: "toolCall", + id: "call_1", + name: "read", + arguments: { path: "/tmp/foo" }, + thoughtSignature: '{"id":1}', + }, + { + type: "toolCall", + id: "call_2", + name: "read", + arguments: { path: "/tmp/bar" }, + thoughtSignature: "c2ln", + }, + ], + }, + ] as unknown as AgentMessage[]; + + const out = await sanitizeSessionHistory({ + messages: input, + modelApi: "google-generative-ai", + provider: "google", + modelId: "gemini-2.0-flash", + sessionManager, + sessionId: "session:google-gemini", + }); + + const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as { + content?: Array<{ + type?: string; + thought_signature?: string; + thoughtSignature?: string; + thinking?: string; + }>; + }; + expect(assistant.content).toEqual([ + { type: "text", text: "hello" }, + { type: "thinking", thinking: "ok", thought_signature: "c2ln" }, + { + type: "toolCall", + id: "call1", + name: "read", + arguments: { path: "/tmp/foo" }, + }, + { + type: "toolCall", + id: "call2", + name: "read", + arguments: { path: "/tmp/bar" }, + thoughtSignature: "c2ln", + }, + ]); + }); + it("keeps mixed signed/unsigned thinking blocks for Google models", async () => { const sessionManager = SessionManager.inMemory(); const input = [ diff --git a/src/agents/pi-embedded-runner.test.ts b/src/agents/pi-embedded-runner.test.ts index cbe892131c6..1b0ccc1d412 100644 --- a/src/agents/pi-embedded-runner.test.ts +++ b/src/agents/pi-embedded-runner.test.ts @@ -130,7 +130,7 @@ beforeAll(async () => { workspaceDir = path.join(tempRoot, "workspace"); await fs.mkdir(agentDir, { recursive: true }); await fs.mkdir(workspaceDir, { recursive: true }); -}, 60_000); +}, 180_000); afterAll(async () => { if (!tempRoot) { diff --git a/src/agents/sessions-spawn-hooks.test.ts b/src/agents/sessions-spawn-hooks.test.ts index e38416af746..4efa7caf6f2 100644 --- a/src/agents/sessions-spawn-hooks.test.ts +++ b/src/agents/sessions-spawn-hooks.test.ts @@ -1,10 +1,11 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import "./test-helpers/fast-core-tools.js"; import { getCallGatewayMock, getSessionsSpawnTool, setSessionsSpawnConfigOverride, } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; +import { resetSubagentRegistryForTests } from "./subagent-registry.js"; const hookRunnerMocks = vi.hoisted(() => ({ hasSubagentEndedHook: true, @@ -79,6 +80,7 @@ function mockAgentStartFailure() { describe("sessions_spawn subagent lifecycle hooks", () => { beforeEach(() => { + resetSubagentRegistryForTests(); hookRunnerMocks.hasSubagentEndedHook = true; hookRunnerMocks.runSubagentSpawning.mockClear(); hookRunnerMocks.runSubagentSpawned.mockClear(); @@ -103,6 +105,10 @@ describe("sessions_spawn subagent lifecycle hooks", () => { }); }); + afterEach(() => { + resetSubagentRegistryForTests(); + }); + it("runs subagent_spawning and emits subagent_spawned with requester metadata", async () => { const tool = await getSessionsSpawnTool({ agentSessionKey: "main", diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 56c1230b65a..1da43856128 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -19,6 +19,10 @@ describe("resolveTranscriptPolicy", () => { modelApi: "google-generative-ai", }); expect(policy.sanitizeToolCallIds).toBe(true); + expect(policy.sanitizeThoughtSignatures).toEqual({ + allowBase64Only: true, + includeCamelCase: true, + }); }); it("enables sanitizeToolCallIds for Mistral provider", () => { diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index 4a8fa8fc5b5..8f218ec749a 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -104,6 +104,22 @@ async function writeCronJobs(storePath: string, jobs: CronJob[]) { await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs }, null, 2), "utf-8"); } +async function removeDirWithRetries(dir: string, attempts = 3) { + let lastError: unknown; + for (let i = 0; i < attempts; i += 1) { + try { + await fs.rm(dir, { recursive: true, force: true }); + return; + } catch (err) { + lastError = err; + await new Promise((resolve) => setTimeout(resolve, 25 * (i + 1))); + } + } + if (lastError) { + throw lastError; + } +} + async function startCronForStore(params: { storePath: string; cronEnabled?: boolean; @@ -142,7 +158,7 @@ describe("Cron issue regressions", () => { }); afterAll(async () => { - await fs.rm(fixtureRoot, { recursive: true, force: true }); + await removeDirWithRetries(fixtureRoot); }); afterEach(() => { diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 2ecebd74e86..f90769fa4eb 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -36,8 +36,8 @@ describe("runCommandWithTimeout", () => { const result = await runCommandWithTimeout( [process.execPath, "-e", "setTimeout(() => {}, 120)"], { - timeoutMs: 1_000, - noOutputTimeoutMs: 35, + timeoutMs: 3_000, + noOutputTimeoutMs: 120, }, ); @@ -70,7 +70,7 @@ describe("runCommandWithTimeout", () => { const result = await runCommandWithTimeout( [process.execPath, "-e", "setTimeout(() => {}, 120)"], { - timeoutMs: 15, + timeoutMs: 100, }, ); diff --git a/src/process/supervisor/supervisor.test.ts b/src/process/supervisor/supervisor.test.ts index dc098983fda..194af43f781 100644 --- a/src/process/supervisor/supervisor.test.ts +++ b/src/process/supervisor/supervisor.test.ts @@ -9,7 +9,7 @@ describe("process supervisor", () => { backendId: "test", mode: "child", argv: [process.execPath, "-e", 'process.stdout.write("ok")'], - timeoutMs: 2_500, + timeoutMs: 10_000, stdinMode: "pipe-closed", }); const exit = await run.wait(); @@ -25,8 +25,8 @@ describe("process supervisor", () => { backendId: "test", mode: "child", argv: [process.execPath, "-e", "setTimeout(() => {}, 120)"], - timeoutMs: 1_000, - noOutputTimeoutMs: 20, + timeoutMs: 3_000, + noOutputTimeoutMs: 100, stdinMode: "pipe-closed", }); const exit = await run.wait(); @@ -43,7 +43,7 @@ describe("process supervisor", () => { scopeKey: "scope:a", mode: "child", argv: [process.execPath, "-e", "setTimeout(() => {}, 120)"], - timeoutMs: 1_000, + timeoutMs: 3_000, stdinMode: "pipe-open", }); @@ -54,7 +54,7 @@ describe("process supervisor", () => { replaceExistingScope: true, mode: "child", argv: [process.execPath, "-e", 'process.stdout.write("new")'], - timeoutMs: 2_500, + timeoutMs: 10_000, stdinMode: "pipe-closed", }); @@ -72,7 +72,7 @@ describe("process supervisor", () => { backendId: "test", mode: "child", argv: [process.execPath, "-e", "setTimeout(() => {}, 120)"], - timeoutMs: 1, + timeoutMs: 25, stdinMode: "pipe-closed", }); const exit = await run.wait(); @@ -88,7 +88,7 @@ describe("process supervisor", () => { backendId: "test", mode: "child", argv: [process.execPath, "-e", 'process.stdout.write("streamed")'], - timeoutMs: 2_500, + timeoutMs: 10_000, stdinMode: "pipe-closed", captureOutput: false, onStdout: (chunk) => { diff --git a/src/security/temp-path-guard.test.ts b/src/security/temp-path-guard.test.ts index e1b5b47287d..dbff38b50fb 100644 --- a/src/security/temp-path-guard.test.ts +++ b/src/security/temp-path-guard.test.ts @@ -13,6 +13,7 @@ const SKIP_PATTERNS = [ /[\\/](?:__tests__|tests)[\\/]/, /[\\/][^\\/]*test-helpers(?:\.[^\\/]+)?\.ts$/, ]; +const QUICK_TMPDIR_JOIN_PATTERN = /\bpath\.join\s*\(\s*os\.tmpdir\s*\(\s*\)/; function shouldSkip(relativePath: string): boolean { return SKIP_PATTERNS.some((pattern) => pattern.test(relativePath)); @@ -146,6 +147,9 @@ describe("temp path guard", () => { continue; } const source = await fs.readFile(file, "utf-8"); + if (!QUICK_TMPDIR_JOIN_PATTERN.test(source)) { + continue; + } if (hasDynamicTmpdirJoin(source, relativePath)) { offenders.push(relativePath); } From 3bbbe33a1b91c3cfe2327e2d5655c19c0b9fe3f8 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 22 Feb 2026 05:23:55 -0600 Subject: [PATCH 21/81] UI: gateway dashboard with glassmorphism theme system Add a full-featured gateway dashboard UI built on Lit web components. Shell & plumbing: - App shell with router, controllers, and dependency wiring - Login gate, i18n keys, and base layout scaffolding Styles & theming: - Base styles, chat styles, and responsive layout CSS - 6-theme glassmorphism system (Obsidian, Aurora, Solar, etc.) - Glass card, glass panel, and glass input components - Favicon logo in expanded sidebar header Views & features: - Overview with attention cards, event log, quick actions, and log tail - Chat view with markdown rendering, tool-call collapse, and delete support - Command palette with fuzzy search - Agent overview with config display, slash commands, and sidebar filtering - Session list navigation and agent selector Privacy & polish: - Redact toggle with stream-mode default - Blur host/IP in Connected Instances with reveal toggle - Sensitive config value masking with count badge - Card accent borders, hover lift effects, and responsive grid --- .gitignore | 3 + ui/index.html | 12 + ui/src/i18n/locales/en.ts | 42 + ui/src/i18n/locales/pt-BR.ts | 42 + ui/src/i18n/locales/zh-CN.ts | 42 + ui/src/i18n/locales/zh-TW.ts | 42 + ui/src/styles.css | 1 + ui/src/styles/base.css | 888 +++++++++--- ui/src/styles/chat.css | 1 + ui/src/styles/chat/agent-chat.css | 1287 +++++++++++++++++ ui/src/styles/chat/grouped.css | 105 +- ui/src/styles/chat/layout.css | 97 +- ui/src/styles/chat/sidebar.css | 15 +- ui/src/styles/chat/text.css | 93 +- ui/src/styles/chat/tool-cards.css | 197 ++- ui/src/styles/components.css | 1237 +++++++++++++--- ui/src/styles/config.css | 230 ++- ui/src/styles/glass.css | 554 +++++++ ui/src/styles/layout.css | 615 +++++--- ui/src/styles/layout.mobile.css | 97 +- ui/src/ui/app-gateway.node.test.ts | 2 +- ui/src/ui/app-gateway.ts | 14 +- ui/src/ui/app-lifecycle.ts | 23 +- ui/src/ui/app-render.helpers.ts | 114 +- ui/src/ui/app-render.ts | 371 +++-- ui/src/ui/app-settings.test.ts | 6 +- ui/src/ui/app-settings.ts | 166 ++- ui/src/ui/app-view-state.ts | 23 +- ui/src/ui/app.ts | 49 +- ui/src/ui/chat/deleted-messages.ts | 49 + ui/src/ui/chat/grouped-render.ts | 95 +- ui/src/ui/chat/input-history.ts | 49 + ui/src/ui/chat/pinned-messages.ts | 61 + ui/src/ui/chat/slash-commands.ts | 84 ++ ui/src/ui/components/dashboard-header.ts | 34 + ui/src/ui/config-form.browser.test.ts | 4 +- ui/src/ui/controllers/debug.ts | 24 +- ui/src/ui/controllers/health.ts | 62 + ui/src/ui/controllers/models.ts | 18 + ui/src/ui/format.ts | 38 + ui/src/ui/gateway.ts | 8 +- ui/src/ui/icons.ts | 141 ++ ui/src/ui/markdown.ts | 31 + ui/src/ui/storage.ts | 11 +- ui/src/ui/theme.ts | 34 +- ui/src/ui/tool-labels.ts | 39 + ui/src/ui/types.ts | 42 + ui/src/ui/views/agents-panels-overview.ts | 233 +++ ui/src/ui/views/agents-panels-status-files.ts | 26 +- ui/src/ui/views/agents-panels-tools-skills.ts | 32 +- ui/src/ui/views/agents-utils.ts | 8 + ui/src/ui/views/agents.ts | 424 +++--- ui/src/ui/views/bottom-tabs.ts | 33 + .../ui/views/channels.nostr-profile-form.ts | 2 +- ui/src/ui/views/chat.test.ts | 3 + ui/src/ui/views/chat.ts | 818 +++++++++-- ui/src/ui/views/command-palette.ts | 244 ++++ ui/src/ui/views/config-form.analyze.ts | 80 +- ui/src/ui/views/config-form.node.ts | 52 +- ui/src/ui/views/config.browser.test.ts | 3 +- ui/src/ui/views/config.ts | 115 +- ui/src/ui/views/cron.ts | 2 +- ui/src/ui/views/debug.ts | 5 +- ui/src/ui/views/instances.ts | 43 +- ui/src/ui/views/login-gate.ts | 86 ++ ui/src/ui/views/overview-attention.ts | 60 + ui/src/ui/views/overview-cards.ts | 129 ++ ui/src/ui/views/overview-event-log.ts | 43 + ui/src/ui/views/overview-log-tail.ts | 36 + ui/src/ui/views/overview-quick-actions.ts | 31 + ui/src/ui/views/overview.ts | 142 +- .../views/usage-styles/usageStyles-part1.ts | 54 +- .../views/usage-styles/usageStyles-part2.ts | 22 +- .../views/usage-styles/usageStyles-part3.ts | 4 +- ui/vite.config.ts | 2 +- 75 files changed, 8323 insertions(+), 1601 deletions(-) create mode 100644 ui/src/styles/chat/agent-chat.css create mode 100644 ui/src/styles/glass.css create mode 100644 ui/src/ui/chat/deleted-messages.ts create mode 100644 ui/src/ui/chat/input-history.ts create mode 100644 ui/src/ui/chat/pinned-messages.ts create mode 100644 ui/src/ui/chat/slash-commands.ts create mode 100644 ui/src/ui/components/dashboard-header.ts create mode 100644 ui/src/ui/controllers/health.ts create mode 100644 ui/src/ui/controllers/models.ts create mode 100644 ui/src/ui/tool-labels.ts create mode 100644 ui/src/ui/views/agents-panels-overview.ts create mode 100644 ui/src/ui/views/bottom-tabs.ts create mode 100644 ui/src/ui/views/command-palette.ts create mode 100644 ui/src/ui/views/login-gate.ts create mode 100644 ui/src/ui/views/overview-attention.ts create mode 100644 ui/src/ui/views/overview-cards.ts create mode 100644 ui/src/ui/views/overview-event-log.ts create mode 100644 ui/src/ui/views/overview-log-tail.ts create mode 100644 ui/src/ui/views/overview-quick-actions.ts diff --git a/.gitignore b/.gitignore index 120ff08b835..69d89b2c4cd 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ __pycache__/ ui/src/ui/__screenshots__/ ui/playwright-report/ ui/test-results/ +packages/dashboard-next/.next/ +packages/dashboard-next/out/ # Mise configuration files mise.toml @@ -101,3 +103,4 @@ package-lock.json apps/ios/LocalSigning.xcconfig # Generated protocol schema (produced via pnpm protocol:gen) dist/protocol.schema.json +.ant-colony/ diff --git a/ui/index.html b/ui/index.html index dc03f49115c..3409ddbf877 100644 --- a/ui/index.html +++ b/ui/index.html @@ -8,6 +8,18 @@ + diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index db973ec2b7e..cfe67013fdc 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -12,6 +12,7 @@ export const en: TranslationMap = { na: "n/a", docs: "Docs", resources: "Resources", + search: "Search", }, nav: { chat: "Chat", @@ -99,6 +100,47 @@ export const en: TranslationMap = { hint: "This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open {url} on the gateway host.", stayHttp: "If you must stay on HTTP, set {config} (token-only).", }, + connection: { + title: "How to connect", + step1: "Start the gateway on your host machine:", + step2: "Get a tokenized dashboard URL:", + step3: "Paste the WebSocket URL and token above, or open the tokenized URL directly.", + step4: "Or generate a reusable token:", + docsHint: "For remote access, Tailscale Serve is recommended. ", + docsLink: "Read the docs →", + }, + cards: { + cost: "Cost", + skills: "Skills", + recentSessions: "Recent Sessions", + }, + attention: { + title: "Attention", + }, + eventLog: { + title: "Event Log", + }, + logTail: { + title: "Gateway Logs", + }, + quickActions: { + newSession: "New Session", + automation: "Automation", + refreshAll: "Refresh All", + terminal: "Terminal", + }, + streamMode: { + active: "Stream mode — values redacted", + disable: "Disable", + }, + palette: { + placeholder: "Type a command…", + noResults: "No results", + }, + }, + login: { + subtitle: "Gateway Dashboard", + passwordPlaceholder: "optional", }, chat: { disconnected: "Disconnected from gateway.", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index 77123f0691a..e9ba45392b7 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -12,6 +12,7 @@ export const pt_BR: TranslationMap = { na: "n/a", docs: "Docs", resources: "Recursos", + search: "Pesquisar", }, nav: { chat: "Chat", @@ -101,6 +102,47 @@ export const pt_BR: TranslationMap = { hint: "Esta página é HTTP, então o navegador bloqueia a identidade do dispositivo. Use HTTPS (Tailscale Serve) ou abra {url} no host do gateway.", stayHttp: "Se você precisar permanecer em HTTP, defina {config} (apenas token).", }, + connection: { + title: "Como conectar", + step1: "Inicie o gateway na sua máquina host:", + step2: "Obtenha uma URL do painel com token:", + step3: "Cole a URL do WebSocket e o token acima, ou abra a URL com token diretamente.", + step4: "Ou gere um token reutilizável:", + docsHint: "Para acesso remoto, recomendamos o Tailscale Serve. ", + docsLink: "Leia a documentação →", + }, + cards: { + cost: "Custo", + skills: "Habilidades", + recentSessions: "Sessões Recentes", + }, + attention: { + title: "Atenção", + }, + eventLog: { + title: "Log de Eventos", + }, + logTail: { + title: "Logs do Gateway", + }, + quickActions: { + newSession: "Nova Sessão", + automation: "Automação", + refreshAll: "Atualizar Tudo", + terminal: "Terminal", + }, + streamMode: { + active: "Modo stream — valores ocultos", + disable: "Desativar", + }, + palette: { + placeholder: "Digite um comando…", + noResults: "Sem resultados", + }, + }, + login: { + subtitle: "Painel do Gateway", + passwordPlaceholder: "opcional", }, chat: { disconnected: "Desconectado do gateway.", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 6addadb11ff..585883e3a8f 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -12,6 +12,7 @@ export const zh_CN: TranslationMap = { na: "不适用", docs: "文档", resources: "资源", + search: "搜索", }, nav: { chat: "聊天", @@ -98,6 +99,47 @@ export const zh_CN: TranslationMap = { hint: "此页面为 HTTP,因此浏览器阻止设备标识。请使用 HTTPS (Tailscale Serve) 或在网关主机上打开 {url}。", stayHttp: "如果您必须保持 HTTP,请设置 {config} (仅限令牌)。", }, + connection: { + title: "如何连接", + step1: "在主机上启动网关:", + step2: "获取带令牌的仪表盘 URL:", + step3: "将 WebSocket URL 和令牌粘贴到上方,或直接打开带令牌的 URL。", + step4: "或生成可重复使用的令牌:", + docsHint: "如需远程访问,建议使用 Tailscale Serve。", + docsLink: "查看文档 →", + }, + cards: { + cost: "费用", + skills: "技能", + recentSessions: "最近会话", + }, + attention: { + title: "注意事项", + }, + eventLog: { + title: "事件日志", + }, + logTail: { + title: "网关日志", + }, + quickActions: { + newSession: "新建会话", + automation: "自动化", + refreshAll: "全部刷新", + terminal: "终端", + }, + streamMode: { + active: "流模式 — 数据已隐藏", + disable: "禁用", + }, + palette: { + placeholder: "输入命令…", + noResults: "无结果", + }, + }, + login: { + subtitle: "网关仪表盘", + passwordPlaceholder: "可选", }, chat: { disconnected: "已断开与网关的连接。", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index 9187776eb78..95104280846 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -12,6 +12,7 @@ export const zh_TW: TranslationMap = { na: "不適用", docs: "文檔", resources: "資源", + search: "搜尋", }, nav: { chat: "聊天", @@ -98,6 +99,47 @@ export const zh_TW: TranslationMap = { hint: "此頁面為 HTTP,因此瀏覽器阻止設備標識。請使用 HTTPS (Tailscale Serve) 或在網關主機上打開 {url}。", stayHttp: "如果您必須保持 HTTP,請設置 {config} (僅限令牌)。", }, + connection: { + title: "如何連接", + step1: "在主機上啟動閘道:", + step2: "取得帶令牌的儀表板 URL:", + step3: "將 WebSocket URL 和令牌貼到上方,或直接開啟帶令牌的 URL。", + step4: "或產生可重複使用的令牌:", + docsHint: "如需遠端存取,建議使用 Tailscale Serve。", + docsLink: "查看文件 →", + }, + cards: { + cost: "費用", + skills: "技能", + recentSessions: "最近會話", + }, + attention: { + title: "注意事項", + }, + eventLog: { + title: "事件日誌", + }, + logTail: { + title: "閘道日誌", + }, + quickActions: { + newSession: "新建會話", + automation: "自動化", + refreshAll: "全部刷新", + terminal: "終端", + }, + streamMode: { + active: "串流模式 — 數據已隱藏", + disable: "禁用", + }, + palette: { + placeholder: "輸入指令…", + noResults: "無結果", + }, + }, + login: { + subtitle: "閘道儀表板", + passwordPlaceholder: "可選", }, chat: { disconnected: "已斷開與網關的連接。", diff --git a/ui/src/styles.css b/ui/src/styles.css index 16b327f3a73..7eb2fd17046 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -2,4 +2,5 @@ @import "./styles/layout.css"; @import "./styles/layout.mobile.css"; @import "./styles/components.css"; +@import "./styles/glass.css"; @import "./styles/config.css"; diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index b83afd32c50..01f9fb3e641 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -1,108 +1,500 @@ -@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@400;700&family=JetBrains+Mono:wght@400;500;700&display=swap"); + +* { + box-sizing: border-box; +} + +/* ════════════════════════════════════════════════════════ + Theme System — 6 Glassmorphism Themes + ════════════════════════════════════════════════════════ */ + +/* ─── Design Tokens (shared across all themes) ─── */ :root { - /* Background - Warmer dark with depth */ - --bg: #12141a; - --bg-accent: #14161d; - --bg-elevated: #1a1d25; - --bg-hover: #262a35; - --bg-muted: #262a35; + --icon-size-xs: 0.9rem; + --icon-size-sm: 1.05rem; + --icon-size-md: 1.25rem; + --icon-size-xl: 2.4rem; - /* Card / Surface - More contrast between levels */ - --card: #181b22; - --card-foreground: #f4f4f5; - --card-highlight: rgba(255, 255, 255, 0.05); - --popover: #181b22; - --popover-foreground: #f4f4f5; + --font-inter: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-serif: "Playfair Display", Georgia, "Times New Roman", serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - /* Panel */ - --panel: #12141a; - --panel-strong: #1a1d25; - --panel-hover: #262a35; - --chrome: rgba(18, 20, 26, 0.95); - --chrome-strong: rgba(18, 20, 26, 0.98); - - /* Text - Slightly warmer */ - --text: #e4e4e7; - --text-strong: #fafafa; - --chat-text: #e4e4e7; - --muted: #71717a; - --muted-strong: #52525b; - --muted-foreground: #71717a; - - /* Border - Subtle but defined */ - --border: #27272a; - --border-strong: #3f3f46; - --border-hover: #52525b; - --input: #27272a; - --ring: #ff5c5c; - - /* Accent - Punchy signature red */ - --accent: #ff5c5c; - --accent-hover: #ff7070; - --accent-muted: #ff5c5c; - --accent-subtle: rgba(255, 92, 92, 0.15); - --accent-foreground: #fafafa; - --accent-glow: rgba(255, 92, 92, 0.25); - --primary: #ff5c5c; - --primary-foreground: #ffffff; - - /* Secondary - Teal accent for variety */ - --secondary: #1e2028; - --secondary-foreground: #f4f4f5; - --accent-2: #14b8a6; - --accent-2-muted: rgba(20, 184, 166, 0.7); - --accent-2-subtle: rgba(20, 184, 166, 0.15); - - /* Semantic - More saturated */ - --ok: #22c55e; - --ok-muted: rgba(34, 197, 94, 0.75); - --ok-subtle: rgba(34, 197, 94, 0.12); - --destructive: #ef4444; - --destructive-foreground: #fafafa; - --warn: #f59e0b; - --warn-muted: rgba(245, 158, 11, 0.75); - --warn-subtle: rgba(245, 158, 11, 0.12); - --danger: #ef4444; - --danger-muted: rgba(239, 68, 68, 0.75); - --danger-subtle: rgba(239, 68, 68, 0.12); - --info: #3b82f6; - - /* Focus - With glow */ - --focus: rgba(255, 92, 92, 0.25); - --focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring); - --focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow); - - /* Grid */ - --grid-line: rgba(255, 255, 255, 0.04); - - /* Theme transition */ --theme-switch-x: 50%; --theme-switch-y: 50%; +} - /* Typography - Space Grotesk for personality */ - --mono: - "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; - --font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --font-display: - "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +@media (prefers-reduced-motion: reduce) { + :root { + --clay-duration-fast: 0ms; + --clay-duration-normal: 0ms; + --clay-duration-slow: 0ms; + } - /* Shadows - Richer with subtle color */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03); - --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03); - --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03); - --shadow-glow: 0 0 30px var(--accent-glow); + * { + animation-duration: 0s !important; + transition-duration: 0s !important; + } +} - /* Radii - Slightly larger for friendlier feel */ +/* ─── Theme: dark (Home) — Deep-sea Operations Console ─── */ + +:root, +:root[data-theme="dark"] { + color-scheme: dark; + + --vscode-bg: #040810; + --vscode-sidebar: #06090f; + --vscode-panel: #0a0e16; + --vscode-panel-border: rgba(0, 212, 170, 0.08); + --vscode-surface: #0e1420; + --vscode-hover: #121a28; + --vscode-contrast: #020408; + --vscode-text: #d0d8e4; + --vscode-muted: #6e7a8a; + --vscode-subtle: #3a4454; + --vscode-ghost: #0c1018; + --vscode-accent: #ca3a29; + --vscode-accent-alpha: rgba(202, 58, 41, 0.14); + --vscode-selection: #3d1418; + --vscode-success: #00d4aa; + --vscode-danger: #ca3a29; + + --kn-claw: #ca3a29; + --kn-claw-bright: #fd8e2e; + --kn-claw-dim: rgba(202, 58, 41, 0.12); + --kn-claw-ember: #fb9231; + --kn-claw-deep: #9a2d1f; + --kn-ocean: #09181e; + --kn-ocean-bright: #132a36; + --kn-ocean-mid: #0c1e28; + --kn-ocean-dim: rgba(9, 24, 30, 0.8); + --kn-ocean-deep: #040810; + --kn-silver: #8a9baa; + --kn-silver-bright: #c0cdd6; + --kn-silver-dim: rgba(138, 155, 170, 0.12); + --kn-bioluminescence: #00d4aa; + --kn-warm-dark: #221016; + --kn-void: #221016; + + --glass-blur: 8px; + --glass-saturate: 120%; + --glass-bg: rgba(10, 14, 22, 0.82); + --glass-bg-elevated: rgba(14, 20, 32, 0.88); + --glass-border: rgba(0, 212, 170, 0.08); + --glass-border-hover: rgba(202, 58, 41, 0.3); + --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.04); + --glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4); + --glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(0, 212, 170, 0.06); + --glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(0, 212, 170, 0.08); + + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; +} + +/* ─── Theme: light (Docs) — Warm Editorial Dark ─── */ + +:root[data-theme="light"] { + color-scheme: dark; + + --vscode-bg: #0e0c0e; + --vscode-sidebar: #131012; + --vscode-panel: #161214; + --vscode-panel-border: rgba(255, 255, 255, 0.06); + --vscode-surface: #1a1618; + --vscode-hover: #201c1e; + --vscode-contrast: #080608; + --vscode-text: #d5d0cf; + --vscode-muted: #7a7472; + --vscode-subtle: #4a4442; + --vscode-ghost: #1a1616; + --vscode-accent: #ca3a29; + --vscode-accent-alpha: rgba(202, 58, 41, 0.14); + --vscode-selection: #3d1418; + --vscode-success: #00d4aa; + --vscode-danger: #ca3a29; + + --kn-claw: #ca3a29; + --kn-claw-bright: #fd8e2e; + --kn-claw-dim: rgba(202, 58, 41, 0.12); + --kn-claw-ember: #fb9231; + --kn-claw-deep: #9a2d1f; + --kn-ocean: #0e0c0e; + --kn-ocean-bright: #201c1e; + --kn-ocean-mid: #161214; + --kn-ocean-dim: rgba(14, 12, 14, 0.8); + --kn-ocean-deep: #0e0c0e; + --kn-silver: #8a7e72; + --kn-silver-bright: #c0b4a8; + --kn-silver-dim: rgba(138, 126, 114, 0.12); + --kn-bioluminescence: #00d4aa; + --kn-warm-dark: #1a1416; + --kn-void: #1a1416; + + --glass-blur: 0px; + --glass-saturate: 100%; + --glass-bg: rgba(22, 18, 20, 0.95); + --glass-bg-elevated: rgba(26, 22, 24, 0.96); + --glass-border: rgba(255, 255, 255, 0.06); + --glass-border-hover: rgba(202, 58, 41, 0.25); + --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.03); + --glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3); + --glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4); + --glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5); + + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; +} + +/* ─── Theme: openknot — Minimalist Premium Noir ─── */ + +:root[data-theme="openknot"] { + color-scheme: dark; + + --vscode-bg: #000000; + --vscode-sidebar: #080808; + --vscode-panel: #0c0c0c; + --vscode-panel-border: rgba(167, 139, 250, 0.08); + --vscode-surface: #111111; + --vscode-hover: #181818; + --vscode-contrast: #000000; + --vscode-text: #e4e4e7; + --vscode-muted: #71717a; + --vscode-subtle: #3f3f46; + --vscode-ghost: #18181b; + --vscode-accent: #a78bfa; + --vscode-accent-alpha: rgba(167, 139, 250, 0.14); + --vscode-selection: #2e1a5e; + --vscode-success: #a78bfa; + --vscode-danger: #a78bfa; + + --kn-claw: #a78bfa; + --kn-claw-bright: #c4b5fd; + --kn-claw-dim: rgba(167, 139, 250, 0.12); + --kn-claw-ember: #c4b5fd; + --kn-claw-deep: #7c3aed; + --kn-ocean: #000000; + --kn-ocean-bright: #1a1a1e; + --kn-ocean-mid: #0e0e12; + --kn-ocean-dim: rgba(0, 0, 0, 0.8); + --kn-ocean-deep: #000000; + --kn-silver: #71717a; + --kn-silver-bright: #a1a1aa; + --kn-silver-dim: rgba(113, 113, 122, 0.12); + --kn-bioluminescence: #c4b5fd; + --kn-warm-dark: #18181b; + --kn-void: #18181b; + + --glass-blur: 12px; + --glass-saturate: 110%; + --glass-bg: rgba(12, 12, 12, 0.85); + --glass-bg-elevated: rgba(17, 17, 17, 0.9); + --glass-border: rgba(167, 139, 250, 0.08); + --glass-border-hover: rgba(167, 139, 250, 0.3); + --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.04); + --glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.5); + --glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(167, 139, 250, 0.06); + --glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(167, 139, 250, 0.08); + + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; +} + +/* ─── Theme: fieldmanual — Industrial Dossier ─── */ + +:root[data-theme="fieldmanual"] { + color-scheme: dark; + + --vscode-bg: #0e0e0e; + --vscode-sidebar: #121212; + --vscode-panel: #161616; + --vscode-panel-border: rgba(255, 255, 255, 0.1); + --vscode-surface: #1a1a1a; + --vscode-hover: #222222; + --vscode-contrast: #0a0a0a; + --vscode-text: #d4d4d4; + --vscode-muted: #737373; + --vscode-subtle: #404040; + --vscode-ghost: #1a1a1a; + --vscode-accent: #ca3a29; + --vscode-accent-alpha: rgba(202, 58, 41, 0.14); + --vscode-selection: #3d1418; + --vscode-success: #61d6ff; + --vscode-danger: #ca3a29; + + --kn-claw: #ca3a29; + --kn-claw-bright: #ff6b4a; + --kn-claw-dim: rgba(202, 58, 41, 0.12); + --kn-claw-ember: #ff6b4a; + --kn-claw-deep: #9a2d1f; + --kn-ocean: #0e0e0e; + --kn-ocean-bright: #222222; + --kn-ocean-mid: #161616; + --kn-ocean-dim: rgba(14, 14, 14, 0.8); + --kn-ocean-deep: #0e0e0e; + --kn-silver: #737373; + --kn-silver-bright: #a3a3a3; + --kn-silver-dim: rgba(115, 115, 115, 0.12); + --kn-bioluminescence: #61d6ff; + --kn-warm-dark: #1a1a1a; + --kn-void: #1a1a1a; + + --glass-blur: 0px; + --glass-saturate: 100%; + --glass-bg: rgba(22, 22, 22, 0.95); + --glass-bg-elevated: rgba(26, 26, 26, 0.96); + --glass-border: rgba(255, 255, 255, 0.1); + --glass-border-hover: rgba(202, 58, 41, 0.35); + --glass-highlight: none; + --glass-shadow-sm: none; + --glass-shadow-md: none; + --glass-shadow-lg: none; + + --radius-xs: 0px; + --radius-sm: 0px; + --radius-md: 0px; + --radius-lg: 0px; + --radius-xl: 0px; + --radius-full: 0px; +} + +/* ─── Theme: openai — Crimson Glassmorphic ─── */ + +:root[data-theme="openai"] { + color-scheme: dark; + + --vscode-bg: #0c0606; + --vscode-sidebar: #100808; + --vscode-panel: #140a0a; + --vscode-panel-border: rgba(202, 58, 41, 0.12); + --vscode-surface: #1a0e0e; + --vscode-hover: #221414; + --vscode-contrast: #060202; + --vscode-text: #e8d8d4; + --vscode-muted: #8a6a64; + --vscode-subtle: #4a3430; + --vscode-ghost: #1a0e0e; + --vscode-accent: #ca3a29; + --vscode-accent-alpha: rgba(202, 58, 41, 0.18); + --vscode-selection: #7d261c; + --vscode-success: #fd8e2e; + --vscode-danger: #ca3a29; + + --kn-claw: #ca3a29; + --kn-claw-bright: #ff4e41; + --kn-claw-dim: rgba(202, 58, 41, 0.15); + --kn-claw-ember: #fd8e2e; + --kn-claw-deep: #9a2d1f; + --kn-ocean: #0c0606; + --kn-ocean-bright: #221414; + --kn-ocean-mid: #140a0a; + --kn-ocean-dim: rgba(12, 6, 6, 0.8); + --kn-ocean-deep: #0c0606; + --kn-silver: #8a6a64; + --kn-silver-bright: #c0a49c; + --kn-silver-dim: rgba(138, 106, 100, 0.12); + --kn-bioluminescence: #fd8e2e; + --kn-warm-dark: #221016; + --kn-void: #221016; + + --glass-blur: 14px; + --glass-saturate: 130%; + --glass-bg: rgba(20, 10, 10, 0.78); + --glass-bg-elevated: rgba(26, 14, 14, 0.85); + --glass-border: rgba(202, 58, 41, 0.12); + --glass-border-hover: rgba(202, 58, 41, 0.4); + --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.05); + --glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4), 0 0 4px rgba(202, 58, 41, 0.08); + --glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 12px rgba(202, 58, 41, 0.1); + --glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 24px rgba(202, 58, 41, 0.12); + + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; +} + +/* ─── Theme: clawdash — Chrome Metallic ─── */ + +:root[data-theme="clawdash"] { + color-scheme: dark; + + --vscode-bg: #050507; + --vscode-sidebar: #08080c; + --vscode-panel: #0c0c10; + --vscode-panel-border: rgba(192, 200, 212, 0.1); + --vscode-surface: #101014; + --vscode-hover: #161620; + --vscode-contrast: #020204; + --vscode-text: #e8ecf0; + --vscode-muted: #8a94a4; + --vscode-subtle: #4a5060; + --vscode-ghost: #1a1a22; + --vscode-accent: #ca3a29; + --vscode-accent-alpha: rgba(202, 58, 41, 0.14); + --vscode-selection: #3d1418; + --vscode-success: #00d4aa; + --vscode-danger: #ca3a29; + + --kn-claw: #ca3a29; + --kn-claw-bright: #ff4e41; + --kn-claw-dim: rgba(202, 58, 41, 0.12); + --kn-claw-ember: #fd8e2e; + --kn-claw-deep: #9a2d1f; + --kn-ocean: #08080c; + --kn-ocean-bright: #161620; + --kn-ocean-mid: #0c0c10; + --kn-ocean-dim: rgba(8, 8, 12, 0.8); + --kn-ocean-deep: #050507; + --kn-silver: #7a8494; + --kn-silver-bright: #c0c8d4; + --kn-silver-dim: rgba(192, 200, 212, 0.12); + --kn-bioluminescence: #00d4aa; + --kn-warm-dark: #1a1a22; + --kn-void: #1a1a22; + + --glass-blur: 16px; + --glass-saturate: 150%; + --glass-bg: rgba(12, 12, 16, 0.8); + --glass-bg-elevated: rgba(16, 16, 20, 0.88); + --glass-border: rgba(192, 200, 212, 0.08); + --glass-border-hover: rgba(192, 200, 212, 0.25); + --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.06); + --glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4), 0 0 4px rgba(192, 200, 212, 0.04); + --glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 12px rgba(192, 200, 212, 0.06); + --glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 24px rgba(192, 200, 212, 0.08); + + --radius-xs: 3px; --radius-sm: 6px; --radius-md: 8px; - --radius-lg: 12px; - --radius-xl: 16px; + --radius-lg: 10px; + --radius-xl: 14px; --radius-full: 9999px; - --radius: 8px; +} - /* Transitions - Snappy but smooth */ +/* ─── Semantic Alias Layer ─── + Maps foundation vars to the short names used throughout + component CSS, so themes work without per-component overrides. */ + +:root, +:root[data-theme="dark"], +:root[data-theme="light"], +:root[data-theme="openknot"], +:root[data-theme="fieldmanual"], +:root[data-theme="openai"], +:root[data-theme="clawdash"] { + /* Core surfaces */ + --bg: var(--vscode-bg); + --bg-accent: var(--vscode-sidebar); + --bg-elevated: var(--vscode-surface); + --bg-hover: var(--vscode-hover); + --bg-muted: var(--vscode-sidebar); + --bg-content: var(--vscode-bg); + + /* Card/popover surfaces */ + --card: var(--vscode-panel); + --card-foreground: var(--vscode-text); + --card-highlight: rgba(255, 255, 255, 0.04); + --popover: var(--vscode-panel); + --popover-foreground: var(--vscode-text); + + /* Panel/chrome surfaces */ + --panel: var(--vscode-sidebar); + --panel-strong: var(--vscode-panel); + --panel-hover: var(--vscode-hover); + --chrome: var(--glass-bg); + --chrome-strong: var(--glass-bg-elevated); + + /* Typography */ + --text: var(--vscode-text); + --text-strong: var(--vscode-text); + --chat-text: var(--vscode-text); + --muted: var(--vscode-muted); + --muted-strong: var(--vscode-subtle); + --muted-foreground: var(--vscode-muted); + + /* Borders + controls */ + --border: var(--glass-border); + --border-strong: var(--glass-border-hover); + --border-hover: var(--glass-border-hover); + --input: var(--glass-border); + --ring: var(--vscode-accent); + + /* Accent */ + --accent: var(--vscode-accent); + --accent-strong: var(--kn-claw-deep); + --accent-hover: var(--kn-claw-bright); + --accent-muted: var(--vscode-accent); + --accent-subtle: var(--vscode-accent-alpha); + --accent-foreground: #fafafa; + --accent-glow: var(--kn-claw-dim); + --accent-soft: var(--vscode-accent-alpha); + --primary: var(--vscode-accent); + --primary-foreground: #ffffff; + + /* Secondary */ + --secondary: var(--vscode-sidebar); + --secondary-foreground: var(--vscode-text); + --accent-2: var(--kn-bioluminescence); + --accent-2-muted: var(--kn-silver); + --accent-2-subtle: var(--kn-silver-dim); + + /* Semantic */ + --ok: var(--vscode-success); + --ok-muted: var(--vscode-success); + --ok-subtle: var(--kn-silver-dim); + --destructive: var(--vscode-danger); + --destructive-foreground: #fafafa; + --warn: var(--kn-claw-ember); + --warn-muted: var(--kn-claw-ember); + --warn-subtle: var(--kn-claw-dim); + --danger: var(--vscode-danger); + --danger-muted: var(--vscode-danger); + --danger-subtle: var(--kn-claw-dim); + --info: #3b82f6; + --success: var(--vscode-success); + + /* Focus */ + --focus: var(--kn-claw-dim); + --focus-offset-color: var(--bg); + --focus-ring-width: 2px; + --focus-ring-offset-width: 2px; + --focus-ring-color: var(--vscode-accent); + --focus-ring: + 0 0 0 var(--focus-ring-offset-width) var(--focus-offset-color), + 0 0 0 calc(var(--focus-ring-offset-width) + var(--focus-ring-width)) var(--focus-ring-color); + --focus-glow: + 0 0 0 var(--focus-ring-offset-width) var(--focus-offset-color), + 0 0 0 calc(var(--focus-ring-offset-width) + var(--focus-ring-width)) var(--focus-ring-color), + 0 0 18px var(--accent-glow); + + --grid-line: rgba(255, 255, 255, 0.04); + + /* Shadows */ + --shadow-sm: var(--glass-shadow-sm); + --shadow-md: var(--glass-shadow-md); + --shadow-lg: var(--glass-shadow-lg); + --shadow-xl: var(--glass-shadow-lg); + --shadow-glow: 0 0 30px var(--accent-glow); + + /* Radii — aliased from foundation */ + --radius: var(--radius-md); + + /* Timing */ --ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); @@ -110,88 +502,68 @@ --duration-normal: 200ms; --duration-slow: 350ms; - color-scheme: dark; + /* Typography stacks */ + --mono: + "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; + --font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + /* Clay compat layer (dashboard-lit components) */ + --clay-bg: var(--vscode-bg); + --clay-bg-card: var(--vscode-panel); + --clay-bg-elevated: var(--vscode-surface); + --clay-bg-button: var(--vscode-hover); + --clay-bg-interactive: var(--vscode-accent-alpha); + --clay-bg-pressed: var(--vscode-selection); + --clay-bg-scrim: rgba(0, 0, 0, 0.6); + --clay-border-color: var(--glass-border); + --clay-border-subtle: var(--vscode-panel-border); + --clay-shadow: var(--glass-shadow-sm); + --clay-shadow-elevated: var(--glass-shadow-md); + --clay-shadow-pressed: var(--glass-shadow-sm); + --clay-shadow-subtle: var(--glass-shadow-sm); + --clay-radius-sm: var(--radius-sm); + --clay-radius: var(--radius-md); + --clay-radius-md: var(--radius-md); + --clay-radius-lg: var(--radius-lg); + --clay-radius-xl: var(--radius-xl); + --clay-radius-pill: var(--radius-full); + --clay-duration-fast: 150ms; + --clay-duration-normal: 250ms; + --clay-duration-slow: 400ms; + --clay-easing: cubic-bezier(0.16, 1, 0.3, 1); + + /* Layout semantic tokens */ + --topbar-bg: var(--vscode-sidebar); + --topbar-shadow: none; + --topbar-border: 1px solid var(--glass-border); + --topbar-title-color: var(--vscode-text); + --topbar-title-weight: 600; + --sidebar-bg: var(--vscode-sidebar); + --sidebar-border: none; + --sidebar-nav-inactive: var(--vscode-muted); + --sidebar-nav-active-bg: var(--vscode-accent-alpha); + --sidebar-nav-active-bar: 3px solid var(--vscode-accent); + --agent-header-bg: var(--vscode-panel); + --agent-header-border: 1px solid var(--glass-border); + --agent-tab-active-bg: var(--vscode-accent-alpha); + --agent-tab-hover-bg: var(--vscode-accent-alpha); } -/* Light theme - Clean with subtle warmth */ -:root[data-theme="light"] { - --bg: #fafafa; - --bg-accent: #f5f5f5; - --bg-elevated: #ffffff; - --bg-hover: #f0f0f0; - --bg-muted: #f0f0f0; - --bg-content: #f5f5f5; +/* ─── Accessibility: High Contrast ─── */ - --card: #ffffff; - --card-foreground: #18181b; - --card-highlight: rgba(0, 0, 0, 0.03); - --popover: #ffffff; - --popover-foreground: #18181b; - - --panel: #fafafa; - --panel-strong: #f5f5f5; - --panel-hover: #ebebeb; - --chrome: rgba(250, 250, 250, 0.95); - --chrome-strong: rgba(250, 250, 250, 0.98); - - --text: #3f3f46; - --text-strong: #18181b; - --chat-text: #3f3f46; - --muted: #71717a; - --muted-strong: #52525b; - --muted-foreground: #71717a; - - --border: #e4e4e7; - --border-strong: #d4d4d8; - --border-hover: #a1a1aa; - --input: #e4e4e7; - - --accent: #dc2626; - --accent-hover: #ef4444; - --accent-muted: #dc2626; - --accent-subtle: rgba(220, 38, 38, 0.12); - --accent-foreground: #ffffff; - --accent-glow: rgba(220, 38, 38, 0.15); - --primary: #dc2626; - --primary-foreground: #ffffff; - - --secondary: #f4f4f5; - --secondary-foreground: #3f3f46; - --accent-2: #0d9488; - --accent-2-muted: rgba(13, 148, 136, 0.75); - --accent-2-subtle: rgba(13, 148, 136, 0.12); - - --ok: #16a34a; - --ok-muted: rgba(22, 163, 74, 0.75); - --ok-subtle: rgba(22, 163, 74, 0.1); - --destructive: #dc2626; - --destructive-foreground: #fafafa; - --warn: #d97706; - --warn-muted: rgba(217, 119, 6, 0.75); - --warn-subtle: rgba(217, 119, 6, 0.1); - --danger: #dc2626; - --danger-muted: rgba(220, 38, 38, 0.75); - --danger-subtle: rgba(220, 38, 38, 0.1); - --info: #2563eb; - - --focus: rgba(220, 38, 38, 0.2); - --focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow); - - --grid-line: rgba(0, 0, 0, 0.05); - - /* Light shadows */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04); - --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04); - --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04); - --shadow-glow: 0 0 24px var(--accent-glow); - - color-scheme: light; +@media (prefers-contrast: more) { + :root { + --glass-shadow-sm: 0 0 0 2px var(--vscode-text); + --glass-shadow-md: 0 0 0 2px var(--vscode-text); + --glass-shadow-lg: 0 0 0 2px var(--vscode-text); + --glass-border: rgba(255, 255, 255, 0.3); + } } -* { - box-sizing: border-box; -} +/* ════════════════════════════════════════════════════════ + Base Styles + ════════════════════════════════════════════════════════ */ html, body { @@ -200,8 +572,8 @@ body { body { margin: 0; - font: 400 14px/1.55 var(--font-body); - letter-spacing: -0.02em; + font: 400 15px/1.55 var(--font-body); + letter-spacing: -0.01em; background: var(--bg); color: var(--text); -webkit-font-smoothing: antialiased; @@ -289,7 +661,170 @@ select { background: var(--border-strong); } -/* Animations - Polished with spring feel */ +/* ════════════════════════════════════════════════════════ + Theme-Specific Decorative Effects + ════════════════════════════════════════════════════════ */ + +/* ─── Dark — Star field + ambient gradients ─── */ + +:root[data-theme="dark"] body { + background: + radial-gradient(ellipse 80% 50% at 50% -5%, rgba(0, 212, 170, 0.06) 0%, transparent 60%), + radial-gradient(ellipse 60% 40% at 60% 20%, rgba(202, 58, 41, 0.04) 0%, transparent 50%), + var(--bg); +} + +@keyframes star-twinkle { + 0% { + opacity: 0.35; + } + 100% { + opacity: 0.55; + } +} + +:root[data-theme="dark"] body::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + opacity: 0.45; + animation: star-twinkle 5s ease-in-out infinite alternate; + box-shadow: + 120px 40px 0 0.4px rgba(0, 212, 170, 0.5), + 340px 90px 0 0.3px rgba(0, 212, 170, 0.3), + 580px 60px 0 0.5px rgba(0, 212, 170, 0.6), + 800px 130px 0 0.3px rgba(0, 212, 170, 0.4), + 1050px 50px 0 0.4px rgba(0, 212, 170, 0.3), + 90px 200px 0 0.5px rgba(0, 212, 170, 0.4), + 470px 220px 0 0.4px rgba(0, 212, 170, 0.5), + 900px 250px 0 0.5px rgba(0, 212, 170, 0.6), + 200px 420px 0 0.5px rgba(0, 212, 170, 0.5), + 640px 450px 0 0.4px rgba(0, 212, 170, 0.4), + 1060px 380px 0 0.5px rgba(0, 212, 170, 0.3), + 380px 580px 0 0.3px rgba(0, 212, 170, 0.4), + 780px 570px 0 0.3px rgba(0, 212, 170, 0.5), + 110px 680px 0 0.5px rgba(0, 212, 170, 0.4), + 520px 660px 0 0.4px rgba(0, 212, 170, 0.5); +} + +/* ─── openknot — Lavender stars ─── */ + +:root[data-theme="openknot"] body::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + opacity: 0.35; + animation: star-twinkle 8s ease-in-out infinite alternate; + box-shadow: + 120px 40px 0 0.4px rgba(196, 181, 253, 0.5), + 340px 90px 0 0.3px rgba(196, 181, 253, 0.3), + 580px 60px 0 0.5px rgba(196, 181, 253, 0.6), + 800px 130px 0 0.3px rgba(196, 181, 253, 0.4), + 90px 200px 0 0.5px rgba(196, 181, 253, 0.4), + 470px 220px 0 0.4px rgba(196, 181, 253, 0.5), + 900px 250px 0 0.5px rgba(196, 181, 253, 0.6), + 200px 420px 0 0.5px rgba(196, 181, 253, 0.5), + 640px 450px 0 0.4px rgba(196, 181, 253, 0.4), + 380px 580px 0 0.3px rgba(196, 181, 253, 0.4), + 780px 570px 0 0.3px rgba(196, 181, 253, 0.5), + 520px 660px 0 0.4px rgba(196, 181, 253, 0.5); +} + +/* ─── fieldmanual — Industrial Dossier Overrides ─── */ + +:root[data-theme="fieldmanual"] .page-title, +:root[data-theme="fieldmanual"] .panel-title, +:root[data-theme="fieldmanual"] .agent-chat__welcome h2 { + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 700; +} + +:root[data-theme="fieldmanual"] .sidebar-brand__title { + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +:root[data-theme="fieldmanual"] .glass-dashboard-card, +:root[data-theme="fieldmanual"] .stat-card, +:root[data-theme="fieldmanual"] .agent-chat__starter { + border-style: dashed; +} + +:root[data-theme="fieldmanual"] .sidebar { + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: var(--vscode-sidebar); +} + +:root[data-theme="fieldmanual"] .glass-dashboard-card { + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: var(--vscode-panel); +} + +:root[data-theme="fieldmanual"] body::after { + display: none; +} + +/* ─── openai — Crimson atmosphere ─── */ + +:root[data-theme="openai"] body { + background: + radial-gradient(ellipse 80% 50% at 50% -5%, rgba(202, 58, 41, 0.12) 0%, transparent 60%), + radial-gradient(ellipse 60% 40% at 60% 20%, rgba(253, 142, 46, 0.04) 0%, transparent 50%), + var(--bg); +} + +:root[data-theme="openai"] body::after { + display: none; +} + +/* ─── clawdash — Chrome Metallic Overrides ─── */ + +:root[data-theme="clawdash"] body { + background: + radial-gradient(ellipse 80% 50% at 40% -10%, rgba(192, 200, 212, 0.06) 0%, transparent 60%), + radial-gradient(ellipse 60% 40% at 70% 30%, rgba(202, 58, 41, 0.04) 0%, transparent 50%), + var(--bg); +} + +:root[data-theme="clawdash"] body::after { + display: none; +} + +:root[data-theme="clawdash"] .nav-item--active { + border-image: linear-gradient(to bottom, var(--kn-silver-bright), var(--kn-claw)) 1; + border-image-slice: 1; +} + +/* ─── High Contrast Overrides (all themes) ─── */ + +@media (prefers-contrast: more) { + .topbar, + .sidebar, + .nav-item--active, + .stat-card, + .callout, + .pill, + pre, + input, + button { + box-shadow: 0 0 0 2px var(--text) !important; + border-width: 1.5px; + } +} + +/* ════════════════════════════════════════════════════════ + Animations + ════════════════════════════════════════════════════════ */ + @keyframes rise { from { opacity: 0; @@ -361,6 +896,15 @@ select { } } +@keyframes chrome-shimmer { + 0% { + left: -100%; + } + 100% { + left: 100%; + } +} + /* Stagger animation delays for grouped elements */ .stagger-1 { animation-delay: 0ms; diff --git a/ui/src/styles/chat.css b/ui/src/styles/chat.css index 07d3b644a63..d35b7316dde 100644 --- a/ui/src/styles/chat.css +++ b/ui/src/styles/chat.css @@ -3,3 +3,4 @@ @import "./chat/grouped.css"; @import "./chat/tool-cards.css"; @import "./chat/sidebar.css"; +@import "./chat/agent-chat.css"; diff --git a/ui/src/styles/chat/agent-chat.css b/ui/src/styles/chat/agent-chat.css new file mode 100644 index 00000000000..13d4023a54b --- /dev/null +++ b/ui/src/styles/chat/agent-chat.css @@ -0,0 +1,1287 @@ +/* =========================================== + Agent Chat — ported from dashboard-lit + =========================================== */ + +.agent-chat { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + position: relative; +} + +.agent-chat__thread { + flex: 1 1 0; + min-height: 0; + overflow-y: auto; + padding: 12px 18px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.agent-chat__empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + font-size: 0.92rem; +} + +.agent-chat__error { + color: color-mix(in srgb, var(--accent) 85%, #fff); + font-size: 0.85rem; + padding: 6px 10px; + margin-top: 4px; + background: color-mix(in srgb, var(--accent) 8%, transparent); + border-radius: var(--radius-sm); + border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); +} + +/* ─── Welcome / Empty State ─── */ + +.agent-chat__welcome { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 40px 24px 32px; + text-align: center; + position: relative; + overflow: hidden; +} + +.agent-chat__welcome-glow { + position: absolute; + top: 10%; + left: 50%; + transform: translateX(-50%); + width: 280px; + height: 180px; + border-radius: 50%; + background: radial-gradient(ellipse, var(--agent-color, var(--accent)) 0%, transparent 70%); + opacity: 0.06; + pointer-events: none; + filter: blur(40px); +} + +.agent-chat__welcome h2 { + font-size: 1.5rem; + font-weight: 700; + color: var(--text); + margin: 8px 0 0; + letter-spacing: -0.02em; +} + +.agent-chat__personality { + font-size: 0.88rem; + color: var(--muted); + max-width: 380px; + line-height: 1.55; + margin: 2px 0 0; +} + +.agent-chat__badges { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: center; + margin-top: 6px; +} + +.agent-chat__badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 12px; + border-radius: 999px; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.01em; +} + +.agent-chat__badge svg { + width: 14px; + height: 14px; +} + +/* ─── Starter Cards ─── */ + +.agent-chat__starters { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 16px; + width: 100%; + max-width: 420px; +} + +.agent-chat__starter { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--card); + color: var(--text); + font-size: 0.82rem; + font-weight: 500; + text-align: left; + cursor: pointer; + transition: + border-color var(--duration-fast) ease, + background var(--duration-fast) ease, + box-shadow var(--duration-fast) ease, + transform var(--duration-fast) var(--ease-spring); + line-height: 1.35; +} + +.agent-chat__starter:hover { + border-color: color-mix(in srgb, var(--agent-color, var(--accent)) 45%, transparent); + background: color-mix(in srgb, var(--agent-color, var(--accent)) 5%, transparent); + box-shadow: 0 2px 12px color-mix(in srgb, var(--agent-color, var(--accent)) 8%, transparent); + transform: translateY(-1px); +} + +.agent-chat__starter:active { + transform: translateY(0); + box-shadow: none; +} + +.agent-chat__starter:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.agent-chat__starter-icon { + font-size: 1.15rem; + line-height: 1; + flex-shrink: 0; +} + +.agent-chat__starter-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-chat__starter-arrow { + display: flex; + align-items: center; + color: var(--agent-color, var(--accent)); + opacity: 0; + transform: translateX(-3px); + transition: + opacity var(--duration-fast) ease, + transform var(--duration-fast) ease; + flex-shrink: 0; +} + +.agent-chat__starter-arrow svg { + width: 14px; + height: 14px; +} + +.agent-chat__starter:hover .agent-chat__starter-arrow { + opacity: 0.8; + transform: translateX(0); +} + +@media (max-width: 400px) { + .agent-chat__starters { + grid-template-columns: 1fr; + max-width: 280px; + } +} + +.agent-chat__hint { + font-size: 0.73rem; + color: var(--muted); + margin-top: 20px; + opacity: 0.7; +} + +.agent-chat__hint kbd { + display: inline-block; + padding: 1px 5px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--card); + font-size: 0.7rem; + font-family: inherit; +} + +/* ─── Avatar Circle ─── */ + +.agent-chat__avatar { + width: 56px; + height: 56px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; + font-weight: 700; + color: #fff; + background: var(--agent-color, var(--accent)); + flex-shrink: 0; +} + +.agent-chat__avatar--sm { + width: 24px; + height: 24px; + font-size: 0.65rem; +} + +/* ─── Chat Bubble ─── */ + +.chat-bubble { + padding: 10px 14px; + max-width: 100%; + word-wrap: break-word; + overflow-wrap: break-word; + position: relative; +} + +.chat-bubble--history { + opacity: 0.65; +} + +.chat-bubble--user { + background: color-mix(in srgb, var(--accent) 6%, var(--card)); + border-radius: var(--radius-lg); + border: 1px solid color-mix(in srgb, var(--accent) 14%, transparent); + margin-left: auto; + max-width: 85%; +} + +.chat-bubble--assistant { + padding: 10px 14px; +} + +.chat-bubble--tool { + padding: 4px 14px; +} + +.chat-bubble__header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.chat-bubble__role { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ok); +} + +.chat-bubble--user .chat-bubble__role { + color: var(--accent); +} + +.chat-bubble__role--tool { + color: var(--warn); + display: inline-flex; + align-items: center; + gap: 4px; +} + +.chat-bubble__role--tool svg { + width: 14px; + height: 14px; +} + +.chat-bubble__model-tag { + font-size: 0.68rem; + font-weight: 600; + padding: 1px 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--text) 8%, transparent); + color: var(--muted); +} + +.chat-bubble__ts { + font-size: 0.72rem; + color: var(--muted); +} + +.chat-bubble__body { + font-size: 0.92rem; + line-height: 1.45; + white-space: pre-wrap; + word-wrap: break-word; +} + +.chat-bubble__actions { + display: none; + gap: 4px; + margin-top: 4px; +} + +.chat-bubble:hover .chat-bubble__actions { + display: flex; +} + +.chat-bubble__action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: all var(--duration-fast) ease; + padding: 0; +} + +.chat-bubble__action svg { + width: 14px; + height: 14px; +} + +.chat-bubble__action:hover { + color: var(--text); + background: var(--bg-hover); +} + +/* ─── Chat Divider ─── */ + +.agent-chat__divider { + display: flex; + align-items: center; + gap: 12px; + margin: 10px 0; + font-size: 0.72rem; + color: var(--accent); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.agent-chat__divider::before, +.agent-chat__divider::after { + content: ""; + flex: 1; + height: 1px; + background: color-mix(in srgb, var(--accent) 30%, transparent); +} + +/* ─── Streaming Indicator ─── */ + +.agent-chat__streaming { + padding: 10px 14px; + border-left: 2px solid var(--accent); + animation: chat-pulse 1.5s ease-in-out infinite; +} + +.agent-chat__streaming-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.agent-chat__streaming-name { + font-size: 0.82rem; + font-weight: 600; + color: var(--text); +} + +.agent-chat__streaming-dots { + display: inline-flex; + gap: 3px; + align-items: center; +} + +.agent-chat__streaming-dots span { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--accent); + animation: chat-pulse 1.2s ease-in-out infinite; +} + +.agent-chat__streaming-dots span:nth-child(2) { + animation-delay: 0.2s; +} + +.agent-chat__streaming-dots span:nth-child(3) { + animation-delay: 0.4s; +} + +.agent-chat__streaming-label { + font-size: 0.75rem; + color: var(--muted); + font-style: italic; +} + +.agent-chat__streaming-timer { + font-size: 0.72rem; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.agent-chat__streaming-content { + font-size: 0.92rem; + line-height: 1.45; +} + +.agent-chat__cursor { + display: inline-block; + width: 2px; + height: 1em; + background: var(--accent); + margin-left: 1px; + vertical-align: text-bottom; + animation: cursor-blink 0.8s step-end infinite; +} + +@keyframes cursor-blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +@keyframes chat-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* ─── Input Bar (Cursor-style unified container) ─── */ + +.agent-chat__input { + position: relative; + display: flex; + flex-direction: column; + margin: 0 18px 14px; + padding: 0; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + flex-shrink: 0; + overflow: hidden; + transition: + border-color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease; +} + +.agent-chat__input:focus-within { + border-color: color-mix(in srgb, var(--accent) 50%, transparent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 10%, transparent); +} + +@supports (backdrop-filter: blur(1px)) { + .agent-chat__input { + backdrop-filter: blur(16px) saturate(1.8); + -webkit-backdrop-filter: blur(16px) saturate(1.8); + } +} + +/* Textarea — full width, borderless inside the container */ + +.agent-chat__input > textarea { + width: 100%; + min-height: 40px; + max-height: 150px; + resize: none; + padding: 12px 14px 8px; + border: none; + background: transparent; + color: var(--text); + font-size: 0.92rem; + font-family: inherit; + line-height: 1.4; + outline: none; + box-sizing: border-box; +} + +.agent-chat__input > textarea::placeholder { + color: var(--muted); +} + +/* ─── Toolbar (below textarea) ─── */ + +.agent-chat__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.agent-chat__toolbar-left, +.agent-chat__toolbar-right { + display: flex; + align-items: center; + gap: 4px; +} + +/* ─── Toolbar buttons (ghost style) ─── */ + +.agent-chat__input-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + flex-shrink: 0; + transition: all var(--duration-fast) ease; + padding: 0; +} + +.agent-chat__input-btn svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.agent-chat__input-btn:hover:not(:disabled) { + color: var(--text); + background: var(--bg-hover); +} + +.agent-chat__input-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.agent-chat__input-btn--active { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.agent-chat__toolbar .btn-ghost { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + padding: 0; + transition: all var(--duration-fast) ease; +} + +.agent-chat__toolbar .btn-ghost svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.agent-chat__toolbar .btn-ghost:hover:not(:disabled) { + color: var(--text); + background: var(--bg-hover); +} + +.agent-chat__toolbar .btn-ghost:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.agent-chat__input-divider { + width: 1px; + height: 16px; + background: var(--border); + margin: 0 4px; +} + +.agent-chat__token-count { + font-size: 0.7rem; + color: var(--muted); + white-space: nowrap; + flex-shrink: 0; + align-self: center; +} + +/* Send / Stop button */ + +.chat-send-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-md); + border: none; + background: var(--accent); + color: var(--accent-foreground); + cursor: pointer; + flex-shrink: 0; + transition: all var(--duration-fast) ease; + padding: 0; +} + +.chat-send-btn svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chat-send-btn:hover:not(:disabled) { + background: var(--accent-hover); + box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 24%, transparent); +} + +.chat-send-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.chat-send-btn--stop { + background: var(--danger); +} + +.chat-send-btn--stop:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger) 85%, #fff); +} + +/* ─── Search Bar ─── */ + +.agent-chat__search-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-bottom: 1px solid var(--border); + background: var(--card); +} + +.agent-chat__search-bar svg { + width: 16px; + height: 16px; + color: var(--muted); + flex-shrink: 0; +} + +.agent-chat__search-bar input { + flex: 1; + border: none; + background: transparent; + color: var(--text); + font-size: 0.88rem; + outline: none; +} + +.agent-chat__search-bar input::placeholder { + color: var(--muted); +} + +/* ─── Pinned Messages ─── */ + +.agent-chat__pinned { + border-bottom: 1px solid var(--border); + padding: 6px 14px; +} + +.agent-chat__pinned-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--accent); + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + transition: background var(--duration-fast) ease; +} + +.agent-chat__pinned-toggle svg { + width: 14px; + height: 14px; +} + +.agent-chat__pinned-toggle:hover { + background: var(--bg-hover); +} + +.agent-chat__pinned-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 4px; + padding-left: 8px; +} + +.agent-chat__pinned-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-radius: var(--radius-sm); + font-size: 0.82rem; +} + +.agent-chat__pinned-role { + font-weight: 600; + font-size: 0.72rem; + text-transform: uppercase; + color: var(--muted); + flex-shrink: 0; +} + +.agent-chat__pinned-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); +} + +/* ─── Scroll Pill ─── */ + +.agent-chat__scroll-pill { + position: absolute; + bottom: 100px; + left: 50%; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 14px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--card); + color: var(--accent); + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + box-shadow: var(--shadow-md); + z-index: 20; + transition: all var(--duration-fast) ease; +} + +.agent-chat__scroll-pill svg { + width: 14px; + height: 14px; +} + +.agent-chat__scroll-pill:hover { + background: color-mix(in srgb, var(--accent) 10%, var(--card)); +} + +/* ─── Slash Command Menu ─── */ + +.slash-menu { + position: absolute; + bottom: 100%; + left: 0; + right: 0; + max-height: 320px; + overflow-y: auto; + background: var(--popover); + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + z-index: 30; + margin-bottom: 4px; + padding: 6px; + scrollbar-width: thin; +} + +.slash-menu-group + .slash-menu-group { + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.slash-menu-group__label { + padding: 4px 10px 2px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--accent); + opacity: 0.7; +} + +.slash-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: + background var(--duration-fast) ease, + color var(--duration-fast) ease; +} + +.slash-menu-item:hover, +.slash-menu-item--active { + background: color-mix(in srgb, var(--accent) 10%, var(--bg-hover)); +} + +.slash-menu-icon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; + color: var(--accent); + opacity: 0.7; +} + +.slash-menu-icon svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.slash-menu-item--active .slash-menu-icon, +.slash-menu-item:hover .slash-menu-icon { + opacity: 1; +} + +.slash-menu-name { + font-size: 0.82rem; + font-weight: 600; + font-family: var(--mono); + color: var(--accent); + white-space: nowrap; +} + +.slash-menu-args { + font-size: 0.75rem; + color: var(--muted); + font-family: var(--mono); + opacity: 0.65; +} + +.slash-menu-desc { + font-size: 0.75rem; + color: var(--muted); + flex: 1; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.slash-menu-item--active .slash-menu-name { + color: var(--accent-hover); +} + +.slash-menu-item--active .slash-menu-desc { + color: var(--text); +} + +/* ─── Attachment Previews ─── */ + +.chat-attachments-preview { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.chat-attachment-thumb { + position: relative; + width: 60px; + height: 60px; + border-radius: var(--radius-sm); + overflow: hidden; + border: 1px solid var(--border); +} + +.chat-attachment-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.chat-attachment-remove { + position: absolute; + top: 2px; + right: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 12px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.chat-attachment-file { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + color: var(--muted); + padding: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ─── Reasoning Block ─── */ + +.reasoning-block { + margin: 4px 0; +} + +.reasoning-block__toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-hover); + color: var(--muted); + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + transition: all var(--duration-fast) ease; +} + +.reasoning-block__toggle:hover { + color: var(--text); + border-color: var(--border-strong); +} + +.reasoning-block__content { + display: none; + margin-top: 6px; + padding: 8px 12px; + font-size: 0.82rem; + line-height: 1.5; + color: var(--muted); + font-style: italic; + white-space: pre-wrap; + word-wrap: break-word; + border-left: 2px solid var(--border); +} + +.reasoning-block--open .reasoning-block__content { + display: block; +} + +.reasoning-block--streaming .reasoning-block__toggle { + animation: chat-pulse 1.5s ease-in-out infinite; +} + +/* ─── Tool Block ─── */ + +.tool-block { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg); + overflow: hidden; + margin: 4px 0; +} + +.tool-block__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + font-size: 0.82rem; + font-weight: 600; + color: var(--text); + transition: background var(--duration-fast) ease; +} + +.tool-block__header:hover { + background: var(--bg-hover); +} + +.tool-block__name { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.tool-block__name svg { + width: 14px; + height: 14px; +} + +.tool-block__body { + display: none; + padding: 0 12px 10px; +} + +.tool-block--open .tool-block__body { + display: block; +} + +.tool-block__output { + margin: 0; + font-family: var(--mono); + font-size: 0.78rem; + line-height: 1.5; + color: var(--muted); + white-space: pre-wrap; + word-wrap: break-word; + max-height: 300px; + overflow: auto; + padding: 8px; + border-radius: var(--radius-sm); + background: var(--bg-accent); + border: 1px solid var(--border); +} + +.tool-block__chevron { + transition: transform var(--duration-fast) ease; +} + +.tool-block__chevron svg { + width: 14px; + height: 14px; +} + +.tool-block--open .tool-block__chevron { + transform: rotate(180deg); +} + +/* ─── File Input (hidden) ─── */ + +.agent-chat__file-input { + display: none; +} + +/* ─── Danger ghost button ─── */ + +.btn-ghost--danger:hover { + color: var(--danger) !important; +} + +.btn-ghost--sm { + padding: 4px; +} + +.btn-ghost--sm svg { + width: 14px; + height: 14px; +} + +/* ─── Agent Bar ─── */ + +.chat-agent-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + flex-shrink: 0; + gap: 8px; +} + +.chat-agent-bar__left { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.chat-agent-bar__right { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.chat-agent-bar__name { + font-size: 13px; + font-weight: 600; + color: var(--text); +} + +.chat-agent-select { + background: color-mix(in srgb, var(--secondary) 70%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text); + font-size: 13px; + font-weight: 500; + padding: 4px 24px 4px 8px; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%237d8590' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 6px center; + transition: + border-color 150ms ease, + background 150ms ease; +} + +.chat-agent-select:hover { + border-color: var(--border-strong); + background: color-mix(in srgb, var(--secondary) 90%, transparent); +} + +.chat-agent-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-subtle); +} + +/* ─── Sessions Panel ─── */ + +.chat-sessions-panel { + position: relative; +} + +.chat-sessions-summary { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: var(--radius-md); + font-size: 12px; + font-weight: 500; + color: var(--muted); + cursor: pointer; + user-select: none; + list-style: none; + transition: + color 150ms ease, + background 150ms ease; +} + +.chat-sessions-summary::-webkit-details-marker { + display: none; +} + +.chat-sessions-summary::before { + content: "▸"; + font-size: 9px; + transition: transform 150ms ease; +} + +.chat-sessions-panel[open] > .chat-sessions-summary::before { + transform: rotate(90deg); +} + +.chat-sessions-summary:hover { + color: var(--text); + background: color-mix(in srgb, var(--bg-hover) 60%, transparent); +} + +.chat-sessions-summary svg { + width: 13px; + height: 13px; +} + +.chat-sessions-list { + position: absolute; + top: 100%; + left: 0; + z-index: 50; + min-width: 240px; + max-width: 360px; + max-height: 280px; + overflow-y: auto; + margin-top: 4px; + padding: 4px; + background: var(--popover); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + gap: 2px; +} + +.chat-session-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--text); + font-size: 12px; + cursor: pointer; + text-align: left; + width: 100%; + transition: background 120ms ease; +} + +.chat-session-item:hover { + background: var(--bg-hover); +} + +.chat-session-item--active { + background: var(--accent-subtle); + color: var(--accent); + font-weight: 500; +} + +.chat-session-item__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.chat-session-item__meta { + font-size: 11px; + flex-shrink: 0; + white-space: nowrap; +} diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index c43743267a9..46cd18f4e24 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -83,14 +83,15 @@ /* Avatar Styles */ .chat-avatar { - width: 40px; - height: 40px; - border-radius: 8px; - background: var(--panel-strong); + width: 38px; + height: 38px; + border-radius: var(--radius-md); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); + background: color-mix(in srgb, var(--panel-strong) 95%, transparent); display: grid; place-items: center; font-weight: 600; - font-size: 14px; + font-size: 13px; flex-shrink: 0; align-self: flex-end; /* Align with last message in group */ margin-bottom: 4px; /* Optical alignment */ @@ -127,14 +128,15 @@ img.chat-avatar { .chat-bubble { position: relative; display: inline-block; - border: 1px solid transparent; - background: var(--card); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); + background: color-mix(in srgb, var(--card) 97%, transparent); border-radius: var(--radius-lg); padding: 10px 14px; - box-shadow: none; + box-shadow: inset 0 1px 0 var(--card-highlight); transition: background 150ms ease-out, - border-color 150ms ease-out; + border-color 150ms ease-out, + box-shadow 150ms ease-out; max-width: 100%; word-wrap: break-word; } @@ -147,8 +149,8 @@ img.chat-avatar { position: absolute; top: 6px; right: 8px; - border: 1px solid var(--border); - background: var(--bg); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); + background: color-mix(in srgb, var(--bg) 94%, transparent); color: var(--muted); border-radius: var(--radius-md); padding: 4px 6px; @@ -159,7 +161,8 @@ img.chat-avatar { pointer-events: none; transition: opacity 120ms ease-out, - background 120ms ease-out; + background 120ms ease-out, + border-color 120ms ease-out; } .chat-copy-btn__icon { @@ -206,6 +209,7 @@ img.chat-avatar { .chat-copy-btn:hover { background: var(--bg-hover); + border-color: var(--border-strong); } .chat-copy-btn[data-copying="1"] { @@ -243,29 +247,20 @@ img.chat-avatar { } } -/* Light mode: restore borders */ -:root[data-theme="light"] .chat-bubble { - border-color: var(--border); - box-shadow: inset 0 1px 0 var(--card-highlight); -} - .chat-bubble:hover { - background: var(--bg-hover); + background: color-mix(in srgb, var(--card) 82%, var(--bg-hover)); + border-color: var(--border-strong); + box-shadow: var(--shadow-sm); } /* User bubbles have different styling */ .chat-group.user .chat-bubble { - background: var(--accent-subtle); - border-color: transparent; -} - -:root[data-theme="light"] .chat-group.user .chat-bubble { - border-color: rgba(234, 88, 12, 0.2); - background: rgba(251, 146, 60, 0.12); + background: color-mix(in srgb, var(--accent-subtle) 85%, var(--secondary)); + border-color: color-mix(in srgb, var(--accent) 30%, transparent); } .chat-group.user .chat-bubble:hover { - background: rgba(255, 77, 77, 0.15); + background: var(--danger-subtle); } /* Streaming animation */ @@ -298,3 +293,59 @@ img.chat-avatar { transform: translateY(0); } } + +/* Delete button (appears on hover in group footer) */ + +.chat-group-delete { + all: unset; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: var(--radius-sm); + color: var(--muted); + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: + opacity 120ms ease-out, + color 120ms ease-out, + background 120ms ease-out; + margin-left: auto; +} + +.chat-group-delete svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chat-group:hover .chat-group-delete { + opacity: 0.5; + pointer-events: auto; +} + +.chat-group-delete:hover { + opacity: 1 !important; + color: var(--danger); + background: var(--danger-subtle); +} + +.chat-group-delete:focus-visible { + opacity: 1; + pointer-events: auto; + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +@media (hover: none) { + .chat-group-delete { + opacity: 0.5; + pointer-events: auto; + } +} diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 67299bab850..fa63922897d 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -52,11 +52,15 @@ flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */ overflow-y: auto; overflow-x: hidden; - padding: 12px 4px; + padding: 14px 8px; margin: 0 -4px; min-height: 0; /* Allow shrinking for flex scroll behavior */ - border-radius: 12px; - background: transparent; + border-radius: var(--radius-lg); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--panel) 72%, transparent), + transparent + ); } /* Focus mode exit button */ @@ -111,20 +115,22 @@ font-size: 13px; font-family: var(--font-body); color: var(--text); - background: var(--panel-strong); - border: 1px solid var(--border); + background: color-mix(in srgb, var(--panel-strong) 92%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 86%, transparent); border-radius: 999px; cursor: pointer; white-space: nowrap; z-index: 10; transition: background 150ms ease-out, - border-color 150ms ease-out; + border-color 150ms ease-out, + box-shadow 150ms ease-out; } .chat-new-messages:hover { background: var(--panel); - border-color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 36%, transparent); + box-shadow: var(--shadow-sm); } .chat-new-messages svg { @@ -147,8 +153,9 @@ flex-direction: column; gap: 12px; margin-top: auto; /* Push to bottom of flex container */ - padding: 12px 4px 4px; - background: linear-gradient(to bottom, transparent, var(--bg) 20%); + padding: 14px 6px 6px; + background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--bg) 94%, black) 22%); + backdrop-filter: blur(4px); z-index: 10; } @@ -218,21 +225,6 @@ stroke-width: 2px; } -/* Light theme attachment overrides */ -:root[data-theme="light"] .chat-attachments { - background: #f8fafc; - border-color: rgba(16, 24, 40, 0.1); -} - -:root[data-theme="light"] .chat-attachment { - border-color: rgba(16, 24, 40, 0.15); - background: #fff; -} - -:root[data-theme="light"] .chat-attachment__remove { - background: rgba(0, 0, 0, 0.6); -} - /* Message images (sent images displayed in chat) */ .chat-message-images { display: flex; @@ -267,10 +259,6 @@ flex: 1; } -:root[data-theme="light"] .chat-compose { - background: linear-gradient(to bottom, transparent, var(--bg-content) 20%); -} - .chat-compose__field { flex: 1 1 auto; min-width: 0; @@ -290,13 +278,16 @@ min-height: 40px; max-height: 150px; padding: 9px 12px; - border-radius: 8px; + border-radius: var(--radius-md); overflow-y: auto; resize: none; white-space: pre-wrap; font-family: var(--font-body); font-size: 14px; line-height: 1.45; + border: 1px solid color-mix(in srgb, var(--input) 92%, transparent); + background: color-mix(in srgb, var(--card) 98%, transparent); + box-shadow: inset 0 1px 0 var(--card-highlight); } .chat-compose__field textarea:disabled { @@ -351,25 +342,22 @@ display: inline-flex; align-items: center; justify-content: center; - border: 1px solid var(--border); - background: rgba(255, 255, 255, 0.06); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); + background: color-mix(in srgb, var(--secondary) 85%, transparent); + border-radius: var(--radius-md); } /* Controls separator */ .chat-controls__separator { - color: rgba(255, 255, 255, 0.4); + color: var(--border); font-size: 18px; margin: 0 8px; font-weight: 300; } -:root[data-theme="light"] .chat-controls__separator { - color: rgba(16, 24, 40, 0.3); -} - .btn--icon:hover { - background: rgba(255, 255, 255, 0.12); - border-color: rgba(255, 255, 255, 0.2); + background: var(--bg-hover); + border-color: var(--border-strong); } /* Ensure chat toolbar toggles have a clearly visible active state. */ @@ -379,27 +367,6 @@ color: var(--accent); } -/* Light theme icon button overrides */ -:root[data-theme="light"] .btn--icon { - background: #ffffff; - border-color: var(--border); - box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); - color: var(--muted); -} - -:root[data-theme="light"] .btn--icon:hover { - background: #ffffff; - border-color: var(--border-strong); - color: var(--text); -} - -:root[data-theme="light"] .chat-controls .btn--icon.active { - border-color: var(--accent); - background: var(--accent-subtle); - color: var(--accent); - box-shadow: 0 0 0 1px var(--accent-subtle); -} - .btn--icon svg { display: block; width: 18px; @@ -425,15 +392,9 @@ gap: 4px; font-size: 12px; padding: 4px 10px; - background: rgba(255, 255, 255, 0.04); - border-radius: 6px; - border: 1px solid var(--border); -} - -/* Light theme thinking indicator override */ -:root[data-theme="light"] .chat-controls__thinking { - background: rgba(255, 255, 255, 0.9); - border-color: rgba(16, 24, 40, 0.15); + background: color-mix(in srgb, var(--secondary) 90%, transparent); + border-radius: var(--radius-sm); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); } @media (max-width: 640px) { diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index 934e285d95b..bc2949309d5 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -19,11 +19,12 @@ .chat-sidebar { flex: 1; min-width: 300px; - border-left: 1px solid var(--border); + border-left: 1px solid color-mix(in srgb, var(--border) 90%, transparent); display: flex; flex-direction: column; overflow: hidden; animation: slide-in 200ms ease-out; + background: color-mix(in srgb, var(--panel) 94%, transparent); } @keyframes slide-in { @@ -50,12 +51,13 @@ justify-content: space-between; align-items: center; padding: 12px 16px; - border-bottom: 1px solid var(--border); + border-bottom: 1px solid color-mix(in srgb, var(--border) 88%, transparent); flex-shrink: 0; position: sticky; top: 0; z-index: 10; - background: var(--panel); + background: color-mix(in srgb, var(--panel) 95%, transparent); + backdrop-filter: blur(6px); } /* Smaller close button for sidebar */ @@ -79,12 +81,13 @@ .sidebar-markdown { font-size: 14px; - line-height: 1.5; + line-height: 1.6; } .sidebar-markdown pre { - background: rgba(0, 0, 0, 0.12); - border-radius: 4px; + background: color-mix(in srgb, var(--secondary) 90%, transparent); + border-radius: var(--radius-md); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); padding: 12px; overflow-x: auto; } diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css index d6eea9866b2..ead2a69058e 100644 --- a/ui/src/styles/chat/text.css +++ b/ui/src/styles/chat/text.css @@ -5,17 +5,12 @@ .chat-thinking { margin-bottom: 10px; padding: 10px 12px; - border-radius: 10px; - border: 1px dashed rgba(255, 255, 255, 0.18); - background: rgba(255, 255, 255, 0.04); + border-radius: var(--radius-md); + border: 1px dashed color-mix(in srgb, var(--border) 84%, transparent); + background: color-mix(in srgb, var(--secondary) 75%, transparent); color: var(--muted); font-size: 12px; - line-height: 1.4; -} - -:root[data-theme="light"] .chat-thinking { - border-color: rgba(16, 24, 40, 0.25); - background: rgba(16, 24, 40, 0.04); + line-height: 1.45; } .chat-text { @@ -57,14 +52,16 @@ } .chat-text :where(:not(pre) > code) { - background: rgba(0, 0, 0, 0.15); - padding: 0.15em 0.4em; - border-radius: 4px; + background: color-mix(in srgb, var(--secondary) 82%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + padding: 0.15em 0.42em; + border-radius: 5px; } .chat-text :where(pre) { - background: rgba(0, 0, 0, 0.15); - border-radius: 6px; + background: color-mix(in srgb, var(--secondary) 82%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + border-radius: var(--radius-md); padding: 10px 12px; overflow-x: auto; } @@ -74,12 +71,50 @@ padding: 0; } +/* Collapsed JSON code blocks */ + +.chat-text :where(details.json-collapse) { + background: color-mix(in srgb, var(--secondary) 82%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + border-radius: var(--radius-md); +} + +.chat-text :where(details.json-collapse > summary) { + padding: 8px 12px; + cursor: pointer; + font-size: 12px; + color: var(--muted); + font-family: var(--mono); + user-select: none; + list-style: none; +} + +.chat-text :where(details.json-collapse > summary::-webkit-details-marker) { + display: none; +} + +.chat-text :where(details.json-collapse > summary::before) { + content: "▸ "; +} + +.chat-text :where(details.json-collapse[open] > summary::before) { + content: "▾ "; +} + +.chat-text :where(details.json-collapse > pre) { + background: none; + border: none; + border-top: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + border-radius: 0; + margin: 0; +} + .chat-text :where(blockquote) { - border-left: 3px solid var(--border-strong); + border-left: 3px solid color-mix(in srgb, var(--border-strong) 88%, transparent); padding-left: 12px; margin-left: 0; color: var(--muted); - background: rgba(255, 255, 255, 0.02); + background: color-mix(in srgb, var(--secondary) 78%, transparent); padding: 8px 12px; border-radius: 0 var(--radius-sm) var(--radius-sm) 0; } @@ -87,34 +122,12 @@ .chat-text :where(blockquote blockquote) { margin-top: 8px; border-left-color: var(--border-hover); - background: rgba(255, 255, 255, 0.03); + background: color-mix(in srgb, var(--secondary) 55%, transparent); } .chat-text :where(blockquote blockquote blockquote) { border-left-color: var(--muted-strong); - background: rgba(255, 255, 255, 0.04); -} - -:root[data-theme="light"] .chat-text :where(blockquote) { - background: rgba(0, 0, 0, 0.03); -} - -:root[data-theme="light"] .chat-text :where(blockquote blockquote) { - background: rgba(0, 0, 0, 0.05); -} - -:root[data-theme="light"] .chat-text :where(blockquote blockquote blockquote) { - background: rgba(0, 0, 0, 0.04); -} - -:root[data-theme="light"] .chat-text :where(:not(pre) > code) { - background: rgba(0, 0, 0, 0.08); - border: 1px solid rgba(0, 0, 0, 0.1); -} - -:root[data-theme="light"] .chat-text :where(pre) { - background: rgba(0, 0, 0, 0.05); - border: 1px solid rgba(0, 0, 0, 0.1); + background: color-mix(in srgb, var(--secondary) 60%, transparent); } .chat-text :where(hr) { diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css index 6384db115f0..c1e478aa9fc 100644 --- a/ui/src/styles/chat/tool-cards.css +++ b/ui/src/styles/chat/tool-cards.css @@ -1,14 +1,15 @@ /* Tool Card Styles */ .chat-tool-card { - border: 1px solid var(--border); - border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--border) 90%, transparent); + border-radius: var(--radius-md); padding: 12px; margin-top: 8px; - background: var(--card); + background: color-mix(in srgb, var(--card) 97%, transparent); box-shadow: inset 0 1px 0 var(--card-highlight); transition: border-color 150ms ease-out, - background 150ms ease-out; + background 150ms ease-out, + box-shadow 150ms ease-out; /* Fixed max-height to ensure cards don't expand too much */ max-height: 120px; overflow: hidden; @@ -16,7 +17,8 @@ .chat-tool-card:hover { border-color: var(--border-strong); - background: var(--bg-hover); + background: color-mix(in srgb, var(--card) 82%, var(--bg-hover)); + box-shadow: var(--shadow-sm); } /* First tool card in a group - no top margin */ @@ -128,13 +130,13 @@ color: var(--muted); margin-top: 8px; padding: 8px 10px; - background: var(--secondary); + background: color-mix(in srgb, var(--secondary) 92%, transparent); border-radius: var(--radius-md); white-space: pre-wrap; overflow: hidden; max-height: 44px; line-height: 1.4; - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); } .chat-tool-card--clickable:hover .chat-tool-card__preview { @@ -148,16 +150,18 @@ color: var(--text); margin-top: 6px; padding: 6px 8px; - background: var(--secondary); + background: color-mix(in srgb, var(--secondary) 92%, transparent); border-radius: var(--radius-sm); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); white-space: pre-wrap; word-break: break-word; } /* Reading Indicator */ .chat-reading-indicator { - background: transparent; - border: 1px solid var(--border); + background: color-mix(in srgb, var(--secondary) 70%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); + border-radius: var(--radius-md); padding: 12px; display: inline-flex; } @@ -200,3 +204,176 @@ transform: scale(1); } } + +/* =========================================== + Collapsible Tool Cards + =========================================== */ + +.chat-tools-collapse { + margin-top: 8px; + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--card) 94%, transparent); + overflow: hidden; +} + +.chat-tools-summary { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + color: var(--muted); + user-select: none; + list-style: none; + transition: + color 150ms ease, + background 150ms ease; +} + +.chat-tools-summary::-webkit-details-marker { + display: none; +} + +.chat-tools-summary::before { + content: "▸"; + font-size: 10px; + flex-shrink: 0; + transition: transform 150ms ease; +} + +.chat-tools-collapse[open] > .chat-tools-summary::before { + transform: rotate(90deg); +} + +.chat-tools-summary:hover { + color: var(--text); + background: color-mix(in srgb, var(--bg-hover) 50%, transparent); +} + +.chat-tools-summary__icon { + display: inline-flex; + align-items: center; + width: 14px; + height: 14px; + color: var(--accent); + opacity: 0.7; + flex-shrink: 0; +} + +.chat-tools-summary__icon svg { + width: 14px; + height: 14px; +} + +.chat-tools-summary__count { + font-weight: 600; + color: var(--text); +} + +.chat-tools-summary__names { + color: var(--muted); + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-tools-collapse__body { + padding: 4px 12px 12px; + border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); +} + +.chat-tools-collapse__body .chat-tool-card:first-child { + margin-top: 8px; +} + +/* =========================================== + Collapsible JSON Block + =========================================== */ + +.chat-json-collapse { + margin-top: 4px; + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--secondary) 60%, transparent); + overflow: hidden; +} + +.chat-json-summary { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + cursor: pointer; + font-size: 12px; + color: var(--muted); + user-select: none; + list-style: none; + transition: + color 150ms ease, + background 150ms ease; +} + +.chat-json-summary::-webkit-details-marker { + display: none; +} + +.chat-json-summary::before { + content: "▸"; + font-size: 10px; + flex-shrink: 0; + transition: transform 150ms ease; +} + +.chat-json-collapse[open] > .chat-json-summary::before { + transform: rotate(90deg); +} + +.chat-json-summary:hover { + color: var(--text); + background: color-mix(in srgb, var(--bg-hover) 50%, transparent); +} + +.chat-json-badge { + display: inline-flex; + align-items: center; + padding: 1px 5px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 15%, transparent); + color: var(--accent); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + line-height: 1.4; + flex-shrink: 0; +} + +.chat-json-label { + font-family: var(--mono); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-json-content { + margin: 0; + padding: 10px 12px; + border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + font-family: var(--mono); + font-size: 12px; + line-height: 1.5; + color: var(--text); + overflow-x: auto; + max-height: 400px; + overflow-y: auto; +} + +.chat-json-content code { + font-family: inherit; + font-size: inherit; +} diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 09b89d9c270..4413ba2e2a2 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1,5 +1,79 @@ @import "./chat.css"; +/* =========================================== + Login Gate + =========================================== */ + +.login-gate { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + min-height: 100dvh; + background: var(--bg); + padding: 24px; +} + +.login-gate__theme { + position: fixed; + top: 16px; + right: 16px; + z-index: 10; +} + +.login-gate__card { + width: min(520px, 100%); + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 32px; + animation: scale-in 0.25s var(--ease-out); +} + +.login-gate__header { + text-align: center; + margin-bottom: 24px; +} + +.login-gate__logo { + width: 48px; + height: 48px; + margin-bottom: 12px; +} + +.login-gate__title { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.login-gate__sub { + color: var(--muted); + font-size: 14px; + margin-top: 4px; +} + +.login-gate__form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.login-gate__connect { + margin-top: 4px; + width: 100%; + justify-content: center; + padding: 10px 16px; + font-size: 15px; + font-weight: 600; +} + +.login-gate__help { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + /* =========================================== Update Banner =========================================== */ @@ -26,7 +100,7 @@ } .update-banner__btn:hover:not(:disabled) { - background: rgba(239, 68, 68, 0.15); + background: var(--danger-subtle); } /* =========================================== @@ -56,7 +130,7 @@ } .card-title { - font-size: 15px; + font-size: 16px; font-weight: 600; letter-spacing: -0.02em; color: var(--text-strong); @@ -64,7 +138,7 @@ .card-sub { color: var(--muted); - font-size: 13px; + font-size: 14px; margin-top: 6px; line-height: 1.5; } @@ -74,10 +148,10 @@ =========================================== */ .stat { - background: var(--card); + background: color-mix(in srgb, var(--card) 96%, transparent); border-radius: var(--radius-md); padding: 14px 16px; - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 92%, transparent); transition: border-color var(--duration-normal) var(--ease-out), box-shadow var(--duration-normal) var(--ease-out); @@ -87,20 +161,20 @@ .stat:hover { border-color: var(--border-strong); box-shadow: - var(--shadow-sm), + 0 6px 16px rgba(0, 0, 0, 0.18), inset 0 1px 0 var(--card-highlight); } .stat-label { color: var(--muted); - font-size: 11px; + font-size: 12px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; } .stat-value { - font-size: 24px; + font-size: 26px; font-weight: 700; margin-top: 6px; letter-spacing: -0.03em; @@ -148,7 +222,7 @@ .account-count { margin-top: 10px; - font-size: 12px; + font-size: 13px; font-weight: 500; color: var(--muted); } @@ -184,13 +258,13 @@ .account-card-id { font-family: var(--mono); - font-size: 12px; + font-size: 13px; color: var(--muted); } .account-card-status { margin-top: 10px; - font-size: 13px; + font-size: 14px; } .account-card-status div { @@ -200,7 +274,7 @@ .account-card-error { margin-top: 8px; color: var(--danger); - font-size: 12px; + font-size: 13px; } /* =========================================== @@ -209,7 +283,7 @@ .label { color: var(--muted); - font-size: 12px; + font-size: 13px; font-weight: 500; } @@ -217,17 +291,20 @@ display: inline-flex; align-items: center; gap: 6px; - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 90%, transparent); padding: 6px 12px; border-radius: var(--radius-full); - background: var(--secondary); - font-size: 13px; + background: color-mix(in srgb, var(--secondary) 92%, transparent); + font-size: 14px; font-weight: 500; - transition: border-color var(--duration-fast) ease; + transition: + border-color var(--duration-fast) ease, + background var(--duration-fast) ease; } .pill:hover { border-color: var(--border-strong); + background: var(--bg-hover); } .pill.danger { @@ -241,67 +318,100 @@ =========================================== */ .theme-toggle { - --theme-item: 28px; - --theme-gap: 2px; - --theme-pad: 4px; - position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--clay-border-color); + border-radius: 999px; + padding: 5px; + height: 36px; + background: var(--clay-bg); + overflow: hidden; + max-width: 36px; + transition: + max-width var(--clay-duration-normal) var(--clay-easing), + padding var(--clay-duration-normal) var(--clay-easing); } -.theme-toggle__track { - position: relative; - display: grid; - grid-template-columns: repeat(3, var(--theme-item)); - gap: var(--theme-gap); - padding: var(--theme-pad); - border-radius: var(--radius-full); - border: 1px solid var(--border); - background: var(--secondary); +@media (hover: hover) { + .theme-toggle:hover { + max-width: 400px; + padding: 4px 6px; + } } -.theme-toggle__indicator { - position: absolute; - top: 50%; - left: var(--theme-pad); - width: var(--theme-item); - height: var(--theme-item); - border-radius: var(--radius-full); - transform: translateY(-50%) - translateX(calc(var(--theme-index, 0) * (var(--theme-item) + var(--theme-gap)))); - background: var(--accent); - transition: transform var(--duration-normal) var(--ease-out); - z-index: 0; +.theme-toggle:focus-within { + max-width: 400px; + padding: 4px 6px; } -.theme-toggle__button { - height: var(--theme-item); - width: var(--theme-item); - display: grid; - place-items: center; +.theme-toggle.theme-toggle--open { + max-width: 400px; + padding: 4px 6px; +} + +.theme-btn { border: 0; - border-radius: var(--radius-full); background: transparent; + padding: 6px 10px; + border-radius: 999px; + font-size: 0.84rem; color: var(--muted); + display: inline-flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; + flex-shrink: 0; cursor: pointer; - position: relative; - z-index: 1; - transition: color var(--duration-fast) ease; + transition: + color var(--clay-duration-fast) var(--clay-easing), + background var(--clay-duration-fast) var(--clay-easing), + transform var(--clay-duration-fast) var(--clay-easing); } -.theme-toggle__button:hover { +.theme-btn.active { + padding: 6px 8px; + background: var(--clay-bg-button); + color: var(--text); + box-shadow: var(--clay-shadow-pressed); +} + +.theme-btn:not(.active) { + opacity: 0; + pointer-events: none; + width: 0; + padding: 6px 0; + overflow: hidden; + transition: + opacity var(--clay-duration-fast) var(--clay-easing), + width var(--clay-duration-fast) var(--clay-easing), + padding var(--clay-duration-fast) var(--clay-easing), + color var(--clay-duration-fast) var(--clay-easing), + background var(--clay-duration-fast) var(--clay-easing), + transform var(--clay-duration-fast) var(--clay-easing); +} + +.theme-toggle:hover .theme-btn, +.theme-toggle:focus-within .theme-btn, +.theme-toggle--open .theme-btn { + opacity: 1; + pointer-events: auto; + width: auto; + padding: 6px 10px; +} + +.theme-btn:hover { + border: 0; color: var(--text); } -.theme-toggle__button.active { - color: var(--accent-foreground); +.theme-btn:active { + transform: scale(0.93); } -.theme-toggle__button.active .theme-icon { - stroke: var(--accent-foreground); -} - -.theme-icon { - width: 14px; - height: 14px; +.theme-btn svg { + width: 16px; + height: 16px; stroke: currentColor; fill: none; stroke-width: 1.5px; @@ -318,13 +428,13 @@ height: 8px; border-radius: var(--radius-full); background: var(--danger); - box-shadow: 0 0 8px rgba(239, 68, 68, 0.5); + box-shadow: 0 0 8px color-mix(in srgb, var(--danger) 50%, transparent); animation: pulse-subtle 2s ease-in-out infinite; } .statusDot.ok { background: var(--ok); - box-shadow: 0 0 8px rgba(34, 197, 94, 0.5); + box-shadow: 0 0 8px color-mix(in srgb, var(--ok) 50%, transparent); animation: none; } @@ -336,12 +446,13 @@ display: inline-flex; align-items: center; justify-content: center; + gap: 8px; - border: 1px solid var(--border); - background: var(--bg-elevated); - padding: 9px 16px; + border: 1px solid color-mix(in srgb, var(--border) 92%, transparent); + background: color-mix(in srgb, var(--bg-elevated) 95%, transparent); + padding: 10px 18px; border-radius: var(--radius-md); - font-size: 13px; + font-size: 14px; font-weight: 500; letter-spacing: -0.01em; cursor: pointer; @@ -352,14 +463,14 @@ transform var(--duration-fast) var(--ease-out); } -.btn:hover { +.btn:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-strong); transform: translateY(-1px); box-shadow: var(--shadow-sm); } -.btn:active { +.btn:active:not(:disabled) { background: var(--secondary); transform: translateY(0); box-shadow: none; @@ -377,18 +488,16 @@ } .btn.primary { - border-color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 88%, black 10%); background: var(--accent); color: var(--primary-foreground); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.24); } .btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); - box-shadow: - var(--shadow-md), - 0 0 20px var(--accent-glow); + box-shadow: var(--shadow-md); } /* Keyboard shortcut badge (shadcn style) */ @@ -412,28 +521,20 @@ background: rgba(255, 255, 255, 0.2); } -:root[data-theme="light"] .btn-kbd { - background: rgba(0, 0, 0, 0.08); -} - -:root[data-theme="light"] .btn.primary .btn-kbd { - background: rgba(255, 255, 255, 0.25); -} - .btn.active { - border-color: var(--accent); - background: var(--accent-subtle); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); + background: color-mix(in srgb, var(--accent-subtle) 75%, var(--secondary)); color: var(--accent); } .btn.danger { - border-color: transparent; + border-color: color-mix(in srgb, var(--danger) 25%, transparent); background: var(--danger-subtle); color: var(--danger); } .btn.danger:hover { - background: rgba(239, 68, 68, 0.15); + background: color-mix(in srgb, var(--danger-subtle) 70%, transparent); } .btn--sm { @@ -441,9 +542,16 @@ font-size: 12px; } +.btn:focus-visible { + border-color: var(--ring); + box-shadow: var(--focus-ring); +} + .btn:disabled { opacity: 0.5; cursor: not-allowed; + transform: none; + box-shadow: none; } /* =========================================== @@ -461,29 +569,39 @@ .field span { color: var(--muted); - font-size: 13px; + font-size: 14px; font-weight: 500; } .field input, .field textarea, .field select { - border: 1px solid var(--input); - background: var(--card); + border: 1px solid color-mix(in srgb, var(--input) 92%, transparent); + background: color-mix(in srgb, var(--card) 96%, var(--bg)); border-radius: var(--radius-md); - padding: 8px 12px; + padding: 10px 14px; outline: none; box-shadow: inset 0 1px 0 var(--card-highlight); transition: border-color var(--duration-fast) ease, - box-shadow var(--duration-fast) ease; + box-shadow var(--duration-fast) ease, + background var(--duration-fast) ease; } -.field input:focus, -.field textarea:focus, -.field select:focus { +.field input:focus-visible, +.field textarea:focus-visible, +.field select:focus-visible { border-color: var(--ring); box-shadow: var(--focus-ring); + background: var(--card); +} + +.field input:disabled, +.field textarea:disabled, +.field select:disabled { + opacity: 0.6; + cursor: not-allowed; + background: color-mix(in srgb, var(--secondary) 80%, transparent); } .field select { @@ -526,33 +644,6 @@ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } -:root[data-theme="light"] .field input, -:root[data-theme="light"] .field textarea, -:root[data-theme="light"] .field select { - background: var(--card); - border-color: var(--input); -} - -:root[data-theme="light"] .btn { - background: var(--bg); - border-color: var(--input); -} - -:root[data-theme="light"] .btn:hover { - background: var(--bg-hover); -} - -:root[data-theme="light"] .btn.active { - border-color: var(--accent); - background: var(--accent-subtle); - color: var(--accent); -} - -:root[data-theme="light"] .btn.primary { - background: var(--accent); - border-color: var(--accent); -} - /* =========================================== Utilities =========================================== */ @@ -580,23 +671,45 @@ } .callout.danger { - border-color: rgba(239, 68, 68, 0.25); - background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(239, 68, 68, 0.04) 100%); + border-color: color-mix(in srgb, var(--danger) 25%, transparent); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--danger) 8%, transparent) 0%, + color-mix(in srgb, var(--danger) 4%, transparent) 100% + ); color: var(--danger); } .callout.info { - border-color: rgba(59, 130, 246, 0.25); - background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.04) 100%); + border-color: color-mix(in srgb, var(--info) 25%, transparent); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--info) 8%, transparent) 0%, + color-mix(in srgb, var(--info) 4%, transparent) 100% + ); color: var(--info); } .callout.success { - border-color: rgba(34, 197, 94, 0.25); - background: linear-gradient(135deg, rgba(34, 197, 94, 0.08) 0%, rgba(34, 197, 94, 0.04) 100%); + border-color: color-mix(in srgb, var(--ok) 25%, transparent); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--ok) 8%, transparent) 0%, + color-mix(in srgb, var(--ok) 4%, transparent) 100% + ); color: var(--ok); } +.callout.warn { + border-color: color-mix(in srgb, var(--warn) 25%, transparent); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--warn) 8%, transparent) 0%, + color-mix(in srgb, var(--warn) 4%, transparent) 100% + ); + color: var(--warn); +} + /* Compaction indicator */ .compaction-indicator { align-self: center; @@ -607,7 +720,7 @@ line-height: 1.2; padding: 6px 14px; margin-bottom: 8px; - border-radius: 999px; + border-radius: var(--radius-full); border: 1px solid var(--border); background: var(--panel-strong); color: var(--text); @@ -629,7 +742,7 @@ .compaction-indicator--active { color: var(--info); - border-color: rgba(59, 130, 246, 0.35); + border-color: color-mix(in srgb, var(--info) 35%, transparent); } .compaction-indicator--active svg { @@ -638,17 +751,17 @@ .compaction-indicator--complete { color: var(--ok); - border-color: rgba(34, 197, 94, 0.35); + border-color: color-mix(in srgb, var(--ok) 35%, transparent); } .compaction-indicator--fallback { - color: #d97706; + color: var(--warn); border-color: rgba(217, 119, 6, 0.35); } .compaction-indicator--fallback-cleared { color: var(--ok); - border-color: rgba(34, 197, 94, 0.35); + border-color: color-mix(in srgb, var(--ok) 35%, transparent); } @keyframes compaction-spin { @@ -674,13 +787,6 @@ max-width: 100%; } -:root[data-theme="light"] .code-block, -:root[data-theme="light"] .list-item, -:root[data-theme="light"] .table-row, -:root[data-theme="light"] .chip { - background: var(--bg); -} - /* =========================================== Lists =========================================== */ @@ -691,16 +797,24 @@ container-type: inline-size; } +.list-scroll { + max-height: 400px; + overflow-y: auto; +} + .list-item { display: grid; grid-template-columns: minmax(0, 1fr) minmax(200px, 260px); gap: 16px; align-items: start; - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 92%, transparent); border-radius: var(--radius-md); padding: 12px; - background: var(--card); - transition: border-color var(--duration-fast) ease; + background: color-mix(in srgb, var(--card) 97%, transparent); + transition: + border-color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease, + background var(--duration-fast) ease; } .list-item-clickable { @@ -709,11 +823,14 @@ .list-item-clickable:hover { border-color: var(--border-strong); + background: color-mix(in srgb, var(--card) 80%, var(--bg-hover)); + box-shadow: var(--shadow-sm); } .list-item-selected { - border-color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); box-shadow: var(--focus-ring); + background: color-mix(in srgb, var(--accent-subtle) 45%, var(--card)); } .list-main { @@ -728,7 +845,9 @@ .list-sub { color: var(--muted); - font-size: 12px; + font-size: 13px; + overflow-wrap: anywhere; + word-break: break-word; } .list-meta { @@ -760,7 +879,7 @@ .cron-job .list-title { font-weight: 600; - font-size: 15px; + font-size: 16px; letter-spacing: -0.015em; } @@ -800,6 +919,7 @@ display: grid; gap: 3px; margin-top: 2px; + min-width: 0; } .cron-job-detail-label { @@ -813,6 +933,9 @@ .cron-job-detail-value { font-size: 13px; line-height: 1.35; + overflow-wrap: anywhere; + word-break: break-word; + min-width: 0; } .cron-job-state { @@ -852,7 +975,7 @@ .cron-job-status-ok { color: var(--ok); - border-color: rgba(34, 197, 94, 0.35); + border-color: color-mix(in srgb, var(--ok) 35%, transparent); background: var(--ok-subtle); } @@ -921,13 +1044,13 @@ } .chip { - font-size: 12px; + font-size: 13px; font-weight: 500; - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 85%, transparent); border-radius: var(--radius-full); padding: 5px 12px; color: var(--muted); - background: var(--secondary); + background: color-mix(in srgb, var(--secondary) 92%, transparent); transition: border-color var(--duration-fast) var(--ease-out), background var(--duration-fast) var(--ease-out), @@ -936,6 +1059,7 @@ .chip:hover { border-color: var(--border-strong); + background: var(--bg-hover); transform: translateY(-1px); } @@ -957,7 +1081,7 @@ .chip-danger { color: var(--danger); - border-color: rgba(239, 68, 68, 0.3); + border-color: color-mix(in srgb, var(--danger) 30%, transparent); background: var(--danger-subtle); } @@ -967,7 +1091,7 @@ .table { display: grid; - gap: 6px; + gap: 8px; } .table-head, @@ -979,22 +1103,32 @@ } .table-head { - font-size: 12px; + font-size: 13px; font-weight: 500; color: var(--muted); padding: 0 12px; } .table-row { - border: 1px solid var(--border); - padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--border) 92%, transparent); + padding: 12px 14px; border-radius: var(--radius-md); - background: var(--card); - transition: border-color var(--duration-fast) ease; + background: color-mix(in srgb, var(--card) 97%, transparent); + transition: + border-color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease, + background var(--duration-fast) ease; } .table-row:hover { border-color: var(--border-strong); + background: color-mix(in srgb, var(--card) 82%, var(--bg-hover)); + box-shadow: var(--shadow-sm); +} + +.table-row:focus-within { + border-color: var(--ring); + box-shadow: var(--focus-ring); } .session-link { @@ -1028,12 +1162,13 @@ =========================================== */ .log-stream { - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 92%, transparent); border-radius: var(--radius-md); - background: var(--card); + background: color-mix(in srgb, var(--card) 98%, transparent); max-height: 500px; overflow: auto; container-type: inline-size; + box-shadow: inset 0 1px 0 var(--card-highlight); } .log-row { @@ -1041,9 +1176,9 @@ grid-template-columns: 90px 70px minmax(140px, 200px) minmax(0, 1fr); gap: 12px; align-items: start; - padding: 8px 12px; - border-bottom: 1px solid var(--border); - font-size: 12px; + padding: 9px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 90%, transparent); + font-size: 13px; transition: background var(--duration-fast) ease; } @@ -1245,7 +1380,7 @@ .chat-new-messages { align-self: center; margin: 8px auto 0; - border-radius: 999px; + border-radius: var(--radius-full); padding: 6px 12px; font-size: 12px; line-height: 1; @@ -1284,31 +1419,16 @@ min-width: 0; } -:root[data-theme="light"] .chat-bubble { - border-color: var(--border); - background: var(--bg); -} - .chat-line.user .chat-bubble { border-color: transparent; background: var(--accent-subtle); } -:root[data-theme="light"] .chat-line.user .chat-bubble { - border-color: rgba(234, 88, 12, 0.2); - background: rgba(251, 146, 60, 0.12); -} - .chat-line.assistant .chat-bubble { border-color: transparent; background: var(--secondary); } -:root[data-theme="light"] .chat-line.assistant .chat-bubble { - border-color: var(--border); - background: var(--bg-muted); -} - @keyframes chatStreamPulse { 0%, 100% { @@ -1439,10 +1559,6 @@ background: var(--secondary); } -:root[data-theme="light"] .chat-text :where(:not(pre) > code) { - background: var(--bg-muted); -} - .chat-text :where(pre) { margin-top: 0.75em; padding: 10px 12px; @@ -1452,10 +1568,6 @@ overflow: auto; } -:root[data-theme="light"] .chat-text :where(pre) { - background: var(--bg-muted); -} - .chat-text :where(pre code) { font-size: 12px; white-space: pre; @@ -1492,10 +1604,6 @@ gap: 4px; } -:root[data-theme="light"] .chat-tool-card { - background: var(--bg-muted); -} - .chat-tool-card__title { font-family: var(--mono); font-size: 12px; @@ -1550,12 +1658,8 @@ background: var(--card); } -:root[data-theme="light"] .chat-tool-card__output { - background: var(--bg); -} - .chat-stamp { - font-size: 11px; + font-size: 12px; color: var(--muted); } @@ -1685,7 +1789,7 @@ } .exec-approval-title { - font-size: 14px; + font-size: 15px; font-weight: 600; } @@ -1762,6 +1866,8 @@ display: grid; gap: 12px; align-self: start; + position: sticky; + top: 16px; } .agents-main { @@ -1802,7 +1908,7 @@ width: 32px; height: 32px; border-radius: 50%; - background: var(--secondary); + background: hsl(var(--agent-hue, 220) 30% 18%); display: grid; place-items: center; font-weight: 600; @@ -1890,6 +1996,13 @@ color: white; } +.agent-tab-count { + font-weight: 400; + font-size: 11px; + opacity: 0.7; + margin-left: 4px; +} + .agents-overview-grid { display: grid; gap: 14px; @@ -1900,6 +2013,10 @@ display: grid; gap: 6px; min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 12px; + background: var(--bg-elevated); } .agent-kv > div { @@ -2149,3 +2266,731 @@ grid-template-columns: 1fr; } } + +.agent-identity-card { + display: flex; + gap: 16px; + align-items: center; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-elevated); +} + +.agent-identity-card .agent-avatar { + width: 56px; + height: 56px; + font-size: 24px; + flex-shrink: 0; +} + +.agent-identity-details { + display: grid; + gap: 4px; + min-width: 0; +} + +.agent-identity-name { + font-weight: 700; + font-size: 16px; +} + +.agent-identity-meta { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + color: var(--muted); + font-size: 13px; +} + +.agent-chip-input { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--card); + padding: 6px 8px; + min-height: 38px; + cursor: text; + transition: border-color var(--duration-fast) ease; +} + +.agent-chip-input:focus-within { + border-color: var(--accent); +} + +.agent-chip-input .chip { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.agent-chip-input .chip-remove { + cursor: pointer; + opacity: 0.6; + font-size: 14px; + line-height: 1; + padding: 0 2px; + background: none; + border: none; + color: inherit; +} + +.agent-chip-input .chip-remove:hover { + opacity: 1; +} + +.agent-chip-input input { + border: none; + background: transparent; + color: inherit; + font: inherit; + font-size: 13px; + outline: none; + padding: 2px 0; + flex: 1; + min-width: 120px; +} + +.agent-actions-wrap { + position: relative; +} + +.agent-actions-toggle { + background: var(--secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 6px 10px; + cursor: pointer; + font-size: 16px; + line-height: 1; + color: var(--muted); + transition: border-color var(--duration-fast) ease; +} + +.agent-actions-toggle:hover { + border-color: var(--border-strong); + color: var(--vscode-text); +} + +.agent-actions-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 50; + min-width: 180px; + background: var(--glass-bg-elevated); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--glass-shadow-md); + padding: 4px; + display: grid; + gap: 2px; +} + +.agent-actions-menu button { + display: block; + width: 100%; + text-align: left; + padding: 8px 12px; + border: none; + background: transparent; + color: var(--vscode-text); + font-size: 13px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background var(--duration-fast) ease; +} + +.agent-actions-menu button:hover { + background: var(--vscode-hover); +} + +.agent-actions-menu button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.agent-actions-menu button:disabled:hover { + background: transparent; +} + +.workspace-link { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + font: inherit; + padding: 0; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 3px; +} + +.workspace-link:hover { + text-decoration-style: solid; +} + +/* =========================================== + Overview Dashboard Cards + =========================================== */ + +.ov-cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-top: 18px; +} + +.ov-stat-card { + --ov-accent: var(--muted); + display: grid; + gap: 0; + padding: 0; + overflow: hidden; + border-top: 2px solid var(--ov-accent); + position: relative; +} + +.ov-stat-card.clickable { + cursor: pointer; + transition: + border-color 0.15s ease, + transform 0.15s ease, + box-shadow 0.15s ease; +} + +.ov-stat-card.clickable:hover { + border-color: var(--accent); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); +} + +.ov-stat-card[data-kind="cost"] { + --ov-accent: var(--kn-bioluminescence); +} + +.ov-stat-card[data-kind="sessions"] { + --ov-accent: var(--kn-silver); +} + +.ov-stat-card[data-kind="skills"] { + --ov-accent: var(--kn-claw-ember); +} + +.ov-stat-card[data-kind="cron"] { + --ov-accent: var(--vscode-accent); +} + +.ov-stat-card__inner { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 14px 16px; +} + +.ov-stat-card__icon { + flex-shrink: 0; + width: 20px; + height: 20px; + color: var(--ov-accent); + opacity: 0.8; + margin-top: 1px; +} + +.ov-stat-card__icon svg { + width: 100%; + height: 100%; +} + +.ov-stat-card__body { + min-width: 0; + flex: 1; +} + +.ov-stat-card__body .stat-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin-bottom: 6px; + font-weight: 600; +} + +.ov-stat-card__body .stat-value { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.1; +} + +.ov-stat-card__body .muted { + font-size: 12px; + margin-top: 6px; + line-height: 1.4; +} + +.redacted { + filter: blur(5px); + user-select: none; + pointer-events: none; + transition: filter var(--duration-normal, 250ms) ease; +} + +/* Recent sessions */ + +.ov-recent-sessions { + margin-top: 14px; +} + +.ov-session-list { + margin-top: 10px; +} + +.ov-session-row { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + font-size: 13px; + transition: opacity 0.1s ease; +} + +.ov-session-row:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.ov-session-row:first-child { + padding-top: 0; +} + +.ov-session-key { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.ov-session-key .blur-digits { + filter: blur(5px); + transition: filter 200ms ease-out; + user-select: none; +} + +.ov-session-row:hover .blur-digits { + filter: none; +} + +/* =========================================== + Attention Center + =========================================== */ + +.ov-attention { + margin-top: 18px; +} + +.ov-attention-list { + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.ov-attention-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius); + border: 1px solid var(--border); + font-size: 13px; +} + +.ov-attention-item.danger { + border-color: var(--danger); + background: var(--danger-subtle); +} + +.ov-attention-item.warn { + border-color: var(--warn, #d97706); + background: color-mix(in srgb, var(--warn, #d97706) 8%, transparent); +} + +.ov-attention-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + margin-top: 1px; +} + +.ov-attention-icon svg { + width: 100%; + height: 100%; +} + +.ov-attention-body { + flex: 1; + min-width: 0; +} + +.ov-attention-title { + font-weight: 600; + margin-bottom: 2px; +} + +.ov-attention-link { + flex-shrink: 0; + font-size: 12px; + color: var(--accent); + text-decoration: none; + align-self: center; +} + +.ov-attention-link:hover { + text-decoration: underline; +} + +/* =========================================== + Overview Event Log + =========================================== */ + +.ov-event-log { + margin-top: 0; +} + +.ov-expandable-toggle { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + list-style: none; + padding: 0; +} + +.ov-expandable-toggle::-webkit-details-marker { + display: none; +} + +.ov-expandable-toggle .nav-item__icon { + width: 16px; + height: 16px; +} + +.ov-expandable-toggle .nav-item__icon svg { + width: 100%; + height: 100%; +} + +.ov-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 10px; + background: var(--border); + color: var(--muted); + font-size: 11px; + font-weight: 600; +} + +.ov-event-log-list { + margin-top: 12px; + max-height: 300px; + overflow-y: auto; +} + +.ov-event-log-entry { + display: flex; + align-items: baseline; + gap: 8px; + padding: 4px 0; + border-bottom: 1px solid var(--border); + font-size: 12px; + font-family: var(--mono); +} + +.ov-event-log-entry:last-child { + border-bottom: none; +} + +.ov-event-log-ts { + flex-shrink: 0; + color: var(--muted); + width: 70px; +} + +.ov-event-log-name { + font-weight: 600; + min-width: 100px; +} + +.ov-event-log-payload { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* =========================================== + Overview Log Tail + =========================================== */ + +.ov-log-tail { + margin-top: 0; +} + +.ov-log-refresh { + margin-left: auto; + cursor: pointer; + width: 14px; + height: 14px; + color: var(--muted); +} + +.ov-log-refresh svg { + width: 100%; + height: 100%; +} + +.ov-log-refresh:hover { + color: var(--fg); +} + +.ov-log-tail-content { + margin-top: 12px; + max-height: 250px; + overflow: auto; + font-family: var(--mono); + font-size: 11px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-all; + background: var(--bg-inset, var(--bg)); + padding: 12px; + border-radius: var(--radius); + border: 1px solid var(--border); +} + +/* =========================================== + Overview Quick Actions + =========================================== */ + +.ov-quick-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 18px; +} + +.ov-quick-action-btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; +} + +.ov-quick-action-btn .nav-item__icon { + width: 14px; + height: 14px; +} + +.ov-quick-action-btn .nav-item__icon svg { + width: 100%; + height: 100%; +} + +/* =========================================== + Stream Mode Banner + =========================================== */ + +.ov-stream-banner { + display: flex; + align-items: center; + gap: 8px; +} + +.ov-stream-banner .nav-item__icon { + width: 14px; + height: 14px; +} + +.ov-stream-banner .nav-item__icon svg { + width: 100%; + height: 100%; +} + +/* =========================================== + Overview Bottom Grid + =========================================== */ + +.ov-bottom-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +@media (max-width: 768px) { + .ov-bottom-grid { + grid-template-columns: 1fr; + } + + .ov-cards { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + .ov-cards { + grid-template-columns: 1fr; + } +} + +/* =========================================== + Command Palette + =========================================== */ + +.cmd-palette-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: min(20vh, 160px); + animation: fade-in 0.12s ease-out; +} + +.cmd-palette { + width: min(560px, 90vw); + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + overflow: hidden; + animation: scale-in 0.15s ease-out; +} + +.cmd-palette__input { + width: 100%; + padding: 14px 18px; + background: transparent; + border: none; + border-bottom: 1px solid var(--border); + font-size: 15px; + color: var(--fg); + outline: none; +} + +.cmd-palette__input::placeholder { + color: var(--muted); +} + +.cmd-palette__results { + max-height: 320px; + overflow-y: auto; + padding: 6px 0; +} + +.cmd-palette__group-label { + padding: 8px 18px 4px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + font-weight: 600; +} + +.cmd-palette__item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 18px; + cursor: pointer; + font-size: 14px; + transition: background 0.1s; +} + +.cmd-palette__item:hover, +.cmd-palette__item--active { + background: var(--hover); +} + +.cmd-palette__item .nav-item__icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.cmd-palette__item .nav-item__icon svg { + width: 100%; + height: 100%; +} + +.cmd-palette__item-desc { + margin-left: auto; + font-size: 12px; +} + +/* =========================================== + Bottom Tabs (Mobile Navigation) + =========================================== */ + +.bottom-tabs { + display: none; + border-top: 1px solid var(--border); + background: var(--card); + padding: 4px 0; +} + +.bottom-tab { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + flex: 1; + padding: 6px 4px; + border: none; + background: none; + color: var(--muted); + cursor: pointer; + font-size: 10px; + transition: color 0.15s; +} + +.bottom-tab--active { + color: var(--accent); +} + +.bottom-tab__icon { + width: 20px; + height: 20px; +} + +.bottom-tab__icon svg { + width: 100%; + height: 100%; +} + +.bottom-tab__label { + font-weight: 500; +} + +@media (max-width: 768px) { + .bottom-tabs { + display: flex; + } +} diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index ec4003a1244..e5ef45bc56b 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -27,10 +27,6 @@ overflow: hidden; } -:root[data-theme="light"] .config-sidebar { - background: var(--bg-hover); -} - .config-sidebar__header { display: flex; align-items: center; @@ -41,7 +37,7 @@ .config-sidebar__title { font-weight: 600; - font-size: 14px; + font-size: 15px; letter-spacing: -0.01em; } @@ -75,7 +71,7 @@ border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-elevated); - font-size: 13px; + font-size: 14px; outline: none; transition: border-color var(--duration-fast) ease, @@ -93,14 +89,6 @@ background: var(--bg-hover); } -:root[data-theme="light"] .config-search__input { - background: white; -} - -:root[data-theme="light"] .config-search__input:focus { - background: white; -} - .config-search__clear { position: absolute; right: 22px; @@ -145,7 +133,7 @@ border-radius: var(--radius-md); background: transparent; color: var(--muted); - font-size: 13px; + font-size: 14px; font-weight: 500; text-align: left; cursor: pointer; @@ -159,10 +147,6 @@ color: var(--text); } -:root[data-theme="light"] .config-nav__item:hover { - background: rgba(0, 0, 0, 0.04); -} - .config-nav__item.active { background: var(--accent-subtle); color: var(--accent); @@ -206,10 +190,6 @@ border: 1px solid var(--border); } -:root[data-theme="light"] .config-mode-toggle { - background: white; -} - .config-mode-toggle__btn { flex: 1; padding: 9px 14px; @@ -260,10 +240,6 @@ border-bottom: 1px solid var(--border); } -:root[data-theme="light"] .config-actions { - background: var(--bg-hover); -} - .config-actions__left, .config-actions__right { display: flex; @@ -275,7 +251,7 @@ padding: 6px 14px; border-radius: var(--radius-full); background: var(--accent-subtle); - border: 1px solid rgba(255, 77, 77, 0.3); + border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); color: var(--accent); font-size: 12px; font-weight: 600; @@ -289,7 +265,7 @@ /* Diff Panel */ .config-diff { margin: 18px 22px 0; - border: 1px solid rgba(255, 77, 77, 0.25); + border: 1px solid color-mix(in srgb, var(--danger) 25%, transparent); border-radius: var(--radius-lg); background: var(--accent-subtle); overflow: hidden; @@ -343,10 +319,6 @@ font-family: var(--mono); } -:root[data-theme="light"] .config-diff__item { - background: white; -} - .config-diff__path { font-weight: 600; color: var(--text); @@ -384,10 +356,6 @@ background: var(--bg-accent); } -:root[data-theme="light"] .config-section-hero { - background: var(--bg-hover); -} - .config-section-hero__icon { width: 30px; height: 30px; @@ -411,7 +379,7 @@ } .config-section-hero__title { - font-size: 16px; + font-size: 17px; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; @@ -420,7 +388,7 @@ } .config-section-hero__desc { - font-size: 13px; + font-size: 14px; color: var(--muted); } @@ -434,10 +402,6 @@ overflow-x: auto; } -:root[data-theme="light"] .config-subnav { - background: var(--bg-hover); -} - .config-subnav__item { border: 1px solid transparent; border-radius: var(--radius-full); @@ -454,10 +418,6 @@ white-space: nowrap; } -:root[data-theme="light"] .config-subnav__item { - background: white; -} - .config-subnav__item:hover { color: var(--text); border-color: var(--border); @@ -551,10 +511,6 @@ border-color: var(--border-strong); } -:root[data-theme="light"] .config-section-card { - background: white; -} - .config-section-card__header { display: flex; align-items: flex-start; @@ -564,10 +520,6 @@ border-bottom: 1px solid var(--border); } -:root[data-theme="light"] .config-section-card__header { - background: var(--bg-hover); -} - .config-section-card__icon { width: 34px; height: 34px; @@ -587,7 +539,7 @@ .config-section-card__title { margin: 0; - font-size: 17px; + font-size: 18px; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; @@ -597,7 +549,7 @@ .config-section-card__desc { margin: 5px 0 0; - font-size: 13px; + font-size: 14px; color: var(--muted); line-height: 1.45; } @@ -624,23 +576,23 @@ padding: 14px; border-radius: var(--radius-md); background: var(--danger-subtle); - border: 1px solid rgba(239, 68, 68, 0.3); + border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); } .cfg-field__label { - font-size: 13px; + font-size: 14px; font-weight: 600; color: var(--text); } .cfg-field__help { - font-size: 12px; + font-size: 13px; color: var(--muted); line-height: 1.45; } .cfg-field__error { - font-size: 12px; + font-size: 13px; color: var(--danger); } @@ -675,14 +627,6 @@ background: var(--bg-hover); } -:root[data-theme="light"] .cfg-input { - background: white; -} - -:root[data-theme="light"] .cfg-input:focus { - background: white; -} - .cfg-input--sm { padding: 9px 12px; font-size: 13px; @@ -733,10 +677,6 @@ box-shadow: var(--focus-ring); } -:root[data-theme="light"] .cfg-textarea { - background: white; -} - .cfg-textarea--sm { padding: 10px 12px; font-size: 12px; @@ -751,10 +691,6 @@ background: var(--bg-accent); } -:root[data-theme="light"] .cfg-number { - background: white; -} - .cfg-number__btn { width: 44px; border: none; @@ -775,14 +711,6 @@ cursor: not-allowed; } -:root[data-theme="light"] .cfg-number__btn { - background: var(--bg-hover); -} - -:root[data-theme="light"] .cfg-number__btn:hover:not(:disabled) { - background: var(--border); -} - .cfg-number__input { width: 85px; padding: 11px; @@ -825,10 +753,6 @@ box-shadow: var(--focus-ring); } -:root[data-theme="light"] .cfg-select { - background-color: white; -} - /* Segmented Control */ .cfg-segmented { display: inline-flex; @@ -838,17 +762,13 @@ background: var(--bg-accent); } -:root[data-theme="light"] .cfg-segmented { - background: var(--bg-hover); -} - .cfg-segmented__btn { padding: 9px 18px; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--muted); - font-size: 13px; + font-size: 14px; font-weight: 500; cursor: pointer; transition: @@ -898,14 +818,6 @@ cursor: not-allowed; } -:root[data-theme="light"] .cfg-toggle-row { - background: white; -} - -:root[data-theme="light"] .cfg-toggle-row:hover:not(.disabled) { - background: var(--bg-hover); -} - .cfg-toggle-row__content { flex: 1; min-width: 0; @@ -913,7 +825,7 @@ .cfg-toggle-row__label { display: block; - font-size: 14px; + font-size: 15px; font-weight: 500; color: var(--text); } @@ -921,7 +833,7 @@ .cfg-toggle-row__help { display: block; margin-top: 3px; - font-size: 12px; + font-size: 13px; color: var(--muted); line-height: 1.45; } @@ -952,10 +864,6 @@ border-color var(--duration-normal) ease; } -:root[data-theme="light"] .cfg-toggle__track { - background: var(--border); -} - .cfg-toggle__track::after { content: ""; position: absolute; @@ -973,7 +881,7 @@ .cfg-toggle input:checked + .cfg-toggle__track { background: var(--ok-subtle); - border-color: rgba(34, 197, 94, 0.4); + border-color: color-mix(in srgb, var(--ok) 40%, transparent); } .cfg-toggle input:checked + .cfg-toggle__track::after { @@ -993,10 +901,6 @@ overflow: hidden; } -:root[data-theme="light"] .cfg-object { - background: white; -} - .cfg-object__header { display: flex; align-items: center; @@ -1066,10 +970,6 @@ border-bottom: 1px solid var(--border); } -:root[data-theme="light"] .cfg-array__header { - background: var(--bg-hover); -} - .cfg-array__label { flex: 1; font-size: 14px; @@ -1085,10 +985,6 @@ border-radius: var(--radius-full); } -:root[data-theme="light"] .cfg-array__count { - background: white; -} - .cfg-array__add { display: inline-flex; align-items: center; @@ -1156,10 +1052,6 @@ border-bottom: 1px solid var(--border); } -:root[data-theme="light"] .cfg-array__item-header { - background: var(--bg-hover); -} - .cfg-array__item-index { font-size: 11px; font-weight: 600; @@ -1220,10 +1112,6 @@ border-bottom: 1px solid var(--border); } -:root[data-theme="light"] .cfg-map__header { - background: var(--bg-hover); -} - .cfg-map__label { font-size: 13px; font-weight: 600; @@ -1320,7 +1208,7 @@ } .pill--ok { - border-color: rgba(34, 197, 94, 0.35); + border-color: color-mix(in srgb, var(--ok) 35%, transparent); color: var(--ok); } @@ -1444,3 +1332,85 @@ min-width: 70px; } } + +/* =========================================== + Environment Values Blur + Peek Toggle + =========================================== */ + +.config-env-values--blurred .cfg-input, +.config-env-values--blurred .cfg-number__input, +.config-env-values--blurred textarea { + color: transparent; + text-shadow: 0 0 8px var(--text); +} + +.config-env-values--blurred .cfg-input::placeholder, +.config-env-values--blurred textarea::placeholder { + text-shadow: none; + color: var(--muted); + opacity: 0.7; +} + +.config-env-values--blurred .cfg-input:focus, +.config-env-values--blurred .cfg-number__input:focus, +.config-env-values--blurred textarea:focus { + color: transparent; + text-shadow: 0 0 8px var(--text); +} + +.config-env-values--visible.config-env-values--blurred .cfg-input, +.config-env-values--visible.config-env-values--blurred .cfg-number__input, +.config-env-values--visible.config-env-values--blurred textarea { + color: var(--text); + text-shadow: none; +} + +.config-env-values--visible.config-env-values--blurred .cfg-input:focus, +.config-env-values--visible.config-env-values--blurred .cfg-number__input:focus, +.config-env-values--visible.config-env-values--blurred textarea:focus { + color: var(--text); + text-shadow: none; +} + +.config-env-peek-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all var(--duration-fast) ease; + flex-shrink: 0; + margin-left: auto; +} + +.config-env-peek-btn:hover { + color: var(--text); + border-color: var(--border-strong); + background: var(--bg-hover); +} + +.config-env-peek-btn--active { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 40%, transparent); + background: color-mix(in srgb, var(--accent) 8%, transparent); +} + +.config-env-peek-btn svg { + flex-shrink: 0; +} + +/* Raw JSON redaction blur */ + +.config-raw-redacted { + color: transparent !important; + text-shadow: 0 0 8px var(--text); + transition: + color var(--duration-normal, 250ms) ease, + text-shadow var(--duration-normal, 250ms) ease; +} diff --git a/ui/src/styles/glass.css b/ui/src/styles/glass.css new file mode 100644 index 00000000000..e059a72b691 --- /dev/null +++ b/ui/src/styles/glass.css @@ -0,0 +1,554 @@ +/* ════════════════════════════════════════════════════════ + Glass Component System + Glassmorphism primitives used across dashboard views. + ════════════════════════════════════════════════════════ */ + +/* ─── Animations ─── */ + +@keyframes glass-enter { + from { + opacity: 0; + transform: scale(0.97) translateY(6px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes modal-overlay-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes modal-dialog-in { + from { + opacity: 0; + transform: scale(0.95) translateY(12px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes glass-dropdown-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes ambient-drift { + 0% { + background-position: 0% 0%; + } + 50% { + background-position: 100% 100%; + } + 100% { + background-position: 0% 0%; + } +} + +@keyframes active-breathe { + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 1; + } +} + +@keyframes card-rise { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.glass-animate-in { + animation: glass-enter var(--clay-duration-normal) var(--clay-easing) both; +} + +/* ─── Glass Buttons ─── */ + +.glass-btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 10px 18px; + border: none; + border-radius: var(--radius-sm); + background: linear-gradient(135deg, var(--kn-claw), var(--kn-claw-deep)); + color: #fff; + font-weight: 600; + font-size: 0.88rem; + cursor: pointer; + position: relative; + overflow: hidden; + transition: + transform 0.15s ease, + box-shadow 0.2s ease, + filter 0.15s ease; +} + +.glass-btn-primary:hover { + transform: translateY(-1px); + filter: brightness(1.1); + box-shadow: 0 4px 16px rgba(202, 58, 41, 0.3); +} + +.glass-btn-primary:active { + transform: translateY(0); +} + +.glass-btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 10px 18px; + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + color: var(--text); + font-weight: 500; + font-size: 0.88rem; + cursor: pointer; + transition: + border-color 0.2s ease, + background 0.15s ease; +} + +.glass-btn-secondary:hover { + border-color: var(--glass-border-hover); + background: var(--bg-hover); +} + +.glass-btn-ocean { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 10px 18px; + border: 1px solid rgba(0, 212, 170, 0.2); + border-radius: var(--radius-sm); + background: rgba(0, 212, 170, 0.08); + color: var(--kn-bioluminescence); + font-weight: 600; + font-size: 0.88rem; + cursor: pointer; + transition: + border-color 0.2s ease, + background 0.15s ease; +} + +.glass-btn-ocean:hover { + border-color: rgba(0, 212, 170, 0.35); + background: rgba(0, 212, 170, 0.14); +} + +/* ─── Glass Input ─── */ + +.glass-input { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + background: var(--glass-bg); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: var(--text); + font-size: 0.92rem; + font-family: inherit; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.glass-input:focus { + outline: none; + border-color: var(--accent); + border-width: 2px; + box-shadow: 0 0 0 3px var(--accent-subtle); +} + +.glass-input::placeholder { + color: var(--muted); +} + +/* ─── Glass Tabs ─── */ + +.glass-tab { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 14px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--muted); + font-size: 0.82rem; + font-weight: 500; + cursor: pointer; + position: relative; + transition: + color 0.15s ease, + background 0.15s ease; +} + +.glass-tab:hover { + color: var(--text); + background: var(--accent-subtle); +} + +.glass-tab-active { + color: var(--text); + background: var(--accent-subtle); + font-weight: 600; +} + +.glass-tab-active::after { + content: ""; + position: absolute; + bottom: 0; + left: 20%; + width: 60%; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + border-radius: 1px; +} + +.glass-segmented-control { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border: 1px solid var(--glass-border); + border-radius: var(--radius-full); + background: var(--glass-bg); +} + +/* ─── Glass Dialog ─── */ + +.glass-dialog { + background: var(--glass-bg-elevated); + backdrop-filter: blur(40px) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(40px) saturate(var(--glass-saturate)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + position: relative; + overflow: hidden; +} + +/* ─── Glass Select Panel (Dropdown) ─── */ + +.glass-select-panel { + background: var(--glass-bg-elevated); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + animation: glass-dropdown-in 0.15s ease-out both; +} + +/* ─── Glass Overlay (Modal Backdrop) ─── */ + +.glass-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 100; + animation: modal-overlay-in 0.25s ease-out both; +} + +/* ─── Glass Depth Layers ─── */ + +.glass-layer-1 { + background: var(--glass-bg); + backdrop-filter: blur(8px) saturate(120%); + -webkit-backdrop-filter: blur(8px) saturate(120%); +} + +.glass-layer-2 { + background: var(--glass-bg-elevated); + backdrop-filter: blur(16px) saturate(140%); + -webkit-backdrop-filter: blur(16px) saturate(140%); +} + +.glass-layer-3 { + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(32px) saturate(160%); + -webkit-backdrop-filter: blur(32px) saturate(160%); +} + +/* ─── Glass Card Variants ─── */ + +.glass-card { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.glass-card:hover { + border-color: var(--glass-border-hover); + box-shadow: var(--shadow-md); +} + +.glass-card-active { + border-color: var(--accent); + box-shadow: + 0 0 0 1px var(--accent), + var(--shadow-md); +} + +.glass-card-active-ocean { + border-color: var(--kn-bioluminescence); + box-shadow: + 0 0 0 1px var(--kn-bioluminescence), + var(--shadow-md); +} + +/* ─── Glass Noise Texture ─── */ + +.glass-noise::after { + content: ""; + position: absolute; + inset: 0; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); + opacity: 0.05; + mix-blend-mode: overlay; + pointer-events: none; + border-radius: inherit; +} + +/* ─── Glass Border Gradient ─── */ + +.glass-border-gradient { + position: relative; +} + +.glass-border-gradient::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + background: linear-gradient(135deg, var(--glass-border-hover), transparent 60%); + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask-composite: exclude; + -webkit-mask-composite: xor; + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.glass-border-gradient:hover::before { + opacity: 1; +} + +/* ─── Ambient Background ─── */ + +.ambient-bg { + position: relative; +} + +.ambient-bg::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: -1; + background: + radial-gradient(ellipse 80% 50% at 20% 80%, var(--kn-claw-dim) 0%, transparent 60%), + radial-gradient(ellipse 60% 40% at 80% 20%, var(--kn-ocean-dim) 0%, transparent 50%); +} + +.ambient-bg::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: -1; + background: + radial-gradient(ellipse 50% 30% at 60% 60%, var(--kn-claw-dim) 0%, transparent 50%), + radial-gradient(ellipse 40% 50% at 30% 30%, rgba(0, 212, 170, 0.03) 0%, transparent 50%); + animation: ambient-drift 120s ease-in-out infinite alternate; + background-size: 200% 200%; +} + +/* ─── Typography Utilities ─── */ + +.text-display { + font-weight: 700; + letter-spacing: -0.04em; + line-height: 1.1; +} + +/* ─── Glass Dashboard Card ─── */ + +.glass-dashboard-card { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + padding: 1.25rem; + overflow: hidden; + position: relative; + box-shadow: var(--shadow-sm), var(--glass-highlight); + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; + min-width: 0; +} + +.glass-dashboard-card::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: 0; + transition: opacity 0.2s ease; +} + +.glass-dashboard-card:hover { + border-color: var(--glass-border-hover); + box-shadow: var(--shadow-md); +} + +.glass-dashboard-card:hover::after { + opacity: 0.6; +} + +/* ─── Card Header Convention ─── */ + +.card-header { + display: flex; + align-items: center; + gap: 0.625rem; + margin-bottom: 0.875rem; + min-height: 28px; +} + +.card-header__prefix { + color: var(--accent); + font-family: var(--mono); + font-size: 0.82rem; + font-weight: 600; + line-height: 1; +} + +.card-header__title { + font-size: 0.9rem; + font-weight: 700; + color: var(--text); + letter-spacing: -0.01em; + margin: 0; +} + +.card-header__actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.card-header__link { + font-size: 0.75rem; + color: var(--accent); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + white-space: nowrap; +} + +.card-header__link:hover { + text-decoration: underline; +} + +/* ─── Count Badge ─── */ + +.count-badge { + font-size: 0.72rem; + font-family: var(--mono); + font-variant-numeric: tabular-nums; + background: var(--clay-bg-card); + color: var(--muted); + padding: 1px 7px; + border-radius: 9999px; + line-height: 1.4; + white-space: nowrap; +} + +.count-badge--accent { + color: var(--accent); +} + +.count-badge--emerald { + color: var(--success); +} + +.count-badge--amber { + color: var(--warn); +} + +.count-badge--red { + color: var(--danger); +} + +/* ─── Glass Divider ─── */ + +.glass-divider { + height: 1px; + background: var(--clay-border-subtle); + margin: 1.25rem 0; + border: none; +} + +/* ─── Glass Event Row ─── */ + +.glass-event-row { + padding: 6px 8px; + border-radius: var(--clay-radius-sm); + cursor: pointer; + transition: background var(--clay-duration-fast) ease; +} + +.glass-event-row:hover { + background: var(--clay-bg-interactive); +} diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index b939c27c29d..384d89c9399 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -5,8 +5,8 @@ .shell { --shell-pad: 16px; --shell-gap: 16px; - --shell-nav-width: 220px; - --shell-topbar-height: 56px; + --shell-nav-width: 240px; + --shell-topbar-height: 62px; --shell-focus-duration: 200ms; --shell-focus-ease: var(--ease-out); height: 100vh; @@ -14,7 +14,7 @@ grid-template-columns: var(--shell-nav-width) minmax(0, 1fr); grid-template-rows: var(--shell-topbar-height) 1fr; grid-template-areas: - "topbar topbar" + "nav topbar" "nav content"; gap: 0; animation: dashboard-enter 0.4s var(--ease-out); @@ -41,7 +41,7 @@ } .shell--nav-collapsed { - grid-template-columns: 0px minmax(0, 1fr); + grid-template-columns: 60px minmax(0, 1fr); } .shell--chat-focus { @@ -80,139 +80,262 @@ display: flex; justify-content: space-between; align-items: center; - gap: 16px; + gap: 12px; padding: 0 20px; height: var(--shell-topbar-height); - border-bottom: 1px solid var(--border); - background: var(--bg); + background: var(--topbar-bg); + backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur)); + -webkit-backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur)); + border-bottom: var(--topbar-border); } -.topbar-left { +/* --- Left: Dashboard Header --- */ + +.dashboard-header { display: flex; align-items: center; - gap: 12px; + gap: 0.5rem; + min-width: 0; } -.topbar .nav-collapse-toggle { - width: 36px; - height: 36px; - margin-bottom: 0; -} - -.topbar .nav-collapse-toggle__icon { - width: 20px; - height: 20px; -} - -.topbar .nav-collapse-toggle__icon svg { - width: 20px; - height: 20px; -} - -/* Brand */ -.brand { +.dashboard-header__breadcrumb { display: flex; align-items: center; - gap: 10px; + gap: 6px; + font-size: 0.82rem; + min-width: 0; } -.brand-logo { - width: 28px; - height: 28px; - flex-shrink: 0; -} - -.brand-logo img { - width: 100%; - height: 100%; - object-fit: contain; -} - -.brand-text { - display: flex; - flex-direction: column; - gap: 1px; -} - -.brand-title { - font-size: 16px; - font-weight: 700; - letter-spacing: -0.03em; - line-height: 1.1; - color: var(--text-strong); -} - -.brand-sub { - font-size: 10px; - font-weight: 500; +.dashboard-header__breadcrumb-link { color: var(--muted); - letter-spacing: 0.05em; - text-transform: uppercase; - line-height: 1; + text-decoration: none; + cursor: pointer; + white-space: nowrap; } -/* Topbar status */ -.topbar-status { +.dashboard-header__breadcrumb-link:hover { + color: var(--text); +} + +.dashboard-header__breadcrumb-sep { + color: var(--muted); + opacity: 0.5; +} + +.dashboard-header__breadcrumb-current { + color: var(--text); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dashboard-header__actions { + margin-left: auto; display: flex; align-items: center; gap: 8px; } -.topbar-status .pill { - padding: 6px 10px; - gap: 6px; - font-size: 12px; - font-weight: 500; - height: 32px; - box-sizing: border-box; -} +/* --- Center: Search / Command Palette Trigger --- */ -.topbar-status .pill .mono { +.topbar-search { display: flex; align-items: center; - line-height: 1; - margin-top: 0px; + gap: 8px; + padding: 6px 12px; + min-width: 200px; + max-width: 340px; + flex: 1; + height: 34px; + border: 1px solid var(--border); + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--secondary) 60%, transparent); + color: var(--muted); + font-size: 13px; + font-family: var(--font-body); + cursor: pointer; + transition: + border-color 180ms ease, + background 180ms ease, + box-shadow 180ms ease; + -webkit-appearance: none; + appearance: none; } -.topbar-status .statusDot { +.topbar-search:hover { + border-color: var(--border-strong); + background: color-mix(in srgb, var(--secondary) 85%, transparent); +} + +.topbar-search:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-subtle); +} + +.topbar-search__label { + flex: 1; + text-align: left; + pointer-events: none; +} + +.topbar-search__kbd { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 6px; + min-width: 22px; + height: 20px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg) 70%, transparent); + color: var(--muted); + font-size: 11px; + font-family: var(--font-body); + font-weight: 500; + line-height: 1; + pointer-events: none; + flex-shrink: 0; +} + +/* --- Right: Status area --- */ + +.topbar-status { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.topbar-divider { + width: 1px; + height: 20px; + background: var(--border); + flex-shrink: 0; +} + +/* Connection indicator */ + +.topbar-connection { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: var(--radius-full); + font-size: 12px; + font-weight: 500; + color: var(--danger); + background: var(--danger-subtle); + transition: + color 250ms ease, + background 250ms ease; +} + +.topbar-connection--ok { + color: var(--ok); + background: var(--ok-subtle); +} + +.topbar-connection__dot { width: 6px; height: 6px; + border-radius: var(--radius-full); + background: currentColor; + box-shadow: 0 0 6px currentColor; + flex-shrink: 0; } +.topbar-connection:not(.topbar-connection--ok) .topbar-connection__dot { + animation: pulse-subtle 2s ease-in-out infinite; +} + +.topbar-connection__label { + text-transform: uppercase; + letter-spacing: 0.04em; + line-height: 1; +} + +/* Redact / stream-mode toggle */ + +.topbar-redact { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid transparent; + border-radius: var(--radius); + background: none; + color: var(--muted); + cursor: pointer; + transition: + color 180ms ease, + background 180ms ease, + border-color 180ms ease; + flex-shrink: 0; +} + +.topbar-redact svg { + width: 14px; + height: 14px; +} + +.topbar-redact:hover { + color: var(--text); + background: color-mix(in srgb, var(--secondary) 80%, transparent); + border-color: var(--border); +} + +.topbar-redact--active { + color: var(--warn); +} + +.topbar-redact--active:hover { + color: var(--warn); + background: var(--warn-subtle); + border-color: color-mix(in srgb, var(--warn) 30%, transparent); +} + +/* Topbar theme toggle sizing */ + .topbar-status .theme-toggle { - --theme-item: 24px; - --theme-gap: 2px; - --theme-pad: 3px; + height: 30px; } -.topbar-status .theme-icon { - width: 12px; - height: 12px; +.topbar-status .theme-btn svg { + width: 13px; + height: 13px; } /* =========================================== Navigation Sidebar =========================================== */ -.nav { +.sidebar { grid-area: nav; + display: flex; + flex-direction: column; overflow-y: auto; overflow-x: hidden; - padding: 16px 12px; - background: var(--bg); - scrollbar-width: none; /* Firefox */ + scrollbar-width: none; + background: var(--sidebar-bg); + backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur)); + -webkit-backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur)); transition: width var(--shell-focus-duration) var(--shell-focus-ease), padding var(--shell-focus-duration) var(--shell-focus-ease), opacity var(--shell-focus-duration) var(--shell-focus-ease); min-height: 0; + border-right: 1px solid var(--glass-border); } -.nav::-webkit-scrollbar { - display: none; /* Chrome/Safari */ +.sidebar::-webkit-scrollbar { + display: none; } -.shell--chat-focus .nav { +.shell--chat-focus .sidebar { width: 0; padding: 0; border-width: 0; @@ -221,51 +344,141 @@ opacity: 0; } -.nav--collapsed { - width: 0; - min-width: 0; - padding: 0; - overflow: hidden; - border: none; - opacity: 0; - pointer-events: none; +.sidebar--collapsed { + align-items: center; } -/* Nav collapse toggle */ -.nav-collapse-toggle { - width: 32px; +.sidebar--collapsed .sidebar-header { + justify-content: center; + padding: 10px 8px; + min-height: 54px; +} + +.sidebar--collapsed .nav-group__items { + padding: 4px 0; + align-items: center; +} + +.sidebar--collapsed .nav-item { + margin: 0; + padding: 10px; + justify-content: center; + width: 44px; + height: 44px; +} + +.sidebar--collapsed .nav-item__icon { + width: 22px; + height: 22px; + opacity: 0.85; +} + +.sidebar--collapsed .nav-item__icon svg { + width: 22px; + height: 22px; + stroke-width: 1.75px; +} + +.sidebar--collapsed .nav-item--active { + border-left: 0; +} + +.sidebar--collapsed .sidebar-footer { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 0; +} + +.sidebar--collapsed .sidebar-footer .nav-item { + margin: 0; + padding: 10px; + width: 44px; + height: 44px; +} + +/* Sidebar header (brand + collapse) */ +.sidebar-header { + display: flex; + flex-direction: row; + align-items: center; + padding: 10px 8px; + gap: 0; + flex-shrink: 0; + min-height: 54px; +} + +.sidebar-brand { + flex: 2; + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + + max-height: 28px; + + padding-left: 10px; + padding-right: 10px; + + @media (max-width: 1100px) { + padding-left: 0; + padding-right: 0; + } +} + +.sidebar-brand__logo { + width: 28px; + height: 28px; + flex-shrink: 0; + object-fit: contain; +} + +.sidebar-brand__title { + font-size: 15px; + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.1; + color: var(--text-strong); + white-space: nowrap; +} + +.sidebar-collapse-btn { + flex: 1; height: 32px; + + @media (max-width: 1100px) { + height: 28px; + } + display: flex; align-items: center; justify-content: center; - background: transparent; - border: 1px solid transparent; - border-radius: var(--radius-md); + background: var(--bg); + border: var(--border) 1px solid transparent; + border-radius: var(--radius-sm); cursor: pointer; + color: var(--muted); + flex-shrink: 0; transition: background var(--duration-fast) ease, - border-color var(--duration-fast) ease; - margin-bottom: 16px; + border-color var(--duration-fast) ease, + color var(--duration-fast) ease; } -.nav-collapse-toggle:hover { - background: var(--bg-hover); +.sidebar--collapsed .sidebar-collapse-btn { + flex: none; + width: 100%; +} + +.sidebar-collapse-btn:hover { + background: var(--bg); border-color: var(--border); + color: var(--text); } -.nav-collapse-toggle__icon { - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - color: var(--muted); - transition: color var(--duration-fast) ease; -} - -.nav-collapse-toggle__icon svg { - width: 18px; - height: 18px; +.sidebar-collapse-btn svg { + width: 24px; + height: 24px; stroke: currentColor; fill: none; stroke-width: 1.5px; @@ -273,13 +486,22 @@ stroke-linejoin: round; } -.nav-collapse-toggle:hover .nav-collapse-toggle__icon { - color: var(--text); +/* Sidebar nav section */ +.sidebar-nav { + flex: 1; + padding: 4px 8px; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: none; +} + +.sidebar-nav::-webkit-scrollbar { + display: none; } /* Nav groups */ .nav-group { - margin-bottom: 20px; + margin-bottom: 16px; display: grid; gap: 2px; } @@ -297,16 +519,16 @@ display: none; } -/* Nav label */ -.nav-label { +/* Nav group label */ +.nav-group__label { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; padding: 6px 10px; - font-size: 11px; - font-weight: 500; + font-size: 12px; + font-weight: 600; color: var(--muted); margin-bottom: 4px; background: transparent; @@ -314,37 +536,40 @@ cursor: pointer; text-align: left; border-radius: var(--radius-sm); + text-transform: uppercase; + letter-spacing: 0.04em; transition: color var(--duration-fast) ease, background var(--duration-fast) ease; } -.nav-label:hover { +.nav-group__label:hover { color: var(--text); background: var(--bg-hover); } -.nav-label--static { - cursor: default; -} - -.nav-label--static:hover { - color: var(--muted); - background: transparent; -} - -.nav-label__text { +.nav-group__label-text { flex: 1; } -.nav-label__chevron { - font-size: 10px; +.nav-group__chevron { + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; opacity: 0.5; transition: transform var(--duration-fast) ease; } -.nav-group--collapsed .nav-label__chevron { - transform: rotate(-90deg); +.nav-group__chevron svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; } /* Nav items */ @@ -354,7 +579,7 @@ align-items: center; justify-content: flex-start; gap: 10px; - padding: 8px 10px; + padding: 9px 12px; border-radius: var(--radius-md); border: 1px solid transparent; background: transparent; @@ -364,12 +589,13 @@ transition: border-color var(--duration-fast) ease, background var(--duration-fast) ease, - color var(--duration-fast) ease; + color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease; } .nav-item__icon { - width: 16px; - height: 16px; + width: 18px; + height: 18px; display: flex; align-items: center; justify-content: center; @@ -379,8 +605,8 @@ } .nav-item__icon svg { - width: 16px; - height: 16px; + width: 18px; + height: 18px; stroke: currentColor; fill: none; stroke-width: 1.5px; @@ -389,14 +615,32 @@ } .nav-item__text { - font-size: 13px; + font-size: 14px; font-weight: 500; white-space: nowrap; } +.nav-item__external-icon { + display: flex; + align-items: center; + margin-left: auto; + opacity: 0.4; +} + +.nav-item__external-icon svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + .nav-item:hover { color: var(--text); - background: var(--bg-hover); + background: color-mix(in srgb, var(--secondary) 90%, transparent); + border-color: color-mix(in srgb, var(--border) 75%, transparent); text-decoration: none; } @@ -404,23 +648,55 @@ opacity: 1; } -.nav-item.active { +.nav-item--active { color: var(--text-strong); - background: var(--accent-subtle); + background: color-mix(in srgb, var(--accent-subtle) 70%, var(--secondary)); + border-color: color-mix(in srgb, var(--accent) 34%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent); } -.nav-item.active .nav-item__icon { +.nav-item--active .nav-item__icon { opacity: 1; color: var(--accent); } +/* Sidebar footer — aligned with chat compose bar */ +.sidebar-footer { + padding: 14px 8px 6px; + border-top: 1px solid var(--border); + flex-shrink: 0; + margin-top: auto; +} + +.sidebar-version { + display: flex; + align-items: center; + justify-content: center; + padding: 6px 10px; +} + +.sidebar-version__text { + font-size: 12px; + font-weight: 500; + color: var(--muted); + letter-spacing: 0.02em; +} + +.sidebar-version__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--muted); + opacity: 0.4; +} + /* =========================================== Content Area =========================================== */ .content { grid-area: content; - padding: 12px 16px 32px; + padding: 14px 18px 36px; display: block; min-height: 0; overflow-y: auto; @@ -431,10 +707,6 @@ margin-top: 24px; } -:root[data-theme="light"] .content { - background: var(--bg-content); -} - .content--chat { display: flex; flex-direction: column; @@ -453,7 +725,7 @@ align-items: flex-end; justify-content: space-between; gap: 16px; - padding: 4px 8px; + padding: 4px 0; overflow: hidden; transform-origin: top center; transition: @@ -473,7 +745,7 @@ } .page-title { - font-size: 26px; + font-size: 28px; font-weight: 700; letter-spacing: -0.035em; line-height: 1.15; @@ -482,7 +754,7 @@ .page-sub { color: var(--muted); - font-size: 14px; + font-size: 15px; font-weight: 400; margin-top: 6px; letter-spacing: -0.01em; @@ -577,16 +849,31 @@ "content"; } - .nav { + .sidebar { position: static; max-height: none; display: flex; + flex-direction: row; gap: 6px; overflow-x: auto; border-right: none; border-bottom: 1px solid var(--border); + } + + .sidebar-header { + display: none; + } + + .sidebar-footer { + display: none; + } + + .sidebar-nav { + display: flex; + flex-direction: row; + gap: 6px; padding: 10px 14px; - background: var(--bg); + overflow-x: auto; } .nav-group { @@ -606,8 +893,12 @@ gap: 10px; } + .topbar-search__kbd { + display: none; + } + .topbar-status { - flex-wrap: wrap; + flex-wrap: nowrap; } .table-head, diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 450a83608c6..084373ab82f 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -4,7 +4,22 @@ /* Tablet: Horizontal nav */ @media (max-width: 1100px) { - .nav { + .sidebar { + flex-direction: row; + flex-wrap: nowrap; + border-right: none; + border-bottom: 1px solid var(--border); + } + + .sidebar-header { + display: none; + } + + .sidebar-footer { + display: none; + } + + .sidebar-nav { display: flex; flex-direction: row; flex-wrap: nowrap; @@ -15,7 +30,7 @@ scrollbar-width: none; } - .nav::-webkit-scrollbar { + .sidebar-nav::-webkit-scrollbar { display: none; } @@ -27,7 +42,7 @@ display: contents; } - .nav-label { + .nav-group__label { display: none; } @@ -56,53 +71,56 @@ padding: 10px 12px; gap: 8px; flex-direction: row; - flex-wrap: wrap; + flex-wrap: nowrap; justify-content: space-between; align-items: center; } - .brand { - flex: 1; - min-width: 0; - } - - .brand-title { + .sidebar-brand__title { font-size: 14px; } - .brand-sub { + .dashboard-header__breadcrumb-link, + .dashboard-header__breadcrumb-sep { + display: none; + } + + .topbar-search { + min-width: 0; + max-width: none; + flex: 1; + } + + .topbar-search__label { + display: none; + } + + .topbar-search__kbd { + display: none; + } + + .topbar-connection__label { + display: none; + } + + .topbar-divider { display: none; } .topbar-status { gap: 6px; - width: auto; flex-wrap: nowrap; } - .topbar-status .pill { - padding: 4px 8px; - font-size: 11px; - gap: 4px; - } - - .topbar-status .pill .mono { - display: none; - } - - .topbar-status .pill span:nth-child(2) { - display: none; - } - /* Nav */ - .nav { + .sidebar-nav { padding: 8px 10px; gap: 4px; -webkit-overflow-scrolling: touch; scrollbar-width: none; } - .nav::-webkit-scrollbar { + .sidebar-nav::-webkit-scrollbar { display: none; } @@ -110,7 +128,7 @@ display: contents; } - .nav-label { + .nav-group__label { display: none; } @@ -288,11 +306,13 @@ font-size: 11px; } - /* Theme toggle */ .theme-toggle { - --theme-item: 24px; - --theme-gap: 2px; - --theme-pad: 3px; + height: 28px; + } + + .theme-btn svg { + width: 12px; + height: 12px; } .theme-icon { @@ -311,11 +331,11 @@ padding: 8px 10px; } - .brand-title { + .sidebar-brand__title { font-size: 13px; } - .nav { + .sidebar-nav { padding: 6px 8px; } @@ -356,15 +376,12 @@ font-size: 11px; } - .topbar-status .pill { + .topbar-connection { padding: 3px 6px; - font-size: 10px; } .theme-toggle { - --theme-item: 22px; - --theme-gap: 2px; - --theme-pad: 2px; + height: 26px; } .theme-icon { diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 30e4a1203ca..c0b9b8b0403 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -50,7 +50,7 @@ function createHost() { token: "", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "dark", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 4126b5707c3..4aacd29c51f 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -24,6 +24,7 @@ import { parseExecApprovalResolved, removeExecApproval, } from "./controllers/exec-approval.ts"; +import { loadHealthState } from "./controllers/health.ts"; import { loadNodes } from "./controllers/nodes.ts"; import { loadSessions } from "./controllers/sessions.ts"; import type { GatewayEventFrame, GatewayHelloOk } from "./gateway.ts"; @@ -33,7 +34,7 @@ import type { UiSettings } from "./storage.ts"; import type { AgentsListResult, PresenceEntry, - HealthSnapshot, + HealthSummary, StatusSummary, UpdateAvailable, } from "./types.ts"; @@ -55,7 +56,10 @@ type GatewayHost = { agentsLoading: boolean; agentsList: AgentsListResult | null; agentsError: string | null; - debugHealth: HealthSnapshot | null; + healthLoading: boolean; + healthResult: HealthSummary | null; + healthError: string | null; + debugHealth: HealthSummary | null; assistantName: string; assistantAvatar: string | null; assistantAgentId: string | null; @@ -156,6 +160,7 @@ export function connectGateway(host: GatewayHost) { resetToolStream(host as unknown as Parameters[0]); void loadAssistantIdentity(host as unknown as OpenClawApp); void loadAgents(host as unknown as OpenClawApp); + void loadHealthState(host as unknown as OpenClawApp); void loadNodes(host as unknown as OpenClawApp, { quiet: true }); void loadDevices(host as unknown as OpenClawApp, { quiet: true }); void refreshActiveTab(host as unknown as Parameters[0]); @@ -201,7 +206,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { { ts: Date.now(), event: evt.event, payload: evt.payload }, ...host.eventLogBuffer, ].slice(0, 250); - if (host.tab === "debug") { + if (host.tab === "debug" || host.tab === "overview") { host.eventLog = host.eventLogBuffer; } @@ -293,7 +298,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { const snapshot = hello.snapshot as | { presence?: PresenceEntry[]; - health?: HealthSnapshot; + health?: HealthSummary; sessionDefaults?: SessionDefaultsSnapshot; updateAvailable?: UpdateAvailable; } @@ -303,6 +308,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { } if (snapshot?.health) { host.debugHealth = snapshot.health; + host.healthResult = snapshot.health; } if (snapshot?.sessionDefaults) { applySessionDefaults(host, snapshot.sessionDefaults); diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 41442714108..f7d8d5c1ef2 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -10,8 +10,6 @@ import { import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts"; import { applySettingsFromUrl, - attachThemeListener, - detachThemeListener, inferBasePath, syncTabWithLocation, syncThemeWithSettings, @@ -38,14 +36,28 @@ type LifecycleHost = { topbarObserver: ResizeObserver | null; }; +function handleCmdK(host: LifecycleHost, e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + (host as unknown as { paletteOpen: boolean }).paletteOpen = !( + host as unknown as { paletteOpen: boolean } + ).paletteOpen; + } +} + export function handleConnected(host: LifecycleHost) { host.basePath = inferBasePath(); void loadControlUiBootstrapConfig(host); applySettingsFromUrl(host as unknown as Parameters[0]); syncTabWithLocation(host as unknown as Parameters[0], true); syncThemeWithSettings(host as unknown as Parameters[0]); - attachThemeListener(host as unknown as Parameters[0]); window.addEventListener("popstate", host.popStateHandler); + (host as unknown as { cmdKHandler: (e: KeyboardEvent) => void }).cmdKHandler = (e) => + handleCmdK(host, e); + window.addEventListener( + "keydown", + (host as unknown as { cmdKHandler: (e: KeyboardEvent) => void }).cmdKHandler, + ); connectGateway(host as unknown as Parameters[0]); startNodesPolling(host as unknown as Parameters[0]); if (host.tab === "logs") { @@ -62,10 +74,13 @@ export function handleFirstUpdated(host: LifecycleHost) { export function handleDisconnected(host: LifecycleHost) { window.removeEventListener("popstate", host.popStateHandler); + const cmdK = (host as unknown as { cmdKHandler?: (e: KeyboardEvent) => void }).cmdKHandler; + if (cmdK) { + window.removeEventListener("keydown", cmdK); + } stopNodesPolling(host as unknown as Parameters[0]); stopLogsPolling(host as unknown as Parameters[0]); stopDebugPolling(host as unknown as Parameters[0]); - detachThemeListener(host as unknown as Parameters[0]); host.topbarObserver?.disconnect(); host.topbarObserver = null; } diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index d954147297b..d7610962872 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -1,4 +1,4 @@ -import { html } from "lit"; +import { html, nothing } from "lit"; import { repeat } from "lit/directives/repeat.js"; import { t } from "../i18n/index.ts"; import { refreshChat } from "./app-chat.ts"; @@ -49,10 +49,12 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string) export function renderTab(state: AppViewState, tab: Tab) { const href = pathForTab(tab, state.basePath); + const isActive = state.tab === tab; + const collapsed = state.settings.navCollapsed; return html` { if ( event.defaultPrevented || @@ -77,7 +79,7 @@ export function renderTab(state: AppViewState, tab: Tab) { title=${titleForTab(tab)} > - ${titleForTab(tab)} + ${!collapsed ? html`${titleForTab(tab)}` : nothing} `; } @@ -394,10 +396,18 @@ function resolveSessionOptions( return options; } -const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"]; +type ThemeOption = { id: ThemeMode; label: string; iconKey: keyof typeof icons }; +const THEME_OPTIONS: ThemeOption[] = [ + { id: "dark", label: "Dark", iconKey: "monitor" }, + { id: "light", label: "Light", iconKey: "book" }, + { id: "openknot", label: "Knot", iconKey: "zap" }, + { id: "fieldmanual", label: "Field", iconKey: "terminal" }, + { id: "openai", label: "Ember", iconKey: "loader" }, + { id: "clawdash", label: "Chrome", iconKey: "settings" }, +]; export function renderThemeToggle(state: AppViewState) { - const index = Math.max(0, THEME_ORDER.indexOf(state.theme)); + const app = state as unknown as OpenClawApp; const applyTheme = (next: ThemeMode) => (event: MouseEvent) => { const element = event.currentTarget as HTMLElement; const context: ThemeTransitionContext = { element }; @@ -408,74 +418,34 @@ export function renderThemeToggle(state: AppViewState) { state.setTheme(next, context); }; + const handleCollapse = () => app.handleThemeToggleCollapse(); + return html` -
-
- - - - -
+
{ + const toggle = e.currentTarget as HTMLElement; + requestAnimationFrame(() => { + if (!toggle.contains(document.activeElement)) { + handleCollapse(); + } + }); + }} + > + ${state.themeOrder.map((id) => { + const opt = THEME_OPTIONS.find((o) => o.id === id)!; + return html` + + `; + })}
`; } - -function renderSunIcon() { - return html` - - `; -} - -function renderMoonIcon() { - return html` - - `; -} - -function renderMonitorIcon() { - return html` - - `; -} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index a87f9a8059c..b56dea7a89b 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1,5 +1,8 @@ import { html, nothing } from "lit"; -import { parseAgentSessionKey } from "../../../src/routing/session-key.js"; +import { + buildAgentMainSessionKey, + parseAgentSessionKey, +} from "../../../src/routing/session-key.js"; import { t } from "../i18n/index.ts"; import { refreshChatAvatar } from "./app-chat.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts"; @@ -52,17 +55,21 @@ import { updateSkillEdit, updateSkillEnabled, } from "./controllers/skills.ts"; +import "./components/dashboard-header.ts"; import { icons } from "./icons.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; import { renderAgents } from "./views/agents.ts"; +import { renderBottomTabs } from "./views/bottom-tabs.ts"; import { renderChannels } from "./views/channels.ts"; import { renderChat } from "./views/chat.ts"; +import { renderCommandPalette } from "./views/command-palette.ts"; import { renderConfig } from "./views/config.ts"; import { renderCron } from "./views/cron.ts"; import { renderDebug } from "./views/debug.ts"; import { renderExecApprovalPrompt } from "./views/exec-approval.ts"; import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts"; import { renderInstances } from "./views/instances.ts"; +import { renderLoginGate } from "./views/login-gate.ts"; import { renderLogs } from "./views/logs.ts"; import { renderNodes } from "./views/nodes.ts"; import { renderOverview } from "./views/overview.ts"; @@ -89,6 +96,15 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { } export function renderApp(state: AppViewState) { + // Gate: require successful gateway connection before showing the dashboard. + // The gateway URL confirmation overlay is always rendered so URL-param flows still work. + if (!state.connected) { + return html` + ${renderLoginGate(state)} + ${renderGatewayUrlConfirmation(state)} + `; + } + const presenceCount = state.presenceEntries.length; const sessionsCount = state.sessionsResult?.count ?? null; const cronNext = state.cronStatus?.nextWakeAtMs ?? null; @@ -108,83 +124,165 @@ export function renderApp(state: AppViewState) { null; return html` + ${renderCommandPalette({ + open: state.paletteOpen, + query: (state as unknown as { paletteQuery?: string }).paletteQuery ?? "", + activeIndex: (state as unknown as { paletteActiveIndex?: number }).paletteActiveIndex ?? 0, + onToggle: () => { + state.paletteOpen = !state.paletteOpen; + }, + onQueryChange: (q) => { + (state as unknown as { paletteQuery: string }).paletteQuery = q; + }, + onActiveIndexChange: (i) => { + (state as unknown as { paletteActiveIndex: number }).paletteActiveIndex = i; + }, + onNavigate: (tab) => { + state.setTab(tab as import("./navigation.ts").Tab); + }, + onSlashCommand: (_cmd) => { + state.setTab("chat" as import("./navigation.ts").Tab); + }, + })}
-
- -
- -
-
OPENCLAW
-
Gateway Dashboard
-
-
-
+ +
-
- - ${t("common.health")} - ${state.connected ? t("common.ok") : t("common.offline")} + + +
+ + ${state.connected ? t("common.ok") : t("common.offline")}
+ ${renderThemeToggle(state)}
-
@@ -225,6 +323,15 @@ export function renderApp(state: AppViewState) { cronEnabled: state.cronStatus?.enabled ?? null, cronNext, lastChannelsRefresh: state.channelsLastSuccess, + usageResult: state.usageResult, + sessionsResult: state.sessionsResult, + skillsReport: state.skillsReport, + cronJobs: state.cronJobs, + cronStatus: state.cronStatus, + attentionItems: state.attentionItems, + eventLog: state.eventLog, + overviewLogLines: state.overviewLogLines, + streamMode: state.streamMode, onSettingsChange: (next) => state.applySettings(next), onPasswordChange: (next) => (state.password = next), onSessionKeyChange: (next) => { @@ -240,6 +347,16 @@ export function renderApp(state: AppViewState) { }, onConnect: () => state.connect(), onRefresh: () => state.loadOverview(), + onNavigate: (tab) => state.setTab(tab as import("./navigation.ts").Tab), + onRefreshLogs: () => state.loadOverview(), + onToggleStreamMode: () => { + state.streamMode = !state.streamMode; + try { + localStorage.setItem("openclaw:stream-mode", String(state.streamMode)); + } catch { + /* */ + } + }, }) : nothing } @@ -290,6 +407,7 @@ export function renderApp(state: AppViewState) { entries: state.presenceEntries, lastError: state.presenceError, statusMessage: state.presenceStatus, + streamMode: state.streamMode, onRefresh: () => loadPresence(state), }) : nothing @@ -358,33 +476,47 @@ export function renderApp(state: AppViewState) { agentsList: state.agentsList, selectedAgentId: resolvedAgentId, activePanel: state.agentsPanel, - configForm: configValue, - configLoading: state.configLoading, - configSaving: state.configSaving, - configDirty: state.configFormDirty, - channelsLoading: state.channelsLoading, - channelsError: state.channelsError, - channelsSnapshot: state.channelsSnapshot, - channelsLastSuccess: state.channelsLastSuccess, - cronLoading: state.cronLoading, - cronStatus: state.cronStatus, - cronJobs: state.cronJobs, - cronError: state.cronError, - agentFilesLoading: state.agentFilesLoading, - agentFilesError: state.agentFilesError, - agentFilesList: state.agentFilesList, - agentFileActive: state.agentFileActive, - agentFileContents: state.agentFileContents, - agentFileDrafts: state.agentFileDrafts, - agentFileSaving: state.agentFileSaving, + config: { + form: configValue, + loading: state.configLoading, + saving: state.configSaving, + dirty: state.configFormDirty, + }, + channels: { + snapshot: state.channelsSnapshot, + loading: state.channelsLoading, + error: state.channelsError, + lastSuccess: state.channelsLastSuccess, + }, + cron: { + status: state.cronStatus, + jobs: state.cronJobs, + loading: state.cronLoading, + error: state.cronError, + }, + agentFiles: { + list: state.agentFilesList, + loading: state.agentFilesLoading, + error: state.agentFilesError, + active: state.agentFileActive, + contents: state.agentFileContents, + drafts: state.agentFileDrafts, + saving: state.agentFileSaving, + }, agentIdentityLoading: state.agentIdentityLoading, agentIdentityError: state.agentIdentityError, agentIdentityById: state.agentIdentityById, - agentSkillsLoading: state.agentSkillsLoading, - agentSkillsReport: state.agentSkillsReport, - agentSkillsError: state.agentSkillsError, - agentSkillsAgentId: state.agentSkillsAgentId, - skillsFilter: state.skillsFilter, + agentSkills: { + report: state.agentSkillsReport, + loading: state.agentSkillsLoading, + error: state.agentSkillsError, + agentId: state.agentSkillsAgentId, + filter: state.skillsFilter, + }, + sidebarFilter: state.agentsSidebarFilter, + onSidebarFilterChange: (value) => { + state.agentsSidebarFilter = value; + }, onRefresh: async () => { await loadAgents(state); const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? []; @@ -523,6 +655,9 @@ export function renderApp(state: AppViewState) { onConfigSave: () => saveConfig(state), onChannelsRefresh: () => loadChannels(state, false), onCronRefresh: () => state.loadCron(), + onCronRunNow: (_jobId) => { + // Stub: backend support pending + }, onSkillsFilterChange: (next) => (state.skillsFilter = next), onSkillsRefresh: () => { if (resolvedAgentId) { @@ -692,6 +827,12 @@ export function renderApp(state: AppViewState) { : { fallbacks: normalized }; updateConfigFormValue(state, basePath, next); }, + onSetDefault: (agentId) => { + if (!configValue) { + return; + } + updateConfigFormValue(state, ["agents", "defaultId"], agentId); + }, }) : nothing } @@ -860,6 +1001,45 @@ export function renderApp(state: AppViewState) { onAbort: () => void state.handleAbortChat(), onQueueRemove: (id) => state.removeQueuedMessage(id), onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }), + onClearHistory: async () => { + if (!state.client || !state.connected) { + return; + } + try { + await state.client.request("sessions.reset", { key: state.sessionKey }); + state.chatMessages = []; + state.chatStream = null; + state.chatRunId = null; + await loadChatHistory(state); + } catch (err) { + state.lastError = String(err); + } + }, + agentsList: state.agentsList, + currentAgentId: resolvedAgentId ?? "main", + onAgentChange: (agentId: string) => { + state.sessionKey = buildAgentMainSessionKey({ agentId }); + state.chatMessages = []; + state.chatStream = null; + state.chatRunId = null; + state.applySettings({ + ...state.settings, + sessionKey: state.sessionKey, + lastActiveSessionKey: state.sessionKey, + }); + void loadChatHistory(state); + void state.loadAssistantIdentity(); + }, + onNavigateToAgent: () => { + state.agentsSelectedId = resolvedAgentId; + state.setTab("agents" as import("./navigation.ts").Tab); + }, + onSessionSelect: (key: string) => { + state.setSessionKey(key); + state.chatMessages = []; + void loadChatHistory(state); + void state.loadAssistantIdentity(); + }, showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight, onScrollToBottom: () => state.scrollToBottom(), // Sidebar props for tool output viewing @@ -897,6 +1077,7 @@ export function renderApp(state: AppViewState) { searchQuery: state.configSearchQuery, activeSection: state.configActiveSection, activeSubsection: state.configActiveSubsection, + streamMode: state.streamMode, onRawChange: (next) => { state.configRaw = next; }, @@ -962,6 +1143,10 @@ export function renderApp(state: AppViewState) {
${renderExecApprovalPrompt(state)} ${renderGatewayUrlConfirmation(state)} + ${renderBottomTabs({ + activeTab: state.tab, + onTabChange: (tab) => state.setTab(tab), + })}
`; } diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index 48411bbe5b0..e1b05791306 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -13,14 +13,14 @@ const createHost = (tab: Tab): SettingsHost => ({ token: "", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "dark", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, navCollapsed: false, navGroupsCollapsed: {}, }, - theme: "system", + theme: "dark", themeResolved: "dark", applySessionKey: "main", sessionKey: "main", @@ -31,8 +31,6 @@ const createHost = (tab: Tab): SettingsHost => ({ eventLog: [], eventLogBuffer: [], basePath: "", - themeMedia: null, - themeMediaHandler: null, logsPollInterval: null, debugPollInterval: null, }); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 7415e468e0b..1d50cd9852c 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -21,6 +21,7 @@ import { loadNodes } from "./controllers/nodes.ts"; import { loadPresence } from "./controllers/presence.ts"; import { loadSessions } from "./controllers/sessions.ts"; import { loadSkills } from "./controllers/skills.ts"; +import { loadUsage } from "./controllers/usage.ts"; import { inferBasePathFromPathname, normalizeBasePath, @@ -32,7 +33,7 @@ import { import { saveSettings, type UiSettings } from "./storage.ts"; import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts"; import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme.ts"; -import type { AgentsListResult } from "./types.ts"; +import type { AgentsListResult, AttentionItem } from "./types.ts"; type SettingsHost = { settings: UiSettings; @@ -51,8 +52,6 @@ type SettingsHost = { agentsList?: AgentsListResult | null; agentsSelectedId?: string | null; agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron"; - themeMedia: MediaQueryList | null; - themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; pendingGatewayUrl?: string | null; }; @@ -259,7 +258,7 @@ export function inferBasePath() { } export function syncThemeWithSettings(host: SettingsHost) { - host.theme = host.settings.theme ?? "system"; + host.theme = host.settings.theme ?? "dark"; applyResolvedTheme(host, resolveTheme(host.theme)); } @@ -270,44 +269,7 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) } const root = document.documentElement; root.dataset.theme = resolved; - root.style.colorScheme = resolved; -} - -export function attachThemeListener(host: SettingsHost) { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") { - return; - } - host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)"); - host.themeMediaHandler = (event) => { - if (host.theme !== "system") { - return; - } - applyResolvedTheme(host, event.matches ? "dark" : "light"); - }; - if (typeof host.themeMedia.addEventListener === "function") { - host.themeMedia.addEventListener("change", host.themeMediaHandler); - return; - } - const legacy = host.themeMedia as MediaQueryList & { - addListener: (cb: (event: MediaQueryListEvent) => void) => void; - }; - legacy.addListener(host.themeMediaHandler); -} - -export function detachThemeListener(host: SettingsHost) { - if (!host.themeMedia || !host.themeMediaHandler) { - return; - } - if (typeof host.themeMedia.removeEventListener === "function") { - host.themeMedia.removeEventListener("change", host.themeMediaHandler); - return; - } - const legacy = host.themeMedia as MediaQueryList & { - removeListener: (cb: (event: MediaQueryListEvent) => void) => void; - }; - legacy.removeListener(host.themeMediaHandler); - host.themeMedia = null; - host.themeMediaHandler = null; + root.style.colorScheme = "dark"; } export function syncTabWithLocation(host: SettingsHost, replace: boolean) { @@ -403,13 +365,121 @@ export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, re } export async function loadOverview(host: SettingsHost) { - await Promise.all([ - loadChannels(host as unknown as OpenClawApp, false), - loadPresence(host as unknown as OpenClawApp), - loadSessions(host as unknown as OpenClawApp), - loadCronStatus(host as unknown as OpenClawApp), - loadDebug(host as unknown as OpenClawApp), + const app = host as unknown as OpenClawApp; + await Promise.allSettled([ + loadChannels(app, false), + loadPresence(app), + loadSessions(app), + loadCronStatus(app), + loadCronJobs(app), + loadDebug(app), + loadSkills(app), + loadUsage(app), + loadOverviewLogs(app), ]); + buildAttentionItems(app); +} + +async function loadOverviewLogs(host: OpenClawApp) { + if (!host.client || !host.connected) { + return; + } + try { + const res = await host.client.request("logs.tail", { + cursor: host.overviewLogCursor || undefined, + limit: 100, + maxBytes: 50_000, + }); + const payload = res as { + cursor?: number; + lines?: unknown; + }; + const lines = Array.isArray(payload.lines) + ? payload.lines.filter((line): line is string => typeof line === "string") + : []; + host.overviewLogLines = [...host.overviewLogLines, ...lines].slice(-500); + if (typeof payload.cursor === "number") { + host.overviewLogCursor = payload.cursor; + } + } catch { + /* non-critical */ + } +} + +function buildAttentionItems(host: OpenClawApp) { + const items: AttentionItem[] = []; + + if (host.lastError) { + items.push({ + severity: "error", + icon: "x", + title: "Gateway Error", + description: host.lastError, + }); + } + + const hello = host.hello; + const auth = (hello as { auth?: { scopes?: string[] } } | null)?.auth; + if (auth?.scopes && !auth.scopes.includes("operator.read")) { + items.push({ + severity: "warning", + icon: "key", + title: "Missing operator.read scope", + description: + "This connection does not have the operator.read scope. Some features may be unavailable.", + href: "https://docs.openclaw.ai/web/dashboard", + external: true, + }); + } + + const skills = host.skillsReport?.skills ?? []; + const missingDeps = skills.filter((s) => !s.disabled && Object.keys(s.missing).length > 0); + if (missingDeps.length > 0) { + const names = missingDeps.slice(0, 3).map((s) => s.name); + const more = missingDeps.length > 3 ? ` +${missingDeps.length - 3} more` : ""; + items.push({ + severity: "warning", + icon: "zap", + title: "Skills with missing dependencies", + description: `${names.join(", ")}${more}`, + }); + } + + const blocked = skills.filter((s) => s.blockedByAllowlist); + if (blocked.length > 0) { + items.push({ + severity: "warning", + icon: "shield", + title: `${blocked.length} skill${blocked.length > 1 ? "s" : ""} blocked`, + description: blocked.map((s) => s.name).join(", "), + }); + } + + const cronJobs = host.cronJobs ?? []; + const failedCron = cronJobs.filter((j) => j.state?.lastStatus === "error"); + if (failedCron.length > 0) { + items.push({ + severity: "error", + icon: "clock", + title: `${failedCron.length} cron job${failedCron.length > 1 ? "s" : ""} failed`, + description: failedCron.map((j) => j.name).join(", "), + }); + } + + const now = Date.now(); + const overdue = cronJobs.filter( + (j) => j.enabled && j.state?.nextRunAtMs != null && now - j.state.nextRunAtMs > 300_000, + ); + if (overdue.length > 0) { + items.push({ + severity: "warning", + icon: "clock", + title: `${overdue.length} overdue job${overdue.length > 1 ? "s" : ""}`, + description: overdue.map((j) => j.name).join(", "), + }); + } + + host.attentionItems = items; } export async function loadChannelsTab(host: SettingsHost) { diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index e7c7735c8bf..5ee23477ba6 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -8,20 +8,22 @@ import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import type { Tab } from "./navigation.ts"; import type { UiSettings } from "./storage.ts"; import type { ThemeTransitionContext } from "./theme-transition.ts"; -import type { ThemeMode } from "./theme.ts"; +import type { ResolvedTheme, ThemeMode } from "./theme.ts"; import type { AgentsListResult, AgentsFilesListResult, AgentIdentityResult, + AttentionItem, ChannelsStatusSnapshot, ConfigSnapshot, ConfigUiHints, CronJob, CronRunLogEntry, CronStatus, - HealthSnapshot, + HealthSummary, LogEntry, LogLevel, + ModelCatalogEntry, NostrProfile, PresenceEntry, SessionsUsageResult, @@ -43,7 +45,8 @@ export type AppViewState = { basePath: string; connected: boolean; theme: ThemeMode; - themeResolved: "light" | "dark"; + themeResolved: ResolvedTheme; + themeOrder: ThemeMode[]; hello: GatewayHelloOk | null; lastError: string | null; eventLog: EventLogEntry[]; @@ -143,6 +146,7 @@ export type AppViewState = { agentSkillsError: string | null; agentSkillsReport: SkillStatusReport | null; agentSkillsAgentId: string | null; + agentsSidebarFilter: string; sessionsLoading: boolean; sessionsResult: SessionsListResult | null; sessionsError: string | null; @@ -200,10 +204,13 @@ export type AppViewState = { skillEdits: Record; skillMessages: Record; skillsBusyKey: string | null; + healthLoading: boolean; + healthResult: HealthSummary | null; + healthError: string | null; debugLoading: boolean; debugStatus: StatusSummary | null; - debugHealth: HealthSnapshot | null; - debugModels: unknown[]; + debugHealth: HealthSummary | null; + debugModels: ModelCatalogEntry[]; debugHeartbeat: unknown; debugCallMethod: string; debugCallParams: string; @@ -223,6 +230,12 @@ export type AppViewState = { logsMaxBytes: number; logsAtBottom: boolean; updateAvailable: import("./types.js").UpdateAvailable | null; + // Overview dashboard state + attentionItems: AttentionItem[]; + paletteOpen: boolean; + streamMode: boolean; + overviewLogLines: string[]; + overviewLogCursor: number; client: GatewayBrowserClient | null; refreshSessionsAfterChat: Set; connect: () => void; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index db4b290b10e..1c284079c93 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -60,7 +60,7 @@ import type { SkillMessage } from "./controllers/skills.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import type { Tab } from "./navigation.ts"; import { loadSettings, type UiSettings } from "./storage.ts"; -import type { ResolvedTheme, ThemeMode } from "./theme.ts"; +import { VALID_THEMES, type ResolvedTheme, type ThemeMode } from "./theme.ts"; import type { AgentsListResult, AgentsFilesListResult, @@ -70,9 +70,10 @@ import type { CronJob, CronRunLogEntry, CronStatus, - HealthSnapshot, + HealthSummary, LogEntry, LogLevel, + ModelCatalogEntry, PresenceEntry, ChannelsStatusSnapshot, SessionsListResult, @@ -118,8 +119,9 @@ export class OpenClawApp extends LitElement { @state() tab: Tab = "chat"; @state() onboarding = resolveOnboardingMode(); @state() connected = false; - @state() theme: ThemeMode = this.settings.theme ?? "system"; + @state() theme: ThemeMode = this.settings.theme ?? "dark"; @state() themeResolved: ResolvedTheme = "dark"; + @state() themeOrder: ThemeMode[] = this.buildThemeOrder(this.theme); @state() hello: GatewayHelloOk | null = null; @state() lastError: string | null = null; @state() eventLog: EventLogEntry[] = []; @@ -229,6 +231,7 @@ export class OpenClawApp extends LitElement { @state() agentSkillsError: string | null = null; @state() agentSkillsReport: SkillStatusReport | null = null; @state() agentSkillsAgentId: string | null = null; + @state() agentsSidebarFilter = ""; @state() sessionsLoading = false; @state() sessionsResult: SessionsListResult | null = null; @@ -304,6 +307,23 @@ export class OpenClawApp extends LitElement { @state() updateAvailable: import("./types.js").UpdateAvailable | null = null; + // Overview dashboard state + @state() attentionItems: import("./types.js").AttentionItem[] = []; + @state() paletteOpen = false; + paletteQuery = ""; + paletteActiveIndex = 0; + @state() streamMode = (() => { + try { + const stored = localStorage.getItem("openclaw:stream-mode"); + // Default to true (redacted) unless explicitly disabled + return stored === null ? true : stored === "true"; + } catch { + return true; + } + })(); + @state() overviewLogLines: string[] = []; + @state() overviewLogCursor = 0; + @state() skillsLoading = false; @state() skillsReport: SkillStatusReport | null = null; @state() skillsError: string | null = null; @@ -312,10 +332,14 @@ export class OpenClawApp extends LitElement { @state() skillsBusyKey: string | null = null; @state() skillMessages: Record = {}; + @state() healthLoading = false; + @state() healthResult: HealthSummary | null = null; + @state() healthError: string | null = null; + @state() debugLoading = false; @state() debugStatus: StatusSummary | null = null; - @state() debugHealth: HealthSnapshot | null = null; - @state() debugModels: unknown[] = []; + @state() debugHealth: HealthSummary | null = null; + @state() debugModels: ModelCatalogEntry[] = []; @state() debugHeartbeat: unknown = null; @state() debugCallMethod = ""; @state() debugCallParams = "{}"; @@ -354,8 +378,6 @@ export class OpenClawApp extends LitElement { basePath = ""; private popStateHandler = () => onPopStateInternal(this as unknown as Parameters[0]); - private themeMedia: MediaQueryList | null = null; - private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null; private topbarObserver: ResizeObserver | null = null; createRenderRoot() { @@ -433,6 +455,19 @@ export class OpenClawApp extends LitElement { setTheme(next: ThemeMode, context?: Parameters[2]) { setThemeInternal(this as unknown as Parameters[0], next, context); + this.themeOrder = this.buildThemeOrder(next); + } + + buildThemeOrder(active: ThemeMode): ThemeMode[] { + const all = [...VALID_THEMES]; + const rest = all.filter((id) => id !== active); + return [active, ...rest]; + } + + handleThemeToggleCollapse() { + setTimeout(() => { + this.themeOrder = this.buildThemeOrder(this.theme); + }, 80); } async loadOverview() { diff --git a/ui/src/ui/chat/deleted-messages.ts b/ui/src/ui/chat/deleted-messages.ts new file mode 100644 index 00000000000..fd3916d78c7 --- /dev/null +++ b/ui/src/ui/chat/deleted-messages.ts @@ -0,0 +1,49 @@ +const PREFIX = "openclaw:deleted:"; + +export class DeletedMessages { + private key: string; + private _keys = new Set(); + + constructor(sessionKey: string) { + this.key = PREFIX + sessionKey; + this.load(); + } + + has(key: string): boolean { + return this._keys.has(key); + } + + delete(key: string): void { + this._keys.add(key); + this.save(); + } + + restore(key: string): void { + this._keys.delete(key); + this.save(); + } + + clear(): void { + this._keys.clear(); + this.save(); + } + + private load(): void { + try { + const raw = localStorage.getItem(this.key); + if (!raw) { + return; + } + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + this._keys = new Set(arr.filter((s) => typeof s === "string")); + } + } catch { + // ignore + } + } + + private save(): void { + localStorage.setItem(this.key, JSON.stringify([...this._keys])); + } +} diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 7c36713c3c0..0eb3f2251f8 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -1,9 +1,10 @@ import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import type { AssistantIdentity } from "../assistant-identity.ts"; +import { icons } from "../icons.ts"; import { toSanitizedMarkdownHtml } from "../markdown.ts"; import { detectTextDirection } from "../text-direction.ts"; -import type { MessageGroup } from "../types/chat-types.ts"; +import type { MessageGroup, ToolCard } from "../types/chat-types.ts"; import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts"; import { extractTextCached, @@ -111,6 +112,7 @@ export function renderMessageGroup( showReasoning: boolean; assistantName?: string; assistantAvatar?: string | null; + onDelete?: () => void; }, ) { const normalizedRole = normalizeRoleForGrouping(group.role); @@ -148,6 +150,16 @@ export function renderMessageGroup(
@@ -216,6 +228,66 @@ function renderMessageImages(images: ImageBlock[]) { `; } +/** Render tool cards inside a collapsed `
` element. */ +function renderCollapsedToolCards( + toolCards: ToolCard[], + onOpenSidebar?: (content: string) => void, +) { + const calls = toolCards.filter((c) => c.kind === "call"); + const results = toolCards.filter((c) => c.kind === "result"); + const totalTools = Math.max(calls.length, results.length) || toolCards.length; + const toolNames = [...new Set(toolCards.map((c) => c.name))]; + const summaryLabel = + toolNames.length <= 3 + ? toolNames.join(", ") + : `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`; + + return html` +
+ + ${icons.zap} + ${totalTools} tool${totalTools === 1 ? "" : "s"} + ${summaryLabel} + +
+ ${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))} +
+
+ `; +} + +/** + * Detect whether a trimmed string is a JSON object or array. + * Must start with `{`/`[` and end with `}`/`]` and parse successfully. + */ +function detectJson(text: string): { parsed: unknown; pretty: string } | null { + const t = text.trim(); + if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) { + try { + const parsed = JSON.parse(t); + return { parsed, pretty: JSON.stringify(parsed, null, 2) }; + } catch { + return null; + } + } + return null; +} + +/** Build a short summary label for collapsed JSON (type + key count or array length). */ +function jsonSummaryLabel(parsed: unknown): string { + if (Array.isArray(parsed)) { + return `Array (${parsed.length} item${parsed.length === 1 ? "" : "s"})`; + } + if (parsed && typeof parsed === "object") { + const keys = Object.keys(parsed as Record); + if (keys.length <= 4) { + return `{ ${keys.join(", ")} }`; + } + return `Object (${keys.length} keys)`; + } + return "JSON"; +} + function renderGroupedMessage( message: unknown, opts: { isStreaming: boolean; showReasoning: boolean }, @@ -243,6 +315,9 @@ function renderGroupedMessage( const markdown = markdownBase; const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim()); + // Detect pure-JSON messages and render as collapsible block + const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null; + const bubbleClasses = [ "chat-bubble", canCopyMarkdown ? "has-copy" : "", @@ -253,7 +328,7 @@ function renderGroupedMessage( .join(" "); if (!markdown && hasToolCards && isToolResult) { - return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`; + return renderCollapsedToolCards(toolCards, onOpenSidebar); } if (!markdown && !hasToolCards && !hasImages) { @@ -272,11 +347,19 @@ function renderGroupedMessage( : nothing } ${ - markdown - ? html`
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
` - : nothing + jsonResult + ? html`
+ + JSON + ${jsonSummaryLabel(jsonResult.parsed)} + +
${jsonResult.pretty}
+
` + : markdown + ? html`
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
` + : nothing } - ${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))} + ${hasToolCards ? renderCollapsedToolCards(toolCards, onOpenSidebar) : nothing} `; } diff --git a/ui/src/ui/chat/input-history.ts b/ui/src/ui/chat/input-history.ts new file mode 100644 index 00000000000..34d8806d072 --- /dev/null +++ b/ui/src/ui/chat/input-history.ts @@ -0,0 +1,49 @@ +const MAX = 50; + +export class InputHistory { + private items: string[] = []; + private cursor = -1; + + push(text: string): void { + const trimmed = text.trim(); + if (!trimmed) { + return; + } + if (this.items[this.items.length - 1] === trimmed) { + return; + } + this.items.push(trimmed); + if (this.items.length > MAX) { + this.items.shift(); + } + this.cursor = -1; + } + + up(): string | null { + if (this.items.length === 0) { + return null; + } + if (this.cursor < 0) { + this.cursor = this.items.length - 1; + } else if (this.cursor > 0) { + this.cursor--; + } + return this.items[this.cursor] ?? null; + } + + down(): string | null { + if (this.cursor < 0) { + return null; + } + this.cursor++; + if (this.cursor >= this.items.length) { + this.cursor = -1; + return null; + } + return this.items[this.cursor] ?? null; + } + + reset(): void { + this.cursor = -1; + } +} diff --git a/ui/src/ui/chat/pinned-messages.ts b/ui/src/ui/chat/pinned-messages.ts new file mode 100644 index 00000000000..4914b0db32a --- /dev/null +++ b/ui/src/ui/chat/pinned-messages.ts @@ -0,0 +1,61 @@ +const PREFIX = "openclaw:pinned:"; + +export class PinnedMessages { + private key: string; + private _indices = new Set(); + + constructor(sessionKey: string) { + this.key = PREFIX + sessionKey; + this.load(); + } + + get indices(): Set { + return this._indices; + } + + has(index: number): boolean { + return this._indices.has(index); + } + + pin(index: number): void { + this._indices.add(index); + this.save(); + } + + unpin(index: number): void { + this._indices.delete(index); + this.save(); + } + + toggle(index: number): void { + if (this._indices.has(index)) { + this.unpin(index); + } else { + this.pin(index); + } + } + + clear(): void { + this._indices.clear(); + this.save(); + } + + private load(): void { + try { + const raw = localStorage.getItem(this.key); + if (!raw) { + return; + } + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + this._indices = new Set(arr.filter((n) => typeof n === "number")); + } + } catch { + // ignore + } + } + + private save(): void { + localStorage.setItem(this.key, JSON.stringify([...this._indices])); + } +} diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts new file mode 100644 index 00000000000..48e6c838817 --- /dev/null +++ b/ui/src/ui/chat/slash-commands.ts @@ -0,0 +1,84 @@ +import type { IconName } from "../icons.ts"; + +export type SlashCommandCategory = "session" | "model" | "agents" | "tools"; + +export type SlashCommandDef = { + name: string; + description: string; + args?: string; + icon?: IconName; + category?: SlashCommandCategory; +}; + +export const SLASH_COMMANDS: SlashCommandDef[] = [ + { name: "help", description: "Show available commands", icon: "book", category: "session" }, + { name: "status", description: "Show current status", icon: "barChart", category: "session" }, + { name: "reset", description: "Reset session", icon: "refresh", category: "session" }, + { name: "compact", description: "Compact session context", icon: "loader", category: "session" }, + { name: "stop", description: "Stop current run", icon: "stop", category: "session" }, + { + name: "model", + description: "Show/set model", + args: "", + icon: "brain", + category: "model", + }, + { + name: "think", + description: "Set thinking level", + args: "", + icon: "brain", + category: "model", + }, + { + name: "verbose", + description: "Toggle verbose mode", + args: "", + icon: "terminal", + category: "model", + }, + { name: "export", description: "Export session to HTML", icon: "download", category: "tools" }, + { + name: "skill", + description: "Run a skill", + args: "", + icon: "zap", + category: "tools", + }, + { name: "agents", description: "List agents", icon: "monitor", category: "agents" }, + { + name: "kill", + description: "Abort sub-agents", + args: "", + icon: "x", + category: "agents", + }, + { + name: "steer", + description: "Steer a sub-agent", + args: " ", + icon: "send", + category: "agents", + }, + { name: "usage", description: "Show token usage", icon: "barChart", category: "tools" }, +]; + +const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "agents", "tools"]; + +export const CATEGORY_LABELS: Record = { + session: "Session", + model: "Model", + agents: "Agents", + tools: "Tools", +}; + +export function getSlashCommandCompletions(filter: string): SlashCommandDef[] { + const commands = filter + ? SLASH_COMMANDS.filter((cmd) => cmd.name.startsWith(filter.toLowerCase())) + : SLASH_COMMANDS; + return commands.toSorted((a, b) => { + const ai = CATEGORY_ORDER.indexOf(a.category ?? "session"); + const bi = CATEGORY_ORDER.indexOf(b.category ?? "session"); + return ai - bi; + }); +} diff --git a/ui/src/ui/components/dashboard-header.ts b/ui/src/ui/components/dashboard-header.ts new file mode 100644 index 00000000000..cf5f9795c0b --- /dev/null +++ b/ui/src/ui/components/dashboard-header.ts @@ -0,0 +1,34 @@ +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { titleForTab, type Tab } from "../navigation.js"; + +@customElement("dashboard-header") +export class DashboardHeader extends LitElement { + override createRenderRoot() { + return this; + } + + @property() tab: Tab = "overview"; + + override render() { + const label = titleForTab(this.tab); + + return html` +
+
+ this.dispatchEvent(new CustomEvent("navigate", { detail: "overview", bubbles: true, composed: true }))} + > + ClawDash + + + ${label} +
+
+ +
+
+ `; + } +} diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index 292c5780b35..b391a27f928 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -197,7 +197,7 @@ describe("config form renderer", () => { expect(container.textContent).toContain("Plugin Enabled"); }); - it("flags unsupported unions", () => { + it("passes mixed unions through for JSON fallback rendering", () => { const schema = { type: "object", properties: { @@ -207,7 +207,7 @@ describe("config form renderer", () => { }, }; const analysis = analyzeConfigSchema(schema); - expect(analysis.unsupportedPaths).toContain("mixed"); + expect(analysis.unsupportedPaths).not.toContain("mixed"); }); it("supports nullable types", () => { diff --git a/ui/src/ui/controllers/debug.ts b/ui/src/ui/controllers/debug.ts index b4dfa7ade4d..3fb743c56a0 100644 --- a/ui/src/ui/controllers/debug.ts +++ b/ui/src/ui/controllers/debug.ts @@ -1,18 +1,24 @@ import type { GatewayBrowserClient } from "../gateway.ts"; -import type { HealthSnapshot, StatusSummary } from "../types.ts"; +import type { HealthSummary, ModelCatalogEntry, StatusSummary } from "../types.ts"; +import { loadHealthState } from "./health.ts"; +import { loadModels } from "./models.ts"; export type DebugState = { client: GatewayBrowserClient | null; connected: boolean; debugLoading: boolean; debugStatus: StatusSummary | null; - debugHealth: HealthSnapshot | null; - debugModels: unknown[]; + debugHealth: HealthSummary | null; + debugModels: ModelCatalogEntry[]; debugHeartbeat: unknown; debugCallMethod: string; debugCallParams: string; debugCallResult: string | null; debugCallError: string | null; + /** Shared health state fields (written by {@link loadHealthState}). */ + healthLoading: boolean; + healthResult: HealthSummary | null; + healthError: string | null; }; export async function loadDebug(state: DebugState) { @@ -24,16 +30,16 @@ export async function loadDebug(state: DebugState) { } state.debugLoading = true; try { - const [status, health, models, heartbeat] = await Promise.all([ + const [status, , models, heartbeat] = await Promise.all([ state.client.request("status", {}), - state.client.request("health", {}), - state.client.request("models.list", {}), + loadHealthState(state), + loadModels(state.client), state.client.request("last-heartbeat", {}), ]); state.debugStatus = status as StatusSummary; - state.debugHealth = health as HealthSnapshot; - const modelPayload = models as { models?: unknown[] } | undefined; - state.debugModels = Array.isArray(modelPayload?.models) ? modelPayload?.models : []; + // Sync debugHealth from the shared healthResult for backward compat. + state.debugHealth = state.healthResult; + state.debugModels = models; state.debugHeartbeat = heartbeat; } catch (err) { state.debugCallError = String(err); diff --git a/ui/src/ui/controllers/health.ts b/ui/src/ui/controllers/health.ts new file mode 100644 index 00000000000..b077794d67a --- /dev/null +++ b/ui/src/ui/controllers/health.ts @@ -0,0 +1,62 @@ +import type { GatewayBrowserClient } from "../gateway.ts"; +import type { HealthSummary } from "../types.ts"; + +/** Default fallback returned when the gateway is unreachable or returns null. */ +const HEALTH_FALLBACK: HealthSummary = { + ok: false, + ts: 0, + durationMs: 0, + heartbeatSeconds: 0, + defaultAgentId: "", + agents: [], + sessions: { path: "", count: 0, recent: [] }, +}; + +/** State slice consumed by {@link loadHealthState}. Follows the agents/sessions convention. */ +export type HealthState = { + client: GatewayBrowserClient | null; + connected: boolean; + healthLoading: boolean; + healthResult: HealthSummary | null; + healthError: string | null; +}; + +/** + * Fetch the gateway health summary. + * + * Accepts a {@link GatewayBrowserClient} (matching the existing ui/ controller + * convention). Returns a fully-typed {@link HealthSummary}; on failure the + * caller receives a safe fallback with `ok: false` rather than `null`. + */ +export async function loadHealth(client: GatewayBrowserClient): Promise { + try { + const result = await client.request("health", {}); + return result ?? HEALTH_FALLBACK; + } catch { + return HEALTH_FALLBACK; + } +} + +/** + * State-mutating health loader (same pattern as {@link import("./agents.ts").loadAgents}). + * + * Populates `healthResult` / `healthError` on the provided state slice and + * toggles `healthLoading` around the request. + */ +export async function loadHealthState(state: HealthState): Promise { + if (!state.client || !state.connected) { + return; + } + if (state.healthLoading) { + return; + } + state.healthLoading = true; + state.healthError = null; + try { + state.healthResult = await loadHealth(state.client); + } catch (err) { + state.healthError = String(err); + } finally { + state.healthLoading = false; + } +} diff --git a/ui/src/ui/controllers/models.ts b/ui/src/ui/controllers/models.ts new file mode 100644 index 00000000000..d9e119c5c3a --- /dev/null +++ b/ui/src/ui/controllers/models.ts @@ -0,0 +1,18 @@ +import type { GatewayBrowserClient } from "../gateway.ts"; +import type { ModelCatalogEntry } from "../types.ts"; + +/** + * Fetch the model catalog from the gateway. + * + * Accepts a {@link GatewayBrowserClient} (matching the existing ui/ controller + * convention). Returns an array of {@link ModelCatalogEntry}; on failure the + * caller receives an empty array rather than throwing. + */ +export async function loadModels(client: GatewayBrowserClient): Promise { + try { + const result = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {}); + return result?.models ?? []; + } catch { + return []; + } +} diff --git a/ui/src/ui/format.ts b/ui/src/ui/format.ts index da3d544f199..e0c92baba3d 100644 --- a/ui/src/ui/format.ts +++ b/ui/src/ui/format.ts @@ -58,3 +58,41 @@ export function parseList(input: string): string[] { export function stripThinkingTags(value: string): string { return stripReasoningTagsFromText(value, { mode: "preserve", trim: "start" }); } + +export function formatCost(cost: number | null | undefined, fallback = "$0.00"): string { + if (cost == null || !Number.isFinite(cost)) { + return fallback; + } + if (cost === 0) { + return "$0.00"; + } + if (cost < 0.01) { + return `$${cost.toFixed(4)}`; + } + if (cost < 1) { + return `$${cost.toFixed(3)}`; + } + return `$${cost.toFixed(2)}`; +} + +export function formatTokens(tokens: number | null | undefined, fallback = "0"): string { + if (tokens == null || !Number.isFinite(tokens)) { + return fallback; + } + if (tokens < 1000) { + return String(Math.round(tokens)); + } + if (tokens < 1_000_000) { + const k = tokens / 1000; + return k < 10 ? `${k.toFixed(1)}k` : `${Math.round(k)}k`; + } + const m = tokens / 1_000_000; + return m < 10 ? `${m.toFixed(1)}M` : `${Math.round(m)}M`; +} + +export function formatPercent(value: number | null | undefined, fallback = "—"): string { + if (value == null || !Number.isFinite(value)) { + return fallback; + } + return `${(value * 100).toFixed(1)}%`; +} diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index ef2c418a014..39ef7ec1c8e 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -155,7 +155,6 @@ export class GatewayBrowserClient { const scopes = DEFAULT_OPERATOR_CONNECT_SCOPES; const role = "operator"; let deviceIdentity: Awaited> | null = null; - let canFallbackToShared = false; let authToken = this.opts.token; if (isSecureContext) { @@ -165,7 +164,6 @@ export class GatewayBrowserClient { role, })?.token; authToken = storedToken ?? this.opts.token; - canFallbackToShared = Boolean(storedToken && this.opts.token); } const auth = authToken || this.opts.password @@ -239,7 +237,11 @@ export class GatewayBrowserClient { this.opts.onHello?.(hello); }) .catch(() => { - if (canFallbackToShared && deviceIdentity) { + // Clear stale device token on any connect failure so the next attempt + // falls back to the shared gateway token (if present) or retries without + // a cached device token. Without this, a rotated/revoked device token + // causes an infinite mismatch loop when no shared token is configured. + if (deviceIdentity) { clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role }); } this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed"); diff --git a/ui/src/ui/icons.ts b/ui/src/ui/icons.ts index 1682dcfa9d3..5a42ef89130 100644 --- a/ui/src/ui/icons.ts +++ b/ui/src/ui/icons.ts @@ -228,6 +228,147 @@ export const icons = { /> `, + panelLeftClose: html` + + + + + + `, + panelLeftOpen: html` + + + + + + `, + chevronDown: html` + + + + `, + chevronRight: html` + + + + `, + externalLink: html` + + + + + `, + send: html` + + + + + `, + stop: html` + + `, + pin: html` + + + + + `, + pinOff: html` + + + + + + `, + download: html` + + + + + + `, + mic: html` + + + + + + `, + micOff: html` + + + + + + + + + `, + bookmark: html` + + `, + plus: html` + + + + + `, + terminal: html` + + + + + `, + spark: html` + + + + `, + refresh: html` + + + + + `, + trash: html` + + + + + + + + `, + eye: html` + + + + + `, + eyeOff: html` + + + + + + + `, } as const; export type IconName = keyof typeof icons; diff --git a/ui/src/ui/markdown.ts b/ui/src/ui/markdown.ts index 1867b0eda46..e892402e5d6 100644 --- a/ui/src/ui/markdown.ts +++ b/ui/src/ui/markdown.ts @@ -14,6 +14,7 @@ const allowedTags = [ "br", "code", "del", + "details", "em", "h1", "h2", @@ -26,6 +27,7 @@ const allowedTags = [ "p", "pre", "strong", + "summary", "table", "tbody", "td", @@ -132,6 +134,35 @@ export function toSanitizedMarkdownHtml(markdown: string): string { const htmlEscapeRenderer = new marked.Renderer(); htmlEscapeRenderer.html = ({ text }: { text: string }) => escapeHtml(text); +htmlEscapeRenderer.code = ({ + text, + lang, + escaped, +}: { + text: string; + lang?: string; + escaped: boolean; +}) => { + const langClass = lang ? ` class="language-${lang}"` : ""; + const safeText = escaped ? text : escapeHtml(text); + const codeBlock = `
${safeText}
`; + + const trimmed = text.trim(); + const isJson = + lang === "json" || + (!lang && + ((trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")))); + + if (isJson) { + const lineCount = text.split("\n").length; + const label = lineCount > 1 ? `JSON · ${lineCount} lines` : "JSON"; + return `
${label}${codeBlock}
`; + } + + return codeBlock; +}; + function escapeHtml(value: string): string { return value .replace(/&/g, "&") diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index b32e6c3c5b2..e9803088576 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -1,7 +1,7 @@ const KEY = "openclaw.control.settings.v1"; import { isSupportedLocale } from "../i18n/index.ts"; -import type { ThemeMode } from "./theme.ts"; +import { VALID_THEMES, type ThemeMode } from "./theme.ts"; export type UiSettings = { gatewayUrl: string; @@ -28,7 +28,7 @@ export function loadSettings(): UiSettings { token: "", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "dark", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, @@ -57,10 +57,9 @@ export function loadSettings(): UiSettings { ? parsed.lastActiveSessionKey.trim() : (typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()) || defaults.lastActiveSessionKey, - theme: - parsed.theme === "light" || parsed.theme === "dark" || parsed.theme === "system" - ? parsed.theme - : defaults.theme, + theme: VALID_THEMES.has(parsed.theme as ThemeMode) + ? (parsed.theme as ThemeMode) + : defaults.theme, chatFocusMode: typeof parsed.chatFocusMode === "boolean" ? parsed.chatFocusMode : defaults.chatFocusMode, chatShowThinking: diff --git a/ui/src/ui/theme.ts b/ui/src/ui/theme.ts index 480f9dbe51a..c27f8b280d2 100644 --- a/ui/src/ui/theme.ts +++ b/ui/src/ui/theme.ts @@ -1,16 +1,26 @@ -export type ThemeMode = "system" | "light" | "dark"; -export type ResolvedTheme = "light" | "dark"; +export type ThemeMode = "dark" | "light" | "openknot" | "fieldmanual" | "openai" | "clawdash"; +export type ResolvedTheme = ThemeMode; -export function getSystemTheme(): ResolvedTheme { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") { - return "dark"; - } - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; -} +export const VALID_THEMES = new Set([ + "dark", + "light", + "openknot", + "fieldmanual", + "openai", + "clawdash", +]); -export function resolveTheme(mode: ThemeMode): ResolvedTheme { - if (mode === "system") { - return getSystemTheme(); +const LEGACY_MAP: Record = { + defaultTheme: "dark", + docsTheme: "light", + lightTheme: "openknot", + landingTheme: "openknot", + newTheme: "openknot", +}; + +export function resolveTheme(mode: string): ResolvedTheme { + if (VALID_THEMES.has(mode as ThemeMode)) { + return mode as ThemeMode; } - return mode; + return LEGACY_MAP[mode] ?? "dark"; } diff --git a/ui/src/ui/tool-labels.ts b/ui/src/ui/tool-labels.ts new file mode 100644 index 00000000000..e4818c49362 --- /dev/null +++ b/ui/src/ui/tool-labels.ts @@ -0,0 +1,39 @@ +/** + * Map raw tool names to human-friendly labels for the chat UI. + * Unknown tools are title-cased with underscores replaced by spaces. + */ + +export const TOOL_LABELS: Record = { + exec: "Run Command", + bash: "Run Command", + read: "Read File", + write: "Write File", + edit: "Edit File", + apply_patch: "Apply Patch", + web_search: "Web Search", + web_fetch: "Fetch Page", + browser: "Browser", + message: "Send Message", + image: "Generate Image", + canvas: "Canvas", + cron: "Cron", + gateway: "Gateway", + nodes: "Nodes", + memory_search: "Search Memory", + memory_get: "Get Memory", + session_status: "Session Status", + sessions_list: "List Sessions", + sessions_history: "Session History", + sessions_send: "Send to Session", + sessions_spawn: "Spawn Session", + agents_list: "List Agents", +}; + +export function friendlyToolName(raw: string): string { + const mapped = TOOL_LABELS[raw]; + if (mapped) { + return mapped; + } + // Title-case fallback: "some_tool_name" → "Some Tool Name" + return raw.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 307bae9388f..eaf7ca06319 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -556,6 +556,35 @@ export type StatusSummary = Record; export type HealthSnapshot = Record; +/** Strongly-typed health response from the gateway (richer than HealthSnapshot). */ +export type HealthSummary = { + ok: boolean; + ts: number; + durationMs: number; + heartbeatSeconds: number; + defaultAgentId: string; + agents: Array<{ id: string; name?: string }>; + sessions: { + path: string; + count: number; + recent: Array<{ + key: string; + updatedAt: number | null; + age: number | null; + }>; + }; +}; + +/** A model entry returned by the gateway model-catalog endpoint. */ +export type ModelCatalogEntry = { + id: string; + name: string; + provider: string; + contextWindow?: number; + reasoning?: boolean; + input?: Array<"text" | "image">; +}; + export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"; export type LogEntry = { @@ -566,3 +595,16 @@ export type LogEntry = { message?: string | null; meta?: Record | null; }; + +// ── Attention ─────────────────────────────────────── + +export type AttentionSeverity = "error" | "warning" | "info"; + +export type AttentionItem = { + severity: AttentionSeverity; + icon: string; + title: string; + description: string; + href?: string; + external?: boolean; +}; diff --git a/ui/src/ui/views/agents-panels-overview.ts b/ui/src/ui/views/agents-panels-overview.ts new file mode 100644 index 00000000000..a19234550b5 --- /dev/null +++ b/ui/src/ui/views/agents-panels-overview.ts @@ -0,0 +1,233 @@ +import { html, nothing } from "lit"; +import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult } from "../types.ts"; +import { + agentAvatarHue, + agentBadgeText, + buildModelOptions, + normalizeAgentLabel, + normalizeModelValue, + parseFallbackList, + resolveAgentConfig, + resolveAgentEmoji, + resolveModelFallbacks, + resolveModelLabel, + resolveModelPrimary, +} from "./agents-utils.ts"; +import type { AgentsPanel } from "./agents.ts"; + +export function renderAgentOverview(params: { + agent: AgentsListResult["agents"][number]; + defaultId: string | null; + configForm: Record | null; + agentFilesList: AgentsFilesListResult | null; + agentIdentity: AgentIdentityResult | null; + agentIdentityLoading: boolean; + agentIdentityError: string | null; + configLoading: boolean; + configSaving: boolean; + configDirty: boolean; + onConfigReload: () => void; + onConfigSave: () => void; + onModelChange: (agentId: string, modelId: string | null) => void; + onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; + onSelectPanel: (panel: AgentsPanel) => void; +}) { + const { + agent, + configForm, + agentFilesList, + agentIdentity, + agentIdentityLoading, + agentIdentityError, + configLoading, + configSaving, + configDirty, + onConfigReload, + onConfigSave, + onModelChange, + onModelFallbacksChange, + onSelectPanel, + } = params; + const config = resolveAgentConfig(configForm, agent.id); + const workspaceFromFiles = + agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null; + const workspace = + workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default"; + const model = config.entry?.model + ? resolveModelLabel(config.entry?.model) + : resolveModelLabel(config.defaults?.model); + const defaultModel = resolveModelLabel(config.defaults?.model); + const modelPrimary = + resolveModelPrimary(config.entry?.model) || (model !== "-" ? normalizeModelValue(model) : null); + const defaultPrimary = + resolveModelPrimary(config.defaults?.model) || + (defaultModel !== "-" ? normalizeModelValue(defaultModel) : null); + const effectivePrimary = modelPrimary ?? defaultPrimary ?? null; + const modelFallbacks = resolveModelFallbacks(config.entry?.model); + const fallbackChips = modelFallbacks ?? []; + const identityName = + agentIdentity?.name?.trim() || + agent.identity?.name?.trim() || + agent.name?.trim() || + config.entry?.name || + "-"; + const resolvedEmoji = resolveAgentEmoji(agent, agentIdentity); + const identityEmoji = resolvedEmoji || "-"; + const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null; + const skillCount = skillFilter?.length ?? null; + const identityStatus = agentIdentityLoading + ? "Loading…" + : agentIdentityError + ? "Unavailable" + : ""; + const isDefault = Boolean(params.defaultId && agent.id === params.defaultId); + const badge = agentBadgeText(agent.id, params.defaultId); + const hue = agentAvatarHue(agent.id); + const displayName = normalizeAgentLabel(agent); + const subtitle = agent.identity?.theme?.trim() || ""; + const disabled = !configForm || configLoading || configSaving; + + const removeChip = (index: number) => { + const next = fallbackChips.filter((_, i) => i !== index); + onModelFallbacksChange(agent.id, next); + }; + + const handleChipKeydown = (e: KeyboardEvent) => { + const input = e.target as HTMLInputElement; + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + const parsed = parseFallbackList(input.value); + if (parsed.length > 0) { + onModelFallbacksChange(agent.id, [...fallbackChips, ...parsed]); + input.value = ""; + } + } + }; + + return html` +
+
Overview
+
Workspace paths and identity metadata.
+ +
+
+ ${resolvedEmoji || displayName.slice(0, 1)} +
+
+
${identityName}
+
+ ${identityEmoji !== "-" ? html`${identityEmoji}` : nothing} + ${subtitle ? html`${subtitle}` : nothing} + ${badge ? html`${badge}` : nothing} + ${identityStatus ? html`${identityStatus}` : nothing} +
+
+
+ +
+
+
Workspace
+
+ +
+
+
+
Primary Model
+
${model}
+
+
+
Skills Filter
+
${skillFilter ? `${skillCount} selected` : "all skills"}
+
+
+ + ${ + configDirty + ? html` +
You have unsaved config changes.
+ ` + : nothing + } + +
+
Model Selection
+
+ +
+ Fallbacks +
{ + const container = e.currentTarget as HTMLElement; + const input = container.querySelector("input"); + if (input) { + input.focus(); + } + }}> + ${fallbackChips.map( + (chip, i) => html` + + ${chip} + + + `, + )} + { + const input = e.target as HTMLInputElement; + const parsed = parseFallbackList(input.value); + if (parsed.length > 0) { + onModelFallbacksChange(agent.id, [...fallbackChips, ...parsed]); + input.value = ""; + } + }} + /> +
+
+
+
+ + +
+
+
+ `; +} diff --git a/ui/src/ui/views/agents-panels-status-files.ts b/ui/src/ui/views/agents-panels-status-files.ts index 23de4cb96b6..58ff34782e2 100644 --- a/ui/src/ui/views/agents-panels-status-files.ts +++ b/ui/src/ui/views/agents-panels-status-files.ts @@ -230,7 +230,7 @@ export function renderAgentChannels(params: { const status = summary.total ? `${summary.connected}/${summary.total} connected` : "no accounts"; - const config = summary.configured + const configLabel = summary.configured ? `${summary.configured} configured` : "not configured"; const enabled = summary.total ? `${summary.enabled} enabled` : "disabled"; @@ -243,8 +243,23 @@ export function renderAgentChannels(params: {
${status}
-
${config}
+
${configLabel}
${enabled}
+ ${ + summary.configured === 0 + ? html` + + ` + : nothing + } ${ extras.length > 0 ? extras.map( @@ -272,6 +287,7 @@ export function renderAgentCron(params: { loading: boolean; error: string | null; onRefresh: () => void; + onRunNow: (jobId: string) => void; }) { const jobs = params.jobs.filter((job) => job.agentId === params.agentId); return html` @@ -341,6 +357,12 @@ export function renderAgentCron(params: {
${formatCronState(job)}
${formatCronPayload(job)}
+
`, diff --git a/ui/src/ui/views/agents-panels-tools-skills.ts b/ui/src/ui/views/agents-panels-tools-skills.ts index 687ec749a62..49da26f34bc 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.ts @@ -301,17 +301,27 @@ export function renderAgentSkills(params: { } -
- - +
+
+ + + +
diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index ecd2c90f13b..4ea1053d511 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -189,6 +189,14 @@ export function agentBadgeText(agentId: string, defaultId: string | null) { return defaultId && agentId === defaultId ? "default" : null; } +export function agentAvatarHue(id: string): number { + let hash = 0; + for (let i = 0; i < id.length; i += 1) { + hash = (hash * 31 + id.charCodeAt(i)) | 0; + } + return ((hash % 360) + 360) % 360; +} + export function formatBytes(bytes?: number) { if (bytes == null || !Number.isFinite(bytes)) { return "-"; diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index f8cf5cb5f57..55a3001abb6 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/ui/views/agents.ts @@ -8,6 +8,7 @@ import type { CronStatus, SkillStatusReport, } from "../types.ts"; +import { renderAgentOverview } from "./agents-panels-overview.ts"; import { renderAgentFiles, renderAgentChannels, @@ -15,54 +16,70 @@ import { } from "./agents-panels-status-files.ts"; import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts"; import { + agentAvatarHue, agentBadgeText, buildAgentContext, - buildModelOptions, normalizeAgentLabel, - normalizeModelValue, - parseFallbackList, - resolveAgentConfig, resolveAgentEmoji, - resolveModelFallbacks, - resolveModelLabel, - resolveModelPrimary, } from "./agents-utils.ts"; export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron"; +export type ConfigState = { + form: Record | null; + loading: boolean; + saving: boolean; + dirty: boolean; +}; + +export type ChannelsState = { + snapshot: ChannelsStatusSnapshot | null; + loading: boolean; + error: string | null; + lastSuccess: number | null; +}; + +export type CronState = { + status: CronStatus | null; + jobs: CronJob[]; + loading: boolean; + error: string | null; +}; + +export type AgentFilesState = { + list: AgentsFilesListResult | null; + loading: boolean; + error: string | null; + active: string | null; + contents: Record; + drafts: Record; + saving: boolean; +}; + +export type AgentSkillsState = { + report: SkillStatusReport | null; + loading: boolean; + error: string | null; + agentId: string | null; + filter: string; +}; + export type AgentsProps = { loading: boolean; error: string | null; agentsList: AgentsListResult | null; selectedAgentId: string | null; activePanel: AgentsPanel; - configForm: Record | null; - configLoading: boolean; - configSaving: boolean; - configDirty: boolean; - channelsLoading: boolean; - channelsError: string | null; - channelsSnapshot: ChannelsStatusSnapshot | null; - channelsLastSuccess: number | null; - cronLoading: boolean; - cronStatus: CronStatus | null; - cronJobs: CronJob[]; - cronError: string | null; - agentFilesLoading: boolean; - agentFilesError: string | null; - agentFilesList: AgentsFilesListResult | null; - agentFileActive: string | null; - agentFileContents: Record; - agentFileDrafts: Record; - agentFileSaving: boolean; + config: ConfigState; + channels: ChannelsState; + cron: CronState; + agentFiles: AgentFilesState; agentIdentityLoading: boolean; agentIdentityError: string | null; agentIdentityById: Record; - agentSkillsLoading: boolean; - agentSkillsReport: SkillStatusReport | null; - agentSkillsError: string | null; - agentSkillsAgentId: string | null; - skillsFilter: string; + agentSkills: AgentSkillsState; + sidebarFilter: string; + onSidebarFilterChange: (value: string) => void; onRefresh: () => void; onSelectAgent: (agentId: string) => void; onSelectPanel: (panel: AgentsPanel) => void; @@ -79,20 +96,13 @@ export type AgentsProps = { onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; onChannelsRefresh: () => void; onCronRefresh: () => void; + onCronRunNow: (jobId: string) => void; onSkillsFilterChange: (next: string) => void; onSkillsRefresh: () => void; onAgentSkillToggle: (agentId: string, skillName: string, enabled: boolean) => void; onAgentSkillsClear: (agentId: string) => void; onAgentSkillsDisableAll: (agentId: string) => void; -}; - -export type AgentContext = { - workspace: string; - model: string; - identityName: string; - identityEmoji: string; - skillsLabel: string; - isDefault: boolean; + onSetDefault: (agentId: string) => void; }; export function renderAgents(props: AgentsProps) { @@ -103,6 +113,27 @@ export function renderAgents(props: AgentsProps) { ? (agents.find((agent) => agent.id === selectedId) ?? null) : null; + const sidebarFilter = props.sidebarFilter.trim().toLowerCase(); + const filteredAgents = sidebarFilter + ? agents.filter((agent) => { + const label = normalizeAgentLabel(agent).toLowerCase(); + return label.includes(sidebarFilter) || agent.id.toLowerCase().includes(sidebarFilter); + }) + : agents; + + const channelEntryCount = props.channels.snapshot + ? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length + : null; + const cronJobCount = selectedId + ? props.cron.jobs.filter((j) => j.agentId === selectedId).length + : null; + const tabCounts: Record = { + files: props.agentFiles.list?.files?.length ?? null, + skills: props.agentSkills.report?.skills?.length ?? null, + channels: channelEntryCount, + cron: cronJobCount || null, + }; + return html`
@@ -115,6 +146,21 @@ export function renderAgents(props: AgentsProps) { ${props.loading ? "Loading…" : "Refresh"}
+ ${ + agents.length > 1 + ? html` + + props.onSidebarFilterChange((e.target as HTMLInputElement).value)} + style="margin-top: 8px;" + /> + ` + : nothing + } ${ props.error ? html`
${props.error}
` @@ -122,20 +168,23 @@ export function renderAgents(props: AgentsProps) { }
${ - agents.length === 0 + filteredAgents.length === 0 ? html` -
No agents found.
+
${sidebarFilter ? "No matching agents." : "No agents found."}
` - : agents.map((agent) => { + : filteredAgents.map((agent) => { const badge = agentBadgeText(agent.id, defaultId); const emoji = resolveAgentEmoji(agent, props.agentIdentityById[agent.id] ?? null); + const hue = agentAvatarHue(agent.id); return html` + ${ + actionsMenuOpen + ? html` +
+ + +
+ ` + : nothing + } +
+
`; } -function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) => void) { +function renderAgentTabs( + active: AgentsPanel, + onSelect: (panel: AgentsPanel) => void, + counts: Record, +) { const tabs: Array<{ id: AgentsPanel; label: string }> = [ { id: "overview", label: "Overview" }, { id: "files", label: "Files" }, @@ -329,161 +428,10 @@ function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) => type="button" @click=${() => onSelect(tab.id)} > - ${tab.label} + ${tab.label}${counts[tab.id] != null ? html`${counts[tab.id]}` : nothing} `, )} `; } - -function renderAgentOverview(params: { - agent: AgentsListResult["agents"][number]; - defaultId: string | null; - configForm: Record | null; - agentFilesList: AgentsFilesListResult | null; - agentIdentity: AgentIdentityResult | null; - agentIdentityLoading: boolean; - agentIdentityError: string | null; - configLoading: boolean; - configSaving: boolean; - configDirty: boolean; - onConfigReload: () => void; - onConfigSave: () => void; - onModelChange: (agentId: string, modelId: string | null) => void; - onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; -}) { - const { - agent, - configForm, - agentFilesList, - agentIdentity, - agentIdentityLoading, - agentIdentityError, - configLoading, - configSaving, - configDirty, - onConfigReload, - onConfigSave, - onModelChange, - onModelFallbacksChange, - } = params; - const config = resolveAgentConfig(configForm, agent.id); - const workspaceFromFiles = - agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null; - const workspace = - workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default"; - const model = config.entry?.model - ? resolveModelLabel(config.entry?.model) - : resolveModelLabel(config.defaults?.model); - const defaultModel = resolveModelLabel(config.defaults?.model); - const modelPrimary = - resolveModelPrimary(config.entry?.model) || (model !== "-" ? normalizeModelValue(model) : null); - const defaultPrimary = - resolveModelPrimary(config.defaults?.model) || - (defaultModel !== "-" ? normalizeModelValue(defaultModel) : null); - const effectivePrimary = modelPrimary ?? defaultPrimary ?? null; - const modelFallbacks = resolveModelFallbacks(config.entry?.model); - const fallbackText = modelFallbacks ? modelFallbacks.join(", ") : ""; - const identityName = - agentIdentity?.name?.trim() || - agent.identity?.name?.trim() || - agent.name?.trim() || - config.entry?.name || - "-"; - const resolvedEmoji = resolveAgentEmoji(agent, agentIdentity); - const identityEmoji = resolvedEmoji || "-"; - const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null; - const skillCount = skillFilter?.length ?? null; - const identityStatus = agentIdentityLoading - ? "Loading…" - : agentIdentityError - ? "Unavailable" - : ""; - const isDefault = Boolean(params.defaultId && agent.id === params.defaultId); - - return html` -
-
Overview
-
Workspace paths and identity metadata.
-
-
-
Workspace
-
${workspace}
-
-
-
Primary Model
-
${model}
-
-
-
Identity Name
-
${identityName}
- ${identityStatus ? html`
${identityStatus}
` : nothing} -
-
-
Default
-
${isDefault ? "yes" : "no"}
-
-
-
Identity Emoji
-
${identityEmoji}
-
-
-
Skills Filter
-
${skillFilter ? `${skillCount} selected` : "all skills"}
-
-
- -
-
Model Selection
-
- - -
-
- - -
-
-
- `; -} diff --git a/ui/src/ui/views/bottom-tabs.ts b/ui/src/ui/views/bottom-tabs.ts new file mode 100644 index 00000000000..b8dfbebf39c --- /dev/null +++ b/ui/src/ui/views/bottom-tabs.ts @@ -0,0 +1,33 @@ +import { html } from "lit"; +import { icons } from "../icons.ts"; +import type { Tab } from "../navigation.ts"; + +export type BottomTabsProps = { + activeTab: Tab; + onTabChange: (tab: Tab) => void; +}; + +const BOTTOM_TABS: Array<{ id: Tab; label: string; icon: keyof typeof icons }> = [ + { id: "overview", label: "Dashboard", icon: "barChart" }, + { id: "chat", label: "Chat", icon: "messageSquare" }, + { id: "sessions", label: "Sessions", icon: "fileText" }, + { id: "config", label: "Settings", icon: "settings" }, +]; + +export function renderBottomTabs(props: BottomTabsProps) { + return html` + + `; +} diff --git a/ui/src/ui/views/channels.nostr-profile-form.ts b/ui/src/ui/views/channels.nostr-profile-form.ts index 62e4669f397..244236eba78 100644 --- a/ui/src/ui/views/channels.nostr-profile-form.ts +++ b/ui/src/ui/views/channels.nostr-profile-form.ts @@ -247,7 +247,7 @@ export function renderNostrProfileForm(params: { @click=${callbacks.onSave} ?disabled=${state.saving || !isDirty} > - ${state.saving ? "Saving..." : "Save & Publish"} + ${state.saving ? "Saving..." : "Save"} + >× `, )} @@ -237,6 +328,265 @@ function renderAttachmentPreview(props: ChatProps) { `; } +function updateSlashMenu(value: string, requestUpdate: () => void): void { + const match = value.match(/^\/(\S*)$/); + if (match) { + const items = getSlashCommandCompletions(match[1]); + slashMenuItems = items; + slashMenuOpen = items.length > 0; + slashMenuIndex = 0; + } else { + slashMenuOpen = false; + slashMenuItems = []; + } + requestUpdate(); +} + +function selectSlashCommand( + cmd: SlashCommandDef, + props: ChatProps, + requestUpdate: () => void, +): void { + const text = `/${cmd.name} `; + props.onDraftChange(text); + slashMenuOpen = false; + slashMenuItems = []; + requestUpdate(); +} + +function tokenEstimate(draft: string): string | null { + if (draft.length < 100) { + return null; + } + return `~${Math.ceil(draft.length / 4)} tokens`; +} + +function startVoice(props: ChatProps, requestUpdate: () => void): void { + const SR = + (window as unknown as Record).webkitSpeechRecognition ?? + (window as unknown as Record).SpeechRecognition; + if (!SR) { + return; + } + const rec = new (SR as new () => Record)(); + rec.continuous = false; + rec.interimResults = true; + rec.lang = "en-US"; + rec.onresult = (event: Record) => { + let transcript = ""; + const results = ( + event as { results: { length: number; [i: number]: { 0: { transcript: string } } } } + ).results; + for (let i = 0; i < results.length; i++) { + transcript += results[i][0].transcript; + } + props.onDraftChange(transcript); + }; + (rec as unknown as EventTarget).addEventListener("end", () => { + voiceActive = false; + recognition = null; + requestUpdate(); + }); + (rec as unknown as EventTarget).addEventListener("error", () => { + voiceActive = false; + recognition = null; + requestUpdate(); + }); + (rec as { start: () => void }).start(); + recognition = rec; + voiceActive = true; + requestUpdate(); +} + +function stopVoice(requestUpdate: () => void): void { + if (recognition && typeof recognition.stop === "function") { + recognition.stop(); + } + recognition = null; + voiceActive = false; + requestUpdate(); +} + +function exportMarkdown(props: ChatProps): void { + const history = Array.isArray(props.messages) ? props.messages : []; + if (history.length === 0) { + return; + } + const lines: string[] = [`# Chat with ${props.assistantName}`, ""]; + for (const msg of history) { + const m = msg as Record; + const role = m.role === "user" ? "You" : m.role === "assistant" ? props.assistantName : "Tool"; + const content = typeof m.content === "string" ? m.content : ""; + const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; + lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); + } + const blob = new Blob([lines.join("\n")], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `chat-${props.assistantName}-${Date.now()}.md`; + a.click(); + URL.revokeObjectURL(url); +} + +function renderWelcomeState(props: ChatProps): TemplateResult { + const name = props.assistantName || "Assistant"; + const avatar = props.assistantAvatar ?? props.assistantAvatarUrl; + const initials = name.slice(0, 2).toUpperCase(); + + return html` +
+
+ ${ + avatar + ? html`${name}` + : html`
${initials}
` + } +

${name}

+
+ ${icons.spark} Ready to chat +
+

+ Type a message below · / for commands +

+
+ `; +} + +function renderSearchBar(requestUpdate: () => void): TemplateResult | typeof nothing { + if (!searchOpen) { + return nothing; + } + return html` + + `; +} + +function renderPinnedSection( + props: ChatProps, + pinned: PinnedMessages, + requestUpdate: () => void, +): TemplateResult | typeof nothing { + const messages = Array.isArray(props.messages) ? props.messages : []; + const entries: Array<{ index: number; text: string; role: string }> = []; + for (const idx of pinned.indices) { + const msg = messages[idx] as Record | undefined; + if (!msg) { + continue; + } + const text = typeof msg.content === "string" ? msg.content : ""; + const role = typeof msg.role === "string" ? msg.role : "unknown"; + entries.push({ index: idx, text, role }); + } + if (entries.length === 0) { + return nothing; + } + return html` +
+ + ${ + pinnedExpanded + ? html` +
+ ${entries.map( + ({ index, text, role }) => html` +
+ ${role === "user" ? "You" : "Assistant"} + ${text.slice(0, 100)}${text.length > 100 ? "..." : ""} + +
+ `, + )} +
+ ` + : nothing + } +
+ `; +} + +function renderSlashMenu( + requestUpdate: () => void, + props: ChatProps, +): TemplateResult | typeof nothing { + if (!slashMenuOpen || slashMenuItems.length === 0) { + return nothing; + } + + const grouped = new Map< + SlashCommandCategory, + Array<{ cmd: SlashCommandDef; globalIdx: number }> + >(); + for (let i = 0; i < slashMenuItems.length; i++) { + const cmd = slashMenuItems[i]; + const cat = cmd.category ?? "session"; + let list = grouped.get(cat); + if (!list) { + list = []; + grouped.set(cat, list); + } + list.push({ cmd, globalIdx: i }); + } + + const sections: TemplateResult[] = []; + for (const [cat, entries] of grouped) { + sections.push(html` +
+
${CATEGORY_LABELS[cat]}
+ ${entries.map( + ({ cmd, globalIdx }) => html` +
selectSlashCommand(cmd, props, requestUpdate)} + @mouseenter=${() => { + slashMenuIndex = globalIdx; + requestUpdate(); + }} + > + ${cmd.icon ? html`${icons[cmd.icon]}` : nothing} + /${cmd.name} + ${cmd.args ? html`${cmd.args}` : nothing} + ${cmd.description} +
+ `, + )} +
+ `); + } + + return html`
${sections}
`; +} + export function renderChat(props: ChatProps) { const canCompose = props.connected; const isBusy = props.sending || props.stream !== null; @@ -248,16 +598,35 @@ export function renderChat(props: ChatProps) { name: props.assistantName, avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null, }; - + const pinned = getPinnedMessages(props.sessionKey); + const deleted = getDeletedMessages(props.sessionKey); + const inputHistory = getInputHistory(props.sessionKey); const hasAttachments = (props.attachments?.length ?? 0) > 0; - const composePlaceholder = props.connected + const tokens = tokenEstimate(props.draft); + + const hasVoice = + typeof (window as unknown as Record).webkitSpeechRecognition !== "undefined" || + typeof (window as unknown as Record).SpeechRecognition !== "undefined"; + + const placeholder = props.connected ? hasAttachments ? "Add a message or paste more images..." - : "Message (↩ to send, Shift+↩ for line breaks, paste images)" - : "Connect to the gateway to start chatting…"; + : `Message ${props.assistantName || "agent"} (Enter to send)` + : "Connect to the gateway to start chatting..."; + + // We need a requestUpdate shim since we're in functional mode: + // the host Lit component will re-render on state change anyway, + // so we trigger by calling onDraftChange with current value. + const requestUpdate = () => { + props.onDraftChange(props.draft); + }; const splitRatio = props.splitRatio ?? 0.6; const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar); + + const chatItems = buildChatItems(props); + const isEmpty = chatItems.length === 0 && !props.loading; + const thread = html`
Loading chat…
+
Loading chat...
+ ` + : nothing + } + ${isEmpty && !searchOpen ? renderWelcomeState(props) : nothing} + ${ + isEmpty && searchOpen + ? html` +
No matching messages
` : nothing } ${repeat( - buildChatItems(props), + chatItems, (item) => item.key, (item) => { if (item.kind === "divider") { @@ -285,11 +662,9 @@ export function renderChat(props: ChatProps) { `; } - if (item.kind === "reading-indicator") { return renderReadingIndicatorGroup(assistantIdentity); } - if (item.kind === "stream") { return renderStreamingGroup( item.text, @@ -298,26 +673,117 @@ export function renderChat(props: ChatProps) { assistantIdentity, ); } - if (item.kind === "group") { + if (deleted.has(item.key)) { + return nothing; + } return renderMessageGroup(item, { onOpenSidebar: props.onOpenSidebar, showReasoning, assistantName: props.assistantName, assistantAvatar: assistantIdentity.avatar, + onDelete: () => { + deleted.delete(item.key); + requestUpdate(); + }, }); } - return nothing; }, )} `; - return html` -
- ${props.disabledReason ? html`
${props.disabledReason}
` : nothing} + const handleKeyDown = (e: KeyboardEvent) => { + // Slash menu navigation + if (slashMenuOpen && slashMenuItems.length > 0) { + const len = slashMenuItems.length; + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + slashMenuIndex = (slashMenuIndex + 1) % len; + requestUpdate(); + return; + case "ArrowUp": + e.preventDefault(); + slashMenuIndex = (slashMenuIndex - 1 + len) % len; + requestUpdate(); + return; + case "Enter": + case "Tab": + e.preventDefault(); + selectSlashCommand(slashMenuItems[slashMenuIndex], props, requestUpdate); + return; + case "Escape": + e.preventDefault(); + slashMenuOpen = false; + requestUpdate(); + return; + } + } + // Input history (only when input is empty) + if (!props.draft.trim()) { + if (e.key === "ArrowUp") { + const prev = inputHistory.up(); + if (prev !== null) { + e.preventDefault(); + props.onDraftChange(prev); + } + return; + } + if (e.key === "ArrowDown") { + const next = inputHistory.down(); + e.preventDefault(); + props.onDraftChange(next ?? ""); + return; + } + } + + // Cmd+F for search + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "f") { + e.preventDefault(); + searchOpen = !searchOpen; + if (!searchOpen) { + searchQuery = ""; + } + requestUpdate(); + return; + } + + // Send on Enter (without shift) + if (e.key === "Enter" && !e.shiftKey) { + if (e.isComposing || e.keyCode === 229) { + return; + } + if (!props.connected) { + return; + } + e.preventDefault(); + if (canCompose) { + if (props.draft.trim()) { + inputHistory.push(props.draft); + } + props.onSend(); + } + } + }; + + const handleInput = (e: Event) => { + const target = e.target as HTMLTextAreaElement; + adjustTextareaHeight(target); + props.onDraftChange(target.value); + updateSlashMenu(target.value, requestUpdate); + inputHistory.reset(); + }; + + return html` +
handleDrop(e, props)} + @dragover=${(e: DragEvent) => e.preventDefault()} + > + ${props.disabledReason ? html`
${props.disabledReason}
` : nothing} ${props.error ? html`
${props.error}
` : nothing} ${ @@ -336,9 +802,12 @@ export function renderChat(props: ChatProps) { : nothing } -
+ ${renderSearchBar(requestUpdate)} + ${renderPinnedSection(props, pinned, requestUpdate)} + + ${renderAgentBar(props)} + +
- New messages ${icons.arrowDown} + ${icons.arrowDown} New messages ` : nothing } -
+ +
+ ${renderSlashMenu(requestUpdate, props)} ${renderAttachmentPreview(props)} -
- -
+ + handleFileSelect(e, props)} + /> + + + +
+
- + + ${ + hasVoice + ? html` + + ` + : nothing + } + + ${tokens ? html`${tokens}` : nothing} +
+ +
+ + + ${ + props.messages.length > 0 + ? html` + + + + + ` + : nothing + } + + ${ + canAbort && isBusy + ? html` + + ` + : html` + + ` + }
@@ -479,6 +1010,83 @@ export function renderChat(props: ChatProps) { `; } +function renderAgentBar(props: ChatProps) { + const agents = props.agentsList?.agents ?? []; + if (agents.length <= 1 && !props.sessions?.sessions?.length) { + return nothing; + } + + // Filter sessions for current agent + const agentSessions = (props.sessions?.sessions ?? []).filter((s) => { + const key = s.key ?? ""; + return ( + key.includes(`:${props.currentAgentId}:`) || key.startsWith(`agent:${props.currentAgentId}:`) + ); + }); + + return html` +
+
+ ${ + agents.length > 1 + ? html` + + ` + : html`${agents[0]?.identity?.name || agents[0]?.name || props.currentAgentId}` + } + ${ + agentSessions.length > 0 + ? html` +
+ + ${icons.fileText} + Sessions (${agentSessions.length}) + +
+ ${agentSessions.map( + (s) => html` + + `, + )} +
+
+ ` + : nothing + } +
+
+ ${ + props.onNavigateToAgent + ? html` + + ` + : nothing + } +
+
+ `; +} + const CHAT_HISTORY_RENDER_LIMIT = 200; function groupMessages(items: ChatItem[]): Array { @@ -560,6 +1168,14 @@ function buildChatItems(props: ChatProps): Array { continue; } + // Apply search filter if active + if (searchOpen && searchQuery.trim()) { + const text = typeof normalized.content === "string" ? normalized.content : ""; + if (!text.toLowerCase().includes(searchQuery.toLowerCase())) { + continue; + } + } + items.push({ kind: "message", key: messageKey(msg, i), diff --git a/ui/src/ui/views/command-palette.ts b/ui/src/ui/views/command-palette.ts new file mode 100644 index 00000000000..639af836ab1 --- /dev/null +++ b/ui/src/ui/views/command-palette.ts @@ -0,0 +1,244 @@ +import { html, nothing } from "lit"; +import { t } from "../../i18n/index.ts"; +import { icons, type IconName } from "../icons.ts"; + +type PaletteItem = { + id: string; + label: string; + icon: IconName; + category: "search" | "navigation" | "skills"; + action: string; + description?: string; +}; + +const PALETTE_ITEMS: PaletteItem[] = [ + { + id: "status", + label: "/status", + icon: "radio", + category: "search", + action: "/status", + description: "Show current status", + }, + { + id: "models", + label: "/model", + icon: "monitor", + category: "search", + action: "/model", + description: "Show/set model", + }, + { + id: "usage", + label: "/usage", + icon: "barChart", + category: "search", + action: "/usage", + description: "Show usage", + }, + { + id: "think", + label: "/think", + icon: "brain", + category: "search", + action: "/think", + description: "Set thinking level", + }, + { + id: "reset", + label: "/reset", + icon: "loader", + category: "search", + action: "/reset", + description: "Reset session", + }, + { + id: "help", + label: "/help", + icon: "book", + category: "search", + action: "/help", + description: "Show help", + }, + { + id: "nav-overview", + label: "Overview", + icon: "barChart", + category: "navigation", + action: "nav:overview", + }, + { + id: "nav-sessions", + label: "Sessions", + icon: "fileText", + category: "navigation", + action: "nav:sessions", + }, + { + id: "nav-cron", + label: "Scheduled", + icon: "scrollText", + category: "navigation", + action: "nav:cron", + }, + { id: "nav-skills", label: "Skills", icon: "zap", category: "navigation", action: "nav:skills" }, + { + id: "nav-config", + label: "Settings", + icon: "settings", + category: "navigation", + action: "nav:config", + }, + { + id: "nav-agents", + label: "Agents", + icon: "folder", + category: "navigation", + action: "nav:agents", + }, + { + id: "skill-shell", + label: "Shell Command", + icon: "monitor", + category: "skills", + action: "/skill shell", + description: "Run shell", + }, + { + id: "skill-debug", + label: "Debug Mode", + icon: "bug", + category: "skills", + action: "/verbose full", + description: "Toggle debug", + }, +]; + +export type CommandPaletteProps = { + open: boolean; + query: string; + activeIndex: number; + onToggle: () => void; + onQueryChange: (query: string) => void; + onActiveIndexChange: (index: number) => void; + onNavigate: (tab: string) => void; + onSlashCommand: (command: string) => void; +}; + +function filteredItems(query: string): PaletteItem[] { + if (!query) { + return PALETTE_ITEMS; + } + const q = query.toLowerCase(); + return PALETTE_ITEMS.filter( + (item) => + item.label.toLowerCase().includes(q) || + (item.description?.toLowerCase().includes(q) ?? false), + ); +} + +function groupItems(items: PaletteItem[]): Array<[string, PaletteItem[]]> { + const map = new Map(); + for (const item of items) { + const group = map.get(item.category) ?? []; + group.push(item); + map.set(item.category, group); + } + return [...map.entries()]; +} + +function selectItem(item: PaletteItem, props: CommandPaletteProps) { + if (item.action.startsWith("nav:")) { + props.onNavigate(item.action.slice(4)); + } else { + props.onSlashCommand(item.action); + } + props.onToggle(); +} + +function handleKeydown(e: KeyboardEvent, props: CommandPaletteProps) { + const items = filteredItems(props.query); + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + props.onActiveIndexChange(Math.min(props.activeIndex + 1, items.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + props.onActiveIndexChange(Math.max(props.activeIndex - 1, 0)); + break; + case "Enter": + e.preventDefault(); + if (items[props.activeIndex]) { + selectItem(items[props.activeIndex], props); + } + break; + case "Escape": + e.preventDefault(); + props.onToggle(); + break; + } +} + +const CATEGORY_LABELS: Record = { + search: "Search", + navigation: "Navigation", + skills: "Skills", +}; + +export function renderCommandPalette(props: CommandPaletteProps) { + if (!props.open) { + return nothing; + } + + const items = filteredItems(props.query); + const grouped = groupItems(items); + + return html` +
props.onToggle()}> +
e.stopPropagation()}> + { + props.onQueryChange((e.target as HTMLInputElement).value); + props.onActiveIndexChange(0); + }} + @keydown=${(e: KeyboardEvent) => handleKeydown(e, props)} + autofocus + /> +
+ ${ + grouped.length === 0 + ? html`
${t("overview.palette.noResults")}
` + : grouped.map( + ([category, groupedItems]) => html` +
${CATEGORY_LABELS[category] ?? category}
+ ${groupedItems.map((item) => { + const globalIndex = items.indexOf(item); + const isActive = globalIndex === props.activeIndex; + return html` +
selectItem(item, props)} + @mouseenter=${() => props.onActiveIndexChange(globalIndex)} + > + ${icons[item.icon]} + ${item.label} + ${ + item.description + ? html`${item.description}` + : nothing + } +
+ `; + })} + `, + ) + } +
+
+
+ `; +} diff --git a/ui/src/ui/views/config-form.analyze.ts b/ui/src/ui/views/config-form.analyze.ts index 9bf17dcde95..261f4fc1618 100644 --- a/ui/src/ui/views/config-form.analyze.ts +++ b/ui/src/ui/views/config-form.analyze.ts @@ -118,12 +118,47 @@ function normalizeSchemaNode( }; } +function mergeAllOf(schema: JsonSchema, path: Array): ConfigSchemaAnalysis | null { + const branches = schema.allOf; + if (!branches || branches.length === 0) { + return null; + } + const merged: JsonSchema = { ...schema, allOf: undefined }; + for (const branch of branches) { + if (!branch || typeof branch !== "object") { + return null; + } + if (branch.type) { + merged.type = merged.type ?? branch.type; + } + if (branch.properties) { + merged.properties = { ...merged.properties, ...branch.properties }; + } + if (branch.items && !merged.items) { + merged.items = branch.items; + } + if (branch.enum) { + merged.enum = branch.enum; + } + if (branch.description && !merged.description) { + merged.description = branch.description; + } + if (branch.title && !merged.title) { + merged.title = branch.title; + } + if (branch.default !== undefined && merged.default === undefined) { + merged.default = branch.default; + } + } + return normalizeSchemaNode(merged, path); +} + function normalizeUnion( schema: JsonSchema, path: Array, ): ConfigSchemaAnalysis | null { if (schema.allOf) { - return null; + return mergeAllOf(schema, path); } const union = schema.anyOf ?? schema.oneOf; if (!union) { @@ -181,7 +216,7 @@ function normalizeUnion( }; } - if (remaining.length === 1) { + if (remaining.length === 1 && literals.length === 0) { const res = normalizeSchemaNode(remaining[0], path); if (res.schema) { res.schema.nullable = nullable || res.schema.nullable; @@ -189,6 +224,41 @@ function normalizeUnion( return res; } + // Literals + single typed remainder (e.g. boolean | enum["off","partial"]): + // merge literals into an enum on the combined schema so segmented/select renders all options. + if (remaining.length === 1 && literals.length > 0) { + const remType = schemaType(remaining[0]); + if (remType === "boolean") { + const all = [true, false, ...literals]; + const unique: unknown[] = []; + for (const v of all) { + if (!unique.some((e) => Object.is(e, v))) { + unique.push(v); + } + } + return { + schema: { + ...schema, + enum: unique, + nullable, + anyOf: undefined, + oneOf: undefined, + allOf: undefined, + }, + unsupportedPaths: [], + }; + } + // Single remaining primitive — pass through as-is so the renderer picks the right widget + const primitiveTypes = new Set(["string", "number", "integer"]); + if (remType && primitiveTypes.has(remType)) { + const res = normalizeSchemaNode(remaining[0], path); + if (res.schema) { + res.schema.nullable = nullable || res.schema.nullable; + } + return res; + } + } + const primitiveTypes = new Set(["string", "number", "integer", "boolean"]); if ( remaining.length > 0 && @@ -204,5 +274,9 @@ function normalizeUnion( }; } - return null; + // Fallback: pass the schema through and let the renderer show a JSON textarea + return { + schema: { ...schema, nullable }, + unsupportedPaths: [], + }; } diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index cd567d5e662..ff24a861fe4 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -27,6 +27,44 @@ function jsonValue(value: unknown): string { } } +function renderJsonFallback(params: { + label: string; + help: string | undefined; + value: unknown; + path: Array; + disabled: boolean; + showLabel: boolean; + onPatch: (path: Array, value: unknown) => void; +}): TemplateResult { + const { label, help, value, path, disabled, showLabel, onPatch } = params; + const display = jsonValue(value); + return html` +
+ ${showLabel ? html`` : nothing} + ${help ? html`
${help}
` : nothing} + +
+ `; +} + // SVG Icons as template literals const icons = { chevronDown: html` @@ -113,10 +151,7 @@ export function renderNode(params: { const key = pathKey(path); if (unsupported.has(key)) { - return html`
-
${label}
-
Unsupported schema node. Use Raw mode.
-
`; + return renderJsonFallback({ label, help, value, path, disabled, onPatch, showLabel }); } // Handle anyOf/oneOf unions @@ -282,13 +317,8 @@ export function renderNode(params: { return renderTextInput({ ...params, inputType: "text" }); } - // Fallback - return html` -
-
${label}
-
Unsupported type: ${type}. Use Raw mode.
-
- `; + // Fallback — render a JSON textarea for types the form renderer doesn't know about + return renderJsonFallback({ label, help, value, path, disabled, onPatch, showLabel }); } function renderTextInput(params: { diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index cdb7fc195c4..80969272330 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -25,6 +25,7 @@ describe("config view", () => { searchQuery: "", activeSection: null, activeSubsection: null, + streamMode: false, onRawChange: vi.fn(), onFormModeChange: vi.fn(), onFormPatch: vi.fn(), @@ -37,7 +38,7 @@ describe("config view", () => { onSubsectionChange: vi.fn(), }); - it("allows save when form is unsafe", () => { + it("allows save with mixed union schemas", () => { const container = document.createElement("div"); render( renderConfig({ diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 221f31e0050..0be5a47d37a 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -1,4 +1,5 @@ import { html, nothing } from "lit"; +import { icons } from "../icons.ts"; import type { ConfigUiHints } from "../types.ts"; import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts"; import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts"; @@ -22,6 +23,7 @@ export type ConfigProps = { searchQuery: string; activeSection: string | null; activeSubsection: string | null; + streamMode: boolean; onRawChange: (next: string) => void; onFormModeChange: (mode: "form" | "raw") => void; onFormPatch: (path: Array, value: unknown) => void; @@ -383,6 +385,44 @@ function truncateValue(value: unknown, maxLen = 40): string { return str.slice(0, maxLen - 3) + "..."; } +const SENSITIVE_KEY_RE = /token|password|secret|api.?key/i; +const SENSITIVE_KEY_WHITELIST_RE = + /maxtokens|maxoutputtokens|maxinputtokens|maxcompletiontokens|contexttokens|totaltokens|tokencount|tokenlimit|tokenbudget|passwordfile/i; + +function countSensitiveValues(formValue: Record | null): number { + if (!formValue) { + return 0; + } + let count = 0; + function walk(obj: unknown, key?: string) { + if (obj == null) { + return; + } + if (typeof obj === "object" && !Array.isArray(obj)) { + for (const [k, v] of Object.entries(obj as Record)) { + walk(v, k); + } + } else if (Array.isArray(obj)) { + for (const item of obj) { + walk(item); + } + } else if ( + key && + typeof obj === "string" && + SENSITIVE_KEY_RE.test(key) && + !SENSITIVE_KEY_WHITELIST_RE.test(key) + ) { + if (obj.trim() && !/^\$\{[^}]*\}$/.test(obj.trim())) { + count++; + } + } + } + walk(formValue); + return count; +} + +let rawRevealed = false; + export function renderConfig(props: ConfigProps) { const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid"; const analysis = analyzeConfigSchema(props.schema); @@ -649,6 +689,32 @@ export function renderConfig(props: ConfigProps) { : nothing }
+ ${ + props.activeSection === "env" + ? html` + + ` + : nothing + }
` : nothing @@ -682,7 +748,7 @@ export function renderConfig(props: ConfigProps) { } -
+
${ props.formMode === "form" ? html` @@ -716,16 +782,43 @@ export function renderConfig(props: ConfigProps) { : nothing } ` - : html` - - ` + : (() => { + const sensitiveCount = countSensitiveValues(props.formValue); + const blurred = sensitiveCount > 0 && (props.streamMode || !rawRevealed); + return html` + + `; + })() }
diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index e5cc32408ea..89527f83a02 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -333,7 +333,7 @@ export function renderCron(props: CronProps) {
No runs yet.
` : html` -
+
${orderedRuns.map((entry) => renderRun(entry, props.basePath))}
` diff --git a/ui/src/ui/views/debug.ts b/ui/src/ui/views/debug.ts index 22ee3bce20f..6a03073726f 100644 --- a/ui/src/ui/views/debug.ts +++ b/ui/src/ui/views/debug.ts @@ -1,12 +1,13 @@ import { html, nothing } from "lit"; import type { EventLogEntry } from "../app-events.ts"; import { formatEventPayload } from "../presenter.ts"; +import type { HealthSummary, ModelCatalogEntry } from "../types.ts"; export type DebugProps = { loading: boolean; status: Record | null; - health: Record | null; - models: unknown[]; + health: HealthSummary | null; + models: ModelCatalogEntry[]; heartbeat: unknown; eventLog: EventLogEntry[]; callMethod: string; diff --git a/ui/src/ui/views/instances.ts b/ui/src/ui/views/instances.ts index df5fe5fd4fe..b805b7ea444 100644 --- a/ui/src/ui/views/instances.ts +++ b/ui/src/ui/views/instances.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; -import { formatPresenceAge, formatPresenceSummary } from "../presenter.ts"; +import { icons } from "../icons.ts"; +import { formatPresenceAge } from "../presenter.ts"; import type { PresenceEntry } from "../types.ts"; export type InstancesProps = { @@ -7,10 +8,15 @@ export type InstancesProps = { entries: PresenceEntry[]; lastError: string | null; statusMessage: string | null; + streamMode: boolean; onRefresh: () => void; }; +let hostsRevealed = false; + export function renderInstances(props: InstancesProps) { + const masked = props.streamMode || !hostsRevealed; + return html`
@@ -18,9 +24,24 @@ export function renderInstances(props: InstancesProps) {
Connected Instances
Presence beacons from the gateway and clients.
- +
+ + +
${ props.lastError @@ -42,16 +63,18 @@ export function renderInstances(props: InstancesProps) { ? html`
No instances reported yet.
` - : props.entries.map((entry) => renderEntry(entry)) + : props.entries.map((entry) => renderEntry(entry, masked)) }
`; } -function renderEntry(entry: PresenceEntry) { +function renderEntry(entry: PresenceEntry, masked: boolean) { const lastInput = entry.lastInputSeconds != null ? `${entry.lastInputSeconds}s ago` : "n/a"; const mode = entry.mode ?? "unknown"; + const host = entry.host ?? "unknown host"; + const ip = entry.ip ?? null; const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : []; const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : []; const scopesLabel = @@ -63,8 +86,12 @@ function renderEntry(entry: PresenceEntry) { return html`
-
${entry.host ?? "unknown host"}
-
${formatPresenceSummary(entry)}
+
+ ${host} +
+
+ ${ip ? html`${ip} ` : nothing}${mode} ${entry.version ?? ""} +
${mode} ${roles.map((role) => html`${role}`)} diff --git a/ui/src/ui/views/login-gate.ts b/ui/src/ui/views/login-gate.ts new file mode 100644 index 00000000000..58b0033d254 --- /dev/null +++ b/ui/src/ui/views/login-gate.ts @@ -0,0 +1,86 @@ +import { html } from "lit"; +import { t } from "../../i18n/index.ts"; +import { renderThemeToggle } from "../app-render.helpers.ts"; +import type { AppViewState } from "../app-view-state.ts"; +import { normalizeBasePath } from "../navigation.ts"; + +export function renderLoginGate(state: AppViewState) { + const basePath = normalizeBasePath(state.basePath ?? ""); + const faviconSrc = basePath ? `${basePath}/favicon.svg` : "/favicon.svg"; + + return html` + + `; +} diff --git a/ui/src/ui/views/overview-attention.ts b/ui/src/ui/views/overview-attention.ts new file mode 100644 index 00000000000..e6762f3e2be --- /dev/null +++ b/ui/src/ui/views/overview-attention.ts @@ -0,0 +1,60 @@ +import { html, nothing } from "lit"; +import { t } from "../../i18n/index.ts"; +import { icons, type IconName } from "../icons.ts"; +import type { AttentionItem } from "../types.ts"; + +export type OverviewAttentionProps = { + items: AttentionItem[]; +}; + +function severityClass(severity: string) { + if (severity === "error") { + return "danger"; + } + if (severity === "warning") { + return "warn"; + } + return ""; +} + +function attentionIcon(name: string) { + if (name in icons) { + return icons[name as IconName]; + } + return icons.radio; +} + +export function renderOverviewAttention(props: OverviewAttentionProps) { + if (props.items.length === 0) { + return nothing; + } + + return html` +
+
${t("overview.attention.title")}
+
+ ${props.items.map( + (item) => html` +
+ ${attentionIcon(item.icon)} +
+
${item.title}
+
${item.description}
+
+ ${ + item.href + ? html`${t("common.docs")}` + : nothing + } +
+ `, + )} +
+
+ `; +} diff --git a/ui/src/ui/views/overview-cards.ts b/ui/src/ui/views/overview-cards.ts new file mode 100644 index 00000000000..3d394a1df11 --- /dev/null +++ b/ui/src/ui/views/overview-cards.ts @@ -0,0 +1,129 @@ +import { html, nothing, type TemplateResult } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { t } from "../../i18n/index.ts"; +import { formatCost, formatTokens, formatRelativeTimestamp } from "../format.ts"; +import { icons } from "../icons.ts"; +import { formatNextRun } from "../presenter.ts"; +import type { + SessionsUsageResult, + SessionsListResult, + SkillStatusReport, + CronJob, + CronStatus, +} from "../types.ts"; + +export type OverviewCardsProps = { + usageResult: SessionsUsageResult | null; + sessionsResult: SessionsListResult | null; + skillsReport: SkillStatusReport | null; + cronJobs: CronJob[]; + cronStatus: CronStatus | null; + presenceCount: number; + redacted: boolean; + onNavigate: (tab: string) => void; +}; + +function redact(value: string, redacted: boolean) { + return redacted ? "••••••" : value; +} + +const DIGIT_RUN = /\d{3,}/g; + +function blurDigits(value: string): TemplateResult { + const escaped = value.replace(/&/g, "&").replace(//g, ">"); + const blurred = escaped.replace(DIGIT_RUN, (m) => `${m}`); + return html`${unsafeHTML(blurred)}`; +} + +export function renderOverviewCards(props: OverviewCardsProps) { + const totals = props.usageResult?.totals; + const totalCost = formatCost(totals?.totalCost); + const totalTokens = formatTokens(totals?.totalTokens); + const totalMessages = totals ? String(props.usageResult?.aggregates?.messages?.total ?? 0) : "0"; + const sessionCount = props.sessionsResult?.count ?? null; + + const skills = props.skillsReport?.skills ?? []; + const enabledSkills = skills.filter((s) => !s.disabled).length; + const blockedSkills = skills.filter((s) => s.blockedByAllowlist).length; + const totalSkills = skills.length; + + const cronEnabled = props.cronStatus?.enabled ?? null; + const cronNext = props.cronStatus?.nextWakeAtMs ?? null; + const cronJobCount = props.cronJobs.length; + const failedCronCount = props.cronJobs.filter((j) => j.state?.lastStatus === "error").length; + + return html` +
+
props.onNavigate("usage")}> +
+
${icons.barChart}
+
+
${t("overview.cards.cost")}
+
${redact(totalCost, props.redacted)}
+
${redact(`${totalTokens} tokens · ${totalMessages} msgs`, props.redacted)}
+
+
+
+
props.onNavigate("sessions")}> +
+
${icons.fileText}
+
+
${t("overview.stats.sessions")}
+
${sessionCount ?? t("common.na")}
+
${t("overview.stats.sessionsHint")}
+
+
+
+
props.onNavigate("skills")}> +
+
${icons.zap}
+
+
${t("overview.cards.skills")}
+
${enabledSkills}/${totalSkills}
+
${blockedSkills > 0 ? `${blockedSkills} blocked` : `${enabledSkills} active`}
+
+
+
+
props.onNavigate("cron")}> +
+
${icons.scrollText}
+
+
${t("overview.stats.cron")}
+
+ ${cronEnabled == null ? t("common.na") : cronEnabled ? `${cronJobCount} jobs` : t("common.disabled")} +
+
+ ${ + failedCronCount > 0 + ? html`${failedCronCount} failed` + : nothing + } + ${cronNext ? t("overview.stats.cronNext", { time: formatNextRun(cronNext) }) : ""} +
+
+
+
+
+ + ${ + props.sessionsResult && props.sessionsResult.sessions.length > 0 + ? html` +
+
${t("overview.cards.recentSessions")}
+
+ ${props.sessionsResult.sessions.slice(0, 5).map( + (s) => html` +
+ ${props.redacted ? redact(s.displayName || s.label || s.key, true) : blurDigits(s.displayName || s.label || s.key)} + ${s.model ?? ""} + ${s.updatedAt ? formatRelativeTimestamp(s.updatedAt) : ""} +
+ `, + )} +
+
+ ` + : nothing + } + `; +} diff --git a/ui/src/ui/views/overview-event-log.ts b/ui/src/ui/views/overview-event-log.ts new file mode 100644 index 00000000000..f4636d3ec27 --- /dev/null +++ b/ui/src/ui/views/overview-event-log.ts @@ -0,0 +1,43 @@ +import { html, nothing } from "lit"; +import { t } from "../../i18n/index.ts"; +import type { EventLogEntry } from "../app-events.ts"; +import { icons } from "../icons.ts"; +import { formatEventPayload } from "../presenter.ts"; + +export type OverviewEventLogProps = { + events: EventLogEntry[]; + redacted: boolean; +}; + +export function renderOverviewEventLog(props: OverviewEventLogProps) { + if (props.events.length === 0) { + return nothing; + } + + const visible = props.events.slice(0, 20); + + return html` +
+ + ${icons.radio} + ${t("overview.eventLog.title")} + ${props.events.length} + +
+ ${visible.map( + (entry) => html` +
+ ${new Date(entry.ts).toLocaleTimeString()} + ${entry.event} + ${ + entry.payload + ? html`${formatEventPayload(entry.payload).slice(0, 120)}` + : nothing + } +
+ `, + )} +
+
+ `; +} diff --git a/ui/src/ui/views/overview-log-tail.ts b/ui/src/ui/views/overview-log-tail.ts new file mode 100644 index 00000000000..72c3c981c2f --- /dev/null +++ b/ui/src/ui/views/overview-log-tail.ts @@ -0,0 +1,36 @@ +import { html, nothing } from "lit"; +import { t } from "../../i18n/index.ts"; +import { icons } from "../icons.ts"; + +export type OverviewLogTailProps = { + lines: string[]; + redacted: boolean; + onRefreshLogs: () => void; +}; + +export function renderOverviewLogTail(props: OverviewLogTailProps) { + if (props.lines.length === 0) { + return nothing; + } + + return html` +
+ + ${icons.scrollText} + ${t("overview.logTail.title")} + ${props.lines.length} + { + e.preventDefault(); + e.stopPropagation(); + props.onRefreshLogs(); + }} + >${icons.loader} + +
${
+        props.redacted ? "[log hidden]" : props.lines.slice(-50).join("\n")
+      }
+
+ `; +} diff --git a/ui/src/ui/views/overview-quick-actions.ts b/ui/src/ui/views/overview-quick-actions.ts new file mode 100644 index 00000000000..b1358ca2e67 --- /dev/null +++ b/ui/src/ui/views/overview-quick-actions.ts @@ -0,0 +1,31 @@ +import { html } from "lit"; +import { t } from "../../i18n/index.ts"; +import { icons } from "../icons.ts"; + +export type OverviewQuickActionsProps = { + onNavigate: (tab: string) => void; + onRefresh: () => void; +}; + +export function renderOverviewQuickActions(props: OverviewQuickActionsProps) { + return html` +
+ + + + +
+ `; +} diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index 6d94ea1fdaf..946e4bfc8d7 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -1,9 +1,22 @@ -import { html } from "lit"; +import { html, nothing } from "lit"; import { t, i18n, type Locale } from "../../i18n/index.ts"; +import type { EventLogEntry } from "../app-events.ts"; import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts"; import type { GatewayHelloOk } from "../gateway.ts"; -import { formatNextRun } from "../presenter.ts"; +import { icons } from "../icons.ts"; import type { UiSettings } from "../storage.ts"; +import type { + AttentionItem, + CronJob, + CronStatus, + SessionsListResult, + SessionsUsageResult, + SkillStatusReport, +} from "../types.ts"; +import { renderOverviewAttention } from "./overview-attention.ts"; +import { renderOverviewCards } from "./overview-cards.ts"; +import { renderOverviewEventLog } from "./overview-event-log.ts"; +import { renderOverviewLogTail } from "./overview-log-tail.ts"; export type OverviewProps = { connected: boolean; @@ -16,11 +29,24 @@ export type OverviewProps = { cronEnabled: boolean | null; cronNext: number | null; lastChannelsRefresh: number | null; + // New dashboard data + usageResult: SessionsUsageResult | null; + sessionsResult: SessionsListResult | null; + skillsReport: SkillStatusReport | null; + cronJobs: CronJob[]; + cronStatus: CronStatus | null; + attentionItems: AttentionItem[]; + eventLog: EventLogEntry[]; + overviewLogLines: string[]; + streamMode: boolean; onSettingsChange: (next: UiSettings) => void; onPasswordChange: (next: string) => void; onSessionKeyChange: (next: string) => void; onConnect: () => void; onRefresh: () => void; + onNavigate: (tab: string) => void; + onRefreshLogs: () => void; + onToggleStreamMode: () => void; }; export function renderOverview(props: OverviewProps) { @@ -33,7 +59,7 @@ export function renderOverview(props: OverviewProps) { | undefined; const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na"); const tick = snapshot?.policy?.tickIntervalMs - ? `${snapshot.policy.tickIntervalMs}ms` + ? `${(snapshot.policy.tickIntervalMs / 1000).toFixed(snapshot.policy.tickIntervalMs % 1000 === 0 ? 0 : 1)}s` : t("common.na"); const authMode = snapshot?.authMode; const isTrustedProxy = authMode === "trusted-proxy"; @@ -135,7 +161,7 @@ export function renderOverview(props: OverviewProps) {
${t("overview.access.title")}
${t("overview.access.subtitle")}
-
+
+ ${ + !props.connected + ? html` +
+
${t("overview.connection.title")}
+
    +
  1. ${t("overview.connection.step1")} +
    openclaw gateway run
    +
  2. +
  3. ${t("overview.connection.step2")} +
    openclaw dashboard --no-open
    +
  4. +
  5. ${t("overview.connection.step3")}
  6. +
  7. ${t("overview.connection.step4")} +
    openclaw doctor --generate-gateway-token
    +
  8. +
+
+ ${t("overview.connection.docsHint")} + ${t("overview.connection.docsLink")} +
+
+ ` + : nothing + }
@@ -253,45 +311,43 @@ export function renderOverview(props: OverviewProps) {
-
-
-
${t("overview.stats.instances")}
-
${props.presenceCount}
-
${t("overview.stats.instancesHint")}
-
-
-
${t("overview.stats.sessions")}
-
${props.sessionsCount ?? t("common.na")}
-
${t("overview.stats.sessionsHint")}
-
-
-
${t("overview.stats.cron")}
-
- ${props.cronEnabled == null ? t("common.na") : props.cronEnabled ? t("common.enabled") : t("common.disabled")} -
-
${t("overview.stats.cronNext", { time: formatNextRun(props.cronNext) })}
-
-
+ ${ + props.streamMode + ? html`
+ ${icons.radio} + ${t("overview.streamMode.active")} + +
` + : nothing + } + + ${renderOverviewCards({ + usageResult: props.usageResult, + sessionsResult: props.sessionsResult, + skillsReport: props.skillsReport, + cronJobs: props.cronJobs, + cronStatus: props.cronStatus, + presenceCount: props.presenceCount, + redacted: props.streamMode, + onNavigate: props.onNavigate, + })} + + ${renderOverviewAttention({ items: props.attentionItems })} + +
+ ${renderOverviewEventLog({ + events: props.eventLog, + redacted: props.streamMode, + })} + + ${renderOverviewLogTail({ + lines: props.overviewLogLines, + redacted: props.streamMode, + onRefreshLogs: props.onRefreshLogs, + })} +
-
-
${t("overview.notes.title")}
-
${t("overview.notes.subtitle")}
-
-
-
${t("overview.notes.tailscaleTitle")}
-
- ${t("overview.notes.tailscaleText")} -
-
-
-
${t("overview.notes.sessionTitle")}
-
${t("overview.notes.sessionText")}
-
-
-
${t("overview.notes.cronTitle")}
-
${t("overview.notes.cronText")}
-
-
-
`; } diff --git a/ui/src/ui/views/usage-styles/usageStyles-part1.ts b/ui/src/ui/views/usage-styles/usageStyles-part1.ts index 1df314e46b5..a6f595170a6 100644 --- a/ui/src/ui/views/usage-styles/usageStyles-part1.ts +++ b/ui/src/ui/views/usage-styles/usageStyles-part1.ts @@ -54,16 +54,16 @@ export const usageStylesPart1 = ` align-items: center; gap: 6px; padding: 4px 10px; - background: rgba(255, 77, 77, 0.1); + background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px; font-size: 12px; - color: #ff4d4d; + color: var(--accent); } .usage-refresh-indicator::before { content: ""; width: 10px; height: 10px; - border: 2px solid #ff4d4d; + border: 2px solid var(--accent); border-top-color: transparent; border-radius: 50%; animation: usage-spin 0.6s linear infinite; @@ -161,36 +161,36 @@ export const usageStylesPart1 = ` border-color: var(--border-strong); } .usage-primary-btn { - background: #ff4d4d; + background: var(--accent); color: #fff; - border-color: #ff4d4d; + border-color: var(--accent); box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.12); } .btn.usage-primary-btn { - background: #ff4d4d !important; - border-color: #ff4d4d !important; + background: var(--accent) !important; + border-color: var(--accent) !important; color: #fff !important; } .usage-primary-btn:hover { - background: #e64545; - border-color: #e64545; + background: var(--accent-strong); + border-color: var(--accent-strong); } .btn.usage-primary-btn:hover { - background: #e64545 !important; - border-color: #e64545 !important; + background: var(--accent-strong) !important; + border-color: var(--accent-strong) !important; } .usage-primary-btn:disabled { - background: rgba(255, 77, 77, 0.18); - border-color: rgba(255, 77, 77, 0.3); - color: #ff4d4d; + background: color-mix(in srgb, var(--accent) 18%, transparent); + border-color: color-mix(in srgb, var(--accent) 30%, transparent); + color: var(--accent); box-shadow: none; cursor: default; opacity: 1; } .usage-primary-btn[disabled] { - background: rgba(255, 77, 77, 0.18) !important; - border-color: rgba(255, 77, 77, 0.3) !important; - color: #ff4d4d !important; + background: color-mix(in srgb, var(--accent) 18%, transparent) !important; + border-color: color-mix(in srgb, var(--accent) 30%, transparent) !important; + color: var(--accent) !important; opacity: 1 !important; } .usage-secondary-btn { @@ -533,8 +533,8 @@ export const usageStylesPart1 = ` border-radius: 8px; padding: 10px; color: var(--text); - background: rgba(255, 77, 77, 0.08); - border: 1px solid rgba(255, 77, 77, 0.2); + background: color-mix(in srgb, var(--accent) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); display: flex; flex-direction: column; gap: 4px; @@ -554,14 +554,14 @@ export const usageStylesPart1 = ` .usage-hour-cell { height: 28px; border-radius: 6px; - background: rgba(255, 77, 77, 0.1); - border: 1px solid rgba(255, 77, 77, 0.2); + background: color-mix(in srgb, var(--accent) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s; } .usage-hour-cell.selected { - border-color: rgba(255, 77, 77, 0.8); - box-shadow: 0 0 0 2px rgba(255, 77, 77, 0.2); + border-color: color-mix(in srgb, var(--accent) 80%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); } .usage-hour-labels { display: grid; @@ -584,8 +584,8 @@ export const usageStylesPart1 = ` width: 14px; height: 10px; border-radius: 4px; - background: rgba(255, 77, 77, 0.15); - border: 1px solid rgba(255, 77, 77, 0.2); + background: color-mix(in srgb, var(--accent) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); } .usage-calendar-labels { display: grid; @@ -603,8 +603,8 @@ export const usageStylesPart1 = ` .usage-calendar-cell { height: 18px; border-radius: 4px; - border: 1px solid rgba(255, 77, 77, 0.2); - background: rgba(255, 77, 77, 0.08); + border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); + background: color-mix(in srgb, var(--accent) 8%, transparent); } .usage-calendar-cell.empty { background: transparent; diff --git a/ui/src/ui/views/usage-styles/usageStyles-part2.ts b/ui/src/ui/views/usage-styles/usageStyles-part2.ts index 75826aec314..98400390d87 100644 --- a/ui/src/ui/views/usage-styles/usageStyles-part2.ts +++ b/ui/src/ui/views/usage-styles/usageStyles-part2.ts @@ -100,7 +100,7 @@ export const usageStylesPart2 = ` color: var(--text); } .chart-toggle .toggle-btn.active { - background: #ff4d4d; + background: var(--accent); color: white; } .chart-toggle.small .toggle-btn { @@ -157,14 +157,14 @@ export const usageStylesPart2 = ` .daily-bar { width: 100%; max-width: var(--bar-max-width, 32px); - background: #ff4d4d; + background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; transition: all 0.15s; overflow: hidden; } .daily-bar-wrapper:hover .daily-bar { - background: #cc3d3d; + background: var(--accent-strong); } .daily-bar-label { position: absolute; @@ -282,7 +282,7 @@ export const usageStylesPart2 = ` background: #06b6d4; } .legend-dot.system { - background: #ff4d4d; + background: var(--accent); } .legend-dot.skills { background: #8b5cf6; @@ -360,7 +360,7 @@ export const usageStylesPart2 = ` } .session-bar-fill { height: 100%; - background: rgba(255, 77, 77, 0.7); + background: color-mix(in srgb, var(--accent) 70%, transparent); border-radius: 4px; transition: width 0.3s ease; } @@ -431,27 +431,27 @@ export const usageStylesPart2 = ` fill: var(--muted); } .timeseries-svg .ts-area { - fill: #ff4d4d; + fill: var(--accent); fill-opacity: 0.1; } .timeseries-svg .ts-line { fill: none; - stroke: #ff4d4d; + stroke: var(--accent); stroke-width: 2; } .timeseries-svg .ts-dot { - fill: #ff4d4d; + fill: var(--accent); transition: r 0.15s, fill 0.15s; } .timeseries-svg .ts-dot:hover { r: 5; } .timeseries-svg .ts-bar { - fill: #ff4d4d; + fill: var(--accent); transition: fill 0.15s; } .timeseries-svg .ts-bar:hover { - fill: #cc3d3d; + fill: var(--accent-strong); } .timeseries-svg .ts-bar.output { fill: #ef4444; } .timeseries-svg .ts-bar.input { fill: #f59e0b; } @@ -582,7 +582,7 @@ export const usageStylesPart2 = ` transition: width 0.3s ease; } .context-segment.system { - background: #ff4d4d; + background: var(--accent); } .context-segment.skills { background: #8b5cf6; diff --git a/ui/src/ui/views/usage-styles/usageStyles-part3.ts b/ui/src/ui/views/usage-styles/usageStyles-part3.ts index 8a114ab69fd..e78cfa63e23 100644 --- a/ui/src/ui/views/usage-styles/usageStyles-part3.ts +++ b/ui/src/ui/views/usage-styles/usageStyles-part3.ts @@ -121,7 +121,7 @@ export const usageStylesPart3 = ` .sessions-card .session-bar-row.selected { border-color: var(--accent); background: var(--accent-subtle); - box-shadow: inset 0 0 0 1px rgba(255, 77, 77, 0.15); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 15%, transparent); } .sessions-card .session-bar-label { flex: 1 1 auto; @@ -139,7 +139,7 @@ export const usageStylesPart3 = ` opacity: 0.5; } .sessions-card .session-bar-fill { - background: rgba(255, 77, 77, 0.55); + background: color-mix(in srgb, var(--accent) 55%, transparent); } .sessions-clear-btn { margin-left: auto; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 161cb9dae3b..988b439fde3 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -34,7 +34,7 @@ export default defineConfig(() => { }, server: { host: true, - port: 5173, + port: 5174, strictPort: true, }, }; From 26763d191015525cea3e1db156ed59028dd692a2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 12:25:48 +0100 Subject: [PATCH 22/81] fix: resolve extension type errors and harden probe mocks --- extensions/bluebubbles/src/runtime.ts | 4 +++- extensions/bluebubbles/src/test-harness.ts | 10 ++++++---- extensions/feishu/src/channel.ts | 4 +--- extensions/line/src/channel.ts | 3 +-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/extensions/bluebubbles/src/runtime.ts b/extensions/bluebubbles/src/runtime.ts index 439e62d2503..c9468234d3e 100644 --- a/extensions/bluebubbles/src/runtime.ts +++ b/extensions/bluebubbles/src/runtime.ts @@ -1,6 +1,7 @@ import type { PluginRuntime } from "openclaw/plugin-sdk"; let runtime: PluginRuntime | null = null; +type LegacyRuntimeLogShape = { log?: (message: string) => void }; export function setBlueBubblesRuntime(next: PluginRuntime): void { runtime = next; @@ -23,7 +24,8 @@ export function getBlueBubblesRuntime(): PluginRuntime { export function warnBlueBubbles(message: string): void { const formatted = `[bluebubbles] ${message}`; - const log = runtime?.log; + // Backward-compatible with tests/legacy injections that pass { log }. + const log = (runtime as unknown as LegacyRuntimeLogShape | null)?.log; if (typeof log === "function") { log(formatted); return; diff --git a/extensions/bluebubbles/src/test-harness.ts b/extensions/bluebubbles/src/test-harness.ts index 7c6938a9681..5f7351b2e9f 100644 --- a/extensions/bluebubbles/src/test-harness.ts +++ b/extensions/bluebubbles/src/test-harness.ts @@ -2,10 +2,10 @@ import type { Mock } from "vitest"; import { afterEach, beforeEach, vi } from "vitest"; export const BLUE_BUBBLES_PRIVATE_API_STATUS = { - enabled: true as const, - disabled: false as const, - unknown: null as const, -}; + enabled: true, + disabled: false, + unknown: null, +} as const; type BlueBubblesPrivateApiStatusMock = { mockReturnValue: (value: boolean | null) => unknown; @@ -47,6 +47,7 @@ export function createBlueBubblesAccountsMockModule() { type BlueBubblesProbeMockModule = { getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>; + isBlueBubblesPrivateApiStatusEnabled: Mock<(status: boolean | null) => boolean>; }; export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule { @@ -54,6 +55,7 @@ export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule { getCachedBlueBubblesPrivateApiStatus: vi .fn() .mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown), + isBlueBubblesPrivateApiStatusEnabled: vi.fn((status: boolean | null) => status === true), }; } diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index c1f29be85e5..dbd1e46facb 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -225,9 +225,7 @@ export const feishuPlugin: ChannelPlugin = { collectWarnings: ({ cfg, accountId }) => { const account = resolveFeishuAccount({ cfg, accountId }); const feishuCfg = account.config; - const defaultGroupPolicy = ( - cfg.channels as Record | undefined - )?.defaults?.groupPolicy; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; const { groupPolicy } = resolveRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.feishu !== undefined, groupPolicy: feishuCfg?.groupPolicy, diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index f5c72cf81b4..b70aa4f1c05 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -162,8 +162,7 @@ export const linePlugin: ChannelPlugin = { }; }, collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = (cfg.channels?.defaults as { groupPolicy?: string } | undefined) - ?.groupPolicy; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; const { groupPolicy } = resolveRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.line !== undefined, groupPolicy: account.config.groupPolicy, From 944d2b826c8661d47983c0ecf6b51693dce78706 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 22 Feb 2026 05:26:41 -0600 Subject: [PATCH 23/81] docs(ui): add dashboard verification checklist --- ui/CHECKLIST.md | 145 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 ui/CHECKLIST.md diff --git a/ui/CHECKLIST.md b/ui/CHECKLIST.md new file mode 100644 index 00000000000..ef13c720913 --- /dev/null +++ b/ui/CHECKLIST.md @@ -0,0 +1,145 @@ +# UI Dashboard — Verification Checklist + +Run through this checklist after every change that touches `ui/` files. +Open the dashboard at `http://localhost:` (or the gateway's configured UI URL). + +## Login & Shell + +- [ ] Login gate renders when not authenticated +- [ ] Login with valid password grants access +- [ ] Login with invalid password shows error +- [ ] App shell loads: sidebar, header, content area visible +- [ ] Sidebar shows all tab groups: Chat, Control, Agent, Settings +- [ ] Sidebar collapse/expand works; favicon logo shows when collapsed +- [ ] Router: clicking each sidebar tab navigates and updates URL +- [ ] Browser back/forward navigates between tabs +- [ ] Direct URL navigation (e.g. `/chat`, `/overview`) loads correct tab + +## Themes + +- [ ] Theme switcher cycles through all 6 themes: + - [ ] Dark (Obsidian) + - [ ] Light + - [ ] OpenKnot (Aurora) + - [ ] Field Manual + - [ ] OpenAI (Solar) + - [ ] ClawDash +- [ ] Glass components (cards, panels, inputs) render correctly per theme +- [ ] Theme persists across page reload + +## Overview + +- [ ] Overview tab loads without errors +- [ ] Stat cards render: cost, sessions, skills, cron +- [ ] Cards show accent color borders per kind +- [ ] Cards show hover lift + shadow effect +- [ ] Cards are clickable and navigate to corresponding tab +- [ ] Responsive grid: 4 columns → 2 → 1 at breakpoints +- [ ] Attention items render with correct severity icons/colors (error, warning, info) +- [ ] Event log renders with timestamps +- [ ] Log tail section renders live gateway log lines +- [ ] Quick actions section renders +- [ ] Redact toggle in topbar redacts/reveals sensitive values in cards + +## Chat + +- [ ] Chat view renders message history +- [ ] Sending a message works and response streams in +- [ ] Markdown rendering works in responses (code blocks, lists, links) +- [ ] Tool call cards render collapsed by default +- [ ] Tool cards expand/collapse on click; summary shows tool name/count +- [ ] JSON messages render collapsed by default +- [ ] Delete message: trash icon appears on hover, click removes message group +- [ ] Deleted messages persist across reload (localStorage) +- [ ] Clear history button resets session via `sessions.reset` RPC +- [ ] Agent selector dropdown appears when multiple agents configured +- [ ] Switching agents updates session key and reloads history +- [ ] Session list panel: shows all sessions for current agent +- [ ] Session list: clicking a session switches to it +- [ ] Input history (up/down arrow) recalls previous messages +- [ ] Slash command menu opens on `/` keystroke +- [ ] Slash commands show icons, categories, and grouping +- [ ] Pinned messages render if present + +## Command Palette + +- [ ] Opens via keyboard shortcut or UI button +- [ ] Fuzzy search filters commands as you type +- [ ] Results grouped by category with labels +- [ ] Selecting a command executes it +- [ ] "No results" message when nothing matches +- [ ] Clicking overlay closes palette +- [ ] Escape key closes palette + +## Agents + +- [ ] Agent tab loads agent list +- [ ] Agent overview panel: identity card with name, ID, avatar color +- [ ] Agent config display: model, tools, skills shown +- [ ] Agent panels: overview, status/files, tools/skills tabs work +- [ ] Tab counts show for files, skills, channels, cron +- [ ] Sidebar agent filter input filters agents in multi-agent setup +- [ ] Agent actions menu: "copy ID" and "set as default" work +- [ ] Chip-based fallback input (model selection): Enter/comma adds chips + +## Channels & Instances + +- [ ] Channels tab lists connected channels +- [ ] Instances tab lists connected instances +- [ ] Host/IP blurred by default in Connected Instances +- [ ] Reveal toggle shows actual host/IP values +- [ ] Nostr profile form renders if nostr channel present + +## Privacy & Redaction + +- [ ] Topbar redact toggle visible; default is stream mode on +- [ ] Redact ON: sensitive values masked in overview cards +- [ ] Redact ON: cost digits blurred +- [ ] Redact ON: access card blurred +- [ ] Redact ON: raw config JSON masks sensitive values with count badge +- [ ] Redact OFF: all values visible + +## Config + +- [ ] Config tab renders current gateway configuration +- [ ] Config form fields editable +- [ ] Sensitive config values masked when redact is on +- [ ] Config analysis view loads + +## Other Tabs + +- [ ] Sessions tab loads session list +- [ ] Usage tab loads usage statistics with styled sections +- [ ] Cron tab lists cron jobs with status +- [ ] Skills tab lists skills with status report +- [ ] Nodes tab loads +- [ ] Debug tab renders debug info +- [ ] Logs tab renders + +## i18n + +- [ ] English locale loads by default +- [ ] All visible strings use i18n keys (no hardcoded English in templates) +- [ ] zh-CN locale keys present +- [ ] zh-TW locale keys present +- [ ] pt-BR locale keys present + +## Responsive & Mobile + +- [ ] Sidebar collapses on narrow viewport +- [ ] Bottom tabs render on mobile breakpoint +- [ ] Card grid reflows: 4 → 2 → 1 columns +- [ ] Chat input usable on mobile +- [ ] No horizontal overflow on any tab at 375px width + +## Build & Tests + +- [ ] `pnpm build` completes without errors +- [ ] `pnpm test` passes — specifically `ui/` test files: + - [ ] `app-gateway.node.test.ts` + - [ ] `app-settings.test.ts` + - [ ] `config-form.browser.test.ts` + - [ ] `config.browser.test.ts` + - [ ] `chat.test.ts` +- [ ] No new TypeScript errors: `pnpm tsgo` +- [ ] No lint/format issues: `pnpm check` From ad404c962621998d9cefa4cfc2312ac481c30095 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 12:27:42 +0100 Subject: [PATCH 24/81] fix: align markdown code renderer with marked token typing --- ui/src/ui/markdown.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/ui/markdown.ts b/ui/src/ui/markdown.ts index e892402e5d6..f7f5602ce4f 100644 --- a/ui/src/ui/markdown.ts +++ b/ui/src/ui/markdown.ts @@ -141,7 +141,7 @@ htmlEscapeRenderer.code = ({ }: { text: string; lang?: string; - escaped: boolean; + escaped?: boolean; }) => { const langClass = lang ? ` class="language-${lang}"` : ""; const safeText = escaped ? text : escapeHtml(text); From 5056f4e1423244b3ee568a7e9f9af854ced072cf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:27:54 +0000 Subject: [PATCH 25/81] fix(bluebubbles): tighten chat target handling --- extensions/bluebubbles/src/actions.test.ts | 15 +- extensions/bluebubbles/src/chat.test.ts | 194 +++++++++++++++++- extensions/bluebubbles/src/chat.ts | 198 +++++++------------ extensions/bluebubbles/src/reactions.test.ts | 15 +- extensions/bluebubbles/src/targets.ts | 83 ++++---- 5 files changed, 321 insertions(+), 184 deletions(-) diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index efb4859fac4..aabc5adf8fe 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -3,17 +3,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { bluebubblesMessageActions } from "./actions.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; -vi.mock("./accounts.js", () => ({ - resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { - const config = cfg?.channels?.bluebubbles ?? {}; - return { - accountId: accountId ?? "default", - enabled: config.enabled !== false, - configured: Boolean(config.serverUrl && config.password), - config, - }; - }), -})); +vi.mock("./accounts.js", async () => { + const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js"); + return createBlueBubblesAccountsMockModule(); +}); vi.mock("./reactions.js", () => ({ sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined), diff --git a/extensions/bluebubbles/src/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts index f372ca4614e..d22ded63613 100644 --- a/extensions/bluebubbles/src/chat.test.ts +++ b/extensions/bluebubbles/src/chat.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it, vi } from "vitest"; import "./test-mocks.js"; -import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js"; +import { + addBlueBubblesParticipant, + editBlueBubblesMessage, + leaveBlueBubblesChat, + markBlueBubblesChatRead, + removeBlueBubblesParticipant, + renameBlueBubblesChat, + sendBlueBubblesTyping, + setGroupIconBlueBubbles, + unsendBlueBubblesMessage, +} from "./chat.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; @@ -278,6 +288,188 @@ describe("chat", () => { }); }); + describe("editBlueBubblesMessage", () => { + it("throws when required args are missing", async () => { + await expect(editBlueBubblesMessage("", "updated", {})).rejects.toThrow("messageGuid"); + await expect(editBlueBubblesMessage("message-guid", " ", {})).rejects.toThrow("newText"); + }); + + it("sends edit request with default payload values", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await editBlueBubblesMessage(" message-guid ", " updated text ", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/message/message-guid/edit"), + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body).toEqual({ + editedMessage: "updated text", + backwardsCompatibilityMessage: "Edited to: updated text", + partIndex: 0, + }); + }); + + it("supports custom part index and backwards compatibility message", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await editBlueBubblesMessage("message-guid", "new text", { + serverUrl: "http://localhost:1234", + password: "test-password", + partIndex: 3, + backwardsCompatMessage: "custom-backwards-message", + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.partIndex).toBe(3); + expect(body.backwardsCompatibilityMessage).toBe("custom-backwards-message"); + }); + + it("throws on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 422, + text: () => Promise.resolve("Unprocessable"), + }); + + await expect( + editBlueBubblesMessage("message-guid", "new text", { + serverUrl: "http://localhost:1234", + password: "test-password", + }), + ).rejects.toThrow("edit failed (422): Unprocessable"); + }); + }); + + describe("unsendBlueBubblesMessage", () => { + it("throws when messageGuid is missing", async () => { + await expect(unsendBlueBubblesMessage("", {})).rejects.toThrow("messageGuid"); + }); + + it("sends unsend request with default part index", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await unsendBlueBubblesMessage(" msg-123 ", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/message/msg-123/unsend"), + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.partIndex).toBe(0); + }); + + it("uses custom part index", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await unsendBlueBubblesMessage("msg-123", { + serverUrl: "http://localhost:1234", + password: "test-password", + partIndex: 2, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.partIndex).toBe(2); + }); + }); + + describe("group chat mutation actions", () => { + it("renames chat", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await renameBlueBubblesChat(" chat-guid ", "New Group Name", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/chat-guid"), + expect.objectContaining({ method: "PUT" }), + ); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.displayName).toBe("New Group Name"); + }); + + it("adds and removes participant using matching endpoint", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await addBlueBubblesParticipant("chat-guid", "+15551234567", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + await removeBlueBubblesParticipant("chat-guid", "+15551234567", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0][0]).toContain("/api/v1/chat/chat-guid/participant"); + expect(mockFetch.mock.calls[0][1].method).toBe("POST"); + expect(mockFetch.mock.calls[1][0]).toContain("/api/v1/chat/chat-guid/participant"); + expect(mockFetch.mock.calls[1][1].method).toBe("DELETE"); + + const addBody = JSON.parse(mockFetch.mock.calls[0][1].body); + const removeBody = JSON.parse(mockFetch.mock.calls[1][1].body); + expect(addBody.address).toBe("+15551234567"); + expect(removeBody.address).toBe("+15551234567"); + }); + + it("leaves chat without JSON body", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await leaveBlueBubblesChat("chat-guid", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/chat-guid/leave"), + expect.objectContaining({ method: "POST" }), + ); + expect(mockFetch.mock.calls[0][1].body).toBeUndefined(); + expect(mockFetch.mock.calls[0][1].headers).toBeUndefined(); + }); + }); + describe("setGroupIconBlueBubbles", () => { it("throws when chatGuid is empty", async () => { await expect( diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts index 354e7076722..f5f83b1b6ae 100644 --- a/extensions/bluebubbles/src/chat.ts +++ b/extensions/bluebubbles/src/chat.ts @@ -26,6 +26,41 @@ function assertPrivateApiEnabled(accountId: string, feature: string): void { } } +function resolvePartIndex(partIndex: number | undefined): number { + return typeof partIndex === "number" ? partIndex : 0; +} + +async function sendPrivateApiJsonRequest(params: { + opts: BlueBubblesChatOpts; + feature: string; + action: string; + path: string; + method: "POST" | "PUT" | "DELETE"; + payload?: unknown; +}): Promise { + const { baseUrl, password, accountId } = resolveAccount(params.opts); + assertPrivateApiEnabled(accountId, params.feature); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: params.path, + password, + }); + + const request: RequestInit = { method: params.method }; + if (params.payload !== undefined) { + request.headers = { "Content-Type": "application/json" }; + request.body = JSON.stringify(params.payload); + } + + const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs); + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error( + `BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`, + ); + } +} + export async function markBlueBubblesChatRead( chatGuid: string, opts: BlueBubblesChatOpts = {}, @@ -97,34 +132,18 @@ export async function editBlueBubblesMessage( throw new Error("BlueBubbles edit requires newText"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "edit"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "edit", + action: "edit", + method: "POST", path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`, - password, - }); - - const payload = { - editedMessage: trimmedText, - backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`, - partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0, - }; - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + payload: { + editedMessage: trimmedText, + backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`, + partIndex: resolvePartIndex(opts.partIndex), }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`); - } + }); } /** @@ -140,32 +159,14 @@ export async function unsendBlueBubblesMessage( throw new Error("BlueBubbles unsend requires messageGuid"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "unsend"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "unsend", + action: "unsend", + method: "POST", path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`, - password, + payload: { partIndex: resolvePartIndex(opts.partIndex) }, }); - - const payload = { - partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0, - }; - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles unsend failed (${res.status}): ${errorText || "unknown"}`); - } } /** @@ -181,28 +182,14 @@ export async function renameBlueBubblesChat( throw new Error("BlueBubbles rename requires chatGuid"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "renameGroup"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "renameGroup", + action: "rename", + method: "PUT", path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`, - password, + payload: { displayName }, }); - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ displayName }), - }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles rename failed (${res.status}): ${errorText || "unknown"}`); - } } /** @@ -222,28 +209,14 @@ export async function addBlueBubblesParticipant( throw new Error("BlueBubbles addParticipant requires address"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "addParticipant"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "addParticipant", + action: "addParticipant", + method: "POST", path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`, - password, + payload: { address: trimmedAddress }, }); - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ address: trimmedAddress }), - }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles addParticipant failed (${res.status}): ${errorText || "unknown"}`); - } } /** @@ -263,30 +236,14 @@ export async function removeBlueBubblesParticipant( throw new Error("BlueBubbles removeParticipant requires address"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "removeParticipant"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "removeParticipant", + action: "removeParticipant", + method: "DELETE", path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`, - password, + payload: { address: trimmedAddress }, }); - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ address: trimmedAddress }), - }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error( - `BlueBubbles removeParticipant failed (${res.status}): ${errorText || "unknown"}`, - ); - } } /** @@ -301,20 +258,13 @@ export async function leaveBlueBubblesChat( throw new Error("BlueBubbles leaveChat requires chatGuid"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "leaveGroup"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "leaveGroup", + action: "leaveChat", + method: "POST", path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`, - password, }); - - const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`); - } } /** diff --git a/extensions/bluebubbles/src/reactions.test.ts b/extensions/bluebubbles/src/reactions.test.ts index 643a926b889..0ea99f911f6 100644 --- a/extensions/bluebubbles/src/reactions.test.ts +++ b/extensions/bluebubbles/src/reactions.test.ts @@ -1,17 +1,10 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { sendBlueBubblesReaction } from "./reactions.js"; -vi.mock("./accounts.js", () => ({ - resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { - const config = cfg?.channels?.bluebubbles ?? {}; - return { - accountId: accountId ?? "default", - enabled: config.enabled !== false, - configured: Boolean(config.serverUrl && config.password), - config, - }; - }), -})); +vi.mock("./accounts.js", async () => { + const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js"); + return createBlueBubblesAccountsMockModule(); +}); const mockFetch = vi.fn(); diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts index be9d0fa6770..b136de3095c 100644 --- a/extensions/bluebubbles/src/targets.ts +++ b/extensions/bluebubbles/src/targets.ts @@ -78,6 +78,40 @@ function looksLikeRawChatIdentifier(value: string): boolean { return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed); } +function parseGroupTarget(params: { + trimmed: string; + lower: string; + requireValue: boolean; +}): { kind: "chat_id"; chatId: number } | { kind: "chat_guid"; chatGuid: string } | null { + if (!params.lower.startsWith("group:")) { + return null; + } + const value = stripPrefix(params.trimmed, "group:"); + const chatId = Number.parseInt(value, 10); + if (Number.isFinite(chatId)) { + return { kind: "chat_id", chatId }; + } + if (value) { + return { kind: "chat_guid", chatGuid: value }; + } + if (params.requireValue) { + throw new Error("group target is required"); + } + return null; +} + +function parseRawChatIdentifierTarget( + trimmed: string, +): { kind: "chat_identifier"; chatIdentifier: string } | null { + if (/^chat\d+$/i.test(trimmed)) { + return { kind: "chat_identifier", chatIdentifier: trimmed }; + } + if (looksLikeRawChatIdentifier(trimmed)) { + return { kind: "chat_identifier", chatIdentifier: trimmed }; + } + return null; +} + export function normalizeBlueBubblesHandle(raw: string): string { const trimmed = raw.trim(); if (!trimmed) { @@ -239,16 +273,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { return chatTarget; } - if (lower.startsWith("group:")) { - const value = stripPrefix(trimmed, "group:"); - const chatId = Number.parseInt(value, 10); - if (Number.isFinite(chatId)) { - return { kind: "chat_id", chatId }; - } - if (!value) { - throw new Error("group target is required"); - } - return { kind: "chat_guid", chatGuid: value }; + const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: true }); + if (groupTarget) { + return groupTarget; } const rawChatGuid = parseRawChatGuid(trimmed); @@ -256,15 +283,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { return { kind: "chat_guid", chatGuid: rawChatGuid }; } - // Handle chat pattern (e.g., "chat660250192681427962") as chat_identifier - // These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs - if (/^chat\d+$/i.test(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; - } - - // Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc") - if (looksLikeRawChatIdentifier(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; + const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed); + if (rawChatIdentifierTarget) { + return rawChatIdentifierTarget; } return { kind: "handle", to: trimmed, service: "auto" }; @@ -298,26 +319,14 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget return chatTarget; } - if (lower.startsWith("group:")) { - const value = stripPrefix(trimmed, "group:"); - const chatId = Number.parseInt(value, 10); - if (Number.isFinite(chatId)) { - return { kind: "chat_id", chatId }; - } - if (value) { - return { kind: "chat_guid", chatGuid: value }; - } + const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: false }); + if (groupTarget) { + return groupTarget; } - // Handle chat pattern (e.g., "chat660250192681427962") as chat_identifier - // These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs - if (/^chat\d+$/i.test(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; - } - - // Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc") - if (looksLikeRawChatIdentifier(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; + const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed); + if (rawChatIdentifierTarget) { + return rawChatIdentifierTarget; } return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) }; From 9e6125ea2f61a29c712651174c76e5cabb5660d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:28:00 +0000 Subject: [PATCH 26/81] test(discord): stabilize subagent hook coverage --- extensions/discord/src/subagent-hooks.test.ts | 298 ++++++++---------- 1 file changed, 128 insertions(+), 170 deletions(-) diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts index 8e2514b3b77..f8a139cd56d 100644 --- a/extensions/discord/src/subagent-hooks.test.ts +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -64,6 +64,95 @@ function registerHandlersForTest( return handlers; } +function getRequiredHandler( + handlers: Map unknown>, + hookName: string, +): (event: unknown, ctx: unknown) => unknown { + const handler = handlers.get(hookName); + if (!handler) { + throw new Error(`expected ${hookName} hook handler`); + } + return handler; +} + +function createSpawnEvent(overrides?: { + childSessionKey?: string; + agentId?: string; + label?: string; + mode?: string; + requester?: { + channel?: string; + accountId?: string; + to?: string; + threadId?: string; + }; + threadRequested?: boolean; +}): { + childSessionKey: string; + agentId: string; + label: string; + mode: string; + requester: { + channel: string; + accountId: string; + to: string; + threadId?: string; + }; + threadRequested: boolean; +} { + const base = { + childSessionKey: "agent:main:subagent:child", + agentId: "main", + label: "banana", + mode: "session", + requester: { + channel: "discord", + accountId: "work", + to: "channel:123", + threadId: "456", + }, + threadRequested: true, + }; + return { + ...base, + ...overrides, + requester: { + ...base.requester, + ...(overrides?.requester ?? {}), + }, + }; +} + +function createSpawnEventWithoutThread() { + return createSpawnEvent({ + label: "", + requester: { threadId: undefined }, + }); +} + +async function runSubagentSpawning( + config?: Record, + event = createSpawnEventWithoutThread(), +) { + const handlers = registerHandlersForTest(config); + const handler = getRequiredHandler(handlers, "subagent_spawning"); + return await handler(event, {}); +} + +async function expectSubagentSpawningError(params?: { + config?: Record; + errorContains?: string; + event?: ReturnType; +}) { + const result = await runSubagentSpawning(params?.config, params?.event); + expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); + expect(result).toMatchObject({ status: "error" }); + if (params?.errorContains) { + const errorText = (result as { error?: string }).error ?? ""; + expect(errorText).toContain(params.errorContains); + } +} + describe("discord subagent hook handlers", () => { beforeEach(() => { hookMocks.resolveDiscordAccount.mockClear(); @@ -90,27 +179,9 @@ describe("discord subagent hook handlers", () => { it("binds thread routing on subagent_spawning", async () => { const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } + const handler = getRequiredHandler(handlers, "subagent_spawning"); - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - label: "banana", - mode: "session", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - threadId: "456", - }, - threadRequested: true, - }, - {}, - ); + const result = await handler(createSpawnEvent(), {}); expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1); expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledWith({ @@ -127,82 +198,42 @@ describe("discord subagent hook handlers", () => { }); it("returns error when thread-bound subagent spawn is disabled", async () => { - const handlers = registerHandlersForTest({ - channels: { - discord: { - threadBindings: { - spawnSubagentSessions: false, + await expectSubagentSpawningError({ + config: { + channels: { + discord: { + threadBindings: { + spawnSubagentSessions: false, + }, }, }, }, + errorContains: "spawnSubagentSessions=true", }); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - }, - threadRequested: true, - }, - {}, - ); - - expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); - expect(result).toMatchObject({ status: "error" }); - const errorText = (result as { error?: string }).error ?? ""; - expect(errorText).toContain("spawnSubagentSessions=true"); }); it("returns error when global thread bindings are disabled", async () => { - const handlers = registerHandlersForTest({ - session: { - threadBindings: { - enabled: false, - }, - }, - channels: { - discord: { + await expectSubagentSpawningError({ + config: { + session: { threadBindings: { - spawnSubagentSessions: true, + enabled: false, + }, + }, + channels: { + discord: { + threadBindings: { + spawnSubagentSessions: true, + }, }, }, }, + errorContains: "threadBindings.enabled=true", }); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - }, - threadRequested: true, - }, - {}, - ); - - expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); - expect(result).toMatchObject({ status: "error" }); - const errorText = (result as { error?: string }).error ?? ""; - expect(errorText).toContain("threadBindings.enabled=true"); }); it("allows account-level threadBindings.enabled to override global disable", async () => { - const handlers = registerHandlersForTest({ + const result = await runSubagentSpawning({ session: { threadBindings: { enabled: false, @@ -221,79 +252,34 @@ describe("discord subagent hook handlers", () => { }, }, }); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - }, - threadRequested: true, - }, - {}, - ); expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1); expect(result).toMatchObject({ status: "ok", threadBindingReady: true }); }); it("defaults thread-bound subagent spawn to disabled when unset", async () => { - const handlers = registerHandlersForTest({ - channels: { - discord: { - threadBindings: {}, + await expectSubagentSpawningError({ + config: { + channels: { + discord: { + threadBindings: {}, + }, }, }, }); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - }, - threadRequested: true, - }, - {}, - ); - - expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); - expect(result).toMatchObject({ status: "error" }); }); it("no-ops when thread binding is requested on non-discord channel", async () => { - const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - mode: "session", + const result = await runSubagentSpawning( + undefined, + createSpawnEvent({ requester: { channel: "signal", + accountId: "", to: "+123", + threadId: undefined, }, - threadRequested: true, - }, - {}, + }), ); expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); @@ -302,26 +288,7 @@ describe("discord subagent hook handlers", () => { it("returns error when thread bind fails", async () => { hookMocks.autoBindSpawnedDiscordSubagent.mockResolvedValueOnce(null); - const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - mode: "session", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - }, - threadRequested: true, - }, - {}, - ); + const result = await runSubagentSpawning(); expect(result).toMatchObject({ status: "error" }); const errorText = (result as { error?: string }).error ?? ""; @@ -330,10 +297,7 @@ describe("discord subagent hook handlers", () => { it("unbinds thread routing on subagent_ended", () => { const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_ended"); - if (!handler) { - throw new Error("expected subagent_ended hook handler"); - } + const handler = getRequiredHandler(handlers, "subagent_ended"); handler( { @@ -361,10 +325,7 @@ describe("discord subagent hook handlers", () => { { accountId: "work", threadId: "777" }, ]); const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_delivery_target"); - if (!handler) { - throw new Error("expected subagent_delivery_target hook handler"); - } + const handler = getRequiredHandler(handlers, "subagent_delivery_target"); const result = handler( { @@ -404,10 +365,7 @@ describe("discord subagent hook handlers", () => { { accountId: "work", threadId: "888" }, ]); const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_delivery_target"); - if (!handler) { - throw new Error("expected subagent_delivery_target hook handler"); - } + const handler = getRequiredHandler(handlers, "subagent_delivery_target"); const result = handler( { From 5574eb6b35373a149081c0bc474aec180ee33e7f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:28:05 +0000 Subject: [PATCH 27/81] fix(feishu): harden onboarding and webhook validation --- .../feishu/src/bot.checkBotMentioned.test.ts | 53 +++---- extensions/feishu/src/bot.test.ts | 66 +++------ extensions/feishu/src/config-schema.test.ts | 22 +++ extensions/feishu/src/config-schema.ts | 68 ++++----- extensions/feishu/src/media.test.ts | 26 ++-- .../src/monitor.webhook-security.test.ts | 139 ++++++++++-------- extensions/feishu/src/onboarding.ts | 64 ++++---- 7 files changed, 202 insertions(+), 236 deletions(-) diff --git a/extensions/feishu/src/bot.checkBotMentioned.test.ts b/extensions/feishu/src/bot.checkBotMentioned.test.ts index a6233e05350..c88b32925e1 100644 --- a/extensions/feishu/src/bot.checkBotMentioned.test.ts +++ b/extensions/feishu/src/bot.checkBotMentioned.test.ts @@ -22,6 +22,20 @@ function makeEvent( }; } +function makePostEvent(content: unknown) { + return { + sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } }, + message: { + message_id: "msg_1", + chat_id: "oc_chat1", + chat_type: "group", + message_type: "post", + content: JSON.stringify(content), + mentions: [], + }, + }; +} + describe("parseFeishuMessageEvent – mentionedBot", () => { const BOT_OPEN_ID = "ou_bot_123"; @@ -85,64 +99,31 @@ describe("parseFeishuMessageEvent – mentionedBot", () => { it("returns mentionedBot=true for post message with at (no top-level mentions)", () => { const BOT_OPEN_ID = "ou_bot_123"; - const postContent = JSON.stringify({ + const event = makePostEvent({ content: [ [{ tag: "at", user_id: BOT_OPEN_ID, user_name: "claw" }], [{ tag: "text", text: "What does this document say" }], ], }); - const event = { - sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } }, - message: { - message_id: "msg_1", - chat_id: "oc_chat1", - chat_type: "group", - message_type: "post", - content: postContent, - mentions: [], - }, - }; const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID); expect(ctx.mentionedBot).toBe(true); }); it("returns mentionedBot=false for post message with no at", () => { - const postContent = JSON.stringify({ + const event = makePostEvent({ content: [[{ tag: "text", text: "hello" }]], }); - const event = { - sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } }, - message: { - message_id: "msg_1", - chat_id: "oc_chat1", - chat_type: "group", - message_type: "post", - content: postContent, - mentions: [], - }, - }; const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123"); expect(ctx.mentionedBot).toBe(false); }); it("returns mentionedBot=false for post message with at for another user", () => { - const postContent = JSON.stringify({ + const event = makePostEvent({ content: [ [{ tag: "at", user_id: "ou_other", user_name: "other" }], [{ tag: "text", text: "hello" }], ], }); - const event = { - sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } }, - message: { - message_id: "msg_1", - chat_id: "oc_chat1", - chat_type: "group", - message_type: "post", - content: postContent, - mentions: [], - }, - }; const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123"); expect(ctx.mentionedBot).toBe(false); }); diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index b9cd691cbb2..0daebe19d04 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -25,6 +25,24 @@ vi.mock("./send.js", () => ({ getMessageFeishu: mockGetMessageFeishu, })); +function createRuntimeEnv(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + } as RuntimeEnv; +} + +async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) { + await handleFeishuMessage({ + cfg: params.cfg, + event: params.event, + runtime: createRuntimeEnv(), + }); +} + describe("handleFeishuMessage command authorization", () => { const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx); const mockDispatchReplyFromConfig = vi @@ -96,17 +114,7 @@ describe("handleFeishuMessage command authorization", () => { }, }; - await handleFeishuMessage({ - cfg, - event, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv, - }); + await dispatchMessage({ cfg, event }); expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({ useAccessGroups: true, @@ -151,17 +159,7 @@ describe("handleFeishuMessage command authorization", () => { }, }; - await handleFeishuMessage({ - cfg, - event, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv, - }); + await dispatchMessage({ cfg, event }); expect(mockReadAllowFromStore).toHaveBeenCalledWith("feishu"); expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled(); @@ -198,17 +196,7 @@ describe("handleFeishuMessage command authorization", () => { }, }; - await handleFeishuMessage({ - cfg, - event, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv, - }); + await dispatchMessage({ cfg, event }); expect(mockUpsertPairingRequest).toHaveBeenCalledWith({ channel: "feishu", @@ -262,17 +250,7 @@ describe("handleFeishuMessage command authorization", () => { }, }; - await handleFeishuMessage({ - cfg, - event, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv, - }); + await dispatchMessage({ cfg, event }); expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({ useAccessGroups: true, diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts index 942d0c8853c..64a278c4afe 100644 --- a/extensions/feishu/src/config-schema.test.ts +++ b/extensions/feishu/src/config-schema.test.ts @@ -2,6 +2,28 @@ import { describe, expect, it } from "vitest"; import { FeishuConfigSchema } from "./config-schema.js"; describe("FeishuConfigSchema webhook validation", () => { + it("applies top-level defaults", () => { + const result = FeishuConfigSchema.parse({}); + expect(result.domain).toBe("feishu"); + expect(result.connectionMode).toBe("websocket"); + expect(result.webhookPath).toBe("/feishu/events"); + expect(result.dmPolicy).toBe("pairing"); + expect(result.groupPolicy).toBe("allowlist"); + expect(result.requireMention).toBe(true); + }); + + it("does not force top-level policy defaults into account config", () => { + const result = FeishuConfigSchema.parse({ + accounts: { + main: {}, + }, + }); + + expect(result.accounts?.main?.dmPolicy).toBeUndefined(); + expect(result.accounts?.main?.groupPolicy).toBeUndefined(); + expect(result.accounts?.main?.requireMention).toBeUndefined(); + }); + it("rejects top-level webhook mode without verificationToken", () => { const result = FeishuConfigSchema.safeParse({ connectionMode: "webhook", diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index b1e9fa24879..f5b08e13ee7 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -112,6 +112,31 @@ export const FeishuGroupSchema = z }) .strict(); +const FeishuSharedConfigShape = { + webhookHost: z.string().optional(), + webhookPort: z.number().int().positive().optional(), + capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, + configWrites: z.boolean().optional(), + dmPolicy: DmPolicySchema.optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + requireMention: z.boolean().optional(), + groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema).optional(), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema, + mediaMaxMb: z.number().positive().optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, + renderMode: RenderModeSchema, + streaming: StreamingModeSchema, + tools: FeishuToolsConfigSchema, +}; + /** * Per-account configuration. * All fields are optional - missing fields inherit from top-level config. @@ -127,28 +152,7 @@ export const FeishuAccountConfigSchema = z domain: FeishuDomainSchema.optional(), connectionMode: FeishuConnectionModeSchema.optional(), webhookPath: z.string().optional(), - webhookHost: z.string().optional(), - webhookPort: z.number().int().positive().optional(), - capabilities: z.array(z.string()).optional(), - markdown: MarkdownConfigSchema, - configWrites: z.boolean().optional(), - dmPolicy: DmPolicySchema.optional(), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - groupPolicy: GroupPolicySchema.optional(), - groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), - requireMention: z.boolean().optional(), - groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema, - mediaMaxMb: z.number().positive().optional(), - heartbeat: ChannelHeartbeatVisibilitySchema, - renderMode: RenderModeSchema, - streaming: StreamingModeSchema, // Enable streaming card mode (default: true) - tools: FeishuToolsConfigSchema, + ...FeishuSharedConfigShape, }) .strict(); @@ -163,29 +167,11 @@ export const FeishuConfigSchema = z domain: FeishuDomainSchema.optional().default("feishu"), connectionMode: FeishuConnectionModeSchema.optional().default("websocket"), webhookPath: z.string().optional().default("/feishu/events"), - webhookHost: z.string().optional(), - webhookPort: z.number().int().positive().optional(), - capabilities: z.array(z.string()).optional(), - markdown: MarkdownConfigSchema, - configWrites: z.boolean().optional(), + ...FeishuSharedConfigShape, dmPolicy: DmPolicySchema.optional().default("pairing"), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), - groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), requireMention: z.boolean().optional().default(true), - groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), topicSessionMode: TopicSessionModeSchema, - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema, - mediaMaxMb: z.number().positive().optional(), - heartbeat: ChannelHeartbeatVisibilitySchema, - renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown - streaming: StreamingModeSchema, // Enable streaming card mode (default: true) - tools: FeishuToolsConfigSchema, // Dynamic agent creation for DM users dynamicAgentCreation: DynamicAgentCreationSchema, // Multi-account configuration diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index b9e97703a1b..5851e849037 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -38,6 +38,16 @@ vi.mock("./runtime.js", () => ({ import { downloadImageFeishu, downloadMessageResourceFeishu, sendMediaFeishu } from "./media.js"; +function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void { + expect(pathValue).not.toContain(key); + expect(pathValue).not.toContain(".."); + + const tmpRoot = path.resolve(os.tmpdir()); + const resolved = path.resolve(pathValue); + const rel = path.relative(tmpRoot, resolved); + expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); +} + describe("sendMediaFeishu msg_type routing", () => { beforeEach(() => { vi.clearAllMocks(); @@ -217,13 +227,7 @@ describe("sendMediaFeishu msg_type routing", () => { expect(result.buffer).toEqual(Buffer.from("image-data")); expect(capturedPath).toBeDefined(); - expect(capturedPath).not.toContain(imageKey); - expect(capturedPath).not.toContain(".."); - - const tmpRoot = path.resolve(os.tmpdir()); - const resolved = path.resolve(capturedPath as string); - const rel = path.relative(tmpRoot, resolved); - expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); + expectPathIsolatedToTmpRoot(capturedPath as string, imageKey); }); it("uses isolated temp paths for message resource downloads", async () => { @@ -246,13 +250,7 @@ describe("sendMediaFeishu msg_type routing", () => { expect(result.buffer).toEqual(Buffer.from("resource-data")); expect(capturedPath).toBeDefined(); - expect(capturedPath).not.toContain(fileKey); - expect(capturedPath).not.toContain(".."); - - const tmpRoot = path.resolve(os.tmpdir()); - const resolved = path.resolve(capturedPath as string); - const rel = path.relative(tmpRoot, resolved); - expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); + expectPathIsolatedToTmpRoot(capturedPath as string, fileKey); }); it("rejects invalid image keys before calling feishu api", async () => { diff --git a/extensions/feishu/src/monitor.webhook-security.test.ts b/extensions/feishu/src/monitor.webhook-security.test.ts index b304ee6ed40..97637e75efe 100644 --- a/extensions/feishu/src/monitor.webhook-security.test.ts +++ b/extensions/feishu/src/monitor.webhook-security.test.ts @@ -78,6 +78,41 @@ function buildConfig(params: { } as ClawdbotConfig; } +async function withRunningWebhookMonitor( + params: { + accountId: string; + path: string; + verificationToken: string; + }, + run: (url: string) => Promise, +) { + const port = await getFreePort(); + const cfg = buildConfig({ + accountId: params.accountId, + path: params.path, + port, + verificationToken: params.verificationToken, + }); + + const abortController = new AbortController(); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const monitorPromise = monitorFeishuProvider({ + config: cfg, + runtime, + abortSignal: abortController.signal, + }); + + const url = `http://127.0.0.1:${port}${params.path}`; + await waitUntilServerReady(url); + + try { + await run(url); + } finally { + abortController.abort(); + await monitorPromise; + } +} + afterEach(() => { stopFeishuMonitor(); }); @@ -99,76 +134,50 @@ describe("Feishu webhook security hardening", () => { it("returns 415 for POST requests without json content type", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); - const port = await getFreePort(); - const path = "/hook-content-type"; - const cfg = buildConfig({ - accountId: "content-type", - path, - port, - verificationToken: "verify_token", - }); + await withRunningWebhookMonitor( + { + accountId: "content-type", + path: "/hook-content-type", + verificationToken: "verify_token", + }, + async (url) => { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "{}", + }); - const abortController = new AbortController(); - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; - const monitorPromise = monitorFeishuProvider({ - config: cfg, - runtime, - abortSignal: abortController.signal, - }); - - await waitUntilServerReady(`http://127.0.0.1:${port}${path}`); - - const response = await fetch(`http://127.0.0.1:${port}${path}`, { - method: "POST", - headers: { "content-type": "text/plain" }, - body: "{}", - }); - - expect(response.status).toBe(415); - expect(await response.text()).toBe("Unsupported Media Type"); - - abortController.abort(); - await monitorPromise; + expect(response.status).toBe(415); + expect(await response.text()).toBe("Unsupported Media Type"); + }, + ); }); it("rate limits webhook burst traffic with 429", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); - const port = await getFreePort(); - const path = "/hook-rate-limit"; - const cfg = buildConfig({ - accountId: "rate-limit", - path, - port, - verificationToken: "verify_token", - }); + await withRunningWebhookMonitor( + { + accountId: "rate-limit", + path: "/hook-rate-limit", + verificationToken: "verify_token", + }, + async (url) => { + let saw429 = false; + for (let i = 0; i < 130; i += 1) { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "{}", + }); + if (response.status === 429) { + saw429 = true; + expect(await response.text()).toBe("Too Many Requests"); + break; + } + } - const abortController = new AbortController(); - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; - const monitorPromise = monitorFeishuProvider({ - config: cfg, - runtime, - abortSignal: abortController.signal, - }); - - await waitUntilServerReady(`http://127.0.0.1:${port}${path}`); - - let saw429 = false; - for (let i = 0; i < 130; i += 1) { - const response = await fetch(`http://127.0.0.1:${port}${path}`, { - method: "POST", - headers: { "content-type": "text/plain" }, - body: "{}", - }); - if (response.status === 429) { - saw429 = true; - expect(await response.text()).toBe("Too Many Requests"); - break; - } - } - - expect(saw429).toBe(true); - - abortController.abort(); - await monitorPromise; + expect(saw429).toBe(true); + }, + ); }); }); diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts index a2cf02dd241..bb847ebabbe 100644 --- a/extensions/feishu/src/onboarding.ts +++ b/extensions/feishu/src/onboarding.ts @@ -104,6 +104,25 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise ); } +async function promptFeishuCredentials(prompter: WizardPrompter): Promise<{ + appId: string; + appSecret: string; +}> { + const appId = String( + await prompter.text({ + message: "Enter Feishu App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + const appSecret = String( + await prompter.text({ + message: "Enter Feishu App Secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + return { appId, appSecret }; +} + function setFeishuGroupPolicy( cfg: ClawdbotConfig, groupPolicy: "open" | "allowlist" | "disabled", @@ -210,18 +229,9 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }, }; } else { - appId = String( - await prompter.text({ - message: "Enter Feishu App ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appSecret = String( - await prompter.text({ - message: "Enter Feishu App Secret", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptFeishuCredentials(prompter); + appId = entered.appId; + appSecret = entered.appSecret; } } else if (hasConfigCreds) { const keep = await prompter.confirm({ @@ -229,32 +239,14 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (!keep) { - appId = String( - await prompter.text({ - message: "Enter Feishu App ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appSecret = String( - await prompter.text({ - message: "Enter Feishu App Secret", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptFeishuCredentials(prompter); + appId = entered.appId; + appSecret = entered.appSecret; } } else { - appId = String( - await prompter.text({ - message: "Enter Feishu App ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appSecret = String( - await prompter.text({ - message: "Enter Feishu App Secret", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptFeishuCredentials(prompter); + appId = entered.appId; + appSecret = entered.appSecret; } if (appId && appSecret) { From 0a421d7409bed59d8f2fabd05c40a0424622069b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:28:09 +0000 Subject: [PATCH 28/81] test(line): improve logout scenario coverage --- extensions/line/src/channel.logout.test.ts | 106 ++++++++++++--------- 1 file changed, 62 insertions(+), 44 deletions(-) diff --git a/extensions/line/src/channel.logout.test.ts b/extensions/line/src/channel.logout.test.ts index dbceacee7d9..c2864ec70c0 100644 --- a/extensions/line/src/channel.logout.test.ts +++ b/extensions/line/src/channel.logout.test.ts @@ -47,15 +47,50 @@ function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } { return { runtime, mocks: { writeConfigFile, resolveLineAccount } }; } +function createRuntimeEnv(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + }; +} + +function resolveAccount( + resolveLineAccount: LineRuntimeMocks["resolveLineAccount"], + cfg: OpenClawConfig, + accountId: string, +): ResolvedLineAccount { + const resolver = resolveLineAccount as unknown as (params: { + cfg: OpenClawConfig; + accountId?: string; + }) => ResolvedLineAccount; + return resolver({ cfg, accountId }); +} + +async function runLogoutScenario(params: { cfg: OpenClawConfig; accountId: string }): Promise<{ + result: Awaited["logoutAccount"]>>>; + mocks: LineRuntimeMocks; +}> { + const { runtime, mocks } = createRuntime(); + setLineRuntime(runtime); + const account = resolveAccount(mocks.resolveLineAccount, params.cfg, params.accountId); + const result = await linePlugin.gateway!.logoutAccount!({ + accountId: params.accountId, + cfg: params.cfg, + account, + runtime: createRuntimeEnv(), + }); + return { result, mocks }; +} + describe("linePlugin gateway.logoutAccount", () => { beforeEach(() => { setLineRuntime(createRuntime().runtime); }); it("clears tokenFile/secretFile on default account logout", async () => { - const { runtime, mocks } = createRuntime(); - setLineRuntime(runtime); - const cfg: OpenClawConfig = { channels: { line: { @@ -64,38 +99,17 @@ describe("linePlugin gateway.logoutAccount", () => { }, }, }; - const runtimeEnv: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; - const resolveAccount = mocks.resolveLineAccount as unknown as (params: { - cfg: OpenClawConfig; - accountId?: string; - }) => ResolvedLineAccount; - const account = resolveAccount({ + const { result, mocks } = await runLogoutScenario({ cfg, accountId: DEFAULT_ACCOUNT_ID, }); - const result = await linePlugin.gateway!.logoutAccount!({ - accountId: DEFAULT_ACCOUNT_ID, - cfg, - account, - runtime: runtimeEnv, - }); - expect(result.cleared).toBe(true); expect(result.loggedOut).toBe(true); expect(mocks.writeConfigFile).toHaveBeenCalledWith({}); }); it("clears tokenFile/secretFile on account logout", async () => { - const { runtime, mocks } = createRuntime(); - setLineRuntime(runtime); - const cfg: OpenClawConfig = { channels: { line: { @@ -108,31 +122,35 @@ describe("linePlugin gateway.logoutAccount", () => { }, }, }; - const runtimeEnv: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; - const resolveAccount = mocks.resolveLineAccount as unknown as (params: { - cfg: OpenClawConfig; - accountId?: string; - }) => ResolvedLineAccount; - const account = resolveAccount({ + const { result, mocks } = await runLogoutScenario({ cfg, accountId: "primary", }); - const result = await linePlugin.gateway!.logoutAccount!({ - accountId: "primary", - cfg, - account, - runtime: runtimeEnv, - }); - expect(result.cleared).toBe(true); expect(result.loggedOut).toBe(true); expect(mocks.writeConfigFile).toHaveBeenCalledWith({}); }); + + it("does not write config when account has no token/secret fields", async () => { + const cfg: OpenClawConfig = { + channels: { + line: { + accounts: { + primary: { + name: "Primary", + }, + }, + }, + }, + }; + const { result, mocks } = await runLogoutScenario({ + cfg, + accountId: "primary", + }); + + expect(result.cleared).toBe(false); + expect(result.loggedOut).toBe(true); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); }); From e80c66a571625ac097779d7b228619056cce9fa7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:28:14 +0000 Subject: [PATCH 29/81] fix(mattermost): refine probe and onboarding flows --- extensions/mattermost/src/channel.test.ts | 72 +++++++------- .../mattermost/src/mattermost/client.ts | 2 +- .../mattermost/src/mattermost/probe.test.ts | 97 +++++++++++++++++++ extensions/mattermost/src/mattermost/probe.ts | 14 +-- extensions/mattermost/src/onboarding.ts | 64 ++++++------ 5 files changed, 163 insertions(+), 86 deletions(-) create mode 100644 extensions/mattermost/src/mattermost/probe.test.ts diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index cd60f4fe65a..9cb5df2b846 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -54,6 +54,25 @@ describe("mattermostPlugin", () => { resetMattermostReactionBotUserCacheForTests(); }); + const runReactAction = async (params: Record, fetchMode: "add" | "remove") => { + const cfg = createMattermostTestConfig(); + const fetchImpl = createMattermostReactionFetchMock({ + mode: fetchMode, + postId: "POST1", + emojiName: "thumbsup", + }); + + return await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => { + return await mattermostPlugin.actions?.handleAction?.({ + channel: "mattermost", + action: "react", + params, + cfg, + accountId: "default", + } as any); + }); + }; + it("exposes react when mattermost is configured", () => { const cfg: OpenClawConfig = { channels: { @@ -152,51 +171,32 @@ describe("mattermostPlugin", () => { }); it("handles react by calling Mattermost reactions API", async () => { - const cfg = createMattermostTestConfig(); - const fetchImpl = createMattermostReactionFetchMock({ - mode: "add", - postId: "POST1", - emojiName: "thumbsup", - }); - - const result = await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => { - const result = await mattermostPlugin.actions?.handleAction?.({ - channel: "mattermost", - action: "react", - params: { messageId: "POST1", emoji: "thumbsup" }, - cfg, - accountId: "default", - } as any); - - return result; - }); + const result = await runReactAction({ messageId: "POST1", emoji: "thumbsup" }, "add"); expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]); expect(result?.details).toEqual({}); }); it("only treats boolean remove flag as removal", async () => { - const cfg = createMattermostTestConfig(); - const fetchImpl = createMattermostReactionFetchMock({ - mode: "add", - postId: "POST1", - emojiName: "thumbsup", - }); - - const result = await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => { - const result = await mattermostPlugin.actions?.handleAction?.({ - channel: "mattermost", - action: "react", - params: { messageId: "POST1", emoji: "thumbsup", remove: "true" }, - cfg, - accountId: "default", - } as any); - - return result; - }); + const result = await runReactAction( + { messageId: "POST1", emoji: "thumbsup", remove: "true" }, + "add", + ); expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]); }); + + it("removes reaction when remove flag is boolean true", async () => { + const result = await runReactAction( + { messageId: "POST1", emoji: "thumbsup", remove: true }, + "remove", + ); + + expect(result?.content).toEqual([ + { type: "text", text: "Removed reaction :thumbsup: from POST1" }, + ]); + expect(result?.details).toEqual({}); + }); }); describe("config", () => { diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts index f0a0fd26adc..826212c9eb8 100644 --- a/extensions/mattermost/src/mattermost/client.ts +++ b/extensions/mattermost/src/mattermost/client.ts @@ -58,7 +58,7 @@ function buildMattermostApiUrl(baseUrl: string, path: string): string { return `${normalized}/api/v4${suffix}`; } -async function readMattermostError(res: Response): Promise { +export async function readMattermostError(res: Response): Promise { const contentType = res.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { const data = (await res.json()) as { message?: string } | undefined; diff --git a/extensions/mattermost/src/mattermost/probe.test.ts b/extensions/mattermost/src/mattermost/probe.test.ts new file mode 100644 index 00000000000..887ac576a85 --- /dev/null +++ b/extensions/mattermost/src/mattermost/probe.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { probeMattermost } from "./probe.js"; + +const mockFetch = vi.fn(); + +describe("probeMattermost", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns baseUrl missing for empty base URL", async () => { + await expect(probeMattermost(" ", "token")).resolves.toEqual({ + ok: false, + error: "baseUrl missing", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("normalizes base URL and returns bot info", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ id: "bot-1", username: "clawbot" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const result = await probeMattermost("https://mm.example.com/api/v4/", "bot-token"); + + expect(mockFetch).toHaveBeenCalledWith( + "https://mm.example.com/api/v4/users/me", + expect.objectContaining({ + headers: { Authorization: "Bearer bot-token" }, + }), + ); + expect(result).toEqual( + expect.objectContaining({ + ok: true, + status: 200, + bot: { id: "bot-1", username: "clawbot" }, + }), + ); + expect(result.elapsedMs).toBeGreaterThanOrEqual(0); + }); + + it("returns API error details from JSON response", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ message: "invalid auth token" }), { + status: 401, + statusText: "Unauthorized", + headers: { "content-type": "application/json" }, + }), + ); + + await expect(probeMattermost("https://mm.example.com", "bad-token")).resolves.toEqual( + expect.objectContaining({ + ok: false, + status: 401, + error: "invalid auth token", + }), + ); + }); + + it("falls back to statusText when error body is empty", async () => { + mockFetch.mockResolvedValueOnce( + new Response("", { + status: 403, + statusText: "Forbidden", + headers: { "content-type": "text/plain" }, + }), + ); + + await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual( + expect.objectContaining({ + ok: false, + status: 403, + error: "Forbidden", + }), + ); + }); + + it("returns fetch error when request throws", async () => { + mockFetch.mockRejectedValueOnce(new Error("network down")); + + await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual( + expect.objectContaining({ + ok: false, + status: null, + error: "network down", + }), + ); + }); +}); diff --git a/extensions/mattermost/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts index cb468ec14db..eda98b21c0e 100644 --- a/extensions/mattermost/src/mattermost/probe.ts +++ b/extensions/mattermost/src/mattermost/probe.ts @@ -1,5 +1,5 @@ import type { BaseProbeResult } from "openclaw/plugin-sdk"; -import { normalizeMattermostBaseUrl, type MattermostUser } from "./client.js"; +import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js"; export type MattermostProbe = BaseProbeResult & { status?: number | null; @@ -7,18 +7,6 @@ export type MattermostProbe = BaseProbeResult & { bot?: MattermostUser; }; -async function readMattermostError(res: Response): Promise { - const contentType = res.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { - const data = (await res.json()) as { message?: string } | undefined; - if (data?.message) { - return data.message; - } - return JSON.stringify(data); - } - return await res.text(); -} - export async function probeMattermost( baseUrl: string, botToken: string, diff --git a/extensions/mattermost/src/onboarding.ts b/extensions/mattermost/src/onboarding.ts index 9f90f1f2ab8..358d3f43f7f 100644 --- a/extensions/mattermost/src/onboarding.ts +++ b/extensions/mattermost/src/onboarding.ts @@ -22,6 +22,25 @@ async function noteMattermostSetup(prompter: WizardPrompter): Promise { ); } +async function promptMattermostCredentials(prompter: WizardPrompter): Promise<{ + botToken: string; + baseUrl: string; +}> { + const botToken = String( + await prompter.text({ + message: "Enter Mattermost bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + const baseUrl = String( + await prompter.text({ + message: "Enter Mattermost base URL", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + return { botToken, baseUrl }; +} + export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = { channel, getStatus: async ({ cfg }) => { @@ -90,18 +109,9 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = { }, }; } else { - botToken = String( - await prompter.text({ - message: "Enter Mattermost bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - baseUrl = String( - await prompter.text({ - message: "Enter Mattermost base URL", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptMattermostCredentials(prompter); + botToken = entered.botToken; + baseUrl = entered.baseUrl; } } else if (accountConfigured) { const keep = await prompter.confirm({ @@ -109,32 +119,14 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (!keep) { - botToken = String( - await prompter.text({ - message: "Enter Mattermost bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - baseUrl = String( - await prompter.text({ - message: "Enter Mattermost base URL", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptMattermostCredentials(prompter); + botToken = entered.botToken; + baseUrl = entered.baseUrl; } } else { - botToken = String( - await prompter.text({ - message: "Enter Mattermost bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - baseUrl = String( - await prompter.text({ - message: "Enter Mattermost base URL", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptMattermostCredentials(prompter); + botToken = entered.botToken; + baseUrl = entered.baseUrl; } if (botToken || baseUrl) { From 8c1afc4b63fe1c22a19cd080f5b817cc19df3025 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:28:19 +0000 Subject: [PATCH 30/81] fix(msteams): improve graph user and token parsing --- extensions/msteams/src/directory-live.ts | 22 +------ extensions/msteams/src/graph-users.test.ts | 66 +++++++++++++++++++ extensions/msteams/src/graph-users.ts | 29 ++++++++ extensions/msteams/src/graph.ts | 13 +--- extensions/msteams/src/messenger.ts | 31 +++------ extensions/msteams/src/probe.ts | 13 +--- extensions/msteams/src/resolve-allowlist.ts | 22 +------ extensions/msteams/src/token-response.test.ts | 23 +++++++ extensions/msteams/src/token-response.ts | 11 ++++ 9 files changed, 145 insertions(+), 85 deletions(-) create mode 100644 extensions/msteams/src/graph-users.test.ts create mode 100644 extensions/msteams/src/graph-users.ts create mode 100644 extensions/msteams/src/token-response.test.ts create mode 100644 extensions/msteams/src/token-response.ts diff --git a/extensions/msteams/src/directory-live.ts b/extensions/msteams/src/directory-live.ts index 8163cab4940..06b2485eb3b 100644 --- a/extensions/msteams/src/directory-live.ts +++ b/extensions/msteams/src/directory-live.ts @@ -1,11 +1,8 @@ import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk"; +import { searchGraphUsers } from "./graph-users.js"; import { - escapeOData, - fetchGraphJson, type GraphChannel, type GraphGroup, - type GraphResponse, - type GraphUser, listChannelsForTeam, listTeamsByName, normalizeQuery, @@ -24,22 +21,7 @@ export async function listMSTeamsDirectoryPeersLive(params: { const token = await resolveGraphToken(params.cfg); const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20; - let users: GraphUser[] = []; - if (query.includes("@")) { - const escaped = escapeOData(query); - const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`; - const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`; - const res = await fetchGraphJson>({ token, path }); - users = res.value ?? []; - } else { - const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${limit}`; - const res = await fetchGraphJson>({ - token, - path, - headers: { ConsistencyLevel: "eventual" }, - }); - users = res.value ?? []; - } + const users = await searchGraphUsers({ token, query, top: limit }); return users .map((user) => { diff --git a/extensions/msteams/src/graph-users.test.ts b/extensions/msteams/src/graph-users.test.ts new file mode 100644 index 00000000000..8b5f2b52dd0 --- /dev/null +++ b/extensions/msteams/src/graph-users.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { searchGraphUsers } from "./graph-users.js"; +import { fetchGraphJson } from "./graph.js"; + +vi.mock("./graph.js", () => ({ + escapeOData: vi.fn((value: string) => value.replace(/'/g, "''")), + fetchGraphJson: vi.fn(), +})); + +describe("searchGraphUsers", () => { + beforeEach(() => { + vi.mocked(fetchGraphJson).mockReset(); + }); + + it("returns empty array for blank queries", async () => { + await expect(searchGraphUsers({ token: "token-1", query: " " })).resolves.toEqual([]); + expect(fetchGraphJson).not.toHaveBeenCalled(); + }); + + it("uses exact mail/upn filter lookup for email-like queries", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({ + value: [{ id: "user-1", displayName: "User One" }], + } as never); + + const result = await searchGraphUsers({ + token: "token-2", + query: "alice.o'hara@example.com", + }); + + expect(fetchGraphJson).toHaveBeenCalledWith({ + token: "token-2", + path: "/users?$filter=(mail%20eq%20'alice.o''hara%40example.com'%20or%20userPrincipalName%20eq%20'alice.o''hara%40example.com')&$select=id,displayName,mail,userPrincipalName", + }); + expect(result).toEqual([{ id: "user-1", displayName: "User One" }]); + }); + + it("uses displayName search with eventual consistency and custom top", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({ + value: [{ id: "user-2", displayName: "Bob" }], + } as never); + + const result = await searchGraphUsers({ + token: "token-3", + query: "bob", + top: 25, + }); + + expect(fetchGraphJson).toHaveBeenCalledWith({ + token: "token-3", + path: "/users?$search=%22displayName%3Abob%22&$select=id,displayName,mail,userPrincipalName&$top=25", + headers: { ConsistencyLevel: "eventual" }, + }); + expect(result).toEqual([{ id: "user-2", displayName: "Bob" }]); + }); + + it("falls back to default top and empty value handling", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never); + + await expect(searchGraphUsers({ token: "token-4", query: "carol" })).resolves.toEqual([]); + expect(fetchGraphJson).toHaveBeenCalledWith({ + token: "token-4", + path: "/users?$search=%22displayName%3Acarol%22&$select=id,displayName,mail,userPrincipalName&$top=10", + headers: { ConsistencyLevel: "eventual" }, + }); + }); +}); diff --git a/extensions/msteams/src/graph-users.ts b/extensions/msteams/src/graph-users.ts new file mode 100644 index 00000000000..965e83296ff --- /dev/null +++ b/extensions/msteams/src/graph-users.ts @@ -0,0 +1,29 @@ +import { escapeOData, fetchGraphJson, type GraphResponse, type GraphUser } from "./graph.js"; + +export async function searchGraphUsers(params: { + token: string; + query: string; + top?: number; +}): Promise { + const query = params.query.trim(); + if (!query) { + return []; + } + + if (query.includes("@")) { + const escaped = escapeOData(query); + const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`; + const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`; + const res = await fetchGraphJson>({ token: params.token, path }); + return res.value ?? []; + } + + const top = typeof params.top === "number" && params.top > 0 ? params.top : 10; + const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${top}`; + const res = await fetchGraphJson>({ + token: params.token, + path, + headers: { ConsistencyLevel: "eventual" }, + }); + return res.value ?? []; +} diff --git a/extensions/msteams/src/graph.ts b/extensions/msteams/src/graph.ts index 943e32ef474..d2c21015361 100644 --- a/extensions/msteams/src/graph.ts +++ b/extensions/msteams/src/graph.ts @@ -1,6 +1,7 @@ import type { MSTeamsConfig } from "openclaw/plugin-sdk"; import { GRAPH_ROOT } from "./attachments/shared.js"; import { loadMSTeamsSdkWithAuth } from "./sdk.js"; +import { readAccessToken } from "./token-response.js"; import { resolveMSTeamsCredentials } from "./token.js"; export type GraphUser = { @@ -22,18 +23,6 @@ export type GraphChannel = { export type GraphResponse = { value?: T[] }; -function readAccessToken(value: unknown): string | null { - if (typeof value === "string") { - return value; - } - if (value && typeof value === "object") { - const token = - (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token; - return typeof token === "string" ? token : null; - } - return null; -} - export function normalizeQuery(value?: string | null): string { return value?.trim() ?? ""; } diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index 1ee0cae68e4..d4de764ea60 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -441,11 +441,7 @@ export async function sendMSTeamsMessages(params: { } }; - if (params.replyStyle === "thread") { - const ctx = params.context; - if (!ctx) { - throw new Error("Missing context for replyStyle=thread"); - } + const sendMessagesInContext = async (ctx: SendContext): Promise => { const messageIds: string[] = []; for (const [idx, message] of messages.entries()) { const response = await sendWithRetry( @@ -464,6 +460,14 @@ export async function sendMSTeamsMessages(params: { messageIds.push(extractMessageId(response) ?? "unknown"); } return messageIds; + }; + + if (params.replyStyle === "thread") { + const ctx = params.context; + if (!ctx) { + throw new Error("Missing context for replyStyle=thread"); + } + return await sendMessagesInContext(ctx); } const baseRef = buildConversationReference(params.conversationRef); @@ -474,22 +478,7 @@ export async function sendMSTeamsMessages(params: { const messageIds: string[] = []; await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => { - for (const [idx, message] of messages.entries()) { - const response = await sendWithRetry( - async () => - await ctx.sendActivity( - await buildActivity( - message, - params.conversationRef, - params.tokenProvider, - params.sharePointSiteId, - params.mediaMaxBytes, - ), - ), - { messageIndex: idx, messageCount: messages.length }, - ); - messageIds.push(extractMessageId(response) ?? "unknown"); - } + messageIds.push(...(await sendMessagesInContext(ctx))); }); return messageIds; } diff --git a/extensions/msteams/src/probe.ts b/extensions/msteams/src/probe.ts index b6732c658c4..8434fa50416 100644 --- a/extensions/msteams/src/probe.ts +++ b/extensions/msteams/src/probe.ts @@ -1,6 +1,7 @@ import type { BaseProbeResult, MSTeamsConfig } from "openclaw/plugin-sdk"; import { formatUnknownError } from "./errors.js"; import { loadMSTeamsSdkWithAuth } from "./sdk.js"; +import { readAccessToken } from "./token-response.js"; import { resolveMSTeamsCredentials } from "./token.js"; export type ProbeMSTeamsResult = BaseProbeResult & { @@ -13,18 +14,6 @@ export type ProbeMSTeamsResult = BaseProbeResult & { }; }; -function readAccessToken(value: unknown): string | null { - if (typeof value === "string") { - return value; - } - if (value && typeof value === "object") { - const token = - (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token; - return typeof token === "string" ? token : null; - } - return null; -} - function decodeJwtPayload(token: string): Record | null { const parts = token.split("."); if (parts.length < 2) { diff --git a/extensions/msteams/src/resolve-allowlist.ts b/extensions/msteams/src/resolve-allowlist.ts index d87bea302e9..1e66c4972df 100644 --- a/extensions/msteams/src/resolve-allowlist.ts +++ b/extensions/msteams/src/resolve-allowlist.ts @@ -1,8 +1,5 @@ +import { searchGraphUsers } from "./graph-users.js"; import { - escapeOData, - fetchGraphJson, - type GraphResponse, - type GraphUser, listChannelsForTeam, listTeamsByName, normalizeQuery, @@ -182,22 +179,7 @@ export async function resolveMSTeamsUserAllowlist(params: { results.push({ input, resolved: true, id: query }); continue; } - let users: GraphUser[] = []; - if (query.includes("@")) { - const escaped = escapeOData(query); - const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`; - const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`; - const res = await fetchGraphJson>({ token, path }); - users = res.value ?? []; - } else { - const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=10`; - const res = await fetchGraphJson>({ - token, - path, - headers: { ConsistencyLevel: "eventual" }, - }); - users = res.value ?? []; - } + const users = await searchGraphUsers({ token, query, top: 10 }); const match = users[0]; if (!match?.id) { results.push({ input, resolved: false }); diff --git a/extensions/msteams/src/token-response.test.ts b/extensions/msteams/src/token-response.test.ts new file mode 100644 index 00000000000..2deddfbc736 --- /dev/null +++ b/extensions/msteams/src/token-response.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { readAccessToken } from "./token-response.js"; + +describe("readAccessToken", () => { + it("returns raw string token values", () => { + expect(readAccessToken("abc")).toBe("abc"); + }); + + it("returns accessToken from object value", () => { + expect(readAccessToken({ accessToken: "access-token" })).toBe("access-token"); + }); + + it("returns token fallback from object value", () => { + expect(readAccessToken({ token: "fallback-token" })).toBe("fallback-token"); + }); + + it("returns null for unsupported values", () => { + expect(readAccessToken({ accessToken: 123 })).toBeNull(); + expect(readAccessToken({ token: false })).toBeNull(); + expect(readAccessToken(null)).toBeNull(); + expect(readAccessToken(undefined)).toBeNull(); + }); +}); diff --git a/extensions/msteams/src/token-response.ts b/extensions/msteams/src/token-response.ts new file mode 100644 index 00000000000..b08804b1c45 --- /dev/null +++ b/extensions/msteams/src/token-response.ts @@ -0,0 +1,11 @@ +export function readAccessToken(value: unknown): string | null { + if (typeof value === "string") { + return value; + } + if (value && typeof value === "object") { + const token = + (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token; + return typeof token === "string" ? token : null; + } + return null; +} From 081ab9c99ded2001c09d5035740bfa722bbaa6ce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:28:23 +0000 Subject: [PATCH 31/81] fix(voice-call): tighten manager outbound behavior --- extensions/voice-call/src/manager.test.ts | 177 +++++------------- .../voice-call/src/manager/events.test.ts | 90 ++++----- extensions/voice-call/src/manager/outbound.ts | 82 +++++--- 3 files changed, 139 insertions(+), 210 deletions(-) diff --git a/extensions/voice-call/src/manager.test.ts b/extensions/voice-call/src/manager.test.ts index 3d02cb323be..d92dbc11f85 100644 --- a/extensions/voice-call/src/manager.test.ts +++ b/extensions/voice-call/src/manager.test.ts @@ -46,17 +46,44 @@ class FakeProvider implements VoiceCallProvider { } } +let storeSeq = 0; + +function createTestStorePath(): string { + storeSeq += 1; + return path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}-${storeSeq}`); +} + +function createManagerHarness( + configOverrides: Record = {}, + provider = new FakeProvider(), +): { + manager: CallManager; + provider: FakeProvider; +} { + const config = VoiceCallConfigSchema.parse({ + enabled: true, + provider: "plivo", + fromNumber: "+15550000000", + ...configOverrides, + }); + const manager = new CallManager(config, createTestStorePath()); + manager.initialize(provider, "https://example.com/voice/webhook"); + return { manager, provider }; +} + +function markCallAnswered(manager: CallManager, callId: string, eventId: string): void { + manager.processEvent({ + id: eventId, + type: "call.answered", + callId, + providerCallId: "request-uuid", + timestamp: Date.now(), + }); +} + describe("CallManager", () => { it("upgrades providerCallId mapping when provider ID changes", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - }); - - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const manager = new CallManager(config, storePath); - manager.initialize(new FakeProvider(), "https://example.com/voice/webhook"); + const { manager } = createManagerHarness(); const { callId, success, error } = await manager.initiateCall("+15550000001"); expect(success).toBe(true); @@ -81,16 +108,7 @@ describe("CallManager", () => { }); it("speaks initial message on answered for notify mode (non-Twilio)", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - }); - - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); + const { manager, provider } = createManagerHarness(); const { callId, success } = await manager.initiateCall("+15550000002", undefined, { message: "Hello there", @@ -113,19 +131,11 @@ describe("CallManager", () => { }); it("rejects inbound calls with missing caller ID when allowlist enabled", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-allowlist-missing", type: "call.initiated", @@ -142,19 +152,11 @@ describe("CallManager", () => { }); it("rejects inbound calls with anonymous caller ID when allowlist enabled", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-allowlist-anon", type: "call.initiated", @@ -172,19 +174,11 @@ describe("CallManager", () => { }); it("rejects inbound calls that only match allowlist suffixes", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-allowlist-suffix", type: "call.initiated", @@ -202,18 +196,10 @@ describe("CallManager", () => { }); it("rejects duplicate inbound events with a single hangup call", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ inboundPolicy: "disabled", }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-reject-init", type: "call.initiated", @@ -242,18 +228,11 @@ describe("CallManager", () => { }); it("accepts inbound calls that exactly match the allowlist", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager } = createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const manager = new CallManager(config, storePath); - manager.initialize(new FakeProvider(), "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-allowlist-exact", type: "call.initiated", @@ -269,28 +248,14 @@ describe("CallManager", () => { }); it("completes a closed-loop turn without live audio", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - const started = await manager.initiateCall("+15550000003"); expect(started.success).toBe(true); - manager.processEvent({ - id: "evt-closed-loop-answered", - type: "call.answered", - callId: started.callId, - providerCallId: "request-uuid", - timestamp: Date.now(), - }); + markCallAnswered(manager, started.callId, "evt-closed-loop-answered"); const turnPromise = manager.continueCall(started.callId, "How can I help?"); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -323,28 +288,14 @@ describe("CallManager", () => { }); it("rejects overlapping continueCall requests for the same call", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - const started = await manager.initiateCall("+15550000004"); expect(started.success).toBe(true); - manager.processEvent({ - id: "evt-overlap-answered", - type: "call.answered", - callId: started.callId, - providerCallId: "request-uuid", - timestamp: Date.now(), - }); + markCallAnswered(manager, started.callId, "evt-overlap-answered"); const first = manager.continueCall(started.callId, "First prompt"); const second = await manager.continueCall(started.callId, "Second prompt"); @@ -369,28 +320,14 @@ describe("CallManager", () => { }); it("tracks latency metadata across multiple closed-loop turns", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - const started = await manager.initiateCall("+15550000005"); expect(started.success).toBe(true); - manager.processEvent({ - id: "evt-multi-answered", - type: "call.answered", - callId: started.callId, - providerCallId: "request-uuid", - timestamp: Date.now(), - }); + markCallAnswered(manager, started.callId, "evt-multi-answered"); const firstTurn = manager.continueCall(started.callId, "First question"); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -436,28 +373,14 @@ describe("CallManager", () => { }); it("handles repeated closed-loop turns without waiter churn", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - const started = await manager.initiateCall("+15550000006"); expect(started.success).toBe(true); - manager.processEvent({ - id: "evt-loop-answered", - type: "call.answered", - callId: started.callId, - providerCallId: "request-uuid", - timestamp: Date.now(), - }); + markCallAnswered(manager, started.callId, "evt-loop-answered"); for (let i = 1; i <= 5; i++) { const turnPromise = manager.continueCall(started.callId, `Prompt ${i}`); diff --git a/extensions/voice-call/src/manager/events.test.ts b/extensions/voice-call/src/manager/events.test.ts index 74d1f10e46c..f1d5b5d6f03 100644 --- a/extensions/voice-call/src/manager/events.test.ts +++ b/extensions/voice-call/src/manager/events.test.ts @@ -45,6 +45,32 @@ function createProvider(overrides: Partial = {}): VoiceCallPr }; } +function createInboundDisabledConfig() { + return VoiceCallConfigSchema.parse({ + enabled: true, + provider: "plivo", + fromNumber: "+15550000000", + inboundPolicy: "disabled", + }); +} + +function createInboundInitiatedEvent(params: { + id: string; + providerCallId: string; + from: string; +}): NormalizedEvent { + return { + id: params.id, + type: "call.initiated", + callId: params.providerCallId, + providerCallId: params.providerCallId, + timestamp: Date.now(), + direction: "inbound", + from: params.from, + to: "+15550000000", + }; +} + describe("processEvent (functional)", () => { it("calls provider hangup when rejecting inbound call", () => { const hangupCalls: HangupCallInput[] = []; @@ -55,24 +81,14 @@ describe("processEvent (functional)", () => { }); const ctx = createContext({ - config: VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - inboundPolicy: "disabled", - }), + config: createInboundDisabledConfig(), provider, }); - const event: NormalizedEvent = { + const event = createInboundInitiatedEvent({ id: "evt-1", - type: "call.initiated", - callId: "prov-1", providerCallId: "prov-1", - timestamp: Date.now(), - direction: "inbound", from: "+15559999999", - to: "+15550000000", - }; + }); processEvent(ctx, event); @@ -87,24 +103,14 @@ describe("processEvent (functional)", () => { it("does not call hangup when provider is null", () => { const ctx = createContext({ - config: VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - inboundPolicy: "disabled", - }), + config: createInboundDisabledConfig(), provider: null, }); - const event: NormalizedEvent = { + const event = createInboundInitiatedEvent({ id: "evt-2", - type: "call.initiated", - callId: "prov-2", providerCallId: "prov-2", - timestamp: Date.now(), - direction: "inbound", from: "+15551111111", - to: "+15550000000", - }; + }); processEvent(ctx, event); @@ -119,24 +125,14 @@ describe("processEvent (functional)", () => { }, }); const ctx = createContext({ - config: VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - inboundPolicy: "disabled", - }), + config: createInboundDisabledConfig(), provider, }); - const event1: NormalizedEvent = { + const event1 = createInboundInitiatedEvent({ id: "evt-init", - type: "call.initiated", - callId: "prov-dup", providerCallId: "prov-dup", - timestamp: Date.now(), - direction: "inbound", from: "+15552222222", - to: "+15550000000", - }; + }); const event2: NormalizedEvent = { id: "evt-ring", type: "call.ringing", @@ -228,24 +224,14 @@ describe("processEvent (functional)", () => { }, }); const ctx = createContext({ - config: VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - inboundPolicy: "disabled", - }), + config: createInboundDisabledConfig(), provider, }); - const event: NormalizedEvent = { + const event = createInboundInitiatedEvent({ id: "evt-fail", - type: "call.initiated", - callId: "prov-fail", providerCallId: "prov-fail", - timestamp: Date.now(), - direction: "inbound", from: "+15553333333", - to: "+15550000000", - }; + }); expect(() => processEvent(ctx, event)).not.toThrow(); expect(ctx.activeCalls.size).toBe(0); diff --git a/extensions/voice-call/src/manager/outbound.ts b/extensions/voice-call/src/manager/outbound.ts index d94c9da99ed..38978b6791c 100644 --- a/extensions/voice-call/src/manager/outbound.ts +++ b/extensions/voice-call/src/manager/outbound.ts @@ -51,6 +51,32 @@ type EndCallContext = Pick< | "maxDurationTimers" >; +type ConnectedCallContext = Pick; + +type ConnectedCallLookup = + | { kind: "error"; error: string } + | { kind: "ended"; call: CallRecord } + | { + kind: "ok"; + call: CallRecord; + providerCallId: string; + provider: NonNullable; + }; + +function lookupConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallLookup { + const call = ctx.activeCalls.get(callId); + if (!call) { + return { kind: "error", error: "Call not found" }; + } + if (!ctx.provider || !call.providerCallId) { + return { kind: "error", error: "Call not connected" }; + } + if (TerminalStates.has(call.state)) { + return { kind: "ended", call }; + } + return { kind: "ok", call, providerCallId: call.providerCallId, provider: ctx.provider }; +} + export async function initiateCall( ctx: InitiateContext, to: string, @@ -149,26 +175,25 @@ export async function speak( callId: CallId, text: string, ): Promise<{ success: boolean; error?: string }> { - const call = ctx.activeCalls.get(callId); - if (!call) { - return { success: false, error: "Call not found" }; + const lookup = lookupConnectedCall(ctx, callId); + if (lookup.kind === "error") { + return { success: false, error: lookup.error }; } - if (!ctx.provider || !call.providerCallId) { - return { success: false, error: "Call not connected" }; - } - if (TerminalStates.has(call.state)) { + if (lookup.kind === "ended") { return { success: false, error: "Call has ended" }; } + const { call, providerCallId, provider } = lookup; + try { transitionState(call, "speaking"); persistCallRecord(ctx.storePath, call); addTranscriptEntry(call, "bot", text); - const voice = ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined; - await ctx.provider.playTts({ + const voice = provider.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined; + await provider.playTts({ callId, - providerCallId: call.providerCallId, + providerCallId, text, voice, }); @@ -232,16 +257,15 @@ export async function continueCall( callId: CallId, prompt: string, ): Promise<{ success: boolean; transcript?: string; error?: string }> { - const call = ctx.activeCalls.get(callId); - if (!call) { - return { success: false, error: "Call not found" }; + const lookup = lookupConnectedCall(ctx, callId); + if (lookup.kind === "error") { + return { success: false, error: lookup.error }; } - if (!ctx.provider || !call.providerCallId) { - return { success: false, error: "Call not connected" }; - } - if (TerminalStates.has(call.state)) { + if (lookup.kind === "ended") { return { success: false, error: "Call has ended" }; } + const { call, providerCallId, provider } = lookup; + if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) { return { success: false, error: "Already waiting for transcript" }; } @@ -256,13 +280,13 @@ export async function continueCall( persistCallRecord(ctx.storePath, call); const listenStartedAt = Date.now(); - await ctx.provider.startListening({ callId, providerCallId: call.providerCallId }); + await provider.startListening({ callId, providerCallId }); const transcript = await waitForFinalTranscript(ctx, callId); const transcriptReceivedAt = Date.now(); // Best-effort: stop listening after final transcript. - await ctx.provider.stopListening({ callId, providerCallId: call.providerCallId }); + await provider.stopListening({ callId, providerCallId }); const lastTurnLatencyMs = transcriptReceivedAt - turnStartedAt; const lastTurnListenWaitMs = transcriptReceivedAt - listenStartedAt; @@ -302,21 +326,19 @@ export async function endCall( ctx: EndCallContext, callId: CallId, ): Promise<{ success: boolean; error?: string }> { - const call = ctx.activeCalls.get(callId); - if (!call) { - return { success: false, error: "Call not found" }; + const lookup = lookupConnectedCall(ctx, callId); + if (lookup.kind === "error") { + return { success: false, error: lookup.error }; } - if (!ctx.provider || !call.providerCallId) { - return { success: false, error: "Call not connected" }; - } - if (TerminalStates.has(call.state)) { + if (lookup.kind === "ended") { return { success: true }; } + const { call, providerCallId, provider } = lookup; try { - await ctx.provider.hangupCall({ + await provider.hangupCall({ callId, - providerCallId: call.providerCallId, + providerCallId, reason: "hangup-bot", }); @@ -329,9 +351,7 @@ export async function endCall( rejectTranscriptWaiter(ctx, callId, "Call ended: hangup-bot"); ctx.activeCalls.delete(callId); - if (call.providerCallId) { - ctx.providerCallIdMap.delete(call.providerCallId); - } + ctx.providerCallIdMap.delete(providerCallId); return { success: true }; } catch (err) { From 5c7ab8eae3067a164419d09ba3af4b8286419f45 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:28:27 +0000 Subject: [PATCH 32/81] test(zalo): broaden webhook monitor coverage --- extensions/zalo/src/monitor.webhook.test.ts | 336 +++++++------------- 1 file changed, 114 insertions(+), 222 deletions(-) diff --git a/extensions/zalo/src/monitor.webhook.test.ts b/extensions/zalo/src/monitor.webhook.test.ts index 97162544b6f..af998bee674 100644 --- a/extensions/zalo/src/monitor.webhook.test.ts +++ b/extensions/zalo/src/monitor.webhook.test.ts @@ -21,113 +21,84 @@ async function withServer(handler: RequestListener, fn: (baseUrl: string) => Pro } } +const DEFAULT_ACCOUNT: ResolvedZaloAccount = { + accountId: "default", + enabled: true, + token: "tok", + tokenSource: "config", + config: {}, +}; + +const webhookRequestHandler: RequestListener = async (req, res) => { + const handled = await handleZaloWebhookRequest(req, res); + if (!handled) { + res.statusCode = 404; + res.end("not found"); + } +}; + +function registerTarget(params: { + path: string; + secret?: string; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}): () => void { + return registerZaloWebhookTarget({ + token: "tok", + account: DEFAULT_ACCOUNT, + config: {} as OpenClawConfig, + runtime: {}, + core: {} as PluginRuntime, + secret: params.secret ?? "secret", + path: params.path, + mediaMaxMb: 5, + statusSink: params.statusSink, + }); +} + describe("handleZaloWebhookRequest", () => { it("returns 400 for non-object payloads", async () => { - const core = {} as PluginRuntime; - const account: ResolvedZaloAccount = { - accountId: "default", - enabled: true, - token: "tok", - tokenSource: "config", - config: {}, - }; - const unregister = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook", - mediaMaxMb: 5, - }); + const unregister = registerTarget({ path: "/hook" }); try { - await withServer( - async (req, res) => { - const handled = await handleZaloWebhookRequest(req, res); - if (!handled) { - res.statusCode = 404; - res.end("not found"); - } - }, - async (baseUrl) => { - const response = await fetch(`${baseUrl}/hook`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: "null", - }); + await withServer(webhookRequestHandler, async (baseUrl) => { + const response = await fetch(`${baseUrl}/hook`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: "null", + }); - expect(response.status).toBe(400); - expect(await response.text()).toBe("Bad Request"); - }, - ); + expect(response.status).toBe(400); + expect(await response.text()).toBe("Bad Request"); + }); } finally { unregister(); } }); it("rejects ambiguous routing when multiple targets match the same secret", async () => { - const core = {} as PluginRuntime; - const account: ResolvedZaloAccount = { - accountId: "default", - enabled: true, - token: "tok", - tokenSource: "config", - config: {}, - }; const sinkA = vi.fn(); const sinkB = vi.fn(); - const unregisterA = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook", - mediaMaxMb: 5, - statusSink: sinkA, - }); - const unregisterB = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook", - mediaMaxMb: 5, - statusSink: sinkB, - }); + const unregisterA = registerTarget({ path: "/hook", statusSink: sinkA }); + const unregisterB = registerTarget({ path: "/hook", statusSink: sinkB }); try { - await withServer( - async (req, res) => { - const handled = await handleZaloWebhookRequest(req, res); - if (!handled) { - res.statusCode = 404; - res.end("not found"); - } - }, - async (baseUrl) => { - const response = await fetch(`${baseUrl}/hook`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: "{}", - }); + await withServer(webhookRequestHandler, async (baseUrl) => { + const response = await fetch(`${baseUrl}/hook`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: "{}", + }); - expect(response.status).toBe(401); - expect(sinkA).not.toHaveBeenCalled(); - expect(sinkB).not.toHaveBeenCalled(); - }, - ); + expect(response.status).toBe(401); + expect(sinkA).not.toHaveBeenCalled(); + expect(sinkB).not.toHaveBeenCalled(); + }); } finally { unregisterA(); unregisterB(); @@ -135,73 +106,29 @@ describe("handleZaloWebhookRequest", () => { }); it("returns 415 for non-json content-type", async () => { - const core = {} as PluginRuntime; - const account: ResolvedZaloAccount = { - accountId: "default", - enabled: true, - token: "tok", - tokenSource: "config", - config: {}, - }; - const unregister = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook-content-type", - mediaMaxMb: 5, - }); + const unregister = registerTarget({ path: "/hook-content-type" }); try { - await withServer( - async (req, res) => { - const handled = await handleZaloWebhookRequest(req, res); - if (!handled) { - res.statusCode = 404; - res.end("not found"); - } - }, - async (baseUrl) => { - const response = await fetch(`${baseUrl}/hook-content-type`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "text/plain", - }, - body: "{}", - }); + await withServer(webhookRequestHandler, async (baseUrl) => { + const response = await fetch(`${baseUrl}/hook-content-type`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "text/plain", + }, + body: "{}", + }); - expect(response.status).toBe(415); - }, - ); + expect(response.status).toBe(415); + }); } finally { unregister(); } }); it("deduplicates webhook replay by event_name + message_id", async () => { - const core = {} as PluginRuntime; - const account: ResolvedZaloAccount = { - accountId: "default", - enabled: true, - token: "tok", - tokenSource: "config", - config: {}, - }; const sink = vi.fn(); - const unregister = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook-replay", - mediaMaxMb: 5, - statusSink: sink, - }); + const unregister = registerTarget({ path: "/hook-replay", statusSink: sink }); const payload = { event_name: "message.text.received", @@ -215,91 +142,56 @@ describe("handleZaloWebhookRequest", () => { }; try { - await withServer( - async (req, res) => { - const handled = await handleZaloWebhookRequest(req, res); - if (!handled) { - res.statusCode = 404; - res.end("not found"); - } - }, - async (baseUrl) => { - const first = await fetch(`${baseUrl}/hook-replay`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: JSON.stringify(payload), - }); - const second = await fetch(`${baseUrl}/hook-replay`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: JSON.stringify(payload), - }); + await withServer(webhookRequestHandler, async (baseUrl) => { + const first = await fetch(`${baseUrl}/hook-replay`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); + const second = await fetch(`${baseUrl}/hook-replay`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); - expect(first.status).toBe(200); - expect(second.status).toBe(200); - expect(sink).toHaveBeenCalledTimes(1); - }, - ); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(sink).toHaveBeenCalledTimes(1); + }); } finally { unregister(); } }); it("returns 429 when per-path request rate exceeds threshold", async () => { - const core = {} as PluginRuntime; - const account: ResolvedZaloAccount = { - accountId: "default", - enabled: true, - token: "tok", - tokenSource: "config", - config: {}, - }; - const unregister = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook-rate", - mediaMaxMb: 5, - }); + const unregister = registerTarget({ path: "/hook-rate" }); try { - await withServer( - async (req, res) => { - const handled = await handleZaloWebhookRequest(req, res); - if (!handled) { - res.statusCode = 404; - res.end("not found"); - } - }, - async (baseUrl) => { - let saw429 = false; - for (let i = 0; i < 130; i += 1) { - const response = await fetch(`${baseUrl}/hook-rate`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: "{}", - }); - if (response.status === 429) { - saw429 = true; - break; - } + await withServer(webhookRequestHandler, async (baseUrl) => { + let saw429 = false; + for (let i = 0; i < 130; i += 1) { + const response = await fetch(`${baseUrl}/hook-rate`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: "{}", + }); + if (response.status === 429) { + saw429 = true; + break; } + } - expect(saw429).toBe(true); - }, - ); + expect(saw429).toBe(true); + }); } finally { unregister(); } From 49648daec0d11768989f147d9e4540b68976b022 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:28:34 +0000 Subject: [PATCH 33/81] fix(zalouser): normalize send and onboarding flows --- extensions/zalouser/src/onboarding.ts | 183 ++++++++------------------ extensions/zalouser/src/send.test.ts | 156 ++++++++++++++++++++++ extensions/zalouser/src/send.ts | 97 ++++++-------- extensions/zalouser/src/types.ts | 31 ++--- 4 files changed, 263 insertions(+), 204 deletions(-) create mode 100644 extensions/zalouser/src/send.test.ts diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/onboarding.ts index 23df4ce42de..c623349e7c8 100644 --- a/extensions/zalouser/src/onboarding.ts +++ b/extensions/zalouser/src/onboarding.ts @@ -23,6 +23,45 @@ import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from ". const channel = "zalouser" as const; +function setZalouserAccountScopedConfig( + cfg: OpenClawConfig, + accountId: string, + defaultPatch: Record, + accountPatch: Record = defaultPatch, +): OpenClawConfig { + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + zalouser: { + ...cfg.channels?.zalouser, + enabled: true, + ...defaultPatch, + }, + }, + } as OpenClawConfig; + } + return { + ...cfg, + channels: { + ...cfg.channels, + zalouser: { + ...cfg.channels?.zalouser, + enabled: true, + accounts: { + ...cfg.channels?.zalouser?.accounts, + [accountId]: { + ...cfg.channels?.zalouser?.accounts?.[accountId], + enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true, + ...accountPatch, + }, + }, + }, + }, + } as OpenClawConfig; +} + function setZalouserDmPolicy( cfg: OpenClawConfig, dmPolicy: "pairing" | "allowlist" | "open" | "disabled", @@ -123,40 +162,10 @@ async function promptZalouserAllowFrom(params: { continue; } const unique = mergeAllowFromEntries(existingAllowFrom, results.filter(Boolean) as string[]); - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - dmPolicy: "allowlist", - allowFrom: unique, - }, - }, - } as OpenClawConfig; - } - - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - accounts: { - ...cfg.channels?.zalouser?.accounts, - [accountId]: { - ...cfg.channels?.zalouser?.accounts?.[accountId], - enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true, - dmPolicy: "allowlist", - allowFrom: unique, - }, - }, - }, - }, - } as OpenClawConfig; + return setZalouserAccountScopedConfig(cfg, accountId, { + dmPolicy: "allowlist", + allowFrom: unique, + }); } } @@ -165,37 +174,9 @@ function setZalouserGroupPolicy( accountId: string, groupPolicy: "open" | "allowlist" | "disabled", ): OpenClawConfig { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - groupPolicy, - }, - }, - } as OpenClawConfig; - } - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - accounts: { - ...cfg.channels?.zalouser?.accounts, - [accountId]: { - ...cfg.channels?.zalouser?.accounts?.[accountId], - enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true, - groupPolicy, - }, - }, - }, - }, - } as OpenClawConfig; + return setZalouserAccountScopedConfig(cfg, accountId, { + groupPolicy, + }); } function setZalouserGroupAllowlist( @@ -204,37 +185,9 @@ function setZalouserGroupAllowlist( groupKeys: string[], ): OpenClawConfig { const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }])); - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - groups, - }, - }, - } as OpenClawConfig; - } - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - accounts: { - ...cfg.channels?.zalouser?.accounts, - [accountId]: { - ...cfg.channels?.zalouser?.accounts?.[accountId], - enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true, - groups, - }, - }, - }, - }, - } as OpenClawConfig; + return setZalouserAccountScopedConfig(cfg, accountId, { + groups, + }); } async function resolveZalouserGroups(params: { @@ -403,38 +356,12 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { } // Enable the channel - if (accountId === DEFAULT_ACCOUNT_ID) { - next = { - ...next, - channels: { - ...next.channels, - zalouser: { - ...next.channels?.zalouser, - enabled: true, - profile: account.profile !== "default" ? account.profile : undefined, - }, - }, - } as OpenClawConfig; - } else { - next = { - ...next, - channels: { - ...next.channels, - zalouser: { - ...next.channels?.zalouser, - enabled: true, - accounts: { - ...next.channels?.zalouser?.accounts, - [accountId]: { - ...next.channels?.zalouser?.accounts?.[accountId], - enabled: true, - profile: account.profile, - }, - }, - }, - }, - } as OpenClawConfig; - } + next = setZalouserAccountScopedConfig( + next, + accountId, + { profile: account.profile !== "default" ? account.profile : undefined }, + { profile: account.profile, enabled: true }, + ); if (forceAllowFrom) { next = await promptZalouserAllowFrom({ diff --git a/extensions/zalouser/src/send.test.ts b/extensions/zalouser/src/send.test.ts new file mode 100644 index 00000000000..abca9fd50ed --- /dev/null +++ b/extensions/zalouser/src/send.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + sendImageZalouser, + sendLinkZalouser, + sendMessageZalouser, + type ZalouserSendResult, +} from "./send.js"; +import { runZca } from "./zca.js"; + +vi.mock("./zca.js", () => ({ + runZca: vi.fn(), +})); + +const mockRunZca = vi.mocked(runZca); +const originalZcaProfile = process.env.ZCA_PROFILE; + +function okResult(stdout = "message_id: msg-1") { + return { + ok: true, + stdout, + stderr: "", + exitCode: 0, + }; +} + +function failResult(stderr = "") { + return { + ok: false, + stdout: "", + stderr, + exitCode: 1, + }; +} + +describe("zalouser send helpers", () => { + beforeEach(() => { + mockRunZca.mockReset(); + delete process.env.ZCA_PROFILE; + }); + + afterEach(() => { + if (originalZcaProfile) { + process.env.ZCA_PROFILE = originalZcaProfile; + return; + } + delete process.env.ZCA_PROFILE; + }); + + it("returns validation error when thread id is missing", async () => { + const result = await sendMessageZalouser("", "hello"); + expect(result).toEqual({ + ok: false, + error: "No threadId provided", + } satisfies ZalouserSendResult); + expect(mockRunZca).not.toHaveBeenCalled(); + }); + + it("builds text send command with truncation and group flag", async () => { + mockRunZca.mockResolvedValueOnce(okResult("message id: mid-123")); + + const result = await sendMessageZalouser(" thread-1 ", "x".repeat(2200), { + profile: "profile-a", + isGroup: true, + }); + + expect(mockRunZca).toHaveBeenCalledWith(["msg", "send", "thread-1", "x".repeat(2000), "-g"], { + profile: "profile-a", + }); + expect(result).toEqual({ ok: true, messageId: "mid-123" }); + }); + + it("routes media sends from sendMessage and keeps text as caption", async () => { + mockRunZca.mockResolvedValueOnce(okResult()); + + await sendMessageZalouser("thread-2", "media caption", { + profile: "profile-b", + mediaUrl: "https://cdn.example.com/video.mp4", + isGroup: true, + }); + + expect(mockRunZca).toHaveBeenCalledWith( + [ + "msg", + "video", + "thread-2", + "-u", + "https://cdn.example.com/video.mp4", + "-m", + "media caption", + "-g", + ], + { profile: "profile-b" }, + ); + }); + + it("maps audio media to voice command", async () => { + mockRunZca.mockResolvedValueOnce(okResult()); + + await sendMessageZalouser("thread-3", "", { + profile: "profile-c", + mediaUrl: "https://cdn.example.com/clip.mp3", + }); + + expect(mockRunZca).toHaveBeenCalledWith( + ["msg", "voice", "thread-3", "-u", "https://cdn.example.com/clip.mp3"], + { profile: "profile-c" }, + ); + }); + + it("builds image command with caption and returns fallback error", async () => { + mockRunZca.mockResolvedValueOnce(failResult("")); + + const result = await sendImageZalouser("thread-4", " https://cdn.example.com/img.png ", { + profile: "profile-d", + caption: "caption text", + isGroup: true, + }); + + expect(mockRunZca).toHaveBeenCalledWith( + [ + "msg", + "image", + "thread-4", + "-u", + "https://cdn.example.com/img.png", + "-m", + "caption text", + "-g", + ], + { profile: "profile-d" }, + ); + expect(result).toEqual({ ok: false, error: "Failed to send image" }); + }); + + it("uses env profile fallback and builds link command", async () => { + process.env.ZCA_PROFILE = "env-profile"; + mockRunZca.mockResolvedValueOnce(okResult("abc123")); + + const result = await sendLinkZalouser("thread-5", " https://openclaw.ai ", { isGroup: true }); + + expect(mockRunZca).toHaveBeenCalledWith( + ["msg", "link", "thread-5", "https://openclaw.ai", "-g"], + { profile: "env-profile" }, + ); + expect(result).toEqual({ ok: true, messageId: "abc123" }); + }); + + it("returns caught command errors", async () => { + mockRunZca.mockRejectedValueOnce(new Error("zca unavailable")); + + await expect(sendLinkZalouser("thread-6", "https://openclaw.ai")).resolves.toEqual({ + ok: false, + error: "zca unavailable", + }); + }); +}); diff --git a/extensions/zalouser/src/send.ts b/extensions/zalouser/src/send.ts index 0674b88e25a..1a3c3d3ea66 100644 --- a/extensions/zalouser/src/send.ts +++ b/extensions/zalouser/src/send.ts @@ -13,12 +13,41 @@ export type ZalouserSendResult = { error?: string; }; +function resolveProfile(options: ZalouserSendOptions): string { + return options.profile || process.env.ZCA_PROFILE || "default"; +} + +function appendCaptionAndGroupFlags(args: string[], options: ZalouserSendOptions): void { + if (options.caption) { + args.push("-m", options.caption.slice(0, 2000)); + } + if (options.isGroup) { + args.push("-g"); + } +} + +async function runSendCommand( + args: string[], + profile: string, + fallbackError: string, +): Promise { + try { + const result = await runZca(args, { profile }); + if (result.ok) { + return { ok: true, messageId: extractMessageId(result.stdout) }; + } + return { ok: false, error: result.stderr || fallbackError }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + export async function sendMessageZalouser( threadId: string, text: string, options: ZalouserSendOptions = {}, ): Promise { - const profile = options.profile || process.env.ZCA_PROFILE || "default"; + const profile = resolveProfile(options); if (!threadId?.trim()) { return { ok: false, error: "No threadId provided" }; @@ -38,17 +67,7 @@ export async function sendMessageZalouser( args.push("-g"); } - try { - const result = await runZca(args, { profile }); - - if (result.ok) { - return { ok: true, messageId: extractMessageId(result.stdout) }; - } - - return { ok: false, error: result.stderr || "Failed to send message" }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + return runSendCommand(args, profile, "Failed to send message"); } async function sendMediaZalouser( @@ -56,7 +75,7 @@ async function sendMediaZalouser( mediaUrl: string, options: ZalouserSendOptions = {}, ): Promise { - const profile = options.profile || process.env.ZCA_PROFILE || "default"; + const profile = resolveProfile(options); if (!threadId?.trim()) { return { ok: false, error: "No threadId provided" }; @@ -78,24 +97,8 @@ async function sendMediaZalouser( } const args = ["msg", command, threadId.trim(), "-u", mediaUrl.trim()]; - if (options.caption) { - args.push("-m", options.caption.slice(0, 2000)); - } - if (options.isGroup) { - args.push("-g"); - } - - try { - const result = await runZca(args, { profile }); - - if (result.ok) { - return { ok: true, messageId: extractMessageId(result.stdout) }; - } - - return { ok: false, error: result.stderr || `Failed to send ${command}` }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + appendCaptionAndGroupFlags(args, options); + return runSendCommand(args, profile, `Failed to send ${command}`); } export async function sendImageZalouser( @@ -103,24 +106,10 @@ export async function sendImageZalouser( imageUrl: string, options: ZalouserSendOptions = {}, ): Promise { - const profile = options.profile || process.env.ZCA_PROFILE || "default"; + const profile = resolveProfile(options); const args = ["msg", "image", threadId.trim(), "-u", imageUrl.trim()]; - if (options.caption) { - args.push("-m", options.caption.slice(0, 2000)); - } - if (options.isGroup) { - args.push("-g"); - } - - try { - const result = await runZca(args, { profile }); - if (result.ok) { - return { ok: true, messageId: extractMessageId(result.stdout) }; - } - return { ok: false, error: result.stderr || "Failed to send image" }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + appendCaptionAndGroupFlags(args, options); + return runSendCommand(args, profile, "Failed to send image"); } export async function sendLinkZalouser( @@ -128,21 +117,13 @@ export async function sendLinkZalouser( url: string, options: ZalouserSendOptions = {}, ): Promise { - const profile = options.profile || process.env.ZCA_PROFILE || "default"; + const profile = resolveProfile(options); const args = ["msg", "link", threadId.trim(), url.trim()]; if (options.isGroup) { args.push("-g"); } - try { - const result = await runZca(args, { profile }); - if (result.ok) { - return { ok: true, messageId: extractMessageId(result.stdout) }; - } - return { ok: false, error: result.stderr || "Failed to send link" }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + return runSendCommand(args, profile, "Failed to send link"); } function extractMessageId(stdout: string): string | undefined { diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index e6557cb0e79..8be1649bae5 100644 --- a/extensions/zalouser/src/types.ts +++ b/extensions/zalouser/src/types.ts @@ -68,35 +68,30 @@ export type ListenOptions = CommonOptions & { prefix?: string; }; -export type ZalouserAccountConfig = { +type ZalouserToolConfig = { allow?: string[]; deny?: string[] }; + +type ZalouserGroupConfig = { + allow?: boolean; + enabled?: boolean; + tools?: ZalouserToolConfig; +}; + +type ZalouserSharedConfig = { enabled?: boolean; name?: string; profile?: string; dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; allowFrom?: Array; groupPolicy?: "open" | "allowlist" | "disabled"; - groups?: Record< - string, - { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } } - >; + groups?: Record; messagePrefix?: string; responsePrefix?: string; }; -export type ZalouserConfig = { - enabled?: boolean; - name?: string; - profile?: string; +export type ZalouserAccountConfig = ZalouserSharedConfig; + +export type ZalouserConfig = ZalouserSharedConfig & { defaultAccount?: string; - dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; - allowFrom?: Array; - groupPolicy?: "open" | "allowlist" | "disabled"; - groups?: Record< - string, - { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } } - >; - messagePrefix?: string; - responsePrefix?: string; accounts?: Record; }; From 32a1273d8238fc01fd0a4bc53820b246cee47165 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:28:42 +0000 Subject: [PATCH 34/81] refactor(onboarding): dedupe channel allowlist flows --- .../plugins/onboarding/channel-access.test.ts | 138 +++++++++ .../plugins/onboarding/channel-access.ts | 6 +- src/channels/plugins/onboarding/discord.ts | 50 ++- .../plugins/onboarding/helpers.test.ts | 248 ++++++++++++++- src/channels/plugins/onboarding/helpers.ts | 120 ++++++++ src/channels/plugins/onboarding/imessage.ts | 113 +++---- src/channels/plugins/onboarding/signal.ts | 113 +++---- src/channels/plugins/onboarding/slack.ts | 82 +++-- src/channels/plugins/onboarding/telegram.ts | 49 ++- .../plugins/onboarding/whatsapp.test.ts | 287 ++++++++++++++++++ src/channels/plugins/onboarding/whatsapp.ts | 104 +++---- 11 files changed, 997 insertions(+), 313 deletions(-) create mode 100644 src/channels/plugins/onboarding/channel-access.test.ts create mode 100644 src/channels/plugins/onboarding/whatsapp.test.ts diff --git a/src/channels/plugins/onboarding/channel-access.test.ts b/src/channels/plugins/onboarding/channel-access.test.ts new file mode 100644 index 00000000000..0e5b2ba6651 --- /dev/null +++ b/src/channels/plugins/onboarding/channel-access.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from "vitest"; +import { + formatAllowlistEntries, + parseAllowlistEntries, + promptChannelAccessConfig, + promptChannelAllowlist, + promptChannelAccessPolicy, +} from "./channel-access.js"; + +function createPrompter(params?: { + confirm?: (options: { message: string; initialValue: boolean }) => Promise; + select?: (options: { + message: string; + options: Array<{ value: string; label: string }>; + initialValue?: string; + }) => Promise; + text?: (options: { + message: string; + placeholder?: string; + initialValue?: string; + }) => Promise; +}) { + return { + confirm: vi.fn(params?.confirm ?? (async () => true)), + select: vi.fn(params?.select ?? (async () => "allowlist")), + text: vi.fn(params?.text ?? (async () => "")), + }; +} + +describe("parseAllowlistEntries", () => { + it("splits comma/newline/semicolon-separated entries", () => { + expect(parseAllowlistEntries("alpha, beta\n gamma;delta")).toEqual([ + "alpha", + "beta", + "gamma", + "delta", + ]); + }); +}); + +describe("formatAllowlistEntries", () => { + it("formats compact comma-separated output", () => { + expect(formatAllowlistEntries([" alpha ", "", "beta"])).toBe("alpha, beta"); + }); +}); + +describe("promptChannelAllowlist", () => { + it("uses existing entries as initial value", async () => { + const prompter = createPrompter({ + text: async () => "one,two", + }); + + const result = await promptChannelAllowlist({ + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + label: "Test", + currentEntries: ["alpha", "beta"], + }); + + expect(result).toEqual(["one", "two"]); + expect(prompter.text).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: "alpha, beta", + }), + ); + }); +}); + +describe("promptChannelAccessPolicy", () => { + it("returns selected policy", async () => { + const prompter = createPrompter({ + select: async () => "open", + }); + + const result = await promptChannelAccessPolicy({ + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + label: "Discord", + currentPolicy: "allowlist", + }); + + expect(result).toBe("open"); + }); +}); + +describe("promptChannelAccessConfig", () => { + it("returns null when user skips configuration", async () => { + const prompter = createPrompter({ + confirm: async () => false, + }); + + const result = await promptChannelAccessConfig({ + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + label: "Slack", + }); + + expect(result).toBeNull(); + }); + + it("returns allowlist entries when policy is allowlist", async () => { + const prompter = createPrompter({ + confirm: async () => true, + select: async () => "allowlist", + text: async () => "c1, c2", + }); + + const result = await promptChannelAccessConfig({ + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + label: "Slack", + }); + + expect(result).toEqual({ + policy: "allowlist", + entries: ["c1", "c2"], + }); + }); + + it("returns non-allowlist policy with empty entries", async () => { + const prompter = createPrompter({ + confirm: async () => true, + select: async () => "open", + }); + + const result = await promptChannelAccessConfig({ + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + label: "Slack", + allowDisabled: true, + }); + + expect(result).toEqual({ + policy: "open", + entries: [], + }); + }); +}); diff --git a/src/channels/plugins/onboarding/channel-access.ts b/src/channels/plugins/onboarding/channel-access.ts index 58e2822660a..ef86b37f336 100644 --- a/src/channels/plugins/onboarding/channel-access.ts +++ b/src/channels/plugins/onboarding/channel-access.ts @@ -1,12 +1,10 @@ import type { WizardPrompter } from "../../../wizard/prompts.js"; +import { splitOnboardingEntries } from "./helpers.js"; export type ChannelAccessPolicy = "allowlist" | "open" | "disabled"; export function parseAllowlistEntries(raw: string): string[] { - return String(raw ?? "") - .split(/[,\n]/g) - .map((entry) => entry.trim()) - .filter(Boolean); + return splitOnboardingEntries(String(raw ?? "")); } export function formatAllowlistEntries(entries: string[]): string { diff --git a/src/channels/plugins/onboarding/discord.ts b/src/channels/plugins/onboarding/discord.ts index 45410ee4e26..9009f528e8f 100644 --- a/src/channels/plugins/onboarding/discord.ts +++ b/src/channels/plugins/onboarding/discord.ts @@ -12,12 +12,18 @@ import { type DiscordChannelResolution, } from "../../../discord/resolve-channels.js"; import { resolveDiscordUserAllowlist } from "../../../discord/resolve-users.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; import { formatDocsLink } from "../../../terminal/links.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; import { promptChannelAccessConfig } from "./channel-access.js"; -import { addWildcardAllowFrom, promptAccountId, promptResolvedAllowFrom } from "./helpers.js"; +import { + addWildcardAllowFrom, + promptResolvedAllowFrom, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + splitOnboardingEntries, +} from "./helpers.js"; const channel = "discord" as const; @@ -145,22 +151,15 @@ function setDiscordAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClaw }; } -function parseDiscordAllowFromInput(raw: string): string[] { - return raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); -} - async function promptDiscordAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = - params.accountId && normalizeAccountId(params.accountId) - ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultDiscordAccountId(params.cfg); + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultDiscordAccountId(params.cfg), + }); const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId }); const token = resolved.token; const existing = @@ -178,7 +177,7 @@ async function promptDiscordAllowFrom(params: { "Discord allowlist", ); - const parseInputs = (value: string) => parseDiscordAllowFromInput(value); + const parseInputs = (value: string) => splitOnboardingEntries(value); const parseId = (value: string) => { const trimmed = value.trim(); if (!trimmed) { @@ -240,21 +239,16 @@ export const discordOnboardingAdapter: ChannelOnboardingAdapter = { }; }, configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { - const discordOverride = accountOverrides.discord?.trim(); const defaultDiscordAccountId = resolveDefaultDiscordAccountId(cfg); - let discordAccountId = discordOverride - ? normalizeAccountId(discordOverride) - : defaultDiscordAccountId; - if (shouldPromptAccountIds && !discordOverride) { - discordAccountId = await promptAccountId({ - cfg, - prompter, - label: "Discord", - currentId: discordAccountId, - listAccountIds: listDiscordAccountIds, - defaultAccountId: defaultDiscordAccountId, - }); - } + const discordAccountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: "Discord", + accountOverride: accountOverrides.discord, + shouldPromptAccountIds, + listAccountIds: listDiscordAccountIds, + defaultAccountId: defaultDiscordAccountId, + }); let next = cfg; const resolvedAccount = resolveDiscordAccount({ diff --git a/src/channels/plugins/onboarding/helpers.test.ts b/src/channels/plugins/onboarding/helpers.test.ts index 14f593f3cfe..2ff9b296769 100644 --- a/src/channels/plugins/onboarding/helpers.test.ts +++ b/src/channels/plugins/onboarding/helpers.test.ts @@ -1,5 +1,21 @@ -import { describe, expect, it, vi } from "vitest"; -import { promptResolvedAllowFrom } from "./helpers.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; + +const promptAccountIdSdkMock = vi.hoisted(() => vi.fn(async () => "default")); +vi.mock("../../../plugin-sdk/onboarding.js", () => ({ + promptAccountId: promptAccountIdSdkMock, +})); + +import { + normalizeAllowFromEntries, + promptResolvedAllowFrom, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + setAccountAllowFromForChannel, + setChannelDmPolicyWithAllowFrom, + splitOnboardingEntries, +} from "./helpers.js"; function createPrompter(inputs: string[]) { return { @@ -9,6 +25,11 @@ function createPrompter(inputs: string[]) { } describe("promptResolvedAllowFrom", () => { + beforeEach(() => { + promptAccountIdSdkMock.mockReset(); + promptAccountIdSdkMock.mockResolvedValue("default"); + }); + it("re-prompts without token until all ids are parseable", async () => { const prompter = createPrompter(["@alice", "123"]); const resolveEntries = vi.fn(); @@ -66,4 +87,227 @@ describe("promptResolvedAllowFrom", () => { expect(prompter.note).toHaveBeenCalledWith("Could not resolve: alice", "allowlist"); expect(resolveEntries).toHaveBeenCalledTimes(2); }); + + it("re-prompts when resolver throws before succeeding", async () => { + const prompter = createPrompter(["alice", "bob"]); + const resolveEntries = vi + .fn() + .mockRejectedValueOnce(new Error("network")) + .mockResolvedValueOnce([{ input: "bob", resolved: true, id: "U234" }]); + + const result = await promptResolvedAllowFrom({ + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + existing: [], + token: "xoxb-test", + message: "msg", + placeholder: "placeholder", + label: "allowlist", + parseInputs: (value) => + value + .split(",") + .map((part) => part.trim()) + .filter(Boolean), + parseId: () => null, + invalidWithoutTokenNote: "ids only", + resolveEntries, + }); + + expect(result).toEqual(["U234"]); + expect(prompter.note).toHaveBeenCalledWith( + "Failed to resolve usernames. Try again.", + "allowlist", + ); + expect(resolveEntries).toHaveBeenCalledTimes(2); + }); +}); + +describe("setAccountAllowFromForChannel", () => { + it("writes allowFrom on default account channel config", () => { + const cfg: OpenClawConfig = { + channels: { + imessage: { + enabled: true, + allowFrom: ["old"], + accounts: { + work: { allowFrom: ["work-old"] }, + }, + }, + }, + }; + + const next = setAccountAllowFromForChannel({ + cfg, + channel: "imessage", + accountId: DEFAULT_ACCOUNT_ID, + allowFrom: ["new-default"], + }); + + expect(next.channels?.imessage?.allowFrom).toEqual(["new-default"]); + expect(next.channels?.imessage?.accounts?.work?.allowFrom).toEqual(["work-old"]); + }); + + it("writes allowFrom on nested non-default account config", () => { + const cfg: OpenClawConfig = { + channels: { + signal: { + enabled: true, + allowFrom: ["default-old"], + accounts: { + alt: { enabled: true, account: "+15555550123", allowFrom: ["alt-old"] }, + }, + }, + }, + }; + + const next = setAccountAllowFromForChannel({ + cfg, + channel: "signal", + accountId: "alt", + allowFrom: ["alt-new"], + }); + + expect(next.channels?.signal?.allowFrom).toEqual(["default-old"]); + expect(next.channels?.signal?.accounts?.alt?.allowFrom).toEqual(["alt-new"]); + expect(next.channels?.signal?.accounts?.alt?.account).toBe("+15555550123"); + }); +}); + +describe("setChannelDmPolicyWithAllowFrom", () => { + it("adds wildcard allowFrom when setting dmPolicy=open", () => { + const cfg: OpenClawConfig = { + channels: { + signal: { + dmPolicy: "pairing", + allowFrom: ["+15555550123"], + }, + }, + }; + + const next = setChannelDmPolicyWithAllowFrom({ + cfg, + channel: "signal", + dmPolicy: "open", + }); + + expect(next.channels?.signal?.dmPolicy).toBe("open"); + expect(next.channels?.signal?.allowFrom).toEqual(["+15555550123", "*"]); + }); + + it("sets dmPolicy without changing allowFrom for non-open policies", () => { + const cfg: OpenClawConfig = { + channels: { + imessage: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }; + + const next = setChannelDmPolicyWithAllowFrom({ + cfg, + channel: "imessage", + dmPolicy: "pairing", + }); + + expect(next.channels?.imessage?.dmPolicy).toBe("pairing"); + expect(next.channels?.imessage?.allowFrom).toEqual(["*"]); + }); +}); + +describe("splitOnboardingEntries", () => { + it("splits comma/newline/semicolon input and trims blanks", () => { + expect(splitOnboardingEntries(" alice, bob \ncarol; ;\n")).toEqual(["alice", "bob", "carol"]); + }); +}); + +describe("normalizeAllowFromEntries", () => { + it("normalizes values, preserves wildcard, and removes duplicates", () => { + expect( + normalizeAllowFromEntries([" +15555550123 ", "*", "+15555550123", "bad"], (value) => + value.startsWith("+1") ? value : null, + ), + ).toEqual(["+15555550123", "*"]); + }); + + it("trims and de-duplicates without a normalizer", () => { + expect(normalizeAllowFromEntries([" alice ", "bob", "alice"])).toEqual(["alice", "bob"]); + }); +}); + +describe("resolveOnboardingAccountId", () => { + it("normalizes provided account ids", () => { + expect( + resolveOnboardingAccountId({ + accountId: " Work Account ", + defaultAccountId: DEFAULT_ACCOUNT_ID, + }), + ).toBe("work-account"); + }); + + it("falls back to default account id when input is blank", () => { + expect( + resolveOnboardingAccountId({ + accountId: " ", + defaultAccountId: "custom-default", + }), + ).toBe("custom-default"); + }); +}); + +describe("resolveAccountIdForConfigure", () => { + beforeEach(() => { + promptAccountIdSdkMock.mockReset(); + promptAccountIdSdkMock.mockResolvedValue("default"); + }); + + it("uses normalized override without prompting", async () => { + const accountId = await resolveAccountIdForConfigure({ + cfg: {}, + // oxlint-disable-next-line typescript/no-explicit-any + prompter: {} as any, + label: "Signal", + accountOverride: " Team Primary ", + shouldPromptAccountIds: true, + listAccountIds: () => ["default", "team-primary"], + defaultAccountId: DEFAULT_ACCOUNT_ID, + }); + expect(accountId).toBe("team-primary"); + }); + + it("uses default account when override is missing and prompting disabled", async () => { + const accountId = await resolveAccountIdForConfigure({ + cfg: {}, + // oxlint-disable-next-line typescript/no-explicit-any + prompter: {} as any, + label: "Signal", + shouldPromptAccountIds: false, + listAccountIds: () => ["default"], + defaultAccountId: "fallback", + }); + expect(accountId).toBe("fallback"); + }); + + it("prompts for account id when prompting is enabled and no override is provided", async () => { + promptAccountIdSdkMock.mockResolvedValueOnce("prompted-id"); + + const accountId = await resolveAccountIdForConfigure({ + cfg: {}, + // oxlint-disable-next-line typescript/no-explicit-any + prompter: {} as any, + label: "Signal", + shouldPromptAccountIds: true, + listAccountIds: () => ["default", "prompted-id"], + defaultAccountId: "fallback", + }); + + expect(accountId).toBe("prompted-id"); + expect(promptAccountIdSdkMock).toHaveBeenCalledWith( + expect.objectContaining({ + label: "Signal", + currentId: "fallback", + defaultAccountId: "fallback", + }), + ); + }); }); diff --git a/src/channels/plugins/onboarding/helpers.ts b/src/channels/plugins/onboarding/helpers.ts index f31f0768f9b..7b40c49c0e9 100644 --- a/src/channels/plugins/onboarding/helpers.ts +++ b/src/channels/plugins/onboarding/helpers.ts @@ -1,4 +1,7 @@ +import type { OpenClawConfig } from "../../../config/config.js"; +import type { DmPolicy } from "../../../config/types.js"; import { promptAccountId as promptAccountIdSdk } from "../../../plugin-sdk/onboarding.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { PromptAccountId, PromptAccountIdParams } from "../onboarding-types.js"; @@ -22,6 +25,123 @@ export function mergeAllowFromEntries( return [...new Set(merged)]; } +export function splitOnboardingEntries(raw: string): string[] { + return raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export function normalizeAllowFromEntries( + entries: Array, + normalizeEntry?: (value: string) => string | null | undefined, +): string[] { + const normalized = entries + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => { + if (entry === "*") { + return "*"; + } + if (!normalizeEntry) { + return entry; + } + const value = normalizeEntry(entry); + return typeof value === "string" ? value.trim() : ""; + }) + .filter(Boolean); + return [...new Set(normalized)]; +} + +export function resolveOnboardingAccountId(params: { + accountId?: string; + defaultAccountId: string; +}): string { + return params.accountId?.trim() ? normalizeAccountId(params.accountId) : params.defaultAccountId; +} + +export async function resolveAccountIdForConfigure(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + label: string; + accountOverride?: string; + shouldPromptAccountIds: boolean; + listAccountIds: (cfg: OpenClawConfig) => string[]; + defaultAccountId: string; +}): Promise { + const override = params.accountOverride?.trim(); + let accountId = override ? normalizeAccountId(override) : params.defaultAccountId; + if (params.shouldPromptAccountIds && !override) { + accountId = await promptAccountId({ + cfg: params.cfg, + prompter: params.prompter, + label: params.label, + currentId: accountId, + listAccountIds: params.listAccountIds, + defaultAccountId: params.defaultAccountId, + }); + } + return accountId; +} + +export function setAccountAllowFromForChannel(params: { + cfg: OpenClawConfig; + channel: "imessage" | "signal"; + accountId: string; + allowFrom: string[]; +}): OpenClawConfig { + const { cfg, channel, accountId, allowFrom } = params; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + [channel]: { + ...cfg.channels?.[channel], + allowFrom, + }, + }, + }; + } + return { + ...cfg, + channels: { + ...cfg.channels, + [channel]: { + ...cfg.channels?.[channel], + accounts: { + ...cfg.channels?.[channel]?.accounts, + [accountId]: { + ...cfg.channels?.[channel]?.accounts?.[accountId], + allowFrom, + }, + }, + }, + }, + }; +} + +export function setChannelDmPolicyWithAllowFrom(params: { + cfg: OpenClawConfig; + channel: "imessage" | "signal"; + dmPolicy: DmPolicy; +}): OpenClawConfig { + const { cfg, channel, dmPolicy } = params; + const allowFrom = + dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.[channel]?.allowFrom) : undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + [channel]: { + ...cfg.channels?.[channel], + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }; +} + type AllowFromResolution = { input: string; resolved: boolean; diff --git a/src/channels/plugins/onboarding/imessage.ts b/src/channels/plugins/onboarding/imessage.ts index c5cdeb83679..20c433ec451 100644 --- a/src/channels/plugins/onboarding/imessage.ts +++ b/src/channels/plugins/onboarding/imessage.ts @@ -7,70 +7,27 @@ import { resolveIMessageAccount, } from "../../../imessage/accounts.js"; import { normalizeIMessageHandle } from "../../../imessage/targets.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; import { formatDocsLink } from "../../../terminal/links.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; -import { addWildcardAllowFrom, mergeAllowFromEntries, promptAccountId } from "./helpers.js"; +import { + mergeAllowFromEntries, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + setAccountAllowFromForChannel, + setChannelDmPolicyWithAllowFrom, + splitOnboardingEntries, +} from "./helpers.js"; const channel = "imessage" as const; function setIMessageDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { - const allowFrom = - dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.imessage?.allowFrom) : undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - imessage: { - ...cfg.channels?.imessage, - dmPolicy, - ...(allowFrom ? { allowFrom } : {}), - }, - }, - }; -} - -function setIMessageAllowFrom( - cfg: OpenClawConfig, - accountId: string, - allowFrom: string[], -): OpenClawConfig { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - imessage: { - ...cfg.channels?.imessage, - allowFrom, - }, - }, - }; - } - return { - ...cfg, - channels: { - ...cfg.channels, - imessage: { - ...cfg.channels?.imessage, - accounts: { - ...cfg.channels?.imessage?.accounts, - [accountId]: { - ...cfg.channels?.imessage?.accounts?.[accountId], - allowFrom, - }, - }, - }, - }, - }; -} - -function parseIMessageAllowFromInput(raw: string): string[] { - return raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); + return setChannelDmPolicyWithAllowFrom({ + cfg, + channel: "imessage", + dmPolicy, + }); } async function promptIMessageAllowFrom(params: { @@ -78,10 +35,10 @@ async function promptIMessageAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = - params.accountId && normalizeAccountId(params.accountId) - ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultIMessageAccountId(params.cfg); + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultIMessageAccountId(params.cfg), + }); const resolved = resolveIMessageAccount({ cfg: params.cfg, accountId }); const existing = resolved.config.allowFrom ?? []; await params.prompter.note( @@ -106,7 +63,7 @@ async function promptIMessageAllowFrom(params: { if (!raw) { return "Required"; } - const parts = parseIMessageAllowFromInput(raw); + const parts = splitOnboardingEntries(raw); for (const part of parts) { if (part === "*") { continue; @@ -137,9 +94,14 @@ async function promptIMessageAllowFrom(params: { return undefined; }, }); - const parts = parseIMessageAllowFromInput(String(entry)); + const parts = splitOnboardingEntries(String(entry)); const unique = mergeAllowFromEntries(undefined, parts); - return setIMessageAllowFrom(params.cfg, accountId, unique); + return setAccountAllowFromForChannel({ + cfg: params.cfg, + channel: "imessage", + accountId, + allowFrom: unique, + }); } const dmPolicy: ChannelOnboardingDmPolicy = { @@ -179,21 +141,16 @@ export const imessageOnboardingAdapter: ChannelOnboardingAdapter = { }; }, configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { - const imessageOverride = accountOverrides.imessage?.trim(); const defaultIMessageAccountId = resolveDefaultIMessageAccountId(cfg); - let imessageAccountId = imessageOverride - ? normalizeAccountId(imessageOverride) - : defaultIMessageAccountId; - if (shouldPromptAccountIds && !imessageOverride) { - imessageAccountId = await promptAccountId({ - cfg, - prompter, - label: "iMessage", - currentId: imessageAccountId, - listAccountIds: listIMessageAccountIds, - defaultAccountId: defaultIMessageAccountId, - }); - } + const imessageAccountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: "iMessage", + accountOverride: accountOverrides.imessage, + shouldPromptAccountIds, + listAccountIds: listIMessageAccountIds, + defaultAccountId: defaultIMessageAccountId, + }); let next = cfg; const resolvedAccount = resolveIMessageAccount({ diff --git a/src/channels/plugins/onboarding/signal.ts b/src/channels/plugins/onboarding/signal.ts index 98b9e691081..4df479d860d 100644 --- a/src/channels/plugins/onboarding/signal.ts +++ b/src/channels/plugins/onboarding/signal.ts @@ -3,7 +3,7 @@ import { detectBinary } from "../../../commands/onboard-helpers.js"; import { installSignalCli } from "../../../commands/signal-install.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { DmPolicy } from "../../../config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, @@ -13,7 +13,14 @@ import { formatDocsLink } from "../../../terminal/links.js"; import { normalizeE164 } from "../../../utils.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; -import { addWildcardAllowFrom, mergeAllowFromEntries, promptAccountId } from "./helpers.js"; +import { + mergeAllowFromEntries, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + setAccountAllowFromForChannel, + setChannelDmPolicyWithAllowFrom, + splitOnboardingEntries, +} from "./helpers.js"; const channel = "signal" as const; const MIN_E164_DIGITS = 5; @@ -39,61 +46,11 @@ export function normalizeSignalAccountInput(value: string | null | undefined): s } function setSignalDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { - const allowFrom = - dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.signal?.allowFrom) : undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - signal: { - ...cfg.channels?.signal, - dmPolicy, - ...(allowFrom ? { allowFrom } : {}), - }, - }, - }; -} - -function setSignalAllowFrom( - cfg: OpenClawConfig, - accountId: string, - allowFrom: string[], -): OpenClawConfig { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - signal: { - ...cfg.channels?.signal, - allowFrom, - }, - }, - }; - } - return { - ...cfg, - channels: { - ...cfg.channels, - signal: { - ...cfg.channels?.signal, - accounts: { - ...cfg.channels?.signal?.accounts, - [accountId]: { - ...cfg.channels?.signal?.accounts?.[accountId], - allowFrom, - }, - }, - }, - }, - }; -} - -function parseSignalAllowFromInput(raw: string): string[] { - return raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); + return setChannelDmPolicyWithAllowFrom({ + cfg, + channel: "signal", + dmPolicy, + }); } function isUuidLike(value: string): boolean { @@ -105,10 +62,10 @@ async function promptSignalAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = - params.accountId && normalizeAccountId(params.accountId) - ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultSignalAccountId(params.cfg); + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultSignalAccountId(params.cfg), + }); const resolved = resolveSignalAccount({ cfg: params.cfg, accountId }); const existing = resolved.config.allowFrom ?? []; await params.prompter.note( @@ -131,7 +88,7 @@ async function promptSignalAllowFrom(params: { if (!raw) { return "Required"; } - const parts = parseSignalAllowFromInput(raw); + const parts = splitOnboardingEntries(raw); for (const part of parts) { if (part === "*") { continue; @@ -152,7 +109,7 @@ async function promptSignalAllowFrom(params: { return undefined; }, }); - const parts = parseSignalAllowFromInput(String(entry)); + const parts = splitOnboardingEntries(String(entry)); const normalized = parts.map((part) => { if (part === "*") { return "*"; @@ -169,7 +126,12 @@ async function promptSignalAllowFrom(params: { undefined, normalized.filter((part): part is string => typeof part === "string" && part.trim().length > 0), ); - return setSignalAllowFrom(params.cfg, accountId, unique); + return setAccountAllowFromForChannel({ + cfg: params.cfg, + channel: "signal", + accountId, + allowFrom: unique, + }); } const dmPolicy: ChannelOnboardingDmPolicy = { @@ -209,21 +171,16 @@ export const signalOnboardingAdapter: ChannelOnboardingAdapter = { shouldPromptAccountIds, options, }) => { - const signalOverride = accountOverrides.signal?.trim(); const defaultSignalAccountId = resolveDefaultSignalAccountId(cfg); - let signalAccountId = signalOverride - ? normalizeAccountId(signalOverride) - : defaultSignalAccountId; - if (shouldPromptAccountIds && !signalOverride) { - signalAccountId = await promptAccountId({ - cfg, - prompter, - label: "Signal", - currentId: signalAccountId, - listAccountIds: listSignalAccountIds, - defaultAccountId: defaultSignalAccountId, - }); - } + const signalAccountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: "Signal", + accountOverride: accountOverrides.signal, + shouldPromptAccountIds, + listAccountIds: listSignalAccountIds, + defaultAccountId: defaultSignalAccountId, + }); let next = cfg; const resolvedAccount = resolveSignalAccount({ diff --git a/src/channels/plugins/onboarding/slack.ts b/src/channels/plugins/onboarding/slack.ts index 81cbdff7637..3937ce29826 100644 --- a/src/channels/plugins/onboarding/slack.ts +++ b/src/channels/plugins/onboarding/slack.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../../../config/config.js"; import type { DmPolicy } from "../../../config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; import { listSlackAccountIds, resolveDefaultSlackAccountId, @@ -12,21 +12,27 @@ import { formatDocsLink } from "../../../terminal/links.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; import { promptChannelAccessConfig } from "./channel-access.js"; -import { addWildcardAllowFrom, promptAccountId, promptResolvedAllowFrom } from "./helpers.js"; +import { + addWildcardAllowFrom, + promptResolvedAllowFrom, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + splitOnboardingEntries, +} from "./helpers.js"; const channel = "slack" as const; -function setSlackDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { - const existingAllowFrom = cfg.channels?.slack?.allowFrom ?? cfg.channels?.slack?.dm?.allowFrom; - const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined; +function patchSlackConfigWithDm( + cfg: OpenClawConfig, + patch: Record, +): OpenClawConfig { return { ...cfg, channels: { ...cfg.channels, slack: { ...cfg.channels?.slack, - dmPolicy, - ...(allowFrom ? { allowFrom } : {}), + ...patch, dm: { ...cfg.channels?.slack?.dm, enabled: cfg.channels?.slack?.dm?.enabled ?? true, @@ -36,6 +42,15 @@ function setSlackDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { }; } +function setSlackDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { + const existingAllowFrom = cfg.channels?.slack?.allowFrom ?? cfg.channels?.slack?.dm?.allowFrom; + const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined; + return patchSlackConfigWithDm(cfg, { + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }); +} + function buildSlackManifest(botName: string) { const safeName = botName.trim() || "OpenClaw"; const manifest = { @@ -199,27 +214,7 @@ function setSlackChannelAllowlist( } function setSlackAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { - return { - ...cfg, - channels: { - ...cfg.channels, - slack: { - ...cfg.channels?.slack, - allowFrom, - dm: { - ...cfg.channels?.slack?.dm, - enabled: cfg.channels?.slack?.dm?.enabled ?? true, - }, - }, - }, - }; -} - -function parseSlackAllowFromInput(raw: string): string[] { - return raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); + return patchSlackConfigWithDm(cfg, { allowFrom }); } async function promptSlackAllowFrom(params: { @@ -227,10 +222,10 @@ async function promptSlackAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = - params.accountId && normalizeAccountId(params.accountId) - ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultSlackAccountId(params.cfg); + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultSlackAccountId(params.cfg), + }); const resolved = resolveSlackAccount({ cfg: params.cfg, accountId }); const token = resolved.config.userToken ?? resolved.config.botToken ?? ""; const existing = @@ -246,7 +241,7 @@ async function promptSlackAllowFrom(params: { ].join("\n"), "Slack allowlist", ); - const parseInputs = (value: string) => parseSlackAllowFromInput(value); + const parseInputs = (value: string) => splitOnboardingEntries(value); const parseId = (value: string) => { const trimmed = value.trim(); if (!trimmed) { @@ -309,19 +304,16 @@ export const slackOnboardingAdapter: ChannelOnboardingAdapter = { }; }, configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { - const slackOverride = accountOverrides.slack?.trim(); const defaultSlackAccountId = resolveDefaultSlackAccountId(cfg); - let slackAccountId = slackOverride ? normalizeAccountId(slackOverride) : defaultSlackAccountId; - if (shouldPromptAccountIds && !slackOverride) { - slackAccountId = await promptAccountId({ - cfg, - prompter, - label: "Slack", - currentId: slackAccountId, - listAccountIds: listSlackAccountIds, - defaultAccountId: defaultSlackAccountId, - }); - } + const slackAccountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: "Slack", + accountOverride: accountOverrides.slack, + shouldPromptAccountIds, + listAccountIds: listSlackAccountIds, + defaultAccountId: defaultSlackAccountId, + }); let next = cfg; const resolvedAccount = resolveSlackAccount({ diff --git a/src/channels/plugins/onboarding/telegram.ts b/src/channels/plugins/onboarding/telegram.ts index c35140915c0..7efcaf91470 100644 --- a/src/channels/plugins/onboarding/telegram.ts +++ b/src/channels/plugins/onboarding/telegram.ts @@ -1,7 +1,7 @@ import { formatCliCommand } from "../../../cli/command-format.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { DmPolicy } from "../../../config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; import { listTelegramAccountIds, resolveDefaultTelegramAccountId, @@ -11,7 +11,13 @@ import { formatDocsLink } from "../../../terminal/links.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import { fetchTelegramChatId } from "../../telegram/api.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; -import { addWildcardAllowFrom, mergeAllowFromEntries, promptAccountId } from "./helpers.js"; +import { + addWildcardAllowFrom, + mergeAllowFromEntries, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + splitOnboardingEntries, +} from "./helpers.js"; const channel = "telegram" as const; @@ -89,12 +95,6 @@ async function promptTelegramAllowFrom(params: { return await fetchTelegramChatId({ token, chatId: username }); }; - const parseInput = (value: string) => - value - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); - let resolvedIds: string[] = []; while (resolvedIds.length === 0) { const entry = await prompter.text({ @@ -103,7 +103,7 @@ async function promptTelegramAllowFrom(params: { initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }); - const parts = parseInput(String(entry)); + const parts = splitOnboardingEntries(String(entry)); const results = await Promise.all(parts.map((part) => resolveTelegramUserId(part))); const unresolved = parts.filter((_, idx) => !results[idx]); if (unresolved.length > 0) { @@ -159,10 +159,10 @@ async function promptTelegramAllowFromForAccount(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = - params.accountId && normalizeAccountId(params.accountId) - ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultTelegramAccountId(params.cfg); + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultTelegramAccountId(params.cfg), + }); return promptTelegramAllowFrom({ cfg: params.cfg, prompter: params.prompter, @@ -201,21 +201,16 @@ export const telegramOnboardingAdapter: ChannelOnboardingAdapter = { shouldPromptAccountIds, forceAllowFrom, }) => { - const telegramOverride = accountOverrides.telegram?.trim(); const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg); - let telegramAccountId = telegramOverride - ? normalizeAccountId(telegramOverride) - : defaultTelegramAccountId; - if (shouldPromptAccountIds && !telegramOverride) { - telegramAccountId = await promptAccountId({ - cfg, - prompter, - label: "Telegram", - currentId: telegramAccountId, - listAccountIds: listTelegramAccountIds, - defaultAccountId: defaultTelegramAccountId, - }); - } + const telegramAccountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: "Telegram", + accountOverride: accountOverrides.telegram, + shouldPromptAccountIds, + listAccountIds: listTelegramAccountIds, + defaultAccountId: defaultTelegramAccountId, + }); let next = cfg; const resolvedAccount = resolveTelegramAccount({ diff --git a/src/channels/plugins/onboarding/whatsapp.test.ts b/src/channels/plugins/onboarding/whatsapp.test.ts new file mode 100644 index 00000000000..90ba9406033 --- /dev/null +++ b/src/channels/plugins/onboarding/whatsapp.test.ts @@ -0,0 +1,287 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; +import type { RuntimeEnv } from "../../../runtime.js"; +import type { WizardPrompter } from "../../../wizard/prompts.js"; +import { whatsappOnboardingAdapter } from "./whatsapp.js"; + +const loginWebMock = vi.hoisted(() => vi.fn(async () => {})); +const pathExistsMock = vi.hoisted(() => vi.fn(async () => false)); +const listWhatsAppAccountIdsMock = vi.hoisted(() => vi.fn(() => [] as string[])); +const resolveDefaultWhatsAppAccountIdMock = vi.hoisted(() => vi.fn(() => DEFAULT_ACCOUNT_ID)); +const resolveWhatsAppAuthDirMock = vi.hoisted(() => + vi.fn(() => ({ + authDir: "/tmp/openclaw-whatsapp-test", + })), +); + +vi.mock("../../../channel-web.js", () => ({ + loginWeb: loginWebMock, +})); + +vi.mock("../../../utils.js", async () => { + const actual = await vi.importActual("../../../utils.js"); + return { + ...actual, + pathExists: pathExistsMock, + }; +}); + +vi.mock("../../../web/accounts.js", () => ({ + listWhatsAppAccountIds: listWhatsAppAccountIdsMock, + resolveDefaultWhatsAppAccountId: resolveDefaultWhatsAppAccountIdMock, + resolveWhatsAppAuthDir: resolveWhatsAppAuthDirMock, +})); + +function createPrompterHarness(params?: { + selectValues?: string[]; + textValues?: string[]; + confirmValues?: boolean[]; +}) { + const selectValues = [...(params?.selectValues ?? [])]; + const textValues = [...(params?.textValues ?? [])]; + const confirmValues = [...(params?.confirmValues ?? [])]; + + const intro = vi.fn(async () => undefined); + const outro = vi.fn(async () => undefined); + const note = vi.fn(async () => undefined); + const select = vi.fn(async () => selectValues.shift() ?? ""); + const multiselect = vi.fn(async () => [] as string[]); + const text = vi.fn(async () => textValues.shift() ?? ""); + const confirm = vi.fn(async () => confirmValues.shift() ?? false); + const progress = vi.fn(() => ({ + update: vi.fn(), + stop: vi.fn(), + })); + + return { + intro, + outro, + note, + select, + multiselect, + text, + confirm, + progress, + prompter: { + intro, + outro, + note, + select, + multiselect, + text, + confirm, + progress, + } as WizardPrompter, + }; +} + +function createRuntime(): RuntimeEnv { + return { + error: vi.fn(), + } as unknown as RuntimeEnv; +} + +describe("whatsappOnboardingAdapter.configure", () => { + beforeEach(() => { + vi.clearAllMocks(); + pathExistsMock.mockResolvedValue(false); + listWhatsAppAccountIdsMock.mockReturnValue([]); + resolveDefaultWhatsAppAccountIdMock.mockReturnValue(DEFAULT_ACCOUNT_ID); + resolveWhatsAppAuthDirMock.mockReturnValue({ authDir: "/tmp/openclaw-whatsapp-test" }); + }); + + it("applies owner allowlist when forceAllowFrom is enabled", async () => { + const harness = createPrompterHarness({ + confirmValues: [false], + textValues: ["+1 (555) 555-0123"], + }); + + const result = await whatsappOnboardingAdapter.configure({ + cfg: {}, + runtime: createRuntime(), + prompter: harness.prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: true, + }); + + expect(result.accountId).toBe(DEFAULT_ACCOUNT_ID); + expect(loginWebMock).not.toHaveBeenCalled(); + expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(true); + expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]); + expect(harness.text).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Your personal WhatsApp number (the phone you will message from)", + }), + ); + }); + + it("supports disabled DM policy for separate-phone setup", async () => { + pathExistsMock.mockResolvedValue(true); + const harness = createPrompterHarness({ + confirmValues: [false], + selectValues: ["separate", "disabled"], + }); + + const result = await whatsappOnboardingAdapter.configure({ + cfg: {}, + runtime: createRuntime(), + prompter: harness.prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false); + expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("disabled"); + expect(result.cfg.channels?.whatsapp?.allowFrom).toBeUndefined(); + expect(harness.text).not.toHaveBeenCalled(); + }); + + it("normalizes allowFrom entries when list mode is selected", async () => { + pathExistsMock.mockResolvedValue(true); + const harness = createPrompterHarness({ + confirmValues: [false], + selectValues: ["separate", "allowlist", "list"], + textValues: ["+1 (555) 555-0123, +15555550123, *"], + }); + + const result = await whatsappOnboardingAdapter.configure({ + cfg: {}, + runtime: createRuntime(), + prompter: harness.prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false); + expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15555550123", "*"]); + }); + + it("enables allowlist self-chat mode for personal-phone setup", async () => { + pathExistsMock.mockResolvedValue(true); + const harness = createPrompterHarness({ + confirmValues: [false], + selectValues: ["personal"], + textValues: ["+1 (555) 111-2222"], + }); + + const result = await whatsappOnboardingAdapter.configure({ + cfg: {}, + runtime: createRuntime(), + prompter: harness.prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(true); + expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15551112222"]); + }); + + it("forces wildcard allowFrom for open policy without allowFrom follow-up prompts", async () => { + pathExistsMock.mockResolvedValue(true); + const harness = createPrompterHarness({ + confirmValues: [false], + selectValues: ["separate", "open"], + }); + + const result = await whatsappOnboardingAdapter.configure({ + cfg: { + channels: { + whatsapp: { + allowFrom: ["+15555550123"], + }, + }, + }, + runtime: createRuntime(), + prompter: harness.prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false); + expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("open"); + expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["*", "+15555550123"]); + expect(harness.select).toHaveBeenCalledTimes(2); + expect(harness.text).not.toHaveBeenCalled(); + }); + + it("runs WhatsApp login when not linked and user confirms linking", async () => { + pathExistsMock.mockResolvedValue(false); + const harness = createPrompterHarness({ + confirmValues: [true], + selectValues: ["separate", "disabled"], + }); + const runtime = createRuntime(); + + await whatsappOnboardingAdapter.configure({ + cfg: {}, + runtime, + prompter: harness.prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(loginWebMock).toHaveBeenCalledWith(false, undefined, runtime, DEFAULT_ACCOUNT_ID); + }); + + it("skips relink note when already linked and relink is declined", async () => { + pathExistsMock.mockResolvedValue(true); + const harness = createPrompterHarness({ + confirmValues: [false], + selectValues: ["separate", "disabled"], + }); + + await whatsappOnboardingAdapter.configure({ + cfg: {}, + runtime: createRuntime(), + prompter: harness.prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(loginWebMock).not.toHaveBeenCalled(); + expect(harness.note).not.toHaveBeenCalledWith( + expect.stringContaining("openclaw channels login"), + "WhatsApp", + ); + }); + + it("shows follow-up login command note when not linked and linking is skipped", async () => { + pathExistsMock.mockResolvedValue(false); + const harness = createPrompterHarness({ + confirmValues: [false], + selectValues: ["separate", "disabled"], + }); + + await whatsappOnboardingAdapter.configure({ + cfg: {}, + runtime: createRuntime(), + prompter: harness.prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(harness.note).toHaveBeenCalledWith( + expect.stringContaining("openclaw channels login"), + "WhatsApp", + ); + }); +}); diff --git a/src/channels/plugins/onboarding/whatsapp.ts b/src/channels/plugins/onboarding/whatsapp.ts index 80be2a47020..4b0d9ceda14 100644 --- a/src/channels/plugins/onboarding/whatsapp.ts +++ b/src/channels/plugins/onboarding/whatsapp.ts @@ -4,7 +4,7 @@ import { formatCliCommand } from "../../../cli/command-format.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { mergeWhatsAppConfig } from "../../../config/merge-config.js"; import type { DmPolicy } from "../../../config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; import type { RuntimeEnv } from "../../../runtime.js"; import { formatDocsLink } from "../../../terminal/links.js"; import { normalizeE164, pathExists } from "../../../utils.js"; @@ -15,7 +15,12 @@ import { } from "../../../web/accounts.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { ChannelOnboardingAdapter } from "../onboarding-types.js"; -import { mergeAllowFromEntries, promptAccountId } from "./helpers.js"; +import { + normalizeAllowFromEntries, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + splitOnboardingEntries, +} from "./helpers.js"; const channel = "whatsapp" as const; @@ -68,14 +73,10 @@ async function promptWhatsAppOwnerAllowFrom(params: { if (!normalized) { throw new Error("Invalid WhatsApp owner number (expected E.164 after validation)."); } - const merged = [ - ...existingAllowFrom - .filter((item) => item !== "*") - .map((item) => normalizeE164(item)) - .filter((item): item is string => typeof item === "string" && item.trim().length > 0), - normalized, - ]; - const allowFrom = mergeAllowFromEntries(undefined, merged); + const allowFrom = normalizeAllowFromEntries( + [...existingAllowFrom.filter((item) => item !== "*"), normalized], + normalizeE164, + ); return { normalized, allowFrom }; } @@ -100,6 +101,26 @@ async function applyWhatsAppOwnerAllowlist(params: { return next; } +function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invalidEntry?: string } { + const parts = splitOnboardingEntries(raw); + if (parts.length === 0) { + return { entries: [] }; + } + const entries: string[] = []; + for (const part of parts) { + if (part === "*") { + entries.push("*"); + continue; + } + const normalized = normalizeE164(part); + if (!normalized) { + return { entries: [], invalidEntry: part }; + } + entries.push(normalized); + } + return { entries: normalizeAllowFromEntries(entries, normalizeE164) }; +} + async function promptWhatsAppAllowFrom( cfg: OpenClawConfig, _runtime: RuntimeEnv, @@ -168,7 +189,9 @@ async function promptWhatsAppAllowFrom( let next = setWhatsAppSelfChatMode(cfg, false); next = setWhatsAppDmPolicy(next, policy); if (policy === "open") { - next = setWhatsAppAllowFrom(next, ["*"]); + const allowFrom = normalizeAllowFromEntries(["*", ...existingAllowFrom], normalizeE164); + next = setWhatsAppAllowFrom(next, allowFrom.length > 0 ? allowFrom : ["*"]); + return next; } if (policy === "disabled") { return next; @@ -210,35 +233,19 @@ async function promptWhatsAppAllowFrom( if (!raw) { return "Required"; } - const parts = raw - .split(/[\n,;]+/g) - .map((p) => p.trim()) - .filter(Boolean); - if (parts.length === 0) { + const parsed = parseWhatsAppAllowFromEntries(raw); + if (parsed.entries.length === 0 && !parsed.invalidEntry) { return "Required"; } - for (const part of parts) { - if (part === "*") { - continue; - } - const normalized = normalizeE164(part); - if (!normalized) { - return `Invalid number: ${part}`; - } + if (parsed.invalidEntry) { + return `Invalid number: ${parsed.invalidEntry}`; } return undefined; }, }); - const parts = String(allowRaw) - .split(/[\n,;]+/g) - .map((p) => p.trim()) - .filter(Boolean); - const normalized = parts - .map((part) => (part === "*" ? "*" : normalizeE164(part))) - .filter((part): part is string => typeof part === "string" && part.trim().length > 0); - const unique = mergeAllowFromEntries(undefined, normalized); - next = setWhatsAppAllowFrom(next, unique); + const parsed = parseWhatsAppAllowFromEntries(String(allowRaw)); + next = setWhatsAppAllowFrom(next, parsed.entries); } return next; @@ -247,9 +254,11 @@ async function promptWhatsAppAllowFrom( export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = { channel, getStatus: async ({ cfg, accountOverrides }) => { - const overrideId = accountOverrides.whatsapp?.trim(); const defaultAccountId = resolveDefaultWhatsAppAccountId(cfg); - const accountId = overrideId ? normalizeAccountId(overrideId) : defaultAccountId; + const accountId = resolveOnboardingAccountId({ + accountId: accountOverrides.whatsapp, + defaultAccountId, + }); const linked = await detectWhatsAppLinked(cfg, accountId); const accountLabel = accountId === DEFAULT_ACCOUNT_ID ? "default" : accountId; return { @@ -269,22 +278,15 @@ export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = { shouldPromptAccountIds, forceAllowFrom, }) => { - const overrideId = accountOverrides.whatsapp?.trim(); - let accountId = overrideId - ? normalizeAccountId(overrideId) - : resolveDefaultWhatsAppAccountId(cfg); - if (shouldPromptAccountIds || options?.promptWhatsAppAccountId) { - if (!overrideId) { - accountId = await promptAccountId({ - cfg, - prompter, - label: "WhatsApp", - currentId: accountId, - listAccountIds: listWhatsAppAccountIds, - defaultAccountId: resolveDefaultWhatsAppAccountId(cfg), - }); - } - } + const accountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: "WhatsApp", + accountOverride: accountOverrides.whatsapp, + shouldPromptAccountIds: Boolean(shouldPromptAccountIds || options?.promptWhatsAppAccountId), + listAccountIds: listWhatsAppAccountIds, + defaultAccountId: resolveDefaultWhatsAppAccountId(cfg), + }); let next = cfg; if (accountId !== DEFAULT_ACCOUNT_ID) { From 05358173da71f201804e4af5de4383497b7fa123 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:28:49 +0000 Subject: [PATCH 35/81] fix(line): harden outbound send behavior --- src/line/send.test.ts | 229 +++++++++++++++++++++++++++- src/line/send.ts | 337 ++++++++++++++---------------------------- 2 files changed, 336 insertions(+), 230 deletions(-) diff --git a/src/line/send.test.ts b/src/line/send.test.ts index 317ab3084f2..01695925932 100644 --- a/src/line/send.test.ts +++ b/src/line/send.test.ts @@ -1,11 +1,228 @@ -import { describe, expect, it } from "vitest"; -import { createQuickReplyItems } from "./send.js"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -describe("createQuickReplyItems", () => { - it("limits items to 13 (LINE maximum)", () => { - const labels = Array.from({ length: 20 }, (_, i) => `Option ${i + 1}`); - const quickReply = createQuickReplyItems(labels); +const { + pushMessageMock, + replyMessageMock, + showLoadingAnimationMock, + getProfileMock, + MessagingApiClientMock, + loadConfigMock, + resolveLineAccountMock, + resolveLineChannelAccessTokenMock, + recordChannelActivityMock, + logVerboseMock, +} = vi.hoisted(() => { + const pushMessageMock = vi.fn(); + const replyMessageMock = vi.fn(); + const showLoadingAnimationMock = vi.fn(); + const getProfileMock = vi.fn(); + const MessagingApiClientMock = vi.fn(function () { + return { + pushMessage: pushMessageMock, + replyMessage: replyMessageMock, + showLoadingAnimation: showLoadingAnimationMock, + getProfile: getProfileMock, + }; + }); + const loadConfigMock = vi.fn(() => ({})); + const resolveLineAccountMock = vi.fn(() => ({ accountId: "default" })); + const resolveLineChannelAccessTokenMock = vi.fn(() => "line-token"); + const recordChannelActivityMock = vi.fn(); + const logVerboseMock = vi.fn(); + return { + pushMessageMock, + replyMessageMock, + showLoadingAnimationMock, + getProfileMock, + MessagingApiClientMock, + loadConfigMock, + resolveLineAccountMock, + resolveLineChannelAccessTokenMock, + recordChannelActivityMock, + logVerboseMock, + }; +}); + +vi.mock("@line/bot-sdk", () => ({ + messagingApi: { MessagingApiClient: MessagingApiClientMock }, +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: loadConfigMock, +})); + +vi.mock("./accounts.js", () => ({ + resolveLineAccount: resolveLineAccountMock, +})); + +vi.mock("./channel-access-token.js", () => ({ + resolveLineChannelAccessToken: resolveLineChannelAccessTokenMock, +})); + +vi.mock("../infra/channel-activity.js", () => ({ + recordChannelActivity: recordChannelActivityMock, +})); + +vi.mock("../globals.js", () => ({ + logVerbose: logVerboseMock, +})); + +let sendModule: typeof import("./send.js"); + +describe("LINE send helpers", () => { + beforeAll(async () => { + sendModule = await import("./send.js"); + }); + + beforeEach(() => { + pushMessageMock.mockReset(); + replyMessageMock.mockReset(); + showLoadingAnimationMock.mockReset(); + getProfileMock.mockReset(); + MessagingApiClientMock.mockClear(); + loadConfigMock.mockReset(); + resolveLineAccountMock.mockReset(); + resolveLineChannelAccessTokenMock.mockReset(); + recordChannelActivityMock.mockReset(); + logVerboseMock.mockReset(); + + loadConfigMock.mockReturnValue({}); + resolveLineAccountMock.mockReturnValue({ accountId: "default" }); + resolveLineChannelAccessTokenMock.mockReturnValue("line-token"); + pushMessageMock.mockResolvedValue({}); + replyMessageMock.mockResolvedValue({}); + showLoadingAnimationMock.mockResolvedValue({}); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("limits quick reply items to 13", () => { + const labels = Array.from({ length: 20 }, (_, index) => `Option ${index + 1}`); + const quickReply = sendModule.createQuickReplyItems(labels); expect(quickReply.items).toHaveLength(13); }); + + it("pushes images via normalized LINE target", async () => { + const result = await sendModule.pushImageMessage( + "line:user:U123", + "https://example.com/original.jpg", + undefined, + { verbose: true }, + ); + + expect(pushMessageMock).toHaveBeenCalledWith({ + to: "U123", + messages: [ + { + type: "image", + originalContentUrl: "https://example.com/original.jpg", + previewImageUrl: "https://example.com/original.jpg", + }, + ], + }); + expect(recordChannelActivityMock).toHaveBeenCalledWith({ + channel: "line", + accountId: "default", + direction: "outbound", + }); + expect(logVerboseMock).toHaveBeenCalledWith("line: pushed image to U123"); + expect(result).toEqual({ messageId: "push", chatId: "U123" }); + }); + + it("replies when reply token is provided", async () => { + const result = await sendModule.sendMessageLine("line:group:C1", "Hello", { + replyToken: "reply-token", + mediaUrl: "https://example.com/media.jpg", + verbose: true, + }); + + expect(replyMessageMock).toHaveBeenCalledTimes(1); + expect(pushMessageMock).not.toHaveBeenCalled(); + expect(replyMessageMock).toHaveBeenCalledWith({ + replyToken: "reply-token", + messages: [ + { + type: "image", + originalContentUrl: "https://example.com/media.jpg", + previewImageUrl: "https://example.com/media.jpg", + }, + { + type: "text", + text: "Hello", + }, + ], + }); + expect(logVerboseMock).toHaveBeenCalledWith("line: replied to C1"); + expect(result).toEqual({ messageId: "reply", chatId: "C1" }); + }); + + it("throws when push messages are empty", async () => { + await expect(sendModule.pushMessagesLine("U123", [])).rejects.toThrow( + "Message must be non-empty for LINE sends", + ); + }); + + it("logs HTTP body when push fails", async () => { + const err = new Error("LINE push failed") as Error & { + status: number; + statusText: string; + body: string; + }; + err.status = 400; + err.statusText = "Bad Request"; + err.body = "invalid flex payload"; + pushMessageMock.mockRejectedValueOnce(err); + + await expect( + sendModule.pushMessagesLine("U999", [{ type: "text", text: "hello" }]), + ).rejects.toThrow("LINE push failed"); + + expect(logVerboseMock).toHaveBeenCalledWith( + "line: push message failed (400 Bad Request): invalid flex payload", + ); + }); + + it("caches profile results by default", async () => { + getProfileMock.mockResolvedValue({ + displayName: "Peter", + pictureUrl: "https://example.com/peter.jpg", + }); + + const first = await sendModule.getUserProfile("U-cache"); + const second = await sendModule.getUserProfile("U-cache"); + + expect(first).toEqual({ + displayName: "Peter", + pictureUrl: "https://example.com/peter.jpg", + }); + expect(second).toEqual(first); + expect(getProfileMock).toHaveBeenCalledTimes(1); + }); + + it("continues when loading animation is unsupported", async () => { + showLoadingAnimationMock.mockRejectedValueOnce(new Error("unsupported")); + + await expect(sendModule.showLoadingAnimation("line:room:R1")).resolves.toBeUndefined(); + + expect(logVerboseMock).toHaveBeenCalledWith( + expect.stringContaining("line: loading animation failed (non-fatal)"), + ); + }); + + it("pushes quick-reply text and caps to 13 buttons", async () => { + await sendModule.pushTextMessageWithQuickReplies( + "U-quick", + "Pick one", + Array.from({ length: 20 }, (_, index) => `Choice ${index + 1}`), + ); + + expect(pushMessageMock).toHaveBeenCalledTimes(1); + const firstCall = pushMessageMock.mock.calls[0] as [ + { messages: Array<{ quickReply?: { items: unknown[] } }> }, + ]; + expect(firstCall[0].messages[0].quickReply?.items).toHaveLength(13); + }); }); diff --git a/src/line/send.ts b/src/line/send.ts index f68df9a290e..7b6f4ac936e 100644 --- a/src/line/send.ts +++ b/src/line/send.ts @@ -32,6 +32,18 @@ interface LineSendOpts { replyToken?: string; } +type LineClientOpts = Pick; +type LinePushOpts = Pick; + +interface LinePushBehavior { + errorContext?: string; + verboseMessage?: (chatId: string, messageCount: number) => string; +} + +interface LineReplyBehavior { + verboseMessage?: (messageCount: number) => string; +} + function normalizeTarget(to: string): string { const trimmed = to.trim(); if (!trimmed) { @@ -52,7 +64,7 @@ function normalizeTarget(to: string): string { return normalized; } -function createLineMessagingClient(opts: { channelAccessToken?: string; accountId?: string }): { +function createLineMessagingClient(opts: LineClientOpts): { account: ReturnType; client: messagingApi.MessagingApiClient; } { @@ -70,7 +82,7 @@ function createLineMessagingClient(opts: { channelAccessToken?: string; accountI function createLinePushContext( to: string, - opts: { channelAccessToken?: string; accountId?: string }, + opts: LineClientOpts, ): { account: ReturnType; client: messagingApi.MessagingApiClient; @@ -126,23 +138,85 @@ function logLineHttpError(err: unknown, context: string): void { } } +function recordLineOutboundActivity(accountId: string): void { + recordChannelActivity({ + channel: "line", + accountId, + direction: "outbound", + }); +} + +async function pushLineMessages( + to: string, + messages: Message[], + opts: LinePushOpts = {}, + behavior: LinePushBehavior = {}, +): Promise { + if (messages.length === 0) { + throw new Error("Message must be non-empty for LINE sends"); + } + + const { account, client, chatId } = createLinePushContext(to, opts); + const pushRequest = client.pushMessage({ + to: chatId, + messages, + }); + + if (behavior.errorContext) { + const errorContext = behavior.errorContext; + await pushRequest.catch((err) => { + logLineHttpError(err, errorContext); + throw err; + }); + } else { + await pushRequest; + } + + recordLineOutboundActivity(account.accountId); + + if (opts.verbose) { + const logMessage = + behavior.verboseMessage?.(chatId, messages.length) ?? + `line: pushed ${messages.length} messages to ${chatId}`; + logVerbose(logMessage); + } + + return { + messageId: "push", + chatId, + }; +} + +async function replyLineMessages( + replyToken: string, + messages: Message[], + opts: LinePushOpts = {}, + behavior: LineReplyBehavior = {}, +): Promise { + const { account, client } = createLineMessagingClient(opts); + + await client.replyMessage({ + replyToken, + messages, + }); + + recordLineOutboundActivity(account.accountId); + + if (opts.verbose) { + logVerbose( + behavior.verboseMessage?.(messages.length) ?? + `line: replied with ${messages.length} messages`, + ); + } +} + export async function sendMessageLine( to: string, text: string, opts: LineSendOpts = {}, ): Promise { - const cfg = loadConfig(); - const account = resolveLineAccount({ - cfg, - accountId: opts.accountId, - }); - const token = resolveLineChannelAccessToken(opts.channelAccessToken, account); const chatId = normalizeTarget(to); - const client = new messagingApi.MessagingApiClient({ - channelAccessToken: token, - }); - const messages: Message[] = []; // Add media if provided @@ -161,21 +235,10 @@ export async function sendMessageLine( // Use reply if we have a reply token, otherwise push if (opts.replyToken) { - await client.replyMessage({ - replyToken: opts.replyToken, - messages, + await replyLineMessages(opts.replyToken, messages, opts, { + verboseMessage: () => `line: replied to ${chatId}`, }); - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", - }); - - if (opts.verbose) { - logVerbose(`line: replied to ${chatId}`); - } - return { messageId: "reply", chatId, @@ -183,25 +246,9 @@ export async function sendMessageLine( } // Push message (for proactive messaging) - await client.pushMessage({ - to: chatId, - messages, + return pushLineMessages(chatId, messages, opts, { + verboseMessage: (resolvedChatId) => `line: pushed message to ${resolvedChatId}`, }); - - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", - }); - - if (opts.verbose) { - logVerbose(`line: pushed message to ${chatId}`); - } - - return { - messageId: "push", - chatId, - }; } export async function pushMessageLine( @@ -216,61 +263,19 @@ export async function pushMessageLine( export async function replyMessageLine( replyToken: string, messages: Message[], - opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, + opts: LinePushOpts = {}, ): Promise { - const { account, client } = createLineMessagingClient(opts); - - await client.replyMessage({ - replyToken, - messages, - }); - - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", - }); - - if (opts.verbose) { - logVerbose(`line: replied with ${messages.length} messages`); - } + await replyLineMessages(replyToken, messages, opts); } export async function pushMessagesLine( to: string, messages: Message[], - opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, + opts: LinePushOpts = {}, ): Promise { - if (messages.length === 0) { - throw new Error("Message must be non-empty for LINE sends"); - } - - const { account, client, chatId } = createLinePushContext(to, opts); - - await client - .pushMessage({ - to: chatId, - messages, - }) - .catch((err) => { - logLineHttpError(err, "push message"); - throw err; - }); - - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", + return pushLineMessages(to, messages, opts, { + errorContext: "push message", }); - - if (opts.verbose) { - logVerbose(`line: pushed ${messages.length} messages to ${chatId}`); - } - - return { - messageId: "push", - chatId, - }; } export function createFlexMessage( @@ -291,31 +296,11 @@ export async function pushImageMessage( to: string, originalContentUrl: string, previewImageUrl?: string, - opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, + opts: LinePushOpts = {}, ): Promise { - const { account, client, chatId } = createLinePushContext(to, opts); - - const imageMessage = createImageMessage(originalContentUrl, previewImageUrl); - - await client.pushMessage({ - to: chatId, - messages: [imageMessage], + return pushLineMessages(to, [createImageMessage(originalContentUrl, previewImageUrl)], opts, { + verboseMessage: (chatId) => `line: pushed image to ${chatId}`, }); - - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", - }); - - if (opts.verbose) { - logVerbose(`line: pushed image to ${chatId}`); - } - - return { - messageId: "push", - chatId, - }; } /** @@ -329,31 +314,11 @@ export async function pushLocationMessage( latitude: number; longitude: number; }, - opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, + opts: LinePushOpts = {}, ): Promise { - const { account, client, chatId } = createLinePushContext(to, opts); - - const locationMessage = createLocationMessage(location); - - await client.pushMessage({ - to: chatId, - messages: [locationMessage], + return pushLineMessages(to, [createLocationMessage(location)], opts, { + verboseMessage: (chatId) => `line: pushed location to ${chatId}`, }); - - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", - }); - - if (opts.verbose) { - logVerbose(`line: pushed location to ${chatId}`); - } - - return { - messageId: "push", - chatId, - }; } /** @@ -363,40 +328,18 @@ export async function pushFlexMessage( to: string, altText: string, contents: FlexContainer, - opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, + opts: LinePushOpts = {}, ): Promise { - const { account, client, chatId } = createLinePushContext(to, opts); - const flexMessage: FlexMessage = { type: "flex", altText: altText.slice(0, 400), // LINE limit contents, }; - await client - .pushMessage({ - to: chatId, - messages: [flexMessage], - }) - .catch((err) => { - logLineHttpError(err, "push flex message"); - throw err; - }); - - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", + return pushLineMessages(to, [flexMessage], opts, { + errorContext: "push flex message", + verboseMessage: (chatId) => `line: pushed flex message to ${chatId}`, }); - - if (opts.verbose) { - logVerbose(`line: pushed flex message to ${chatId}`); - } - - return { - messageId: "push", - chatId, - }; } /** @@ -405,29 +348,11 @@ export async function pushFlexMessage( export async function pushTemplateMessage( to: string, template: TemplateMessage, - opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, + opts: LinePushOpts = {}, ): Promise { - const { account, client, chatId } = createLinePushContext(to, opts); - - await client.pushMessage({ - to: chatId, - messages: [template], + return pushLineMessages(to, [template], opts, { + verboseMessage: (chatId) => `line: pushed template message to ${chatId}`, }); - - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", - }); - - if (opts.verbose) { - logVerbose(`line: pushed template message to ${chatId}`); - } - - return { - messageId: "push", - chatId, - }; } /** @@ -437,31 +362,13 @@ export async function pushTextMessageWithQuickReplies( to: string, text: string, quickReplyLabels: string[], - opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, + opts: LinePushOpts = {}, ): Promise { - const { account, client, chatId } = createLinePushContext(to, opts); - const message = createTextMessageWithQuickReplies(text, quickReplyLabels); - await client.pushMessage({ - to: chatId, - messages: [message], + return pushLineMessages(to, [message], opts, { + verboseMessage: (chatId) => `line: pushed message with quick replies to ${chatId}`, }); - - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", - }); - - if (opts.verbose) { - logVerbose(`line: pushed message with quick replies to ${chatId}`); - } - - return { - messageId: "push", - chatId, - }; } /** @@ -500,16 +407,7 @@ export async function showLoadingAnimation( chatId: string, opts: { channelAccessToken?: string; accountId?: string; loadingSeconds?: number } = {}, ): Promise { - const cfg = loadConfig(); - const account = resolveLineAccount({ - cfg, - accountId: opts.accountId, - }); - const token = resolveLineChannelAccessToken(opts.channelAccessToken, account); - - const client = new messagingApi.MessagingApiClient({ - channelAccessToken: token, - }); + const { client } = createLineMessagingClient(opts); try { await client.showLoadingAnimation({ @@ -540,16 +438,7 @@ export async function getUserProfile( } } - const cfg = loadConfig(); - const account = resolveLineAccount({ - cfg, - accountId: opts.accountId, - }); - const token = resolveLineChannelAccessToken(opts.channelAccessToken, account); - - const client = new messagingApi.MessagingApiClient({ - channelAccessToken: token, - }); + const { client } = createLineMessagingClient(opts); try { const profile = await client.getProfile(userId); From 0f989d3109a4c0dd640efd51c31e6276d49ae4e6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:28:55 +0000 Subject: [PATCH 36/81] fix(gateway): tighten openai-http edge handling --- src/gateway/openai-http.e2e.test.ts | 40 +++ src/gateway/openai-http.ts | 139 ++++---- .../server.models-voicewake-misc.e2e.test.ts | 301 +++++++++--------- 3 files changed, 258 insertions(+), 222 deletions(-) diff --git a/src/gateway/openai-http.e2e.test.ts b/src/gateway/openai-http.e2e.test.ts index 36c9cadfc42..e8571e88e90 100644 --- a/src/gateway/openai-http.e2e.test.ts +++ b/src/gateway/openai-http.e2e.test.ts @@ -334,6 +334,21 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { expect(msg.content).toBe("hello"); } + { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ payloads: [{ text: "" }] } as never); + const res = await postChatCompletions(port, { + stream: false, + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }); + expect(res.status).toBe(200); + const json = (await res.json()) as Record; + const choice0 = (json.choices as Array>)[0] ?? {}; + const msg = (choice0.message as Record | undefined) ?? {}; + expect(msg.content).toBe("No response from OpenClaw."); + } + { const res = await postChatCompletions(port, { model: "openclaw", @@ -475,6 +490,31 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { expect(fallbackText).toContain("[DONE]"); expect(fallbackText).toContain("hello"); } + + { + agentCommand.mockClear(); + agentCommand.mockRejectedValueOnce(new Error("boom")); + + const errorRes = await postChatCompletions(port, { + stream: true, + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }); + expect(errorRes.status).toBe(200); + const errorText = await errorRes.text(); + const errorData = parseSseDataLines(errorText); + expect(errorData[errorData.length - 1]).toBe("[DONE]"); + + const errorChunks = errorData + .filter((d) => d !== "[DONE]") + .map((d) => JSON.parse(d) as Record); + const stopChoice = errorChunks + .flatMap((c) => (c.choices as Array> | undefined) ?? []) + .find((choice) => choice.finish_reason === "stop"); + expect((stopChoice?.delta as Record | undefined)?.content).toBe( + "Error: internal error", + ); + } } finally { // shared server } diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 354d389f73a..8a616866752 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -41,6 +41,51 @@ function writeSse(res: ServerResponse, data: unknown) { res.write(`data: ${JSON.stringify(data)}\n\n`); } +function buildAgentCommandInput(params: { + prompt: { message: string; extraSystemPrompt?: string }; + sessionKey: string; + runId: string; +}) { + return { + message: params.prompt.message, + extraSystemPrompt: params.prompt.extraSystemPrompt, + sessionKey: params.sessionKey, + runId: params.runId, + deliver: false as const, + messageChannel: "webchat" as const, + bestEffortDeliver: false as const, + }; +} + +function writeAssistantRoleChunk(res: ServerResponse, params: { runId: string; model: string }) { + writeSse(res, { + id: params.runId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: params.model, + choices: [{ index: 0, delta: { role: "assistant" } }], + }); +} + +function writeAssistantContentChunk( + res: ServerResponse, + params: { runId: string; model: string; content: string; finishReason: "stop" | null }, +) { + writeSse(res, { + id: params.runId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: params.model, + choices: [ + { + index: 0, + delta: { content: params.content }, + finish_reason: params.finishReason, + }, + ], + }); +} + function asMessages(val: unknown): OpenAiChatMessage[] { return Array.isArray(val) ? (val as OpenAiChatMessage[]) : []; } @@ -194,22 +239,15 @@ export async function handleOpenAiHttpRequest( const runId = `chatcmpl_${randomUUID()}`; const deps = createDefaultDeps(); + const commandInput = buildAgentCommandInput({ + prompt, + sessionKey, + runId, + }); if (!stream) { try { - const result = await agentCommand( - { - message: prompt.message, - extraSystemPrompt: prompt.extraSystemPrompt, - sessionKey, - runId, - deliver: false, - messageChannel: "webchat", - bestEffortDeliver: false, - }, - defaultRuntime, - deps, - ); + const result = await agentCommand(commandInput, defaultRuntime, deps); const content = resolveAgentResponseText(result); @@ -258,28 +296,15 @@ export async function handleOpenAiHttpRequest( if (!wroteRole) { wroteRole = true; - writeSse(res, { - id: runId, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [{ index: 0, delta: { role: "assistant" } }], - }); + writeAssistantRoleChunk(res, { runId, model }); } sawAssistantDelta = true; - writeSse(res, { - id: runId, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), + writeAssistantContentChunk(res, { + runId, model, - choices: [ - { - index: 0, - delta: { content }, - finish_reason: null, - }, - ], + content, + finishReason: null, }); return; } @@ -302,19 +327,7 @@ export async function handleOpenAiHttpRequest( void (async () => { try { - const result = await agentCommand( - { - message: prompt.message, - extraSystemPrompt: prompt.extraSystemPrompt, - sessionKey, - runId, - deliver: false, - messageChannel: "webchat", - bestEffortDeliver: false, - }, - defaultRuntime, - deps, - ); + const result = await agentCommand(commandInput, defaultRuntime, deps); if (closed) { return; @@ -323,30 +336,17 @@ export async function handleOpenAiHttpRequest( if (!sawAssistantDelta) { if (!wroteRole) { wroteRole = true; - writeSse(res, { - id: runId, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [{ index: 0, delta: { role: "assistant" } }], - }); + writeAssistantRoleChunk(res, { runId, model }); } const content = resolveAgentResponseText(result); sawAssistantDelta = true; - writeSse(res, { - id: runId, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), + writeAssistantContentChunk(res, { + runId, model, - choices: [ - { - index: 0, - delta: { content }, - finish_reason: null, - }, - ], + content, + finishReason: null, }); } } catch (err) { @@ -354,18 +354,11 @@ export async function handleOpenAiHttpRequest( if (closed) { return; } - writeSse(res, { - id: runId, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), + writeAssistantContentChunk(res, { + runId, model, - choices: [ - { - index: 0, - delta: { content: "Error: internal error" }, - finish_reason: "stop", - }, - ], + content: "Error: internal error", + finishReason: "stop", }); emitAgentEvent({ runId, diff --git a/src/gateway/server.models-voicewake-misc.e2e.test.ts b/src/gateway/server.models-voicewake-misc.e2e.test.ts index 1d7c954a310..1963dcee85e 100644 --- a/src/gateway/server.models-voicewake-misc.e2e.test.ts +++ b/src/gateway/server.models-voicewake-misc.e2e.test.ts @@ -81,7 +81,106 @@ const whatsappRegistry = createRegistry([ ]); const emptyRegistry = createRegistry([]); +type ModelCatalogRpcEntry = { + id: string; + name: string; + provider: string; + contextWindow?: number; +}; + +type PiCatalogFixtureEntry = { + id: string; + provider: string; + name?: string; + contextWindow?: number; +}; + +const buildPiCatalogFixture = (): PiCatalogFixtureEntry[] => [ + { id: "gpt-test-z", provider: "openai", contextWindow: 0 }, + { + id: "gpt-test-a", + name: "A-Model", + provider: "openai", + contextWindow: 8000, + }, + { + id: "claude-test-b", + name: "B-Model", + provider: "anthropic", + contextWindow: 1000, + }, + { + id: "claude-test-a", + name: "A-Model", + provider: "anthropic", + contextWindow: 200_000, + }, +]; + +const expectedSortedCatalog = (): ModelCatalogRpcEntry[] => [ + { + id: "claude-test-a", + name: "A-Model", + provider: "anthropic", + contextWindow: 200_000, + }, + { + id: "claude-test-b", + name: "B-Model", + provider: "anthropic", + contextWindow: 1000, + }, + { + id: "gpt-test-a", + name: "A-Model", + provider: "openai", + contextWindow: 8000, + }, + { + id: "gpt-test-z", + name: "gpt-test-z", + provider: "openai", + }, +]; + describe("gateway server models + voicewake", () => { + const listModels = async () => rpcReq<{ models: ModelCatalogRpcEntry[] }>(ws, "models.list"); + + const seedPiCatalog = () => { + piSdkMock.enabled = true; + piSdkMock.models = buildPiCatalogFixture(); + }; + + const withModelsConfig = async (config: unknown, run: () => Promise): Promise => { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("Missing OPENCLAW_CONFIG_PATH"); + } + let previousConfig: string | undefined; + try { + previousConfig = await fs.readFile(configPath, "utf-8"); + } catch (err) { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + if (code !== "ENOENT") { + throw err; + } + } + + try { + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8"); + clearConfigCache(); + return await run(); + } finally { + if (previousConfig === undefined) { + await fs.rm(configPath, { force: true }); + } else { + await fs.writeFile(configPath, previousConfig, "utf-8"); + } + clearConfigCache(); + } + }; + const withTempHome = async (fn: (homeDir: string) => Promise): Promise => { const tempHome = await createTempHomeEnv("openclaw-home-"); try { @@ -178,171 +277,75 @@ describe("gateway server models + voicewake", () => { }); test("models.list returns model catalog", async () => { - piSdkMock.enabled = true; - piSdkMock.models = [ - { id: "gpt-test-z", provider: "openai", contextWindow: 0 }, - { - id: "gpt-test-a", - name: "A-Model", - provider: "openai", - contextWindow: 8000, - }, - { - id: "claude-test-b", - name: "B-Model", - provider: "anthropic", - contextWindow: 1000, - }, - { - id: "claude-test-a", - name: "A-Model", - provider: "anthropic", - contextWindow: 200_000, - }, - ]; + seedPiCatalog(); - const res1 = await rpcReq<{ - models: Array<{ - id: string; - name: string; - provider: string; - contextWindow?: number; - }>; - }>(ws, "models.list"); - - const res2 = await rpcReq<{ - models: Array<{ - id: string; - name: string; - provider: string; - contextWindow?: number; - }>; - }>(ws, "models.list"); + const res1 = await listModels(); + const res2 = await listModels(); expect(res1.ok).toBe(true); expect(res2.ok).toBe(true); const models = res1.payload?.models ?? []; - expect(models).toEqual([ - { - id: "claude-test-a", - name: "A-Model", - provider: "anthropic", - contextWindow: 200_000, - }, - { - id: "claude-test-b", - name: "B-Model", - provider: "anthropic", - contextWindow: 1000, - }, - { - id: "gpt-test-a", - name: "A-Model", - provider: "openai", - contextWindow: 8000, - }, - { - id: "gpt-test-z", - name: "gpt-test-z", - provider: "openai", - }, - ]); + expect(models).toEqual(expectedSortedCatalog()); expect(piSdkMock.discoverCalls).toBe(1); }); test("models.list filters to allowlisted configured models by default", async () => { - const configPath = process.env.OPENCLAW_CONFIG_PATH; - if (!configPath) { - throw new Error("Missing OPENCLAW_CONFIG_PATH"); - } - let previousConfig: string | undefined; - try { - previousConfig = await fs.readFile(configPath, "utf-8"); - } catch (err) { - const code = (err as NodeJS.ErrnoException | undefined)?.code; - if (code !== "ENOENT") { - throw err; - } - } - try { - await fs.mkdir(path.dirname(configPath), { recursive: true }); - await fs.writeFile( - configPath, - JSON.stringify( - { - agents: { - defaults: { - model: { primary: "openai/gpt-test-z" }, - models: { - "openai/gpt-test-z": {}, - "anthropic/claude-test-a": {}, - }, - }, + await withModelsConfig( + { + agents: { + defaults: { + model: { primary: "openai/gpt-test-z" }, + models: { + "openai/gpt-test-z": {}, + "anthropic/claude-test-a": {}, }, }, - null, - 2, - ), - "utf-8", - ); - clearConfigCache(); + }, + }, + async () => { + seedPiCatalog(); + const res = await listModels(); - piSdkMock.enabled = true; - piSdkMock.models = [ - { id: "gpt-test-z", provider: "openai", contextWindow: 0 }, - { - id: "gpt-test-a", - name: "A-Model", - provider: "openai", - contextWindow: 8000, - }, - { - id: "claude-test-b", - name: "B-Model", - provider: "anthropic", - contextWindow: 1000, - }, - { - id: "claude-test-a", - name: "A-Model", - provider: "anthropic", - contextWindow: 200_000, - }, - ]; + expect(res.ok).toBe(true); + expect(res.payload?.models).toEqual([ + { + id: "claude-test-a", + name: "A-Model", + provider: "anthropic", + contextWindow: 200_000, + }, + { + id: "gpt-test-z", + name: "gpt-test-z", + provider: "openai", + }, + ]); + }, + ); + }); - const res = await rpcReq<{ - models: Array<{ - id: string; - name: string; - provider: string; - contextWindow?: number; - }>; - }>(ws, "models.list"); + test("models.list falls back to full catalog when allowlist has no catalog match", async () => { + await withModelsConfig( + { + agents: { + defaults: { + model: { primary: "openai/not-in-catalog" }, + models: { + "openai/not-in-catalog": {}, + }, + }, + }, + }, + async () => { + seedPiCatalog(); + const res = await listModels(); - expect(res.ok).toBe(true); - expect(res.payload?.models).toEqual([ - { - id: "claude-test-a", - name: "A-Model", - provider: "anthropic", - contextWindow: 200_000, - }, - { - id: "gpt-test-z", - name: "gpt-test-z", - provider: "openai", - }, - ]); - } finally { - if (previousConfig === undefined) { - await fs.rm(configPath, { force: true }); - } else { - await fs.writeFile(configPath, previousConfig, "utf-8"); - } - clearConfigCache(); - } + expect(res.ok).toBe(true); + expect(res.payload?.models).toEqual(expectedSortedCatalog()); + }, + ); }); test("models.list rejects unknown params", async () => { From a4981efae36091ef754a60062e3f02b1802a6b45 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:29:01 +0000 Subject: [PATCH 37/81] fix(discord): improve outbound send consistency --- src/discord/send.outbound.ts | 71 ++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/src/discord/send.outbound.ts b/src/discord/send.outbound.ts index 64ee07e715f..979054b435e 100644 --- a/src/discord/send.outbound.ts +++ b/src/discord/send.outbound.ts @@ -62,6 +62,31 @@ type DiscordChannelMessageResult = { channel_id?: string | null; }; +async function sendDiscordThreadTextChunks(params: { + rest: RequestClient; + threadId: string; + chunks: readonly string[]; + request: DiscordClientRequest; + maxLinesPerMessage?: number; + chunkMode: ReturnType; + silent?: boolean; +}): Promise { + for (const chunk of params.chunks) { + await sendDiscordText( + params.rest, + params.threadId, + chunk, + undefined, + params.request, + params.maxLinesPerMessage, + undefined, + undefined, + params.chunkMode, + params.silent, + ); + } +} + /** Discord thread names are capped at 100 characters. */ const DISCORD_THREAD_NAME_LIMIT = 100; @@ -194,35 +219,25 @@ export async function sendMessageDiscord( chunkMode, opts.silent, ); - for (const chunk of afterMediaChunks) { - await sendDiscordText( - rest, - threadId, - chunk, - undefined, - request, - accountInfo.config.maxLinesPerMessage, - undefined, - undefined, - chunkMode, - opts.silent, - ); - } + await sendDiscordThreadTextChunks({ + rest, + threadId, + chunks: afterMediaChunks, + request, + maxLinesPerMessage: accountInfo.config.maxLinesPerMessage, + chunkMode, + silent: opts.silent, + }); } else { - for (const chunk of remainingChunks) { - await sendDiscordText( - rest, - threadId, - chunk, - undefined, - request, - accountInfo.config.maxLinesPerMessage, - undefined, - undefined, - chunkMode, - opts.silent, - ); - } + await sendDiscordThreadTextChunks({ + rest, + threadId, + chunks: remainingChunks, + request, + maxLinesPerMessage: accountInfo.config.maxLinesPerMessage, + chunkMode, + silent: opts.silent, + }); } } catch (err) { throw await buildDiscordSendError(err, { From c343132dbb3a926a8cca6e4556452ee698b4bdc9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:29:10 +0000 Subject: [PATCH 38/81] fix(agents): harden bash tool and reply directive handling --- src/agents/bash-tools.process.ts | 87 +++++++------------ .../session-transcript-repair.e2e.test.ts | 37 ++++---- .../reply/get-reply-directives-apply.ts | 54 ++++++------ 3 files changed, 75 insertions(+), 103 deletions(-) diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index dbdb6f9976a..25248bf2218 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -278,6 +278,18 @@ export function createProcessTool( }); }; + const runningSessionResult = ( + session: ProcessSession, + text: string, + ): AgentToolResult => ({ + content: [{ type: "text", text }], + details: { + status: "running", + sessionId: params.sessionId, + name: deriveSessionName(session.command), + }, + }); + switch (params.action) { case "poll": { if (!scopedSession) { @@ -452,21 +464,12 @@ export function createProcessTool( if (params.eof) { resolved.stdin.end(); } - return { - content: [ - { - type: "text", - text: `Wrote ${(params.data ?? "").length} bytes to session ${params.sessionId}${ - params.eof ? " (stdin closed)" : "" - }.`, - }, - ], - details: { - status: "running", - sessionId: params.sessionId, - name: deriveSessionName(resolved.session.command), - }, - }; + return runningSessionResult( + resolved.session, + `Wrote ${(params.data ?? "").length} bytes to session ${params.sessionId}${ + params.eof ? " (stdin closed)" : "" + }.`, + ); } case "send-keys": { @@ -491,21 +494,11 @@ export function createProcessTool( }; } await writeToStdin(resolved.stdin, data); - return { - content: [ - { - type: "text", - text: - `Sent ${data.length} bytes to session ${params.sessionId}.` + - (warnings.length ? `\nWarnings:\n- ${warnings.join("\n- ")}` : ""), - }, - ], - details: { - status: "running", - sessionId: params.sessionId, - name: deriveSessionName(resolved.session.command), - }, - }; + return runningSessionResult( + resolved.session, + `Sent ${data.length} bytes to session ${params.sessionId}.` + + (warnings.length ? `\nWarnings:\n- ${warnings.join("\n- ")}` : ""), + ); } case "submit": { @@ -514,19 +507,10 @@ export function createProcessTool( return resolved.result; } await writeToStdin(resolved.stdin, "\r"); - return { - content: [ - { - type: "text", - text: `Submitted session ${params.sessionId} (sent CR).`, - }, - ], - details: { - status: "running", - sessionId: params.sessionId, - name: deriveSessionName(resolved.session.command), - }, - }; + return runningSessionResult( + resolved.session, + `Submitted session ${params.sessionId} (sent CR).`, + ); } case "paste": { @@ -547,19 +531,10 @@ export function createProcessTool( }; } await writeToStdin(resolved.stdin, payload); - return { - content: [ - { - type: "text", - text: `Pasted ${params.text?.length ?? 0} chars to session ${params.sessionId}.`, - }, - ], - details: { - status: "running", - sessionId: params.sessionId, - name: deriveSessionName(resolved.session.command), - }, - }; + return runningSessionResult( + resolved.session, + `Pasted ${params.text?.length ?? 0} chars to session ${params.sessionId}.`, + ); } case "kill": { diff --git a/src/agents/session-transcript-repair.e2e.test.ts b/src/agents/session-transcript-repair.e2e.test.ts index 68797cfeedc..e1422f7ea40 100644 --- a/src/agents/session-transcript-repair.e2e.test.ts +++ b/src/agents/session-transcript-repair.e2e.test.ts @@ -6,6 +6,19 @@ import { repairToolUseResultPairing, } from "./session-transcript-repair.js"; +const TOOL_CALL_BLOCK_TYPES = new Set(["toolCall", "toolUse", "functionCall"]); + +function getAssistantToolCallBlocks(messages: AgentMessage[]) { + const assistant = messages[0] as Extract | undefined; + if (!assistant || !Array.isArray(assistant.content)) { + return [] as Array<{ type?: unknown; id?: unknown; name?: unknown }>; + } + return assistant.content.filter((block) => { + const type = (block as { type?: unknown }).type; + return typeof type === "string" && TOOL_CALL_BLOCK_TYPES.has(type); + }) as Array<{ type?: unknown; id?: unknown; name?: unknown }>; +} + describe("sanitizeToolUseResultPairing", () => { const buildDuplicateToolResultInput = (opts?: { middleMessage?: unknown; @@ -229,13 +242,7 @@ describe("sanitizeToolCallInputs", () => { ] as unknown as AgentMessage[]; const out = sanitizeToolCallInputs(input); - const assistant = out[0] as Extract; - const toolCalls = Array.isArray(assistant.content) - ? assistant.content.filter((block) => { - const type = (block as { type?: unknown }).type; - return typeof type === "string" && ["toolCall", "toolUse", "functionCall"].includes(type); - }) - : []; + const toolCalls = getAssistantToolCallBlocks(out); expect(toolCalls).toHaveLength(1); expect((toolCalls[0] as { id?: unknown }).id).toBe("call_ok"); @@ -264,13 +271,7 @@ describe("sanitizeToolCallInputs", () => { ] as unknown as AgentMessage[]; const out = sanitizeToolCallInputs(input); - const assistant = out[0] as Extract; - const toolCalls = Array.isArray(assistant.content) - ? assistant.content.filter((block) => { - const type = (block as { type?: unknown }).type; - return typeof type === "string" && ["toolCall", "toolUse", "functionCall"].includes(type); - }) - : []; + const toolCalls = getAssistantToolCallBlocks(out); expect(toolCalls).toHaveLength(1); expect((toolCalls[0] as { name?: unknown }).name).toBe("read"); @@ -288,13 +289,7 @@ describe("sanitizeToolCallInputs", () => { ] as unknown as AgentMessage[]; const out = sanitizeToolCallInputs(input, { allowedToolNames: ["read"] }); - const assistant = out[0] as Extract; - const toolCalls = Array.isArray(assistant.content) - ? assistant.content.filter((block) => { - const type = (block as { type?: unknown }).type; - return typeof type === "string" && ["toolCall", "toolUse", "functionCall"].includes(type); - }) - : []; + const toolCalls = getAssistantToolCallBlocks(out); expect(toolCalls).toHaveLength(1); expect((toolCalls[0] as { name?: unknown }).name).toBe("read"); diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index fe42a2ca9e0..4232171a82b 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -102,6 +102,31 @@ export async function applyInlineDirectiveOverrides(params: { let { directives } = params; let { provider, model } = params; let { contextTokens } = params; + const directiveModelState = { + allowedModelKeys: modelState.allowedModelKeys, + allowedModelCatalog: modelState.allowedModelCatalog, + resetModelOverride: modelState.resetModelOverride, + }; + const createDirectiveHandlingBase = () => ({ + cfg, + directives, + sessionEntry, + sessionStore, + sessionKey, + storePath, + elevatedEnabled, + elevatedAllowed, + elevatedFailures, + messageProviderKey, + defaultProvider, + defaultModel, + aliasIndex, + ...directiveModelState, + provider, + model, + initialModelLabel, + formatModelSwitchEvent, + }); let directiveAck: ReplyPayload | undefined; @@ -135,26 +160,7 @@ export async function applyInlineDirectiveOverrides(params: { }); const currentThinkLevel = resolvedDefaultThinkLevel; const directiveReply = await handleDirectiveOnly({ - cfg, - directives, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - elevatedFailures, - messageProviderKey, - defaultProvider, - defaultModel, - aliasIndex, - allowedModelKeys: modelState.allowedModelKeys, - allowedModelCatalog: modelState.allowedModelCatalog, - resetModelOverride: modelState.resetModelOverride, - provider, - model, - initialModelLabel, - formatModelSwitchEvent, + ...createDirectiveHandlingBase(), currentThinkLevel, currentVerboseLevel, currentReasoningLevel, @@ -222,9 +228,7 @@ export async function applyInlineDirectiveOverrides(params: { defaultProvider, defaultModel, aliasIndex, - allowedModelKeys: modelState.allowedModelKeys, - allowedModelCatalog: modelState.allowedModelCatalog, - resetModelOverride: modelState.resetModelOverride, + ...directiveModelState, provider, model, initialModelLabel, @@ -232,9 +236,7 @@ export async function applyInlineDirectiveOverrides(params: { agentCfg, modelState: { resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel, - allowedModelKeys: modelState.allowedModelKeys, - allowedModelCatalog: modelState.allowedModelCatalog, - resetModelOverride: modelState.resetModelOverride, + ...directiveModelState, }, }); directiveAck = fastLane.directiveAck; From 0a758dc7105a737e0c5b485c1a3c155c8ead9409 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:29:15 +0000 Subject: [PATCH 39/81] test(cron): improve fire-and-forget harness coverage --- src/cron/service.every-jobs-fire.test.ts | 66 ++++++++----------- src/cron/service.read-ops-nonblocking.test.ts | 42 ++++++------ src/cron/service.test-harness.ts | 16 +++++ 3 files changed, 63 insertions(+), 61 deletions(-) diff --git a/src/cron/service.every-jobs-fire.test.ts b/src/cron/service.every-jobs-fire.test.ts index f1ef2d9eeb4..fa7b53e5986 100644 --- a/src/cron/service.every-jobs-fire.test.ts +++ b/src/cron/service.every-jobs-fire.test.ts @@ -1,5 +1,3 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { CronService } from "./service.js"; import { @@ -7,6 +5,7 @@ import { createCronStoreHarness, createNoopLogger, installCronTestHooks, + writeCronStoreSnapshot, } from "./service.test-harness.js"; const noopLogger = createNoopLogger(); @@ -120,44 +119,35 @@ describe("CronService interval/cron jobs fire on time", () => { const requestHeartbeatNow = vi.fn(); const nowMs = Date.parse("2025-12-13T00:00:00.000Z"); - await fs.mkdir(path.dirname(store.storePath), { recursive: true }); - await fs.writeFile( - store.storePath, - JSON.stringify( + await writeCronStoreSnapshot({ + storePath: store.storePath, + jobs: [ { - version: 1, - jobs: [ - { - id: "legacy-every", - name: "legacy every", - enabled: true, - createdAtMs: nowMs, - updatedAtMs: nowMs, - schedule: { kind: "every", everyMs: 120_000 }, - sessionTarget: "main", - wakeMode: "now", - payload: { kind: "systemEvent", text: "sf-tick" }, - state: { nextRunAtMs: nowMs + 120_000 }, - }, - { - id: "minute-cron", - name: "minute cron", - enabled: true, - createdAtMs: nowMs, - updatedAtMs: nowMs, - schedule: { kind: "cron", expr: "* * * * *", tz: "UTC" }, - sessionTarget: "main", - wakeMode: "now", - payload: { kind: "systemEvent", text: "minute-tick" }, - state: { nextRunAtMs: nowMs + 60_000 }, - }, - ], + id: "legacy-every", + name: "legacy every", + enabled: true, + createdAtMs: nowMs, + updatedAtMs: nowMs, + schedule: { kind: "every", everyMs: 120_000 }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "sf-tick" }, + state: { nextRunAtMs: nowMs + 120_000 }, }, - null, - 2, - ), - "utf-8", - ); + { + id: "minute-cron", + name: "minute cron", + enabled: true, + createdAtMs: nowMs, + updatedAtMs: nowMs, + schedule: { kind: "cron", expr: "* * * * *", tz: "UTC" }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "minute-tick" }, + state: { nextRunAtMs: nowMs + 60_000 }, + }, + ], + }); const cron = new CronService({ storePath: store.storePath, diff --git a/src/cron/service.read-ops-nonblocking.test.ts b/src/cron/service.read-ops-nonblocking.test.ts index 120061de448..e6a24957a79 100644 --- a/src/cron/service.read-ops-nonblocking.test.ts +++ b/src/cron/service.read-ops-nonblocking.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { CronService } from "./service.js"; +import { writeCronStoreSnapshot } from "./service.test-harness.js"; const noopLogger = { debug: vi.fn(), @@ -167,29 +168,24 @@ describe("CronService read ops while job is running", () => { const requestHeartbeatNow = vi.fn(); const nowMs = Date.parse("2025-12-13T00:00:00.000Z"); - await fs.mkdir(path.dirname(store.storePath), { recursive: true }); - await fs.writeFile( - store.storePath, - JSON.stringify({ - version: 1, - jobs: [ - { - id: "startup-catchup", - name: "startup catch-up", - enabled: true, - createdAtMs: nowMs - 86_400_000, - updatedAtMs: nowMs - 86_400_000, - schedule: { kind: "at", at: new Date(nowMs - 60_000).toISOString() }, - sessionTarget: "isolated", - wakeMode: "next-heartbeat", - payload: { kind: "agentTurn", message: "startup replay" }, - delivery: { mode: "none" }, - state: { nextRunAtMs: nowMs - 60_000 }, - }, - ], - }), - "utf-8", - ); + await writeCronStoreSnapshot({ + storePath: store.storePath, + jobs: [ + { + id: "startup-catchup", + name: "startup catch-up", + enabled: true, + createdAtMs: nowMs - 86_400_000, + updatedAtMs: nowMs - 86_400_000, + schedule: { kind: "at", at: new Date(nowMs - 60_000).toISOString() }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "startup replay" }, + delivery: { mode: "none" }, + state: { nextRunAtMs: nowMs - 60_000 }, + }, + ], + }); const isolatedRun = createDeferredIsolatedRun(); diff --git a/src/cron/service.test-harness.ts b/src/cron/service.test-harness.ts index 641f8fd3a96..5ed45e33761 100644 --- a/src/cron/service.test-harness.ts +++ b/src/cron/service.test-harness.ts @@ -51,6 +51,22 @@ export function createCronStoreHarness(options?: { prefix?: string }) { return { makeStorePath }; } +export async function writeCronStoreSnapshot(params: { storePath: string; jobs: CronJob[] }) { + await fs.mkdir(path.dirname(params.storePath), { recursive: true }); + await fs.writeFile( + params.storePath, + JSON.stringify( + { + version: 1, + jobs: params.jobs, + }, + null, + 2, + ), + "utf-8", + ); +} + export function installCronTestHooks(options: { logger: ReturnType; baseTimeIso?: string; From 1e4e24852a19fe9f094425a5c40de1e0864b17e7 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 22 Feb 2026 05:29:46 -0600 Subject: [PATCH 40/81] UI: remove OpenAI/Ember theme, reduce to 5 themes --- ui/src/styles/base.css | 72 --------------------------------- ui/src/ui/app-render.helpers.ts | 1 - ui/src/ui/theme.ts | 3 +- 3 files changed, 1 insertion(+), 75 deletions(-) diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index 01f9fb3e641..de02aef78bf 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -270,64 +270,6 @@ --radius-full: 0px; } -/* ─── Theme: openai — Crimson Glassmorphic ─── */ - -:root[data-theme="openai"] { - color-scheme: dark; - - --vscode-bg: #0c0606; - --vscode-sidebar: #100808; - --vscode-panel: #140a0a; - --vscode-panel-border: rgba(202, 58, 41, 0.12); - --vscode-surface: #1a0e0e; - --vscode-hover: #221414; - --vscode-contrast: #060202; - --vscode-text: #e8d8d4; - --vscode-muted: #8a6a64; - --vscode-subtle: #4a3430; - --vscode-ghost: #1a0e0e; - --vscode-accent: #ca3a29; - --vscode-accent-alpha: rgba(202, 58, 41, 0.18); - --vscode-selection: #7d261c; - --vscode-success: #fd8e2e; - --vscode-danger: #ca3a29; - - --kn-claw: #ca3a29; - --kn-claw-bright: #ff4e41; - --kn-claw-dim: rgba(202, 58, 41, 0.15); - --kn-claw-ember: #fd8e2e; - --kn-claw-deep: #9a2d1f; - --kn-ocean: #0c0606; - --kn-ocean-bright: #221414; - --kn-ocean-mid: #140a0a; - --kn-ocean-dim: rgba(12, 6, 6, 0.8); - --kn-ocean-deep: #0c0606; - --kn-silver: #8a6a64; - --kn-silver-bright: #c0a49c; - --kn-silver-dim: rgba(138, 106, 100, 0.12); - --kn-bioluminescence: #fd8e2e; - --kn-warm-dark: #221016; - --kn-void: #221016; - - --glass-blur: 14px; - --glass-saturate: 130%; - --glass-bg: rgba(20, 10, 10, 0.78); - --glass-bg-elevated: rgba(26, 14, 14, 0.85); - --glass-border: rgba(202, 58, 41, 0.12); - --glass-border-hover: rgba(202, 58, 41, 0.4); - --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.05); - --glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4), 0 0 4px rgba(202, 58, 41, 0.08); - --glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 12px rgba(202, 58, 41, 0.1); - --glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 24px rgba(202, 58, 41, 0.12); - - --radius-xs: 4px; - --radius-sm: 8px; - --radius-md: 12px; - --radius-lg: 16px; - --radius-xl: 20px; - --radius-full: 9999px; -} - /* ─── Theme: clawdash — Chrome Metallic ─── */ :root[data-theme="clawdash"] { @@ -395,7 +337,6 @@ :root[data-theme="light"], :root[data-theme="openknot"], :root[data-theme="fieldmanual"], -:root[data-theme="openai"], :root[data-theme="clawdash"] { /* Core surfaces */ --bg: var(--vscode-bg); @@ -773,19 +714,6 @@ select { display: none; } -/* ─── openai — Crimson atmosphere ─── */ - -:root[data-theme="openai"] body { - background: - radial-gradient(ellipse 80% 50% at 50% -5%, rgba(202, 58, 41, 0.12) 0%, transparent 60%), - radial-gradient(ellipse 60% 40% at 60% 20%, rgba(253, 142, 46, 0.04) 0%, transparent 50%), - var(--bg); -} - -:root[data-theme="openai"] body::after { - display: none; -} - /* ─── clawdash — Chrome Metallic Overrides ─── */ :root[data-theme="clawdash"] body { diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index d7610962872..316c7968ebe 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -402,7 +402,6 @@ const THEME_OPTIONS: ThemeOption[] = [ { id: "light", label: "Light", iconKey: "book" }, { id: "openknot", label: "Knot", iconKey: "zap" }, { id: "fieldmanual", label: "Field", iconKey: "terminal" }, - { id: "openai", label: "Ember", iconKey: "loader" }, { id: "clawdash", label: "Chrome", iconKey: "settings" }, ]; diff --git a/ui/src/ui/theme.ts b/ui/src/ui/theme.ts index c27f8b280d2..77d060b789f 100644 --- a/ui/src/ui/theme.ts +++ b/ui/src/ui/theme.ts @@ -1,4 +1,4 @@ -export type ThemeMode = "dark" | "light" | "openknot" | "fieldmanual" | "openai" | "clawdash"; +export type ThemeMode = "dark" | "light" | "openknot" | "fieldmanual" | "clawdash"; export type ResolvedTheme = ThemeMode; export const VALID_THEMES = new Set([ @@ -6,7 +6,6 @@ export const VALID_THEMES = new Set([ "light", "openknot", "fieldmanual", - "openai", "clawdash", ]); From 59191474eb65618ae0a9ff325850e4acbafc73e5 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 22 Feb 2026 05:30:46 -0600 Subject: [PATCH 41/81] docs(ui): update checklist for 5-theme setup --- ui/CHECKLIST.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ui/CHECKLIST.md b/ui/CHECKLIST.md index ef13c720913..d2558b6bc5e 100644 --- a/ui/CHECKLIST.md +++ b/ui/CHECKLIST.md @@ -17,13 +17,12 @@ Open the dashboard at `http://localhost:` (or the gateway's configured UI ## Themes -- [ ] Theme switcher cycles through all 6 themes: +- [ ] Theme switcher cycles through all 5 themes: - [ ] Dark (Obsidian) - [ ] Light - [ ] OpenKnot (Aurora) - [ ] Field Manual - - [ ] OpenAI (Solar) - - [ ] ClawDash + - [ ] ClawDash (Chrome) - [ ] Glass components (cards, panels, inputs) render correctly per theme - [ ] Theme persists across page reload From a4607277a918c9ee6eb7e5a45b7eceb7f2edc92c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 12:34:39 +0100 Subject: [PATCH 42/81] test: consolidate sessions_spawn and guardrail helpers --- ...subagents.sessions-spawn.lifecycle.test.ts | 110 +---------------- ...s.subagents.sessions-spawn.test-harness.ts | 111 ++++++++++++++++++ src/agents/sessions-spawn-hooks.test.ts | 17 +-- src/process/exec.test.ts | 31 +++-- src/process/supervisor/supervisor.test.ts | 37 ++++-- src/process/test-timeouts.ts | 20 ++++ src/security/temp-path-guard.test.ts | 56 +++------ src/security/weak-random-patterns.test.ts | 68 +++-------- src/test-utils/repo-scan.ts | 78 ++++++++++++ 9 files changed, 299 insertions(+), 229 deletions(-) create mode 100644 src/process/test-timeouts.ts create mode 100644 src/test-utils/repo-scan.ts diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index 1e522c0435d..d10be4b4253 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -3,7 +3,9 @@ import { emitAgentEvent } from "../infra/agent-events.js"; import "./test-helpers/fast-core-tools.js"; import { getCallGatewayMock, + getSessionsSpawnTool, resetSessionsSpawnConfigOverride, + setupSessionsSpawnGatewayMock, setSessionsSpawnConfigOverride, } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; @@ -18,22 +20,6 @@ vi.mock("./pi-embedded.js", () => ({ const callGatewayMock = getCallGatewayMock(); const RUN_TIMEOUT_SECONDS = 1; -type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; -type CreateOpenClawToolsOpts = Parameters[0]; - -async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { - // Dynamic import: ensure harness mocks are installed before tool modules load. - const { createOpenClawTools } = await import("./openclaw-tools.js"); - const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } - return tool; -} - -type GatewayRequest = { method?: string; params?: unknown }; -type AgentWaitCall = { runId?: string; timeoutMs?: number }; - function buildDiscordCleanupHooks(onDelete: (key: string | undefined) => void) { return { onAgentSubagentSpawn: (params: unknown) => { @@ -48,98 +34,6 @@ function buildDiscordCleanupHooks(onDelete: (key: string | undefined) => void) { }; } -function setupSessionsSpawnGatewayMock(opts: { - includeSessionsList?: boolean; - includeChatHistory?: boolean; - onAgentSubagentSpawn?: (params: unknown) => void; - onSessionsPatch?: (params: unknown) => void; - onSessionsDelete?: (params: unknown) => void; - agentWaitResult?: { status: "ok" | "timeout"; startedAt: number; endedAt: number }; -}): { - calls: Array; - waitCalls: Array; - getChild: () => { runId?: string; sessionKey?: string }; -} { - const calls: Array = []; - const waitCalls: Array = []; - let agentCallCount = 0; - let childRunId: string | undefined; - let childSessionKey: string | undefined; - - callGatewayMock.mockImplementation(async (optsUnknown: unknown) => { - const request = optsUnknown as GatewayRequest; - calls.push(request); - - if (request.method === "sessions.list" && opts.includeSessionsList) { - return { - sessions: [ - { - key: "main", - lastChannel: "whatsapp", - lastTo: "+123", - }, - ], - }; - } - - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - const params = request.params as { lane?: string; sessionKey?: string } | undefined; - // Only capture the first agent call (subagent spawn, not main agent trigger) - if (params?.lane === "subagent") { - childRunId = runId; - childSessionKey = params?.sessionKey ?? ""; - opts.onAgentSubagentSpawn?.(params); - } - return { - runId, - status: "accepted", - acceptedAt: 1000 + agentCallCount, - }; - } - - if (request.method === "agent.wait") { - const params = request.params as AgentWaitCall | undefined; - waitCalls.push(params ?? {}); - const res = opts.agentWaitResult ?? { status: "ok", startedAt: 1000, endedAt: 2000 }; - return { - runId: params?.runId ?? "run-1", - ...res, - }; - } - - if (request.method === "sessions.patch") { - opts.onSessionsPatch?.(request.params); - return { ok: true }; - } - - if (request.method === "sessions.delete") { - opts.onSessionsDelete?.(request.params); - return { ok: true }; - } - - if (request.method === "chat.history" && opts.includeChatHistory) { - return { - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "done" }], - }, - ], - }; - } - - return {}; - }); - - return { - calls, - waitCalls, - getChild: () => ({ runId: childRunId, sessionKey: childSessionKey }), - }; -} - const waitFor = async (predicate: () => boolean, timeoutMs = 1_500) => { await vi.waitFor( () => { diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts index d13bf231f2f..6a50517ebb5 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts @@ -3,6 +3,16 @@ import { vi } from "vitest"; type SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>; type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; export type CreateOpenClawToolsOpts = Parameters[0]; +export type GatewayRequest = { method?: string; params?: unknown }; +export type AgentWaitCall = { runId?: string; timeoutMs?: number }; +type SessionsSpawnGatewayMockOptions = { + includeSessionsList?: boolean; + includeChatHistory?: boolean; + onAgentSubagentSpawn?: (params: unknown) => void; + onSessionsPatch?: (params: unknown) => void; + onSessionsDelete?: (params: unknown) => void; + agentWaitResult?: { status: "ok" | "timeout"; startedAt: number; endedAt: number }; +}; // Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). // oxlint-disable-next-line typescript/no-explicit-any @@ -24,6 +34,18 @@ export function getCallGatewayMock(): AnyMock { return hoisted.callGatewayMock; } +export function getGatewayRequests(): Array { + return getCallGatewayMock().mock.calls.map((call: [unknown]) => call[0] as GatewayRequest); +} + +export function getGatewayMethods(): Array { + return getGatewayRequests().map((request) => request.method); +} + +export function findGatewayRequest(method: string): GatewayRequest | undefined { + return getGatewayRequests().find((request) => request.method === method); +} + export function resetSessionsSpawnConfigOverride(): void { hoisted.state.configOverride = hoisted.defaultConfigOverride; } @@ -42,6 +64,95 @@ export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { return tool; } +export function setupSessionsSpawnGatewayMock(setupOpts: SessionsSpawnGatewayMockOptions): { + calls: Array; + waitCalls: Array; + getChild: () => { runId?: string; sessionKey?: string }; +} { + const calls: Array = []; + const waitCalls: Array = []; + let agentCallCount = 0; + let childRunId: string | undefined; + let childSessionKey: string | undefined; + + getCallGatewayMock().mockImplementation(async (optsUnknown: unknown) => { + const request = optsUnknown as GatewayRequest; + calls.push(request); + + if (request.method === "sessions.list" && setupOpts.includeSessionsList) { + return { + sessions: [ + { + key: "main", + lastChannel: "whatsapp", + lastTo: "+123", + }, + ], + }; + } + + if (request.method === "agent") { + agentCallCount += 1; + const runId = `run-${agentCallCount}`; + const params = request.params as { lane?: string; sessionKey?: string } | undefined; + // Capture only the subagent run metadata. + if (params?.lane === "subagent") { + childRunId = runId; + childSessionKey = params.sessionKey ?? ""; + setupOpts.onAgentSubagentSpawn?.(params); + } + return { + runId, + status: "accepted", + acceptedAt: 1000 + agentCallCount, + }; + } + + if (request.method === "agent.wait") { + const params = request.params as AgentWaitCall | undefined; + waitCalls.push(params ?? {}); + const waitResult = setupOpts.agentWaitResult ?? { + status: "ok", + startedAt: 1000, + endedAt: 2000, + }; + return { + runId: params?.runId ?? "run-1", + ...waitResult, + }; + } + + if (request.method === "sessions.patch") { + setupOpts.onSessionsPatch?.(request.params); + return { ok: true }; + } + + if (request.method === "sessions.delete") { + setupOpts.onSessionsDelete?.(request.params); + return { ok: true }; + } + + if (request.method === "chat.history" && setupOpts.includeChatHistory) { + return { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "done" }], + }, + ], + }; + } + + return {}; + }); + + return { + calls, + waitCalls, + getChild: () => ({ runId: childRunId, sessionKey: childSessionKey }), + }; +} + vi.mock("../gateway/call.js", () => ({ callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), })); diff --git a/src/agents/sessions-spawn-hooks.test.ts b/src/agents/sessions-spawn-hooks.test.ts index 4efa7caf6f2..0a8c82ca60a 100644 --- a/src/agents/sessions-spawn-hooks.test.ts +++ b/src/agents/sessions-spawn-hooks.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import "./test-helpers/fast-core-tools.js"; import { + findGatewayRequest, getCallGatewayMock, + getGatewayMethods, getSessionsSpawnTool, setSessionsSpawnConfigOverride, } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; @@ -46,21 +48,6 @@ vi.mock("../plugins/hook-runner-global.js", () => ({ })), })); -type GatewayRequest = { method?: string; params?: Record }; - -function getGatewayRequests(): GatewayRequest[] { - const callGatewayMock = getCallGatewayMock(); - return callGatewayMock.mock.calls.map((call: [unknown]) => call[0] as GatewayRequest); -} - -function getGatewayMethods(): Array { - return getGatewayRequests().map((request) => request.method); -} - -function findGatewayRequest(method: string): GatewayRequest | undefined { - return getGatewayRequests().find((request) => request.method === method); -} - function expectSessionsDeleteWithoutAgentStart() { const methods = getGatewayMethods(); expect(methods).toContain("sessions.delete"); diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index f90769fa4eb..703d13a945f 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; import { runCommandWithTimeout, shouldSpawnWithShell } from "./exec.js"; +import { + PROCESS_TEST_NO_OUTPUT_TIMEOUT_MS, + PROCESS_TEST_SCRIPT_DELAY_MS, + PROCESS_TEST_TIMEOUT_MS, +} from "./test-timeouts.js"; describe("runCommandWithTimeout", () => { it("never enables shell execution (Windows cmd.exe injection hardening)", () => { @@ -21,7 +26,7 @@ describe("runCommandWithTimeout", () => { 'process.stdout.write((process.env.OPENCLAW_BASE_ENV ?? "") + "|" + (process.env.OPENCLAW_TEST_ENV ?? ""))', ], { - timeoutMs: 5_000, + timeoutMs: PROCESS_TEST_TIMEOUT_MS.medium, env: { OPENCLAW_TEST_ENV: "ok" }, }, ); @@ -34,10 +39,14 @@ describe("runCommandWithTimeout", () => { it("kills command when no output timeout elapses", async () => { const result = await runCommandWithTimeout( - [process.execPath, "-e", "setTimeout(() => {}, 120)"], + [ + process.execPath, + "-e", + `setTimeout(() => {}, ${PROCESS_TEST_SCRIPT_DELAY_MS.silentProcess})`, + ], { - timeoutMs: 3_000, - noOutputTimeoutMs: 120, + timeoutMs: PROCESS_TEST_TIMEOUT_MS.standard, + noOutputTimeoutMs: PROCESS_TEST_NO_OUTPUT_TIMEOUT_MS.exec, }, ); @@ -51,11 +60,11 @@ describe("runCommandWithTimeout", () => { [ process.execPath, "-e", - 'process.stdout.write(".\\n"); const interval = setInterval(() => process.stdout.write(".\\n"), 1800); setTimeout(() => { clearInterval(interval); process.exit(0); }, 9000);', + `process.stdout.write(".\\n"); const interval = setInterval(() => process.stdout.write(".\\n"), ${PROCESS_TEST_SCRIPT_DELAY_MS.streamingInterval}); setTimeout(() => { clearInterval(interval); process.exit(0); }, ${PROCESS_TEST_SCRIPT_DELAY_MS.streamingDuration});`, ], { - timeoutMs: 15_000, - noOutputTimeoutMs: 6_000, + timeoutMs: PROCESS_TEST_TIMEOUT_MS.extraLong, + noOutputTimeoutMs: PROCESS_TEST_NO_OUTPUT_TIMEOUT_MS.streamingAllowance, }, ); @@ -68,9 +77,13 @@ describe("runCommandWithTimeout", () => { it("reports global timeout termination when overall timeout elapses", async () => { const result = await runCommandWithTimeout( - [process.execPath, "-e", "setTimeout(() => {}, 120)"], + [ + process.execPath, + "-e", + `setTimeout(() => {}, ${PROCESS_TEST_SCRIPT_DELAY_MS.silentProcess})`, + ], { - timeoutMs: 100, + timeoutMs: PROCESS_TEST_TIMEOUT_MS.short, }, ); diff --git a/src/process/supervisor/supervisor.test.ts b/src/process/supervisor/supervisor.test.ts index 194af43f781..825832b251e 100644 --- a/src/process/supervisor/supervisor.test.ts +++ b/src/process/supervisor/supervisor.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it } from "vitest"; +import { + PROCESS_TEST_NO_OUTPUT_TIMEOUT_MS, + PROCESS_TEST_SCRIPT_DELAY_MS, + PROCESS_TEST_TIMEOUT_MS, +} from "../test-timeouts.js"; import { createProcessSupervisor } from "./supervisor.js"; describe("process supervisor", () => { @@ -9,7 +14,7 @@ describe("process supervisor", () => { backendId: "test", mode: "child", argv: [process.execPath, "-e", 'process.stdout.write("ok")'], - timeoutMs: 10_000, + timeoutMs: PROCESS_TEST_TIMEOUT_MS.long, stdinMode: "pipe-closed", }); const exit = await run.wait(); @@ -24,9 +29,13 @@ describe("process supervisor", () => { sessionId: "s1", backendId: "test", mode: "child", - argv: [process.execPath, "-e", "setTimeout(() => {}, 120)"], - timeoutMs: 3_000, - noOutputTimeoutMs: 100, + argv: [ + process.execPath, + "-e", + `setTimeout(() => {}, ${PROCESS_TEST_SCRIPT_DELAY_MS.silentProcess})`, + ], + timeoutMs: PROCESS_TEST_TIMEOUT_MS.standard, + noOutputTimeoutMs: PROCESS_TEST_NO_OUTPUT_TIMEOUT_MS.supervisor, stdinMode: "pipe-closed", }); const exit = await run.wait(); @@ -42,8 +51,12 @@ describe("process supervisor", () => { backendId: "test", scopeKey: "scope:a", mode: "child", - argv: [process.execPath, "-e", "setTimeout(() => {}, 120)"], - timeoutMs: 3_000, + argv: [ + process.execPath, + "-e", + `setTimeout(() => {}, ${PROCESS_TEST_SCRIPT_DELAY_MS.silentProcess})`, + ], + timeoutMs: PROCESS_TEST_TIMEOUT_MS.standard, stdinMode: "pipe-open", }); @@ -54,7 +67,7 @@ describe("process supervisor", () => { replaceExistingScope: true, mode: "child", argv: [process.execPath, "-e", 'process.stdout.write("new")'], - timeoutMs: 10_000, + timeoutMs: PROCESS_TEST_TIMEOUT_MS.long, stdinMode: "pipe-closed", }); @@ -71,8 +84,12 @@ describe("process supervisor", () => { sessionId: "s-timeout", backendId: "test", mode: "child", - argv: [process.execPath, "-e", "setTimeout(() => {}, 120)"], - timeoutMs: 25, + argv: [ + process.execPath, + "-e", + `setTimeout(() => {}, ${PROCESS_TEST_SCRIPT_DELAY_MS.silentProcess})`, + ], + timeoutMs: PROCESS_TEST_TIMEOUT_MS.tiny, stdinMode: "pipe-closed", }); const exit = await run.wait(); @@ -88,7 +105,7 @@ describe("process supervisor", () => { backendId: "test", mode: "child", argv: [process.execPath, "-e", 'process.stdout.write("streamed")'], - timeoutMs: 10_000, + timeoutMs: PROCESS_TEST_TIMEOUT_MS.long, stdinMode: "pipe-closed", captureOutput: false, onStdout: (chunk) => { diff --git a/src/process/test-timeouts.ts b/src/process/test-timeouts.ts new file mode 100644 index 00000000000..d1721d5bfcd --- /dev/null +++ b/src/process/test-timeouts.ts @@ -0,0 +1,20 @@ +export const PROCESS_TEST_TIMEOUT_MS = { + tiny: 25, + short: 100, + standard: 3_000, + medium: 5_000, + long: 10_000, + extraLong: 15_000, +} as const; + +export const PROCESS_TEST_SCRIPT_DELAY_MS = { + silentProcess: 120, + streamingInterval: 1_800, + streamingDuration: 9_000, +} as const; + +export const PROCESS_TEST_NO_OUTPUT_TIMEOUT_MS = { + exec: 120, + supervisor: 100, + streamingAllowance: 6_000, +} as const; diff --git a/src/security/temp-path-guard.test.ts b/src/security/temp-path-guard.test.ts index dbff38b50fb..05dfb9d9d14 100644 --- a/src/security/temp-path-guard.test.ts +++ b/src/security/temp-path-guard.test.ts @@ -2,8 +2,9 @@ import fs from "node:fs/promises"; import path from "node:path"; import ts from "typescript"; import { describe, expect, it } from "vitest"; +import { listRepoFiles } from "../test-utils/repo-scan.js"; -const RUNTIME_ROOTS = ["src", "extensions"]; +const RUNTIME_ROOTS = ["src", "extensions"] as const; const SKIP_PATTERNS = [ /\.test\.tsx?$/, /\.test-helpers\.tsx?$/, @@ -83,28 +84,6 @@ function hasDynamicTmpdirJoin(source: string, filePath = "fixture.ts"): boolean return found; } -async function listTsFiles(dir: string): Promise { - const entries = await fs.readdir(dir, { withFileTypes: true }); - const out: string[] = []; - for (const entry of entries) { - if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) { - continue; - } - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - out.push(...(await listTsFiles(fullPath))); - continue; - } - if (!entry.isFile()) { - continue; - } - if (fullPath.endsWith(".ts") || fullPath.endsWith(".tsx")) { - out.push(fullPath); - } - } - return out; -} - describe("temp path guard", () => { it("skips test helper filename variants", () => { expect(shouldSkip("src/commands/test-helpers.ts")).toBe(true); @@ -138,21 +117,22 @@ describe("temp path guard", () => { const repoRoot = process.cwd(); const offenders: string[] = []; - for (const root of RUNTIME_ROOTS) { - const absRoot = path.join(repoRoot, root); - const files = await listTsFiles(absRoot); - for (const file of files) { - const relativePath = path.relative(repoRoot, file); - if (shouldSkip(relativePath)) { - continue; - } - const source = await fs.readFile(file, "utf-8"); - if (!QUICK_TMPDIR_JOIN_PATTERN.test(source)) { - continue; - } - if (hasDynamicTmpdirJoin(source, relativePath)) { - offenders.push(relativePath); - } + const files = await listRepoFiles(repoRoot, { + roots: RUNTIME_ROOTS, + extensions: [".ts", ".tsx"], + skipHiddenDirectories: true, + }); + for (const file of files) { + const relativePath = path.relative(repoRoot, file); + if (shouldSkip(relativePath)) { + continue; + } + const source = await fs.readFile(file, "utf-8"); + if (!QUICK_TMPDIR_JOIN_PATTERN.test(source)) { + continue; + } + if (hasDynamicTmpdirJoin(source, relativePath)) { + offenders.push(relativePath); } } diff --git a/src/security/weak-random-patterns.test.ts b/src/security/weak-random-patterns.test.ts index fa1d0b342c3..fca78a76a68 100644 --- a/src/security/weak-random-patterns.test.ts +++ b/src/security/weak-random-patterns.test.ts @@ -1,68 +1,38 @@ -import fs from "node:fs"; +import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { listRepoFiles } from "../test-utils/repo-scan.js"; const SCAN_ROOTS = ["src", "extensions"] as const; -const SKIP_DIRS = new Set([".git", "dist", "node_modules"]); -function collectTypeScriptFiles(rootDir: string): string[] { - const out: string[] = []; - const stack = [rootDir]; - while (stack.length > 0) { - const current = stack.pop(); - if (!current) { - continue; - } - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - const fullPath = path.join(current, entry.name); - if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) { - stack.push(fullPath); - } - continue; - } - if (!entry.isFile()) { - continue; - } - if ( - !entry.name.endsWith(".ts") || - entry.name.endsWith(".test.ts") || - entry.name.endsWith(".d.ts") - ) { - continue; - } - out.push(fullPath); - } - } - return out; +function isRuntimeTypeScriptFile(relativePath: string): boolean { + return !relativePath.endsWith(".test.ts") && !relativePath.endsWith(".d.ts"); } -function findWeakRandomPatternMatches(repoRoot: string): string[] { +async function findWeakRandomPatternMatches(repoRoot: string): Promise { const matches: string[] = []; - for (const scanRoot of SCAN_ROOTS) { - const root = path.join(repoRoot, scanRoot); - if (!fs.existsSync(root)) { - continue; - } - const files = collectTypeScriptFiles(root); - for (const filePath of files) { - const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/); - for (let idx = 0; idx < lines.length; idx += 1) { - const line = lines[idx] ?? ""; - if (!line.includes("Date.now") || !line.includes("Math.random")) { - continue; - } - matches.push(`${path.relative(repoRoot, filePath)}:${idx + 1}`); + const files = await listRepoFiles(repoRoot, { + roots: SCAN_ROOTS, + extensions: [".ts"], + shouldIncludeFile: isRuntimeTypeScriptFile, + }); + for (const filePath of files) { + const lines = (await fs.readFile(filePath, "utf8")).split(/\r?\n/); + for (let idx = 0; idx < lines.length; idx += 1) { + const line = lines[idx] ?? ""; + if (!line.includes("Date.now") || !line.includes("Math.random")) { + continue; } + matches.push(`${path.relative(repoRoot, filePath)}:${idx + 1}`); } } return matches; } describe("weak random pattern guardrail", () => { - it("rejects Date.now + Math.random token/id patterns in runtime code", () => { + it("rejects Date.now + Math.random token/id patterns in runtime code", async () => { const repoRoot = path.resolve(process.cwd()); - const matches = findWeakRandomPatternMatches(repoRoot); + const matches = await findWeakRandomPatternMatches(repoRoot); expect(matches).toEqual([]); }); }); diff --git a/src/test-utils/repo-scan.ts b/src/test-utils/repo-scan.ts new file mode 100644 index 00000000000..c01509ea693 --- /dev/null +++ b/src/test-utils/repo-scan.ts @@ -0,0 +1,78 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export const DEFAULT_REPO_SCAN_SKIP_DIR_NAMES = new Set([".git", "dist", "node_modules"]); + +export type RepoFileScanOptions = { + roots: readonly string[]; + extensions: readonly string[]; + skipDirNames?: ReadonlySet; + skipHiddenDirectories?: boolean; + shouldIncludeFile?: (relativePath: string) => boolean; +}; + +type PendingDir = { + absolutePath: string; +}; + +function shouldSkipDirectory( + name: string, + options: Pick, +): boolean { + if (options.skipHiddenDirectories && name.startsWith(".")) { + return true; + } + return (options.skipDirNames ?? DEFAULT_REPO_SCAN_SKIP_DIR_NAMES).has(name); +} + +function hasAllowedExtension(fileName: string, extensions: readonly string[]): boolean { + return extensions.some((extension) => fileName.endsWith(extension)); +} + +export async function listRepoFiles( + repoRoot: string, + options: RepoFileScanOptions, +): Promise> { + const files: Array = []; + const pending: Array = []; + + for (const root of options.roots) { + const absolutePath = path.join(repoRoot, root); + try { + const stats = await fs.stat(absolutePath); + if (stats.isDirectory()) { + pending.push({ absolutePath }); + } + } catch { + // Skip missing roots. Useful when extensions/ is absent. + } + } + + while (pending.length > 0) { + const current = pending.pop(); + if (!current) { + continue; + } + const entries = await fs.readdir(current.absolutePath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + if (!shouldSkipDirectory(entry.name, options)) { + pending.push({ absolutePath: path.join(current.absolutePath, entry.name) }); + } + continue; + } + if (!entry.isFile() || !hasAllowedExtension(entry.name, options.extensions)) { + continue; + } + const filePath = path.join(current.absolutePath, entry.name); + const relativePath = path.relative(repoRoot, filePath); + if (options.shouldIncludeFile && !options.shouldIncludeFile(relativePath)) { + continue; + } + files.push(filePath); + } + } + + files.sort((a, b) => a.localeCompare(b)); + return files; +} From 85e5ed3f782a40d434d7b138545230b52af418b0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 12:35:02 +0100 Subject: [PATCH 43/81] refactor(channels): centralize runtime group policy handling --- docs/gateway/configuration-reference.md | 2 +- extensions/discord/src/channel.ts | 6 +- extensions/feishu/src/bot.ts | 20 ++--- extensions/feishu/src/channel.ts | 6 +- extensions/googlechat/src/channel.ts | 6 +- extensions/googlechat/src/monitor.ts | 30 +++---- extensions/imessage/src/channel.ts | 6 +- extensions/irc/src/channel.ts | 6 +- extensions/irc/src/inbound.ts | 28 +++--- extensions/line/src/channel.ts | 6 +- extensions/matrix/src/channel.ts | 6 +- extensions/matrix/src/matrix/monitor/index.ts | 24 ++--- extensions/mattermost/src/channel.ts | 6 +- .../mattermost/src/mattermost/monitor.ts | 25 +++--- extensions/msteams/src/channel.ts | 6 +- extensions/nextcloud-talk/src/channel.ts | 6 +- extensions/nextcloud-talk/src/inbound.ts | 32 +++---- extensions/signal/src/channel.ts | 6 +- extensions/slack/src/channel.ts | 6 +- extensions/telegram/src/channel.ts | 6 +- extensions/whatsapp/src/channel.ts | 6 +- extensions/zalouser/src/monitor.ts | 20 ++--- src/config/runtime-group-policy.test.ts | 87 +++++++++++++++---- src/config/runtime-group-policy.ts | 72 ++++++++++++++- src/discord/monitor/message-handler.ts | 6 +- src/discord/monitor/native-command.ts | 6 +- src/discord/monitor/provider.ts | 41 +++------ src/imessage/monitor/monitor-provider.ts | 40 +++------ src/line/bot-handlers.ts | 30 +++---- src/plugin-sdk/index.ts | 5 ++ src/signal/monitor.ts | 27 +++--- src/slack/monitor/provider.ts | 40 +++------ src/telegram/group-access.ts | 6 +- src/web/inbound/access-control.ts | 20 +++-- 34 files changed, 345 insertions(+), 300 deletions(-) diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b11ea7a37aa..34478bb324f 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -35,7 +35,7 @@ All channels support DM policies and group policies: `channels.defaults.groupPolicy` sets the default when a provider's `groupPolicy` is unset. Pairing codes expire after 1 hour. Pending DM pairing requests are capped at **3 per channel**. -Slack/Discord have a special fallback: if their provider section is missing entirely, runtime group policy can resolve to `open` (with a startup warning). +If a provider block is missing entirely (`channels.` absent), runtime group policy falls back to `allowlist` (fail-closed) with a startup warning. ### Channel model overrides diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 9922062c4c4..9131ae42ee2 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -22,7 +22,7 @@ import { resolveDefaultDiscordAccountId, resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, - resolveRuntimeGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelMessageActionAdapter, type ChannelPlugin, @@ -132,12 +132,10 @@ export const discordPlugin: ChannelPlugin = { collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy } = resolveRuntimeGroupPolicy({ + const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.discord !== undefined, groupPolicy: account.config.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "open", - missingProviderFallbackPolicy: "allowlist", }); const guildEntries = account.config.guilds ?? {}; const guildsConfigured = Object.keys(guildEntries).length > 0; diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 7922997c7d5..14b4c95f0a7 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -6,7 +6,8 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, recordPendingHistoryEntryIfEnabled, - resolveRuntimeGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; @@ -78,7 +79,6 @@ const senderNameCache = new Map(); // Key: appId or "default", Value: timestamp of last notification const permissionErrorNotifiedAt = new Map(); const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes -const groupPolicyFallbackWarningShown = new Set(); type SenderNameResult = { name?: string; @@ -566,19 +566,17 @@ export async function handleFeishuMessage(params: { if (isGroup) { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.feishu !== undefined, groupPolicy: feishuCfg?.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "open", - missingProviderFallbackPolicy: "allowlist", }); - if (providerMissingFallbackApplied && !groupPolicyFallbackWarningShown.has(account.accountId)) { - groupPolicyFallbackWarningShown.add(account.accountId); - log( - 'feishu: channels.feishu is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', - ); - } + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "feishu", + accountId: account.accountId, + log, + }); const groupAllowFrom = feishuCfg?.groupAllowFrom ?? []; // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index dbd1e46facb..c4437247608 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -4,7 +4,7 @@ import { createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, } from "openclaw/plugin-sdk"; import { resolveFeishuAccount, @@ -226,12 +226,10 @@ export const feishuPlugin: ChannelPlugin = { const account = resolveFeishuAccount({ cfg, accountId }); const feishuCfg = account.config; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy } = resolveRuntimeGroupPolicy({ + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.feishu !== undefined, groupPolicy: feishuCfg?.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", }); if (groupPolicy !== "open") return []; return [ diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 9cd9bd182aa..d8a9aed16aa 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -11,7 +11,7 @@ import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, resolveGoogleChatGroupRequireMention, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelDock, type ChannelMessageActionAdapter, @@ -200,12 +200,10 @@ export const googlechatPlugin: ChannelPlugin = { collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy } = resolveRuntimeGroupPolicy({ + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.googlechat !== undefined, groupPolicy: account.config.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", }); if (groupPolicy === "open") { warnings.push( diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 8889ec8d5f5..10501c8e1f2 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -5,10 +5,11 @@ import { readJsonBodyWithLimit, registerWebhookTarget, rejectNonPostWebhookRequest, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, resolveSingleWebhookTargetAsync, resolveWebhookPath, resolveWebhookTargets, + warnMissingProviderGroupPolicyFallbackOnce, requestBodyErrorToText, resolveMentionGatingWithBypass, } from "openclaw/plugin-sdk"; @@ -68,7 +69,6 @@ function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, } const warnedDeprecatedUsersEmailAllowFrom = new Set(); -const warnedMissingProviderGroupPolicy = new Set(); function warnDeprecatedUsersEmailEntries( core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, @@ -429,21 +429,19 @@ async function processMessageWithPipeline(params: { } const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; - const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ - providerConfigPresent: config.channels?.googlechat !== undefined, - groupPolicy: account.config.groupPolicy, - defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: config.channels?.googlechat !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "googlechat", + accountId: account.accountId, + blockedLabel: "space messages", + log: (message) => logVerbose(core, runtime, message), }); - if (providerMissingFallbackApplied && !warnedMissingProviderGroupPolicy.has(account.accountId)) { - warnedMissingProviderGroupPolicy.add(account.accountId); - logVerbose( - core, - runtime, - 'googlechat: channels.googlechat is missing; defaulting groupPolicy to "allowlist" (space messages blocked until explicitly configured).', - ); - } const groupConfigResolved = resolveGroupConfig({ groupId: spaceId, groupName: space.displayName ?? null, diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index aacc3246d25..7cba0174000 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -18,7 +18,7 @@ import { resolveIMessageAccount, resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, type ResolvedIMessageAccount, @@ -99,12 +99,10 @@ export const imessagePlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy } = resolveRuntimeGroupPolicy({ + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.imessage !== undefined, groupPolicy: account.config.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", }); if (groupPolicy !== "open") { return []; diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 18bcece05ad..a9e7a4766ed 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -4,7 +4,7 @@ import { formatPairingApproveHint, getChatChannelMeta, PAIRING_APPROVED_MESSAGE, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, setAccountEnabledInConfigSection, deleteAccountFromConfigSection, type ChannelPlugin, @@ -136,12 +136,10 @@ export const ircPlugin: ChannelPlugin = { collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy } = resolveRuntimeGroupPolicy({ + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.irc !== undefined, groupPolicy: account.config.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", }); if (groupPolicy === "open") { warnings.push( diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index eb6daeff611..31586f01417 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -2,7 +2,8 @@ import { createReplyPrefixOptions, logInboundDrop, resolveControlCommandGate, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; @@ -20,7 +21,6 @@ import { sendMessageIrc } from "./send.js"; import type { CoreConfig, IrcInboundMessage } from "./types.js"; const CHANNEL_ID = "irc" as const; -const warnedMissingProviderGroupPolicy = new Set(); const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -87,19 +87,19 @@ export async function handleIrcInbound(params: { const dmPolicy = account.config.dmPolicy ?? "pairing"; const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; - const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ - providerConfigPresent: config.channels?.irc !== undefined, - groupPolicy: account.config.groupPolicy, - defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: config.channels?.irc !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "irc", + accountId: account.accountId, + blockedLabel: "channel messages", + log: (message) => runtime.log?.(message), }); - if (providerMissingFallbackApplied && !warnedMissingProviderGroupPolicy.has(account.accountId)) { - warnedMissingProviderGroupPolicy.add(account.accountId); - runtime.log?.( - 'irc: channels.irc is missing; defaulting groupPolicy to "allowlist" (channel messages blocked until explicitly configured).', - ); - } const configAllowFrom = normalizeIrcAllowlist(account.config.allowFrom); const configGroupAllowFrom = normalizeIrcAllowlist(account.config.groupAllowFrom); diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index b70aa4f1c05..a2a73a87eb9 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -3,7 +3,7 @@ import { DEFAULT_ACCOUNT_ID, LineConfigSchema, processLineMessage, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, type ChannelPlugin, type ChannelStatusIssue, type OpenClawConfig, @@ -163,12 +163,10 @@ export const linePlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy } = resolveRuntimeGroupPolicy({ + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.line !== undefined, groupPolicy: account.config.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", }); if (groupPolicy !== "open") { return []; diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 75e4b464660..7547d6f0260 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -6,7 +6,7 @@ import { formatPairingApproveHint, normalizeAccountId, PAIRING_APPROVED_MESSAGE, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk"; @@ -171,12 +171,10 @@ export const matrixPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy; - const { groupPolicy } = resolveRuntimeGroupPolicy({ + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: (cfg as CoreConfig).channels?.matrix !== undefined, groupPolicy: account.config.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", }); if (groupPolicy !== "open") { return []; diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 91648498936..eba8b3703f6 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -1,8 +1,9 @@ import { format } from "node:util"; import { mergeAllowlist, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, summarizeMapping, + warnMissingProviderGroupPolicyFallbackOnce, type RuntimeEnv, } from "openclaw/plugin-sdk"; import { resolveMatrixTargets } from "../../resolve-targets.js"; @@ -248,20 +249,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg); const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy( - { + const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.matrix !== undefined, groupPolicy: accountConfig.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", - }, - ); - if (providerMissingFallbackApplied) { - logVerboseMessage( - 'matrix: channels.matrix is missing; defaulting groupPolicy to "allowlist" (room messages blocked until explicitly configured).', - ); - } + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "matrix", + accountId: account.accountId, + blockedLabel: "room messages", + log: (message) => logVerboseMessage(message), + }); const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw; const replyToMode = opts.replyToMode ?? accountConfig.replyToMode ?? "off"; const threadReplies = accountConfig.threadReplies ?? "inbound"; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 55e189b55de..4fcc38d189a 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -6,7 +6,7 @@ import { formatPairingApproveHint, migrateBaseNameToDefaultAccount, normalizeAccountId, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelMessageActionAdapter, type ChannelMessageActionName, @@ -230,12 +230,10 @@ export const mattermostPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy } = resolveRuntimeGroupPolicy({ + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.mattermost !== undefined, groupPolicy: account.config.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", }); if (groupPolicy !== "open") { return []; diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 81777f213e4..176d0e19d73 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -16,8 +16,9 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntryIfEnabled, resolveControlCommandGate, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, resolveChannelMediaMaxBytes, + warnMissingProviderGroupPolicyFallbackOnce, type HistoryEntry, } from "openclaw/plugin-sdk"; import { getMattermostRuntime } from "../runtime.js"; @@ -244,18 +245,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} ); const channelHistories = new Map(); const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ - providerConfigPresent: cfg.channels?.mattermost !== undefined, - groupPolicy: account.config.groupPolicy, - defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.mattermost !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "mattermost", + accountId: account.accountId, + log: (message) => logVerboseMessage(message), }); - if (providerMissingFallbackApplied) { - logVerboseMessage( - 'mattermost: channels.mattermost is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', - ); - } const fetchWithAuth: FetchLike = (input, init) => { const headers = new Headers(init?.headers); diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 9e35450d77a..b0aff91dd85 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -6,7 +6,7 @@ import { DEFAULT_ACCOUNT_ID, MSTeamsConfigSchema, PAIRING_APPROVED_MESSAGE, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, } from "openclaw/plugin-sdk"; import { listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryPeersLive } from "./directory-live.js"; import { msteamsOnboardingAdapter } from "./onboarding.js"; @@ -129,12 +129,10 @@ export const msteamsPlugin: ChannelPlugin = { security: { collectWarnings: ({ cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy } = resolveRuntimeGroupPolicy({ + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.msteams !== undefined, groupPolicy: cfg.channels?.msteams?.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", }); if (groupPolicy !== "open") { return []; diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 3b7769013f8..eb55a4cbd75 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -5,7 +5,7 @@ import { deleteAccountFromConfigSection, formatPairingApproveHint, normalizeAccountId, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, type OpenClawConfig, @@ -130,13 +130,11 @@ export const nextcloudTalkPlugin: ChannelPlugin = }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy } = resolveRuntimeGroupPolicy({ + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: (cfg.channels as Record | undefined)?.["nextcloud-talk"] !== undefined, groupPolicy: account.config.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", }); if (groupPolicy !== "open") { return []; diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 149bff15818..20195c9b817 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -2,7 +2,8 @@ import { createReplyPrefixOptions, logInboundDrop, resolveControlCommandGate, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; @@ -21,7 +22,6 @@ import { sendMessageNextcloudTalk } from "./send.js"; import type { CoreConfig, GroupPolicy, NextcloudTalkInboundMessage } from "./types.js"; const CHANNEL_ID = "nextcloud-talk" as const; -const warnedMissingProviderGroupPolicy = new Set(); async function deliverNextcloudTalkReply(params: { payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; @@ -91,21 +91,21 @@ export async function handleNextcloudTalkInbound(params: { | { groupPolicy?: string } | undefined )?.groupPolicy as GroupPolicy | undefined; - const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ - providerConfigPresent: - ((config.channels as Record | undefined)?.["nextcloud-talk"] ?? - undefined) !== undefined, - groupPolicy: account.config.groupPolicy as GroupPolicy | undefined, - defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: + ((config.channels as Record | undefined)?.["nextcloud-talk"] ?? + undefined) !== undefined, + groupPolicy: account.config.groupPolicy as GroupPolicy | undefined, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "nextcloud-talk", + accountId: account.accountId, + blockedLabel: "room messages", + log: (message) => runtime.log?.(message), }); - if (providerMissingFallbackApplied && !warnedMissingProviderGroupPolicy.has(account.accountId)) { - warnedMissingProviderGroupPolicy.add(account.accountId); - runtime.log?.( - 'nextcloud-talk: channels.nextcloud-talk is missing; defaulting groupPolicy to "allowlist" (room messages blocked until explicitly configured).', - ); - } const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom); const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom); diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index db309b5a09d..01426dd7ebc 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -17,7 +17,7 @@ import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, resolveDefaultSignalAccountId, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, resolveSignalAccount, setAccountEnabledInConfigSection, signalOnboardingAdapter, @@ -125,12 +125,10 @@ export const signalPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy } = resolveRuntimeGroupPolicy({ + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.signal !== undefined, groupPolicy: account.config.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", }); if (groupPolicy !== "open") { return []; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 8eda437cfed..050fa213e28 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -19,7 +19,7 @@ import { resolveDefaultSlackAccountId, resolveSlackAccount, resolveSlackReplyToMode, - resolveRuntimeGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, buildSlackThreadingToolContext, @@ -152,12 +152,10 @@ export const slackPlugin: ChannelPlugin = { collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy } = resolveRuntimeGroupPolicy({ + const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.slack !== undefined, groupPolicy: account.config.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "open", - missingProviderFallbackPolicy: "allowlist", }); const channelAllowlistConfigured = Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 858e6405e55..9836e0e139b 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -17,7 +17,7 @@ import { parseTelegramReplyToMessageId, parseTelegramThreadId, resolveDefaultTelegramAccountId, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, resolveTelegramAccount, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, @@ -197,12 +197,10 @@ export const telegramPlugin: ChannelPlugin { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy } = resolveRuntimeGroupPolicy({ + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.telegram !== undefined, groupPolicy: account.config.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", }); if (groupPolicy !== "open") { return []; diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 8796dcc14b6..d7abf02b031 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -19,7 +19,7 @@ import { readStringParam, resolveDefaultWhatsAppAccountId, resolveWhatsAppOutboundTarget, - resolveRuntimeGroupPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, resolveWhatsAppAccount, resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupToolPolicy, @@ -144,12 +144,10 @@ export const whatsappPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy } = resolveRuntimeGroupPolicy({ + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.whatsapp !== undefined, groupPolicy: account.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", }); if (groupPolicy !== "open") { return []; diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 6d723e0513b..ba2ee890e73 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -3,9 +3,10 @@ import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plu import { createReplyPrefixOptions, mergeAllowlist, - resolveRuntimeGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, resolveSenderCommandAuthorization, summarizeMapping, + warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk"; import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser } from "./send.js"; @@ -179,20 +180,17 @@ async function processMessage( const chatId = threadId; const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; - const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ providerConfigPresent: config.channels?.zalouser !== undefined, groupPolicy: account.config.groupPolicy, defaultGroupPolicy, - configuredFallbackPolicy: "open", - missingProviderFallbackPolicy: "allowlist", }); - if (providerMissingFallbackApplied) { - logVerbose( - core, - runtime, - 'zalouser: channels.zalouser is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', - ); - } + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "zalouser", + accountId: account.accountId, + log: (message) => logVerbose(core, runtime, message), + }); const groups = account.config.groups ?? {}; if (isGroup) { if (groupPolicy === "disabled") { diff --git a/src/config/runtime-group-policy.test.ts b/src/config/runtime-group-policy.test.ts index f49acda5cad..230954ca3b9 100644 --- a/src/config/runtime-group-policy.test.ts +++ b/src/config/runtime-group-policy.test.ts @@ -1,32 +1,85 @@ import { describe, expect, it } from "vitest"; -import { resolveRuntimeGroupPolicy } from "./runtime-group-policy.js"; +import { + resolveAllowlistProviderRuntimeGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, + resolveRuntimeGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "./runtime-group-policy.js"; describe("resolveRuntimeGroupPolicy", () => { - it("fails closed when provider config is missing and no defaults are set", () => { - const resolved = resolveRuntimeGroupPolicy({ - providerConfigPresent: false, - }); - expect(resolved.groupPolicy).toBe("allowlist"); - expect(resolved.providerMissingFallbackApplied).toBe(true); + it.each([ + { + title: "fails closed when provider config is missing and no defaults are set", + params: { providerConfigPresent: false }, + expectedPolicy: "allowlist", + expectedFallbackApplied: true, + }, + { + title: "keeps configured fallback when provider config is present", + params: { providerConfigPresent: true, configuredFallbackPolicy: "open" as const }, + expectedPolicy: "open", + expectedFallbackApplied: false, + }, + { + title: "ignores global defaults when provider config is missing", + params: { + providerConfigPresent: false, + defaultGroupPolicy: "disabled" as const, + configuredFallbackPolicy: "open" as const, + missingProviderFallbackPolicy: "allowlist" as const, + }, + expectedPolicy: "allowlist", + expectedFallbackApplied: true, + }, + ])("$title", ({ params, expectedPolicy, expectedFallbackApplied }) => { + const resolved = resolveRuntimeGroupPolicy(params); + expect(resolved.groupPolicy).toBe(expectedPolicy); + expect(resolved.providerMissingFallbackApplied).toBe(expectedFallbackApplied); }); +}); - it("keeps configured fallback when provider config is present", () => { - const resolved = resolveRuntimeGroupPolicy({ +describe("resolveOpenProviderRuntimeGroupPolicy", () => { + it("uses open fallback when provider config exists", () => { + const resolved = resolveOpenProviderRuntimeGroupPolicy({ providerConfigPresent: true, - configuredFallbackPolicy: "open", }); expect(resolved.groupPolicy).toBe("open"); expect(resolved.providerMissingFallbackApplied).toBe(false); }); +}); - it("ignores global defaults when provider config is missing", () => { - const resolved = resolveRuntimeGroupPolicy({ - providerConfigPresent: false, - defaultGroupPolicy: "disabled", - configuredFallbackPolicy: "open", - missingProviderFallbackPolicy: "allowlist", +describe("resolveAllowlistProviderRuntimeGroupPolicy", () => { + it("uses allowlist fallback when provider config exists", () => { + const resolved = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: true, }); expect(resolved.groupPolicy).toBe("allowlist"); - expect(resolved.providerMissingFallbackApplied).toBe(true); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); +}); + +describe("warnMissingProviderGroupPolicyFallbackOnce", () => { + it("logs only once per provider/account key", () => { + const lines: string[] = []; + const first = warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied: true, + providerKey: "runtime-policy-test", + accountId: "account-a", + blockedLabel: "room messages", + log: (message) => lines.push(message), + }); + const second = warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied: true, + providerKey: "runtime-policy-test", + accountId: "account-a", + blockedLabel: "room messages", + log: (message) => lines.push(message), + }); + + expect(first).toBe(true); + expect(second).toBe(false); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("channels.runtime-policy-test is missing"); + expect(lines[0]).toContain("room messages blocked"); }); }); diff --git a/src/config/runtime-group-policy.ts b/src/config/runtime-group-policy.ts index 12be2c2f8b9..c2658f3862a 100644 --- a/src/config/runtime-group-policy.ts +++ b/src/config/runtime-group-policy.ts @@ -5,13 +5,17 @@ export type RuntimeGroupPolicyResolution = { providerMissingFallbackApplied: boolean; }; -export function resolveRuntimeGroupPolicy(params: { +export type RuntimeGroupPolicyParams = { providerConfigPresent: boolean; groupPolicy?: GroupPolicy; defaultGroupPolicy?: GroupPolicy; configuredFallbackPolicy?: GroupPolicy; missingProviderFallbackPolicy?: GroupPolicy; -}): RuntimeGroupPolicyResolution { +}; + +export function resolveRuntimeGroupPolicy( + params: RuntimeGroupPolicyParams, +): RuntimeGroupPolicyResolution { const configuredFallbackPolicy = params.configuredFallbackPolicy ?? "open"; const missingProviderFallbackPolicy = params.missingProviderFallbackPolicy ?? "allowlist"; const groupPolicy = params.providerConfigPresent @@ -21,3 +25,67 @@ export function resolveRuntimeGroupPolicy(params: { !params.providerConfigPresent && params.groupPolicy === undefined; return { groupPolicy, providerMissingFallbackApplied }; } + +export type ResolveProviderRuntimeGroupPolicyParams = { + providerConfigPresent: boolean; + groupPolicy?: GroupPolicy; + defaultGroupPolicy?: GroupPolicy; +}; + +/** + * Standard provider runtime policy: + * - configured provider fallback: open + * - missing provider fallback: allowlist (fail-closed) + */ +export function resolveOpenProviderRuntimeGroupPolicy( + params: ResolveProviderRuntimeGroupPolicyParams, +): RuntimeGroupPolicyResolution { + return resolveRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); +} + +/** + * Strict provider runtime policy: + * - configured provider fallback: allowlist + * - missing provider fallback: allowlist (fail-closed) + */ +export function resolveAllowlistProviderRuntimeGroupPolicy( + params: ResolveProviderRuntimeGroupPolicyParams, +): RuntimeGroupPolicyResolution { + return resolveRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); +} + +const warnedMissingProviderGroupPolicy = new Set(); + +export function warnMissingProviderGroupPolicyFallbackOnce(params: { + providerMissingFallbackApplied: boolean; + providerKey: string; + accountId?: string; + blockedLabel?: string; + log: (message: string) => void; +}): boolean { + if (!params.providerMissingFallbackApplied) { + return false; + } + const key = `${params.providerKey}:${params.accountId ?? "*"}`; + if (warnedMissingProviderGroupPolicy.has(key)) { + return false; + } + warnedMissingProviderGroupPolicy.add(key); + const blockedLabel = params.blockedLabel?.trim() || "group messages"; + params.log( + `${params.providerKey}: channels.${params.providerKey} is missing; defaulting groupPolicy to "allowlist" (${blockedLabel} blocked until explicitly configured).`, + ); + return true; +} diff --git a/src/discord/monitor/message-handler.ts b/src/discord/monitor/message-handler.ts index 8beae2e6277..fd69ff4e320 100644 --- a/src/discord/monitor/message-handler.ts +++ b/src/discord/monitor/message-handler.ts @@ -4,7 +4,7 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../auto-reply/inbound-debounce.js"; -import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; +import { resolveOpenProviderRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import { danger } from "../../globals.js"; import type { DiscordMessageEvent, DiscordMessageHandler } from "./listeners.js"; import { preflightDiscordMessage } from "./message-handler.preflight.js"; @@ -24,12 +24,10 @@ type DiscordMessageHandlerParams = Omit< export function createDiscordMessageHandler( params: DiscordMessageHandlerParams, ): DiscordMessageHandler { - const { groupPolicy } = resolveRuntimeGroupPolicy({ + const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ providerConfigPresent: params.cfg.channels?.discord !== undefined, groupPolicy: params.discordConfig?.groupPolicy, defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy, - configuredFallbackPolicy: "open", - missingProviderFallbackPolicy: "allowlist", }); const ackReactionScope = params.cfg.messages?.ackReactionScope ?? "group-mentions"; const debounceMs = resolveInboundDebounceMs({ cfg: params.cfg, channel: "discord" }); diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 9ab2c5c3a4c..adad1be709f 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -39,7 +39,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import type { OpenClawConfig, loadConfig } from "../../config/config.js"; -import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; +import { resolveOpenProviderRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; @@ -1330,12 +1330,10 @@ async function dispatchDiscordCommandInteraction(params: { const channelAllowlistConfigured = Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0; const channelAllowed = channelConfig?.allowed !== false; - const { groupPolicy } = resolveRuntimeGroupPolicy({ + const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.discord !== undefined, groupPolicy: discordConfig?.groupPolicy, defaultGroupPolicy: cfg.channels?.defaults?.groupPolicy, - configuredFallbackPolicy: "open", - missingProviderFallbackPolicy: "allowlist", }); const allowByPolicy = isDiscordGroupAllowedByPolicy({ groupPolicy, diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index cea9303f0da..6fab5af9e67 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -21,8 +21,10 @@ import { } from "../../config/commands.js"; import type { OpenClawConfig, ReplyToMode } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; -import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; -import type { GroupPolicy } from "../../config/types.base.js"; +import { + resolveOpenProviderRuntimeGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../../config/runtime-group-policy.js"; import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createDiscordRetryRunner } from "../../infra/retry-policy.js"; @@ -172,23 +174,6 @@ function dedupeSkillCommandsForDiscord( return deduped; } -function resolveDiscordRuntimeGroupPolicy(params: { - providerConfigPresent: boolean; - groupPolicy?: GroupPolicy; - defaultGroupPolicy?: GroupPolicy; -}): { - groupPolicy: GroupPolicy; - providerMissingFallbackApplied: boolean; -} { - return resolveRuntimeGroupPolicy({ - providerConfigPresent: params.providerConfigPresent, - groupPolicy: params.groupPolicy, - defaultGroupPolicy: params.defaultGroupPolicy, - configuredFallbackPolicy: "open", - missingProviderFallbackPolicy: "allowlist", - }); -} - async function deployDiscordCommands(params: { client: Client; runtime: RuntimeEnv; @@ -273,20 +258,20 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { let guildEntries = rawDiscordCfg.guilds; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; const providerConfigPresent = cfg.channels?.discord !== undefined; - const { groupPolicy, providerMissingFallbackApplied } = resolveDiscordRuntimeGroupPolicy({ + const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ providerConfigPresent, groupPolicy: rawDiscordCfg.groupPolicy, defaultGroupPolicy, }); const discordCfg = rawDiscordCfg.groupPolicy === groupPolicy ? rawDiscordCfg : { ...rawDiscordCfg, groupPolicy }; - if (providerMissingFallbackApplied) { - runtime.log?.( - warn( - 'discord: channels.discord is missing; defaulting groupPolicy to "allowlist" (guild messages blocked until explicitly configured).', - ), - ); - } + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "discord", + accountId: account.accountId, + blockedLabel: "guild messages", + log: (message) => runtime.log?.(warn(message)), + }); let allowFrom = discordCfg.allowFrom ?? dmConfig?.allowFrom; const mediaMaxBytes = (opts.mediaMaxMb ?? discordCfg.mediaMaxMb ?? 8) * 1024 * 1024; const textLimit = resolveTextChunkLimit(cfg, "discord", account.accountId, { @@ -643,7 +628,7 @@ async function clearDiscordNativeCommands(params: { export const __testing = { createDiscordGatewayPlugin, dedupeSkillCommandsForDiscord, - resolveDiscordRuntimeGroupPolicy, + resolveDiscordRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, resolveDiscordRestFetch, resolveThreadBindingsEnabled, }; diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 2a114e8465e..69f568442a2 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -16,9 +16,11 @@ import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.j import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; import { loadConfig } from "../../config/config.js"; -import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; +import { + resolveOpenProviderRuntimeGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../../config/runtime-group-policy.js"; import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; -import type { GroupPolicy } from "../../config/types.base.js"; import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js"; import { normalizeScpRemoteHost } from "../../infra/scp-host.js"; import { waitForTransportReady } from "../../infra/transport-ready.js"; @@ -122,23 +124,6 @@ class SentMessageCache { } } -function resolveIMessageRuntimeGroupPolicy(params: { - providerConfigPresent: boolean; - groupPolicy?: GroupPolicy; - defaultGroupPolicy?: GroupPolicy; -}): { - groupPolicy: GroupPolicy; - providerMissingFallbackApplied: boolean; -} { - return resolveRuntimeGroupPolicy({ - providerConfigPresent: params.providerConfigPresent, - groupPolicy: params.groupPolicy, - defaultGroupPolicy: params.defaultGroupPolicy, - configuredFallbackPolicy: "open", - missingProviderFallbackPolicy: "allowlist", - }); -} - export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise { const runtime = resolveRuntime(opts); const cfg = opts.config ?? loadConfig(); @@ -163,18 +148,17 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P (imessageCfg.allowFrom && imessageCfg.allowFrom.length > 0 ? imessageCfg.allowFrom : []), ); const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy, providerMissingFallbackApplied } = resolveIMessageRuntimeGroupPolicy({ + const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.imessage !== undefined, groupPolicy: imessageCfg.groupPolicy, defaultGroupPolicy, }); - if (providerMissingFallbackApplied) { - runtime.log?.( - warn( - 'imessage: channels.imessage is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', - ), - ); - } + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "imessage", + accountId: accountInfo.accountId, + log: (message) => runtime.log?.(warn(message)), + }); const dmPolicy = imessageCfg.dmPolicy ?? "pairing"; const includeAttachments = opts.includeAttachments ?? imessageCfg.includeAttachments ?? false; const mediaMaxBytes = (opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024; @@ -540,5 +524,5 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P } export const __testing = { - resolveIMessageRuntimeGroupPolicy, + resolveIMessageRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, }; diff --git a/src/line/bot-handlers.ts b/src/line/bot-handlers.ts index 096d7fcc188..b86a4f1a4ee 100644 --- a/src/line/bot-handlers.ts +++ b/src/line/bot-handlers.ts @@ -8,7 +8,10 @@ import type { PostbackEvent, } from "@line/bot-sdk"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveRuntimeGroupPolicy } from "../config/runtime-group-policy.js"; +import { + resolveAllowlistProviderRuntimeGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../config/runtime-group-policy.js"; import { danger, logVerbose } from "../globals.js"; import { resolvePairingIdLabel } from "../pairing/pairing-labels.js"; import { buildPairingReply } from "../pairing/pairing-messages.js"; @@ -41,8 +44,6 @@ export interface LineHandlerContext { processMessage: (ctx: LineInboundContext) => Promise; } -let lineGroupPolicyFallbackWarned = false; - function resolveLineGroupConfig(params: { config: ResolvedLineAccount["config"]; groupId?: string; @@ -136,19 +137,18 @@ async function shouldProcessLineEvent( dmPolicy, }); const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ - providerConfigPresent: cfg.channels?.line !== undefined, - groupPolicy: account.config.groupPolicy, - defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.line !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "line", + accountId: account.accountId, + log: (message) => logVerbose(message), }); - if (providerMissingFallbackApplied && !lineGroupPolicyFallbackWarned) { - lineGroupPolicyFallbackWarned = true; - logVerbose( - 'line: channels.line is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', - ); - } if (isGroup) { if (groupConfig?.enabled === false) { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 07e3c63d7f6..7d64d5ffa27 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -133,8 +133,13 @@ export type { MSTeamsTeamConfig, } from "../config/types.js"; export { + resolveAllowlistProviderRuntimeGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, resolveRuntimeGroupPolicy, type RuntimeGroupPolicyResolution, + type RuntimeGroupPolicyParams, + type ResolveProviderRuntimeGroupPolicyParams, + warnMissingProviderGroupPolicyFallbackOnce, } from "../config/runtime-group-policy.js"; export { DiscordConfigSchema, diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index c9bc8dcb219..8424e11cea4 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -3,7 +3,10 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/re import type { ReplyPayload } from "../auto-reply/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; -import { resolveRuntimeGroupPolicy } from "../config/runtime-group-policy.js"; +import { + resolveAllowlistProviderRuntimeGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../config/runtime-group-policy.js"; import type { SignalReactionNotificationMode } from "../config/types.js"; import { waitForTransportReady } from "../infra/transport-ready.js"; import { saveMediaBuffer } from "../media/store.js"; @@ -346,18 +349,18 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi : []), ); const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ - providerConfigPresent: cfg.channels?.signal !== undefined, - groupPolicy: accountInfo.config.groupPolicy, - defaultGroupPolicy, - configuredFallbackPolicy: "allowlist", - missingProviderFallbackPolicy: "allowlist", + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.signal !== undefined, + groupPolicy: accountInfo.config.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "signal", + accountId: accountInfo.accountId, + log: (message) => runtime.log?.(message), }); - if (providerMissingFallbackApplied) { - runtime.log?.( - 'signal: channels.signal is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', - ); - } const reactionMode = accountInfo.config.reactionNotifications ?? "own"; const reactionAllowlist = normalizeAllowList(accountInfo.config.reactionAllowlist); const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024; diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 1d52d561036..472d459b35d 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -10,9 +10,11 @@ import { summarizeMapping, } from "../../channels/allowlists/resolve-utils.js"; import { loadConfig } from "../../config/config.js"; -import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; +import { + resolveOpenProviderRuntimeGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../../config/runtime-group-policy.js"; import type { SessionScope } from "../../config/sessions.js"; -import type { GroupPolicy } from "../../config/types.base.js"; import { warn } from "../../globals.js"; import { installRequestBodyLimitGuard } from "../../infra/http-body.js"; import { normalizeMainKey } from "../../routing/session-key.js"; @@ -43,23 +45,6 @@ const { App, HTTPReceiver } = slackBolt; const SLACK_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; const SLACK_WEBHOOK_BODY_TIMEOUT_MS = 30_000; -function resolveSlackRuntimeGroupPolicy(params: { - providerConfigPresent: boolean; - groupPolicy?: GroupPolicy; - defaultGroupPolicy?: GroupPolicy; -}): { - groupPolicy: GroupPolicy; - providerMissingFallbackApplied: boolean; -} { - return resolveRuntimeGroupPolicy({ - providerConfigPresent: params.providerConfigPresent, - groupPolicy: params.groupPolicy, - defaultGroupPolicy: params.defaultGroupPolicy, - configuredFallbackPolicy: "open", - missingProviderFallbackPolicy: "allowlist", - }); -} - function parseApiAppIdFromAppToken(raw?: string) { const token = raw?.trim(); if (!token) { @@ -119,18 +104,17 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { let channelsConfig = slackCfg.channels; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; const providerConfigPresent = cfg.channels?.slack !== undefined; - const { groupPolicy, providerMissingFallbackApplied } = resolveSlackRuntimeGroupPolicy({ + const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ providerConfigPresent, groupPolicy: slackCfg.groupPolicy, defaultGroupPolicy, }); - if (providerMissingFallbackApplied) { - runtime.log?.( - warn( - 'slack: channels.slack is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', - ), - ); - } + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "slack", + accountId: account.accountId, + log: (message) => runtime.log?.(warn(message)), + }); const resolveToken = slackCfg.userToken?.trim() || botToken; const useAccessGroups = cfg.commands?.useAccessGroups !== false; @@ -384,5 +368,5 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { } export const __testing = { - resolveSlackRuntimeGroupPolicy, + resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, }; diff --git a/src/telegram/group-access.ts b/src/telegram/group-access.ts index 571457d3b65..dcd0dd2ef6e 100644 --- a/src/telegram/group-access.ts +++ b/src/telegram/group-access.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js"; -import { resolveRuntimeGroupPolicy } from "../config/runtime-group-policy.js"; +import { resolveOpenProviderRuntimeGroupPolicy } from "../config/runtime-group-policy.js"; import type { TelegramAccountConfig, TelegramGroupConfig, @@ -78,12 +78,10 @@ export const resolveTelegramRuntimeGroupPolicy = (params: { groupPolicy?: TelegramAccountConfig["groupPolicy"]; defaultGroupPolicy?: TelegramAccountConfig["groupPolicy"]; }) => - resolveRuntimeGroupPolicy({ + resolveOpenProviderRuntimeGroupPolicy({ providerConfigPresent: params.providerConfigPresent, groupPolicy: params.groupPolicy, defaultGroupPolicy: params.defaultGroupPolicy, - configuredFallbackPolicy: "open", - missingProviderFallbackPolicy: "allowlist", }); export const evaluateTelegramGroupPolicyAccess = (params: { diff --git a/src/web/inbound/access-control.ts b/src/web/inbound/access-control.ts index 5f5737f3a2b..e4f6454345b 100644 --- a/src/web/inbound/access-control.ts +++ b/src/web/inbound/access-control.ts @@ -1,5 +1,8 @@ import { loadConfig } from "../../config/config.js"; -import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; +import { + resolveOpenProviderRuntimeGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../../config/runtime-group-policy.js"; import { logVerbose } from "../../globals.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { @@ -26,12 +29,10 @@ function resolveWhatsAppRuntimeGroupPolicy(params: { groupPolicy: "open" | "allowlist" | "disabled"; providerMissingFallbackApplied: boolean; } { - return resolveRuntimeGroupPolicy({ + return resolveOpenProviderRuntimeGroupPolicy({ providerConfigPresent: params.providerConfigPresent, groupPolicy: params.groupPolicy, defaultGroupPolicy: params.defaultGroupPolicy, - configuredFallbackPolicy: "open", - missingProviderFallbackPolicy: "allowlist", }); } @@ -105,11 +106,12 @@ export async function checkInboundAccessControl(params: { groupPolicy: account.groupPolicy, defaultGroupPolicy, }); - if (providerMissingFallbackApplied) { - logVerbose( - 'whatsapp: channels.whatsapp is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', - ); - } + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "whatsapp", + accountId: account.accountId, + log: (message) => logVerbose(message), + }); if (params.group && groupPolicy === "disabled") { logVerbose("Blocked group message (groupPolicy: disabled)"); return { From 8f0b2b84e78dae22ce928524594c9c7a8fbabf17 Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Sun, 22 Feb 2026 03:30:09 -0700 Subject: [PATCH 44/81] Onboarding: default dmScope to per-channel-peer --- src/commands/onboard-config.test.ts | 28 ++++++++++++++++++++++++++++ src/commands/onboard-config.ts | 6 ++++++ 2 files changed, 34 insertions(+) create mode 100644 src/commands/onboard-config.test.ts diff --git a/src/commands/onboard-config.test.ts b/src/commands/onboard-config.test.ts new file mode 100644 index 00000000000..7c9060ea6d3 --- /dev/null +++ b/src/commands/onboard-config.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + applyOnboardingLocalWorkspaceConfig, + ONBOARDING_DEFAULT_DM_SCOPE, +} from "./onboard-config.js"; + +describe("applyOnboardingLocalWorkspaceConfig", () => { + it("sets secure dmScope default when unset", () => { + const baseConfig: OpenClawConfig = {}; + const result = applyOnboardingLocalWorkspaceConfig(baseConfig, "/tmp/workspace"); + + expect(result.session?.dmScope).toBe(ONBOARDING_DEFAULT_DM_SCOPE); + expect(result.gateway?.mode).toBe("local"); + expect(result.agents?.defaults?.workspace).toBe("/tmp/workspace"); + }); + + it("preserves existing dmScope when already configured", () => { + const baseConfig: OpenClawConfig = { + session: { + dmScope: "main", + }, + }; + const result = applyOnboardingLocalWorkspaceConfig(baseConfig, "/tmp/workspace"); + + expect(result.session?.dmScope).toBe("main"); + }); +}); diff --git a/src/commands/onboard-config.ts b/src/commands/onboard-config.ts index dc7c8cd4faa..579e5f9d700 100644 --- a/src/commands/onboard-config.ts +++ b/src/commands/onboard-config.ts @@ -1,5 +1,7 @@ import type { OpenClawConfig } from "../config/config.js"; +export const ONBOARDING_DEFAULT_DM_SCOPE = "per-channel-peer"; + export function applyOnboardingLocalWorkspaceConfig( baseConfig: OpenClawConfig, workspaceDir: string, @@ -17,5 +19,9 @@ export function applyOnboardingLocalWorkspaceConfig( ...baseConfig.gateway, mode: "local", }, + session: { + ...baseConfig.session, + dmScope: baseConfig.session?.dmScope ?? ONBOARDING_DEFAULT_DM_SCOPE, + }, }; } From 65dccbdb4b4880a08cd7c805e72da808daf7611c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 12:36:33 +0100 Subject: [PATCH 45/81] fix: document onboarding dmScope default as breaking change (#23468) (thanks @bmendonca3) --- CHANGELOG.md | 1 + docs/cli/onboard.md | 1 + docs/concepts/session.md | 1 + docs/gateway/security/index.md | 1 + docs/reference/wizard.md | 1 + docs/start/wizard-cli-reference.md | 1 + docs/start/wizard.md | 1 + 7 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3abdeb157cb..c7896ac2879 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected. - **BREAKING:** unify channel preview-streaming config to `channels..streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names. +- **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3. ### Fixes diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index ee6f147f288..fab08d8dae5 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -60,6 +60,7 @@ Flow notes: - `quickstart`: minimal prompts, auto-generates a gateway token. - `manual`: full prompts for port/bind/auth (alias of `advanced`). +- Local onboarding defaults `session.dmScope` to `per-channel-peer` unless `session.dmScope` is already set. - Fastest first chat: `openclaw dashboard` (Control UI, no channel setup). - Custom Provider: connect any OpenAI or Anthropic compatible endpoint, including hosted providers not listed. Use Unknown to auto-detect. diff --git a/docs/concepts/session.md b/docs/concepts/session.md index edd6f415d28..3d1503ab80e 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -49,6 +49,7 @@ Use `session.dmScope` to control how **direct messages** are grouped: Notes: - Default is `dmScope: "main"` for continuity (all DMs share the main session). This is fine for single-user setups. +- Local CLI onboarding writes `session.dmScope: "per-channel-peer"` by default when unset (existing explicit values are preserved). - For multi-account inboxes on the same channel, prefer `per-account-channel-peer`. - If the same person contacts you on multiple channels, use `session.identityLinks` to collapse their DM sessions into one canonical identity. - You can verify your DM settings with `openclaw security audit` (see [security](/cli/security)). diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index f5e46dce43c..7bf0f84abc7 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -332,6 +332,7 @@ This is a messaging-context boundary, not a host-admin boundary. If users are mu Treat the snippet above as **secure DM mode**: - Default: `session.dmScope: "main"` (all DMs share one session for continuity). +- Local CLI onboarding default: writes `session.dmScope: "per-channel-peer"` when unset (keeps existing explicit values). - Secure DM mode: `session.dmScope: "per-channel-peer"` (each channel+sender pair gets an isolated DM context). If you run multiple accounts on the same channel, use `per-account-channel-peer` instead. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration). diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index 19191252e11..3583420a769 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -243,6 +243,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `agents.defaults.workspace` - `agents.defaults.model` / `models.providers` (if Minimax chosen) - `gateway.*` (mode, bind, auth, tailscale) +- `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` - Channel allowlists (Slack/Discord/Matrix/Microsoft Teams) when you opt in during the prompts (names resolve to IDs when possible). - `skills.install.nodeManager` diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index b0b31de8c60..96fd1d87afc 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -215,6 +215,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `agents.defaults.workspace` - `agents.defaults.model` / `models.providers` (if Minimax chosen) - `gateway.*` (mode, bind, auth, tailscale) +- `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` - Channel allowlists (Slack, Discord, Matrix, Microsoft Teams) when you opt in during prompts (names resolve to IDs when possible) - `skills.install.nodeManager` diff --git a/docs/start/wizard.md b/docs/start/wizard.md index b869c85665f..57a25b15810 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -50,6 +50,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). - Workspace default (or existing workspace) - Gateway port **18789** - Gateway auth **Token** (auto‑generated, even on loopback) + - DM isolation default: `session.dmScope: "per-channel-peer"` (existing explicit `session.dmScope` values are preserved) - Tailscale exposure **Off** - Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number) From 3a65e4b523b84ebdf9649ce7f6ac310556e2a3dc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 12:40:21 +0100 Subject: [PATCH 46/81] test: make snapshot env override assertion independent of host env --- src/agents/skills.test.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index f8dfdd083cf..8020c33800b 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -380,24 +380,26 @@ describe("applySkillEnvOverrides", () => { metadata: '{"openclaw":{"requires":{"env":["OPENAI_API_KEY"]}}}', }); + const config = { + skills: { + entries: { + "snapshot-env-skill": { + env: { + OPENAI_API_KEY: "snap-secret", + }, + }, + }, + }, + }; const snapshot = buildWorkspaceSkillSnapshot(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), + config, }); withClearedEnv(["OPENAI_API_KEY"], () => { const restore = applySkillEnvOverridesFromSnapshot({ snapshot, - config: { - skills: { - entries: { - "snapshot-env-skill": { - env: { - OPENAI_API_KEY: "snap-secret", - }, - }, - }, - }, - }, + config, }); try { From 13944f773ff59ac5c255dfa7547b12bdc1c1f219 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 22 Feb 2026 05:39:09 -0600 Subject: [PATCH 47/81] UI: use gateway token for login gate auth --- ui/src/i18n/locales/en.ts | 2 +- ui/src/i18n/locales/pt-BR.ts | 2 +- ui/src/i18n/locales/zh-CN.ts | 2 +- ui/src/i18n/locales/zh-TW.ts | 2 +- ui/src/ui/views/login-gate.ts | 5 +++-- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index cfe67013fdc..8c66a63c203 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -140,7 +140,7 @@ export const en: TranslationMap = { }, login: { subtitle: "Gateway Dashboard", - passwordPlaceholder: "optional", + tokenPlaceholder: "paste gateway token", }, chat: { disconnected: "Disconnected from gateway.", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index e9ba45392b7..b42234917c5 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -142,7 +142,7 @@ export const pt_BR: TranslationMap = { }, login: { subtitle: "Painel do Gateway", - passwordPlaceholder: "opcional", + tokenPlaceholder: "cole o token do gateway", }, chat: { disconnected: "Desconectado do gateway.", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 585883e3a8f..8fd4d86bd91 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -139,7 +139,7 @@ export const zh_CN: TranslationMap = { }, login: { subtitle: "网关仪表盘", - passwordPlaceholder: "可选", + tokenPlaceholder: "粘贴网关令牌", }, chat: { disconnected: "已断开与网关的连接。", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index 95104280846..c480d32fb2b 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -139,7 +139,7 @@ export const zh_TW: TranslationMap = { }, login: { subtitle: "閘道儀表板", - passwordPlaceholder: "可選", + tokenPlaceholder: "貼上閘道令牌", }, chat: { disconnected: "已斷開與網關的連接。", diff --git a/ui/src/ui/views/login-gate.ts b/ui/src/ui/views/login-gate.ts index 58b0033d254..624da905095 100644 --- a/ui/src/ui/views/login-gate.ts +++ b/ui/src/ui/views/login-gate.ts @@ -30,15 +30,16 @@ export function renderLoginGate(state: AppViewState) { />