Matrix: simplify plugin migration plumbing

This commit is contained in:
Gustavo Madeira Santana
2026-03-12 11:45:32 +00:00
parent 2504a610e4
commit 17695f83d1
30 changed files with 909 additions and 384 deletions

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

@@ -447,7 +447,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 = () => {};
@@ -458,9 +458,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/compat";
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

@@ -7,6 +7,7 @@ import {
normalizeAccountId,
promptAccountId,
promptChannelAccessConfig,
requiresExplicitMatrixDefaultAccount,
type RuntimeEnv,
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
@@ -492,12 +493,21 @@ async function runMatrixConfigure(params: {
export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
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,
};