From ab318de8b751fa01fc636c32f41825ada03c223e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 00:08:53 +0100 Subject: [PATCH] test(plugins): finish moving contract coverage --- .../native-command.think-autocomplete.test.ts | 38 +- extensions/imessage/src/normalize.test.ts | 28 + extensions/imessage/src/test-plugin.test.ts | 20 + .../line/src/reply-payload-transform.test.ts | 387 +++++++++++++ .../matrix/src/account-selection.test.ts | 214 ++++---- extensions/matrix/src/legacy-crypto.test.ts | 194 +++++++ extensions/matrix/src/legacy-state.test.ts | 86 +++ .../matrix/src/migration-config.test.ts | 228 ++++++++ .../matrix/src/migration-snapshot.test.ts | 98 ++++ .../monitor-route-test-support.ts | 12 +- .../signal/src/install-signal-cli.test.ts | 193 +++++++ extensions/signal/src/normalize.test.ts | 50 ++ extensions/slack/src/outbound-payload.test.ts | 2 +- .../telegram/src/bot-native-commands.test.ts | 2 +- extensions/whatsapp/src/outbound-base.test.ts | 5 +- .../whatsapp/src/resolve-target.test.ts | 17 + extensions/whatsapp/src/text-runtime.test.ts | 152 ++++++ .../test-support/lifecycle-test-support.ts | 2 +- .../monitor-mocks-test-support.ts | 2 +- src/acp/persistent-bindings.test.ts | 2 +- .../pi-embedded-runner-extraparams.test.ts | 2 +- src/auto-reply/reply/commands.test.ts | 6 +- src/auto-reply/reply/line-directives.ts | 342 ------------ src/auto-reply/reply/slack-directives.test.ts | 220 -------- .../command-auth-registry-fixture.ts | 32 +- .../channel-import-guardrails.test.ts | 7 +- .../contracts/dm-policy.contract.test.ts | 5 +- .../contracts/slack-outbound-harness.ts | 50 -- .../plugins/normalize/targets.test.ts | 32 -- src/cli/update-cli.test.ts | 7 +- ...time-errors-channels-status-output.test.ts | 2 +- src/commands/doctor-config-flow.test.ts | 2 +- src/commands/doctor-state-migrations.test.ts | 24 +- src/commands/doctor.e2e-harness.ts | 9 +- .../doctor/shared/mutable-allowlist.test.ts | 85 --- src/commands/onboard-channels.e2e.test.ts | 5 +- .../channel-token-and-http.validation.test.ts | 167 ++++++ ...nel-webhook-and-actions.validation.test.ts | 175 ++++++ src/config/discord-preview-streaming.test.ts | 14 - src/config/discord-preview-streaming.ts | 161 ------ src/config/slack-http-config.test.ts | 96 ---- src/config/slack-token-validation.test.ts | 71 --- src/config/telegram-actions-poll.test.ts | 36 -- src/config/telegram-webhook-port.test.ts | 46 -- src/config/telegram-webhook-secret.test.ts | 93 ---- src/gateway/hooks.test.ts | 2 +- ...tbeat-runner.returns-default-unset.test.ts | 5 +- src/infra/matrix-account-selection.test.ts | 124 ----- src/infra/matrix-legacy-crypto.test.ts | 440 --------------- src/infra/matrix-legacy-crypto.ts | 513 ------------------ src/infra/matrix-legacy-state.test.ts | 244 --------- src/infra/matrix-legacy-state.ts | 156 ------ src/infra/matrix-migration-config.ts | 327 ----------- src/infra/matrix-migration-snapshot.test.ts | 226 -------- src/infra/matrix-migration-snapshot.ts | 151 ------ src/infra/matrix-plugin-helper.test.ts | 221 -------- src/infra/matrix.test-helpers.ts | 80 --- src/infra/outbound/deliver.test.ts | 2 +- src/infra/retry-policy.test.ts | 8 +- src/infra/state-migrations.test.ts | 14 +- src/markdown/whatsapp.test.ts | 36 -- .../channel-inbound-roots.contract.test.ts | 80 +++ src/media/inbound-path-policy.test.ts | 79 --- src/plugin-sdk/channel-policy.test.ts | 52 +- src/plugin-sdk/whatsapp-targets.test.ts | 24 - .../contracts/plugin-sdk-subpaths.test.ts | 7 +- src/plugins/interactive.test.ts | 16 +- src/plugins/signal-cli-install.ts | 302 ----------- src/process/exec.windows.test.ts | 3 +- src/routing/session-key.test.ts | 1 + src/security/audit.test.ts | 12 +- src/test-helpers/whatsapp-outbound.ts | 33 -- src/test-utils/imessage-test-plugin.ts | 82 --- src/utils.test.ts | 100 ---- test/extension-test-boundary.test.ts | 5 +- test/helpers/bundled-runtime-sidecars.ts | 5 + .../channels/channel-media-roots-contract.ts | 5 + test/helpers/channels/command-contract.ts | 6 + test/helpers/channels/imessage-test-plugin.ts | 1 + test/helpers/channels/interactive-contract.ts | 12 + .../matrix-setup-contract.ts} | 2 +- .../channels/outbound-payload-contract.ts | 2 +- test/helpers/channels/policy-contract.ts | 9 + .../channels/security-audit-contract.ts | 5 + test/helpers/plugins/matrix-monitor-route.ts | 8 - test/helpers/providers/anthropic-contract.ts | 8 + test/vitest-unit-paths.test.ts | 3 + 87 files changed, 2225 insertions(+), 4607 deletions(-) create mode 100644 extensions/imessage/src/normalize.test.ts create mode 100644 extensions/imessage/src/test-plugin.test.ts create mode 100644 extensions/line/src/reply-payload-transform.test.ts create mode 100644 extensions/matrix/src/legacy-crypto.test.ts create mode 100644 extensions/matrix/src/legacy-state.test.ts create mode 100644 extensions/matrix/src/migration-config.test.ts create mode 100644 extensions/matrix/src/migration-snapshot.test.ts create mode 100644 extensions/signal/src/install-signal-cli.test.ts create mode 100644 extensions/signal/src/normalize.test.ts create mode 100644 extensions/whatsapp/src/text-runtime.test.ts delete mode 100644 src/auto-reply/reply/line-directives.ts delete mode 100644 src/auto-reply/reply/slack-directives.test.ts delete mode 100644 src/channels/plugins/contracts/slack-outbound-harness.ts delete mode 100644 src/channels/plugins/normalize/targets.test.ts delete mode 100644 src/commands/doctor/shared/mutable-allowlist.test.ts create mode 100644 src/config/channel-token-and-http.validation.test.ts create mode 100644 src/config/channel-webhook-and-actions.validation.test.ts delete mode 100644 src/config/discord-preview-streaming.test.ts delete mode 100644 src/config/discord-preview-streaming.ts delete mode 100644 src/config/slack-http-config.test.ts delete mode 100644 src/config/slack-token-validation.test.ts delete mode 100644 src/config/telegram-actions-poll.test.ts delete mode 100644 src/config/telegram-webhook-port.test.ts delete mode 100644 src/config/telegram-webhook-secret.test.ts delete mode 100644 src/infra/matrix-account-selection.test.ts delete mode 100644 src/infra/matrix-legacy-crypto.test.ts delete mode 100644 src/infra/matrix-legacy-crypto.ts delete mode 100644 src/infra/matrix-legacy-state.test.ts delete mode 100644 src/infra/matrix-legacy-state.ts delete mode 100644 src/infra/matrix-migration-config.ts delete mode 100644 src/infra/matrix-migration-snapshot.test.ts delete mode 100644 src/infra/matrix-migration-snapshot.ts delete mode 100644 src/infra/matrix-plugin-helper.test.ts delete mode 100644 src/infra/matrix.test-helpers.ts delete mode 100644 src/markdown/whatsapp.test.ts create mode 100644 src/media/channel-inbound-roots.contract.test.ts delete mode 100644 src/plugin-sdk/whatsapp-targets.test.ts delete mode 100644 src/plugins/signal-cli-install.ts delete mode 100644 src/test-helpers/whatsapp-outbound.ts delete mode 100644 src/test-utils/imessage-test-plugin.ts create mode 100644 test/helpers/bundled-runtime-sidecars.ts create mode 100644 test/helpers/channels/channel-media-roots-contract.ts create mode 100644 test/helpers/channels/command-contract.ts create mode 100644 test/helpers/channels/imessage-test-plugin.ts create mode 100644 test/helpers/channels/interactive-contract.ts rename test/helpers/{plugins/matrix-setup.ts => channels/matrix-setup-contract.ts} (64%) create mode 100644 test/helpers/channels/policy-contract.ts create mode 100644 test/helpers/channels/security-audit-contract.ts delete mode 100644 test/helpers/plugins/matrix-monitor-route.ts create mode 100644 test/helpers/providers/anthropic-contract.ts diff --git a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts index db250ae2ea1..b961caa01ec 100644 --- a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts +++ b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts @@ -11,22 +11,34 @@ import { clearSessionStoreCacheForTest } from "openclaw/plugin-sdk/config-runtim import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createNoopThreadBindingManager } from "./thread-bindings.js"; +type ConversationRuntimeModule = typeof import("openclaw/plugin-sdk/conversation-runtime"); +type ResolveConfiguredBindingRoute = ConversationRuntimeModule["resolveConfiguredBindingRoute"]; +type ConfiguredBindingRouteResult = ReturnType; type EnsureConfiguredBindingRouteReady = - typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady; -type ResolveConfiguredBindingRoute = - typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute; + ConversationRuntimeModule["ensureConfiguredBindingRouteReady"]; +function createUnboundConfiguredRouteResult(): ConfiguredBindingRouteResult { + return { + bindingResolution: null, + route: { + agentId: "main", + channel: "discord", + accountId: "default", + sessionKey: SESSION_KEY, + mainSessionKey: SESSION_KEY, + lastRoutePolicy: "main", + matchedBy: "default", + }, + }; +} const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn(async () => ({ ok: true })), ); const resolveConfiguredBindingRouteMock = vi.hoisted(() => - vi.fn(({ route }) => ({ - bindingResolution: null, - route, - })), + vi.fn(() => createUnboundConfiguredRouteResult()), ); -type ConfiguredBindingRoute = ReturnType; +type ConfiguredBindingRoute = ConfiguredBindingRouteResult; type ConfiguredBindingResolution = NonNullable; function createConfiguredRouteResult( @@ -35,6 +47,11 @@ function createConfiguredRouteResult( return { bindingResolution: { record: { + bindingId: "binding-1", + targetSessionKey: SESSION_KEY, + targetKind: "session", + status: "active", + boundAt: Date.now(), conversation: { channel: "discord", accountId: "default", @@ -87,10 +104,7 @@ describe("discord native /think autocomplete", () => { ensureConfiguredBindingRouteReadyMock.mockReset(); ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true }); resolveConfiguredBindingRouteMock.mockReset(); - resolveConfiguredBindingRouteMock.mockImplementation(({ route }) => ({ - bindingResolution: null, - route, - })); + resolveConfiguredBindingRouteMock.mockReturnValue(createUnboundConfiguredRouteResult()); fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true }); fs.writeFileSync( STORE_PATH, diff --git a/extensions/imessage/src/normalize.test.ts b/extensions/imessage/src/normalize.test.ts new file mode 100644 index 00000000000..8b8b6125705 --- /dev/null +++ b/extensions/imessage/src/normalize.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./normalize.js"; + +describe("normalizeIMessageMessagingTarget", () => { + it("normalizes blank inputs to undefined", () => { + expect(normalizeIMessageMessagingTarget(" ")).toBeUndefined(); + }); + + it("preserves service prefixes for handles", () => { + expect(normalizeIMessageMessagingTarget("sms:+1 (555) 222-3333")).toBe("sms:+15552223333"); + }); + + it("drops service prefixes for chat targets", () => { + expect(normalizeIMessageMessagingTarget("sms:chat_id:123")).toBe("chat_id:123"); + expect(normalizeIMessageMessagingTarget("imessage:CHAT_GUID:abc")).toBe("chat_guid:abc"); + expect(normalizeIMessageMessagingTarget("auto:ChatIdentifier:foo")).toBe("chatidentifier:foo"); + }); +}); + +describe("looksLikeIMessageTargetId", () => { + it("detects common iMessage target forms", () => { + expect(looksLikeIMessageTargetId("sms:+15555550123")).toBe(true); + expect(looksLikeIMessageTargetId("chat_id:123")).toBe(true); + expect(looksLikeIMessageTargetId("user@example.com")).toBe(true); + expect(looksLikeIMessageTargetId("+15555550123")).toBe(true); + expect(looksLikeIMessageTargetId("")).toBe(false); + }); +}); diff --git a/extensions/imessage/src/test-plugin.test.ts b/extensions/imessage/src/test-plugin.test.ts new file mode 100644 index 00000000000..65bb0bd4334 --- /dev/null +++ b/extensions/imessage/src/test-plugin.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + listImportedBundledPluginFacadeIds, + resetFacadeRuntimeStateForTest, +} from "../../../src/plugin-sdk/facade-runtime.js"; +import { createIMessageTestPlugin } from "./test-plugin.js"; + +afterEach(() => { + resetFacadeRuntimeStateForTest(); +}); + +describe("createIMessageTestPlugin", () => { + it("does not load the bundled iMessage facade by default", () => { + expect(listImportedBundledPluginFacadeIds()).toEqual([]); + + createIMessageTestPlugin(); + + expect(listImportedBundledPluginFacadeIds()).toEqual([]); + }); +}); diff --git a/extensions/line/src/reply-payload-transform.test.ts b/extensions/line/src/reply-payload-transform.test.ts new file mode 100644 index 00000000000..1d8c18444ed --- /dev/null +++ b/extensions/line/src/reply-payload-transform.test.ts @@ -0,0 +1,387 @@ +import { describe, expect, it } from "vitest"; +import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js"; + +const getLineData = (result: ReturnType) => + (result.channelData?.line as Record | undefined) ?? {}; + +describe("hasLineDirectives", () => { + it("matches expected detection across directive patterns", () => { + const cases: Array<{ text: string; expected: boolean }> = [ + { text: "Here are options [[quick_replies: A, B, C]]", expected: true }, + { text: "[[location: Place | Address | 35.6 | 139.7]]", expected: true }, + { text: "[[confirm: Continue? | Yes | No]]", expected: true }, + { text: "[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]", expected: true }, + { text: "Just regular text", expected: false }, + { text: "[[not_a_directive: something]]", expected: false }, + { text: "[[media_player: Song | Artist | Speaker]]", expected: true }, + { text: "[[event: Meeting | Jan 24 | 2pm]]", expected: true }, + { text: "[[agenda: Today | Meeting:9am, Lunch:12pm]]", expected: true }, + { text: "[[device: TV | Room]]", expected: true }, + { text: "[[appletv_remote: Apple TV | Playing]]", expected: true }, + ]; + + for (const testCase of cases) { + expect(hasLineDirectives(testCase.text)).toBe(testCase.expected); + } + }); +}); + +describe("parseLineDirectives", () => { + describe("quick_replies", () => { + it("parses quick replies variants", () => { + const cases: Array<{ + text: string; + channelData?: { line: { quickReplies: string[] } }; + quickReplies: string[]; + outputText?: string; + }> = [ + { + text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]", + quickReplies: ["Option A", "Option B", "Option C"], + outputText: "Choose one:", + }, + { + text: "Before [[quick_replies: A, B]] After", + quickReplies: ["A", "B"], + outputText: "Before After", + }, + { + text: "Text [[quick_replies: C, D]]", + channelData: { line: { quickReplies: ["A", "B"] } }, + quickReplies: ["A", "B", "C", "D"], + outputText: "Text", + }, + ]; + + for (const testCase of cases) { + const result = parseLineDirectives({ + text: testCase.text, + channelData: testCase.channelData, + }); + expect(getLineData(result).quickReplies).toEqual(testCase.quickReplies); + if (testCase.outputText !== undefined) { + expect(result.text).toBe(testCase.outputText); + } + } + }); + }); + + describe("location", () => { + it("parses location variants", () => { + const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 }; + const cases: Array<{ + text: string; + channelData?: { line: { location: typeof existing } }; + location?: typeof existing; + outputText?: string; + }> = [ + { + text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]", + location: { + title: "Tokyo Station", + address: "Tokyo, Japan", + latitude: 35.6812, + longitude: 139.7671, + }, + outputText: "Here's the location:", + }, + { + text: "[[location: Place | Address | invalid | 139.7]]", + location: undefined, + }, + { + text: "[[location: New | New Addr | 35.6 | 139.7]]", + channelData: { line: { location: existing } }, + location: existing, + }, + ]; + + for (const testCase of cases) { + const result = parseLineDirectives({ + text: testCase.text, + channelData: testCase.channelData, + }); + expect(getLineData(result).location).toEqual(testCase.location); + if (testCase.outputText !== undefined) { + expect(result.text).toBe(testCase.outputText); + } + } + }); + }); + + describe("confirm", () => { + it("parses confirm directives with default and custom action payloads", () => { + const cases = [ + { + name: "default yes/no data", + text: "[[confirm: Delete this item? | Yes | No]]", + expectedTemplate: { + type: "confirm", + text: "Delete this item?", + confirmLabel: "Yes", + confirmData: "yes", + cancelLabel: "No", + cancelData: "no", + altText: "Delete this item?", + }, + expectedText: undefined, + }, + { + name: "custom action data", + text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]", + expectedTemplate: { + type: "confirm", + text: "Proceed?", + confirmLabel: "OK", + confirmData: "action=confirm", + cancelLabel: "Cancel", + cancelData: "action=cancel", + altText: "Proceed?", + }, + expectedText: undefined, + }, + ] as const; + + for (const testCase of cases) { + const result = parseLineDirectives({ text: testCase.text }); + expect(getLineData(result).templateMessage, testCase.name).toEqual( + testCase.expectedTemplate, + ); + expect(result.text, testCase.name).toBe(testCase.expectedText); + } + }); + }); + + describe("buttons", () => { + it("parses message/uri/postback button actions and enforces action caps", () => { + const cases = [ + { + name: "message actions", + text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]", + expectedTemplate: { + type: "buttons", + title: "Menu", + text: "Select an option", + actions: [ + { type: "message", label: "Help", data: "/help" }, + { type: "message", label: "Status", data: "/status" }, + ], + altText: "Menu: Select an option", + }, + }, + { + name: "uri action", + text: "[[buttons: Links | Visit us | Site:https://example.com]]", + expectedFirstAction: { + type: "uri", + label: "Site", + uri: "https://example.com", + }, + }, + { + name: "postback action", + text: "[[buttons: Actions | Choose | Select:action=select&id=1]]", + expectedFirstAction: { + type: "postback", + label: "Select", + data: "action=select&id=1", + }, + }, + { + name: "action cap", + text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]", + expectedActionCount: 4, + }, + ] as const; + + for (const testCase of cases) { + const result = parseLineDirectives({ text: testCase.text }); + const templateMessage = getLineData(result).templateMessage as { + type?: string; + actions?: Array>; + }; + expect(templateMessage?.type, testCase.name).toBe("buttons"); + if ("expectedTemplate" in testCase) { + expect(templateMessage, testCase.name).toEqual(testCase.expectedTemplate); + } + if ("expectedFirstAction" in testCase) { + expect(templateMessage?.actions?.[0], testCase.name).toEqual( + testCase.expectedFirstAction, + ); + } + if ("expectedActionCount" in testCase) { + expect(templateMessage?.actions?.length, testCase.name).toBe( + testCase.expectedActionCount, + ); + } + } + }); + }); + + describe("media_player", () => { + it("parses media_player directives across full/minimal/paused variants", () => { + const cases = [ + { + name: "all fields", + text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]", + expectedAltText: "🎵 Bohemian Rhapsody - Queen", + expectedText: "Now playing:", + expectFooter: true, + expectBodyContents: false, + }, + { + name: "minimal", + text: "[[media_player: Unknown Track]]", + expectedAltText: "🎵 Unknown Track", + expectedText: undefined, + expectFooter: false, + expectBodyContents: false, + }, + { + name: "paused status", + text: "[[media_player: Song | Artist | Player | | paused]]", + expectedAltText: undefined, + expectedText: undefined, + expectFooter: false, + expectBodyContents: true, + }, + ] as const; + + for (const testCase of cases) { + const result = parseLineDirectives({ text: testCase.text }); + const flexMessage = getLineData(result).flexMessage as { + altText?: string; + contents?: { footer?: { contents?: unknown[] }; body?: { contents?: unknown[] } }; + }; + expect(flexMessage, testCase.name).toBeDefined(); + if (testCase.expectedAltText !== undefined) { + expect(flexMessage?.altText, testCase.name).toBe(testCase.expectedAltText); + } + if (testCase.expectedText !== undefined) { + expect(result.text, testCase.name).toBe(testCase.expectedText); + } + if (testCase.expectFooter) { + expect(flexMessage?.contents?.footer?.contents?.length, testCase.name).toBeGreaterThan(0); + } + if ("expectBodyContents" in testCase && testCase.expectBodyContents) { + expect(flexMessage?.contents?.body?.contents, testCase.name).toBeDefined(); + } + } + }); + }); + + describe("event", () => { + it("parses event variants", () => { + const cases = [ + { + text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]", + altText: "📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM", + }, + { + text: "[[event: Birthday Party | March 15]]", + altText: "📅 Birthday Party - March 15", + }, + ]; + + for (const testCase of cases) { + const result = parseLineDirectives({ text: testCase.text }); + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe(testCase.altText); + } + }); + }); + + describe("agenda", () => { + it("parses agenda variants", () => { + const cases = [ + { + text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]", + altText: "📋 Today's Schedule (3 events)", + }, + { + text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]", + altText: "📋 Tasks (3 events)", + }, + ]; + + for (const testCase of cases) { + const result = parseLineDirectives({ text: testCase.text }); + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe(testCase.altText); + } + }); + }); + + describe("device", () => { + it("parses device variants", () => { + const cases = [ + { + text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]", + altText: "📱 TV: Playing", + }, + { + text: "[[device: Speaker]]", + altText: "📱 Speaker", + }, + ]; + + for (const testCase of cases) { + const result = parseLineDirectives({ text: testCase.text }); + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe(testCase.altText); + } + }); + }); + + describe("appletv_remote", () => { + it("parses appletv remote variants", () => { + const cases = [ + { + text: "[[appletv_remote: Apple TV | Playing]]", + contains: "Apple TV", + }, + { + text: "[[appletv_remote: Apple TV]]", + contains: undefined, + }, + ]; + + for (const testCase of cases) { + const result = parseLineDirectives({ text: testCase.text }); + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + if (testCase.contains) { + expect(flexMessage?.altText).toContain(testCase.contains); + } + } + }); + }); + + describe("combined directives", () => { + it("handles text with no directives", () => { + const result = parseLineDirectives({ + text: "Just plain text here", + }); + + expect(result.text).toBe("Just plain text here"); + expect(getLineData(result).quickReplies).toBeUndefined(); + expect(getLineData(result).location).toBeUndefined(); + expect(getLineData(result).templateMessage).toBeUndefined(); + }); + + it("preserves other payload fields", () => { + const result = parseLineDirectives({ + text: "Hello [[quick_replies: A, B]]", + mediaUrl: "https://example.com/image.jpg", + replyToId: "msg123", + }); + + expect(result.mediaUrl).toBe("https://example.com/image.jpg"); + expect(result.replyToId).toBe("msg123"); + expect(getLineData(result).quickReplies).toEqual(["A", "B"]); + }); + }); +}); diff --git a/extensions/matrix/src/account-selection.test.ts b/extensions/matrix/src/account-selection.test.ts index 0f1b997c31f..785e8139e65 100644 --- a/extensions/matrix/src/account-selection.test.ts +++ b/extensions/matrix/src/account-selection.test.ts @@ -1,142 +1,124 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { describe, expect, it } from "vitest"; import { + findMatrixAccountEntry, requiresExplicitMatrixDefaultAccount, resolveConfiguredMatrixAccountIds, resolveMatrixDefaultOrOnlyAccountId, } from "./account-selection.js"; -import type { CoreConfig } from "./types.js"; +import { getMatrixScopedEnvVarNames } from "./env-vars.js"; -describe("Matrix account selection topology", () => { - it("includes a top-level default account when its auth is actually complete", () => { - const cfg = { +describe("matrix account selection", () => { + it("resolves configured account ids from non-canonical account keys", () => { + const cfg: OpenClawConfig = { channels: { matrix: { - accessToken: "default-token", accounts: { - ops: { + "Team Ops": { homeserver: "https://matrix.example.org" }, + }, + }, + }, + }; + + expect(resolveConfiguredMatrixAccountIds(cfg)).toEqual(["team-ops"]); + expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops"); + }); + + it("matches the default account against normalized Matrix account keys", () => { + const cfg: OpenClawConfig = { + channels: { + matrix: { + defaultAccount: "Team Ops", + accounts: { + "Ops Bot": { homeserver: "https://matrix.example.org" }, + "Team Ops": { homeserver: "https://matrix.example.org" }, + }, + }, + }, + }; + + expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops"); + expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false); + }); + + it("requires an explicit default when multiple Matrix accounts exist without one", () => { + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: { homeserver: "https://matrix.example.org" }, + alerts: { homeserver: "https://matrix.example.org" }, + }, + }, + }, + }; + + expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(true); + }); + + it("finds the raw Matrix account entry by normalized account id", () => { + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + "Team Ops": { homeserver: "https://matrix.example.org", - accessToken: "ops-token", + userId: "@ops:example.org", }, }, }, }, - } as CoreConfig; + }; + + expect(findMatrixAccountEntry(cfg, "team-ops")).toEqual({ + homeserver: "https://matrix.example.org", + userId: "@ops:example.org", + }); + }); + + it("discovers env-backed named Matrix accounts during enumeration", () => { + const keys = getMatrixScopedEnvVarNames("team-ops"); + const cfg: OpenClawConfig = { + channels: { + matrix: {}, + }, + }; + const env = { + [keys.homeserver]: "https://matrix.example.org", + [keys.accessToken]: "secret", + } satisfies NodeJS.ProcessEnv; + + expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["team-ops"]); + expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("team-ops"); + expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false); + }); + + it("treats mixed default and named env-backed Matrix accounts as multi-account", () => { + const keys = getMatrixScopedEnvVarNames("team-ops"); + const cfg: OpenClawConfig = { + channels: { + matrix: {}, + }, + }; const env = { MATRIX_HOMESERVER: "https://matrix.example.org", - } as NodeJS.ProcessEnv; + MATRIX_ACCESS_TOKEN: "default-secret", + [keys.homeserver]: "https://matrix.example.org", + [keys.accessToken]: "team-secret", + } satisfies NodeJS.ProcessEnv; - expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "ops"]); - expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default"); + expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "team-ops"]); expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(true); }); - it("does not materialize a top-level default account from partial shared auth fields", () => { - const cfg = { - channels: { - matrix: { - accessToken: "shared-token", - accounts: { - ops: { - homeserver: "https://matrix.example.org", - accessToken: "ops-token", - }, - }, - }, - }, - } as CoreConfig; - - expect(resolveConfiguredMatrixAccountIds(cfg, {} as NodeJS.ProcessEnv)).toEqual(["ops"]); - expect(resolveMatrixDefaultOrOnlyAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops"); - expect(requiresExplicitMatrixDefaultAccount(cfg, {} as NodeJS.ProcessEnv)).toBe(false); - }); - - it("does not materialize a default env account from partial global auth fields", () => { - const cfg = { - channels: { - matrix: {}, - }, - } as CoreConfig; - const env = { - MATRIX_ACCESS_TOKEN: "shared-token", - MATRIX_OPS_HOMESERVER: "https://matrix.example.org", - MATRIX_OPS_ACCESS_TOKEN: "ops-token", - } as NodeJS.ProcessEnv; - - expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]); - expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops"); - expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false); - }); - - it("does not materialize a top-level default account from homeserver plus userId alone", () => { - const cfg = { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@default:example.org", - accounts: { - ops: { - homeserver: "https://matrix.example.org", - accessToken: "ops-token", - }, - }, - }, - }, - } as CoreConfig; - - expect(resolveConfiguredMatrixAccountIds(cfg, {} as NodeJS.ProcessEnv)).toEqual(["ops"]); - expect(resolveMatrixDefaultOrOnlyAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops"); - expect(requiresExplicitMatrixDefaultAccount(cfg, {} as NodeJS.ProcessEnv)).toBe(false); - }); - - it("does not materialize a default env account from global homeserver plus userId alone", () => { - const cfg = { - channels: { - matrix: {}, - }, - } as CoreConfig; + it("discovers default Matrix accounts backed only by global env vars", () => { + const cfg: OpenClawConfig = {}; const env = { MATRIX_HOMESERVER: "https://matrix.example.org", - MATRIX_USER_ID: "@default:example.org", - MATRIX_OPS_HOMESERVER: "https://matrix.example.org", - MATRIX_OPS_ACCESS_TOKEN: "ops-token", - } as NodeJS.ProcessEnv; + MATRIX_ACCESS_TOKEN: "default-secret", + } satisfies NodeJS.ProcessEnv; - expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]); - expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops"); - expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false); - }); - - it("counts env-backed named accounts when shared homeserver comes from channel config", () => { - const cfg = { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - }, - }, - } as CoreConfig; - const env = { - MATRIX_OPS_ACCESS_TOKEN: "ops-token", - } as NodeJS.ProcessEnv; - - expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]); - expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops"); - expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false); - }); - - it("keeps env-backed named accounts that rely on cached credentials", () => { - const cfg = { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - }, - }, - } as CoreConfig; - const env = { - MATRIX_OPS_USER_ID: "@ops:example.org", - } as NodeJS.ProcessEnv; - - expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]); - expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops"); - expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false); + expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default"]); + expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default"); }); }); diff --git a/extensions/matrix/src/legacy-crypto.test.ts b/extensions/matrix/src/legacy-crypto.test.ts new file mode 100644 index 00000000000..8370b2fc9af --- /dev/null +++ b/extensions/matrix/src/legacy-crypto.test.ts @@ -0,0 +1,194 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { afterEach, describe, expect, it } from "vitest"; +import { withTempHome } from "../../../test/helpers/temp-home.js"; +import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js"; +import { resolveMatrixAccountStorageRoot } from "./storage-paths.js"; +import { + MATRIX_DEFAULT_ACCESS_TOKEN, + MATRIX_DEFAULT_DEVICE_ID, + MATRIX_DEFAULT_USER_ID, + MATRIX_OPS_ACCESS_TOKEN, + MATRIX_OPS_ACCOUNT_ID, + MATRIX_OPS_DEVICE_ID, + MATRIX_OPS_USER_ID, + MATRIX_TEST_HOMESERVER, + writeFile, + writeMatrixCredentials, +} from "./test-helpers.js"; + +function createDefaultMatrixConfig(): OpenClawConfig { + return { + channels: { + matrix: { + homeserver: MATRIX_TEST_HOMESERVER, + userId: MATRIX_DEFAULT_USER_ID, + accessToken: MATRIX_DEFAULT_ACCESS_TOKEN, + }, + }, + }; +} + +function writeDefaultLegacyCryptoFixture(home: string) { + const stateDir = path.join(home, ".openclaw"); + const cfg = createDefaultMatrixConfig(); + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver: MATRIX_TEST_HOMESERVER, + userId: MATRIX_DEFAULT_USER_ID, + accessToken: MATRIX_DEFAULT_ACCESS_TOKEN, + }); + writeFile( + path.join(rootDir, "crypto", "bot-sdk.json"), + JSON.stringify({ deviceId: MATRIX_DEFAULT_DEVICE_ID }), + ); + return { cfg, rootDir }; +} + +function createOpsLegacyCryptoFixture(params: { + home: string; + accessToken?: string; + includeStoredCredentials?: boolean; +}) { + const stateDir = path.join(params.home, ".openclaw"); + writeFile( + path.join(stateDir, "matrix", "crypto", "bot-sdk.json"), + JSON.stringify({ deviceId: MATRIX_OPS_DEVICE_ID }), + ); + if (params.includeStoredCredentials) { + writeMatrixCredentials(stateDir, { + accountId: MATRIX_OPS_ACCOUNT_ID, + accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN, + deviceId: MATRIX_OPS_DEVICE_ID, + }); + } + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver: MATRIX_TEST_HOMESERVER, + userId: MATRIX_OPS_USER_ID, + accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN, + accountId: MATRIX_OPS_ACCOUNT_ID, + }); + return { rootDir }; +} + +describe("matrix legacy encrypted-state migration", () => { + afterEach(() => {}); + + it("extracts a saved backup key into the new recovery-key path", async () => { + await withTempHome(async (home) => { + const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home); + + const detection = detectLegacyMatrixCrypto({ cfg, env: process.env }); + expect(detection.warnings).toEqual([]); + expect(detection.plans).toHaveLength(1); + + const result = await autoPrepareLegacyMatrixCrypto({ + cfg, + env: process.env, + deps: { + inspectLegacyStore: async () => ({ + deviceId: MATRIX_DEFAULT_DEVICE_ID, + roomKeyCounts: { total: 12, backedUp: 12 }, + backupVersion: "1", + decryptionKeyBase64: "YWJjZA==", + }), + }, + }); + + expect(result.migrated).toBe(true); + expect(result.warnings).toEqual([]); + + const recovery = JSON.parse( + fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"), + ) as { + privateKeyBase64: string; + }; + expect(recovery.privateKeyBase64).toBe("YWJjZA=="); + }); + }); + + it("skips migration when no legacy Matrix plans exist", async () => { + await withTempHome(async () => { + const result = await autoPrepareLegacyMatrixCrypto({ + cfg: createDefaultMatrixConfig(), + env: process.env, + }); + + expect(result).toEqual({ + migrated: false, + changes: [], + warnings: [], + }); + }); + }); + + it("warns when legacy local-only room keys cannot be recovered automatically", async () => { + await withTempHome(async (home) => { + const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home); + + const result = await autoPrepareLegacyMatrixCrypto({ + cfg, + env: process.env, + deps: { + inspectLegacyStore: async () => ({ + deviceId: MATRIX_DEFAULT_DEVICE_ID, + roomKeyCounts: { total: 15, backedUp: 10 }, + backupVersion: null, + decryptionKeyBase64: null, + }), + }, + }); + + expect(result.migrated).toBe(true); + expect(result.warnings).toContain( + 'Legacy Matrix encrypted state for account "default" contains 5 room key(s) that were never backed up. Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.', + ); + expect(result.warnings).toContain( + 'Legacy Matrix encrypted state for account "default" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.', + ); + const state = JSON.parse( + fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"), + ) as { restoreStatus: string }; + expect(state.restoreStatus).toBe("manual-action-required"); + }); + }); + + it("prefers stored credentials for named accounts when config is token-only", async () => { + await withTempHome(async (home) => { + const { rootDir } = createOpsLegacyCryptoFixture({ + home, + includeStoredCredentials: true, + }); + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: { + homeserver: MATRIX_TEST_HOMESERVER, + accessToken: MATRIX_OPS_ACCESS_TOKEN, + }, + }, + }, + }, + }; + + const result = await autoPrepareLegacyMatrixCrypto({ + cfg, + env: process.env, + deps: { + inspectLegacyStore: async () => ({ + deviceId: MATRIX_OPS_DEVICE_ID, + roomKeyCounts: { total: 1, backedUp: 1 }, + backupVersion: "1", + decryptionKeyBase64: "b3Bz", + }), + }, + }); + + expect(result.migrated).toBe(true); + expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(true); + }); + }); +}); diff --git a/extensions/matrix/src/legacy-state.test.ts b/extensions/matrix/src/legacy-state.test.ts new file mode 100644 index 00000000000..32eefa577ec --- /dev/null +++ b/extensions/matrix/src/legacy-state.test.ts @@ -0,0 +1,86 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { describe, expect, it } from "vitest"; +import { withTempHome } from "../../../test/helpers/temp-home.js"; +import { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js"; + +function writeFile(filePath: string, value: string) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, value, "utf-8"); +} + +describe("matrix legacy state migration", () => { + it("migrates the flat legacy Matrix store into account-scoped storage", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); + writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto"); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }; + + const detection = detectLegacyMatrixState({ cfg, env: process.env }); + expect(detection && "warning" in detection).toBe(false); + if (!detection || "warning" in detection) { + throw new Error("expected a migratable Matrix legacy state plan"); + } + + const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env }); + expect(result.migrated).toBe(true); + expect(result.warnings).toEqual([]); + expect(fs.existsSync(path.join(stateDir, "matrix", "bot-storage.json"))).toBe(false); + expect(fs.existsSync(path.join(stateDir, "matrix", "crypto"))).toBe(false); + expect(fs.existsSync(detection.targetStoragePath)).toBe(true); + expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true); + }); + }); + + it("uses cached Matrix credentials when the config no longer stores an access token", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); + writeFile( + path.join(stateDir, "credentials", "matrix", "credentials.json"), + JSON.stringify( + { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-from-cache", + }, + null, + 2, + ), + ); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + password: "secret", + }, + }, + }; + + const detection = detectLegacyMatrixState({ cfg, env: process.env }); + expect(detection && "warning" in detection).toBe(false); + if (!detection || "warning" in detection) { + throw new Error("expected cached credentials to make Matrix migration resolvable"); + } + + expect(detection.targetRootDir).toContain("matrix.example.org__bot_example.org"); + + const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env }); + expect(result.migrated).toBe(true); + expect(fs.existsSync(detection.targetStoragePath)).toBe(true); + }); + }); +}); diff --git a/extensions/matrix/src/migration-config.test.ts b/extensions/matrix/src/migration-config.test.ts new file mode 100644 index 00000000000..0f6e7d3b532 --- /dev/null +++ b/extensions/matrix/src/migration-config.test.ts @@ -0,0 +1,228 @@ +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { describe, expect, it } from "vitest"; +import { withTempHome } from "../../../test/helpers/temp-home.js"; +import { resolveMatrixMigrationAccountTarget } from "./migration-config.js"; +import { + MATRIX_OPS_ACCESS_TOKEN, + MATRIX_OPS_ACCOUNT_ID, + MATRIX_OPS_USER_ID, + MATRIX_TEST_HOMESERVER, + writeMatrixCredentials, +} from "./test-helpers.js"; + +function resolveOpsTarget(cfg: OpenClawConfig, env = process.env) { + return resolveMatrixMigrationAccountTarget({ + cfg, + env, + accountId: MATRIX_OPS_ACCOUNT_ID, + }); +} + +describe("resolveMatrixMigrationAccountTarget", () => { + it("reuses stored user identity for token-only configs when the access token matches", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeMatrixCredentials(stateDir, { + accountId: MATRIX_OPS_ACCOUNT_ID, + deviceId: "DEVICE-OPS", + accessToken: MATRIX_OPS_ACCESS_TOKEN, + }); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: { + homeserver: MATRIX_TEST_HOMESERVER, + accessToken: MATRIX_OPS_ACCESS_TOKEN, + }, + }, + }, + }, + }; + + const target = resolveOpsTarget(cfg); + + expect(target).not.toBeNull(); + expect(target?.userId).toBe(MATRIX_OPS_USER_ID); + expect(target?.storedDeviceId).toBe("DEVICE-OPS"); + }); + }); + + it("ignores stored device IDs from stale cached Matrix credentials", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeMatrixCredentials(stateDir, { + accountId: MATRIX_OPS_ACCOUNT_ID, + userId: "@old-bot:example.org", + accessToken: "tok-old", + deviceId: "DEVICE-OLD", + }); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: { + homeserver: MATRIX_TEST_HOMESERVER, + userId: "@new-bot:example.org", + accessToken: "tok-new", + }, + }, + }, + }, + }; + + const target = resolveOpsTarget(cfg); + + expect(target).not.toBeNull(); + expect(target?.userId).toBe("@new-bot:example.org"); + expect(target?.accessToken).toBe("tok-new"); + expect(target?.storedDeviceId).toBeNull(); + }); + }); + + it("does not trust stale stored creds on the same homeserver when the token changes", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeMatrixCredentials(stateDir, { + accountId: MATRIX_OPS_ACCOUNT_ID, + userId: "@old-bot:example.org", + accessToken: "tok-old", + deviceId: "DEVICE-OLD", + }); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: { + homeserver: MATRIX_TEST_HOMESERVER, + accessToken: "tok-new", + }, + }, + }, + }, + }; + + const target = resolveOpsTarget(cfg); + + expect(target).toBeNull(); + }); + }); + + it("does not inherit the base userId for non-default token-only accounts", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeMatrixCredentials(stateDir, { + accountId: MATRIX_OPS_ACCOUNT_ID, + deviceId: "DEVICE-OPS", + accessToken: MATRIX_OPS_ACCESS_TOKEN, + }); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + homeserver: MATRIX_TEST_HOMESERVER, + userId: "@base-bot:example.org", + accounts: { + ops: { + homeserver: MATRIX_TEST_HOMESERVER, + accessToken: MATRIX_OPS_ACCESS_TOKEN, + }, + }, + }, + }, + }; + + const target = resolveOpsTarget(cfg); + + expect(target).not.toBeNull(); + expect(target?.userId).toBe(MATRIX_OPS_USER_ID); + expect(target?.storedDeviceId).toBe("DEVICE-OPS"); + }); + }); + + it("does not inherit the base access token for non-default accounts", async () => { + await withTempHome(async () => { + const cfg: OpenClawConfig = { + channels: { + matrix: { + homeserver: MATRIX_TEST_HOMESERVER, + userId: "@base-bot:example.org", + accessToken: "tok-base", + accounts: { + ops: { + homeserver: MATRIX_TEST_HOMESERVER, + userId: MATRIX_OPS_USER_ID, + }, + }, + }, + }, + }; + + const target = resolveOpsTarget(cfg); + + expect(target).toBeNull(); + }); + }); + + it("does not inherit the global Matrix access token for non-default accounts", async () => { + await withTempHome( + async () => { + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: { + homeserver: MATRIX_TEST_HOMESERVER, + userId: MATRIX_OPS_USER_ID, + }, + }, + }, + }, + }; + + const target = resolveOpsTarget(cfg); + + expect(target).toBeNull(); + }, + { + env: { + MATRIX_ACCESS_TOKEN: "tok-global", + }, + }, + ); + }); + + it("uses the same scoped env token encoding as runtime account auth", async () => { + await withTempHome(async () => { + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + "ops-prod": {}, + }, + }, + }, + }; + const env = { + MATRIX_OPS_X2D_PROD_HOMESERVER: "https://matrix.example.org", + MATRIX_OPS_X2D_PROD_USER_ID: "@ops-prod:example.org", + MATRIX_OPS_X2D_PROD_ACCESS_TOKEN: "tok-ops-prod", + } as NodeJS.ProcessEnv; + + const target = resolveMatrixMigrationAccountTarget({ + cfg, + env, + accountId: "ops-prod", + }); + + expect(target).not.toBeNull(); + expect(target?.homeserver).toBe("https://matrix.example.org"); + expect(target?.userId).toBe("@ops-prod:example.org"); + expect(target?.accessToken).toBe("tok-ops-prod"); + }); + }); +}); diff --git a/extensions/matrix/src/migration-snapshot.test.ts b/extensions/matrix/src/migration-snapshot.test.ts new file mode 100644 index 00000000000..18dbb1b08d0 --- /dev/null +++ b/extensions/matrix/src/migration-snapshot.test.ts @@ -0,0 +1,98 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withTempHome } from "../../../test/helpers/temp-home.js"; +import { detectLegacyMatrixCrypto } from "./legacy-crypto.js"; +import { + hasActionableMatrixMigration, + maybeCreateMatrixMigrationSnapshot, + resolveMatrixMigrationSnapshotMarkerPath, + resolveMatrixMigrationSnapshotOutputDir, +} from "./migration-snapshot.js"; +import { resolveMatrixAccountStorageRoot } from "./storage-paths.js"; + +describe("matrix migration snapshots", () => { + it("creates a backup marker after writing a pre-migration snapshot", async () => { + await withTempHome(async (home) => { + fs.writeFileSync(path.join(home, ".openclaw", "openclaw.json"), "{}\n", "utf8"); + fs.writeFileSync(path.join(home, ".openclaw", "state.txt"), "state\n", "utf8"); + + const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" }); + + expect(result.created).toBe(true); + expect(result.markerPath).toBe(resolveMatrixMigrationSnapshotMarkerPath(process.env)); + expect( + result.archivePath.startsWith(resolveMatrixMigrationSnapshotOutputDir(process.env)), + ).toBe(true); + expect(fs.existsSync(result.archivePath)).toBe(true); + }); + }); + + it("treats resolvable Matrix legacy state as actionable", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + fs.mkdirSync(path.join(stateDir, "matrix"), { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "matrix", "bot-storage.json"), + '{"legacy":true}', + "utf8", + ); + + expect( + hasActionableMatrixMigration({ + cfg: { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + } as never, + env: process.env, + }), + ).toBe(true); + }); + }); + + it("treats legacy Matrix crypto as actionable when the extension inspector is present", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }); + fs.mkdirSync(path.join(rootDir, "crypto"), { recursive: true }); + fs.writeFileSync( + path.join(rootDir, "crypto", "bot-sdk.json"), + JSON.stringify({ deviceId: "DEVICE123" }), + "utf8", + ); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + } as never; + + const detection = detectLegacyMatrixCrypto({ + cfg, + env: process.env, + }); + expect(detection.plans).toHaveLength(1); + expect(detection.warnings).toEqual([]); + expect( + hasActionableMatrixMigration({ + cfg, + env: process.env, + }), + ).toBe(true); + }); + }); +}); diff --git a/extensions/matrix/src/test-support/monitor-route-test-support.ts b/extensions/matrix/src/test-support/monitor-route-test-support.ts index d90e4217ef7..620fd12da25 100644 --- a/extensions/matrix/src/test-support/monitor-route-test-support.ts +++ b/extensions/matrix/src/test-support/monitor-route-test-support.ts @@ -1,8 +1,8 @@ export { - __testing, - createTestRegistry, registerSessionBindingAdapter, - resolveAgentRoute, - setActivePluginRegistry, - type OpenClawConfig, -} from "../../../../test/helpers/plugins/matrix-monitor-route.js"; + __testing, +} from "../../../../src/infra/outbound/session-binding-service.js"; +export { setActivePluginRegistry } from "../../../../src/plugins/runtime.js"; +export { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; +export { createTestRegistry } from "../../../../src/test-utils/channel-plugins.js"; +export type { OpenClawConfig } from "../../../../src/config/config.js"; diff --git a/extensions/signal/src/install-signal-cli.test.ts b/extensions/signal/src/install-signal-cli.test.ts new file mode 100644 index 00000000000..ce1808e83a8 --- /dev/null +++ b/extensions/signal/src/install-signal-cli.test.ts @@ -0,0 +1,193 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import JSZip from "jszip"; +import * as tar from "tar"; +import { describe, expect, it } from "vitest"; +import type { ReleaseAsset } from "./install-signal-cli.js"; +import { extractSignalCliArchive, looksLikeArchive, pickAsset } from "./install-signal-cli.js"; + +const SAMPLE_ASSETS: ReleaseAsset[] = [ + { + name: "signal-cli-0.13.14-Linux-native.tar.gz", + browser_download_url: "https://example.com/linux-native.tar.gz", + }, + { + name: "signal-cli-0.13.14-Linux-native.tar.gz.asc", + browser_download_url: "https://example.com/linux-native.tar.gz.asc", + }, + { + name: "signal-cli-0.13.14-macOS-native.tar.gz", + browser_download_url: "https://example.com/macos-native.tar.gz", + }, + { + name: "signal-cli-0.13.14-macOS-native.tar.gz.asc", + browser_download_url: "https://example.com/macos-native.tar.gz.asc", + }, + { + name: "signal-cli-0.13.14-Windows-native.zip", + browser_download_url: "https://example.com/windows-native.zip", + }, + { + name: "signal-cli-0.13.14-Windows-native.zip.asc", + browser_download_url: "https://example.com/windows-native.zip.asc", + }, + { name: "signal-cli-0.13.14.tar.gz", browser_download_url: "https://example.com/jvm.tar.gz" }, + { + name: "signal-cli-0.13.14.tar.gz.asc", + browser_download_url: "https://example.com/jvm.tar.gz.asc", + }, +]; + +describe("looksLikeArchive", () => { + it("recognises .tar.gz", () => { + expect(looksLikeArchive("foo.tar.gz")).toBe(true); + }); + + it("recognises .tgz", () => { + expect(looksLikeArchive("foo.tgz")).toBe(true); + }); + + it("recognises .zip", () => { + expect(looksLikeArchive("foo.zip")).toBe(true); + }); + + it("rejects signature files", () => { + expect(looksLikeArchive("foo.tar.gz.asc")).toBe(false); + }); + + it("rejects unrelated files", () => { + expect(looksLikeArchive("README.md")).toBe(false); + }); +}); + +describe("pickAsset", () => { + describe("linux", () => { + it("selects the Linux-native asset on x64", () => { + const result = pickAsset(SAMPLE_ASSETS, "linux", "x64"); + expect(result).toBeDefined(); + expect(result!.name).toContain("Linux-native"); + expect(result!.name).toMatch(/\.tar\.gz$/); + }); + + it("returns undefined on arm64 (triggers brew fallback)", () => { + const result = pickAsset(SAMPLE_ASSETS, "linux", "arm64"); + expect(result).toBeUndefined(); + }); + + it("returns undefined on arm (32-bit)", () => { + const result = pickAsset(SAMPLE_ASSETS, "linux", "arm"); + expect(result).toBeUndefined(); + }); + }); + + describe("darwin", () => { + it("selects the macOS-native asset", () => { + const result = pickAsset(SAMPLE_ASSETS, "darwin", "arm64"); + expect(result).toBeDefined(); + expect(result!.name).toContain("macOS-native"); + }); + + it("selects the macOS-native asset on x64", () => { + const result = pickAsset(SAMPLE_ASSETS, "darwin", "x64"); + expect(result).toBeDefined(); + expect(result!.name).toContain("macOS-native"); + }); + }); + + describe("win32", () => { + it("selects the Windows-native asset", () => { + const result = pickAsset(SAMPLE_ASSETS, "win32", "x64"); + expect(result).toBeDefined(); + expect(result!.name).toContain("Windows-native"); + expect(result!.name).toMatch(/\.zip$/); + }); + }); + + describe("edge cases", () => { + it("returns undefined for an empty asset list", () => { + expect(pickAsset([], "linux", "x64")).toBeUndefined(); + }); + + it("skips assets with missing name or url", () => { + const partial: ReleaseAsset[] = [ + { name: "signal-cli.tar.gz" }, + { browser_download_url: "https://example.com/file.tar.gz" }, + ]; + expect(pickAsset(partial, "linux", "x64")).toBeUndefined(); + }); + + it("falls back to first archive for unknown platform", () => { + const result = pickAsset(SAMPLE_ASSETS, "freebsd" as NodeJS.Platform, "x64"); + expect(result).toBeDefined(); + expect(result!.name).toMatch(/\.tar\.gz$/); + }); + + it("never selects .asc signature files", () => { + const result = pickAsset(SAMPLE_ASSETS, "linux", "x64"); + expect(result).toBeDefined(); + expect(result!.name).not.toMatch(/\.asc$/); + }); + }); +}); + +describe("extractSignalCliArchive", () => { + async function withArchiveWorkspace(run: (workDir: string) => Promise) { + const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-install-")); + try { + await run(workDir); + } finally { + await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined); + } + } + + it("rejects zip slip path traversal", async () => { + await withArchiveWorkspace(async (workDir) => { + const archivePath = path.join(workDir, "bad.zip"); + const extractDir = path.join(workDir, "extract"); + await fs.mkdir(extractDir, { recursive: true }); + + const zip = new JSZip(); + zip.file("../pwned.txt", "pwnd"); + await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" })); + + await expect(extractSignalCliArchive(archivePath, extractDir, 5_000)).rejects.toThrow( + /(escapes destination|absolute)/i, + ); + }); + }); + + it("extracts zip archives", async () => { + await withArchiveWorkspace(async (workDir) => { + const archivePath = path.join(workDir, "ok.zip"); + const extractDir = path.join(workDir, "extract"); + await fs.mkdir(extractDir, { recursive: true }); + + const zip = new JSZip(); + zip.file("root/signal-cli", "bin"); + await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" })); + + await extractSignalCliArchive(archivePath, extractDir, 5_000); + + const extracted = await fs.readFile(path.join(extractDir, "root", "signal-cli"), "utf-8"); + expect(extracted).toBe("bin"); + }); + }); + + it("extracts tar.gz archives", async () => { + await withArchiveWorkspace(async (workDir) => { + const archivePath = path.join(workDir, "ok.tgz"); + const extractDir = path.join(workDir, "extract"); + const rootDir = path.join(workDir, "root"); + await fs.mkdir(rootDir, { recursive: true }); + await fs.writeFile(path.join(rootDir, "signal-cli"), "bin", "utf-8"); + await tar.c({ cwd: workDir, file: archivePath, gzip: true }, ["root"]); + + await fs.mkdir(extractDir, { recursive: true }); + await extractSignalCliArchive(archivePath, extractDir, 5_000); + + const extracted = await fs.readFile(path.join(extractDir, "root", "signal-cli"), "utf-8"); + expect(extracted).toBe("bin"); + }); + }); +}); diff --git a/extensions/signal/src/normalize.test.ts b/extensions/signal/src/normalize.test.ts new file mode 100644 index 00000000000..259df234f85 --- /dev/null +++ b/extensions/signal/src/normalize.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize.js"; + +describe("normalizeSignalMessagingTarget", () => { + it("normalizes uuid targets by stripping uuid:", () => { + expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + }); + + it("normalizes signal:uuid targets", () => { + expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + }); + + it("preserves case for group targets", () => { + expect( + normalizeSignalMessagingTarget("signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="), + ).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="); + }); + + it("preserves case for base64-like group IDs without signal prefix", () => { + expect( + normalizeSignalMessagingTarget("group:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789+/ABCD="), + ).toBe("group:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789+/ABCD="); + }); +}); + +describe("looksLikeSignalTargetId", () => { + it("accepts uuid prefixes for target detection", () => { + expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); + expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); + }); + + it("accepts signal-prefixed E.164 targets for detection", () => { + expect(looksLikeSignalTargetId("signal:+15551234567")).toBe(true); + expect(looksLikeSignalTargetId("signal:15551234567")).toBe(true); + }); + + it("accepts compact UUIDs for target detection", () => { + expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true); + expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true); + }); + + it("rejects invalid uuid prefixes", () => { + expect(looksLikeSignalTargetId("uuid:")).toBe(false); + expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false); + }); +}); diff --git a/extensions/slack/src/outbound-payload.test.ts b/extensions/slack/src/outbound-payload.test.ts index 679e4d51ace..466a1928a38 100644 --- a/extensions/slack/src/outbound-payload.test.ts +++ b/extensions/slack/src/outbound-payload.test.ts @@ -1,6 +1,6 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { describe, expect, it } from "vitest"; -import { createSlackOutboundPayloadHarness } from "../../../src/channels/plugins/contracts/slack-outbound-harness.js"; +import { createSlackOutboundPayloadHarness } from "../contract-api.js"; function createHarness(params: { payload: ReplyPayload; diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index f4147209478..e10e2d13b62 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -1,9 +1,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { TELEGRAM_COMMAND_NAME_PATTERN } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { STATE_DIR } from "openclaw/plugin-sdk/state-paths"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js"; import { pluginCommandMocks, resetPluginCommandMocks } from "./test-support/plugin-command.js"; let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands; diff --git a/extensions/whatsapp/src/outbound-base.test.ts b/extensions/whatsapp/src/outbound-base.test.ts index f04106c16db..20ca187ff0e 100644 --- a/extensions/whatsapp/src/outbound-base.test.ts +++ b/extensions/whatsapp/src/outbound-base.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { - createWhatsAppPollFixture, - expectWhatsAppPollSent, -} from "../../../src/test-helpers/whatsapp-outbound.js"; +import { createWhatsAppPollFixture, expectWhatsAppPollSent } from "../contract-api.js"; import { createWhatsAppOutboundBase } from "./outbound-base.js"; describe("createWhatsAppOutboundBase", () => { diff --git a/extensions/whatsapp/src/resolve-target.test.ts b/extensions/whatsapp/src/resolve-target.test.ts index 8c57bbc197f..afdf65fd4b6 100644 --- a/extensions/whatsapp/src/resolve-target.test.ts +++ b/extensions/whatsapp/src/resolve-target.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { isWhatsAppGroupJid, + looksLikeWhatsAppTargetId, isWhatsAppUserTarget, + normalizeWhatsAppMessagingTarget, normalizeWhatsAppTarget, } from "./normalize-target.js"; @@ -71,3 +73,18 @@ describe("isWhatsAppGroupJid", () => { expect(isWhatsAppGroupJid("+1555123")).toBe(false); }); }); + +describe("normalizeWhatsAppMessagingTarget", () => { + it("normalizes blank inputs to undefined", () => { + expect(normalizeWhatsAppMessagingTarget(" ")).toBeUndefined(); + }); +}); + +describe("looksLikeWhatsAppTargetId", () => { + it("detects common WhatsApp target forms", () => { + expect(looksLikeWhatsAppTargetId("whatsapp:+15555550123")).toBe(true); + expect(looksLikeWhatsAppTargetId("15555550123@c.us")).toBe(true); + expect(looksLikeWhatsAppTargetId("+15555550123")).toBe(true); + expect(looksLikeWhatsAppTargetId("")).toBe(false); + }); +}); diff --git a/extensions/whatsapp/src/text-runtime.test.ts b/extensions/whatsapp/src/text-runtime.test.ts new file mode 100644 index 00000000000..3c7e9102537 --- /dev/null +++ b/extensions/whatsapp/src/text-runtime.test.ts @@ -0,0 +1,152 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + assertWebChannel, + jidToE164, + markdownToWhatsApp, + resolveJidToE164, + toWhatsappJid, +} from "./text-runtime.js"; + +const CONFIG_DIR = path.join(process.env.HOME ?? os.tmpdir(), ".openclaw"); + +async function withTempDir( + prefix: string, + run: (dir: string) => T | Promise, +): Promise> { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + try { + return await run(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +describe("markdownToWhatsApp", () => { + it.each([ + ["converts **bold** to *bold*", "**SOD Blast:**", "*SOD Blast:*"], + ["converts __bold__ to *bold*", "__important__", "*important*"], + ["converts ~~strikethrough~~ to ~strikethrough~", "~~deleted~~", "~deleted~"], + ["leaves single *italic* unchanged (already WhatsApp bold)", "*text*", "*text*"], + ["leaves _italic_ unchanged (already WhatsApp italic)", "_text_", "_text_"], + ["preserves inline code", "Use `**not bold**` here", "Use `**not bold**` here"], + [ + "handles mixed formatting", + "**bold** and ~~strike~~ and _italic_", + "*bold* and ~strike~ and _italic_", + ], + ["handles multiple bold segments", "**one** then **two**", "*one* then *two*"], + ["returns empty string for empty input", "", ""], + ["returns plain text unchanged", "no formatting here", "no formatting here"], + ["handles bold inside a sentence", "This is **very** important", "This is *very* important"], + ] as const)("handles markdown-to-whatsapp conversion: %s", (_name, input, expected) => { + expect(markdownToWhatsApp(input)).toBe(expected); + }); + + it("preserves fenced code blocks", () => { + const input = "```\nconst x = **bold**;\n```"; + expect(markdownToWhatsApp(input)).toBe(input); + }); + + it("preserves code block with formatting inside", () => { + const input = "Before ```**bold** and ~~strike~~``` after **real bold**"; + expect(markdownToWhatsApp(input)).toBe( + "Before ```**bold** and ~~strike~~``` after *real bold*", + ); + }); +}); + +describe("assertWebChannel", () => { + it("accepts valid channel", () => { + expect(() => assertWebChannel("web")).not.toThrow(); + }); + + it("throws for invalid channel", () => { + expect(() => assertWebChannel("bad" as string)).toThrow(); + }); +}); + +describe("toWhatsappJid", () => { + it("strips formatting and prefixes", () => { + expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe("5551234567@s.whatsapp.net"); + }); + + it("preserves existing JIDs", () => { + expect(toWhatsappJid("123456789-987654321@g.us")).toBe("123456789-987654321@g.us"); + expect(toWhatsappJid("whatsapp:123456789-987654321@g.us")).toBe("123456789-987654321@g.us"); + expect(toWhatsappJid("1555123@s.whatsapp.net")).toBe("1555123@s.whatsapp.net"); + }); +}); + +describe("jidToE164", () => { + it("maps @lid using reverse mapping file", () => { + const mappingPath = path.join(CONFIG_DIR, "credentials", "lid-mapping-123_reverse.json"); + const original = fs.readFileSync; + const spy = vi.spyOn(fs, "readFileSync").mockImplementation((...args) => { + if (args[0] === mappingPath) { + return `"5551234"`; + } + return original(...args); + }); + expect(jidToE164("123@lid")).toBe("+5551234"); + spy.mockRestore(); + }); + + it("maps @lid from authDir mapping files", async () => { + await withTempDir("openclaw-auth-", (authDir) => { + const mappingPath = path.join(authDir, "lid-mapping-456_reverse.json"); + fs.writeFileSync(mappingPath, JSON.stringify("5559876")); + expect(jidToE164("456@lid", { authDir })).toBe("+5559876"); + }); + }); + + it("maps @hosted.lid from authDir mapping files", async () => { + await withTempDir("openclaw-auth-", (authDir) => { + const mappingPath = path.join(authDir, "lid-mapping-789_reverse.json"); + fs.writeFileSync(mappingPath, JSON.stringify(4440001)); + expect(jidToE164("789@hosted.lid", { authDir })).toBe("+4440001"); + }); + }); + + it("accepts hosted PN JIDs", () => { + expect(jidToE164("1555000:2@hosted")).toBe("+1555000"); + }); + + it("falls back through lidMappingDirs in order", async () => { + await withTempDir("openclaw-lid-a-", async (first) => { + await withTempDir("openclaw-lid-b-", (second) => { + const mappingPath = path.join(second, "lid-mapping-321_reverse.json"); + fs.writeFileSync(mappingPath, JSON.stringify("123321")); + expect(jidToE164("321@lid", { lidMappingDirs: [first, second] })).toBe("+123321"); + }); + }); + }); +}); + +describe("resolveJidToE164", () => { + it("resolves @lid via lidLookup when mapping file is missing", async () => { + const lidLookup = { + getPNForLID: vi.fn().mockResolvedValue("777:0@s.whatsapp.net"), + }; + await expect(resolveJidToE164("777@lid", { lidLookup })).resolves.toBe("+777"); + expect(lidLookup.getPNForLID).toHaveBeenCalledWith("777@lid"); + }); + + it("skips lidLookup for non-lid JIDs", async () => { + const lidLookup = { + getPNForLID: vi.fn().mockResolvedValue("888:0@s.whatsapp.net"), + }; + await expect(resolveJidToE164("888@s.whatsapp.net", { lidLookup })).resolves.toBe("+888"); + expect(lidLookup.getPNForLID).not.toHaveBeenCalled(); + }); + + it("returns null when lidLookup throws", async () => { + const lidLookup = { + getPNForLID: vi.fn().mockRejectedValue(new Error("lookup failed")), + }; + await expect(resolveJidToE164("777@lid", { lidLookup })).resolves.toBeNull(); + expect(lidLookup.getPNForLID).toHaveBeenCalledWith("777@lid"); + }); +}); diff --git a/extensions/zalo/test-support/lifecycle-test-support.ts b/extensions/zalo/test-support/lifecycle-test-support.ts index baf75646547..0148a6fe80d 100644 --- a/extensions/zalo/test-support/lifecycle-test-support.ts +++ b/extensions/zalo/test-support/lifecycle-test-support.ts @@ -1,6 +1,6 @@ import { request as httpRequest } from "node:http"; -import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/zalo"; import { expect, vi } from "vitest"; +import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js"; import type { ResolvedZaloAccount } from "../src/types.js"; export function createLifecycleConfig(params: { diff --git a/extensions/zalo/test-support/monitor-mocks-test-support.ts b/extensions/zalo/test-support/monitor-mocks-test-support.ts index 57556f280fd..1f014fc8aef 100644 --- a/extensions/zalo/test-support/monitor-mocks-test-support.ts +++ b/extensions/zalo/test-support/monitor-mocks-test-support.ts @@ -1,4 +1,3 @@ -import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/zalo"; import { vi, type Mock } from "vitest"; import { createEmptyPluginRegistry, @@ -6,6 +5,7 @@ import { } from "../../../test/helpers/plugins/plugin-registry.js"; import { createPluginRuntimeMock } from "../../../test/helpers/plugins/plugin-runtime-mock.js"; import { createRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js"; +import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js"; import type { ResolvedZaloAccount } from "../src/types.js"; type MonitorModule = typeof import("../src/monitor.js"); diff --git a/src/acp/persistent-bindings.test.ts b/src/acp/persistent-bindings.test.ts index 0d3691364de..b46681cdc14 100644 --- a/src/acp/persistent-bindings.test.ts +++ b/src/acp/persistent-bindings.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { parseTelegramTopicConversation } from "../../extensions/telegram/api.js"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import type { ChannelConfiguredBindingProvider, ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; -import { parseTelegramTopicConversation } from "./conversation-id.js"; import { buildConfiguredAcpSessionKey } from "./persistent-bindings.types.js"; const managerMocks = vi.hoisted(() => ({ resolveSession: vi.fn(), diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 8717d50d551..37a56147999 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -8,7 +8,7 @@ import { resolveAnthropicBetas, resolveAnthropicFastMode, resolveAnthropicServiceTier, -} from "../../extensions/anthropic/api.js"; +} from "../../test/helpers/providers/anthropic-contract.js"; import { createConfiguredOllamaCompatNumCtxWrapper } from "../plugin-sdk/ollama.js"; import { __testing as extraParamsTesting } from "./pi-embedded-runner/extra-params.js"; import { diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 9dcf6d4d2ea..54792939aab 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -2,8 +2,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { buildTelegramModelsProviderChannelData } from "../../../extensions/telegram/api.js"; -import { whatsappCommandPolicy } from "../../../extensions/whatsapp/api.js"; +import { + buildTelegramModelsProviderChannelData, + whatsappCommandPolicy, +} from "../../../test/helpers/channels/command-contract.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { updateSessionStore, type SessionEntry } from "../../config/sessions.js"; diff --git a/src/auto-reply/reply/line-directives.ts b/src/auto-reply/reply/line-directives.ts deleted file mode 100644 index 6dc13459286..00000000000 --- a/src/auto-reply/reply/line-directives.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { - createAgendaCard, - createAppleTvRemoteCard, - createDeviceControlCard, - createMediaPlayerCard, - createEventCard, -} from "../../plugin-sdk/line.js"; -import type { LineChannelData } from "../../plugin-sdk/line.js"; -import type { ReplyPayload } from "../types.js"; - -/** - * Parse LINE-specific directives from text and extract them into ReplyPayload fields. - * - * Supported directives: - * - [[quick_replies: option1, option2, option3]] - * - [[location: title | address | latitude | longitude]] - * - [[confirm: question | yes_label | no_label]] - * - [[buttons: title | text | btn1:data1, btn2:data2]] - * - [[media_player: title | artist | source | imageUrl | playing/paused]] - * - [[event: title | date | time | location | description]] - * - [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]] - * - [[device: name | type | status | ctrl1:data1, ctrl2:data2]] - * - [[appletv_remote: name | status]] - * - * Returns the modified payload with directives removed from text and fields populated. - */ -export function parseLineDirectives(payload: ReplyPayload): ReplyPayload { - let text = payload.text; - if (!text) { - return payload; - } - - const result: ReplyPayload = { ...payload }; - const lineData: LineChannelData = { - ...(result.channelData?.line as LineChannelData | undefined), - }; - const toSlug = (value: string): string => - value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, "") || "device"; - const lineActionData = (action: string, extras?: Record): string => { - const base = [`line.action=${encodeURIComponent(action)}`]; - if (extras) { - for (const [key, value] of Object.entries(extras)) { - base.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); - } - } - return base.join("&"); - }; - - // Parse [[quick_replies: option1, option2, option3]] - const quickRepliesMatch = text.match(/\[\[quick_replies:\s*([^\]]+)\]\]/i); - if (quickRepliesMatch) { - const options = quickRepliesMatch[1] - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - if (options.length > 0) { - lineData.quickReplies = [...(lineData.quickReplies || []), ...options]; - } - text = text.replace(quickRepliesMatch[0], "").trim(); - } - - // Parse [[location: title | address | latitude | longitude]] - const locationMatch = text.match(/\[\[location:\s*([^\]]+)\]\]/i); - if (locationMatch && !lineData.location) { - const parts = locationMatch[1].split("|").map((s) => s.trim()); - if (parts.length >= 4) { - const [title, address, latStr, lonStr] = parts; - const latitude = parseFloat(latStr); - const longitude = parseFloat(lonStr); - if (!isNaN(latitude) && !isNaN(longitude)) { - lineData.location = { - title: title || "Location", - address: address || "", - latitude, - longitude, - }; - } - } - text = text.replace(locationMatch[0], "").trim(); - } - - // Parse [[confirm: question | yes_label | no_label]] or [[confirm: question | yes_label:yes_data | no_label:no_data]] - const confirmMatch = text.match(/\[\[confirm:\s*([^\]]+)\]\]/i); - if (confirmMatch && !lineData.templateMessage) { - const parts = confirmMatch[1].split("|").map((s) => s.trim()); - if (parts.length >= 3) { - const [question, yesPart, noPart] = parts; - - // Parse yes_label:yes_data format - const [yesLabel, yesData] = yesPart.includes(":") - ? yesPart.split(":").map((s) => s.trim()) - : [yesPart, yesPart.toLowerCase()]; - - const [noLabel, noData] = noPart.includes(":") - ? noPart.split(":").map((s) => s.trim()) - : [noPart, noPart.toLowerCase()]; - - lineData.templateMessage = { - type: "confirm", - text: question, - confirmLabel: yesLabel, - confirmData: yesData, - cancelLabel: noLabel, - cancelData: noData, - altText: question, - }; - } - text = text.replace(confirmMatch[0], "").trim(); - } - - // Parse [[buttons: title | text | btn1:data1, btn2:data2]] - const buttonsMatch = text.match(/\[\[buttons:\s*([^\]]+)\]\]/i); - if (buttonsMatch && !lineData.templateMessage) { - const parts = buttonsMatch[1].split("|").map((s) => s.trim()); - if (parts.length >= 3) { - const [title, bodyText, actionsStr] = parts; - - const actions = actionsStr.split(",").map((actionStr) => { - const trimmed = actionStr.trim(); - // Find first colon delimiter, ignoring URLs without a label. - const colonIndex = (() => { - const index = trimmed.indexOf(":"); - if (index === -1) { - return -1; - } - const lower = trimmed.toLowerCase(); - if (lower.startsWith("http://") || lower.startsWith("https://")) { - return -1; - } - return index; - })(); - - let label: string; - let data: string; - - if (colonIndex === -1) { - label = trimmed; - data = trimmed; - } else { - label = trimmed.slice(0, colonIndex).trim(); - data = trimmed.slice(colonIndex + 1).trim(); - } - - // Detect action type - if (data.startsWith("http://") || data.startsWith("https://")) { - return { type: "uri" as const, label, uri: data }; - } - if (data.includes("=")) { - return { type: "postback" as const, label, data }; - } - return { type: "message" as const, label, data: data || label }; - }); - - if (actions.length > 0) { - lineData.templateMessage = { - type: "buttons", - title, - text: bodyText, - actions: actions.slice(0, 4), // LINE limit - altText: `${title}: ${bodyText}`, - }; - } - } - text = text.replace(buttonsMatch[0], "").trim(); - } - - // Parse [[media_player: title | artist | source | imageUrl | playing/paused]] - const mediaPlayerMatch = text.match(/\[\[media_player:\s*([^\]]+)\]\]/i); - if (mediaPlayerMatch && !lineData.flexMessage) { - const parts = mediaPlayerMatch[1].split("|").map((s) => s.trim()); - if (parts.length >= 1) { - const [title, artist, source, imageUrl, statusStr] = parts; - const isPlaying = statusStr?.toLowerCase() === "playing"; - - // LINE requires HTTPS URLs for images - skip local/HTTP URLs - const validImageUrl = imageUrl?.startsWith("https://") ? imageUrl : undefined; - - const deviceKey = toSlug(source || title || "media"); - const card = createMediaPlayerCard({ - title: title || "Unknown Track", - subtitle: artist || undefined, - source: source || undefined, - imageUrl: validImageUrl, - isPlaying: statusStr ? isPlaying : undefined, - controls: { - previous: { data: lineActionData("previous", { "line.device": deviceKey }) }, - play: { data: lineActionData("play", { "line.device": deviceKey }) }, - pause: { data: lineActionData("pause", { "line.device": deviceKey }) }, - next: { data: lineActionData("next", { "line.device": deviceKey }) }, - }, - }); - - lineData.flexMessage = { - altText: `🎵 ${title}${artist ? ` - ${artist}` : ""}`, - contents: card, - }; - } - text = text.replace(mediaPlayerMatch[0], "").trim(); - } - - // Parse [[event: title | date | time | location | description]] - const eventMatch = text.match(/\[\[event:\s*([^\]]+)\]\]/i); - if (eventMatch && !lineData.flexMessage) { - const parts = eventMatch[1].split("|").map((s) => s.trim()); - if (parts.length >= 2) { - const [title, date, time, location, description] = parts; - - const card = createEventCard({ - title: title || "Event", - date: date || "TBD", - time: time || undefined, - location: location || undefined, - description: description || undefined, - }); - - lineData.flexMessage = { - altText: `📅 ${title} - ${date}${time ? ` ${time}` : ""}`, - contents: card, - }; - } - text = text.replace(eventMatch[0], "").trim(); - } - - // Parse [[appletv_remote: name | status]] - const appleTvMatch = text.match(/\[\[appletv_remote:\s*([^\]]+)\]\]/i); - if (appleTvMatch && !lineData.flexMessage) { - const parts = appleTvMatch[1].split("|").map((s) => s.trim()); - if (parts.length >= 1) { - const [deviceName, status] = parts; - const deviceKey = toSlug(deviceName || "apple_tv"); - - const card = createAppleTvRemoteCard({ - deviceName: deviceName || "Apple TV", - status: status || undefined, - actionData: { - up: lineActionData("up", { "line.device": deviceKey }), - down: lineActionData("down", { "line.device": deviceKey }), - left: lineActionData("left", { "line.device": deviceKey }), - right: lineActionData("right", { "line.device": deviceKey }), - select: lineActionData("select", { "line.device": deviceKey }), - menu: lineActionData("menu", { "line.device": deviceKey }), - home: lineActionData("home", { "line.device": deviceKey }), - play: lineActionData("play", { "line.device": deviceKey }), - pause: lineActionData("pause", { "line.device": deviceKey }), - volumeUp: lineActionData("volume_up", { "line.device": deviceKey }), - volumeDown: lineActionData("volume_down", { "line.device": deviceKey }), - mute: lineActionData("mute", { "line.device": deviceKey }), - }, - }); - - lineData.flexMessage = { - altText: `📺 ${deviceName || "Apple TV"} Remote`, - contents: card, - }; - } - text = text.replace(appleTvMatch[0], "").trim(); - } - - // Parse [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]] - const agendaMatch = text.match(/\[\[agenda:\s*([^\]]+)\]\]/i); - if (agendaMatch && !lineData.flexMessage) { - const parts = agendaMatch[1].split("|").map((s) => s.trim()); - if (parts.length >= 2) { - const [title, eventsStr] = parts; - - const events = eventsStr.split(",").map((eventStr) => { - const trimmed = eventStr.trim(); - const colonIdx = trimmed.lastIndexOf(":"); - if (colonIdx > 0) { - return { - title: trimmed.slice(0, colonIdx).trim(), - time: trimmed.slice(colonIdx + 1).trim(), - }; - } - return { title: trimmed }; - }); - - const card = createAgendaCard({ - title: title || "Agenda", - events, - }); - - lineData.flexMessage = { - altText: `📋 ${title} (${events.length} events)`, - contents: card, - }; - } - text = text.replace(agendaMatch[0], "").trim(); - } - - // Parse [[device: name | type | status | ctrl1:data1, ctrl2:data2]] - const deviceMatch = text.match(/\[\[device:\s*([^\]]+)\]\]/i); - if (deviceMatch && !lineData.flexMessage) { - const parts = deviceMatch[1].split("|").map((s) => s.trim()); - if (parts.length >= 1) { - const [deviceName, deviceType, status, controlsStr] = parts; - - const deviceKey = toSlug(deviceName || "device"); - const controls = controlsStr - ? controlsStr.split(",").map((ctrlStr) => { - const [label, data] = ctrlStr.split(":").map((s) => s.trim()); - const action = data || label.toLowerCase().replace(/\s+/g, "_"); - return { label, data: lineActionData(action, { "line.device": deviceKey }) }; - }) - : []; - - const card = createDeviceControlCard({ - deviceName: deviceName || "Device", - deviceType: deviceType || undefined, - status: status || undefined, - controls, - }); - - lineData.flexMessage = { - altText: `📱 ${deviceName}${status ? `: ${status}` : ""}`, - contents: card, - }; - } - text = text.replace(deviceMatch[0], "").trim(); - } - - // Clean up multiple whitespace/newlines - text = text.replace(/\n{3,}/g, "\n\n").trim(); - - result.text = text || undefined; - if (Object.keys(lineData).length > 0) { - result.channelData = { ...result.channelData, line: lineData }; - } - return result; -} - -/** - * Check if text contains any LINE directives - */ -export function hasLineDirectives(text: string): boolean { - return /\[\[(quick_replies|location|confirm|buttons|media_player|event|agenda|device|appletv_remote):/i.test( - text, - ); -} diff --git a/src/auto-reply/reply/slack-directives.test.ts b/src/auto-reply/reply/slack-directives.test.ts deleted file mode 100644 index 1d9eac316a6..00000000000 --- a/src/auto-reply/reply/slack-directives.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { hasSlackDirectives, parseSlackDirectives } from "./slack-directives.js"; - -const getSlackInteractive = (result: ReturnType) => - result.interactive?.blocks ?? []; - -describe("hasSlackDirectives", () => { - it("matches expected detection across Slack directive patterns", () => { - const cases: Array<{ text: string; expected: boolean }> = [ - { text: "Pick one [[slack_buttons: Approve:approve, Reject:reject]]", expected: true }, - { - text: "[[slack_select: Choose a project | Alpha:alpha, Beta:beta]]", - expected: true, - }, - { text: "Just regular text", expected: false }, - { text: "[[buttons: Menu | Choose | A:a]]", expected: false }, - ]; - - for (const testCase of cases) { - expect(hasSlackDirectives(testCase.text)).toBe(testCase.expected); - } - }); -}); - -describe("parseSlackDirectives", () => { - it("builds shared text and button blocks from slack_buttons directives", () => { - const result = parseSlackDirectives({ - text: "Choose an action [[slack_buttons: Approve:approve, Reject:reject]]", - }); - - expect(result.text).toBe("Choose an action"); - expect(getSlackInteractive(result)).toEqual([ - { - type: "text", - text: "Choose an action", - }, - { - type: "buttons", - buttons: [ - { - label: "Approve", - value: "approve", - }, - { - label: "Reject", - value: "reject", - }, - ], - }, - ]); - }); - - it("builds shared select blocks from slack_select directives", () => { - const result = parseSlackDirectives({ - text: "[[slack_select: Choose a project | Alpha:alpha, Beta:beta]]", - }); - - expect(result.text).toBeUndefined(); - expect(getSlackInteractive(result)).toEqual([ - { - type: "select", - placeholder: "Choose a project", - options: [ - { label: "Alpha", value: "alpha" }, - { label: "Beta", value: "beta" }, - ], - }, - ]); - }); - - it("leaves existing slack blocks in channelData and appends shared interactive blocks", () => { - const result = parseSlackDirectives({ - text: "Act now [[slack_buttons: Retry:retry]]", - channelData: { - slack: { - blocks: [{ type: "divider" }], - }, - }, - }); - - expect(result.text).toBe("Act now"); - expect(result.channelData).toEqual({ - slack: { - blocks: [{ type: "divider" }], - }, - }); - expect(getSlackInteractive(result)).toEqual([ - { - type: "text", - text: "Act now", - }, - { - type: "buttons", - buttons: [{ label: "Retry", value: "retry" }], - }, - ]); - }); - - it("preserves authored order for mixed Slack directives", () => { - const result = parseSlackDirectives({ - text: "[[slack_select: Pick one | Alpha:alpha]] then [[slack_buttons: Retry:retry]]", - }); - - expect(getSlackInteractive(result)).toEqual([ - { - type: "select", - placeholder: "Pick one", - options: [{ label: "Alpha", value: "alpha" }], - }, - { - type: "text", - text: "then", - }, - { - type: "buttons", - buttons: [{ label: "Retry", value: "retry" }], - }, - ]); - }); - - it("preserves long Slack directive values in the shared interactive model", () => { - const long = "x".repeat(120); - const result = parseSlackDirectives({ - text: `${"y".repeat(3100)} [[slack_select: ${long} | ${long}:${long}]] [[slack_buttons: ${long}:${long}]]`, - }); - - expect(getSlackInteractive(result)).toEqual([ - { - type: "text", - text: "y".repeat(3100), - }, - { - type: "select", - placeholder: long, - options: [{ label: long, value: long }], - }, - { - type: "buttons", - buttons: [{ label: long, value: long }], - }, - ]); - }); - - it("parses optional Slack button styles without truncating callback values", () => { - const result = parseSlackDirectives({ - text: "[[slack_buttons: Approve:pluginbind:approval-123:o:primary, Reject:deny:danger, Skip:skip:secondary]]", - }); - - expect(getSlackInteractive(result)).toEqual([ - { - type: "buttons", - buttons: [ - { - label: "Approve", - value: "pluginbind:approval-123:o", - style: "primary", - }, - { - label: "Reject", - value: "deny", - style: "danger", - }, - { - label: "Skip", - value: "skip", - style: "secondary", - }, - ], - }, - ]); - }); - - it("preserves slack_select values that end in style-like suffixes", () => { - const result = parseSlackDirectives({ - text: "[[slack_select: Choose one | Queue:queue:danger, Archive:archive:primary]]", - }); - - expect(getSlackInteractive(result)).toEqual([ - { - type: "select", - placeholder: "Choose one", - options: [ - { - label: "Queue", - value: "queue:danger", - }, - { - label: "Archive", - value: "archive:primary", - }, - ], - }, - ]); - }); - - it("keeps existing interactive blocks when compiling additional Slack directives", () => { - const result = parseSlackDirectives({ - text: "Choose [[slack_buttons: Retry:retry]]", - interactive: { - blocks: [{ type: "text", text: "Existing" }], - }, - }); - - expect(getSlackInteractive(result)).toEqual([ - { type: "text", text: "Existing" }, - { type: "text", text: "Choose" }, - { type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }, - ]); - }); - - it("ignores malformed directive choices when none remain", () => { - const result = parseSlackDirectives({ - text: "Choose [[slack_buttons: : , : ]]", - }); - - expect(result).toEqual({ - text: "Choose [[slack_buttons: : , : ]]", - }); - }); -}); diff --git a/src/auto-reply/test-helpers/command-auth-registry-fixture.ts b/src/auto-reply/test-helpers/command-auth-registry-fixture.ts index f7eb2ebcc13..ba4bf04b832 100644 --- a/src/auto-reply/test-helpers/command-auth-registry-fixture.ts +++ b/src/auto-reply/test-helpers/command-auth-registry-fixture.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach } from "vitest"; -import { normalizeWhatsAppAllowFromEntries } from "../../../extensions/whatsapp/api.js"; +import { normalizeE164 } from "../../plugin-sdk/account-resolution.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; @@ -11,6 +11,34 @@ function formatDiscordAllowFromEntries(allowFrom: Array): strin .map((entry) => entry.toLowerCase()); } +function normalizePhoneAllowFromEntries(allowFrom: Array): string[] { + return allowFrom + .map((entry) => String(entry).trim()) + .filter((entry): entry is string => Boolean(entry)) + .map((entry) => { + if (entry === "*") { + return entry; + } + const stripped = entry.replace(/^whatsapp:/i, "").trim(); + if (/@g\.us$/i.test(stripped)) { + return stripped; + } + if (/^(\d+)(?::\d+)?@s\.whatsapp\.net$/i.test(stripped)) { + const match = stripped.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/i); + return match ? normalizeE164(match[1]) : null; + } + if (/^(\d+)@lid$/i.test(stripped)) { + const match = stripped.match(/^(\d+)@lid$/i); + return match ? normalizeE164(match[1]) : null; + } + if (stripped.includes("@")) { + return null; + } + return normalizeE164(stripped); + }) + .filter((entry): entry is string => Boolean(entry)); +} + function resolveChannelAllowFrom( cfg: Record, channelId: string, @@ -52,7 +80,7 @@ export const createCommandAuthRegistry = () => resolveAllowFrom: ({ cfg }: { cfg: Record }) => resolveChannelAllowFrom(cfg, "whatsapp"), formatAllowFrom: ({ allowFrom }: { allowFrom: Array }) => - normalizeWhatsAppAllowFromEntries(allowFrom), + normalizePhoneAllowFromEntries(allowFrom), }, }, source: "test", diff --git a/src/channels/plugins/contracts/channel-import-guardrails.test.ts b/src/channels/plugins/contracts/channel-import-guardrails.test.ts index 731530dd946..4c0471d7edb 100644 --- a/src/channels/plugins/contracts/channel-import-guardrails.test.ts +++ b/src/channels/plugins/contracts/channel-import-guardrails.test.ts @@ -133,12 +133,7 @@ const SETUP_BARREL_GUARDS: GuardedSource[] = [ }, { path: bundledPluginFile("signal", "src/setup-surface.ts"), - forbiddenPatterns: [ - /\bdetectBinary\b/, - /\binstallSignalCli\b/, - /\bformatCliCommand\b/, - /\bformatDocsLink\b/, - ], + forbiddenPatterns: [/\bdetectBinary\b/, /\bformatCliCommand\b/, /\bformatDocsLink\b/], }, { path: bundledPluginFile("slack", "src/setup-core.ts"), diff --git a/src/channels/plugins/contracts/dm-policy.contract.test.ts b/src/channels/plugins/contracts/dm-policy.contract.test.ts index 82b16248033..689f5825e4d 100644 --- a/src/channels/plugins/contracts/dm-policy.contract.test.ts +++ b/src/channels/plugins/contracts/dm-policy.contract.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { isSignalSenderAllowed, type SignalSender } from "../../../../extensions/signal/api.js"; +import { + isSignalSenderAllowed, + type SignalSender, +} from "../../../../test/helpers/channels/policy-contract.js"; import { isAllowedBlueBubblesSender } from "../../../plugin-sdk/bluebubbles-policy.js"; import { isMattermostSenderAllowed } from "../../../plugin-sdk/mattermost-policy.js"; import { diff --git a/src/channels/plugins/contracts/slack-outbound-harness.ts b/src/channels/plugins/contracts/slack-outbound-harness.ts deleted file mode 100644 index 02c7d9e9f34..00000000000 --- a/src/channels/plugins/contracts/slack-outbound-harness.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { vi, type Mock } from "vitest"; -import type { ReplyPayload } from "../../../auto-reply/types.js"; -import { loadBundledPluginTestApiSync } from "../../../test-utils/bundled-plugin-public-surface.js"; -import type { ChannelOutboundAdapter } from "../types.js"; -import { primeChannelOutboundSendMock } from "./test-helpers.js"; - -type OutboundSendMock = Mock<(...args: unknown[]) => Promise>>; - -type SlackOutboundPayloadHarness = { - run: () => Promise>; - sendMock: OutboundSendMock; - to: string; -}; - -let slackOutboundCache: ChannelOutboundAdapter | undefined; - -function getSlackOutbound(): ChannelOutboundAdapter { - if (!slackOutboundCache) { - ({ slackOutbound: slackOutboundCache } = loadBundledPluginTestApiSync<{ - slackOutbound: ChannelOutboundAdapter; - }>("slack")); - } - return slackOutboundCache; -} - -export function createSlackOutboundPayloadHarness(params: { - payload: ReplyPayload; - sendResults?: Array<{ messageId: string }>; -}): SlackOutboundPayloadHarness { - const sendSlack: OutboundSendMock = vi.fn(); - primeChannelOutboundSendMock( - sendSlack, - { messageId: "sl-1", channelId: "C12345", ts: "1234.5678" }, - params.sendResults, - ); - const ctx = { - cfg: {}, - to: "C12345", - text: "", - payload: params.payload, - deps: { - sendSlack, - }, - }; - return { - run: async () => await getSlackOutbound().sendPayload!(ctx), - sendMock: sendSlack, - to: ctx.to, - }; -} diff --git a/src/channels/plugins/normalize/targets.test.ts b/src/channels/plugins/normalize/targets.test.ts deleted file mode 100644 index cf30f51afb8..00000000000 --- a/src/channels/plugins/normalize/targets.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./imessage.js"; -import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./whatsapp.js"; - -describe("normalize target helpers", () => { - describe("iMessage", () => { - it("normalizes blank inputs to undefined", () => { - expect(normalizeIMessageMessagingTarget(" ")).toBeUndefined(); - }); - - it("detects common iMessage target forms", () => { - expect(looksLikeIMessageTargetId("sms:+15555550123")).toBe(true); - expect(looksLikeIMessageTargetId("chat_id:123")).toBe(true); - expect(looksLikeIMessageTargetId("user@example.com")).toBe(true); - expect(looksLikeIMessageTargetId("+15555550123")).toBe(true); - expect(looksLikeIMessageTargetId("")).toBe(false); - }); - }); - - describe("WhatsApp", () => { - it("normalizes blank inputs to undefined", () => { - expect(normalizeWhatsAppMessagingTarget(" ")).toBeUndefined(); - }); - - it("detects common WhatsApp target forms", () => { - expect(looksLikeWhatsAppTargetId("whatsapp:+15555550123")).toBe(true); - expect(looksLikeWhatsAppTargetId("15555550123@c.us")).toBe(true); - expect(looksLikeWhatsAppTargetId("+15555550123")).toBe(true); - expect(looksLikeWhatsAppTargetId("")).toBe(false); - }); - }); -}); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 5ade9bc3c3d..07559fb8d0a 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -3,17 +3,12 @@ import os from "node:os"; import path from "node:path"; import { Command } from "commander"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TEST_BUNDLED_RUNTIME_SIDECAR_PATHS } from "../../test/helpers/bundled-runtime-sidecars.js"; import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js"; import type { UpdateRunResult } from "../infra/update-runner.js"; import { withEnvAsync } from "../test-utils/env.js"; import { createCliRuntimeCapture } from "./test-runtime-capture.js"; -const TEST_BUNDLED_RUNTIME_SIDECAR_PATHS = [ - "dist/extensions/discord/runtime-api.js", - "dist/extensions/slack/helper-api.js", - "dist/extensions/telegram/thread-bindings-runtime.js", -] as const; - const confirm = vi.fn(); const select = vi.fn(); const spinner = vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })); diff --git a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts index e2437c8b667..88e72f75b02 100644 --- a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts +++ b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createIMessageTestPlugin } from "../../test/helpers/channels/imessage-test-plugin.js"; import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; -import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js"; import { formatGatewayChannelsStatusLines } from "./channels/status.js"; const signalPlugin = { diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index b3fd6a90631..8adc9fbbe86 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { withTempHome } from "../../test/helpers/temp-home.js"; -import { resolveMatrixAccountStorageRoot } from "../infra/matrix-config-helpers.js"; +import { resolveMatrixAccountStorageRoot } from "../plugin-sdk/matrix.js"; import * as noteModule from "../terminal/note.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js"; diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index 4d2e38766a1..b7b7df8e9d7 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -304,10 +304,10 @@ describe("doctor legacy state migrations", () => { it("migrates legacy Telegram pairing allowFrom store to account-scoped default file", async () => { const { root, cfg } = await makeRootWithEmptyCfg(); const { oauthDir, detected, result } = await runTelegramAllowFromMigration({ root, cfg }); - expect(detected.pairingAllowFrom.hasLegacyTelegram).toBe(true); - expect( - detected.pairingAllowFrom.copyPlans.map((plan) => path.basename(plan.targetPath)), - ).toEqual(["telegram-default-allowFrom.json"]); + expect(detected.channelPlans.hasLegacy).toBe(true); + expect(detected.channelPlans.plans.map((plan) => path.basename(plan.targetPath))).toEqual([ + "telegram-default-allowFrom.json", + ]); expect(result.warnings).toEqual([]); const target = path.join(oauthDir, "telegram-default-allowFrom.json"); @@ -332,10 +332,10 @@ describe("doctor legacy state migrations", () => { }, }; const { oauthDir, detected, result } = await runTelegramAllowFromMigration({ root, cfg }); - expect(detected.pairingAllowFrom.hasLegacyTelegram).toBe(true); - expect( - detected.pairingAllowFrom.copyPlans.map((plan) => path.basename(plan.targetPath)), - ).toEqual(["telegram-bot2-allowFrom.json"]); + expect(detected.channelPlans.hasLegacy).toBe(true); + expect(detected.channelPlans.plans.map((plan) => path.basename(plan.targetPath))).toEqual([ + "telegram-bot2-allowFrom.json", + ]); expect(result.warnings).toEqual([]); const bot1Target = path.join(oauthDir, "telegram-bot1-allowFrom.json"); @@ -368,10 +368,10 @@ describe("doctor legacy state migrations", () => { }; const { oauthDir, detected, result } = await runTelegramAllowFromMigration({ root, cfg }); - expect(detected.pairingAllowFrom.hasLegacyTelegram).toBe(true); - expect( - detected.pairingAllowFrom.copyPlans.map((plan) => path.basename(plan.targetPath)), - ).toEqual(["telegram-alerts-allowFrom.json"]); + expect(detected.channelPlans.hasLegacy).toBe(true); + expect(detected.channelPlans.plans.map((plan) => path.basename(plan.targetPath))).toEqual([ + "telegram-alerts-allowFrom.json", + ]); expect(result.warnings).toEqual([]); const alertsTarget = path.join(oauthDir, "telegram-alerts-allowFrom.json"); diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index cd600785e1a..d52f0a597c9 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -160,14 +160,9 @@ function createLegacyStateMigrationDetectionResult(params?: { targetDir: "/tmp/state/agents/main/agent", hasLegacy: false, }, - whatsappAuth: { - legacyDir: "/tmp/oauth", - targetDir: "/tmp/oauth/whatsapp/default", + channelPlans: { hasLegacy: false, - }, - pairingAllowFrom: { - hasLegacyTelegram: false, - copyPlans: [], + plans: [], }, preview: params?.preview ?? [], }; diff --git a/src/commands/doctor/shared/mutable-allowlist.test.ts b/src/commands/doctor/shared/mutable-allowlist.test.ts deleted file mode 100644 index 308a51efab3..00000000000 --- a/src/commands/doctor/shared/mutable-allowlist.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - collectMutableAllowlistWarnings, - scanMutableAllowlistEntries, -} from "./mutable-allowlist.js"; - -describe("doctor mutable allowlist scanner", () => { - it("finds mutable built-in allowlist entries when dangerous matching is disabled", () => { - const hits = scanMutableAllowlistEntries({ - channels: { - irc: { - allowFrom: ["charlie"], - groups: { - "#ops": { - allowFrom: ["dana"], - }, - }, - }, - googlechat: { - groupAllowFrom: ["engineering@example.com"], - }, - }, - }); - - expect(hits).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - channel: "irc", - path: "channels.irc.allowFrom", - entry: "charlie", - }), - expect.objectContaining({ - channel: "irc", - path: "channels.irc.groups.#ops.allowFrom", - entry: "dana", - }), - expect.objectContaining({ - channel: "googlechat", - path: "channels.googlechat.groupAllowFrom", - entry: "engineering@example.com", - }), - ]), - ); - }); - - it("skips scopes that explicitly allow dangerous name matching", () => { - const hits = scanMutableAllowlistEntries({ - channels: { - googlechat: { - dangerouslyAllowNameMatching: true, - groupAllowFrom: ["engineering@example.com"], - }, - }, - }); - - expect(hits).toEqual([]); - }); - - it("formats mutable allowlist warnings", () => { - const warnings = collectMutableAllowlistWarnings([ - { - channel: "irc", - path: "channels.irc.allowFrom", - entry: "bob", - dangerousFlagPath: "channels.irc.dangerouslyAllowNameMatching", - }, - { - channel: "googlechat", - path: "channels.googlechat.groupAllowFrom", - entry: "engineering@example.com", - dangerousFlagPath: "channels.googlechat.dangerouslyAllowNameMatching", - }, - ]); - - expect(warnings).toEqual( - expect.arrayContaining([ - expect.stringContaining("mutable allowlist entries across googlechat, irc"), - expect.stringContaining("channels.irc.allowFrom: bob"), - expect.stringContaining("channels.googlechat.groupAllowFrom: engineering@example.com"), - expect.stringContaining("Option A"), - expect.stringContaining("Option B"), - ]), - ); - }); -}); diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index a1cbda461d0..9c411098bc1 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { matrixSetupAdapter, matrixSetupWizard } from "../../test/helpers/plugins/matrix-setup.js"; +import { + matrixSetupAdapter, + matrixSetupWizard, +} from "../../test/helpers/channels/matrix-setup-contract.js"; import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js"; import type { OpenClawConfig } from "../config/config.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; diff --git a/src/config/channel-token-and-http.validation.test.ts b/src/config/channel-token-and-http.validation.test.ts new file mode 100644 index 00000000000..b2d01dfc06d --- /dev/null +++ b/src/config/channel-token-and-http.validation.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; +import { validateConfigObject } from "./config.js"; + +describe("channel token and HTTP validation", () => { + describe("Slack token fields", () => { + it("accepts user token config fields", () => { + const res = validateConfigObject({ + channels: { + slack: { + botToken: "xoxb-any", + appToken: "xapp-any", + userToken: "xoxp-any", + userTokenReadOnly: false, + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("accepts account-level user token config", () => { + const res = validateConfigObject({ + channels: { + slack: { + accounts: { + work: { + botToken: "xoxb-any", + appToken: "xapp-any", + userToken: "xoxp-any", + userTokenReadOnly: true, + }, + }, + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects invalid userTokenReadOnly types", () => { + const res = validateConfigObject({ + channels: { + slack: { + botToken: "xoxb-any", + appToken: "xapp-any", + userToken: "xoxp-any", + // oxlint-disable-next-line typescript/no-explicit-any + userTokenReadOnly: "no" as any, + }, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues.some((iss) => iss.path.includes("userTokenReadOnly"))).toBe(true); + } + }); + + it("rejects invalid userToken types", () => { + const res = validateConfigObject({ + channels: { + slack: { + botToken: "xoxb-any", + appToken: "xapp-any", + // oxlint-disable-next-line typescript/no-explicit-any + userToken: 123 as any, + }, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues.some((iss) => iss.path.includes("userToken"))).toBe(true); + } + }); + }); + + describe("Slack HTTP mode", () => { + it("accepts HTTP mode when signing secret is configured", () => { + const res = validateConfigObject({ + channels: { + slack: { + mode: "http", + signingSecret: "secret", + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("accepts HTTP mode when signing secret is configured as SecretRef", () => { + const res = validateConfigObject({ + channels: { + slack: { + mode: "http", + signingSecret: { source: "env", provider: "default", id: "SLACK_SIGNING_SECRET" }, + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects HTTP mode without signing secret", () => { + const res = validateConfigObject({ + channels: { + slack: { + mode: "http", + }, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("channels.slack.signingSecret"); + } + }); + + it("accepts account HTTP mode when base signing secret is set", () => { + const res = validateConfigObject({ + channels: { + slack: { + signingSecret: "secret", + accounts: { + ops: { + mode: "http", + }, + }, + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("accepts account HTTP mode when account signing secret is set as SecretRef", () => { + const res = validateConfigObject({ + channels: { + slack: { + accounts: { + ops: { + mode: "http", + signingSecret: { + source: "env", + provider: "default", + id: "SLACK_OPS_SIGNING_SECRET", + }, + }, + }, + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects account HTTP mode without signing secret", () => { + const res = validateConfigObject({ + channels: { + slack: { + accounts: { + ops: { + mode: "http", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("channels.slack.accounts.ops.signingSecret"); + } + }); + }); +}); diff --git a/src/config/channel-webhook-and-actions.validation.test.ts b/src/config/channel-webhook-and-actions.validation.test.ts new file mode 100644 index 00000000000..87f0f024bdf --- /dev/null +++ b/src/config/channel-webhook-and-actions.validation.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "vitest"; +import { validateConfigObject } from "./config.js"; + +describe("channel webhook and actions validation", () => { + describe("Telegram poll actions", () => { + it("accepts channels.telegram.actions.poll", () => { + const res = validateConfigObject({ + channels: { + telegram: { + actions: { + poll: false, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it("accepts channels.telegram.accounts..actions.poll", () => { + const res = validateConfigObject({ + channels: { + telegram: { + accounts: { + ops: { + actions: { + poll: false, + }, + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + }); + + describe("Telegram webhookPort", () => { + it("accepts a positive webhookPort", () => { + const res = validateConfigObject({ + channels: { + telegram: { + webhookUrl: "https://example.com/telegram-webhook", + webhookSecret: "secret", + webhookPort: 8787, + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("accepts webhookPort set to 0 for ephemeral port binding", () => { + const res = validateConfigObject({ + channels: { + telegram: { + webhookUrl: "https://example.com/telegram-webhook", + webhookSecret: "secret", + webhookPort: 0, + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects negative webhookPort", () => { + const res = validateConfigObject({ + channels: { + telegram: { + webhookUrl: "https://example.com/telegram-webhook", + webhookSecret: "secret", + webhookPort: -1, + }, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues.some((issue) => issue.path === "channels.telegram.webhookPort")).toBe( + true, + ); + } + }); + }); + + describe("Telegram webhook secret", () => { + it.each([ + { + name: "webhookUrl when webhookSecret is configured", + config: { + telegram: { + webhookUrl: "https://example.com/telegram-webhook", + webhookSecret: "secret", + }, + }, + }, + { + name: "webhookUrl when webhookSecret is configured as SecretRef", + config: { + telegram: { + webhookUrl: "https://example.com/telegram-webhook", + webhookSecret: { + source: "env", + provider: "default", + id: "TELEGRAM_WEBHOOK_SECRET", + }, + }, + }, + }, + { + name: "account webhookUrl when base webhookSecret is configured", + config: { + telegram: { + webhookSecret: "secret", + accounts: { + ops: { + webhookUrl: "https://example.com/telegram-webhook", + }, + }, + }, + }, + }, + { + name: "account webhookUrl when account webhookSecret is configured as SecretRef", + config: { + telegram: { + accounts: { + ops: { + webhookUrl: "https://example.com/telegram-webhook", + webhookSecret: { + source: "env", + provider: "default", + id: "TELEGRAM_OPS_WEBHOOK_SECRET", + }, + }, + }, + }, + }, + }, + ] as const)("accepts $name", ({ config }) => { + expect(validateConfigObject({ channels: config }).ok).toBe(true); + }); + + it("rejects webhookUrl without webhookSecret", () => { + const res = validateConfigObject({ + channels: { + telegram: { + webhookUrl: "https://example.com/telegram-webhook", + }, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("channels.telegram.webhookSecret"); + } + }); + + it("rejects account webhookUrl without webhookSecret", () => { + const res = validateConfigObject({ + channels: { + telegram: { + accounts: { + ops: { + webhookUrl: "https://example.com/telegram-webhook", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("channels.telegram.accounts.ops.webhookSecret"); + } + }); + }); +}); diff --git a/src/config/discord-preview-streaming.test.ts b/src/config/discord-preview-streaming.test.ts deleted file mode 100644 index 1673ea5a82a..00000000000 --- a/src/config/discord-preview-streaming.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveDiscordPreviewStreamMode } from "./discord-preview-streaming.js"; - -describe("resolveDiscordPreviewStreamMode", () => { - it("defaults to off when unset", () => { - expect(resolveDiscordPreviewStreamMode({})).toBe("off"); - }); - - it("preserves explicit off", () => { - expect(resolveDiscordPreviewStreamMode({ streaming: "off" })).toBe("off"); - expect(resolveDiscordPreviewStreamMode({ streamMode: "off" })).toBe("off"); - expect(resolveDiscordPreviewStreamMode({ streaming: false })).toBe("off"); - }); -}); diff --git a/src/config/discord-preview-streaming.ts b/src/config/discord-preview-streaming.ts deleted file mode 100644 index 37e96e08bf3..00000000000 --- a/src/config/discord-preview-streaming.ts +++ /dev/null @@ -1,161 +0,0 @@ -export type StreamingMode = "off" | "partial" | "block" | "progress"; -export type DiscordPreviewStreamMode = "off" | "partial" | "block"; -export type TelegramPreviewStreamMode = "off" | "partial" | "block"; -export type SlackLegacyDraftStreamMode = "replace" | "status_final" | "append"; - -function normalizeStreamingMode(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const normalized = value.trim().toLowerCase(); - return normalized || null; -} - -export function parseStreamingMode(value: unknown): StreamingMode | null { - const normalized = normalizeStreamingMode(value); - if ( - normalized === "off" || - normalized === "partial" || - normalized === "block" || - normalized === "progress" - ) { - return normalized; - } - return null; -} - -export function parseDiscordPreviewStreamMode(value: unknown): DiscordPreviewStreamMode | null { - const parsed = parseStreamingMode(value); - if (!parsed) { - return null; - } - return parsed === "progress" ? "partial" : parsed; -} - -export function parseSlackLegacyDraftStreamMode(value: unknown): SlackLegacyDraftStreamMode | null { - const normalized = normalizeStreamingMode(value); - if (normalized === "replace" || normalized === "status_final" || normalized === "append") { - return normalized; - } - return null; -} - -export function mapSlackLegacyDraftStreamModeToStreaming( - mode: SlackLegacyDraftStreamMode, -): StreamingMode { - if (mode === "append") { - return "block"; - } - if (mode === "status_final") { - return "progress"; - } - return "partial"; -} - -export function mapStreamingModeToSlackLegacyDraftStreamMode(mode: StreamingMode) { - if (mode === "block") { - return "append" as const; - } - if (mode === "progress") { - return "status_final" as const; - } - return "replace" as const; -} - -export function resolveTelegramPreviewStreamMode( - params: { - streamMode?: unknown; - streaming?: unknown; - } = {}, -): TelegramPreviewStreamMode { - const parsedStreaming = parseStreamingMode(params.streaming); - if (parsedStreaming) { - if (parsedStreaming === "progress") { - return "partial"; - } - return parsedStreaming; - } - - const legacy = parseDiscordPreviewStreamMode(params.streamMode); - if (legacy) { - return legacy; - } - if (typeof params.streaming === "boolean") { - return params.streaming ? "partial" : "off"; - } - return "partial"; -} - -export function resolveDiscordPreviewStreamMode( - params: { - streamMode?: unknown; - streaming?: unknown; - } = {}, -): DiscordPreviewStreamMode { - const parsedStreaming = parseDiscordPreviewStreamMode(params.streaming); - if (parsedStreaming) { - return parsedStreaming; - } - - const legacy = parseDiscordPreviewStreamMode(params.streamMode); - if (legacy) { - return legacy; - } - if (typeof params.streaming === "boolean") { - return params.streaming ? "partial" : "off"; - } - // Discord preview streaming edits can hit aggressive rate limits, especially - // when multiple gateways or multiple bots share the same account/server. Keep - // the default off unless the operator opts in explicitly. - return "off"; -} - -export function resolveSlackStreamingMode( - params: { - streamMode?: unknown; - streaming?: unknown; - } = {}, -): StreamingMode { - const parsedStreaming = parseStreamingMode(params.streaming); - if (parsedStreaming) { - return parsedStreaming; - } - const legacyStreamMode = parseSlackLegacyDraftStreamMode(params.streamMode); - if (legacyStreamMode) { - return mapSlackLegacyDraftStreamModeToStreaming(legacyStreamMode); - } - // Legacy boolean `streaming` values map to the unified enum. - if (typeof params.streaming === "boolean") { - return params.streaming ? "partial" : "off"; - } - return "partial"; -} - -export function resolveSlackNativeStreaming( - params: { - nativeStreaming?: unknown; - streaming?: unknown; - } = {}, -): boolean { - if (typeof params.nativeStreaming === "boolean") { - return params.nativeStreaming; - } - if (typeof params.streaming === "boolean") { - return params.streaming; - } - return true; -} - -export function formatSlackStreamModeMigrationMessage( - pathPrefix: string, - resolvedStreaming: string, -): string { - return `Moved ${pathPrefix}.streamMode → ${pathPrefix}.streaming (${resolvedStreaming}).`; -} - -export function formatSlackStreamingBooleanMigrationMessage( - pathPrefix: string, - resolvedNativeStreaming: boolean, -): string { - return `Moved ${pathPrefix}.streaming (boolean) → ${pathPrefix}.nativeStreaming (${resolvedNativeStreaming}).`; -} diff --git a/src/config/slack-http-config.test.ts b/src/config/slack-http-config.test.ts deleted file mode 100644 index f5e46c62763..00000000000 --- a/src/config/slack-http-config.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateConfigObject } from "./config.js"; - -describe("Slack HTTP mode config", () => { - it("accepts HTTP mode when signing secret is configured", () => { - const res = validateConfigObject({ - channels: { - slack: { - mode: "http", - signingSecret: "secret", - }, - }, - }); - expect(res.ok).toBe(true); - }); - - it("accepts HTTP mode when signing secret is configured as SecretRef", () => { - const res = validateConfigObject({ - channels: { - slack: { - mode: "http", - signingSecret: { source: "env", provider: "default", id: "SLACK_SIGNING_SECRET" }, - }, - }, - }); - expect(res.ok).toBe(true); - }); - - it("rejects HTTP mode without signing secret", () => { - const res = validateConfigObject({ - channels: { - slack: { - mode: "http", - }, - }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues[0]?.path).toBe("channels.slack.signingSecret"); - } - }); - - it("accepts account HTTP mode when base signing secret is set", () => { - const res = validateConfigObject({ - channels: { - slack: { - signingSecret: "secret", - accounts: { - ops: { - mode: "http", - }, - }, - }, - }, - }); - expect(res.ok).toBe(true); - }); - - it("accepts account HTTP mode when account signing secret is set as SecretRef", () => { - const res = validateConfigObject({ - channels: { - slack: { - accounts: { - ops: { - mode: "http", - signingSecret: { - source: "env", - provider: "default", - id: "SLACK_OPS_SIGNING_SECRET", - }, - }, - }, - }, - }, - }); - expect(res.ok).toBe(true); - }); - - it("rejects account HTTP mode without signing secret", () => { - const res = validateConfigObject({ - channels: { - slack: { - accounts: { - ops: { - mode: "http", - }, - }, - }, - }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues[0]?.path).toBe("channels.slack.accounts.ops.signingSecret"); - } - }); -}); diff --git a/src/config/slack-token-validation.test.ts b/src/config/slack-token-validation.test.ts deleted file mode 100644 index 6ebfcabb858..00000000000 --- a/src/config/slack-token-validation.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateConfigObject } from "./config.js"; - -describe("Slack token config fields", () => { - it("accepts user token config fields", () => { - const res = validateConfigObject({ - channels: { - slack: { - botToken: "xoxb-any", - appToken: "xapp-any", - userToken: "xoxp-any", - userTokenReadOnly: false, - }, - }, - }); - expect(res.ok).toBe(true); - }); - - it("accepts account-level user token config", () => { - const res = validateConfigObject({ - channels: { - slack: { - accounts: { - work: { - botToken: "xoxb-any", - appToken: "xapp-any", - userToken: "xoxp-any", - userTokenReadOnly: true, - }, - }, - }, - }, - }); - expect(res.ok).toBe(true); - }); - - it("rejects invalid userTokenReadOnly types", () => { - const res = validateConfigObject({ - channels: { - slack: { - botToken: "xoxb-any", - appToken: "xapp-any", - userToken: "xoxp-any", - // oxlint-disable-next-line typescript/no-explicit-any - userTokenReadOnly: "no" as any, - }, - }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues.some((iss) => iss.path.includes("userTokenReadOnly"))).toBe(true); - } - }); - - it("rejects invalid userToken types", () => { - const res = validateConfigObject({ - channels: { - slack: { - botToken: "xoxb-any", - appToken: "xapp-any", - // oxlint-disable-next-line typescript/no-explicit-any - userToken: 123 as any, - }, - }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues.some((iss) => iss.path.includes("userToken"))).toBe(true); - } - }); -}); diff --git a/src/config/telegram-actions-poll.test.ts b/src/config/telegram-actions-poll.test.ts deleted file mode 100644 index 0193cab9a69..00000000000 --- a/src/config/telegram-actions-poll.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateConfigObject } from "./config.js"; - -describe("telegram poll action config", () => { - it("accepts channels.telegram.actions.poll", () => { - const res = validateConfigObject({ - channels: { - telegram: { - actions: { - poll: false, - }, - }, - }, - }); - - expect(res.ok).toBe(true); - }); - - it("accepts channels.telegram.accounts..actions.poll", () => { - const res = validateConfigObject({ - channels: { - telegram: { - accounts: { - ops: { - actions: { - poll: false, - }, - }, - }, - }, - }, - }); - - expect(res.ok).toBe(true); - }); -}); diff --git a/src/config/telegram-webhook-port.test.ts b/src/config/telegram-webhook-port.test.ts deleted file mode 100644 index f2ffce5419b..00000000000 --- a/src/config/telegram-webhook-port.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateConfigObject } from "./config.js"; - -describe("Telegram webhookPort config", () => { - it("accepts a positive webhookPort", () => { - const res = validateConfigObject({ - channels: { - telegram: { - webhookUrl: "https://example.com/telegram-webhook", - webhookSecret: "secret", // pragma: allowlist secret - webhookPort: 8787, - }, - }, - }); - expect(res.ok).toBe(true); - }); - - it("accepts webhookPort set to 0 for ephemeral port binding", () => { - const res = validateConfigObject({ - channels: { - telegram: { - webhookUrl: "https://example.com/telegram-webhook", - webhookSecret: "secret", // pragma: allowlist secret - webhookPort: 0, - }, - }, - }); - expect(res.ok).toBe(true); - }); - - it("rejects negative webhookPort", () => { - const res = validateConfigObject({ - channels: { - telegram: { - webhookUrl: "https://example.com/telegram-webhook", - webhookSecret: "secret", // pragma: allowlist secret - webhookPort: -1, - }, - }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues.some((issue) => issue.path === "channels.telegram.webhookPort")).toBe(true); - } - }); -}); diff --git a/src/config/telegram-webhook-secret.test.ts b/src/config/telegram-webhook-secret.test.ts deleted file mode 100644 index 8127a44cebd..00000000000 --- a/src/config/telegram-webhook-secret.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateConfigObject } from "./config.js"; - -describe("Telegram webhook config", () => { - it.each([ - { - name: "webhookUrl when webhookSecret is configured", - config: { - telegram: { - webhookUrl: "https://example.com/telegram-webhook", - webhookSecret: "secret", - }, - }, - }, - { - name: "webhookUrl when webhookSecret is configured as SecretRef", - config: { - telegram: { - webhookUrl: "https://example.com/telegram-webhook", - webhookSecret: { - source: "env", - provider: "default", - id: "TELEGRAM_WEBHOOK_SECRET", - }, - }, - }, - }, - { - name: "account webhookUrl when base webhookSecret is configured", - config: { - telegram: { - webhookSecret: "secret", - accounts: { - ops: { - webhookUrl: "https://example.com/telegram-webhook", - }, - }, - }, - }, - }, - { - name: "account webhookUrl when account webhookSecret is configured as SecretRef", - config: { - telegram: { - accounts: { - ops: { - webhookUrl: "https://example.com/telegram-webhook", - webhookSecret: { - source: "env", - provider: "default", - id: "TELEGRAM_OPS_WEBHOOK_SECRET", - }, - }, - }, - }, - }, - }, - ] as const)("accepts $name", ({ config }) => { - expect(validateConfigObject({ channels: config }).ok).toBe(true); - }); - - it("rejects webhookUrl without webhookSecret", () => { - const res = validateConfigObject({ - channels: { - telegram: { - webhookUrl: "https://example.com/telegram-webhook", - }, - }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues[0]?.path).toBe("channels.telegram.webhookSecret"); - } - }); - - it("rejects account webhookUrl without webhookSecret", () => { - const res = validateConfigObject({ - channels: { - telegram: { - accounts: { - ops: { - webhookUrl: "https://example.com/telegram-webhook", - }, - }, - }, - }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues[0]?.path).toBe("channels.telegram.accounts.ops.webhookSecret"); - } - }); -}); diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index 3c998b969cd..6e5f136755c 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -1,9 +1,9 @@ import type { IncomingMessage } from "node:http"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { createIMessageTestPlugin } from "../../test/helpers/channels/imessage-test-plugin.js"; import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; -import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js"; import { extractHookToken, isHookAgentAllowed, diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index c2f25b20a1b..2c7d4c5ad29 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -2,6 +2,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + isWhatsAppGroupJid, + normalizeWhatsAppTarget, +} from "../../test/helpers/channels/command-contract.js"; import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js"; import type { OpenClawConfig } from "../config/config.js"; import { @@ -10,7 +14,6 @@ import { resolveMainSessionKey, resolveStorePath, } from "../config/sessions.js"; -import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../plugin-sdk/whatsapp-targets.js"; import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; import { buildAgentPeerSessionKey } from "../routing/session-key.js"; import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; diff --git a/src/infra/matrix-account-selection.test.ts b/src/infra/matrix-account-selection.test.ts deleted file mode 100644 index a0e0d6c5d0d..00000000000 --- a/src/infra/matrix-account-selection.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { - findMatrixAccountEntry, - getMatrixScopedEnvVarNames, - requiresExplicitMatrixDefaultAccount, - resolveConfiguredMatrixAccountIds, - resolveMatrixDefaultOrOnlyAccountId, -} from "./matrix-config-helpers.js"; - -describe("matrix account selection", () => { - it("resolves configured account ids from non-canonical account keys", () => { - const cfg: OpenClawConfig = { - channels: { - matrix: { - accounts: { - "Team Ops": { homeserver: "https://matrix.example.org" }, - }, - }, - }, - }; - - expect(resolveConfiguredMatrixAccountIds(cfg)).toEqual(["team-ops"]); - expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops"); - }); - - it("matches the default account against normalized Matrix account keys", () => { - const cfg: OpenClawConfig = { - channels: { - matrix: { - defaultAccount: "Team Ops", - accounts: { - "Ops Bot": { homeserver: "https://matrix.example.org" }, - "Team Ops": { homeserver: "https://matrix.example.org" }, - }, - }, - }, - }; - - expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops"); - expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false); - }); - - it("requires an explicit default when multiple Matrix accounts exist without one", () => { - const cfg: OpenClawConfig = { - channels: { - matrix: { - accounts: { - ops: { homeserver: "https://matrix.example.org" }, - alerts: { homeserver: "https://matrix.example.org" }, - }, - }, - }, - }; - - expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(true); - }); - - it("finds the raw Matrix account entry by normalized account id", () => { - const cfg: OpenClawConfig = { - channels: { - matrix: { - accounts: { - "Team Ops": { - homeserver: "https://matrix.example.org", - userId: "@ops:example.org", - }, - }, - }, - }, - }; - - expect(findMatrixAccountEntry(cfg, "team-ops")).toEqual({ - homeserver: "https://matrix.example.org", - userId: "@ops:example.org", - }); - }); - - it("discovers env-backed named Matrix accounts during enumeration", () => { - const keys = getMatrixScopedEnvVarNames("team-ops"); - const cfg: OpenClawConfig = { - channels: { - matrix: {}, - }, - }; - const env = { - [keys.homeserver]: "https://matrix.example.org", - [keys.accessToken]: "secret", - } satisfies NodeJS.ProcessEnv; - - expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["team-ops"]); - expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("team-ops"); - expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false); - }); - - it("treats mixed default and named env-backed Matrix accounts as multi-account", () => { - const keys = getMatrixScopedEnvVarNames("team-ops"); - const cfg: OpenClawConfig = { - channels: { - matrix: {}, - }, - }; - const env = { - MATRIX_HOMESERVER: "https://matrix.example.org", - MATRIX_ACCESS_TOKEN: "default-secret", - [keys.homeserver]: "https://matrix.example.org", - [keys.accessToken]: "team-secret", - } satisfies NodeJS.ProcessEnv; - - expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "team-ops"]); - expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(true); - }); - - it("discovers default Matrix accounts backed only by global env vars", () => { - const cfg: OpenClawConfig = {}; - const env = { - MATRIX_HOMESERVER: "https://matrix.example.org", - MATRIX_ACCESS_TOKEN: "default-secret", - } satisfies NodeJS.ProcessEnv; - - expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default"]); - expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default"); - }); -}); diff --git a/src/infra/matrix-legacy-crypto.test.ts b/src/infra/matrix-legacy-crypto.test.ts deleted file mode 100644 index 6d413c690f1..00000000000 --- a/src/infra/matrix-legacy-crypto.test.ts +++ /dev/null @@ -1,440 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { withTempHome } from "../../test/helpers/temp-home.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { resolveMatrixAccountStorageRoot } from "./matrix-config-helpers.js"; -import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js"; -import { MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE } from "./matrix-plugin-helper.js"; -import { - MATRIX_DEFAULT_ACCESS_TOKEN, - MATRIX_DEFAULT_DEVICE_ID, - MATRIX_DEFAULT_USER_ID, - MATRIX_OPS_ACCESS_TOKEN, - MATRIX_OPS_ACCOUNT_ID, - MATRIX_OPS_DEVICE_ID, - MATRIX_OPS_USER_ID, - MATRIX_TEST_HOMESERVER, - matrixHelperEnv, - writeFile, - writeMatrixCredentials, - writeMatrixPluginFixture, -} from "./matrix.test-helpers.js"; - -vi.unmock("../version.js"); - -function createDefaultMatrixConfig(): OpenClawConfig { - return { - channels: { - matrix: { - homeserver: MATRIX_TEST_HOMESERVER, - userId: MATRIX_DEFAULT_USER_ID, - accessToken: MATRIX_DEFAULT_ACCESS_TOKEN, - }, - }, - }; -} - -function writeDefaultLegacyCryptoFixture(home: string) { - const stateDir = path.join(home, ".openclaw"); - const cfg = createDefaultMatrixConfig(); - const { rootDir } = resolveMatrixAccountStorageRoot({ - stateDir, - homeserver: MATRIX_TEST_HOMESERVER, - userId: MATRIX_DEFAULT_USER_ID, - accessToken: MATRIX_DEFAULT_ACCESS_TOKEN, - }); - writeFile( - path.join(rootDir, "crypto", "bot-sdk.json"), - JSON.stringify({ deviceId: MATRIX_DEFAULT_DEVICE_ID }), - ); - return { cfg, rootDir, stateDir }; -} - -function createOpsLegacyCryptoFixture(params: { - home: string; - cfg: OpenClawConfig; - accessToken?: string; - includeStoredCredentials?: boolean; -}) { - const stateDir = path.join(params.home, ".openclaw"); - writeMatrixPluginFixture(path.join(params.home, "bundled", "matrix")); - writeFile( - path.join(stateDir, "matrix", "crypto", "bot-sdk.json"), - JSON.stringify({ deviceId: MATRIX_OPS_DEVICE_ID }), - ); - if (params.includeStoredCredentials) { - writeMatrixCredentials(stateDir, { - accountId: MATRIX_OPS_ACCOUNT_ID, - accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN, - deviceId: MATRIX_OPS_DEVICE_ID, - }); - } - const { rootDir } = resolveMatrixAccountStorageRoot({ - stateDir, - homeserver: MATRIX_TEST_HOMESERVER, - userId: MATRIX_OPS_USER_ID, - accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN, - accountId: MATRIX_OPS_ACCOUNT_ID, - }); - return { rootDir, stateDir }; -} - -async function expectPreparedOpsLegacyMigration(params: { - cfg: OpenClawConfig; - env: NodeJS.ProcessEnv; - rootDir: string; - inspectLegacyStore: { - deviceId: string; - roomKeyCounts: { total: number; backedUp: number }; - backupVersion: string; - decryptionKeyBase64: string; - }; - expectAccountId?: boolean; -}) { - const detection = detectLegacyMatrixCrypto({ cfg: params.cfg, env: params.env }); - expect(detection.warnings).toEqual([]); - expect(detection.plans).toHaveLength(1); - expect(detection.plans[0]?.accountId).toBe("ops"); - - const result = await autoPrepareLegacyMatrixCrypto({ - cfg: params.cfg, - env: params.env, - deps: { - inspectLegacyStore: async () => params.inspectLegacyStore, - }, - }); - - expect(result.migrated).toBe(true); - expect(result.warnings).toEqual([]); - const recovery = JSON.parse( - fs.readFileSync(path.join(params.rootDir, "recovery-key.json"), "utf8"), - ) as { - privateKeyBase64: string; - }; - expect(recovery.privateKeyBase64).toBe(params.inspectLegacyStore.decryptionKeyBase64); - if (!params.expectAccountId) { - return; - } - const state = JSON.parse( - fs.readFileSync(path.join(params.rootDir, "legacy-crypto-migration.json"), "utf8"), - ) as { - accountId: string; - }; - expect(state.accountId).toBe("ops"); -} - -describe("matrix legacy encrypted-state migration", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("extracts a saved backup key into the new recovery-key path", async () => { - await withTempHome( - async (home) => { - writeMatrixPluginFixture(path.join(home, "bundled", "matrix")); - const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home); - - const detection = detectLegacyMatrixCrypto({ cfg, env: process.env }); - expect(detection.warnings).toEqual([]); - expect(detection.plans).toHaveLength(1); - - const inspectLegacyStore = vi.fn(async () => ({ - deviceId: MATRIX_DEFAULT_DEVICE_ID, - roomKeyCounts: { total: 12, backedUp: 12 }, - backupVersion: "1", - decryptionKeyBase64: "YWJjZA==", - })); - - const result = await autoPrepareLegacyMatrixCrypto({ - cfg, - env: process.env, - deps: { inspectLegacyStore }, - }); - - expect(result.migrated).toBe(true); - expect(result.warnings).toEqual([]); - expect(inspectLegacyStore).toHaveBeenCalledOnce(); - - const recovery = JSON.parse( - fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"), - ) as { - privateKeyBase64: string; - }; - expect(recovery.privateKeyBase64).toBe("YWJjZA=="); - - const state = JSON.parse( - fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"), - ) as { - restoreStatus: string; - decryptionKeyImported: boolean; - }; - expect(state.restoreStatus).toBe("pending"); - expect(state.decryptionKeyImported).toBe(true); - }, - { env: matrixHelperEnv }, - ); - }); - - it("skips inspector loading when no legacy Matrix plans exist", async () => { - await withTempHome( - async () => { - const matrixHelperModule = await import("./matrix-plugin-helper.js"); - const loadInspectorSpy = vi.spyOn(matrixHelperModule, "loadMatrixLegacyCryptoInspector"); - - const result = await autoPrepareLegacyMatrixCrypto({ - cfg: createDefaultMatrixConfig(), - env: process.env, - }); - - expect(result).toEqual({ - migrated: false, - changes: [], - warnings: [], - }); - expect(result.warnings).not.toContain(MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE); - expect(loadInspectorSpy).not.toHaveBeenCalled(); - }, - { - env: { - OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"), - }, - }, - ); - }); - - it("warns when legacy local-only room keys cannot be recovered automatically", async () => { - await withTempHome(async (home) => { - const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home); - - const result = await autoPrepareLegacyMatrixCrypto({ - cfg, - env: process.env, - deps: { - inspectLegacyStore: async () => ({ - deviceId: MATRIX_DEFAULT_DEVICE_ID, - roomKeyCounts: { total: 15, backedUp: 10 }, - backupVersion: null, - decryptionKeyBase64: null, - }), - }, - }); - - expect(result.migrated).toBe(true); - expect(result.warnings).toContain( - 'Legacy Matrix encrypted state for account "default" contains 5 room key(s) that were never backed up. Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.', - ); - expect(result.warnings).toContain( - 'Legacy Matrix encrypted state for account "default" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.', - ); - const state = JSON.parse( - fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"), - ) as { - restoreStatus: string; - }; - expect(state.restoreStatus).toBe("manual-action-required"); - }); - }); - - it("warns instead of throwing when recovery-key persistence fails", async () => { - await withTempHome(async (home) => { - const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home); - - const result = await autoPrepareLegacyMatrixCrypto({ - cfg, - env: process.env, - deps: { - inspectLegacyStore: async () => ({ - deviceId: MATRIX_DEFAULT_DEVICE_ID, - roomKeyCounts: { total: 12, backedUp: 12 }, - backupVersion: "1", - decryptionKeyBase64: "YWJjZA==", - }), - writeJsonFileAtomically: async (filePath) => { - if (filePath.endsWith("recovery-key.json")) { - throw new Error("disk full"); - } - writeFile(filePath, JSON.stringify({ ok: true }, null, 2)); - }, - }, - }); - - expect(result.migrated).toBe(false); - expect(result.warnings).toContain( - `Failed writing Matrix recovery key for account "default" (${path.join(rootDir, "recovery-key.json")}): Error: disk full`, - ); - expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(false); - expect(fs.existsSync(path.join(rootDir, "legacy-crypto-migration.json"))).toBe(false); - }); - }); - - it("prepares flat legacy crypto for the only configured non-default Matrix account", async () => { - await withTempHome( - async (home) => { - const cfg: OpenClawConfig = { - channels: { - matrix: { - accounts: { - ops: { - homeserver: MATRIX_TEST_HOMESERVER, - userId: MATRIX_OPS_USER_ID, - }, - }, - }, - }, - }; - const { rootDir } = createOpsLegacyCryptoFixture({ - home, - cfg, - includeStoredCredentials: true, - }); - - await expectPreparedOpsLegacyMigration({ - cfg, - env: process.env, - rootDir, - inspectLegacyStore: { - deviceId: MATRIX_OPS_DEVICE_ID, - roomKeyCounts: { total: 6, backedUp: 6 }, - backupVersion: "21868", - decryptionKeyBase64: "YWJjZA==", - }, - expectAccountId: true, - }); - }, - { env: matrixHelperEnv }, - ); - }); - - it("uses scoped Matrix env vars when resolving flat legacy crypto migration", async () => { - await withTempHome( - async (home) => { - const cfg: OpenClawConfig = { - channels: { - matrix: { - accounts: { - ops: {}, - }, - }, - }, - }; - const { rootDir } = createOpsLegacyCryptoFixture({ - home, - cfg, - accessToken: "tok-ops-env", - }); - - await expectPreparedOpsLegacyMigration({ - cfg, - env: process.env, - rootDir, - inspectLegacyStore: { - deviceId: MATRIX_OPS_DEVICE_ID, - roomKeyCounts: { total: 4, backedUp: 4 }, - backupVersion: "9001", - decryptionKeyBase64: "YWJjZA==", - }, - }); - }, - { - env: { - ...matrixHelperEnv, - MATRIX_OPS_HOMESERVER: MATRIX_TEST_HOMESERVER, - MATRIX_OPS_USER_ID, - MATRIX_OPS_ACCESS_TOKEN: "tok-ops-env", - }, - }, - ); - }); - - it("requires channels.matrix.defaultAccount before preparing flat legacy crypto for one of multiple accounts", async () => { - await withTempHome(async (home) => { - const stateDir = path.join(home, ".openclaw"); - writeFile( - path.join(stateDir, "matrix", "crypto", "bot-sdk.json"), - JSON.stringify({ deviceId: MATRIX_OPS_DEVICE_ID }), - ); - - const cfg: OpenClawConfig = { - channels: { - matrix: { - accounts: { - ops: { - homeserver: MATRIX_TEST_HOMESERVER, - userId: MATRIX_OPS_USER_ID, - accessToken: MATRIX_OPS_ACCESS_TOKEN, - }, - alerts: { - homeserver: MATRIX_TEST_HOMESERVER, - userId: "@alerts-bot:example.org", - accessToken: "tok-alerts", - }, - }, - }, - }, - }; - - const detection = detectLegacyMatrixCrypto({ cfg, env: process.env }); - expect(detection.plans).toHaveLength(0); - expect(detection.warnings).toContain( - "Legacy Matrix encrypted state detected at " + - path.join(stateDir, "matrix", "crypto") + - ', but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. Set "channels.matrix.defaultAccount" to the intended target account before rerunning "openclaw doctor --fix" or restarting the gateway.', - ); - }); - }); - - it("warns instead of throwing when a legacy crypto path is a file", async () => { - await withTempHome(async (home) => { - const stateDir = path.join(home, ".openclaw"); - writeFile(path.join(stateDir, "matrix", "crypto"), "not-a-directory"); - - const cfg: OpenClawConfig = { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - }, - }, - }; - - const detection = detectLegacyMatrixCrypto({ cfg, env: process.env }); - expect(detection.plans).toHaveLength(0); - expect(detection.warnings).toContain( - `Legacy Matrix encrypted state path exists but is not a directory: ${path.join(stateDir, "matrix", "crypto")}. OpenClaw skipped automatic crypto migration for that path.`, - ); - }); - }); - - it("reports a missing matrix plugin helper once when encrypted-state migration cannot run", async () => { - await withTempHome( - async (home) => { - const stateDir = path.join(home, ".openclaw"); - writeFile( - path.join(stateDir, "matrix", "crypto", "bot-sdk.json"), - JSON.stringify({ deviceId: MATRIX_DEFAULT_DEVICE_ID }), - ); - - const cfg = createDefaultMatrixConfig(); - - const result = await autoPrepareLegacyMatrixCrypto({ - cfg, - env: process.env, - }); - - expect(result.migrated).toBe(false); - expect( - result.warnings.filter( - (warning) => warning === MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE, - ), - ).toHaveLength(1); - }, - { - env: { - OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"), - }, - }, - ); - }); -}); diff --git a/src/infra/matrix-legacy-crypto.ts b/src/infra/matrix-legacy-crypto.ts deleted file mode 100644 index 1fdefdc39fc..00000000000 --- a/src/infra/matrix-legacy-crypto.ts +++ /dev/null @@ -1,513 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import type { OpenClawConfig } from "../config/config.js"; -import { resolveStateDir } from "../config/paths.js"; -import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "../plugin-sdk/json-store.js"; -import { - resolveConfiguredMatrixAccountIds, - resolveMatrixLegacyFlatStoragePaths, -} from "./matrix-config-helpers.js"; -import { - resolveLegacyMatrixFlatStoreTarget, - resolveMatrixMigrationAccountTarget, -} from "./matrix-migration-config.js"; -import { - MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE, - isMatrixLegacyCryptoInspectorAvailable, - loadMatrixLegacyCryptoInspector, - type MatrixLegacyCryptoInspector, -} from "./matrix-plugin-helper.js"; - -type MatrixLegacyCryptoCounts = { - total: number; - backedUp: number; -}; - -type MatrixLegacyCryptoSummary = { - deviceId: string | null; - roomKeyCounts: MatrixLegacyCryptoCounts | null; - backupVersion: string | null; - decryptionKeyBase64: string | null; -}; - -type MatrixLegacyCryptoMigrationState = { - version: 1; - source: "matrix-bot-sdk-rust"; - accountId: string; - deviceId: string | null; - roomKeyCounts: MatrixLegacyCryptoCounts | null; - backupVersion: string | null; - decryptionKeyImported: boolean; - restoreStatus: "pending" | "completed" | "manual-action-required"; - detectedAt: string; - restoredAt?: string; - importedCount?: number; - totalCount?: number; - lastError?: string | null; -}; - -type MatrixLegacyCryptoPlan = { - accountId: string; - rootDir: string; - recoveryKeyPath: string; - statePath: string; - legacyCryptoPath: string; - homeserver: string; - userId: string; - accessToken: string; - deviceId: string | null; -}; - -type MatrixLegacyCryptoDetection = { - plans: MatrixLegacyCryptoPlan[]; - warnings: string[]; -}; - -type MatrixLegacyCryptoPreparationResult = { - migrated: boolean; - changes: string[]; - warnings: string[]; -}; - -type MatrixLegacyCryptoPrepareDeps = { - inspectLegacyStore: MatrixLegacyCryptoInspector; - writeJsonFileAtomically: typeof writeJsonFileAtomicallyImpl; -}; - -type MatrixLegacyBotSdkMetadata = { - deviceId: string | null; -}; - -type MatrixStoredRecoveryKey = { - version: 1; - createdAt: string; - keyId?: string | null; - encodedPrivateKey?: string; - privateKeyBase64: string; - keyInfo?: { - passphrase?: unknown; - name?: string; - }; -}; - -function detectLegacyBotSdkCryptoStore(cryptoRootDir: string): { - detected: boolean; - warning?: string; -} { - try { - const stat = fs.statSync(cryptoRootDir); - if (!stat.isDirectory()) { - return { - detected: false, - warning: - `Legacy Matrix encrypted state path exists but is not a directory: ${cryptoRootDir}. ` + - "OpenClaw skipped automatic crypto migration for that path.", - }; - } - } catch (err) { - return { - detected: false, - warning: - `Failed reading legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` + - "OpenClaw skipped automatic crypto migration for that path.", - }; - } - - try { - return { - detected: - fs.existsSync(path.join(cryptoRootDir, "bot-sdk.json")) || - fs.existsSync(path.join(cryptoRootDir, "matrix-sdk-crypto.sqlite3")) || - fs - .readdirSync(cryptoRootDir, { withFileTypes: true }) - .some( - (entry) => - entry.isDirectory() && - fs.existsSync(path.join(cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")), - ), - }; - } catch (err) { - return { - detected: false, - warning: - `Failed scanning legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` + - "OpenClaw skipped automatic crypto migration for that path.", - }; - } -} - -function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] { - return resolveConfiguredMatrixAccountIds(cfg); -} - -function resolveLegacyMatrixFlatStorePlan(params: { - cfg: OpenClawConfig; - env: NodeJS.ProcessEnv; -}): MatrixLegacyCryptoPlan | { warning: string } | null { - const legacy = resolveMatrixLegacyFlatStoragePaths(resolveStateDir(params.env, os.homedir)); - if (!fs.existsSync(legacy.cryptoPath)) { - return null; - } - const legacyStore = detectLegacyBotSdkCryptoStore(legacy.cryptoPath); - if (legacyStore.warning) { - return { warning: legacyStore.warning }; - } - if (!legacyStore.detected) { - return null; - } - - const target = resolveLegacyMatrixFlatStoreTarget({ - cfg: params.cfg, - env: params.env, - detectedPath: legacy.cryptoPath, - detectedKind: "encrypted state", - }); - if ("warning" in target) { - return target; - } - - const metadata = loadLegacyBotSdkMetadata(legacy.cryptoPath); - return { - accountId: target.accountId, - rootDir: target.rootDir, - recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"), - statePath: path.join(target.rootDir, "legacy-crypto-migration.json"), - legacyCryptoPath: legacy.cryptoPath, - homeserver: target.homeserver, - userId: target.userId, - accessToken: target.accessToken, - deviceId: metadata.deviceId ?? target.storedDeviceId, - }; -} - -function loadLegacyBotSdkMetadata(cryptoRootDir: string): MatrixLegacyBotSdkMetadata { - const metadataPath = path.join(cryptoRootDir, "bot-sdk.json"); - const fallback: MatrixLegacyBotSdkMetadata = { deviceId: null }; - try { - if (!fs.existsSync(metadataPath)) { - return fallback; - } - const parsed = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as { - deviceId?: unknown; - }; - return { - deviceId: - typeof parsed.deviceId === "string" && parsed.deviceId.trim() ? parsed.deviceId : null, - }; - } catch { - return fallback; - } -} - -function resolveMatrixLegacyCryptoPlans(params: { - cfg: OpenClawConfig; - env: NodeJS.ProcessEnv; -}): MatrixLegacyCryptoDetection { - const warnings: string[] = []; - const plans: MatrixLegacyCryptoPlan[] = []; - - const flatPlan = resolveLegacyMatrixFlatStorePlan(params); - if (flatPlan) { - if ("warning" in flatPlan) { - warnings.push(flatPlan.warning); - } else { - plans.push(flatPlan); - } - } - - for (const accountId of resolveMatrixAccountIds(params.cfg)) { - const target = resolveMatrixMigrationAccountTarget({ - cfg: params.cfg, - env: params.env, - accountId, - }); - if (!target) { - continue; - } - const legacyCryptoPath = path.join(target.rootDir, "crypto"); - if (!fs.existsSync(legacyCryptoPath)) { - continue; - } - const detectedStore = detectLegacyBotSdkCryptoStore(legacyCryptoPath); - if (detectedStore.warning) { - warnings.push(detectedStore.warning); - continue; - } - if (!detectedStore.detected) { - continue; - } - if ( - plans.some( - (plan) => - plan.accountId === accountId && - path.resolve(plan.legacyCryptoPath) === path.resolve(legacyCryptoPath), - ) - ) { - continue; - } - const metadata = loadLegacyBotSdkMetadata(legacyCryptoPath); - plans.push({ - accountId: target.accountId, - rootDir: target.rootDir, - recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"), - statePath: path.join(target.rootDir, "legacy-crypto-migration.json"), - legacyCryptoPath, - homeserver: target.homeserver, - userId: target.userId, - accessToken: target.accessToken, - deviceId: metadata.deviceId ?? target.storedDeviceId, - }); - } - - return { plans, warnings }; -} - -function loadStoredRecoveryKey(filePath: string): MatrixStoredRecoveryKey | null { - try { - if (!fs.existsSync(filePath)) { - return null; - } - return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixStoredRecoveryKey; - } catch { - return null; - } -} - -function loadLegacyCryptoMigrationState(filePath: string): MatrixLegacyCryptoMigrationState | null { - try { - if (!fs.existsSync(filePath)) { - return null; - } - return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixLegacyCryptoMigrationState; - } catch { - return null; - } -} - -async function persistLegacyMigrationState(params: { - filePath: string; - state: MatrixLegacyCryptoMigrationState; - writeJsonFileAtomically: typeof writeJsonFileAtomicallyImpl; -}): Promise { - await params.writeJsonFileAtomically(params.filePath, params.state); -} - -export function detectLegacyMatrixCrypto(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; -}): MatrixLegacyCryptoDetection { - const detection = resolveMatrixLegacyCryptoPlans({ - cfg: params.cfg, - env: params.env ?? process.env, - }); - if ( - detection.plans.length > 0 && - !isMatrixLegacyCryptoInspectorAvailable({ - cfg: params.cfg, - env: params.env, - }) - ) { - return { - plans: detection.plans, - warnings: [...detection.warnings, MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE], - }; - } - return detection; -} - -export async function autoPrepareLegacyMatrixCrypto(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - log?: { info?: (message: string) => void; warn?: (message: string) => void }; - deps?: Partial; -}): Promise { - const env = params.env ?? process.env; - const detection = params.deps?.inspectLegacyStore - ? resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env }) - : detectLegacyMatrixCrypto({ cfg: params.cfg, env }); - const warnings = [...detection.warnings]; - const changes: string[] = []; - const writeJsonFileAtomically = - params.deps?.writeJsonFileAtomically ?? writeJsonFileAtomicallyImpl; - if (detection.plans.length === 0) { - if (warnings.length > 0) { - params.log?.warn?.( - `matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`, - ); - } - return { - migrated: false, - changes, - warnings, - }; - } - - let inspectLegacyStore = params.deps?.inspectLegacyStore; - if (!inspectLegacyStore) { - try { - inspectLegacyStore = await loadMatrixLegacyCryptoInspector({ - cfg: params.cfg, - env, - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (!warnings.includes(message)) { - warnings.push(message); - } - if (warnings.length > 0) { - params.log?.warn?.( - `matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`, - ); - } - return { - migrated: false, - changes, - warnings, - }; - } - } - if (!inspectLegacyStore) { - return { - migrated: false, - changes, - warnings, - }; - } - - for (const plan of detection.plans) { - const existingState = loadLegacyCryptoMigrationState(plan.statePath); - if (existingState?.version === 1) { - continue; - } - if (!plan.deviceId) { - warnings.push( - `Legacy Matrix encrypted state detected at ${plan.legacyCryptoPath}, but no device ID was found for account "${plan.accountId}". ` + - `OpenClaw will continue, but old encrypted history cannot be recovered automatically.`, - ); - continue; - } - - let summary: MatrixLegacyCryptoSummary; - try { - summary = await inspectLegacyStore({ - cryptoRootDir: plan.legacyCryptoPath, - userId: plan.userId, - deviceId: plan.deviceId, - log: params.log?.info, - }); - } catch (err) { - warnings.push( - `Failed inspecting legacy Matrix encrypted state for account "${plan.accountId}" (${plan.legacyCryptoPath}): ${String(err)}`, - ); - continue; - } - - let decryptionKeyImported = false; - if (summary.decryptionKeyBase64) { - const existingRecoveryKey = loadStoredRecoveryKey(plan.recoveryKeyPath); - if ( - existingRecoveryKey?.privateKeyBase64 && - existingRecoveryKey.privateKeyBase64 !== summary.decryptionKeyBase64 - ) { - warnings.push( - `Legacy Matrix backup key was found for account "${plan.accountId}", but ${plan.recoveryKeyPath} already contains a different recovery key. Leaving the existing file unchanged.`, - ); - } else if (!existingRecoveryKey?.privateKeyBase64) { - const payload: MatrixStoredRecoveryKey = { - version: 1, - createdAt: new Date().toISOString(), - keyId: null, - privateKeyBase64: summary.decryptionKeyBase64, - }; - try { - await writeJsonFileAtomically(plan.recoveryKeyPath, payload); - changes.push( - `Imported Matrix legacy backup key for account "${plan.accountId}": ${plan.recoveryKeyPath}`, - ); - decryptionKeyImported = true; - } catch (err) { - warnings.push( - `Failed writing Matrix recovery key for account "${plan.accountId}" (${plan.recoveryKeyPath}): ${String(err)}`, - ); - } - } else { - decryptionKeyImported = true; - } - } - - const localOnlyKeys = - summary.roomKeyCounts && summary.roomKeyCounts.total > summary.roomKeyCounts.backedUp - ? summary.roomKeyCounts.total - summary.roomKeyCounts.backedUp - : 0; - if (localOnlyKeys > 0) { - warnings.push( - `Legacy Matrix encrypted state for account "${plan.accountId}" contains ${localOnlyKeys} room key(s) that were never backed up. ` + - "Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.", - ); - } - if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.backedUp ?? 0) > 0) { - warnings.push( - `Legacy Matrix encrypted state for account "${plan.accountId}" has backed-up room keys, but no local backup decryption key was found. ` + - `Ask the operator to run "openclaw matrix verify backup restore --recovery-key " after upgrade if they have the recovery key.`, - ); - } - if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.total ?? 0) > 0) { - warnings.push( - `Legacy Matrix encrypted state for account "${plan.accountId}" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.`, - ); - } - // If recovery-key persistence failed, leave the migration state absent so the next startup can retry. - if ( - summary.decryptionKeyBase64 && - !decryptionKeyImported && - !loadStoredRecoveryKey(plan.recoveryKeyPath) - ) { - continue; - } - - const state: MatrixLegacyCryptoMigrationState = { - version: 1, - source: "matrix-bot-sdk-rust", - accountId: plan.accountId, - deviceId: summary.deviceId, - roomKeyCounts: summary.roomKeyCounts, - backupVersion: summary.backupVersion, - decryptionKeyImported, - restoreStatus: decryptionKeyImported ? "pending" : "manual-action-required", - detectedAt: new Date().toISOString(), - lastError: null, - }; - try { - await persistLegacyMigrationState({ - filePath: plan.statePath, - state, - writeJsonFileAtomically, - }); - changes.push( - `Prepared Matrix legacy encrypted-state migration for account "${plan.accountId}": ${plan.statePath}`, - ); - } catch (err) { - warnings.push( - `Failed writing Matrix legacy encrypted-state migration record for account "${plan.accountId}" (${plan.statePath}): ${String(err)}`, - ); - } - } - - if (changes.length > 0) { - params.log?.info?.( - `matrix: prepared encrypted-state upgrade.\n${changes.map((entry) => `- ${entry}`).join("\n")}`, - ); - } - if (warnings.length > 0) { - params.log?.warn?.( - `matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`, - ); - } - - return { - migrated: changes.length > 0, - changes, - warnings, - }; -} diff --git a/src/infra/matrix-legacy-state.test.ts b/src/infra/matrix-legacy-state.test.ts deleted file mode 100644 index f2b921ad626..00000000000 --- a/src/infra/matrix-legacy-state.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { withTempHome } from "../../test/helpers/temp-home.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./matrix-legacy-state.js"; - -function writeFile(filePath: string, value: string) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, value, "utf-8"); -} - -describe("matrix legacy state migration", () => { - it("migrates the flat legacy Matrix store into account-scoped storage", async () => { - await withTempHome(async (home) => { - const stateDir = path.join(home, ".openclaw"); - writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); - writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto"); - - const cfg: OpenClawConfig = { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - }, - }, - }; - - const detection = detectLegacyMatrixState({ cfg, env: process.env }); - expect(detection && "warning" in detection).toBe(false); - if (!detection || "warning" in detection) { - throw new Error("expected a migratable Matrix legacy state plan"); - } - - const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env }); - expect(result.migrated).toBe(true); - expect(result.warnings).toEqual([]); - expect(fs.existsSync(path.join(stateDir, "matrix", "bot-storage.json"))).toBe(false); - expect(fs.existsSync(path.join(stateDir, "matrix", "crypto"))).toBe(false); - expect(fs.existsSync(detection.targetStoragePath)).toBe(true); - expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true); - }); - }); - - it("uses cached Matrix credentials when the config no longer stores an access token", async () => { - await withTempHome(async (home) => { - const stateDir = path.join(home, ".openclaw"); - writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); - writeFile( - path.join(stateDir, "credentials", "matrix", "credentials.json"), - JSON.stringify( - { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-from-cache", - }, - null, - 2, - ), - ); - - const cfg: OpenClawConfig = { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - password: "secret", // pragma: allowlist secret - }, - }, - }; - - const detection = detectLegacyMatrixState({ cfg, env: process.env }); - expect(detection && "warning" in detection).toBe(false); - if (!detection || "warning" in detection) { - throw new Error("expected cached credentials to make Matrix migration resolvable"); - } - - expect(detection.targetRootDir).toContain("matrix.example.org__bot_example.org"); - - const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env }); - expect(result.migrated).toBe(true); - expect(fs.existsSync(detection.targetStoragePath)).toBe(true); - }); - }); - - it("records which account receives a flat legacy store when multiple Matrix accounts exist", async () => { - await withTempHome(async (home) => { - const stateDir = path.join(home, ".openclaw"); - writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); - - const cfg: OpenClawConfig = { - channels: { - matrix: { - defaultAccount: "work", - accounts: { - work: { - homeserver: "https://matrix.example.org", - userId: "@work-bot:example.org", - accessToken: "tok-work", - }, - alerts: { - homeserver: "https://matrix.example.org", - userId: "@alerts-bot:example.org", - accessToken: "tok-alerts", - }, - }, - }, - }, - }; - - const detection = detectLegacyMatrixState({ cfg, env: process.env }); - expect(detection && "warning" in detection).toBe(false); - if (!detection || "warning" in detection) { - throw new Error("expected a migratable Matrix legacy state plan"); - } - - expect(detection.accountId).toBe("work"); - expect(detection.selectionNote).toContain('account "work"'); - }); - }); - - it("requires channels.matrix.defaultAccount before migrating a flat store into one of multiple accounts", async () => { - await withTempHome(async (home) => { - const stateDir = path.join(home, ".openclaw"); - writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); - - const cfg: OpenClawConfig = { - channels: { - matrix: { - accounts: { - work: { - homeserver: "https://matrix.example.org", - userId: "@work-bot:example.org", - accessToken: "tok-work", - }, - alerts: { - homeserver: "https://matrix.example.org", - userId: "@alerts-bot:example.org", - accessToken: "tok-alerts", - }, - }, - }, - }, - }; - - const detection = detectLegacyMatrixState({ cfg, env: process.env }); - expect(detection && "warning" in detection).toBe(true); - if (!detection || !("warning" in detection)) { - throw new Error("expected a warning-only Matrix legacy state result"); - } - expect(detection.warning).toContain("channels.matrix.defaultAccount is not set"); - }); - }); - - it("uses scoped Matrix env vars when resolving a flat-store migration target", async () => { - await withTempHome( - async (home) => { - const stateDir = path.join(home, ".openclaw"); - writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); - writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto"); - - const cfg: OpenClawConfig = { - channels: { - matrix: { - accounts: { - ops: {}, - }, - }, - }, - }; - - const detection = detectLegacyMatrixState({ cfg, env: process.env }); - expect(detection && "warning" in detection).toBe(false); - if (!detection || "warning" in detection) { - throw new Error("expected scoped Matrix env vars to resolve a legacy state plan"); - } - - expect(detection.accountId).toBe("ops"); - expect(detection.targetRootDir).toContain("matrix.example.org__ops-bot_example.org"); - - const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env }); - expect(result.migrated).toBe(true); - expect(result.warnings).toEqual([]); - expect(fs.existsSync(detection.targetStoragePath)).toBe(true); - expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true); - }, - { - env: { - MATRIX_OPS_HOMESERVER: "https://matrix.example.org", - MATRIX_OPS_USER_ID: "@ops-bot:example.org", - MATRIX_OPS_ACCESS_TOKEN: "tok-ops-env", - }, - }, - ); - }); - - it("migrates flat legacy Matrix state into the only configured non-default account", async () => { - await withTempHome(async (home) => { - const stateDir = path.join(home, ".openclaw"); - writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); - writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto"); - writeFile( - path.join(stateDir, "credentials", "matrix", "credentials-ops.json"), - JSON.stringify( - { - homeserver: "https://matrix.example.org", - userId: "@ops-bot:example.org", - accessToken: "tok-ops", - }, - null, - 2, - ), - ); - - const cfg: OpenClawConfig = { - channels: { - matrix: { - accounts: { - ops: { - homeserver: "https://matrix.example.org", - userId: "@ops-bot:example.org", - }, - }, - }, - }, - }; - - const detection = detectLegacyMatrixState({ cfg, env: process.env }); - expect(detection && "warning" in detection).toBe(false); - if (!detection || "warning" in detection) { - throw new Error("expected a migratable Matrix legacy state plan"); - } - - expect(detection.accountId).toBe("ops"); - - const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env }); - expect(result.migrated).toBe(true); - expect(result.warnings).toEqual([]); - expect(fs.existsSync(detection.targetStoragePath)).toBe(true); - expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true); - }); - }); -}); diff --git a/src/infra/matrix-legacy-state.ts b/src/infra/matrix-legacy-state.ts deleted file mode 100644 index d40d547654f..00000000000 --- a/src/infra/matrix-legacy-state.ts +++ /dev/null @@ -1,156 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import type { OpenClawConfig } from "../config/config.js"; -import { resolveStateDir } from "../config/paths.js"; -import { resolveMatrixLegacyFlatStoragePaths } from "./matrix-config-helpers.js"; -import { resolveLegacyMatrixFlatStoreTarget } from "./matrix-migration-config.js"; - -export type MatrixLegacyStateMigrationResult = { - migrated: boolean; - changes: string[]; - warnings: string[]; -}; - -type MatrixLegacyStatePlan = { - accountId: string; - legacyStoragePath: string; - legacyCryptoPath: string; - targetRootDir: string; - targetStoragePath: string; - targetCryptoPath: string; - selectionNote?: string; -}; - -function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): { - rootDir: string; - storagePath: string; - cryptoPath: string; -} { - const stateDir = resolveStateDir(env, os.homedir); - return resolveMatrixLegacyFlatStoragePaths(stateDir); -} - -function resolveMatrixMigrationPlan(params: { - cfg: OpenClawConfig; - env: NodeJS.ProcessEnv; -}): MatrixLegacyStatePlan | { warning: string } | null { - const legacy = resolveLegacyMatrixPaths(params.env); - if (!fs.existsSync(legacy.storagePath) && !fs.existsSync(legacy.cryptoPath)) { - return null; - } - - const target = resolveLegacyMatrixFlatStoreTarget({ - cfg: params.cfg, - env: params.env, - detectedPath: legacy.rootDir, - detectedKind: "state", - }); - if ("warning" in target) { - return target; - } - - return { - accountId: target.accountId, - legacyStoragePath: legacy.storagePath, - legacyCryptoPath: legacy.cryptoPath, - targetRootDir: target.rootDir, - targetStoragePath: path.join(target.rootDir, "bot-storage.json"), - targetCryptoPath: path.join(target.rootDir, "crypto"), - selectionNote: target.selectionNote, - }; -} - -export function detectLegacyMatrixState(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; -}): MatrixLegacyStatePlan | { warning: string } | null { - return resolveMatrixMigrationPlan({ - cfg: params.cfg, - env: params.env ?? process.env, - }); -} - -function moveLegacyPath(params: { - sourcePath: string; - targetPath: string; - label: string; - changes: string[]; - warnings: string[]; -}): void { - if (!fs.existsSync(params.sourcePath)) { - return; - } - if (fs.existsSync(params.targetPath)) { - params.warnings.push( - `Matrix legacy ${params.label} not migrated because the target already exists (${params.targetPath}).`, - ); - return; - } - try { - fs.mkdirSync(path.dirname(params.targetPath), { recursive: true }); - fs.renameSync(params.sourcePath, params.targetPath); - params.changes.push( - `Migrated Matrix legacy ${params.label}: ${params.sourcePath} -> ${params.targetPath}`, - ); - } catch (err) { - params.warnings.push( - `Failed migrating Matrix legacy ${params.label} (${params.sourcePath} -> ${params.targetPath}): ${String(err)}`, - ); - } -} - -export async function autoMigrateLegacyMatrixState(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - log?: { info?: (message: string) => void; warn?: (message: string) => void }; -}): Promise { - const env = params.env ?? process.env; - const detection = detectLegacyMatrixState({ cfg: params.cfg, env }); - if (!detection) { - return { migrated: false, changes: [], warnings: [] }; - } - if ("warning" in detection) { - params.log?.warn?.(`matrix: ${detection.warning}`); - return { migrated: false, changes: [], warnings: [detection.warning] }; - } - - const changes: string[] = []; - const warnings: string[] = []; - moveLegacyPath({ - sourcePath: detection.legacyStoragePath, - targetPath: detection.targetStoragePath, - label: "sync store", - changes, - warnings, - }); - moveLegacyPath({ - sourcePath: detection.legacyCryptoPath, - targetPath: detection.targetCryptoPath, - label: "crypto store", - changes, - warnings, - }); - - if (changes.length > 0) { - const details = [ - ...changes.map((entry) => `- ${entry}`), - ...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []), - "- No user action required.", - ]; - params.log?.info?.( - `matrix: plugin upgraded in place for account "${detection.accountId}".\n${details.join("\n")}`, - ); - } - if (warnings.length > 0) { - params.log?.warn?.( - `matrix: legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`, - ); - } - - return { - migrated: changes.length > 0, - changes, - warnings, - }; -} diff --git a/src/infra/matrix-migration-config.ts b/src/infra/matrix-migration-config.ts deleted file mode 100644 index 7eb24e80452..00000000000 --- a/src/infra/matrix-migration-config.ts +++ /dev/null @@ -1,327 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import type { OpenClawConfig } from "../config/config.js"; -import { resolveStateDir } from "../config/paths.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -import { - findMatrixAccountEntry, - getMatrixScopedEnvVarNames, - requiresExplicitMatrixDefaultAccount, - resolveConfiguredMatrixAccountIds, - resolveMatrixAccountStorageRoot, - resolveMatrixChannelConfig, - resolveMatrixCredentialsPath, - resolveMatrixDefaultOrOnlyAccountId, -} from "./matrix-config-helpers.js"; - -export type MatrixStoredCredentials = { - homeserver: string; - userId: string; - accessToken: string; - deviceId?: string; -}; - -export type MatrixMigrationAccountTarget = { - accountId: string; - homeserver: string; - userId: string; - accessToken: string; - rootDir: string; - storedDeviceId: string | null; -}; - -export type MatrixLegacyFlatStoreTarget = MatrixMigrationAccountTarget & { - selectionNote?: string; -}; - -type MatrixLegacyFlatStoreKind = "state" | "encrypted state"; - -type MatrixResolvedStringField = - | "homeserver" - | "userId" - | "accessToken" - | "password" - | "deviceId" - | "deviceName"; - -type MatrixResolvedStringValues = Record; - -type MatrixStringSourceMap = Partial>; - -const MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS = new Set([ - "userId", - "accessToken", - "password", - "deviceId", -]); - -function clean(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - -function resolveMatrixStringSourceValue(value: string | undefined): string { - return typeof value === "string" ? value : ""; -} - -function shouldAllowBaseAuthFallback(accountId: string, field: MatrixResolvedStringField): boolean { - return ( - normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID || - !MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS.has(field) - ); -} - -function resolveMatrixAccountStringValues(params: { - accountId: string; - account?: MatrixStringSourceMap; - scopedEnv?: MatrixStringSourceMap; - channel?: MatrixStringSourceMap; - globalEnv?: MatrixStringSourceMap; -}): MatrixResolvedStringValues { - const fields: MatrixResolvedStringField[] = [ - "homeserver", - "userId", - "accessToken", - "password", - "deviceId", - "deviceName", - ]; - const resolved = {} as MatrixResolvedStringValues; - - for (const field of fields) { - resolved[field] = - resolveMatrixStringSourceValue(params.account?.[field]) || - resolveMatrixStringSourceValue(params.scopedEnv?.[field]) || - (shouldAllowBaseAuthFallback(params.accountId, field) - ? resolveMatrixStringSourceValue(params.channel?.[field]) || - resolveMatrixStringSourceValue(params.globalEnv?.[field]) - : ""); - } - - return resolved; -} - -function resolveScopedMatrixEnvConfig( - accountId: string, - env: NodeJS.ProcessEnv, -): { - homeserver: string; - userId: string; - accessToken: string; -} { - const keys = getMatrixScopedEnvVarNames(accountId); - return { - homeserver: clean(env[keys.homeserver]), - userId: clean(env[keys.userId]), - accessToken: clean(env[keys.accessToken]), - }; -} - -function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): { - homeserver: string; - userId: string; - accessToken: string; -} { - return { - homeserver: clean(env.MATRIX_HOMESERVER), - userId: clean(env.MATRIX_USER_ID), - accessToken: clean(env.MATRIX_ACCESS_TOKEN), - }; -} - -function resolveMatrixAccountConfigEntry( - cfg: OpenClawConfig, - accountId: string, -): Record | null { - return findMatrixAccountEntry(cfg, accountId); -} - -function resolveMatrixFlatStoreSelectionNote( - cfg: OpenClawConfig, - accountId: string, -): string | undefined { - if (resolveConfiguredMatrixAccountIds(cfg).length <= 1) { - return undefined; - } - return ( - `Legacy Matrix flat store uses one shared on-disk state, so it will be migrated into ` + - `account "${accountId}".` - ); -} - -export function resolveMatrixMigrationConfigFields(params: { - cfg: OpenClawConfig; - env: NodeJS.ProcessEnv; - accountId: string; -}): { - homeserver: string; - userId: string; - accessToken: string; -} { - const channel = resolveMatrixChannelConfig(params.cfg); - const account = resolveMatrixAccountConfigEntry(params.cfg, params.accountId); - const scopedEnv = resolveScopedMatrixEnvConfig(params.accountId, params.env); - const globalEnv = resolveGlobalMatrixEnvConfig(params.env); - const normalizedAccountId = normalizeAccountId(params.accountId); - const resolvedStrings = resolveMatrixAccountStringValues({ - accountId: normalizedAccountId, - account: { - homeserver: clean(account?.homeserver), - userId: clean(account?.userId), - accessToken: clean(account?.accessToken), - }, - scopedEnv, - channel: { - homeserver: clean(channel?.homeserver), - userId: clean(channel?.userId), - accessToken: clean(channel?.accessToken), - }, - globalEnv, - }); - - return { - homeserver: resolvedStrings.homeserver, - userId: resolvedStrings.userId, - accessToken: resolvedStrings.accessToken, - }; -} - -export function loadStoredMatrixCredentials( - env: NodeJS.ProcessEnv, - accountId: string, -): MatrixStoredCredentials | null { - const stateDir = resolveStateDir(env, os.homedir); - const credentialsPath = resolveMatrixCredentialsPath({ - stateDir, - accountId: normalizeAccountId(accountId), - }); - try { - if (!fs.existsSync(credentialsPath)) { - return null; - } - const parsed = JSON.parse( - fs.readFileSync(credentialsPath, "utf8"), - ) as Partial; - if ( - typeof parsed.homeserver !== "string" || - typeof parsed.userId !== "string" || - typeof parsed.accessToken !== "string" - ) { - return null; - } - return { - homeserver: parsed.homeserver, - userId: parsed.userId, - accessToken: parsed.accessToken, - deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : undefined, - }; - } catch { - return null; - } -} - -export function credentialsMatchResolvedIdentity( - stored: MatrixStoredCredentials | null, - identity: { - homeserver: string; - userId: string; - accessToken: string; - }, -): stored is MatrixStoredCredentials { - if (!stored || !identity.homeserver) { - return false; - } - if (!identity.userId) { - if (!identity.accessToken) { - return false; - } - return stored.homeserver === identity.homeserver && stored.accessToken === identity.accessToken; - } - return stored.homeserver === identity.homeserver && stored.userId === identity.userId; -} - -export function resolveMatrixMigrationAccountTarget(params: { - cfg: OpenClawConfig; - env: NodeJS.ProcessEnv; - accountId: string; -}): MatrixMigrationAccountTarget | null { - const stored = loadStoredMatrixCredentials(params.env, params.accountId); - const resolved = resolveMatrixMigrationConfigFields(params); - const matchingStored = credentialsMatchResolvedIdentity(stored, { - homeserver: resolved.homeserver, - userId: resolved.userId, - accessToken: resolved.accessToken, - }) - ? stored - : null; - const homeserver = resolved.homeserver; - const userId = resolved.userId || matchingStored?.userId || ""; - const accessToken = resolved.accessToken || matchingStored?.accessToken || ""; - if (!homeserver || !userId || !accessToken) { - return null; - } - - const stateDir = resolveStateDir(params.env, os.homedir); - const { rootDir } = resolveMatrixAccountStorageRoot({ - stateDir, - homeserver, - userId, - accessToken, - accountId: params.accountId, - }); - - return { - accountId: params.accountId, - homeserver, - userId, - accessToken, - rootDir, - storedDeviceId: matchingStored?.deviceId ?? null, - }; -} - -export function resolveLegacyMatrixFlatStoreTarget(params: { - cfg: OpenClawConfig; - env: NodeJS.ProcessEnv; - detectedPath: string; - detectedKind: MatrixLegacyFlatStoreKind; -}): MatrixLegacyFlatStoreTarget | { warning: string } { - const channel = resolveMatrixChannelConfig(params.cfg); - if (!channel) { - return { - warning: - `Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but channels.matrix is not configured yet. ` + - 'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.', - }; - } - if (requiresExplicitMatrixDefaultAccount(params.cfg)) { - return { - warning: - `Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. ` + - 'Set "channels.matrix.defaultAccount" to the intended target account before rerunning "openclaw doctor --fix" or restarting the gateway.', - }; - } - - const accountId = resolveMatrixDefaultOrOnlyAccountId(params.cfg); - const target = resolveMatrixMigrationAccountTarget({ - cfg: params.cfg, - env: params.env, - accountId, - }); - if (!target) { - const targetDescription = - params.detectedKind === "state" - ? "the new account-scoped target" - : "the account-scoped target"; - return { - warning: - `Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but ${targetDescription} could not be resolved yet ` + - `(need homeserver, userId, and access token for channels.matrix${accountId === DEFAULT_ACCOUNT_ID ? "" : `.accounts.${accountId}`}). ` + - 'Start the gateway once with a working Matrix login, or rerun "openclaw doctor --fix" after cached credentials are available.', - }; - } - - return { - ...target, - selectionNote: resolveMatrixFlatStoreSelectionNote(params.cfg, accountId), - }; -} diff --git a/src/infra/matrix-migration-snapshot.test.ts b/src/infra/matrix-migration-snapshot.test.ts deleted file mode 100644 index df774ca7672..00000000000 --- a/src/infra/matrix-migration-snapshot.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { withTempHome } from "../../test/helpers/temp-home.js"; -import { resolveMatrixAccountStorageRoot } from "./matrix-config-helpers.js"; -import { detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js"; -import { - hasActionableMatrixMigration, - maybeCreateMatrixMigrationSnapshot, - resolveMatrixMigrationSnapshotMarkerPath, - resolveMatrixMigrationSnapshotOutputDir, -} from "./matrix-migration-snapshot.js"; - -describe("matrix migration snapshots", () => { - it("creates a backup marker after writing a pre-migration snapshot", async () => { - await withTempHome(async (home) => { - fs.writeFileSync(path.join(home, ".openclaw", "openclaw.json"), "{}\n", "utf8"); - fs.writeFileSync(path.join(home, ".openclaw", "state.txt"), "state\n", "utf8"); - - const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" }); - - expect(result.created).toBe(true); - expect(result.markerPath).toBe(resolveMatrixMigrationSnapshotMarkerPath(process.env)); - expect( - result.archivePath.startsWith(resolveMatrixMigrationSnapshotOutputDir(process.env)), - ).toBe(true); - expect(fs.existsSync(result.archivePath)).toBe(true); - - const marker = JSON.parse( - fs.readFileSync(resolveMatrixMigrationSnapshotMarkerPath(process.env), "utf8"), - ) as { - archivePath: string; - trigger: string; - }; - expect(marker.archivePath).toBe(result.archivePath); - expect(marker.trigger).toBe("unit-test"); - }); - }); - - it("reuses an existing snapshot marker when the archive still exists", async () => { - await withTempHome(async (home) => { - const archivePath = path.join(home, "Backups", "openclaw-migrations", "snapshot.tar.gz"); - const markerPath = resolveMatrixMigrationSnapshotMarkerPath(process.env); - fs.mkdirSync(path.dirname(archivePath), { recursive: true }); - fs.mkdirSync(path.dirname(markerPath), { recursive: true }); - fs.writeFileSync(archivePath, "archive", "utf8"); - fs.writeFileSync( - markerPath, - JSON.stringify({ - version: 1, - createdAt: "2026-03-10T18:00:00.000Z", - archivePath, - trigger: "older-run", - includeWorkspace: false, - }), - "utf8", - ); - - const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" }); - - expect(result.created).toBe(false); - expect(result.archivePath).toBe(archivePath); - }); - }); - - it("recreates the snapshot when the marker exists but the archive is missing", async () => { - await withTempHome(async (home) => { - const markerPath = resolveMatrixMigrationSnapshotMarkerPath(process.env); - fs.mkdirSync(path.dirname(markerPath), { recursive: true }); - fs.mkdirSync(path.join(home, "Backups", "openclaw-migrations"), { recursive: true }); - fs.writeFileSync( - markerPath, - JSON.stringify({ - version: 1, - createdAt: "2026-03-10T18:00:00.000Z", - archivePath: path.join(home, "Backups", "openclaw-migrations", "missing.tar.gz"), - trigger: "older-run", - includeWorkspace: false, - }), - "utf8", - ); - - const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" }); - - expect(result.created).toBe(true); - expect(result.archivePath).not.toBe( - path.join(home, "Backups", "openclaw-migrations", "missing.tar.gz"), - ); - expect( - result.archivePath.startsWith(resolveMatrixMigrationSnapshotOutputDir(process.env)), - ).toBe(true); - expect(fs.existsSync(result.archivePath)).toBe(true); - const marker = JSON.parse(fs.readFileSync(markerPath, "utf8")) as { archivePath: string }; - expect(marker.archivePath).toBe(result.archivePath); - }); - }); - - it("surfaces backup creation failures without writing a marker", async () => { - await withTempHome(async (home) => { - const invalidOutputPath = path.join(home, "invalid-output"); - fs.writeFileSync(invalidOutputPath, "occupied\n", "utf8"); - - await expect( - maybeCreateMatrixMigrationSnapshot({ - trigger: "unit-test", - outputDir: invalidOutputPath, - }), - ).rejects.toThrow(); - expect(fs.existsSync(resolveMatrixMigrationSnapshotMarkerPath(process.env))).toBe(false); - }); - }); - - it("does not treat warning-only Matrix migration as actionable", async () => { - await withTempHome(async (home) => { - const stateDir = path.join(home, ".openclaw"); - fs.mkdirSync(path.join(stateDir, "matrix", "crypto"), { recursive: true }); - fs.writeFileSync( - path.join(stateDir, "matrix", "bot-storage.json"), - '{"legacy":true}', - "utf8", - ); - fs.writeFileSync( - path.join(stateDir, "openclaw.json"), - JSON.stringify({ - channels: { - matrix: { - homeserver: "https://matrix.example.org", - }, - }, - }), - "utf8", - ); - - expect( - hasActionableMatrixMigration({ - cfg: { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - }, - }, - } as never, - env: process.env, - }), - ).toBe(false); - }); - }); - - it("treats resolvable Matrix legacy state as actionable", async () => { - await withTempHome(async (home) => { - const stateDir = path.join(home, ".openclaw"); - fs.mkdirSync(path.join(stateDir, "matrix"), { recursive: true }); - fs.writeFileSync( - path.join(stateDir, "matrix", "bot-storage.json"), - '{"legacy":true}', - "utf8", - ); - - expect( - hasActionableMatrixMigration({ - cfg: { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - }, - }, - } as never, - env: process.env, - }), - ).toBe(true); - }); - }); - - it("treats legacy Matrix crypto as warning-only until the plugin helper is available", async () => { - await withTempHome( - async (home) => { - const stateDir = path.join(home, ".openclaw"); - fs.mkdirSync(path.join(home, "empty-bundled"), { recursive: true }); - const { rootDir } = resolveMatrixAccountStorageRoot({ - stateDir, - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - }); - fs.mkdirSync(path.join(rootDir, "crypto"), { recursive: true }); - fs.writeFileSync( - path.join(rootDir, "crypto", "bot-sdk.json"), - JSON.stringify({ deviceId: "DEVICE123" }), - "utf8", - ); - - const cfg = { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - }, - }, - } as never; - - const detection = detectLegacyMatrixCrypto({ - cfg, - env: process.env, - }); - expect(detection.plans).toHaveLength(1); - expect(detection.warnings).toContain( - "Legacy Matrix encrypted state was detected, but the Matrix plugin helper is unavailable. Install or repair @openclaw/matrix so OpenClaw can inspect the old rust crypto store before upgrading.", - ); - expect( - hasActionableMatrixMigration({ - cfg, - env: process.env, - }), - ).toBe(false); - }, - { - env: { - OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"), - }, - }, - ); - }); -}); diff --git a/src/infra/matrix-migration-snapshot.ts b/src/infra/matrix-migration-snapshot.ts deleted file mode 100644 index ff3129be554..00000000000 --- a/src/infra/matrix-migration-snapshot.ts +++ /dev/null @@ -1,151 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import type { OpenClawConfig } from "../config/config.js"; -import { resolveStateDir } from "../config/paths.js"; -import { writeJsonFileAtomically } from "../plugin-sdk/json-store.js"; -import { createBackupArchive } from "./backup-create.js"; -import { resolveRequiredHomeDir } from "./home-dir.js"; -import { detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js"; -import { detectLegacyMatrixState } from "./matrix-legacy-state.js"; -import { isMatrixLegacyCryptoInspectorAvailable } from "./matrix-plugin-helper.js"; - -const MATRIX_MIGRATION_SNAPSHOT_DIRNAME = "openclaw-migrations"; - -type MatrixMigrationSnapshotMarker = { - version: 1; - createdAt: string; - archivePath: string; - trigger: string; - includeWorkspace: boolean; -}; - -export type MatrixMigrationSnapshotResult = { - created: boolean; - archivePath: string; - markerPath: string; -}; - -function loadSnapshotMarker(filePath: string): MatrixMigrationSnapshotMarker | null { - try { - if (!fs.existsSync(filePath)) { - return null; - } - const parsed = JSON.parse( - fs.readFileSync(filePath, "utf8"), - ) as Partial; - if ( - parsed.version !== 1 || - typeof parsed.createdAt !== "string" || - typeof parsed.archivePath !== "string" || - typeof parsed.trigger !== "string" - ) { - return null; - } - return { - version: 1, - createdAt: parsed.createdAt, - archivePath: parsed.archivePath, - trigger: parsed.trigger, - includeWorkspace: parsed.includeWorkspace === true, - }; - } catch { - return null; - } -} - -export function resolveMatrixMigrationSnapshotMarkerPath( - env: NodeJS.ProcessEnv = process.env, -): string { - const stateDir = resolveStateDir(env, os.homedir); - return path.join(stateDir, "matrix", "migration-snapshot.json"); -} - -export function resolveMatrixMigrationSnapshotOutputDir( - env: NodeJS.ProcessEnv = process.env, -): string { - const homeDir = resolveRequiredHomeDir(env, os.homedir); - return path.join(homeDir, "Backups", MATRIX_MIGRATION_SNAPSHOT_DIRNAME); -} - -export function hasPendingMatrixMigration(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; -}): boolean { - const env = params.env ?? process.env; - const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env }); - if (legacyState) { - return true; - } - const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env }); - return legacyCrypto.plans.length > 0 || legacyCrypto.warnings.length > 0; -} - -export function hasActionableMatrixMigration(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; -}): boolean { - const env = params.env ?? process.env; - const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env }); - if (legacyState && !("warning" in legacyState)) { - return true; - } - const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env }); - return ( - legacyCrypto.plans.length > 0 && - isMatrixLegacyCryptoInspectorAvailable({ - cfg: params.cfg, - env, - }) - ); -} - -export async function maybeCreateMatrixMigrationSnapshot(params: { - trigger: string; - env?: NodeJS.ProcessEnv; - outputDir?: string; - log?: { info?: (message: string) => void; warn?: (message: string) => void }; -}): Promise { - const env = params.env ?? process.env; - const markerPath = resolveMatrixMigrationSnapshotMarkerPath(env); - const existingMarker = loadSnapshotMarker(markerPath); - if (existingMarker?.archivePath && fs.existsSync(existingMarker.archivePath)) { - params.log?.info?.( - `matrix: reusing existing pre-migration backup snapshot: ${existingMarker.archivePath}`, - ); - return { - created: false, - archivePath: existingMarker.archivePath, - markerPath, - }; - } - if (existingMarker?.archivePath && !fs.existsSync(existingMarker.archivePath)) { - params.log?.warn?.( - `matrix: previous migration snapshot is missing (${existingMarker.archivePath}); creating a replacement backup before continuing`, - ); - } - - const snapshot = await createBackupArchive({ - output: (() => { - const outputDir = params.outputDir ?? resolveMatrixMigrationSnapshotOutputDir(env); - fs.mkdirSync(outputDir, { recursive: true }); - return outputDir; - })(), - includeWorkspace: false, - }); - - const marker: MatrixMigrationSnapshotMarker = { - version: 1, - createdAt: snapshot.createdAt, - archivePath: snapshot.archivePath, - trigger: params.trigger, - includeWorkspace: snapshot.includeWorkspace, - }; - await writeJsonFileAtomically(markerPath, marker); - params.log?.info?.(`matrix: created pre-migration backup snapshot: ${snapshot.archivePath}`); - return { - created: true, - archivePath: snapshot.archivePath, - markerPath, - }; -} diff --git a/src/infra/matrix-plugin-helper.test.ts b/src/infra/matrix-plugin-helper.test.ts deleted file mode 100644 index d2f9c05bc29..00000000000 --- a/src/infra/matrix-plugin-helper.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { withTempHome } from "../../test/helpers/temp-home.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { - isMatrixLegacyCryptoInspectorAvailable, - loadMatrixLegacyCryptoInspector, -} from "./matrix-plugin-helper.js"; -import { - MATRIX_DEFAULT_DEVICE_ID, - MATRIX_DEFAULT_USER_ID, - matrixHelperEnv, - writeMatrixPluginFixture, - writeMatrixPluginManifest, -} from "./matrix.test-helpers.js"; - -vi.unmock("../version.js"); - -async function expectLoadedInspector(params: { - cfg: OpenClawConfig | Record; - env: NodeJS.ProcessEnv; - expected: { - deviceId: string; - roomKeyCounts: { total: number; backedUp: number } | null; - backupVersion: string | null; - decryptionKeyBase64: string | null; - }; -}) { - expect(isMatrixLegacyCryptoInspectorAvailable({ cfg: params.cfg, env: params.env })).toBe(true); - const inspectLegacyStore = await loadMatrixLegacyCryptoInspector({ - cfg: params.cfg, - env: params.env, - }); - - await expect( - inspectLegacyStore({ - cryptoRootDir: "/tmp/legacy", - userId: MATRIX_DEFAULT_USER_ID, - deviceId: MATRIX_DEFAULT_DEVICE_ID, - }), - ).resolves.toEqual(params.expected); -} - -describe("matrix plugin helper resolution", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("loads the legacy crypto inspector from the bundled matrix plugin", async () => { - await withTempHome( - async (home) => { - const bundledRoot = path.join(home, "bundled", "matrix"); - writeMatrixPluginFixture( - bundledRoot, - [ - "export async function inspectLegacyMatrixCryptoStore() {", - ' return { deviceId: "BUNDLED", roomKeyCounts: { total: 7, backedUp: 6 }, backupVersion: "1", decryptionKeyBase64: "YWJjZA==" };', - "}", - ].join("\n"), - ); - - const cfg = {} as const; - - await expectLoadedInspector({ - cfg, - env: process.env, - expected: { - deviceId: "BUNDLED", - roomKeyCounts: { total: 7, backedUp: 6 }, - backupVersion: "1", - decryptionKeyBase64: "YWJjZA==", - }, - }); - }, - { env: matrixHelperEnv }, - ); - }); - - it("prefers configured plugin load paths over bundled matrix plugins", async () => { - await withTempHome( - async (home) => { - const bundledRoot = path.join(home, "bundled", "matrix"); - const customRoot = path.join(home, "plugins", "matrix-local"); - writeMatrixPluginFixture( - bundledRoot, - [ - "export async function inspectLegacyMatrixCryptoStore() {", - ' return { deviceId: "BUNDLED", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null };', - "}", - ].join("\n"), - ); - writeMatrixPluginFixture( - customRoot, - [ - "export default async function inspectLegacyMatrixCryptoStore() {", - ' return { deviceId: "CONFIG", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null };', - "}", - ].join("\n"), - ); - - const cfg: OpenClawConfig = { - plugins: { - load: { - paths: [customRoot], - }, - }, - }; - - await expectLoadedInspector({ - cfg, - env: process.env, - expected: { - deviceId: "CONFIG", - roomKeyCounts: null, - backupVersion: null, - decryptionKeyBase64: null, - }, - }); - }, - { env: matrixHelperEnv }, - ); - }); - - it("keeps source-style root helper shims on the Jiti fallback path", async () => { - await withTempHome( - async (home) => { - const customRoot = path.join(home, "plugins", "matrix-local"); - writeMatrixPluginManifest(customRoot); - fs.mkdirSync(path.join(customRoot, "src", "matrix"), { recursive: true }); - fs.writeFileSync( - path.join(customRoot, "legacy-crypto-inspector.js"), - 'export { inspectLegacyMatrixCryptoStore } from "./src/matrix/legacy-crypto-inspector.js";\n', - "utf8", - ); - fs.writeFileSync( - path.join(customRoot, "src", "matrix", "legacy-crypto-inspector.ts"), - [ - "export async function inspectLegacyMatrixCryptoStore() {", - ' return { deviceId: "SRCJS", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null };', - "}", - ].join("\n"), - "utf8", - ); - - const cfg: OpenClawConfig = { - plugins: { - load: { - paths: [customRoot], - }, - }, - }; - - await expectLoadedInspector({ - cfg, - env: process.env, - expected: { - deviceId: "SRCJS", - roomKeyCounts: null, - backupVersion: null, - decryptionKeyBase64: null, - }, - }); - }, - { - env: { - OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"), - }, - }, - ); - }); - - it("rejects helper files that escape the plugin root", async () => { - await withTempHome( - async (home) => { - const customRoot = path.join(home, "plugins", "matrix-local"); - const outsideRoot = path.join(home, "outside"); - fs.mkdirSync(customRoot, { recursive: true }); - fs.mkdirSync(outsideRoot, { recursive: true }); - writeMatrixPluginManifest(customRoot); - const outsideHelper = path.join(outsideRoot, "legacy-crypto-inspector.js"); - fs.writeFileSync( - outsideHelper, - 'export default async function inspectLegacyMatrixCryptoStore() { return { deviceId: "ESCAPE", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null }; }\n', - "utf8", - ); - - try { - fs.symlinkSync( - outsideHelper, - path.join(customRoot, "legacy-crypto-inspector.js"), - process.platform === "win32" ? "file" : undefined, - ); - } catch { - return; - } - - const cfg: OpenClawConfig = { - plugins: { - load: { - paths: [customRoot], - }, - }, - }; - - expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(false); - await expect( - loadMatrixLegacyCryptoInspector({ - cfg, - env: process.env, - }), - ).rejects.toThrow("Matrix plugin helper path is unsafe"); - }, - { - env: { - OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"), - }, - }, - ); - }); -}); diff --git a/src/infra/matrix.test-helpers.ts b/src/infra/matrix.test-helpers.ts deleted file mode 100644 index 0737aa3e04e..00000000000 --- a/src/infra/matrix.test-helpers.ts +++ /dev/null @@ -1,80 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -export const MATRIX_TEST_HOMESERVER = "https://matrix.example.org"; -export const MATRIX_DEFAULT_USER_ID = "@bot:example.org"; -export const MATRIX_DEFAULT_ACCESS_TOKEN = "tok-123"; -export const MATRIX_DEFAULT_DEVICE_ID = "DEVICE123"; -export const MATRIX_OPS_ACCOUNT_ID = "ops"; -export const MATRIX_OPS_USER_ID = "@ops-bot:example.org"; -export const MATRIX_OPS_ACCESS_TOKEN = "tok-ops"; -export const MATRIX_OPS_DEVICE_ID = "DEVICEOPS"; - -export const matrixHelperEnv = { - OPENCLAW_BUNDLED_PLUGINS_DIR: (home: string) => path.join(home, "bundled"), - OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_VERSION: undefined, - VITEST: "true", -} as const; - -export function writeFile(filePath: string, value: string) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, value, "utf8"); -} - -export function writeMatrixPluginManifest(rootDir: string): void { - fs.mkdirSync(rootDir, { recursive: true }); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "matrix", - configSchema: { - type: "object", - additionalProperties: false, - }, - }), - "utf8", - ); - fs.writeFileSync(path.join(rootDir, "index.js"), "export default {};\n", "utf8"); -} - -export function writeMatrixPluginFixture(rootDir: string, helperBody?: string): void { - writeMatrixPluginManifest(rootDir); - fs.writeFileSync( - path.join(rootDir, "legacy-crypto-inspector.js"), - helperBody ?? - [ - "export async function inspectLegacyMatrixCryptoStore() {", - ' return { deviceId: "FIXTURE", roomKeyCounts: { total: 1, backedUp: 1 }, backupVersion: "1", decryptionKeyBase64: null };', - "}", - ].join("\n"), - "utf8", - ); -} - -export function writeMatrixCredentials( - stateDir: string, - params?: { - accountId?: string; - homeserver?: string; - userId?: string; - accessToken?: string; - deviceId?: string; - }, -) { - const accountId = params?.accountId ?? MATRIX_OPS_ACCOUNT_ID; - writeFile( - path.join(stateDir, "credentials", "matrix", `credentials-${accountId}.json`), - JSON.stringify( - { - homeserver: params?.homeserver ?? MATRIX_TEST_HOMESERVER, - userId: params?.userId ?? MATRIX_OPS_USER_ID, - accessToken: params?.accessToken ?? MATRIX_OPS_ACCESS_TOKEN, - deviceId: params?.deviceId ?? MATRIX_OPS_DEVICE_ID, - }, - null, - 2, - ), - ); -} diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 345d0d0d145..1e863746bab 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createIMessageTestPlugin } from "../../../test/helpers/channels/imessage-test-plugin.js"; import type { OpenClawConfig } from "../../config/config.js"; import { createHookRunner } from "../../plugins/hooks.js"; import { addTestHook } from "../../plugins/hooks.test-helpers.js"; @@ -10,7 +11,6 @@ import { } from "../../plugins/runtime.js"; import type { PluginHookRegistration } from "../../plugins/types.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; -import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js"; import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; import { diff --git a/src/infra/retry-policy.test.ts b/src/infra/retry-policy.test.ts index 872c0ecf2c2..7ba5e9cb3e3 100644 --- a/src/infra/retry-policy.test.ts +++ b/src/infra/retry-policy.test.ts @@ -1,17 +1,17 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createTelegramRetryRunner } from "./retry-policy.js"; +import { createChannelApiRetryRunner } from "./retry-policy.js"; const ZERO_DELAY_RETRY = { attempts: 3, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }; async function runRetryCase(params: { - runnerOptions: Parameters[0]; + runnerOptions: Parameters[0]; fnSteps: Array<{ type: "reject" | "resolve"; value: unknown }>; expectedCalls: number; expectedValue?: unknown; expectedError?: string; }): Promise { vi.useFakeTimers(); - const runner = createTelegramRetryRunner(params.runnerOptions); + const runner = createChannelApiRetryRunner(params.runnerOptions); const fn = vi.fn(); const allRejects = params.fnSteps.length > 0 && params.fnSteps.every((step) => step.type === "reject"); @@ -39,7 +39,7 @@ async function runRetryCase(params: { expect(fn).toHaveBeenCalledTimes(params.expectedCalls); } -describe("createTelegramRetryRunner", () => { +describe("createChannelApiRetryRunner", () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index 5f0ae657154..cd3eec37e40 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -97,16 +97,18 @@ describe("state migrations", () => { expect(detected.sessions.hasLegacy).toBe(true); expect(detected.sessions.legacyKeys).toEqual(["group:123@g.us"]); expect(detected.agentDir.hasLegacy).toBe(true); - expect(detected.whatsappAuth.hasLegacy).toBe(true); - expect(detected.pairingAllowFrom.hasLegacyTelegram).toBe(true); - expect(detected.pairingAllowFrom.copyPlans.map((plan) => plan.targetPath)).toEqual([ + expect(detected.channelPlans.hasLegacy).toBe(true); + expect(detected.channelPlans.plans.map((plan) => plan.targetPath)).toEqual([ + path.join(stateDir, "credentials", "whatsapp", "default", "creds.json"), + path.join(stateDir, "credentials", "whatsapp", "default", "pre-key-1.json"), resolveChannelAllowFromPath("telegram", env, "alpha"), ]); expect(detected.preview).toEqual([ `- Sessions: ${path.join(stateDir, "sessions")} → ${path.join(stateDir, "agents", "worker-1", "sessions")}`, `- Sessions: canonicalize legacy keys in ${path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json")}`, `- Agent dir: ${path.join(stateDir, "agent")} → ${path.join(stateDir, "agents", "worker-1", "agent")}`, - `- WhatsApp auth: ${path.join(stateDir, "credentials")} → ${path.join(stateDir, "credentials", "whatsapp", "default")} (keep oauth.json)`, + `- WhatsApp auth creds.json: ${path.join(stateDir, "credentials", "creds.json")} → ${path.join(stateDir, "credentials", "whatsapp", "default", "creds.json")}`, + `- WhatsApp auth pre-key-1.json: ${path.join(stateDir, "credentials", "pre-key-1.json")} → ${path.join(stateDir, "credentials", "whatsapp", "default", "pre-key-1.json")}`, `- Telegram pairing allowFrom: ${resolveChannelAllowFromPath("telegram", env)} → ${resolveChannelAllowFromPath("telegram", env, "alpha")}`, ]); }); @@ -131,8 +133,8 @@ describe("state migrations", () => { "Canonicalized 1 legacy session key(s)", "Moved trace.jsonl → agents/worker-1/sessions", "Moved agent file settings.json → agents/worker-1/agent", - "Moved WhatsApp auth creds.json → whatsapp/default", - "Moved WhatsApp auth pre-key-1.json → whatsapp/default", + `Moved WhatsApp auth creds.json → ${path.join(stateDir, "credentials", "whatsapp", "default", "creds.json")}`, + `Moved WhatsApp auth pre-key-1.json → ${path.join(stateDir, "credentials", "whatsapp", "default", "pre-key-1.json")}`, `Copied Telegram pairing allowFrom → ${resolveChannelAllowFromPath("telegram", env, "alpha")}`, ]); diff --git a/src/markdown/whatsapp.test.ts b/src/markdown/whatsapp.test.ts deleted file mode 100644 index e92f94179f7..00000000000 --- a/src/markdown/whatsapp.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { markdownToWhatsApp } from "./whatsapp.js"; - -describe("markdownToWhatsApp", () => { - it.each([ - ["converts **bold** to *bold*", "**SOD Blast:**", "*SOD Blast:*"], - ["converts __bold__ to *bold*", "__important__", "*important*"], - ["converts ~~strikethrough~~ to ~strikethrough~", "~~deleted~~", "~deleted~"], - ["leaves single *italic* unchanged (already WhatsApp bold)", "*text*", "*text*"], - ["leaves _italic_ unchanged (already WhatsApp italic)", "_text_", "_text_"], - ["preserves inline code", "Use `**not bold**` here", "Use `**not bold**` here"], - [ - "handles mixed formatting", - "**bold** and ~~strike~~ and _italic_", - "*bold* and ~strike~ and _italic_", - ], - ["handles multiple bold segments", "**one** then **two**", "*one* then *two*"], - ["returns empty string for empty input", "", ""], - ["returns plain text unchanged", "no formatting here", "no formatting here"], - ["handles bold inside a sentence", "This is **very** important", "This is *very* important"], - ] as const)("handles markdown-to-whatsapp conversion: %s", (_name, input, expected) => { - expect(markdownToWhatsApp(input)).toBe(expected); - }); - - it("preserves fenced code blocks", () => { - const input = "```\nconst x = **bold**;\n```"; - expect(markdownToWhatsApp(input)).toBe(input); - }); - - it("preserves code block with formatting inside", () => { - const input = "Before ```**bold** and ~~strike~~``` after **real bold**"; - expect(markdownToWhatsApp(input)).toBe( - "Before ```**bold** and ~~strike~~``` after *real bold*", - ); - }); -}); diff --git a/src/media/channel-inbound-roots.contract.test.ts b/src/media/channel-inbound-roots.contract.test.ts new file mode 100644 index 00000000000..35ffd845a9c --- /dev/null +++ b/src/media/channel-inbound-roots.contract.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, + resolveIMessageAttachmentRoots, + resolveIMessageRemoteAttachmentRoots, +} from "../../test/helpers/channels/channel-media-roots-contract.js"; +import type { OpenClawConfig } from "../config/config.js"; + +describe("channel-inbound-roots contract", () => { + function expectResolvedRootsCase(resolve: () => string[], expected: readonly string[]) { + expect(resolve()).toEqual(expected); + } + + const accountOverrideCfg = { + channels: { + imessage: { + attachmentRoots: ["/Users/*/Library/Messages/Attachments"], + remoteAttachmentRoots: ["/Volumes/shared/imessage"], + accounts: { + work: { + attachmentRoots: ["/Users/work/Library/Messages/Attachments"], + remoteAttachmentRoots: ["/srv/work/attachments"], + }, + }, + }, + }, + } as OpenClawConfig; + + it("resolves configured attachment roots with account overrides", () => { + expectResolvedRootsCase( + () => resolveIMessageAttachmentRoots({ cfg: accountOverrideCfg, accountId: "work" }), + ["/Users/work/Library/Messages/Attachments", "/Users/*/Library/Messages/Attachments"], + ); + }); + + it("resolves configured remote attachment roots with account overrides", () => { + expectResolvedRootsCase( + () => resolveIMessageRemoteAttachmentRoots({ cfg: accountOverrideCfg, accountId: "work" }), + [ + "/srv/work/attachments", + "/Volumes/shared/imessage", + "/Users/work/Library/Messages/Attachments", + "/Users/*/Library/Messages/Attachments", + ], + ); + }); + + it("matches iMessage account ids case-insensitively for attachment roots", () => { + const cfg = { + channels: { + imessage: { + accounts: { + Work: { + attachmentRoots: ["/Users/work/Library/Messages/Attachments"], + }, + }, + }, + }, + } as OpenClawConfig; + + expectResolvedRootsCase( + () => resolveIMessageAttachmentRoots({ cfg, accountId: "work" }), + ["/Users/work/Library/Messages/Attachments", ...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS], + ); + }); + + it("falls back to default iMessage attachment roots", () => { + expectResolvedRootsCase( + () => resolveIMessageAttachmentRoots({ cfg: {} as OpenClawConfig }), + [...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS], + ); + }); + + it("falls back to default iMessage remote attachment roots", () => { + expectResolvedRootsCase( + () => resolveIMessageRemoteAttachmentRoots({ cfg: {} as OpenClawConfig }), + [...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS], + ); + }); +}); diff --git a/src/media/inbound-path-policy.test.ts b/src/media/inbound-path-policy.test.ts index 3d7f08473e9..66c88117d9b 100644 --- a/src/media/inbound-path-policy.test.ts +++ b/src/media/inbound-path-policy.test.ts @@ -1,12 +1,8 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; import { - DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, isInboundPathAllowed, isValidInboundPathRootPattern, mergeInboundPathRoots, - resolveIMessageAttachmentRoots, - resolveIMessageRemoteAttachmentRoots, } from "./inbound-path-policy.js"; describe("inbound-path-policy", () => { @@ -20,10 +16,6 @@ describe("inbound-path-policy", () => { ).toBe(expected); } - function expectResolvedIMessageRootsCase(resolve: () => string[], expected: readonly string[]) { - expect(resolve()).toEqual(expected); - } - function expectMergedInboundPathRootsCase(params: { defaults: string[]; additions: string[]; @@ -54,21 +46,6 @@ describe("inbound-path-policy", () => { expectInboundPathAllowedCase(filePath, expected); }); - const accountOverrideCfg = { - channels: { - imessage: { - attachmentRoots: ["/Users/*/Library/Messages/Attachments"], - remoteAttachmentRoots: ["/Volumes/shared/imessage"], - accounts: { - work: { - attachmentRoots: ["/Users/work/Library/Messages/Attachments"], - remoteAttachmentRoots: ["/srv/work/attachments"], - }, - }, - }, - }, - } as OpenClawConfig; - it.each([ { name: "normalizes and de-duplicates merged roots", @@ -82,63 +59,7 @@ describe("inbound-path-policy", () => { expected: ["/Users/*/Library/Messages/Attachments", "/Volumes/relay/attachments"], }), }, - { - name: "resolves configured attachment roots with account overrides", - run: () => - expectResolvedIMessageRootsCase( - () => resolveIMessageAttachmentRoots({ cfg: accountOverrideCfg, accountId: "work" }), - ["/Users/work/Library/Messages/Attachments", "/Users/*/Library/Messages/Attachments"], - ), - }, - { - name: "resolves configured remote attachment roots with account overrides", - run: () => - expectResolvedIMessageRootsCase( - () => - resolveIMessageRemoteAttachmentRoots({ cfg: accountOverrideCfg, accountId: "work" }), - [ - "/srv/work/attachments", - "/Volumes/shared/imessage", - "/Users/work/Library/Messages/Attachments", - "/Users/*/Library/Messages/Attachments", - ], - ), - }, ] as const)("$name", ({ run }) => { run(); }); - - it.each([ - { - name: "matches iMessage account ids case-insensitively for attachment roots", - resolve: () => { - const cfg = { - channels: { - imessage: { - accounts: { - Work: { - attachmentRoots: ["/Users/work/Library/Messages/Attachments"], - }, - }, - }, - }, - } as OpenClawConfig; - - return resolveIMessageAttachmentRoots({ cfg, accountId: "work" }); - }, - expected: ["/Users/work/Library/Messages/Attachments", ...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS], - }, - { - name: "falls back to default iMessage attachment roots", - resolve: () => resolveIMessageAttachmentRoots({ cfg: {} as OpenClawConfig }), - expected: [...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS], - }, - { - name: "falls back to default iMessage remote attachment roots", - resolve: () => resolveIMessageRemoteAttachmentRoots({ cfg: {} as OpenClawConfig }), - expected: [...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS], - }, - ] as const)("$name", ({ resolve, expected }) => { - expectResolvedIMessageRootsCase(resolve, expected); - }); }); diff --git a/src/plugin-sdk/channel-policy.test.ts b/src/plugin-sdk/channel-policy.test.ts index 18339afb92e..7a22170963d 100644 --- a/src/plugin-sdk/channel-policy.test.ts +++ b/src/plugin-sdk/channel-policy.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; import { formatPairingApproveHint } from "../channels/plugins/helpers.js"; import type { GroupPolicy } from "../config/types.base.js"; -import { createRestrictSendersChannelSecurity } from "./channel-policy.js"; +import { + createDangerousNameMatchingMutableAllowlistWarningCollector, + createRestrictSendersChannelSecurity, +} from "./channel-policy.js"; describe("createRestrictSendersChannelSecurity", () => { it("builds dm policy resolution and open-group warnings from one descriptor", async () => { @@ -56,3 +59,50 @@ describe("createRestrictSendersChannelSecurity", () => { ]); }); }); + +describe("createDangerousNameMatchingMutableAllowlistWarningCollector", () => { + const collectWarnings = createDangerousNameMatchingMutableAllowlistWarningCollector({ + channel: "irc", + detector: (entry) => !entry.includes("@"), + collectLists: (scope) => [ + { + pathLabel: `${scope.prefix}.allowFrom`, + list: scope.account.allowFrom, + }, + ], + }); + + it("collects mutable entries while dangerous matching is disabled", () => { + expect( + collectWarnings({ + cfg: { + channels: { + irc: { + allowFrom: ["charlie"], + }, + }, + } as never, + }), + ).toEqual( + expect.arrayContaining([ + expect.stringContaining("mutable allowlist entry"), + expect.stringContaining("channels.irc.allowFrom: charlie"), + ]), + ); + }); + + it("skips scopes that explicitly allow dangerous name matching", () => { + expect( + collectWarnings({ + cfg: { + channels: { + irc: { + dangerouslyAllowNameMatching: true, + allowFrom: ["charlie"], + }, + }, + } as never, + }), + ).toEqual([]); + }); +}); diff --git a/src/plugin-sdk/whatsapp-targets.test.ts b/src/plugin-sdk/whatsapp-targets.test.ts deleted file mode 100644 index de5410b18e3..00000000000 --- a/src/plugin-sdk/whatsapp-targets.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - isWhatsAppGroupJid, - isWhatsAppUserTarget, - normalizeWhatsAppTarget, -} from "./whatsapp-targets.js"; - -describe("plugin-sdk whatsapp-targets", () => { - it("normalizes user targets through the public facade", () => { - expect(normalizeWhatsAppTarget("1555123@s.whatsapp.net")).toBe("+1555123"); - expect(normalizeWhatsAppTarget("whatsapp:+1555123")).toBe("+1555123"); - }); - - it("preserves valid group JIDs through the public facade", () => { - expect(isWhatsAppGroupJid("120363401234567890@g.us")).toBe(true); - expect(normalizeWhatsAppTarget("120363401234567890@g.us")).toBe("120363401234567890@g.us"); - }); - - it("detects WhatsApp user JIDs through the public facade", () => { - expect(isWhatsAppUserTarget("41796666864:0@s.whatsapp.net")).toBe(true); - expect(isWhatsAppUserTarget("123456789@lid")).toBe(true); - expect(isWhatsAppUserTarget("123456789-987654321@g.us")).toBe(false); - }); -}); diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index 51a66ffd602..72bebe15934 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -700,12 +700,7 @@ describe("plugin-sdk subpath exports", () => { "createTopLevelChannelDmPolicy", "mergeAllowFromEntries", ]); - expectSourceMentions("setup-tools", [ - "formatCliCommand", - "detectBinary", - "installSignalCli", - "formatDocsLink", - ]); + expectSourceMentions("setup-tools", ["formatCliCommand", "detectBinary", "formatDocsLink"]); expectSourceMentions("lazy-runtime", ["createLazyRuntimeSurface", "createLazyRuntimeModule"]); expectSourceContract("self-hosted-provider-setup", { mentions: [ diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index 81737d04ff6..9670de806d6 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -2,15 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } fr import type { DiscordInteractiveHandlerContext, DiscordInteractiveHandlerRegistration, -} from "../../extensions/discord/api.js"; +} from "../../test/helpers/channels/interactive-contract.js"; import type { SlackInteractiveHandlerContext, SlackInteractiveHandlerRegistration, -} from "../../extensions/slack/api.js"; +} from "../../test/helpers/channels/interactive-contract.js"; import type { TelegramInteractiveHandlerContext, TelegramInteractiveHandlerRegistration, -} from "../../extensions/telegram/api.js"; +} from "../../test/helpers/channels/interactive-contract.js"; import * as conversationBinding from "./conversation-binding.js"; import { createInteractiveConversationBindingHelpers } from "./interactive-binding-helpers.js"; import { @@ -608,6 +608,16 @@ describe("plugin interactive handlers", () => { }); }); + it("preserves arbitrary plugin-owned channel ids", () => { + const result = registerPluginInteractiveHandler("plugin-a", { + channel: "msteams", + namespace: "codex", + handler: async () => ({ handled: true }), + }); + + expect(result).toEqual({ ok: true }); + }); + it("acknowledges matched Discord interactions before awaiting plugin handlers", async () => { const callOrder: string[] = []; const handler = vi.fn(async () => { diff --git a/src/plugins/signal-cli-install.ts b/src/plugins/signal-cli-install.ts deleted file mode 100644 index a5c73392b4b..00000000000 --- a/src/plugins/signal-cli-install.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { createWriteStream } from "node:fs"; -import fs from "node:fs/promises"; -import { request } from "node:https"; -import os from "node:os"; -import path from "node:path"; -import { pipeline } from "node:stream/promises"; -import { extractArchive } from "../infra/archive.js"; -import { resolveBrewExecutable } from "../infra/brew.js"; -import { runCommandWithTimeout } from "../process/exec.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { CONFIG_DIR } from "../utils.js"; - -export type ReleaseAsset = { - name?: string; - browser_download_url?: string; -}; - -export type NamedAsset = { - name: string; - browser_download_url: string; -}; - -type ReleaseResponse = { - tag_name?: string; - assets?: ReleaseAsset[]; -}; - -export type SignalInstallResult = { - ok: boolean; - cliPath?: string; - version?: string; - error?: string; -}; - -/** @internal Exported for testing. */ -export async function extractSignalCliArchive( - archivePath: string, - installRoot: string, - timeoutMs: number, -): Promise { - await extractArchive({ archivePath, destDir: installRoot, timeoutMs }); -} - -/** @internal Exported for testing. */ -export function looksLikeArchive(name: string): boolean { - return name.endsWith(".tar.gz") || name.endsWith(".tgz") || name.endsWith(".zip"); -} - -/** - * Pick a native release asset from the official GitHub releases. - * - * The official signal-cli releases only publish native (GraalVM) binaries for - * x86-64 Linux. On architectures where no native asset is available this - * returns `undefined` so the caller can fall back to a different install - * strategy (e.g. Homebrew). - */ -/** @internal Exported for testing. */ -export function pickAsset( - assets: ReleaseAsset[], - platform: NodeJS.Platform, - arch: string, -): NamedAsset | undefined { - const withName = assets.filter((asset): asset is NamedAsset => - Boolean(asset.name && asset.browser_download_url), - ); - - // Archives only, excluding signature files (.asc) - const archives = withName.filter((a) => looksLikeArchive(a.name.toLowerCase())); - - const byName = (pattern: RegExp) => - archives.find((asset) => pattern.test(asset.name.toLowerCase())); - - if (platform === "linux") { - // The official "Linux-native" asset is an x86-64 GraalVM binary. - // On non-x64 architectures it will fail with "Exec format error", - // so only select it when the host architecture matches. - if (arch === "x64") { - return byName(/linux-native/) || byName(/linux/) || archives[0]; - } - // No native release for this arch — caller should fall back. - return undefined; - } - - if (platform === "darwin") { - return byName(/macos|osx|darwin/) || archives[0]; - } - - if (platform === "win32") { - return byName(/windows|win/) || archives[0]; - } - - return archives[0]; -} - -async function downloadToFile(url: string, dest: string, maxRedirects = 5): Promise { - await new Promise((resolve, reject) => { - const req = request(url, (res) => { - if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) { - const location = res.headers.location; - if (!location || maxRedirects <= 0) { - reject(new Error("Redirect loop or missing Location header")); - return; - } - const redirectUrl = new URL(location, url).href; - resolve(downloadToFile(redirectUrl, dest, maxRedirects - 1)); - return; - } - if (!res.statusCode || res.statusCode >= 400) { - reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading file`)); - return; - } - const out = createWriteStream(dest); - pipeline(res, out).then(resolve).catch(reject); - }); - req.on("error", reject); - req.end(); - }); -} - -async function findSignalCliBinary(root: string): Promise { - const candidates: string[] = []; - const enqueue = async (dir: string, depth: number) => { - if (depth > 3) { - return; - } - const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); - for (const entry of entries) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - await enqueue(full, depth + 1); - } else if (entry.isFile() && entry.name === "signal-cli") { - candidates.push(full); - } - } - }; - await enqueue(root, 0); - return candidates[0] ?? null; -} - -// --------------------------------------------------------------------------- -// Brew-based install (used on architectures without an official native build) -// --------------------------------------------------------------------------- - -async function resolveBrewSignalCliPath(brewExe: string): Promise { - try { - const result = await runCommandWithTimeout([brewExe, "--prefix", "signal-cli"], { - timeoutMs: 10_000, - }); - if (result.code === 0 && result.stdout.trim()) { - const prefix = result.stdout.trim(); - // Homebrew installs the wrapper script at /bin/signal-cli - const candidate = path.join(prefix, "bin", "signal-cli"); - try { - await fs.access(candidate); - return candidate; - } catch { - // Fall back to searching the prefix - return findSignalCliBinary(prefix); - } - } - } catch { - // ignore - } - return null; -} - -async function installSignalCliViaBrew(runtime: RuntimeEnv): Promise { - const brewExe = resolveBrewExecutable(); - if (!brewExe) { - return { - ok: false, - error: - `No native signal-cli build is available for ${process.arch}. ` + - "Install Homebrew (https://brew.sh) and try again, or install signal-cli manually.", - }; - } - - runtime.log(`Installing signal-cli via Homebrew (${brewExe})…`); - const result = await runCommandWithTimeout([brewExe, "install", "signal-cli"], { - timeoutMs: 15 * 60_000, // brew builds from source; can take a while - }); - - if (result.code !== 0) { - return { - ok: false, - error: `brew install signal-cli failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`, - }; - } - - const cliPath = await resolveBrewSignalCliPath(brewExe); - if (!cliPath) { - return { - ok: false, - error: "brew install succeeded but signal-cli binary was not found.", - }; - } - - // Extract version from the installed binary. - let version: string | undefined; - try { - const vResult = await runCommandWithTimeout([cliPath, "--version"], { - timeoutMs: 10_000, - }); - // Output is typically "signal-cli 0.13.24" - version = vResult.stdout.trim().replace(/^signal-cli\s+/, "") || undefined; - } catch { - // non-critical; leave version undefined - } - - return { ok: true, cliPath, version }; -} - -// --------------------------------------------------------------------------- -// Direct download install (used when an official native asset is available) -// --------------------------------------------------------------------------- - -async function installSignalCliFromRelease(runtime: RuntimeEnv): Promise { - const apiUrl = "https://api.github.com/repos/AsamK/signal-cli/releases/latest"; - const response = await fetch(apiUrl, { - headers: { - "User-Agent": "openclaw", - Accept: "application/vnd.github+json", - }, - }); - - if (!response.ok) { - return { - ok: false, - error: `Failed to fetch release info (${response.status})`, - }; - } - - const payload = (await response.json()) as ReleaseResponse; - const version = payload.tag_name?.replace(/^v/, "") ?? "unknown"; - const assets = payload.assets ?? []; - const asset = pickAsset(assets, process.platform, process.arch); - - if (!asset) { - return { - ok: false, - error: "No compatible release asset found for this platform.", - }; - } - - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-")); - const archivePath = path.join(tmpDir, asset.name); - - runtime.log(`Downloading signal-cli ${version} (${asset.name})…`); - await downloadToFile(asset.browser_download_url, archivePath); - - const installRoot = path.join(CONFIG_DIR, "tools", "signal-cli", version); - await fs.mkdir(installRoot, { recursive: true }); - - if (!looksLikeArchive(asset.name.toLowerCase())) { - return { ok: false, error: `Unsupported archive type: ${asset.name}` }; - } - try { - await extractSignalCliArchive(archivePath, installRoot, 60_000); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { - ok: false, - error: `Failed to extract ${asset.name}: ${message}`, - }; - } - - const cliPath = await findSignalCliBinary(installRoot); - if (!cliPath) { - return { - ok: false, - error: `signal-cli binary not found after extracting ${asset.name}`, - }; - } - - await fs.chmod(cliPath, 0o755).catch(() => {}); - - return { ok: true, cliPath, version }; -} - -// --------------------------------------------------------------------------- -// Public entry point -// --------------------------------------------------------------------------- - -export async function installSignalCli(runtime: RuntimeEnv): Promise { - if (process.platform === "win32") { - return { - ok: false, - error: "Signal CLI auto-install is not supported on Windows yet.", - }; - } - - // The official signal-cli GitHub releases only ship a native binary for - // x86-64 Linux. On other architectures (arm64, armv7, etc.) we delegate - // to Homebrew which builds from source and bundles the JRE automatically. - const hasNativeRelease = process.platform !== "linux" || process.arch === "x64"; - - if (hasNativeRelease) { - return installSignalCliFromRelease(runtime); - } - - return installSignalCliViaBrew(runtime); -} diff --git a/src/process/exec.windows.test.ts b/src/process/exec.windows.test.ts index 3b1c1dcc1f0..0747b16c513 100644 --- a/src/process/exec.windows.test.ts +++ b/src/process/exec.windows.test.ts @@ -1,3 +1,4 @@ +import type { execFile as execFileType } from "node:child_process"; import { EventEmitter } from "node:events"; import fs from "node:fs"; import path from "node:path"; @@ -16,7 +17,7 @@ vi.mock("node:child_process", async () => { () => vi.importActual("node:child_process"), { spawn: spawnMock, - execFile: execFileMock as unknown as typeof import("node:child_process").execFile, + execFile: execFileMock as unknown as typeof execFileType, }, ); }); diff --git a/src/routing/session-key.test.ts b/src/routing/session-key.test.ts index b8f0724f02d..a5415e468df 100644 --- a/src/routing/session-key.test.ts +++ b/src/routing/session-key.test.ts @@ -78,6 +78,7 @@ describe("deriveSessionChatType", () => { { key: "agent:main:telegram:dm:123456", expected: "direct" }, { key: "telegram:dm:123456", expected: "direct" }, { key: "discord:acc-1:guild-123:channel-456", expected: "channel" }, + { key: "12345-678@g.us", expected: "group" }, { key: "agent:main:main", expected: "unknown" }, { key: "agent:main", expected: "unknown" }, { key: "", expected: "unknown" }, diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index c7c213ca0bf..58ef241eb76 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -2,11 +2,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { collectDiscordSecurityAuditFindings } from "../../extensions/discord/api.js"; -import { collectSlackSecurityAuditFindings } from "../../extensions/slack/api.js"; -import { collectSynologyChatSecurityAuditFindings } from "../../extensions/synology-chat/api.js"; -import { collectTelegramSecurityAuditFindings } from "../../extensions/telegram/api.js"; -import { collectZalouserSecurityAuditFindings } from "../../extensions/zalouser/api.js"; +import { + collectDiscordSecurityAuditFindings, + collectSlackSecurityAuditFindings, + collectSynologyChatSecurityAuditFindings, + collectTelegramSecurityAuditFindings, + collectZalouserSecurityAuditFindings, +} from "../../test/helpers/channels/security-audit-contract.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { saveExecApprovals } from "../infra/exec-approvals.js"; diff --git a/src/test-helpers/whatsapp-outbound.ts b/src/test-helpers/whatsapp-outbound.ts deleted file mode 100644 index 71433107fba..00000000000 --- a/src/test-helpers/whatsapp-outbound.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { expect, type MockInstance } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; - -export function createWhatsAppPollFixture() { - const cfg = { marker: "resolved-cfg" } as OpenClawConfig; - const poll = { - question: "Lunch?", - options: ["Pizza", "Sushi"], - maxSelections: 1, - }; - return { - cfg, - poll, - to: "+1555", - accountId: "work", - }; -} - -export function expectWhatsAppPollSent( - sendPollWhatsApp: MockInstance, - params: { - cfg: OpenClawConfig; - poll: { question: string; options: string[]; maxSelections: number }; - to?: string; - accountId?: string; - }, -) { - expect(sendPollWhatsApp).toHaveBeenCalledWith(params.to ?? "+1555", params.poll, { - verbose: false, - accountId: params.accountId ?? "work", - cfg: params.cfg, - }); -} diff --git a/src/test-utils/imessage-test-plugin.ts b/src/test-utils/imessage-test-plugin.ts deleted file mode 100644 index c2e81a8e2d6..00000000000 --- a/src/test-utils/imessage-test-plugin.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { normalizeIMessageHandle } from "../channels/plugins/normalize/imessage.js"; -import type { ChannelOutboundAdapter, ChannelPlugin } from "../channels/plugins/types.js"; -import { resolveOutboundSendDep } from "../infra/outbound/send-deps.js"; -import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js"; - -const defaultIMessageOutbound: ChannelOutboundAdapter = { - deliveryMode: "direct", - sendText: async ({ to, text, accountId, replyToId, deps, cfg }) => { - const sendIMessage = resolveOutboundSendDep< - ( - target: string, - content: string, - opts?: Record, - ) => Promise<{ messageId: string }> - >(deps, "imessage"); - const result = await sendIMessage?.(to, text, { - config: cfg, - accountId: accountId ?? undefined, - replyToId: replyToId ?? undefined, - }); - return { channel: "imessage", messageId: result?.messageId ?? "imessage-test-stub" }; - }, - sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, deps, cfg, mediaLocalRoots }) => { - const sendIMessage = resolveOutboundSendDep< - ( - target: string, - content: string, - opts?: Record, - ) => Promise<{ messageId: string }> - >(deps, "imessage"); - const result = await sendIMessage?.(to, text, { - config: cfg, - mediaUrl, - accountId: accountId ?? undefined, - replyToId: replyToId ?? undefined, - mediaLocalRoots, - }); - return { channel: "imessage", messageId: result?.messageId ?? "imessage-test-stub" }; - }, -}; - -export const createIMessageTestPlugin = (params?: { - outbound?: ChannelOutboundAdapter; -}): ChannelPlugin => ({ - id: "imessage", - meta: { - id: "imessage", - label: "iMessage", - selectionLabel: "iMessage (imsg)", - docsPath: "/channels/imessage", - blurb: "iMessage test stub.", - aliases: ["imsg"], - }, - capabilities: { chatTypes: ["direct", "group"], media: true }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({}), - }, - status: { - collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("imessage", accounts), - }, - outbound: params?.outbound ?? defaultIMessageOutbound, - messaging: { - targetResolver: { - looksLikeId: (raw) => { - const trimmed = raw.trim(); - if (!trimmed) { - return false; - } - if (/^(imessage:|sms:|auto:|chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) { - return true; - } - if (trimmed.includes("@")) { - return true; - } - return /^\+?\d{3,}$/.test(trimmed); - }, - hint: "", - }, - normalizeTarget: (raw) => normalizeIMessageHandle(raw), - }, -}); diff --git a/src/utils.test.ts b/src/utils.test.ts index 8880f41f6b1..b15d39e45f0 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -3,19 +3,13 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { - assertWebChannel, - CONFIG_DIR, ensureDir, - jidToE164, - normalizeE164, resolveConfigDir, resolveHomeDir, - resolveJidToE164, resolveUserPath, shortenHomeInString, shortenHomePath, sleep, - toWhatsappJid, } from "./utils.js"; async function withTempDir( @@ -50,74 +44,6 @@ describe("sleep", () => { }); }); -describe("assertWebChannel", () => { - it("accepts valid channel", () => { - expect(() => assertWebChannel("web")).not.toThrow(); - }); - - it("throws for invalid channel", () => { - expect(() => assertWebChannel("bad" as string)).toThrow(); - }); -}); - -describe("normalizeE164 & toWhatsappJid", () => { - it("strips formatting and prefixes", () => { - expect(normalizeE164("whatsapp:(555) 123-4567")).toBe("+5551234567"); - expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe("5551234567@s.whatsapp.net"); - }); - - it("preserves existing JIDs", () => { - expect(toWhatsappJid("123456789-987654321@g.us")).toBe("123456789-987654321@g.us"); - expect(toWhatsappJid("whatsapp:123456789-987654321@g.us")).toBe("123456789-987654321@g.us"); - expect(toWhatsappJid("1555123@s.whatsapp.net")).toBe("1555123@s.whatsapp.net"); - }); -}); - -describe("jidToE164", () => { - it("maps @lid using reverse mapping file", () => { - const mappingPath = path.join(CONFIG_DIR, "credentials", "lid-mapping-123_reverse.json"); - const original = fs.readFileSync; - const spy = vi.spyOn(fs, "readFileSync").mockImplementation((...args) => { - if (args[0] === mappingPath) { - return `"5551234"`; - } - return original(...args); - }); - expect(jidToE164("123@lid")).toBe("+5551234"); - spy.mockRestore(); - }); - - it("maps @lid from authDir mapping files", async () => { - await withTempDir("openclaw-auth-", (authDir) => { - const mappingPath = path.join(authDir, "lid-mapping-456_reverse.json"); - fs.writeFileSync(mappingPath, JSON.stringify("5559876")); - expect(jidToE164("456@lid", { authDir })).toBe("+5559876"); - }); - }); - - it("maps @hosted.lid from authDir mapping files", async () => { - await withTempDir("openclaw-auth-", (authDir) => { - const mappingPath = path.join(authDir, "lid-mapping-789_reverse.json"); - fs.writeFileSync(mappingPath, JSON.stringify(4440001)); - expect(jidToE164("789@hosted.lid", { authDir })).toBe("+4440001"); - }); - }); - - it("accepts hosted PN JIDs", () => { - expect(jidToE164("1555000:2@hosted")).toBe("+1555000"); - }); - - it("falls back through lidMappingDirs in order", async () => { - await withTempDir("openclaw-lid-a-", async (first) => { - await withTempDir("openclaw-lid-b-", (second) => { - const mappingPath = path.join(second, "lid-mapping-321_reverse.json"); - fs.writeFileSync(mappingPath, JSON.stringify("123321")); - expect(jidToE164("321@lid", { lidMappingDirs: [first, second] })).toBe("+123321"); - }); - }); - }); -}); - describe("resolveConfigDir", () => { it("prefers ~/.openclaw when legacy dir is missing", async () => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-config-dir-")); @@ -178,32 +104,6 @@ describe("shortenHomeInString", () => { }); }); -describe("resolveJidToE164", () => { - it("resolves @lid via lidLookup when mapping file is missing", async () => { - const lidLookup = { - getPNForLID: vi.fn().mockResolvedValue("777:0@s.whatsapp.net"), - }; - await expect(resolveJidToE164("777@lid", { lidLookup })).resolves.toBe("+777"); - expect(lidLookup.getPNForLID).toHaveBeenCalledWith("777@lid"); - }); - - it("skips lidLookup for non-lid JIDs", async () => { - const lidLookup = { - getPNForLID: vi.fn().mockResolvedValue("888:0@s.whatsapp.net"), - }; - await expect(resolveJidToE164("888@s.whatsapp.net", { lidLookup })).resolves.toBe("+888"); - expect(lidLookup.getPNForLID).not.toHaveBeenCalled(); - }); - - it("returns null when lidLookup throws", async () => { - const lidLookup = { - getPNForLID: vi.fn().mockRejectedValue(new Error("lookup failed")), - }; - await expect(resolveJidToE164("777@lid", { lidLookup })).resolves.toBeNull(); - expect(lidLookup.getPNForLID).toHaveBeenCalledWith("777@lid"); - }); -}); - describe("resolveUserPath", () => { it("expands ~ to home dir", () => { expect(resolveUserPath("~", {}, () => "/Users/thoffman")).toBe(path.resolve("/Users/thoffman")); diff --git a/test/extension-test-boundary.test.ts b/test/extension-test-boundary.test.ts index a2be05779e5..2af25df1645 100644 --- a/test/extension-test-boundary.test.ts +++ b/test/extension-test-boundary.test.ts @@ -13,6 +13,10 @@ const allowedNonExtensionTests = new Set([ "src/agents/pi-embedded-runner-extraparams.test.ts", "src/channels/plugins/contracts/dm-policy.contract.test.ts", "src/channels/plugins/contracts/group-policy.contract.test.ts", + "src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts", + "src/commands/onboard-channels.e2e.test.ts", + "src/gateway/hooks.test.ts", + "src/infra/outbound/deliver.test.ts", "src/plugins/interactive.test.ts", "src/plugins/contracts/discovery.contract.test.ts", ]); @@ -156,7 +160,6 @@ describe("non-extension test boundaries", () => { it("keeps bundled plugin public-surface imports on an explicit core allowlist", () => { const allowed = new Set([ "src/auto-reply/reply.triggers.trigger-handling.test-harness.ts", - "src/channels/plugins/contracts/slack-outbound-harness.ts", "src/commands/channel-test-registry.ts", "src/plugin-sdk/testing.ts", ]); diff --git a/test/helpers/bundled-runtime-sidecars.ts b/test/helpers/bundled-runtime-sidecars.ts new file mode 100644 index 00000000000..b83544955ef --- /dev/null +++ b/test/helpers/bundled-runtime-sidecars.ts @@ -0,0 +1,5 @@ +export const TEST_BUNDLED_RUNTIME_SIDECAR_PATHS = [ + "dist/extensions/discord/runtime-api.js", + "dist/extensions/slack/helper-api.js", + "dist/extensions/telegram/thread-bindings-runtime.js", +] as const; diff --git a/test/helpers/channels/channel-media-roots-contract.ts b/test/helpers/channels/channel-media-roots-contract.ts new file mode 100644 index 00000000000..61a86c14393 --- /dev/null +++ b/test/helpers/channels/channel-media-roots-contract.ts @@ -0,0 +1,5 @@ +export { + DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, + resolveIMessageAttachmentRoots, + resolveIMessageRemoteAttachmentRoots, +} from "../../../extensions/imessage/contract-api.js"; diff --git a/test/helpers/channels/command-contract.ts b/test/helpers/channels/command-contract.ts new file mode 100644 index 00000000000..1000ad9239a --- /dev/null +++ b/test/helpers/channels/command-contract.ts @@ -0,0 +1,6 @@ +export { buildTelegramModelsProviderChannelData } from "../../../extensions/telegram/contract-api.js"; +export { whatsappCommandPolicy } from "../../../extensions/whatsapp/contract-api.js"; +export { + isWhatsAppGroupJid, + normalizeWhatsAppTarget, +} from "../../../extensions/whatsapp/contract-api.js"; diff --git a/test/helpers/channels/imessage-test-plugin.ts b/test/helpers/channels/imessage-test-plugin.ts new file mode 100644 index 00000000000..a0294405683 --- /dev/null +++ b/test/helpers/channels/imessage-test-plugin.ts @@ -0,0 +1 @@ +export { createIMessageTestPlugin } from "../../../extensions/imessage/contract-api.js"; diff --git a/test/helpers/channels/interactive-contract.ts b/test/helpers/channels/interactive-contract.ts new file mode 100644 index 00000000000..581f069df9e --- /dev/null +++ b/test/helpers/channels/interactive-contract.ts @@ -0,0 +1,12 @@ +export type { + DiscordInteractiveHandlerContext, + DiscordInteractiveHandlerRegistration, +} from "../../../extensions/discord/contract-api.js"; +export type { + SlackInteractiveHandlerContext, + SlackInteractiveHandlerRegistration, +} from "../../../extensions/slack/contract-api.js"; +export type { + TelegramInteractiveHandlerContext, + TelegramInteractiveHandlerRegistration, +} from "../../../extensions/telegram/contract-api.js"; diff --git a/test/helpers/plugins/matrix-setup.ts b/test/helpers/channels/matrix-setup-contract.ts similarity index 64% rename from test/helpers/plugins/matrix-setup.ts rename to test/helpers/channels/matrix-setup-contract.ts index 5740885f758..af72716c5d8 100644 --- a/test/helpers/plugins/matrix-setup.ts +++ b/test/helpers/channels/matrix-setup-contract.ts @@ -1 +1 @@ -export { matrixSetupAdapter, matrixSetupWizard } from "../../../extensions/matrix/api.js"; +export { matrixSetupAdapter, matrixSetupWizard } from "../../../extensions/matrix/contract-api.js"; diff --git a/test/helpers/channels/outbound-payload-contract.ts b/test/helpers/channels/outbound-payload-contract.ts index 15c8c3eace9..157b68c567a 100644 --- a/test/helpers/channels/outbound-payload-contract.ts +++ b/test/helpers/channels/outbound-payload-contract.ts @@ -1,11 +1,11 @@ import { expect, it, type Mock, vi } from "vitest"; +import { createSlackOutboundPayloadHarness } from "../../../extensions/slack/contract-api.js"; import { chunkTextForOutbound as chunkZaloTextForOutbound, sendPayloadWithChunkedTextAndMedia as sendZaloPayloadWithChunkedTextAndMedia, } from "../../../extensions/zalo/runtime-api.js"; import { sendPayloadWithChunkedTextAndMedia as sendZalouserPayloadWithChunkedTextAndMedia } from "../../../extensions/zalouser/runtime-api.js"; import type { ReplyPayload } from "../../../src/auto-reply/types.js"; -import { createSlackOutboundPayloadHarness } from "../../../src/channels/plugins/contracts/slack-outbound-harness.js"; import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/test-helpers.js"; import { createDirectTextMediaOutbound } from "../../../src/channels/plugins/outbound/direct-text-media.js"; import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; diff --git a/test/helpers/channels/policy-contract.ts b/test/helpers/channels/policy-contract.ts new file mode 100644 index 00000000000..92ef712896f --- /dev/null +++ b/test/helpers/channels/policy-contract.ts @@ -0,0 +1,9 @@ +export { whatsappAccessControlTesting } from "../../../extensions/whatsapp/contract-api.js"; +export { + evaluateZaloGroupAccess, + resolveZaloRuntimeGroupPolicy, +} from "../../../extensions/zalo/contract-api.js"; +export { + isSignalSenderAllowed, + type SignalSender, +} from "../../../extensions/signal/contract-api.js"; diff --git a/test/helpers/channels/security-audit-contract.ts b/test/helpers/channels/security-audit-contract.ts new file mode 100644 index 00000000000..23afbeaad70 --- /dev/null +++ b/test/helpers/channels/security-audit-contract.ts @@ -0,0 +1,5 @@ +export { collectDiscordSecurityAuditFindings } from "../../../extensions/discord/contract-api.js"; +export { collectSlackSecurityAuditFindings } from "../../../extensions/slack/contract-api.js"; +export { collectSynologyChatSecurityAuditFindings } from "../../../extensions/synology-chat/contract-api.js"; +export { collectTelegramSecurityAuditFindings } from "../../../extensions/telegram/contract-api.js"; +export { collectZalouserSecurityAuditFindings } from "../../../extensions/zalouser/contract-api.js"; diff --git a/test/helpers/plugins/matrix-monitor-route.ts b/test/helpers/plugins/matrix-monitor-route.ts deleted file mode 100644 index 1668a7e441a..00000000000 --- a/test/helpers/plugins/matrix-monitor-route.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type { OpenClawConfig } from "../../../src/config/config.js"; -export { - __testing, - registerSessionBindingAdapter, -} from "../../../src/infra/outbound/session-binding-service.js"; -export { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; -export { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; -export { createTestRegistry } from "../../../src/test-utils/channel-plugins.js"; diff --git a/test/helpers/providers/anthropic-contract.ts b/test/helpers/providers/anthropic-contract.ts new file mode 100644 index 00000000000..f84d0592c0a --- /dev/null +++ b/test/helpers/providers/anthropic-contract.ts @@ -0,0 +1,8 @@ +export { + createAnthropicBetaHeadersWrapper, + createAnthropicFastModeWrapper, + createAnthropicServiceTierWrapper, + resolveAnthropicBetas, + resolveAnthropicFastMode, + resolveAnthropicServiceTier, +} from "../../../extensions/anthropic/contract-api.js"; diff --git a/test/vitest-unit-paths.test.ts b/test/vitest-unit-paths.test.ts index 52f09e4ba4e..70a4fac1c01 100644 --- a/test/vitest-unit-paths.test.ts +++ b/test/vitest-unit-paths.test.ts @@ -21,6 +21,9 @@ describe("isUnitConfigTestFile", () => { expect(isUnitConfigTestFile("src/infra/git-root.test.ts")).toBe(false); expect(isUnitConfigTestFile("src/infra/home-dir.test.ts")).toBe(false); expect(isUnitConfigTestFile("src/infra/openclaw-exec-env.test.ts")).toBe(false); + expect( + isUnitConfigTestFile(bundledPluginFile("matrix", "src/migration-snapshot.test.ts")), + ).toBe(false); expect(isUnitConfigTestFile("src/infra/openclaw-root.test.ts")).toBe(false); expect(isUnitConfigTestFile("src/infra/package-json.test.ts")).toBe(false); expect(isUnitConfigTestFile("src/infra/path-env.test.ts")).toBe(false);