diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 94fdf6fcf8f..cbd09faca48 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -262,6 +262,7 @@ All `verify` commands are concise by default (including quiet internal SDK loggi Use `--json` for full machine-readable output when scripting. In multi-account setups, Matrix CLI commands use the implicit Matrix default account unless you pass `--account `. +If you configure multiple named accounts, set `channels.matrix.defaultAccount` first or those implicit CLI operations will stop and ask you to choose an account explicitly. Use `--account` whenever you want verification or device operations to target a named account explicitly: ```bash @@ -531,6 +532,7 @@ See [Pairing](/channels/pairing) for the shared DM pairing flow and storage layo Top-level `channels.matrix` values act as defaults for named accounts unless an account overrides them. Set `defaultAccount` when you want OpenClaw to prefer one named Matrix account for implicit routing, probing, and CLI operations. +If you configure multiple named accounts, set `defaultAccount` or pass `--account ` for CLI commands that rely on implicit account selection. Pass `--account ` to `openclaw matrix verify ...` and `openclaw matrix devices ...` when you want to override that implicit selection for one command. ## Target resolution @@ -550,6 +552,7 @@ Live directory lookup uses the logged-in Matrix account: ## Configuration reference - `enabled`: enable or disable the channel. +- `name`: optional label for the account. - `defaultAccount`: preferred account ID when multiple Matrix accounts are configured. - `homeserver`: homeserver URL, for example `https://matrix.example.org`. - `userId`: full Matrix user ID, for example `@bot:example.org`. @@ -557,6 +560,7 @@ Live directory lookup uses the logged-in Matrix account: - `password`: password for password-based login. - `deviceId`: explicit Matrix device ID. - `deviceName`: device display name for password login. +- `avatarUrl`: stored self-avatar URL for profile sync and `set-profile` updates. - `initialSyncLimit`: startup sync event limit. - `encryption`: enable E2EE. - `allowlistOnly`: force allowlist-only behavior for DMs and rooms. @@ -582,4 +586,4 @@ Live directory lookup uses the logged-in Matrix account: - `accounts`: named per-account overrides. Top-level `channels.matrix` values act as defaults for these entries. - `groups`: per-room policy map. Prefer room IDs or aliases; unresolved room names are ignored at runtime. Session/group identity uses the stable room ID after resolution, while human-readable labels still come from room names. - `rooms`: legacy alias for `groups`. -- `actions`: per-action tool gating (`messages`, `reactions`, `pins`, `memberInfo`, `channelInfo`, `verification`). +- `actions`: per-action tool gating (`messages`, `reactions`, `pins`, `profile`, `memberInfo`, `channelInfo`, `verification`). diff --git a/docs/install/migrating-matrix.md b/docs/install/migrating-matrix.md index 39ba054a0ed..d1e85c5ecd1 100644 --- a/docs/install/migrating-matrix.md +++ b/docs/install/migrating-matrix.md @@ -124,7 +124,7 @@ If your old installation had local-only encrypted history that was never backed Encrypted migration is a two-stage process: 1. Startup or `openclaw doctor --fix` creates or reuses the pre-migration snapshot if encrypted migration is actionable. -2. Startup or `openclaw doctor --fix` inspects the old Matrix crypto store. +2. Startup or `openclaw doctor --fix` inspects the old Matrix crypto store through the active Matrix plugin install. 3. If a backup decryption key is found, OpenClaw writes it into the new recovery-key flow and marks room-key restore as pending. 4. On the next Matrix startup, OpenClaw restores backed-up room keys into the new crypto store automatically. @@ -194,6 +194,16 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins - Meaning: OpenClaw detected old Matrix state, but the migration is still blocked on missing identity or credential data. - What to do: finish Matrix login or config setup, then rerun `openclaw doctor --fix` or restart the gateway. +`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.` + +- Meaning: OpenClaw found old encrypted Matrix state, but it could not load the helper entrypoint from the Matrix plugin that normally inspects that store. +- What to do: reinstall or repair the Matrix plugin (`openclaw plugins install @openclaw/matrix`, or `openclaw plugins install ./extensions/matrix` for a repo checkout), then rerun `openclaw doctor --fix` or restart the gateway. + +`Matrix plugin helper path is unsafe: ... Reinstall @openclaw/matrix and try again.` + +- Meaning: OpenClaw found a helper file path that escapes the plugin root or fails plugin boundary checks, so it refused to import it. +- What to do: reinstall the Matrix plugin from a trusted path, then rerun `openclaw doctor --fix` or restart the gateway. + `gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ...` - Meaning: OpenClaw refused to mutate Matrix state because it could not create the recovery snapshot first. diff --git a/extensions/matrix/legacy-crypto-inspector.ts b/extensions/matrix/legacy-crypto-inspector.ts new file mode 100644 index 00000000000..de34f3c5c33 --- /dev/null +++ b/extensions/matrix/legacy-crypto-inspector.ts @@ -0,0 +1,2 @@ +export type { MatrixLegacyCryptoInspectionResult } from "./src/matrix/legacy-crypto-inspector.js"; +export { inspectLegacyMatrixCryptoStore } from "./src/matrix/legacy-crypto-inspector.js"; diff --git a/extensions/matrix/src/actions.test.ts b/extensions/matrix/src/actions.test.ts index 60fe63c1dcf..ecccc495ad2 100644 --- a/extensions/matrix/src/actions.test.ts +++ b/extensions/matrix/src/actions.test.ts @@ -118,4 +118,27 @@ describe("matrixMessageActions", () => { expect(actions).toEqual(["poll", "poll-vote"]); }); + + it("hides actions until defaultAccount is set for ambiguous multi-account configs", () => { + const actions = matrixMessageActions.listActions!({ + cfg: { + channels: { + matrix: { + accounts: { + assistant: { + homeserver: "https://matrix.example.org", + accessToken: "assistant-token", + }, + ops: { + homeserver: "https://matrix.example.org", + accessToken: "ops-token", + }, + }, + }, + }, + } as CoreConfig, + } as never); + + expect(actions).toEqual([]); + }); }); diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index 50fb6249ade..97c46867683 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -2,6 +2,7 @@ import { createActionGate, readNumberParam, readStringParam, + requiresExplicitMatrixDefaultAccount, type ChannelMessageActionAdapter, type ChannelMessageActionContext, type ChannelMessageActionName, @@ -66,6 +67,9 @@ function createMatrixExposedActions(params: { export const matrixMessageActions: ChannelMessageActionAdapter = { listActions: ({ cfg }) => { const resolvedCfg = cfg as CoreConfig; + if (requiresExplicitMatrixDefaultAccount(resolvedCfg)) { + return []; + } const account = resolveMatrixAccount({ cfg: resolvedCfg, accountId: resolveDefaultMatrixAccountId(resolvedCfg), diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 86df3a09e3a..66af8443b68 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -478,7 +478,7 @@ export const matrixPlugin: ChannelPlugin = { // // INVARIANT: The import() below cannot hang because: // 1. It only loads local ESM modules with no circular awaits - // 2. Module initialization is synchronous (no top-level await in ./matrix/index.js) + // 2. Module initialization is synchronous (no top-level await in ./matrix/monitor/index.js) // 3. The lock only serializes the import phase, not the provider startup const previousLock = matrixStartupLock; let releaseLock: () => void = () => {}; @@ -489,9 +489,9 @@ export const matrixPlugin: ChannelPlugin = { // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. // Wrap in try/finally to ensure lock is released even if import fails. - let monitorMatrixProvider: typeof import("./matrix/index.js").monitorMatrixProvider; + let monitorMatrixProvider: typeof import("./matrix/monitor/index.js").monitorMatrixProvider; try { - const module = await import("./matrix/index.js"); + const module = await import("./matrix/monitor/index.js"); monitorMatrixProvider = module.monitorMatrixProvider; } finally { // Release lock after import completes or fails diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index 3b98a7e9dc7..82d186dfa37 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -4,9 +4,12 @@ import { DmPolicySchema, GroupPolicySchema, } from "openclaw/plugin-sdk/channel-config-schema"; -import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/matrix"; +import { + buildSecretInputSchema, + MarkdownConfigSchema, + ToolPolicySchema, +} from "openclaw/plugin-sdk/matrix"; import { z } from "zod"; -import { buildSecretInputSchema } from "./secret-input.js"; const matrixActionSchema = z .object({ @@ -81,5 +84,4 @@ export const MatrixConfigSchema = z.object({ groups: z.object({}).catchall(matrixRoomSchema).optional(), rooms: z.object({}).catchall(matrixRoomSchema).optional(), actions: matrixActionSchema, - register: z.boolean().optional(), }); diff --git a/extensions/matrix/src/matrix/accounts.test.ts b/extensions/matrix/src/matrix/accounts.test.ts index 6d7e5605727..673d50e4f9c 100644 --- a/extensions/matrix/src/matrix/accounts.test.ts +++ b/extensions/matrix/src/matrix/accounts.test.ts @@ -110,4 +110,42 @@ describe("resolveMatrixAccount", () => { expect(listMatrixAccountIds(cfg)).toEqual(["main-bot", "ops"]); expect(resolveDefaultMatrixAccountId(cfg)).toBe("main-bot"); }); + + it("returns the only named account when no explicit default is set", () => { + const cfg: CoreConfig = { + channels: { + matrix: { + accounts: { + ops: { + homeserver: "https://matrix.example.org", + accessToken: "ops-token", + }, + }, + }, + }, + }; + + expect(resolveDefaultMatrixAccountId(cfg)).toBe("ops"); + }); + + it('uses the synthetic "default" account when multiple named accounts need explicit selection', () => { + const cfg: CoreConfig = { + channels: { + matrix: { + accounts: { + alpha: { + homeserver: "https://matrix.example.org", + accessToken: "alpha-token", + }, + beta: { + homeserver: "https://matrix.example.org", + accessToken: "beta-token", + }, + }, + }, + }, + }; + + expect(resolveDefaultMatrixAccountId(cfg)).toBe("default"); + }); }); diff --git a/extensions/matrix/src/matrix/accounts.ts b/extensions/matrix/src/matrix/accounts.ts index 7bf315e3cce..1d576b02f1e 100644 --- a/extensions/matrix/src/matrix/accounts.ts +++ b/extensions/matrix/src/matrix/accounts.ts @@ -1,9 +1,9 @@ import { DEFAULT_ACCOUNT_ID, + hasConfiguredSecretInput, normalizeAccountId, - normalizeOptionalAccountId, -} from "openclaw/plugin-sdk/account-id"; -import { hasConfiguredSecretInput } from "../secret-input.js"; + resolveMatrixDefaultOrOnlyAccountId, +} from "openclaw/plugin-sdk/matrix"; import type { CoreConfig, MatrixConfig } from "../types.js"; import { findMatrixAccountConfig, @@ -49,15 +49,7 @@ export function listMatrixAccountIds(cfg: CoreConfig): string[] { } export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string { - const configuredDefault = normalizeOptionalAccountId(cfg.channels?.matrix?.defaultAccount); - const ids = listMatrixAccountIds(cfg); - if (configuredDefault && ids.includes(configuredDefault)) { - return configuredDefault; - } - if (ids.includes(DEFAULT_ACCOUNT_ID)) { - return DEFAULT_ACCOUNT_ID; - } - return ids[0] ?? DEFAULT_ACCOUNT_ID; + return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg)); } export function resolveMatrixAccount(params: { diff --git a/extensions/matrix/src/matrix/actions/client.test.ts b/extensions/matrix/src/matrix/actions/client.test.ts index be24dbc49bf..9a50a1f4ddd 100644 --- a/extensions/matrix/src/matrix/actions/client.test.ts +++ b/extensions/matrix/src/matrix/actions/client.test.ts @@ -36,12 +36,11 @@ vi.mock("../send.js", () => ({ resolveMatrixRoomId: (...args: unknown[]) => resolveMatrixRoomIdMock(...args), })); -let resolveActionClient: typeof import("./client.js").resolveActionClient; let withResolvedActionClient: typeof import("./client.js").withResolvedActionClient; let withResolvedRoomAction: typeof import("./client.js").withResolvedRoomAction; let withStartedActionClient: typeof import("./client.js").withStartedActionClient; -describe("resolveActionClient", () => { +describe("action client helpers", () => { beforeEach(async () => { vi.resetModules(); primeMatrixClientResolverMocks(); @@ -49,12 +48,8 @@ describe("resolveActionClient", () => { .mockReset() .mockImplementation(async (_client, roomId: string) => roomId); - ({ - resolveActionClient, - withResolvedActionClient, - withResolvedRoomAction, - withStartedActionClient, - } = await import("./client.js")); + ({ withResolvedActionClient, withResolvedRoomAction, withStartedActionClient } = + await import("./client.js")); }); afterEach(() => { @@ -64,7 +59,7 @@ describe("resolveActionClient", () => { it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => { vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799"); - const result = await resolveActionClient({ accountId: "default" }); + const result = await withResolvedActionClient({ accountId: "default" }, async () => "ok"); expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default"); expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1); @@ -76,56 +71,56 @@ describe("resolveActionClient", () => { ); const oneOffClient = await createMatrixClientMock.mock.results[0]?.value; expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1); - expect(result.stopOnDone).toBe(true); + expect(oneOffClient.stop).toHaveBeenCalledTimes(1); + expect(result).toBe("ok"); }); it("skips one-off room preparation when readiness is disabled", async () => { - const result = await resolveActionClient({ - accountId: "default", - readiness: "none", - }); + await withResolvedActionClient({ accountId: "default", readiness: "none" }, async () => {}); const oneOffClient = await createMatrixClientMock.mock.results[0]?.value; expect(oneOffClient.prepareForOneOff).not.toHaveBeenCalled(); expect(oneOffClient.start).not.toHaveBeenCalled(); - expect(result.stopOnDone).toBe(true); + expect(oneOffClient.stop).toHaveBeenCalledTimes(1); }); it("starts one-off clients when started readiness is required", async () => { - const result = await resolveActionClient({ - accountId: "default", - readiness: "started", - }); + await withStartedActionClient({ accountId: "default" }, async () => {}); const oneOffClient = await createMatrixClientMock.mock.results[0]?.value; expect(oneOffClient.start).toHaveBeenCalledTimes(1); expect(oneOffClient.prepareForOneOff).not.toHaveBeenCalled(); - expect(result.stopOnDone).toBe(true); + expect(oneOffClient.stop).not.toHaveBeenCalled(); + expect(oneOffClient.stopAndPersist).toHaveBeenCalledTimes(1); }); it("reuses active monitor client when available", async () => { const activeClient = createMockMatrixClient(); getActiveMatrixClientMock.mockReturnValue(activeClient); - const result = await resolveActionClient({ accountId: "default" }); + const result = await withResolvedActionClient({ accountId: "default" }, async (client) => { + expect(client).toBe(activeClient); + return "ok"; + }); - expect(result).toEqual({ client: activeClient, stopOnDone: false }); + expect(result).toBe("ok"); expect(resolveMatrixAuthMock).not.toHaveBeenCalled(); expect(createMatrixClientMock).not.toHaveBeenCalled(); + expect(activeClient.stop).not.toHaveBeenCalled(); }); it("starts active clients when started readiness is required", async () => { const activeClient = createMockMatrixClient(); getActiveMatrixClientMock.mockReturnValue(activeClient); - const result = await resolveActionClient({ - accountId: "default", - readiness: "started", + await withStartedActionClient({ accountId: "default" }, async (client) => { + expect(client).toBe(activeClient); }); - expect(result).toEqual({ client: activeClient, stopOnDone: false }); expect(activeClient.start).toHaveBeenCalledTimes(1); expect(activeClient.prepareForOneOff).not.toHaveBeenCalled(); + expect(activeClient.stop).not.toHaveBeenCalled(); + expect(activeClient.stopAndPersist).not.toHaveBeenCalled(); }); it("uses the implicit resolved account id for active client lookup and storage", async () => { @@ -164,7 +159,7 @@ describe("resolveActionClient", () => { encryption: true, }); - await resolveActionClient({}); + await withResolvedActionClient({}, async () => {}); expect(getActiveMatrixClientMock).toHaveBeenCalledWith("ops"); expect(resolveMatrixAuthMock).toHaveBeenCalledWith( @@ -189,10 +184,7 @@ describe("resolveActionClient", () => { }, }; - await resolveActionClient({ - cfg: explicitCfg, - accountId: "ops", - }); + await withResolvedActionClient({ cfg: explicitCfg, accountId: "ops" }, async () => {}); expect(getMatrixRuntimeMock).not.toHaveBeenCalled(); expect(resolveMatrixAuthContextMock).toHaveBeenCalledWith({ @@ -233,20 +225,6 @@ describe("resolveActionClient", () => { expect(oneOffClient.stopAndPersist).not.toHaveBeenCalled(); }); - it("persists one-off action clients after started wrappers complete", async () => { - const oneOffClient = createMockMatrixClient(); - createMatrixClientMock.mockResolvedValue(oneOffClient); - - await withStartedActionClient({ accountId: "default" }, async (client) => { - expect(client).toBe(oneOffClient); - return undefined; - }); - - expect(oneOffClient.start).toHaveBeenCalledTimes(1); - expect(oneOffClient.stop).not.toHaveBeenCalled(); - expect(oneOffClient.stopAndPersist).toHaveBeenCalledTimes(1); - }); - it("resolves room ids before running wrapped room actions", async () => { const oneOffClient = createMockMatrixClient(); createMatrixClientMock.mockResolvedValue(oneOffClient); diff --git a/extensions/matrix/src/matrix/actions/client.ts b/extensions/matrix/src/matrix/actions/client.ts index abc314faea4..b4327434603 100644 --- a/extensions/matrix/src/matrix/actions/client.ts +++ b/extensions/matrix/src/matrix/actions/client.ts @@ -1,64 +1,15 @@ -import { resolveRuntimeMatrixClient } from "../client-bootstrap.js"; +import { withResolvedRuntimeMatrixClient } from "../client-bootstrap.js"; import { resolveMatrixRoomId } from "../send.js"; import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js"; -async function ensureActionClientReadiness( - client: MatrixActionClient["client"], - readiness: MatrixActionClientOpts["readiness"], - opts: { createdForOneOff: boolean }, -): Promise { - if (readiness === "started") { - await client.start(); - return; - } - if (readiness === "prepared" || (!readiness && opts.createdForOneOff)) { - await client.prepareForOneOff(); - } -} - -export async function resolveActionClient( - opts: MatrixActionClientOpts = {}, -): Promise { - return await resolveRuntimeMatrixClient({ - client: opts.client, - cfg: opts.cfg, - timeoutMs: opts.timeoutMs, - accountId: opts.accountId, - onResolved: async (client, context) => { - await ensureActionClientReadiness(client, opts.readiness, { - createdForOneOff: context.createdForOneOff, - }); - }, - }); -} - type MatrixActionClientStopMode = "stop" | "persist"; -export async function stopActionClient( - resolved: MatrixActionClient, - mode: MatrixActionClientStopMode = "stop", -): Promise { - if (!resolved.stopOnDone) { - return; - } - if (mode === "persist") { - await resolved.client.stopAndPersist(); - return; - } - resolved.client.stop(); -} - export async function withResolvedActionClient( opts: MatrixActionClientOpts, run: (client: MatrixActionClient["client"]) => Promise, mode: MatrixActionClientStopMode = "stop", ): Promise { - const resolved = await resolveActionClient(opts); - try { - return await run(resolved.client); - } finally { - await stopActionClient(resolved, mode); - } + return await withResolvedRuntimeMatrixClient(opts, run, mode); } export async function withStartedActionClient( diff --git a/extensions/matrix/src/matrix/client-bootstrap.ts b/extensions/matrix/src/matrix/client-bootstrap.ts index 75ca477793c..3ddd615be6b 100644 --- a/extensions/matrix/src/matrix/client-bootstrap.ts +++ b/extensions/matrix/src/matrix/client-bootstrap.ts @@ -9,23 +9,40 @@ import { } from "./client.js"; import type { MatrixClient } from "./sdk.js"; -export type ResolvedRuntimeMatrixClient = { +type ResolvedRuntimeMatrixClient = { client: MatrixClient; stopOnDone: boolean; }; +type MatrixRuntimeClientReadiness = "none" | "prepared" | "started"; +type ResolvedRuntimeMatrixClientStopMode = "stop" | "persist"; + type MatrixResolvedClientHook = ( client: MatrixClient, context: { createdForOneOff: boolean }, ) => Promise | void; -export function ensureMatrixNodeRuntime() { +async function ensureResolvedClientReadiness(params: { + client: MatrixClient; + readiness?: MatrixRuntimeClientReadiness; + createdForOneOff: boolean; +}): Promise { + if (params.readiness === "started") { + await params.client.start(); + return; + } + if (params.readiness === "prepared" || (!params.readiness && params.createdForOneOff)) { + await params.client.prepareForOneOff(); + } +} + +function ensureMatrixNodeRuntime() { if (isBunRuntime()) { throw new Error("Matrix support requires Node (bun runtime not supported)"); } } -export async function resolveRuntimeMatrixClient(opts: { +async function resolveRuntimeMatrixClient(opts: { client?: MatrixClient; cfg?: CoreConfig; timeoutMs?: number; @@ -67,3 +84,58 @@ export async function resolveRuntimeMatrixClient(opts: { await opts.onResolved?.(client, { createdForOneOff: true }); return { client, stopOnDone: true }; } + +export async function resolveRuntimeMatrixClientWithReadiness(opts: { + client?: MatrixClient; + cfg?: CoreConfig; + timeoutMs?: number; + accountId?: string | null; + readiness?: MatrixRuntimeClientReadiness; +}): Promise { + return await resolveRuntimeMatrixClient({ + client: opts.client, + cfg: opts.cfg, + timeoutMs: opts.timeoutMs, + accountId: opts.accountId, + onResolved: async (client, context) => { + await ensureResolvedClientReadiness({ + client, + readiness: opts.readiness, + createdForOneOff: context.createdForOneOff, + }); + }, + }); +} + +export async function stopResolvedRuntimeMatrixClient( + resolved: ResolvedRuntimeMatrixClient, + mode: ResolvedRuntimeMatrixClientStopMode = "stop", +): Promise { + if (!resolved.stopOnDone) { + return; + } + if (mode === "persist") { + await resolved.client.stopAndPersist(); + return; + } + resolved.client.stop(); +} + +export async function withResolvedRuntimeMatrixClient( + opts: { + client?: MatrixClient; + cfg?: CoreConfig; + timeoutMs?: number; + accountId?: string | null; + readiness?: MatrixRuntimeClientReadiness; + }, + run: (client: MatrixClient) => Promise, + stopMode: ResolvedRuntimeMatrixClientStopMode = "stop", +): Promise { + const resolved = await resolveRuntimeMatrixClientWithReadiness(opts); + try { + return await run(resolved.client); + } finally { + await stopResolvedRuntimeMatrixClient(resolved, stopMode); + } +} diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts index 5933c77dd7a..133e1293db3 100644 --- a/extensions/matrix/src/matrix/client.test.ts +++ b/extensions/matrix/src/matrix/client.test.ts @@ -2,12 +2,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { CoreConfig } from "../types.js"; import { resolveImplicitMatrixAccountId, - resolveMatrixAuth, - resolveMatrixAuthContext, resolveMatrixConfig, resolveMatrixConfigForAccount, + resolveMatrixAuth, + resolveMatrixAuthContext, validateMatrixHomeserverUrl, -} from "./client.js"; +} from "./client/config.js"; import * as credentialsModule from "./credentials.js"; import * as sdkModule from "./sdk.js"; @@ -142,12 +142,36 @@ describe("resolveMatrixConfig", () => { }, } as CoreConfig; - expect(resolveImplicitMatrixAccountId(cfg, {} as NodeJS.ProcessEnv)).toBeNull(); + expect(resolveImplicitMatrixAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("default"); expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe( "default", ); }); + it("requires explicit defaultAccount selection when multiple named Matrix accounts exist", () => { + const cfg = { + channels: { + matrix: { + accounts: { + assistant: { + homeserver: "https://matrix.assistant.example.org", + accessToken: "assistant-token", + }, + ops: { + homeserver: "https://matrix.ops.example.org", + accessToken: "ops-token", + }, + }, + }, + }, + } as CoreConfig; + + expect(resolveImplicitMatrixAccountId(cfg, {} as NodeJS.ProcessEnv)).toBeNull(); + expect(() => resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv })).toThrow( + /channels\.matrix\.defaultAccount.*--account /i, + ); + }); + it("rejects insecure public http Matrix homeservers", () => { expect(() => validateMatrixHomeserverUrl("http://matrix.example.org")).toThrow( "Matrix homeserver must use https:// unless it targets a private or loopback host", diff --git a/extensions/matrix/src/matrix/client.ts b/extensions/matrix/src/matrix/client.ts index 887b49f9799..23d4011f9f3 100644 --- a/extensions/matrix/src/matrix/client.ts +++ b/extensions/matrix/src/matrix/client.ts @@ -1,20 +1,13 @@ -export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js"; +export type { MatrixAuth } from "./client/types.js"; export { isBunRuntime } from "./client/runtime.js"; export { getMatrixScopedEnvVarNames, hasReadyMatrixEnvAuth, - resolveMatrixConfig, resolveMatrixConfigForAccount, resolveScopedMatrixEnvConfig, - resolveImplicitMatrixAccountId, resolveMatrixAuth, resolveMatrixAuthContext, validateMatrixHomeserverUrl, } from "./client/config.js"; export { createMatrixClient } from "./client/create-client.js"; -export { - resolveSharedMatrixClient, - waitForMatrixSync, - stopSharedClient, - stopSharedClientForAccount, -} from "./client/shared.js"; +export { resolveSharedMatrixClient, stopSharedClientForAccount } from "./client/shared.js"; diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index c06a2c563c3..55a2f8464f4 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -3,15 +3,13 @@ import { isPrivateOrLoopbackHost, normalizeAccountId, normalizeOptionalAccountId, + normalizeResolvedSecretInputString, + requiresExplicitMatrixDefaultAccount, + resolveMatrixDefaultOrOnlyAccountId, } from "openclaw/plugin-sdk/matrix"; import { getMatrixRuntime } from "../../runtime.js"; -import { normalizeResolvedSecretInputString } from "../../secret-input.js"; import type { CoreConfig } from "../../types.js"; -import { - findMatrixAccountConfig, - listNormalizedMatrixAccountIds, - resolveMatrixBaseConfig, -} from "../account-config.js"; +import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "../account-config.js"; import { resolveMatrixConfigFieldPath } from "../config-update.js"; import { MatrixClient } from "../sdk.js"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; @@ -315,39 +313,14 @@ export function resolveMatrixConfigForAccount( }; } -function hasMatrixAuthInputs(config: MatrixResolvedConfig): boolean { - return Boolean(config.homeserver && (config.accessToken || (config.userId && config.password))); -} - export function resolveImplicitMatrixAccountId( cfg: CoreConfig, - env: NodeJS.ProcessEnv = process.env, + _env: NodeJS.ProcessEnv = process.env, ): string | null { - const accountIds = listNormalizedMatrixAccountIds(cfg); - const configuredDefault = normalizeOptionalAccountId(cfg.channels?.matrix?.defaultAccount); - if (configuredDefault && accountIds.includes(configuredDefault)) { - const resolved = resolveMatrixConfigForAccount(cfg, configuredDefault, env); - if (hasMatrixAuthInputs(resolved)) { - return configuredDefault; - } - } - - if (accountIds.length === 0) { + if (requiresExplicitMatrixDefaultAccount(cfg)) { return null; } - - const readyIds = accountIds.filter((accountId) => - hasMatrixAuthInputs(resolveMatrixConfigForAccount(cfg, accountId, env)), - ); - if (readyIds.length === 1) { - return readyIds[0] ?? null; - } - - if (readyIds.includes(DEFAULT_ACCOUNT_ID)) { - return DEFAULT_ACCOUNT_ID; - } - - return null; + return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg)); } export function resolveMatrixAuthContext(params?: { @@ -363,8 +336,12 @@ export function resolveMatrixAuthContext(params?: { const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); const env = params?.env ?? process.env; const explicitAccountId = normalizeOptionalAccountId(params?.accountId); - const effectiveAccountId = - explicitAccountId ?? resolveImplicitMatrixAccountId(cfg, env) ?? DEFAULT_ACCOUNT_ID; + const effectiveAccountId = explicitAccountId ?? resolveImplicitMatrixAccountId(cfg, env); + if (!effectiveAccountId) { + throw new Error( + 'Multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. Set "channels.matrix.defaultAccount" to the intended account or pass --account .', + ); + } const resolved = resolveMatrixConfigForAccount(cfg, effectiveAccountId, env); return { diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts index 1bfc86da272..c92a995fc37 100644 --- a/extensions/matrix/src/matrix/client/shared.ts +++ b/extensions/matrix/src/matrix/client/shared.ts @@ -175,15 +175,6 @@ export async function resolveSharedMatrixClient( } } -export async function waitForMatrixSync(_params: { - client: MatrixClient; - timeoutMs?: number; - abortSignal?: AbortSignal; -}): Promise { - // matrix-js-sdk handles sync lifecycle in start() for this integration. - // This is kept for API compatibility but is essentially a no-op now -} - export function stopSharedClient(): void { for (const state of sharedClientStates.values()) { state.client.stop(); diff --git a/extensions/matrix/src/matrix/index.ts b/extensions/matrix/src/matrix/index.ts deleted file mode 100644 index 7cd75d8a1ae..00000000000 --- a/extensions/matrix/src/matrix/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { monitorMatrixProvider } from "./monitor/index.js"; -export { probeMatrix } from "./probe.js"; -export { - reactMatrixMessage, - resolveMatrixRoomId, - sendReadReceiptMatrix, - sendMessageMatrix, - sendPollMatrix, - sendTypingMatrix, -} from "./send.js"; -export { resolveMatrixAuth, resolveSharedMatrixClient } from "./client.js"; diff --git a/extensions/matrix/src/matrix/legacy-crypto-inspector.ts b/extensions/matrix/src/matrix/legacy-crypto-inspector.ts new file mode 100644 index 00000000000..7f22cd3379d --- /dev/null +++ b/extensions/matrix/src/matrix/legacy-crypto-inspector.ts @@ -0,0 +1,95 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { ensureMatrixCryptoRuntime } from "./deps.js"; + +export type MatrixLegacyCryptoInspectionResult = { + deviceId: string | null; + roomKeyCounts: { + total: number; + backedUp: number; + } | null; + backupVersion: string | null; + decryptionKeyBase64: string | null; +}; + +function resolveLegacyMachineStorePath(params: { + cryptoRootDir: string; + deviceId: string; +}): string | null { + const hashedDir = path.join( + params.cryptoRootDir, + crypto.createHash("sha256").update(params.deviceId).digest("hex"), + ); + if (fs.existsSync(path.join(hashedDir, "matrix-sdk-crypto.sqlite3"))) { + return hashedDir; + } + if (fs.existsSync(path.join(params.cryptoRootDir, "matrix-sdk-crypto.sqlite3"))) { + return params.cryptoRootDir; + } + const match = fs + .readdirSync(params.cryptoRootDir, { withFileTypes: true }) + .find( + (entry) => + entry.isDirectory() && + fs.existsSync(path.join(params.cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")), + ); + return match ? path.join(params.cryptoRootDir, match.name) : null; +} + +export async function inspectLegacyMatrixCryptoStore(params: { + cryptoRootDir: string; + userId: string; + deviceId: string; + log?: (message: string) => void; +}): Promise { + const machineStorePath = resolveLegacyMachineStorePath(params); + if (!machineStorePath) { + throw new Error(`Matrix legacy crypto store not found for device ${params.deviceId}`); + } + + const requireFn = createRequire(import.meta.url); + await ensureMatrixCryptoRuntime({ + requireFn, + resolveFn: requireFn.resolve.bind(requireFn), + log: params.log, + }); + + const { DeviceId, OlmMachine, StoreType, UserId } = requireFn( + "@matrix-org/matrix-sdk-crypto-nodejs", + ) as typeof import("@matrix-org/matrix-sdk-crypto-nodejs"); + const machine = await OlmMachine.initialize( + new UserId(params.userId), + new DeviceId(params.deviceId), + machineStorePath, + "", + StoreType.Sqlite, + ); + + try { + const [backupKeys, roomKeyCounts] = await Promise.all([ + machine.getBackupKeys(), + machine.roomKeyCounts(), + ]); + return { + deviceId: params.deviceId, + roomKeyCounts: roomKeyCounts + ? { + total: typeof roomKeyCounts.total === "number" ? roomKeyCounts.total : 0, + backedUp: typeof roomKeyCounts.backedUp === "number" ? roomKeyCounts.backedUp : 0, + } + : null, + backupVersion: + typeof backupKeys?.backupVersion === "string" && backupKeys.backupVersion.trim() + ? backupKeys.backupVersion + : null, + decryptionKeyBase64: + typeof backupKeys?.decryptionKeyBase64 === "string" && backupKeys.decryptionKeyBase64.trim() + ? backupKeys.decryptionKeyBase64 + : null, + }; + } finally { + machine.close(); + } +} diff --git a/extensions/matrix/src/matrix/send/client.test.ts b/extensions/matrix/src/matrix/send/client.test.ts index 5b3f93d8467..6a282dc6eb0 100644 --- a/extensions/matrix/src/matrix/send/client.test.ts +++ b/extensions/matrix/src/matrix/send/client.test.ts @@ -29,17 +29,16 @@ vi.mock("../../runtime.js", () => ({ getMatrixRuntime: () => getMatrixRuntimeMock(), })); -let resolveMatrixClient: typeof import("./client.js").resolveMatrixClient; let withResolvedMatrixClient: typeof import("./client.js").withResolvedMatrixClient; -describe("resolveMatrixClient", () => { +describe("withResolvedMatrixClient", () => { beforeEach(async () => { vi.resetModules(); primeMatrixClientResolverMocks({ resolved: {}, }); - ({ resolveMatrixClient, withResolvedMatrixClient } = await import("./client.js")); + ({ withResolvedMatrixClient } = await import("./client.js")); }); afterEach(() => { @@ -49,7 +48,7 @@ describe("resolveMatrixClient", () => { it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => { vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799"); - const result = await resolveMatrixClient({ accountId: "default" }); + const result = await withResolvedMatrixClient({ accountId: "default" }, async () => "ok"); expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default"); expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1); @@ -61,18 +60,23 @@ describe("resolveMatrixClient", () => { ); const oneOffClient = await createMatrixClientMock.mock.results[0]?.value; expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1); - expect(result.stopOnDone).toBe(true); + expect(oneOffClient.stop).toHaveBeenCalledTimes(1); + expect(result).toBe("ok"); }); it("reuses active monitor client when available", async () => { const activeClient = createMockMatrixClient(); getActiveMatrixClientMock.mockReturnValue(activeClient); - const result = await resolveMatrixClient({ accountId: "default" }); + const result = await withResolvedMatrixClient({ accountId: "default" }, async (client) => { + expect(client).toBe(activeClient); + return "ok"; + }); - expect(result).toEqual({ client: activeClient, stopOnDone: false }); + expect(result).toBe("ok"); expect(resolveMatrixAuthMock).not.toHaveBeenCalled(); expect(createMatrixClientMock).not.toHaveBeenCalled(); + expect(activeClient.stop).not.toHaveBeenCalled(); }); it("uses the effective account id when auth resolution is implicit", async () => { @@ -92,7 +96,7 @@ describe("resolveMatrixClient", () => { encryption: false, }); - await resolveMatrixClient({}); + await withResolvedMatrixClient({}, async () => {}); expect(getActiveMatrixClientMock).toHaveBeenCalledWith("ops"); expect(resolveMatrixAuthMock).toHaveBeenCalledWith({ @@ -115,10 +119,7 @@ describe("resolveMatrixClient", () => { }, }; - await resolveMatrixClient({ - cfg: explicitCfg, - accountId: "ops", - }); + await withResolvedMatrixClient({ cfg: explicitCfg, accountId: "ops" }, async () => {}); expect(getMatrixRuntimeMock).not.toHaveBeenCalled(); expect(resolveMatrixAuthContextMock).toHaveBeenCalledWith({ @@ -131,19 +132,6 @@ describe("resolveMatrixClient", () => { }); }); - it("stops one-off matrix clients after wrapped sends succeed", async () => { - const oneOffClient = createMockMatrixClient(); - createMatrixClientMock.mockResolvedValue(oneOffClient); - - const result = await withResolvedMatrixClient({ accountId: "default" }, async (client) => { - expect(client).toBe(oneOffClient); - return "ok"; - }); - - expect(result).toBe("ok"); - expect(oneOffClient.stop).toHaveBeenCalledTimes(1); - }); - it("still stops one-off matrix clients when wrapped sends fail", async () => { const oneOffClient = createMockMatrixClient(); createMatrixClientMock.mockResolvedValue(oneOffClient); diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts index b7f32aa0927..f68d8e8c7f9 100644 --- a/extensions/matrix/src/matrix/send/client.ts +++ b/extensions/matrix/src/matrix/send/client.ts @@ -1,10 +1,7 @@ import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig } from "../../types.js"; import { resolveMatrixAccountConfig } from "../accounts.js"; -import { - resolveRuntimeMatrixClient, - type ResolvedRuntimeMatrixClient, -} from "../client-bootstrap.js"; +import { withResolvedRuntimeMatrixClient } from "../client-bootstrap.js"; import type { MatrixClient } from "../sdk.js"; const getCore = () => getMatrixRuntime(); @@ -22,31 +19,6 @@ export function resolveMediaMaxBytes( return undefined; } -export async function resolveMatrixClient(opts: { - client?: MatrixClient; - cfg?: CoreConfig; - timeoutMs?: number; - accountId?: string | null; -}): Promise<{ client: MatrixClient; stopOnDone: boolean }> { - return await resolveRuntimeMatrixClient({ - client: opts.client, - cfg: opts.cfg, - timeoutMs: opts.timeoutMs, - accountId: opts.accountId, - onResolved: async (client, context) => { - if (context.createdForOneOff) { - await client.prepareForOneOff(); - } - }, - }); -} - -function stopResolvedMatrixClient(resolved: ResolvedRuntimeMatrixClient): void { - if (resolved.stopOnDone) { - resolved.client.stop(); - } -} - export async function withResolvedMatrixClient( opts: { client?: MatrixClient; @@ -56,10 +28,11 @@ export async function withResolvedMatrixClient( }, run: (client: MatrixClient) => Promise, ): Promise { - const resolved = await resolveMatrixClient(opts); - try { - return await run(resolved.client); - } finally { - stopResolvedMatrixClient(resolved); - } + return await withResolvedRuntimeMatrixClient( + { + ...opts, + readiness: "prepared", + }, + run, + ); } diff --git a/extensions/matrix/src/onboarding.test.ts b/extensions/matrix/src/onboarding.test.ts index 6bf85f03f82..1ee7b29f163 100644 --- a/extensions/matrix/src/onboarding.test.ts +++ b/extensions/matrix/src/onboarding.test.ts @@ -356,4 +356,42 @@ describe("matrix onboarding", () => { expect(status.statusLines).toContain("Matrix: configured"); expect(status.selectionHint).toBe("configured"); }); + + it("asks for defaultAccount when multiple named Matrix accounts exist", async () => { + setMatrixRuntime({ + state: { + resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => + (homeDir ?? (() => "/tmp"))(), + }, + config: { + loadConfig: () => ({}), + }, + } as never); + + const status = await matrixOnboardingAdapter.getStatus({ + cfg: { + channels: { + matrix: { + accounts: { + assistant: { + homeserver: "https://matrix.assistant.example.org", + accessToken: "assistant-token", + }, + ops: { + homeserver: "https://matrix.ops.example.org", + accessToken: "ops-token", + }, + }, + }, + }, + } as CoreConfig, + accountOverrides: {}, + }); + + expect(status.configured).toBe(false); + expect(status.statusLines).toEqual([ + 'Matrix: set "channels.matrix.defaultAccount" to select a named account', + ]); + expect(status.selectionHint).toBe("set defaultAccount"); + }); }); diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index c866366d026..908b8865658 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -6,6 +6,7 @@ import { mergeAllowFromEntries, normalizeAccountId, promptAccountId, + requiresExplicitMatrixDefaultAccount, type RuntimeEnv, type WizardPrompter, } from "openclaw/plugin-sdk/matrix"; @@ -494,12 +495,21 @@ async function runMatrixConfigure(params: { export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = { channel, getStatus: async ({ cfg, accountOverrides }) => { + const resolvedCfg = cfg as CoreConfig; + const sdkReady = isMatrixSdkAvailable(); + if (!accountOverrides[channel] && requiresExplicitMatrixDefaultAccount(resolvedCfg)) { + return { + channel, + configured: false, + statusLines: ['Matrix: set "channels.matrix.defaultAccount" to select a named account'], + selectionHint: !sdkReady ? "install matrix-js-sdk" : "set defaultAccount", + }; + } const account = resolveMatrixAccount({ - cfg: cfg as CoreConfig, - accountId: resolveMatrixOnboardingAccountId(cfg as CoreConfig, accountOverrides[channel]), + cfg: resolvedCfg, + accountId: resolveMatrixOnboardingAccountId(resolvedCfg, accountOverrides[channel]), }); const configured = account.configured; - const sdkReady = isMatrixSdkAvailable(); return { channel, configured, diff --git a/extensions/matrix/src/secret-input.ts b/extensions/matrix/src/secret-input.ts deleted file mode 100644 index c0827573480..00000000000 --- a/extensions/matrix/src/secret-input.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { - buildSecretInputSchema, - hasConfiguredSecretInput, - normalizeResolvedSecretInputString, - normalizeSecretInputString, -} from "openclaw/plugin-sdk/matrix"; - -export { - buildSecretInputSchema, - hasConfiguredSecretInput, - normalizeResolvedSecretInputString, - normalizeSecretInputString, -}; diff --git a/src/infra/matrix-legacy-crypto.test.ts b/src/infra/matrix-legacy-crypto.test.ts index 8402c416c0c..90326af874c 100644 --- a/src/infra/matrix-legacy-crypto.test.ts +++ b/src/infra/matrix-legacy-crypto.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { withTempHome } from "../../test/helpers/temp-home.js"; import type { OpenClawConfig } from "../config/config.js"; import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js"; +import { MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE } from "./matrix-plugin-helper.js"; import { resolveMatrixAccountStorageRoot } from "./matrix-storage-paths.js"; function writeFile(filePath: string, value: string) { @@ -318,4 +319,43 @@ describe("matrix legacy encrypted-state migration", () => { ); }); }); + + 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"), + '{"deviceId":"DEVICE123"}', + ); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }; + + 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 index 28e5c692a6a..a025e699296 100644 --- a/src/infra/matrix-legacy-crypto.ts +++ b/src/infra/matrix-legacy-crypto.ts @@ -1,4 +1,3 @@ -import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -10,6 +9,12 @@ import { resolveLegacyMatrixFlatStoreTarget, resolveMatrixMigrationAccountTarget, } from "./matrix-migration-config.js"; +import { + MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE, + isMatrixLegacyCryptoInspectorAvailable, + loadMatrixLegacyCryptoInspector, + type MatrixLegacyCryptoInspector, +} from "./matrix-plugin-helper.js"; import { resolveMatrixLegacyFlatStoragePaths } from "./matrix-storage-paths.js"; type MatrixLegacyCryptoCounts = { @@ -24,7 +29,7 @@ type MatrixLegacyCryptoSummary = { decryptionKeyBase64: string | null; }; -export type MatrixLegacyCryptoMigrationState = { +type MatrixLegacyCryptoMigrationState = { version: 1; source: "matrix-bot-sdk-rust"; accountId: string; @@ -57,18 +62,14 @@ type MatrixLegacyCryptoDetection = { warnings: string[]; }; -export type MatrixLegacyCryptoPreparationResult = { +type MatrixLegacyCryptoPreparationResult = { migrated: boolean; changes: string[]; warnings: string[]; }; -export type MatrixLegacyCryptoPrepareDeps = { - inspectLegacyStore: (params: { - cryptoRootDir: string; - userId: string; - deviceId: string; - }) => Promise; +type MatrixLegacyCryptoPrepareDeps = { + inspectLegacyStore: MatrixLegacyCryptoInspector; }; type MatrixLegacyBotSdkMetadata = { @@ -281,75 +282,6 @@ function loadLegacyCryptoMigrationState(filePath: string): MatrixLegacyCryptoMig } } -function resolveLegacyMachineStorePath(params: { - cryptoRootDir: string; - deviceId: string; -}): string | null { - const hashedDir = path.join( - params.cryptoRootDir, - crypto.createHash("sha256").update(params.deviceId).digest("hex"), - ); - if (fs.existsSync(path.join(hashedDir, "matrix-sdk-crypto.sqlite3"))) { - return hashedDir; - } - if (fs.existsSync(path.join(params.cryptoRootDir, "matrix-sdk-crypto.sqlite3"))) { - return params.cryptoRootDir; - } - const match = fs - .readdirSync(params.cryptoRootDir, { withFileTypes: true }) - .find( - (entry) => - entry.isDirectory() && - fs.existsSync(path.join(params.cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")), - ); - return match ? path.join(params.cryptoRootDir, match.name) : null; -} - -async function inspectLegacyStoreWithCryptoNodejs(params: { - cryptoRootDir: string; - userId: string; - deviceId: string; -}): Promise { - const machineStorePath = resolveLegacyMachineStorePath(params); - if (!machineStorePath) { - throw new Error(`Matrix legacy crypto store not found for device ${params.deviceId}`); - } - const { DeviceId, OlmMachine, StoreType, UserId } = - await import("@matrix-org/matrix-sdk-crypto-nodejs"); - const machine = await OlmMachine.initialize( - new UserId(params.userId), - new DeviceId(params.deviceId), - machineStorePath, - "", - StoreType.Sqlite, - ); - try { - const [backupKeys, roomKeyCounts] = await Promise.all([ - machine.getBackupKeys(), - machine.roomKeyCounts(), - ]); - return { - deviceId: params.deviceId, - roomKeyCounts: roomKeyCounts - ? { - total: typeof roomKeyCounts.total === "number" ? roomKeyCounts.total : 0, - backedUp: typeof roomKeyCounts.backedUp === "number" ? roomKeyCounts.backedUp : 0, - } - : null, - backupVersion: - typeof backupKeys?.backupVersion === "string" && backupKeys.backupVersion.trim() - ? backupKeys.backupVersion - : null, - decryptionKeyBase64: - typeof backupKeys?.decryptionKeyBase64 === "string" && backupKeys.decryptionKeyBase64.trim() - ? backupKeys.decryptionKeyBase64 - : null, - }; - } finally { - machine.close(); - } -} - async function persistLegacyMigrationState(params: { filePath: string; state: MatrixLegacyCryptoMigrationState; @@ -361,10 +293,23 @@ export function detectLegacyMatrixCrypto(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; }): MatrixLegacyCryptoDetection { - return resolveMatrixLegacyCryptoPlans({ + 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: { @@ -374,10 +319,35 @@ export async function autoPrepareLegacyMatrixCrypto(params: { deps?: Partial; }): Promise { const env = params.env ?? process.env; - const detection = resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env }); + const detection = params.deps?.inspectLegacyStore + ? resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env }) + : detectLegacyMatrixCrypto({ cfg: params.cfg, env }); const warnings = [...detection.warnings]; const changes: string[] = []; - const inspectLegacyStore = params.deps?.inspectLegacyStore ?? inspectLegacyStoreWithCryptoNodejs; + 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, + }; + } + } for (const plan of detection.plans) { const existingState = loadLegacyCryptoMigrationState(plan.statePath); @@ -398,6 +368,7 @@ export async function autoPrepareLegacyMatrixCrypto(params: { cryptoRootDir: plan.legacyCryptoPath, userId: plan.userId, deviceId: plan.deviceId, + log: params.log?.info, }); } catch (err) { warnings.push( diff --git a/src/infra/matrix-migration-snapshot.test.ts b/src/infra/matrix-migration-snapshot.test.ts index c4c9739eb3d..31d24f0fdc7 100644 --- a/src/infra/matrix-migration-snapshot.test.ts +++ b/src/infra/matrix-migration-snapshot.test.ts @@ -2,6 +2,8 @@ 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 { detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js"; +import { resolveMatrixAccountStorageRoot } from "./matrix-storage-paths.js"; const createBackupArchiveMock = vi.hoisted(() => vi.fn()); @@ -195,4 +197,55 @@ describe("matrix migration snapshots", () => { ).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 index 761f1d968c3..ff3129be554 100644 --- a/src/infra/matrix-migration-snapshot.ts +++ b/src/infra/matrix-migration-snapshot.ts @@ -8,6 +8,7 @@ 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"; @@ -90,7 +91,13 @@ export function hasActionableMatrixMigration(params: { return true; } const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env }); - return legacyCrypto.plans.length > 0; + return ( + legacyCrypto.plans.length > 0 && + isMatrixLegacyCryptoInspectorAvailable({ + cfg: params.cfg, + env, + }) + ); } export async function maybeCreateMatrixMigrationSnapshot(params: { diff --git a/src/infra/matrix-plugin-helper.test.ts b/src/infra/matrix-plugin-helper.test.ts new file mode 100644 index 00000000000..650edc434ca --- /dev/null +++ b/src/infra/matrix-plugin-helper.test.ts @@ -0,0 +1,186 @@ +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 { + isMatrixLegacyCryptoInspectorAvailable, + loadMatrixLegacyCryptoInspector, +} from "./matrix-plugin-helper.js"; + +function writeMatrixPluginFixture(rootDir: string, helperBody: 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"); + fs.writeFileSync(path.join(rootDir, "legacy-crypto-inspector.js"), helperBody, "utf8"); +} + +describe("matrix plugin helper resolution", () => { + 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; + + expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(true); + const inspectLegacyStore = await loadMatrixLegacyCryptoInspector({ + cfg, + env: process.env, + }); + + await expect( + inspectLegacyStore({ + cryptoRootDir: "/tmp/legacy", + userId: "@bot:example.org", + deviceId: "DEVICE123", + }), + ).resolves.toEqual({ + deviceId: "BUNDLED", + roomKeyCounts: { total: 7, backedUp: 6 }, + backupVersion: "1", + decryptionKeyBase64: "YWJjZA==", + }); + }, + { + env: { + OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "bundled"), + }, + }, + ); + }); + + 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 = { + plugins: { + load: { + paths: [customRoot], + }, + }, + } as const; + + expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(true); + const inspectLegacyStore = await loadMatrixLegacyCryptoInspector({ + cfg, + env: process.env, + }); + + await expect( + inspectLegacyStore({ + cryptoRootDir: "/tmp/legacy", + userId: "@bot:example.org", + deviceId: "DEVICE123", + }), + ).resolves.toEqual({ + deviceId: "CONFIG", + roomKeyCounts: null, + backupVersion: null, + decryptionKeyBase64: null, + }); + }, + { + env: { + OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "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 }); + fs.writeFileSync( + path.join(customRoot, "openclaw.plugin.json"), + JSON.stringify({ + id: "matrix", + configSchema: { + type: "object", + additionalProperties: false, + }, + }), + "utf8", + ); + fs.writeFileSync(path.join(customRoot, "index.js"), "export default {};\n", "utf8"); + 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 = { + plugins: { + load: { + paths: [customRoot], + }, + }, + } as const; + + 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-plugin-helper.ts b/src/infra/matrix-plugin-helper.ts new file mode 100644 index 00000000000..0e1f0c404dd --- /dev/null +++ b/src/infra/matrix-plugin-helper.ts @@ -0,0 +1,164 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createJiti } from "jiti"; +import type { OpenClawConfig } from "../config/config.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRecord, +} from "../plugins/manifest-registry.js"; +import { openBoundaryFileSync } from "./boundary-file-read.js"; + +const MATRIX_PLUGIN_ID = "matrix"; +const MATRIX_HELPER_CANDIDATES = [ + "legacy-crypto-inspector.ts", + "legacy-crypto-inspector.js", + path.join("dist", "legacy-crypto-inspector.js"), +] as const; + +export const MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE = + "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."; + +type MatrixLegacyCryptoInspectorParams = { + cryptoRootDir: string; + userId: string; + deviceId: string; + log?: (message: string) => void; +}; + +type MatrixLegacyCryptoInspectorResult = { + deviceId: string | null; + roomKeyCounts: { + total: number; + backedUp: number; + } | null; + backupVersion: string | null; + decryptionKeyBase64: string | null; +}; + +export type MatrixLegacyCryptoInspector = ( + params: MatrixLegacyCryptoInspectorParams, +) => Promise; + +function resolveMatrixPluginRecord(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + workspaceDir?: string; +}): PluginManifestRecord | null { + const registry = loadPluginManifestRegistry({ + config: params.cfg, + workspaceDir: params.workspaceDir, + env: params.env, + }); + return registry.plugins.find((plugin) => plugin.id === MATRIX_PLUGIN_ID) ?? null; +} + +type MatrixLegacyCryptoInspectorPathResolution = + | { status: "ok"; helperPath: string } + | { status: "missing" } + | { status: "unsafe"; candidatePath: string }; + +function resolveMatrixLegacyCryptoInspectorPath(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + workspaceDir?: string; +}): MatrixLegacyCryptoInspectorPathResolution { + const plugin = resolveMatrixPluginRecord(params); + if (!plugin) { + return { status: "missing" }; + } + for (const relativePath of MATRIX_HELPER_CANDIDATES) { + const candidatePath = path.join(plugin.rootDir, relativePath); + const opened = openBoundaryFileSync({ + absolutePath: candidatePath, + rootPath: plugin.rootDir, + boundaryLabel: "plugin root", + rejectHardlinks: plugin.origin !== "bundled", + allowedType: "file", + }); + if (opened.ok) { + fs.closeSync(opened.fd); + return { status: "ok", helperPath: opened.path }; + } + if (opened.reason !== "path") { + return { status: "unsafe", candidatePath }; + } + } + return { status: "missing" }; +} + +export function isMatrixLegacyCryptoInspectorAvailable(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + workspaceDir?: string; +}): boolean { + return resolveMatrixLegacyCryptoInspectorPath(params).status === "ok"; +} + +let jitiLoader: ReturnType | null = null; +const inspectorCache = new Map>(); + +function getJiti() { + if (!jitiLoader) { + jitiLoader = createJiti(import.meta.url, { + interopDefault: false, + extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"], + }); + } + return jitiLoader; +} + +type LoadedMatrixLegacyCryptoInspectorModule = { + inspectLegacyMatrixCryptoStore?: unknown; + default?: unknown; +}; + +export async function loadMatrixLegacyCryptoInspector(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + workspaceDir?: string; +}): Promise { + const resolution = resolveMatrixLegacyCryptoInspectorPath(params); + if (resolution.status === "missing") { + throw new Error(MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE); + } + if (resolution.status === "unsafe") { + throw new Error( + `Matrix plugin helper path is unsafe: ${resolution.candidatePath}. Reinstall @openclaw/matrix and try again.`, + ); + } + const helperPath = resolution.helperPath; + + const cached = inspectorCache.get(helperPath); + if (cached) { + return await cached; + } + + const pending = (async () => { + const loaded = await getJiti().import(helperPath); + const defaultExport = + loaded.default && typeof loaded.default === "object" + ? (loaded.default as LoadedMatrixLegacyCryptoInspectorModule) + : null; + const inspectLegacyMatrixCryptoStore = + typeof loaded?.inspectLegacyMatrixCryptoStore === "function" + ? loaded.inspectLegacyMatrixCryptoStore + : typeof loaded?.default === "function" + ? loaded.default + : typeof defaultExport?.inspectLegacyMatrixCryptoStore === "function" + ? defaultExport.inspectLegacyMatrixCryptoStore + : null; + if (!inspectLegacyMatrixCryptoStore) { + throw new Error( + `Matrix plugin helper at ${helperPath} does not export inspectLegacyMatrixCryptoStore(). Reinstall @openclaw/matrix and try again.`, + ); + } + return inspectLegacyMatrixCryptoStore as MatrixLegacyCryptoInspector; + })(); + inspectorCache.set(helperPath, pending); + try { + return await pending; + } catch (err) { + inspectorCache.delete(helperPath); + throw err; + } +} diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 40487e4bfb5..5a5f0d3b7f4 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -21,7 +21,6 @@ export { addAllowlistUserEntriesFromConfigEntry, buildAllowlistResolutionSummary, canonicalizeAllowlistWithResolvedIds, - mergeAllowlist, patchAllowlistUsersInConfigEntries, summarizeMapping, } from "../channels/allowlists/resolve-utils.js"; @@ -44,7 +43,6 @@ export { export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { - buildSingleChannelSecretPromptState, addWildcardAllowFrom, mergeAllowFromEntries, promptAccountId, @@ -55,10 +53,8 @@ export { promptChannelAccessConfig } from "../channels/plugins/setup-group-acces export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, moveSingleAccountChannelSectionToDefaultAccount, } from "../channels/plugins/setup-helpers.js"; -export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; export type { BaseProbeResult, ChannelDirectoryEntry, @@ -74,7 +70,6 @@ export type { } from "../channels/plugins/types.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export { createReplyPrefixOptions } from "../channels/reply-prefix.js"; -export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js"; export { resolveThreadBindingFarewellText } from "../channels/thread-bindings-messages.js"; export { resolveThreadBindingIdleTimeoutMsForChannel, @@ -105,28 +100,17 @@ export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js"; export { - hashMatrixAccessToken, resolveMatrixAccountStorageRoot, resolveMatrixCredentialsDir, - resolveMatrixCredentialsFilename, resolveMatrixCredentialsPath, - resolveMatrixHomeserverKey, resolveMatrixLegacyFlatStoragePaths, - sanitizeMatrixPathSegment, } from "../infra/matrix-storage-paths.js"; export { requiresExplicitMatrixDefaultAccount, resolveMatrixDefaultOrOnlyAccountId, } from "../infra/matrix-account-selection.js"; -export { - hasActionableMatrixMigration, - hasPendingMatrixMigration, - maybeCreateMatrixMigrationSnapshot, - resolveMatrixMigrationSnapshotMarkerPath, - resolveMatrixMigrationSnapshotOutputDir, -} from "../infra/matrix-migration-snapshot.js"; +export { maybeCreateMatrixMigrationSnapshot } from "../infra/matrix-migration-snapshot.js"; export { getAgentScopedMediaLocalRoots } from "../media/local-roots.js"; -export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; export { isPrivateOrLoopbackHost } from "../gateway/net.js"; export { getSessionBindingService, @@ -136,10 +120,8 @@ export { export { resolveOutboundSendDep } from "../infra/outbound/send-deps.js"; export type { BindingTargetKind, - SessionBindingAdapter, SessionBindingRecord, } from "../infra/outbound/session-binding-service.js"; -export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; @@ -152,19 +134,10 @@ export { resolveAgentIdFromSessionKey, } from "../routing/session-key.js"; export type { RuntimeEnv } from "../runtime.js"; -export { - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, -} from "../security/dm-policy-shared.js"; export { normalizeStringEntries } from "../shared/string-normalization.js"; export { formatDocsLink } from "../terminal/links.js"; export { redactSensitiveText } from "../logging/redact.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { - evaluateGroupRouteAccessForPolicy, - resolveSenderScopedGroupPolicy, -} from "./group-access.js"; -export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js"; export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; export { createScopedPairingAccess } from "./pairing-access.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js";