Matrix: simplify plugin migration plumbing

This commit is contained in:
Gustavo Madeira Santana 2026-03-12 11:45:32 +00:00
parent efc07dc6ca
commit 0298bc01fb
No known key found for this signature in database
30 changed files with 909 additions and 374 deletions

View File

@ -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 <id>`.
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 <id>` for CLI commands that rely on implicit account selection.
Pass `--account <id>` 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`).

View File

@ -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.

View File

@ -0,0 +1,2 @@
export type { MatrixLegacyCryptoInspectionResult } from "./src/matrix/legacy-crypto-inspector.js";
export { inspectLegacyMatrixCryptoStore } from "./src/matrix/legacy-crypto-inspector.js";

View File

@ -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([]);
});
});

View File

@ -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),

View File

@ -478,7 +478,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
//
// 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<ResolvedMatrixAccount> = {
// 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

View File

@ -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(),
});

View File

@ -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");
});
});

View File

@ -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: {

View File

@ -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);

View File

@ -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<void> {
if (readiness === "started") {
await client.start();
return;
}
if (readiness === "prepared" || (!readiness && opts.createdForOneOff)) {
await client.prepareForOneOff();
}
}
export async function resolveActionClient(
opts: MatrixActionClientOpts = {},
): Promise<MatrixActionClient> {
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<void> {
if (!resolved.stopOnDone) {
return;
}
if (mode === "persist") {
await resolved.client.stopAndPersist();
return;
}
resolved.client.stop();
}
export async function withResolvedActionClient<T>(
opts: MatrixActionClientOpts,
run: (client: MatrixActionClient["client"]) => Promise<T>,
mode: MatrixActionClientStopMode = "stop",
): Promise<T> {
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<T>(

View File

@ -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> | void;
export function ensureMatrixNodeRuntime() {
async function ensureResolvedClientReadiness(params: {
client: MatrixClient;
readiness?: MatrixRuntimeClientReadiness;
createdForOneOff: boolean;
}): Promise<void> {
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<ResolvedRuntimeMatrixClient> {
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<void> {
if (!resolved.stopOnDone) {
return;
}
if (mode === "persist") {
await resolved.client.stopAndPersist();
return;
}
resolved.client.stop();
}
export async function withResolvedRuntimeMatrixClient<T>(
opts: {
client?: MatrixClient;
cfg?: CoreConfig;
timeoutMs?: number;
accountId?: string | null;
readiness?: MatrixRuntimeClientReadiness;
},
run: (client: MatrixClient) => Promise<T>,
stopMode: ResolvedRuntimeMatrixClientStopMode = "stop",
): Promise<T> {
const resolved = await resolveRuntimeMatrixClientWithReadiness(opts);
try {
return await run(resolved.client);
} finally {
await stopResolvedRuntimeMatrixClient(resolved, stopMode);
}
}

View File

@ -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 <id>/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",

View File

@ -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";

View File

@ -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 <id>.',
);
}
const resolved = resolveMatrixConfigForAccount(cfg, effectiveAccountId, env);
return {

View File

@ -175,15 +175,6 @@ export async function resolveSharedMatrixClient(
}
}
export async function waitForMatrixSync(_params: {
client: MatrixClient;
timeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise<void> {
// 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();

View File

@ -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";

View File

@ -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<MatrixLegacyCryptoInspectionResult> {
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();
}
}

View File

@ -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);

View File

@ -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<T>(
opts: {
client?: MatrixClient;
@ -56,10 +28,11 @@ export async function withResolvedMatrixClient<T>(
},
run: (client: MatrixClient) => Promise<T>,
): Promise<T> {
const resolved = await resolveMatrixClient(opts);
try {
return await run(resolved.client);
} finally {
stopResolvedMatrixClient(resolved);
}
return await withResolvedRuntimeMatrixClient(
{
...opts,
readiness: "prepared",
},
run,
);
}

View File

@ -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");
});
});

View File

@ -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,

View File

@ -1,13 +0,0 @@
import {
buildSecretInputSchema,
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/matrix";
export {
buildSecretInputSchema,
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
};

View File

@ -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"),
},
},
);
});
});

View File

@ -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<MatrixLegacyCryptoSummary>;
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<MatrixLegacyCryptoSummary> {
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<MatrixLegacyCryptoPrepareDeps>;
}): Promise<MatrixLegacyCryptoPreparationResult> {
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(

View File

@ -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"),
},
},
);
});
});

View File

@ -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: {

View File

@ -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"),
},
},
);
});
});

View File

@ -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<MatrixLegacyCryptoInspectorResult>;
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<typeof createJiti> | null = null;
const inspectorCache = new Map<string, Promise<MatrixLegacyCryptoInspector>>();
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<MatrixLegacyCryptoInspector> {
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;
}
}

View File

@ -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";