Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,142 @@
// Matrix tests cover account selection plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import {
findMatrixAccountEntry,
requiresExplicitMatrixDefaultAccount,
resolveConfiguredMatrixAccountIds,
resolveMatrixDefaultOrOnlyAccountId,
} from "./account-selection.js";
import { getMatrixScopedEnvVarNames } from "./env-vars.js";
describe("matrix account selection", () => {
it("resolves configured account ids from non-canonical account keys", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
"Team Ops": { homeserver: "https://matrix.example.org" },
},
},
},
};
expect(resolveConfiguredMatrixAccountIds(cfg)).toEqual(["team-ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
});
it("matches the default account against normalized Matrix account keys", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
defaultAccount: "Team Ops",
accounts: {
"Ops Bot": { homeserver: "https://matrix.example.org" },
"Team Ops": { homeserver: "https://matrix.example.org" },
},
},
},
};
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false);
});
it("requires an explicit default when multiple Matrix accounts exist without one", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: { homeserver: "https://matrix.example.org" },
alerts: { homeserver: "https://matrix.example.org" },
},
},
},
};
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(true);
});
it('uses a named "default" Matrix account when defaultAccount is unset', () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
default: { homeserver: "https://matrix.example.org" },
ops: { homeserver: "https://matrix.example.org" },
},
},
},
};
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("default");
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false);
});
it("finds the raw Matrix account entry by normalized account id", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
"Team Ops": {
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
},
},
},
},
};
expect(findMatrixAccountEntry(cfg, "team-ops")).toEqual({
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
});
});
it("discovers env-backed named Matrix accounts during enumeration", () => {
const keys = getMatrixScopedEnvVarNames("team-ops");
const cfg: OpenClawConfig = {
channels: {
matrix: {},
},
};
const env = {
[keys.homeserver]: "https://matrix.example.org",
[keys.accessToken]: "secret",
} satisfies NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["team-ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("team-ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
it('uses the "default" Matrix account when mixed default and named env-backed accounts exist', () => {
const keys = getMatrixScopedEnvVarNames("team-ops");
const cfg: OpenClawConfig = {
channels: {
matrix: {},
},
};
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_ACCESS_TOKEN: "default-secret",
[keys.homeserver]: "https://matrix.example.org",
[keys.accessToken]: "team-secret",
} satisfies NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "team-ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
it("discovers default Matrix accounts backed only by global env vars", () => {
const cfg: OpenClawConfig = {};
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_ACCESS_TOKEN: "default-secret",
} satisfies NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
});
});

View File

@@ -0,0 +1,224 @@
// Matrix plugin module implements account selection behavior.
import {
listCombinedAccountIds,
listConfiguredAccountIds,
resolveListedDefaultAccountId,
resolveNormalizedAccountEntry,
} from "openclaw/plugin-sdk/account-core";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
resolveMatrixAccountStringValues,
type MatrixResolvedStringField,
} from "./auth-precedence.js";
import { getMatrixScopedEnvVarNames, listMatrixEnvAccountIds } from "./env-vars.js";
import { isRecord } from "./record-shared.js";
type MatrixTopologyStringSources = Partial<Record<MatrixResolvedStringField, string>>;
function readConfiguredMatrixString(value: unknown): string {
return normalizeOptionalString(value) ?? "";
}
function readConfiguredMatrixSecretSource(value: unknown): string {
return hasConfiguredSecretInput(value) ? "configured" : "";
}
function resolveMatrixChannelStringSources(
entry: Record<string, unknown> | null,
): MatrixTopologyStringSources {
if (!entry) {
return {};
}
return {
homeserver: readConfiguredMatrixString(entry.homeserver),
userId: readConfiguredMatrixString(entry.userId),
accessToken: readConfiguredMatrixSecretSource(entry.accessToken),
password: readConfiguredMatrixSecretSource(entry.password),
deviceId: readConfiguredMatrixString(entry.deviceId),
deviceName: readConfiguredMatrixString(entry.deviceName),
};
}
function readEnvMatrixString(env: NodeJS.ProcessEnv, key: string): string {
return normalizeOptionalString(env[key]) ?? "";
}
function resolveScopedMatrixEnvStringSources(
accountId: string,
env: NodeJS.ProcessEnv,
): MatrixTopologyStringSources {
const keys = getMatrixScopedEnvVarNames(accountId);
return {
homeserver: readEnvMatrixString(env, keys.homeserver),
userId: readEnvMatrixString(env, keys.userId),
accessToken: readEnvMatrixString(env, keys.accessToken),
password: readEnvMatrixString(env, keys.password),
deviceId: readEnvMatrixString(env, keys.deviceId),
deviceName: readEnvMatrixString(env, keys.deviceName),
};
}
function resolveGlobalMatrixEnvStringSources(env: NodeJS.ProcessEnv): MatrixTopologyStringSources {
return {
homeserver: readEnvMatrixString(env, "MATRIX_HOMESERVER"),
userId: readEnvMatrixString(env, "MATRIX_USER_ID"),
accessToken: readEnvMatrixString(env, "MATRIX_ACCESS_TOKEN"),
password: readEnvMatrixString(env, "MATRIX_PASSWORD"),
deviceId: readEnvMatrixString(env, "MATRIX_DEVICE_ID"),
deviceName: readEnvMatrixString(env, "MATRIX_DEVICE_NAME"),
};
}
function hasUsableResolvedMatrixAuth(values: {
homeserver: string;
userId: string;
accessToken: string;
}): boolean {
// Account discovery must keep homeserver+userId shapes because auth can still
// resolve through cached Matrix credentials even when no fresh token/password
// is present in config or env.
return Boolean(values.homeserver && (values.accessToken || values.userId));
}
function hasFreshResolvedMatrixAuth(values: {
homeserver: string;
userId: string;
accessToken: string;
password: string;
}): boolean {
return Boolean(values.homeserver && (values.accessToken || (values.userId && values.password)));
}
function resolveEffectiveMatrixAccountSources(params: {
channel: Record<string, unknown> | null;
accountId: string;
env: NodeJS.ProcessEnv;
}): ReturnType<typeof resolveMatrixAccountStringValues> {
const normalizedAccountId = normalizeAccountId(params.accountId);
return resolveMatrixAccountStringValues({
accountId: normalizedAccountId,
scopedEnv: resolveScopedMatrixEnvStringSources(normalizedAccountId, params.env),
channel: resolveMatrixChannelStringSources(params.channel),
globalEnv: resolveGlobalMatrixEnvStringSources(params.env),
});
}
function hasUsableEffectiveMatrixAccountSource(params: {
channel: Record<string, unknown> | null;
accountId: string;
env: NodeJS.ProcessEnv;
}): boolean {
return hasUsableResolvedMatrixAuth(resolveEffectiveMatrixAccountSources(params));
}
function hasFreshEffectiveMatrixAccountSource(params: {
channel: Record<string, unknown> | null;
accountId: string;
env: NodeJS.ProcessEnv;
}): boolean {
return hasFreshResolvedMatrixAuth(resolveEffectiveMatrixAccountSources(params));
}
function hasConfiguredDefaultMatrixAccountSource(params: {
channel: Record<string, unknown> | null;
env: NodeJS.ProcessEnv;
}): boolean {
return hasFreshEffectiveMatrixAccountSource({
channel: params.channel,
accountId: DEFAULT_ACCOUNT_ID,
env: params.env,
});
}
export function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record<string, unknown> | null {
return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null;
}
export function findMatrixAccountEntry(
cfg: OpenClawConfig,
accountId: string,
): Record<string, unknown> | null {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return null;
}
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
if (!accounts) {
return null;
}
const entry = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
return isRecord(entry) ? entry : null;
}
export function resolveConfiguredMatrixAccountIds(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): string[] {
const channel = resolveMatrixChannelConfig(cfg);
const configuredAccountIds = listConfiguredAccountIds({
accounts: channel && isRecord(channel.accounts) ? channel.accounts : undefined,
normalizeAccountId,
});
if (hasConfiguredDefaultMatrixAccountSource({ channel, env })) {
configuredAccountIds.push(DEFAULT_ACCOUNT_ID);
}
const readyEnvAccountIds = listMatrixEnvAccountIds(env).filter((accountId) =>
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID
? hasConfiguredDefaultMatrixAccountSource({ channel, env })
: hasUsableEffectiveMatrixAccountSource({ channel, accountId, env }),
);
return listCombinedAccountIds({
configuredAccountIds,
additionalAccountIds: readyEnvAccountIds,
fallbackAccountIdWhenEmpty: channel ? DEFAULT_ACCOUNT_ID : undefined,
});
}
export function resolveMatrixDefaultOrOnlyAccountId(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): string {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return DEFAULT_ACCOUNT_ID;
}
const configuredDefault = normalizeOptionalAccountId(
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
);
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg, env);
return resolveListedDefaultAccountId({
accountIds: configuredAccountIds,
configuredDefaultAccountId: configuredDefault,
ambiguousFallbackAccountId: DEFAULT_ACCOUNT_ID,
});
}
export function requiresExplicitMatrixDefaultAccount(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return false;
}
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg, env);
if (configuredAccountIds.length <= 1) {
return false;
}
if (configuredAccountIds.includes(DEFAULT_ACCOUNT_ID)) {
return false;
}
const configuredDefault = normalizeOptionalAccountId(
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
);
return !(configuredDefault && configuredAccountIds.includes(configuredDefault));
}

View File

@@ -0,0 +1,238 @@
// Matrix tests cover actions.account propagation plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelMessageActionContext } from "../runtime-api.js";
import type { CoreConfig } from "./types.js";
const mocks = vi.hoisted(() => ({
handleMatrixAction: vi.fn(),
}));
vi.mock("./tool-actions.js", () => ({
handleMatrixAction: mocks.handleMatrixAction,
}));
const { matrixMessageActions } = await import("./actions.js");
const profileAction = "set-profile" as ChannelMessageActionContext["action"];
function matrixActionCall() {
const call = mocks.handleMatrixAction.mock.calls[0];
if (!call) {
throw new Error("expected handleMatrixAction call");
}
return {
input: call[0] as Record<string, unknown>,
cfg: call[1],
options: call[2],
};
}
function createContext(
overrides: Partial<ChannelMessageActionContext>,
): ChannelMessageActionContext {
return {
channel: "matrix",
action: "send",
cfg: {
channels: {
matrix: {
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
},
},
} as CoreConfig,
params: {},
...overrides,
};
}
describe("matrixMessageActions account propagation", () => {
beforeEach(() => {
mocks.handleMatrixAction.mockReset().mockResolvedValue({
ok: true,
output: "",
details: { ok: true },
});
});
it("forwards accountId for send actions", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: "send",
accountId: "ops",
params: {
to: "room:!room:example",
message: "hello",
},
}),
);
const call = matrixActionCall();
expect(call.input.action).toBe("sendMessage");
expect(call.input.accountId).toBe("ops");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: undefined });
});
it("forwards accountId for permissions actions", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: "permissions",
accountId: "ops",
params: {
operation: "verification-list",
},
}),
);
const call = matrixActionCall();
expect(call.input.action).toBe("verificationList");
expect(call.input.accountId).toBe("ops");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: undefined });
});
it("forwards accountId for self-profile updates", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
accountId: "ops",
senderIsOwner: true,
params: {
displayName: "Ops Bot",
avatarUrl: "mxc://example/avatar",
},
}),
);
const call = matrixActionCall();
expect(call.input.action).toBe("setProfile");
expect(call.input.accountId).toBe("ops");
expect(call.input.displayName).toBe("Ops Bot");
expect(call.input.avatarUrl).toBe("mxc://example/avatar");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: undefined });
});
it("rejects self-profile updates without sender owner context", async () => {
await expect(
matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
accountId: "ops",
params: {
displayName: "Ops Bot",
},
}),
),
).rejects.toThrow("Matrix profile updates require owner access.");
});
it("dispatches self-profile updates with sender owner context", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
accountId: "ops",
senderIsOwner: true,
params: {
displayName: "Ops Bot",
},
}),
);
const call = matrixActionCall();
expect(call.input).toMatchObject({
action: "setProfile",
accountId: "ops",
displayName: "Ops Bot",
});
});
it("forwards local avatar paths for self-profile updates", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
accountId: "ops",
senderIsOwner: true,
params: {
path: "/tmp/avatar.jpg",
},
}),
);
const call = matrixActionCall();
expect(call.input.action).toBe("setProfile");
expect(call.input.accountId).toBe("ops");
expect(call.input.avatarPath).toBe("/tmp/avatar.jpg");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: undefined });
});
it("forwards mediaLocalRoots for media sends", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: "send",
accountId: "ops",
mediaLocalRoots: ["/tmp/openclaw-matrix-test"],
params: {
to: "room:!room:example",
message: "hello",
media: "file:///tmp/photo.png",
},
}),
);
const call = matrixActionCall();
expect(call.input.action).toBe("sendMessage");
expect(call.input.accountId).toBe("ops");
expect(call.input.mediaUrl).toBe("file:///tmp/photo.png");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: ["/tmp/openclaw-matrix-test"] });
});
it("allows media-only sends without requiring a message body", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: "send",
accountId: "ops",
params: {
to: "room:!room:example",
media: "file:///tmp/photo.png",
},
}),
);
const call = matrixActionCall();
expect(call.input.action).toBe("sendMessage");
expect(call.input.accountId).toBe("ops");
expect(call.input.content).toBeUndefined();
expect(call.input.mediaUrl).toBe("file:///tmp/photo.png");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: undefined });
});
it("accepts shared media aliases and forwards voice-send intent", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: "send",
accountId: "ops",
params: {
to: "room:!room:example",
filePath: "/tmp/clip.mp3",
asVoice: true,
},
}),
);
const call = matrixActionCall();
expect(call.input.action).toBe("sendMessage");
expect(call.input.accountId).toBe("ops");
expect(call.input.content).toBeUndefined();
expect(call.input.mediaUrl).toBe("/tmp/clip.mp3");
expect(call.input.audioAsVoice).toBe(true);
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: undefined });
});
});

View File

@@ -0,0 +1,249 @@
// Matrix tests cover actions plugin behavior.
import { beforeEach, describe, expect, it } from "vitest";
import type { PluginRuntime } from "../runtime-api.js";
import { matrixMessageActions } from "./actions.js";
import { setMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
const profileAction = "set-profile" as const;
const runtimeStub = {
config: {
current: () => ({}),
},
media: {
loadWebMedia: async () => {
throw new Error("not used");
},
mediaKindFromMime: () => "image",
isVoiceCompatibleAudio: () => false,
getImageMetadata: async () => null,
resizeToJpeg: async () => Buffer.from(""),
},
state: {
resolveStateDir: () => "/tmp/openclaw-matrix-test",
},
channel: {
text: {
resolveTextChunkLimit: () => 4000,
resolveChunkMode: () => "length",
chunkMarkdownText: (text: string) => (text ? [text] : []),
chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []),
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},
},
} as unknown as PluginRuntime;
function createConfiguredMatrixConfig(): CoreConfig {
return {
channels: {
matrix: {
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
},
},
} as CoreConfig;
}
describe("matrixMessageActions", () => {
beforeEach(() => {
setMatrixRuntime(runtimeStub);
});
it("exposes poll create but only handles poll votes inside the plugin", () => {
const describeMessageTool = matrixMessageActions.describeMessageTool;
const supportsAction = matrixMessageActions.supportsAction ?? (() => false);
expect(describeMessageTool).toBeTypeOf("function");
expect(supportsAction).toBeTypeOf("function");
const discovery = describeMessageTool({
cfg: createConfiguredMatrixConfig(),
} as never);
if (!discovery) {
throw new Error("describeMessageTool returned null");
}
const actions = discovery.actions;
expect(actions).toContain("poll");
expect(actions).toContain("poll-vote");
expect(supportsAction({ action: "poll" } as never)).toBe(false);
expect(supportsAction({ action: "poll-vote" } as never)).toBe(true);
});
it("exposes and describes self-profile updates", () => {
const describeMessageTool = matrixMessageActions.describeMessageTool;
const supportsAction = matrixMessageActions.supportsAction ?? (() => false);
const discovery = describeMessageTool({
cfg: createConfiguredMatrixConfig(),
senderIsOwner: true,
} as never);
if (!discovery) {
throw new Error("describeMessageTool returned null");
}
const actions = discovery.actions;
const schema = discovery.schema;
if (!schema) {
throw new Error("matrix schema missing");
}
const properties = (schema as { properties?: Record<string, unknown> }).properties ?? {};
expect(actions).toContain(profileAction);
expect(supportsAction({ action: profileAction } as never)).toBe(true);
expect(discovery.mediaSourceParams).toEqual({
"set-profile": ["avatarUrl", "avatarPath"],
});
expect(Object.keys(properties).toSorted()).toEqual([
"avatarPath",
"avatarUrl",
"avatar_path",
"avatar_url",
"displayName",
"display_name",
]);
expect(properties.displayName).toHaveProperty("type", "string");
expect(properties.avatarUrl).toHaveProperty("type", "string");
expect(properties.avatarPath).toHaveProperty("type", "string");
});
it("hides self-profile updates without owner identity context", () => {
const discovery = matrixMessageActions.describeMessageTool({
cfg: createConfiguredMatrixConfig(),
} as never);
if (!discovery) {
throw new Error("describeMessageTool returned null");
}
expect(discovery.actions).not.toContain(profileAction);
});
it("hides gated actions when the default Matrix account disables them", () => {
const discovery = matrixMessageActions.describeMessageTool({
cfg: {
channels: {
matrix: {
defaultAccount: "assistant",
actions: {
messages: true,
reactions: true,
pins: true,
profile: true,
memberInfo: true,
channelInfo: true,
verification: true,
},
accounts: {
assistant: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
actions: {
messages: false,
reactions: false,
pins: false,
profile: false,
memberInfo: false,
channelInfo: false,
verification: false,
},
},
},
},
},
} as CoreConfig,
} as never);
if (!discovery) {
throw new Error("describeMessageTool returned null");
}
const actions = discovery.actions;
expect(actions).toEqual(["poll", "poll-vote"]);
});
it("hides actions until defaultAccount is set for ambiguous multi-account configs", () => {
const discovery = matrixMessageActions.describeMessageTool({
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);
if (!discovery) {
throw new Error("describeMessageTool returned null");
}
const actions = discovery.actions;
expect(actions).toStrictEqual([]);
});
it("honors the selected Matrix account during discovery", () => {
const cfg = {
channels: {
matrix: {
defaultAccount: "assistant",
accounts: {
assistant: {
homeserver: "https://matrix.example.org",
userId: "@assistant:example.org",
accessToken: "assistant-token",
actions: {
messages: true,
reactions: false,
},
},
ops: {
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
actions: {
messages: true,
reactions: true,
},
},
},
},
},
} as CoreConfig;
const describeMessageTool = matrixMessageActions.describeMessageTool;
if (!describeMessageTool) {
throw new Error("matrix message action discovery is unavailable");
}
const assistantDiscovery = describeMessageTool({
cfg,
accountId: "assistant",
} as never);
const opsDiscovery = describeMessageTool({
cfg,
accountId: "ops",
} as never);
if (!assistantDiscovery || !opsDiscovery) {
throw new Error("matrix action discovery returned null");
}
const assistantActions = assistantDiscovery.actions;
const opsActions = opsDiscovery.actions;
expect(assistantActions).not.toContain("react");
expect(assistantActions).not.toContain("reactions");
expect(opsActions).toContain("react");
expect(opsActions).toContain("reactions");
});
});

View File

@@ -0,0 +1,352 @@
// Matrix plugin module implements actions behavior.
import {
createActionGate,
readPositiveIntegerParam,
readStringParam,
ToolAuthorizationError,
} from "openclaw/plugin-sdk/channel-actions";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionContext,
ChannelMessageActionName,
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-contract";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import { Type } from "typebox";
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
import { resolveDefaultMatrixAccountId, resolveMatrixAccount } from "./matrix/accounts.js";
import type { CoreConfig } from "./types.js";
const MATRIX_PLUGIN_HANDLED_ACTIONS = new Set<ChannelMessageActionName>([
"send",
"poll-vote",
"react",
"reactions",
"read",
"edit",
"delete",
"pin",
"unpin",
"list-pins",
"set-profile",
"member-info",
"channel-info",
"permissions",
]);
const MATRIX_PROFILE_MEDIA_PROPERTIES = {
avatarUrl: Type.Optional(
Type.String({
description:
"Profile avatar URL for Matrix self-profile update actions. Matrix accepts mxc:// and http(s) URLs.",
}),
),
avatar_url: Type.Optional(
Type.String({
description:
"snake_case alias of avatarUrl for Matrix self-profile update actions. Matrix accepts mxc:// and http(s) URLs.",
}),
),
avatarPath: Type.Optional(
Type.String({
description:
"Local avatar file path for Matrix self-profile update actions. Matrix uploads this file and sets the resulting MXC URI.",
}),
),
avatar_path: Type.Optional(
Type.String({
description:
"snake_case alias of avatarPath for Matrix self-profile update actions. Matrix uploads this file and sets the resulting MXC URI.",
}),
),
} as const;
const MATRIX_PROFILE_MEDIA_SOURCE_PARAMS = Object.freeze(["avatarUrl", "avatarPath"]);
function createMatrixExposedActions(params: {
gate: ReturnType<typeof createActionGate>;
encryptionEnabled: boolean;
senderIsOwner?: boolean;
}) {
const actions = new Set<ChannelMessageActionName>(["poll", "poll-vote"]);
if (params.gate("messages")) {
actions.add("send");
actions.add("read");
actions.add("edit");
actions.add("delete");
}
if (params.gate("reactions")) {
actions.add("react");
actions.add("reactions");
}
if (params.gate("pins")) {
actions.add("pin");
actions.add("unpin");
actions.add("list-pins");
}
if (params.gate("profile") && params.senderIsOwner === true) {
actions.add("set-profile");
}
if (params.gate("memberInfo")) {
actions.add("member-info");
}
if (params.gate("channelInfo")) {
actions.add("channel-info");
}
if (params.encryptionEnabled && params.gate("verification")) {
actions.add("permissions");
}
return actions;
}
function buildMatrixProfileToolSchema(): NonNullable<ChannelMessageToolDiscovery["schema"]> {
return {
actions: ["set-profile"],
properties: {
displayName: Type.Optional(
Type.String({
description: "Profile display name for Matrix self-profile update actions.",
}),
),
display_name: Type.Optional(
Type.String({
description: "snake_case alias of displayName for Matrix self-profile update actions.",
}),
),
...MATRIX_PROFILE_MEDIA_PROPERTIES,
},
};
}
export const matrixMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: ({ cfg, accountId, senderIsOwner }) => {
const resolvedCfg = cfg as CoreConfig;
if (!accountId && requiresExplicitMatrixDefaultAccount(resolvedCfg)) {
return { actions: [], capabilities: [] };
}
const account = resolveMatrixAccount({
cfg: resolvedCfg,
accountId: accountId ?? resolveDefaultMatrixAccountId(resolvedCfg),
});
if (!account.enabled || !account.configured) {
return { actions: [], capabilities: [] };
}
const gate = createActionGate(account.config.actions);
const actions = createMatrixExposedActions({
gate,
encryptionEnabled: account.config.encryption === true,
senderIsOwner,
});
const listedActions = Array.from(actions);
return {
actions: listedActions,
capabilities: [],
schema: listedActions.includes("set-profile") ? buildMatrixProfileToolSchema() : null,
mediaSourceParams: listedActions.includes("set-profile")
? { "set-profile": MATRIX_PROFILE_MEDIA_SOURCE_PARAMS }
: null,
};
},
supportsAction: ({ action }) => MATRIX_PLUGIN_HANDLED_ACTIONS.has(action),
extractToolSend: ({ args }) => {
return extractToolSend(args, "sendMessage");
},
handleAction: async (ctx: ChannelMessageActionContext) => {
const { handleMatrixAction } = await import("./tool-actions.runtime.js");
const { action, params, cfg, accountId, mediaLocalRoots } = ctx;
const dispatch = async (actionParams: Record<string, unknown>) =>
await handleMatrixAction(
{
...actionParams,
...(accountId ? { accountId } : {}),
},
cfg as CoreConfig,
{ mediaLocalRoots },
);
const resolveRoomId = () =>
readStringParam(params, "roomId") ??
readStringParam(params, "channelId") ??
readStringParam(params, "to", { required: true });
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const mediaUrl =
readStringParam(params, "media", { trim: false }) ??
readStringParam(params, "mediaUrl", { trim: false }) ??
readStringParam(params, "filePath", { trim: false }) ??
readStringParam(params, "path", { trim: false });
const content = readStringParam(params, "message", {
required: !mediaUrl,
allowEmpty: true,
});
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const audioAsVoice =
typeof params.asVoice === "boolean"
? params.asVoice
: typeof params.audioAsVoice === "boolean"
? params.audioAsVoice
: undefined;
return await dispatch({
action: "sendMessage",
to,
content,
mediaUrl: mediaUrl ?? undefined,
replyToId: replyTo ?? undefined,
threadId: threadId ?? undefined,
audioAsVoice,
});
}
if (action === "poll-vote") {
return await dispatch({
...params,
action: "pollVote",
});
}
if (action === "react") {
const messageId = readStringParam(params, "messageId", { required: true });
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
return await dispatch({
action: "react",
roomId: resolveRoomId(),
messageId,
emoji,
remove,
});
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", { required: true });
const limit = readPositiveIntegerParam(params, "limit", {
message: "limit must be a positive integer.",
});
return await dispatch({
action: "reactions",
roomId: resolveRoomId(),
messageId,
limit,
});
}
if (action === "read") {
const limit = readPositiveIntegerParam(params, "limit", {
message: "limit must be a positive integer.",
});
return await dispatch({
action: "readMessages",
roomId: resolveRoomId(),
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
threadId: readStringParam(params, "threadId"),
});
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", { required: true });
const content = readStringParam(params, "message", { required: true });
return await dispatch({
action: "editMessage",
roomId: resolveRoomId(),
messageId,
content,
});
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", { required: true });
return await dispatch({
action: "deleteMessage",
roomId: resolveRoomId(),
messageId,
});
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins"
? undefined
: readStringParam(params, "messageId", { required: true });
return await dispatch({
action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
roomId: resolveRoomId(),
messageId,
});
}
if (action === "set-profile") {
if (ctx.senderIsOwner !== true) {
throw new ToolAuthorizationError("Matrix profile updates require owner access.");
}
const avatarPath =
readStringParam(params, "avatarPath") ??
readStringParam(params, "path") ??
readStringParam(params, "filePath");
return await dispatch({
action: "setProfile",
displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"),
avatarUrl: readStringParam(params, "avatarUrl"),
avatarPath,
});
}
if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true });
return await dispatch({
action: "memberInfo",
userId,
roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"),
});
}
if (action === "channel-info") {
return await dispatch({
action: "channelInfo",
roomId: resolveRoomId(),
});
}
if (action === "permissions") {
const operation = normalizeLowercaseStringOrEmpty(
readStringParam(params, "operation") ??
readStringParam(params, "mode") ??
"verification-list",
);
const operationToAction: Record<string, string> = {
"encryption-status": "encryptionStatus",
"verification-status": "verificationStatus",
"verification-bootstrap": "verificationBootstrap",
"verification-recovery-key": "verificationRecoveryKey",
"verification-backup-status": "verificationBackupStatus",
"verification-backup-restore": "verificationBackupRestore",
"verification-list": "verificationList",
"verification-request": "verificationRequest",
"verification-accept": "verificationAccept",
"verification-cancel": "verificationCancel",
"verification-start": "verificationStart",
"verification-generate-qr": "verificationGenerateQr",
"verification-scan-qr": "verificationScanQr",
"verification-sas": "verificationSas",
"verification-confirm": "verificationConfirm",
"verification-mismatch": "verificationMismatch",
"verification-confirm-qr": "verificationConfirmQr",
};
const resolvedAction = operationToAction[operation];
if (!resolvedAction) {
throw new Error(
`Unsupported Matrix permissions operation: ${operation}. Supported values: ${Object.keys(
operationToAction,
).join(", ")}`,
);
}
return await dispatch({
...params,
action: resolvedAction,
});
}
throw new Error(`Action ${action} is not supported for provider matrix.`);
},
};

View File

@@ -0,0 +1,24 @@
// Matrix tests cover approval auth plugin behavior.
import { describe, expect, it } from "vitest";
import { matrixApprovalAuth } from "./approval-auth.js";
describe("matrixApprovalAuth", () => {
it("normalizes Matrix user ids before authorizing", () => {
const cfg = {
channels: {
matrix: {
dm: { allowFrom: ["matrix:@Owner:Example.org"] },
},
},
};
expect(
matrixApprovalAuth.authorizeActorAction({
cfg,
senderId: "@owner:example.org",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ authorized: true });
});
});

View File

@@ -0,0 +1,26 @@
// Matrix plugin module implements approval auth behavior.
import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-auth-runtime";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import type { CoreConfig } from "./types.js";
export function getMatrixApprovalAuthApprovers(params: {
cfg: CoreConfig;
accountId?: string | null;
}): string[] {
const account = resolveMatrixAccount(params);
return resolveApprovalApprovers({
allowFrom: account.config.dm?.allowFrom,
normalizeApprover: normalizeMatrixApproverId,
});
}
export const matrixApprovalAuth = createResolvedApproverActionAuthAdapter({
channelLabel: "Matrix",
resolveApprovers: ({ cfg, accountId }) =>
getMatrixApprovalAuthApprovers({ cfg: cfg as CoreConfig, accountId }),
normalizeSenderId: (value) => normalizeMatrixApproverId(value),
});

View File

@@ -0,0 +1,577 @@
// Matrix tests cover approval handler plugin behavior.
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { matrixApprovalNativeRuntime } from "./approval-handler.runtime.js";
import {
clearMatrixApprovalReactionTargetsForTest,
resolveMatrixApprovalReactionTargetWithPersistence,
} from "./approval-reactions.js";
type MatrixDeliverPendingParams = Parameters<
typeof matrixApprovalNativeRuntime.transport.deliverPending
>[0];
type MatrixPendingApprovalView = MatrixDeliverPendingParams["view"];
type MatrixPendingExecApprovalView = Extract<MatrixPendingApprovalView, { approvalKind: "exec" }>;
type MatrixPendingPluginApprovalView = Extract<
MatrixPendingApprovalView,
{ approvalKind: "plugin" }
>;
const MATRIX_APPROVAL_METADATA_KEY = "com.openclaw.approval";
function expectRecordFields(value: unknown, expected: Record<string, unknown>) {
if (!value || typeof value !== "object") {
throw new Error("Expected record");
}
const actual = value as Record<string, unknown>;
for (const [key, expectedValue] of Object.entries(expected)) {
expect(actual[key]).toEqual(expectedValue);
}
return actual;
}
function mockCall<T extends readonly unknown[]>(mock: { mock: { calls: T[] } }, index = 0) {
return mock.mock.calls.at(index);
}
function buildMatrixReceipt(messageIds: readonly string[], roomId = "!room:example.org") {
return {
primaryPlatformMessageId: messageIds[0],
platformMessageIds: [...messageIds],
parts: messageIds.map((messageId, index) => ({
platformMessageId: messageId,
kind: "text" as const,
index,
raw: {
channel: "matrix",
messageId,
roomId,
},
})),
sentAt: 100,
raw: messageIds.map((messageId) => ({
channel: "matrix",
messageId,
roomId,
})),
};
}
function buildMatrixApprovalRoomTarget(
roomId: string,
): MatrixDeliverPendingParams["plannedTarget"] {
return {
surface: "approver-dm",
target: {
to: `room:${roomId}`,
},
reason: "preferred",
};
}
// Pending approvals expire in the future; the reaction target store TTLs its
// memory layer from `view.expiresAtMs - now`, so epoch-past fixtures would
// evict the mapping before assertions run.
const TEST_APPROVAL_EXPIRES_AT_MS = Date.now() + 5 * 60_000;
function buildExecApprovalView(
overrides: Partial<MatrixPendingExecApprovalView> = {},
): MatrixPendingExecApprovalView {
return {
approvalKind: "exec",
approvalId: "req-1",
phase: "pending",
title: "Exec Approval Required",
description: "A command needs your approval.",
metadata: [],
ask: "on-request",
agentId: "agent-1",
commandText: "echo hi",
commandPreview: "echo hi",
cwd: "/repo",
host: "gateway",
actions: [
{
decision: "allow-once",
label: "Allow Once",
style: "success",
command: "/approve req-1 allow-once",
},
{
decision: "deny",
label: "Deny",
style: "danger",
command: "/approve req-1 deny",
},
],
expiresAtMs: TEST_APPROVAL_EXPIRES_AT_MS,
...overrides,
};
}
function buildPluginApprovalView(
overrides: Partial<MatrixPendingPluginApprovalView> = {},
): MatrixPendingPluginApprovalView {
return {
approvalKind: "plugin",
approvalId: "plugin:req-1",
phase: "pending",
title: "Plugin Approval Required",
description: "Approve the tool call.",
metadata: [],
agentId: "agent-1",
pluginId: "ops",
toolName: "deploy",
severity: "critical",
actions: [
{
decision: "allow-once",
label: "Allow Once",
style: "success",
command: "/approve plugin:req-1 allow-once",
},
],
expiresAtMs: TEST_APPROVAL_EXPIRES_AT_MS,
...overrides,
};
}
async function buildPendingPayload(view: MatrixPendingApprovalView) {
const request =
view.approvalKind === "plugin"
? ({
id: view.approvalId,
request: {
title: view.title,
description: view.description ?? "",
severity: view.severity,
toolName: view.toolName ?? undefined,
pluginId: view.pluginId ?? undefined,
agentId: view.agentId ?? undefined,
},
createdAtMs: 0,
expiresAtMs: view.expiresAtMs,
} satisfies PluginApprovalRequest)
: ({
id: view.approvalId,
request: {
command: view.commandText,
cwd: view.cwd ?? undefined,
host: view.host ?? undefined,
agentId: view.agentId ?? undefined,
},
createdAtMs: 0,
expiresAtMs: view.expiresAtMs,
} satisfies ExecApprovalRequest);
return await matrixApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: { client: {} as never },
request,
approvalKind: view.approvalKind,
nowMs: 100,
view,
});
}
describe("matrixApprovalNativeRuntime", () => {
beforeEach(() => {
clearMatrixApprovalReactionTargetsForTest();
});
it("sends versioned Matrix approval content with pending exec approvals", async () => {
const sendSingleTextMessage = vi.fn().mockResolvedValue({
messageId: "$approval",
primaryMessageId: "$approval",
receipt: buildMatrixReceipt(["$approval"]),
roomId: "!room:example.org",
});
const reactMessage = vi.fn().mockResolvedValue(undefined);
const view = buildExecApprovalView();
const pendingPayload = await buildPendingPayload(view);
await matrixApprovalNativeRuntime.transport.deliverPending({
cfg: {} as never,
accountId: "default",
context: {
client: {} as never,
deps: {
sendSingleTextMessage,
reactMessage,
},
},
request: {} as never,
approvalKind: "exec",
plannedTarget: buildMatrixApprovalRoomTarget("!room:example.org"),
preparedTarget: {
to: "room:!room:example.org",
roomId: "!room:example.org",
},
view,
pendingPayload,
});
const [target, text, options] = mockCall(sendSingleTextMessage) ?? [];
expect(target).toBe("room:!room:example.org");
expect(String(text)).toContain("echo hi");
const extraContent = (options as { extraContent?: Record<string, unknown> } | undefined)
?.extraContent;
expectRecordFields(extraContent?.[MATRIX_APPROVAL_METADATA_KEY], {
version: 1,
type: "approval.request",
state: "pending",
id: "req-1",
kind: "exec",
commandText: "echo hi",
cwd: "/repo",
agentId: "agent-1",
allowedDecisions: ["allow-once", "deny"],
});
});
it("delivers Matrix approval content with plugin approval fields", async () => {
const sendSingleTextMessage = vi.fn().mockResolvedValue({
messageId: "$plugin-approval",
primaryMessageId: "$plugin-approval",
receipt: buildMatrixReceipt(["$plugin-approval"]),
roomId: "!room:example.org",
});
const reactMessage = vi.fn().mockResolvedValue(undefined);
const view = buildPluginApprovalView();
const pendingPayload = await buildPendingPayload(view);
await matrixApprovalNativeRuntime.transport.deliverPending({
cfg: {} as never,
accountId: "default",
context: {
client: {} as never,
deps: {
sendSingleTextMessage,
reactMessage,
},
},
request: {} as never,
approvalKind: "plugin",
plannedTarget: buildMatrixApprovalRoomTarget("!room:example.org"),
preparedTarget: {
to: "room:!room:example.org",
roomId: "!room:example.org",
},
view,
pendingPayload,
});
const [target, text, options] = mockCall(sendSingleTextMessage) ?? [];
expect(target).toBe("room:!room:example.org");
expect(String(text)).toContain("deploy");
const extraContent = (options as { extraContent?: Record<string, unknown> } | undefined)
?.extraContent;
expect(extraContent?.[MATRIX_APPROVAL_METADATA_KEY]).toEqual({
version: 1,
type: "approval.request",
state: "pending",
phase: "pending",
id: "plugin:req-1",
kind: "plugin",
title: "Plugin Approval Required",
description: "Approve the tool call.",
expiresAtMs: TEST_APPROVAL_EXPIRES_AT_MS,
metadata: [],
allowedDecisions: ["allow-once"],
actions: [
{
decision: "allow-once",
label: "Allow Once",
style: "success",
command: "/approve plugin:req-1 allow-once",
},
],
pluginId: "ops",
toolName: "deploy",
agentId: "agent-1",
severity: "critical",
});
expect(mockCall(reactMessage)?.[0]).toBe("!room:example.org");
expect(mockCall(reactMessage)?.[1]).toBe("$plugin-approval");
expect(mockCall(reactMessage)?.[2]).toBe("✅");
expectRecordFields(mockCall(reactMessage)?.[3], { accountId: "default" });
});
it("binds Matrix approval reactions before publishing option reactions", async () => {
const sendSingleTextMessage = vi.fn().mockResolvedValue({
messageId: "$approval",
primaryMessageId: "$approval",
receipt: buildMatrixReceipt(["$approval"]),
roomId: "!room:example.org",
});
const reactMessage = vi.fn().mockImplementation(async () => {
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!room:example.org",
eventId: "$approval",
reactionKey: "✅",
}),
).toEqual({
approvalId: "req-1",
decision: "allow-once",
});
});
const view = buildExecApprovalView();
const pendingPayload = await buildPendingPayload(view);
await matrixApprovalNativeRuntime.transport.deliverPending({
cfg: {} as never,
accountId: "default",
context: {
client: {} as never,
deps: {
sendSingleTextMessage,
reactMessage,
},
},
request: {} as never,
approvalKind: "exec",
plannedTarget: buildMatrixApprovalRoomTarget("!room:example.org"),
preparedTarget: {
to: "room:!room:example.org",
roomId: "!room:example.org",
},
view,
pendingPayload,
});
expect(reactMessage).toHaveBeenCalled();
});
it("retries transient Matrix approval send failures", async () => {
const sendSingleTextMessage = vi
.fn()
.mockRejectedValueOnce(new Error("transient Matrix send failure"))
.mockResolvedValue({
messageId: "$approval",
primaryMessageId: "$approval",
receipt: buildMatrixReceipt(["$approval"]),
roomId: "!room:example.org",
});
const reactMessage = vi.fn().mockResolvedValue(undefined);
const view = buildExecApprovalView();
const pendingPayload = await buildPendingPayload(view);
const entry = await matrixApprovalNativeRuntime.transport.deliverPending({
cfg: {} as never,
accountId: "default",
context: {
client: {} as never,
deps: {
sendSingleTextMessage,
reactMessage,
},
},
request: {} as never,
approvalKind: "exec",
plannedTarget: buildMatrixApprovalRoomTarget("!room:example.org"),
preparedTarget: {
to: "room:!room:example.org",
roomId: "!room:example.org",
},
view,
pendingPayload,
});
expect(sendSingleTextMessage).toHaveBeenCalledTimes(2);
expectRecordFields(entry, {
roomId: "!room:example.org",
platformMessageIds: ["$approval"],
});
});
it("retries transient Matrix direct-room repair failures before preparing approval DMs", async () => {
const repairDirectRooms = vi
.fn()
.mockRejectedValueOnce(new Error("direct account data not ready"))
.mockResolvedValue({
activeRoomId: "!dm:example.org",
});
const prepared = await matrixApprovalNativeRuntime.transport.prepareTarget({
cfg: {
channels: {
matrix: {
encryption: false,
},
},
} as never,
accountId: "default",
context: {
client: {} as never,
deps: {
repairDirectRooms,
},
},
request: {} as never,
approvalKind: "exec",
view: buildExecApprovalView(),
pendingPayload: {} as never,
plannedTarget: {
surface: "approver-dm",
target: {
to: "user:@owner:example.org",
},
reason: "preferred",
},
});
expect(repairDirectRooms).toHaveBeenCalledTimes(2);
const preparedTarget = expectRecordFields(prepared, {});
expect(preparedTarget.target).toEqual({
to: "room:!dm:example.org",
roomId: "!dm:example.org",
threadId: undefined,
});
});
it("falls back to chunked Matrix delivery when approval content exceeds one event", async () => {
const sendSingleTextMessage = vi
.fn()
.mockRejectedValue(new Error("Matrix single-message text exceeds limit (5000 > 4000)"));
const sendMessage = vi.fn().mockResolvedValue({
messageId: "$last",
primaryMessageId: "$legacy-primary",
receipt: buildMatrixReceipt(["$primary", "$last"]),
roomId: "!room:example.org",
});
const reactMessage = vi.fn().mockResolvedValue(undefined);
const view = buildExecApprovalView({
actions: [
{
decision: "allow-once",
label: "Allow Once",
style: "success",
command: "/approve req-1 allow-once",
},
],
});
const pendingPayload = await buildPendingPayload(view);
const entry = await matrixApprovalNativeRuntime.transport.deliverPending({
cfg: {} as never,
accountId: "default",
context: {
client: {} as never,
deps: {
sendSingleTextMessage,
sendMessage,
reactMessage,
},
},
request: {} as never,
approvalKind: "exec",
plannedTarget: buildMatrixApprovalRoomTarget("!room:example.org"),
preparedTarget: {
to: "room:!room:example.org",
roomId: "!room:example.org",
},
view,
pendingPayload,
});
expect(mockCall(sendMessage)?.[0]).toBe("room:!room:example.org");
expect(mockCall(sendMessage)?.[1]).toBe(pendingPayload.text);
expectRecordFields(mockCall(sendMessage)?.[2], {
accountId: "default",
extraContent: pendingPayload.extraContent,
});
expect(mockCall(reactMessage)?.[0]).toBe("!room:example.org");
expect(mockCall(reactMessage)?.[1]).toBe("$primary");
expect(typeof mockCall(reactMessage)?.[2]).toBe("string");
expectRecordFields(mockCall(reactMessage)?.[3], { accountId: "default" });
expectRecordFields(entry, {
roomId: "!room:example.org",
platformMessageIds: ["$primary", "$last"],
reactionEventId: "$primary",
});
const bindPending = matrixApprovalNativeRuntime.interactions?.bindPending;
if (!bindPending) {
throw new Error("Matrix approval runtime must expose bindPending");
}
const binding = await bindPending({
cfg: {} as never,
accountId: "default",
context: {
client: {} as never,
},
request: {} as never,
approvalKind: "exec",
view,
pendingPayload,
entry: entry!,
});
expect(binding).toEqual({
roomId: "!room:example.org",
eventId: "$primary",
});
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!room:example.org",
eventId: "$primary",
reactionKey: "✅",
}),
).toEqual({
approvalId: "req-1",
decision: "allow-once",
});
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!room:example.org",
eventId: "$last",
reactionKey: "✅",
}),
).toBeNull();
});
it("uses a longer code fence when resolved commands contain triple backticks", async () => {
const result = await matrixApprovalNativeRuntime.presentation.buildResolvedResult({
cfg: {} as never,
accountId: "default",
context: {
client: {} as never,
},
request: {
id: "req-1",
request: {
command: "echo hi",
},
createdAtMs: 0,
expiresAtMs: 1_000,
},
resolved: {
id: "req-1",
decision: "allow-once",
ts: 0,
},
view: {
approvalKind: "exec",
approvalId: "req-1",
decision: "allow-once",
commandText: "echo ```danger```",
} as never,
entry: {} as never,
});
expect(result).toEqual({
kind: "update",
payload: [
"Exec approval: Allowed once",
"",
"Command",
"````",
"echo ```danger```",
"````",
].join("\n"),
});
});
});

View File

@@ -0,0 +1,595 @@
// Matrix plugin module implements approval handler behavior.
import { setTimeout as sleep } from "node:timers/promises";
import type {
ChannelApprovalCapabilityHandlerContext,
PendingApprovalView,
ResolvedApprovalView,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import {
buildExecApprovalPendingReplyPayload,
buildPluginApprovalPendingReplyPayload,
type ExecApprovalReplyDecision,
} from "openclaw/plugin-sdk/approval-reply-runtime";
import { buildPluginApprovalResolvedReplyPayload } from "openclaw/plugin-sdk/approval-runtime";
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import {
listMessageReceiptPlatformIds,
resolveMessageReceiptPrimaryId,
} from "openclaw/plugin-sdk/channel-outbound";
import { normalizeUniqueStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
buildMatrixApprovalReactionHint,
listMatrixApprovalReactionBindings,
registerMatrixApprovalReactionTarget,
unregisterMatrixApprovalReactionTarget,
} from "./approval-reactions.js";
import {
isMatrixAnyApprovalClientEnabled,
shouldHandleMatrixApprovalRequest,
} from "./exec-approvals.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { deleteMatrixMessage, editMatrixMessage } from "./matrix/actions/messages.js";
import { repairMatrixDirectRooms } from "./matrix/direct-management.js";
import type { MatrixClient } from "./matrix/sdk.js";
import {
reactMatrixMessage,
sendMessageMatrix,
sendSingleTextMessageMatrix,
} from "./matrix/send.js";
import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js";
import type { CoreConfig } from "./types.js";
// OpenClaw Matrix custom event content for capable clients; body and reactions remain fallback.
const MATRIX_APPROVAL_METADATA_KEY = "com.openclaw.approval" as const;
type PendingMessage = {
roomId: string;
platformMessageIds: readonly string[];
reactionEventId: string;
};
type PreparedMatrixTarget = {
to: string;
roomId: string;
threadId?: string;
};
type MatrixApprovalMetadataAction = {
decision: ExecApprovalReplyDecision;
label: string;
style: PendingApprovalView["actions"][number]["style"];
command: string;
};
type MatrixApprovalMetadataBase = {
version: 1;
type: "approval.request";
id: string;
state: "pending";
kind: PendingApprovalView["approvalKind"];
phase: "pending";
title: string;
description?: string;
expiresAtMs: number;
metadata: PendingApprovalView["metadata"];
allowedDecisions: ExecApprovalReplyDecision[];
actions: MatrixApprovalMetadataAction[];
};
type MatrixExecApprovalMetadata = MatrixApprovalMetadataBase & {
kind: "exec";
ask?: string;
agentId?: string;
commandText: string;
commandPreview?: string;
cwd?: string;
envKeys?: readonly string[];
host?: string;
nodeId?: string;
sessionKey?: string;
};
type MatrixPluginApprovalSeverity = Extract<
PendingApprovalView,
{ approvalKind: "plugin" }
>["severity"];
type MatrixPluginApprovalMetadata = MatrixApprovalMetadataBase & {
kind: "plugin";
agentId?: string;
pluginId?: string;
toolName?: string;
severity: MatrixPluginApprovalSeverity;
};
type MatrixApprovalMetadata = MatrixExecApprovalMetadata | MatrixPluginApprovalMetadata;
type MatrixApprovalExtraContent = {
[MATRIX_APPROVAL_METADATA_KEY]: MatrixApprovalMetadata;
};
type PendingApprovalContent = {
approvalId: string;
text: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
extraContent: MatrixApprovalExtraContent;
};
type ReactionTargetRef = {
roomId: string;
eventId: string;
};
type MatrixRawApprovalTarget = {
to: string;
threadId?: string | number | null;
};
type MatrixPrepareTargetParams = {
cfg: CoreConfig;
accountId?: string | null;
gatewayUrl?: string;
context?: unknown;
rawTarget: MatrixRawApprovalTarget;
};
const MATRIX_APPROVAL_DELIVERY_ATTEMPTS = 3;
const MATRIX_APPROVAL_DELIVERY_RETRY_DELAY_MS = 250;
export type MatrixApprovalHandlerDeps = {
nowMs?: () => number;
sendMessage?: typeof sendMessageMatrix;
sendSingleTextMessage?: typeof sendSingleTextMessageMatrix;
reactMessage?: typeof reactMatrixMessage;
editMessage?: typeof editMatrixMessage;
deleteMessage?: typeof deleteMatrixMessage;
repairDirectRooms?: typeof repairMatrixDirectRooms;
};
export type MatrixApprovalHandlerContext = {
client: MatrixClient;
deps?: MatrixApprovalHandlerDeps;
};
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
accountId: string;
context: MatrixApprovalHandlerContext;
} | null {
const context = params.context as MatrixApprovalHandlerContext | undefined;
const accountId = params.accountId?.trim() || "";
if (!context?.client || !accountId) {
return null;
}
return { accountId, context };
}
function normalizePendingMessageIds(entry: PendingMessage): string[] {
return normalizeUniqueStringEntries(entry.platformMessageIds);
}
function normalizeReactionTargetRef(params: ReactionTargetRef): ReactionTargetRef | null {
const roomId = params.roomId.trim();
const eventId = params.eventId.trim();
if (!roomId || !eventId) {
return null;
}
return { roomId, eventId };
}
function normalizeThreadId(value?: string | number | null): string | undefined {
const trimmed = value == null ? "" : String(value).trim();
return trimmed || undefined;
}
function isSingleMatrixMessageLimitError(error: unknown): boolean {
return (
error instanceof Error && error.message.includes("Matrix single-message text exceeds limit")
);
}
async function retryMatrixApprovalDelivery<T>(
operation: () => Promise<T>,
params: { shouldRetry?: (error: unknown) => boolean } = {},
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= MATRIX_APPROVAL_DELIVERY_ATTEMPTS; attempt += 1) {
try {
return await operation();
} catch (error) {
lastError = error;
if (attempt === MATRIX_APPROVAL_DELIVERY_ATTEMPTS || params.shouldRetry?.(error) === false) {
break;
}
await sleep(MATRIX_APPROVAL_DELIVERY_RETRY_DELAY_MS * attempt);
}
}
throw lastError;
}
async function prepareTarget(
params: MatrixPrepareTargetParams,
): Promise<PreparedMatrixTarget | null> {
const resolved = resolveHandlerContext(params);
if (!resolved) {
return null;
}
const target = resolveMatrixTargetIdentity(params.rawTarget.to);
if (!target) {
return null;
}
const threadId = normalizeThreadId(params.rawTarget.threadId);
if (target.kind === "user") {
const account = resolveMatrixAccount({
cfg: params.cfg,
accountId: resolved.accountId,
});
const repairDirectRooms = resolved.context.deps?.repairDirectRooms ?? repairMatrixDirectRooms;
const repaired = await retryMatrixApprovalDelivery(
async () =>
await repairDirectRooms({
client: resolved.context.client,
remoteUserId: target.id,
encrypted: account.config.encryption === true,
}),
);
if (!repaired.activeRoomId) {
return null;
}
return {
to: `room:${repaired.activeRoomId}`,
roomId: repaired.activeRoomId,
threadId,
};
}
return {
to: `room:${target.id}`,
roomId: target.id,
threadId,
};
}
function buildMatrixApprovalMetadata(params: {
view: PendingApprovalView;
allowedDecisions: readonly ExecApprovalReplyDecision[];
}): MatrixApprovalMetadata {
const base: MatrixApprovalMetadataBase = {
version: 1,
type: "approval.request",
id: params.view.approvalId,
state: "pending",
kind: params.view.approvalKind,
phase: params.view.phase,
title: params.view.title,
expiresAtMs: params.view.expiresAtMs,
metadata: params.view.metadata,
allowedDecisions: Array.from(params.allowedDecisions),
actions: params.view.actions.map((action) => ({
decision: action.decision,
label: action.label,
style: action.style,
command: action.command,
})),
...(params.view.description != null ? { description: params.view.description } : {}),
};
if (params.view.approvalKind === "plugin") {
return {
...base,
kind: "plugin",
severity: params.view.severity,
...(params.view.agentId != null ? { agentId: params.view.agentId } : {}),
...(params.view.pluginId != null ? { pluginId: params.view.pluginId } : {}),
...(params.view.toolName != null ? { toolName: params.view.toolName } : {}),
};
}
return {
...base,
kind: "exec",
commandText: params.view.commandText,
...(params.view.ask != null ? { ask: params.view.ask } : {}),
...(params.view.agentId != null ? { agentId: params.view.agentId } : {}),
...(params.view.commandPreview != null ? { commandPreview: params.view.commandPreview } : {}),
...(params.view.cwd != null ? { cwd: params.view.cwd } : {}),
...(params.view.envKeys != null ? { envKeys: params.view.envKeys } : {}),
...(params.view.host != null ? { host: params.view.host } : {}),
...(params.view.nodeId != null ? { nodeId: params.view.nodeId } : {}),
...(params.view.sessionKey != null ? { sessionKey: params.view.sessionKey } : {}),
};
}
function buildPendingApprovalContent(params: {
view: PendingApprovalView;
nowMs: number;
}): PendingApprovalContent {
const allowedDecisions = params.view.actions.map((action) => action.decision);
const payload =
params.view.approvalKind === "plugin"
? buildPluginApprovalPendingReplyPayload({
request: {
id: params.view.approvalId,
request: {
title: params.view.title,
description: params.view.description ?? "",
severity: params.view.severity,
toolName: params.view.toolName ?? undefined,
pluginId: params.view.pluginId ?? undefined,
agentId: params.view.agentId ?? undefined,
},
createdAtMs: 0,
expiresAtMs: params.view.expiresAtMs,
} satisfies PluginApprovalRequest,
nowMs: params.nowMs,
allowedDecisions,
})
: buildExecApprovalPendingReplyPayload({
approvalId: params.view.approvalId,
approvalSlug: params.view.approvalId.slice(0, 8),
approvalCommandId: params.view.approvalId,
ask: params.view.ask ?? undefined,
agentId: params.view.agentId ?? undefined,
allowedDecisions,
command: params.view.commandText,
cwd: params.view.cwd ?? undefined,
host: params.view.host === "node" ? "node" : "gateway",
nodeId: params.view.nodeId ?? undefined,
sessionKey: params.view.sessionKey ?? undefined,
expiresAtMs: params.view.expiresAtMs,
nowMs: params.nowMs,
});
const hint = buildMatrixApprovalReactionHint(allowedDecisions);
const text = payload.text ?? "";
return {
approvalId: params.view.approvalId,
text: hint ? (text ? `${hint}\n\n${text}` : hint) : text,
allowedDecisions,
extraContent: {
[MATRIX_APPROVAL_METADATA_KEY]: buildMatrixApprovalMetadata({
view: params.view,
allowedDecisions,
}),
},
};
}
function buildResolvedApprovalText(view: ResolvedApprovalView): string {
if (view.approvalKind === "plugin") {
return (
buildPluginApprovalResolvedReplyPayload({
resolved: {
id: view.approvalId,
decision: view.decision,
resolvedBy: view.resolvedBy ?? undefined,
ts: 0,
},
}).text ?? ""
);
}
const decisionLabel =
view.decision === "allow-once"
? "Allowed once"
: view.decision === "allow-always"
? "Allowed always"
: "Denied";
return [
`Exec approval: ${decisionLabel}`,
"",
"Command",
buildMarkdownCodeBlock(view.commandText),
].join("\n");
}
function buildMarkdownCodeBlock(text: string): string {
const longestFence = Math.max(...Array.from(text.matchAll(/`+/g), (match) => match[0].length), 0);
const fence = "`".repeat(Math.max(3, longestFence + 1));
return [fence, text, fence].join("\n");
}
export const matrixApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
PendingApprovalContent,
PreparedMatrixTarget,
PendingMessage,
ReactionTargetRef,
string
>({
eventKinds: ["exec", "plugin"],
availability: {
isConfigured: ({ cfg, accountId, context }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return false;
}
return isMatrixAnyApprovalClientEnabled({
cfg,
accountId: resolved.accountId,
});
},
shouldHandle: ({ cfg, accountId, request, context }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return false;
}
return shouldHandleMatrixApprovalRequest({
cfg,
accountId: resolved.accountId,
request: request as ExecApprovalRequest | PluginApprovalRequest,
});
},
},
presentation: {
buildPendingPayload: ({ view, nowMs }) =>
buildPendingApprovalContent({
view,
nowMs,
}),
buildResolvedResult: ({ view }) => ({
kind: "update",
payload: buildResolvedApprovalText(view),
}),
buildExpiredResult: () => ({ kind: "delete" }),
},
transport: {
prepareTarget: ({ cfg, accountId, context, plannedTarget }) => {
return prepareTarget({
cfg,
accountId,
context,
rawTarget: plannedTarget.target,
}).then((preparedTarget) =>
preparedTarget
? {
dedupeKey: buildChannelApprovalNativeTargetKey({
to: preparedTarget.roomId,
threadId: preparedTarget.threadId,
}),
target: preparedTarget,
}
: null,
);
},
deliverPending: async ({ cfg, accountId, context, preparedTarget, pendingPayload, view }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
const sendSingleTextMessage =
resolved.context.deps?.sendSingleTextMessage ?? sendSingleTextMessageMatrix;
const reactMessage = resolved.context.deps?.reactMessage ?? reactMatrixMessage;
let result;
try {
result = await retryMatrixApprovalDelivery(
async () =>
await sendSingleTextMessage(preparedTarget.to, pendingPayload.text, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
threadId: preparedTarget.threadId,
extraContent: pendingPayload.extraContent,
}),
{ shouldRetry: (error) => !isSingleMatrixMessageLimitError(error) },
);
} catch (error) {
if (!isSingleMatrixMessageLimitError(error)) {
throw error;
}
const sendMessage = resolved.context.deps?.sendMessage ?? sendMessageMatrix;
result = await retryMatrixApprovalDelivery(
async () =>
await sendMessage(preparedTarget.to, pendingPayload.text, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
threadId: preparedTarget.threadId,
extraContent: pendingPayload.extraContent,
}),
);
}
const receiptMessageIds = listMessageReceiptPlatformIds(result.receipt);
const platformMessageIds = receiptMessageIds.length
? receiptMessageIds
: [result.messageId.trim()].filter(Boolean);
const reactionEventId =
resolveMessageReceiptPrimaryId(result.receipt) ||
result.primaryMessageId?.trim() ||
platformMessageIds[0] ||
result.messageId.trim();
registerMatrixApprovalReactionTarget({
roomId: result.roomId,
eventId: reactionEventId,
approvalId: pendingPayload.approvalId,
allowedDecisions: pendingPayload.allowedDecisions,
ttlMs: view.expiresAtMs - Date.now(),
});
await Promise.allSettled(
listMatrixApprovalReactionBindings(pendingPayload.allowedDecisions).map(
async ({ emoji }) => {
await reactMessage(result.roomId, reactionEventId, emoji, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
});
},
),
);
return {
roomId: result.roomId,
platformMessageIds,
reactionEventId,
};
},
updateEntry: async ({ cfg, accountId, context, entry, payload }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const editMessage = resolved.context.deps?.editMessage ?? editMatrixMessage;
const deleteMessage = resolved.context.deps?.deleteMessage ?? deleteMatrixMessage;
const [primaryMessageId, ...staleMessageIds] = normalizePendingMessageIds(entry);
if (!primaryMessageId) {
return;
}
const text = payload;
await Promise.allSettled([
editMessage(entry.roomId, primaryMessageId, text, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
}),
...staleMessageIds.map(async (messageId) => {
await deleteMessage(entry.roomId, messageId, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
reason: "approval resolved",
});
}),
]);
},
deleteEntry: async ({ cfg, accountId, context, entry, phase }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const deleteMessage = resolved.context.deps?.deleteMessage ?? deleteMatrixMessage;
await Promise.allSettled(
normalizePendingMessageIds(entry).map(async (messageId) => {
await deleteMessage(entry.roomId, messageId, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
reason: phase === "expired" ? "approval expired" : "approval resolved",
});
}),
);
},
},
interactions: {
bindPending: (params) => {
const target = normalizeReactionTargetRef({
roomId: params.entry.roomId,
eventId: params.entry.reactionEventId,
});
if (!target) {
return null;
}
registerMatrixApprovalReactionTarget({
roomId: target.roomId,
eventId: target.eventId,
approvalId: params.pendingPayload.approvalId,
allowedDecisions: params.pendingPayload.allowedDecisions,
ttlMs: params.view.expiresAtMs - Date.now(),
});
return target;
},
unbindPending: (params) => {
const target = normalizeReactionTargetRef(params.binding);
if (!target) {
return;
}
unregisterMatrixApprovalReactionTarget(target);
},
cancelDelivered: (params) => {
const target = normalizeReactionTargetRef({
roomId: params.entry.roomId,
eventId: params.entry.reactionEventId,
});
if (!target) {
return;
}
unregisterMatrixApprovalReactionTarget(target);
},
},
});

View File

@@ -0,0 +1,7 @@
// Matrix plugin module implements approval ids behavior.
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
export function normalizeMatrixApproverId(value: string | number): string | undefined {
const normalized = normalizeMatrixUserId(String(value));
return normalized || undefined;
}

View File

@@ -0,0 +1,330 @@
// Matrix tests cover approval native plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { matrixApprovalCapability } from "./approval-native.js";
function buildConfig(
overrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["matrix"]>>,
): OpenClawConfig {
return {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "both",
},
...overrides,
},
},
} as OpenClawConfig;
}
describe("matrix approval capability", () => {
it("describes the correct Matrix exec-approval setup path", () => {
const text = matrixApprovalCapability.describeExecApprovalSetup?.({
channel: "matrix",
channelLabel: "Matrix",
});
expect(text).toContain("`channels.matrix.execApprovals.approvers`");
expect(text).toContain("`channels.matrix.dm.allowFrom`");
});
it("describes the named-account Matrix exec-approval setup path", () => {
const text = matrixApprovalCapability.describeExecApprovalSetup?.({
channel: "matrix",
channelLabel: "Matrix",
accountId: "work",
});
expect(text).toContain("`channels.matrix.accounts.work.execApprovals.approvers`");
expect(text).toContain("`channels.matrix.accounts.work.dm.allowFrom`");
expect(text).not.toContain("`channels.matrix.execApprovals.approvers`");
});
it("describes native matrix approval delivery capabilities", () => {
const capabilities = matrixApprovalCapability.native?.describeDeliveryCapabilities({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "matrix",
turnSourceTo: "room:!ops:example.org",
turnSourceAccountId: "default",
sessionKey: "agent:main:matrix:channel:!ops:example.org",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(capabilities).toEqual({
enabled: true,
preferredSurface: "both",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
});
});
it("resolves origin targets from matrix turn source", async () => {
const target = await matrixApprovalCapability.native?.resolveOriginTarget?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "matrix",
turnSourceTo: "room:!ops:example.org",
turnSourceThreadId: "$thread",
turnSourceAccountId: "default",
sessionKey: "agent:main:matrix:channel:!ops:example.org",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "room:!ops:example.org",
threadId: "$thread",
});
});
it("resolves approver dm targets", async () => {
const targets = await matrixApprovalCapability.native?.resolveApproverDmTargets?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(targets).toEqual([{ to: "user:@owner:example.org" }]);
});
it("suppresses same-channel plugin forwarding when Matrix native delivery is available", () => {
const shouldSuppress = matrixApprovalCapability.delivery?.shouldSuppressForwardingFallback;
if (!shouldSuppress) {
throw new Error("delivery suppression helper unavailable");
}
expect(
shouldSuppress({
cfg: buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
}),
approvalKind: "plugin",
target: {
channel: "matrix",
to: "room:!ops:example.org",
accountId: "default",
},
request: {
id: "plugin:req-1",
request: {
title: "Plugin Approval Required",
description: "Allow plugin action",
pluginId: "git-tools",
turnSourceChannel: "matrix",
turnSourceTo: "room:!ops:example.org",
turnSourceAccountId: "default",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
} as never),
).toBe(true);
});
it("preserves room-id case when matching Matrix origin targets", async () => {
const target = await matrixApprovalCapability.native?.resolveOriginTarget?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "matrix",
turnSourceTo: "room:!Ops:Example.org",
turnSourceThreadId: "$thread",
turnSourceAccountId: "default",
sessionKey: "agent:main:matrix:channel:!Ops:Example.org",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "room:!Ops:Example.org",
threadId: "$thread",
});
});
it("keeps plugin approval auth independent from exec approvers", () => {
const cfg = buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
execApprovals: {
enabled: true,
approvers: ["@exec:example.org"],
target: "both",
},
});
expect(
matrixApprovalCapability.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "@owner:example.org",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ authorized: true });
expect(
matrixApprovalCapability.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "@exec:example.org",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({
authorized: false,
reason: "❌ You are not authorized to approve plugin requests on Matrix.",
});
expect(
matrixApprovalCapability.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "@exec:example.org",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
});
it("requires Matrix DM approvers before enabling plugin approval auth", () => {
const cfg = buildConfig({
dm: { allowFrom: [] },
execApprovals: {
enabled: true,
approvers: ["@exec:example.org"],
target: "both",
},
});
expect(
matrixApprovalCapability.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "@exec:example.org",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({
authorized: false,
reason: "❌ Matrix plugin approvals are not enabled for this bot account.",
});
});
it("reports exec initiating-surface availability independently from plugin auth", () => {
const cfg = buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
execApprovals: {
enabled: false,
approvers: [],
target: "both",
},
});
expect(
matrixApprovalCapability.getActionAvailabilityState?.({
cfg,
accountId: "default",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ kind: "enabled" });
expect(
matrixApprovalCapability.getExecInitiatingSurfaceState?.({
cfg,
accountId: "default",
action: "approve",
}),
).toEqual({ kind: "disabled" });
});
it("enables matrix-native plugin approval delivery when DM approvers are configured", () => {
const capabilities = matrixApprovalCapability.native?.describeDeliveryCapabilities({
cfg: buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
}),
accountId: "default",
approvalKind: "plugin",
request: {
id: "plugin:req-1",
request: {
title: "Plugin Approval Required",
description: "Allow plugin access",
pluginId: "git-tools",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(capabilities).toEqual({
enabled: true,
preferredSurface: "both",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
});
});
it("keeps matrix-native plugin approval delivery disabled without DM approvers", () => {
const capabilities = matrixApprovalCapability.native?.describeDeliveryCapabilities({
cfg: buildConfig(),
accountId: "default",
approvalKind: "plugin",
request: {
id: "plugin:req-1",
request: {
title: "Plugin Approval Required",
description: "Allow plugin access",
pluginId: "git-tools",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(capabilities).toEqual({
enabled: false,
preferredSurface: "both",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
});
});
});

View File

@@ -0,0 +1,349 @@
// Matrix plugin module implements approval native behavior.
import {
createChannelApprovalCapability,
createApproverRestrictedNativeApprovalCapability,
splitChannelApprovalCapability,
} from "openclaw/plugin-sdk/approval-delivery-runtime";
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import {
createChannelNativeOriginTargetResolver,
resolveApprovalRequestSessionConversation,
} from "openclaw/plugin-sdk/approval-native-runtime";
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { getMatrixApprovalAuthApprovers, matrixApprovalAuth } from "./approval-auth.js";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import {
getMatrixApprovalApprovers,
getMatrixExecApprovalApprovers,
isMatrixAnyApprovalClientEnabled,
isMatrixApprovalClientEnabled,
isMatrixExecApprovalClientEnabled,
isMatrixExecApprovalAuthorizedSender,
resolveMatrixExecApprovalTarget,
shouldHandleMatrixApprovalRequest,
} from "./exec-approvals.js";
import { listMatrixAccountIds } from "./matrix/accounts.js";
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js";
import type { CoreConfig } from "./types.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalKind = "exec" | "plugin";
type MatrixOriginTarget = { to: string; threadId?: string };
function normalizeComparableTarget(value: string): string {
const target = resolveMatrixTargetIdentity(value);
if (!target) {
return normalizeLowercaseStringOrEmpty(value);
}
if (target.kind === "user") {
return `user:${normalizeMatrixUserId(target.id)}`;
}
return `${normalizeLowercaseStringOrEmpty(target.kind)}:${target.id}`;
}
function resolveMatrixNativeTarget(raw: string): string | null {
const target = resolveMatrixTargetIdentity(raw);
if (!target) {
return null;
}
return target.kind === "user" ? `user:${target.id}` : `room:${target.id}`;
}
function resolveTurnSourceMatrixOriginTarget(request: ApprovalRequest): MatrixOriginTarget | null {
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
const turnSourceTo = request.request.turnSourceTo?.trim() || "";
const target = resolveMatrixNativeTarget(turnSourceTo);
if (turnSourceChannel !== "matrix" || !target) {
return null;
}
return {
to: target,
threadId: normalizeOptionalStringifiedId(request.request.turnSourceThreadId),
};
}
function resolveSessionMatrixOriginTarget(sessionTarget: {
to: string;
threadId?: string | number | null;
}): MatrixOriginTarget | null {
const target = resolveMatrixNativeTarget(sessionTarget.to);
if (!target) {
return null;
}
return {
to: target,
threadId: normalizeOptionalStringifiedId(sessionTarget.threadId),
};
}
function normalizeMatrixOriginTarget(target: MatrixOriginTarget): MatrixOriginTarget {
return {
...target,
to: normalizeComparableTarget(target.to),
};
}
function hasMatrixPluginApprovers(params: { cfg: CoreConfig; accountId?: string | null }): boolean {
return getMatrixApprovalAuthApprovers(params).length > 0;
}
function availabilityState(enabled: boolean) {
return enabled ? ({ kind: "enabled" } as const) : ({ kind: "disabled" } as const);
}
function hasMatrixApprovalApprovers(params: {
cfg: CoreConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
}): boolean {
return (
getMatrixApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
approvalKind: params.approvalKind,
}).length > 0
);
}
function hasAnyMatrixApprovalApprovers(params: {
cfg: CoreConfig;
accountId?: string | null;
}): boolean {
return (
getMatrixExecApprovalApprovers(params).length > 0 ||
getMatrixApprovalAuthApprovers(params).length > 0
);
}
function isMatrixPluginAuthorizedSender(params: {
cfg: CoreConfig;
accountId?: string | null;
senderId?: string | null;
}): boolean {
const normalizedSenderId = params.senderId
? normalizeMatrixApproverId(params.senderId)
: undefined;
if (!normalizedSenderId) {
return false;
}
return getMatrixApprovalAuthApprovers(params).includes(normalizedSenderId);
}
function resolveSuppressionAccountId(params: {
target: { accountId?: string | null };
request: { request: { turnSourceAccountId?: string | null } };
}): string | undefined {
return (
params.target.accountId?.trim() ||
params.request.request.turnSourceAccountId?.trim() ||
undefined
);
}
const resolveMatrixOriginTarget = createChannelNativeOriginTargetResolver({
channel: "matrix",
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleMatrixApprovalRequest({
cfg,
accountId,
request,
}),
resolveTurnSourceTarget: resolveTurnSourceMatrixOriginTarget,
resolveSessionTarget: resolveSessionMatrixOriginTarget,
normalizeTargetForMatch: normalizeMatrixOriginTarget,
resolveFallbackTarget: (request) => {
const sessionConversation = resolveApprovalRequestSessionConversation({
request,
channel: "matrix",
});
if (!sessionConversation) {
return null;
}
const target = resolveMatrixNativeTarget(sessionConversation.id);
if (!target) {
return null;
}
return {
to: target,
threadId: normalizeOptionalStringifiedId(sessionConversation.threadId),
};
},
});
function resolveMatrixApproverDmTargets(params: {
cfg: CoreConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
request: ApprovalRequest;
}): { to: string }[] {
if (!shouldHandleMatrixApprovalRequest(params)) {
return [];
}
return getMatrixApprovalApprovers(params)
.map((approver) => {
const normalized = normalizeMatrixUserId(approver);
return normalized ? { to: `user:${normalized}` } : null;
})
.filter((target): target is { to: string } => target !== null);
}
const matrixNativeApprovalCapability = createApproverRestrictedNativeApprovalCapability({
channel: "matrix",
channelLabel: "Matrix",
describeExecApprovalSetup: ({
accountId,
}: Parameters<NonNullable<ChannelApprovalCapability["describeExecApprovalSetup"]>>[0]) => {
const prefix =
accountId && accountId !== "default"
? `channels.matrix.accounts.${accountId}`
: "channels.matrix";
return `Approve it from the Web UI or terminal UI for now. Matrix supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\` or \`${prefix}.dm.allowFrom\`; leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
},
listAccountIds: listMatrixAccountIds,
hasApprovers: ({ cfg, accountId }) =>
hasAnyMatrixApprovalApprovers({
cfg: cfg as CoreConfig,
accountId,
}),
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
isMatrixExecApprovalAuthorizedSender({ cfg, accountId, senderId }),
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) =>
isMatrixPluginAuthorizedSender({
cfg: cfg as CoreConfig,
accountId,
senderId,
}),
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
isMatrixExecApprovalClientEnabled({ cfg, accountId }),
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
resolveMatrixExecApprovalTarget({ cfg, accountId }),
requireMatchingTurnSourceChannel: true,
resolveSuppressionAccountId,
resolveOriginTarget: resolveMatrixOriginTarget,
resolveApproverDmTargets: resolveMatrixApproverDmTargets,
notifyOriginWhenDmOnly: true,
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec", "plugin"],
isConfigured: ({ cfg, accountId }) =>
isMatrixAnyApprovalClientEnabled({
cfg,
accountId,
}),
shouldHandle: ({ cfg, accountId, request }) =>
shouldHandleMatrixApprovalRequest({
cfg,
accountId,
request,
}),
load: async () =>
(await import("./approval-handler.runtime.js"))
.matrixApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter,
}),
});
const splitMatrixApprovalCapability = splitChannelApprovalCapability(
matrixNativeApprovalCapability,
);
const matrixBaseNativeApprovalAdapter = splitMatrixApprovalCapability.native;
const matrixBaseDeliveryAdapter = splitMatrixApprovalCapability.delivery;
type MatrixForwardingSuppressionParams = Parameters<
NonNullable<NonNullable<typeof matrixBaseDeliveryAdapter>["shouldSuppressForwardingFallback"]>
>[0];
const matrixDeliveryAdapter = matrixBaseDeliveryAdapter && {
...matrixBaseDeliveryAdapter,
shouldSuppressForwardingFallback: (params: MatrixForwardingSuppressionParams) => {
const accountId = resolveSuppressionAccountId(params);
if (
!hasMatrixApprovalApprovers({
cfg: params.cfg as CoreConfig,
accountId,
approvalKind: params.approvalKind,
})
) {
return false;
}
return matrixBaseDeliveryAdapter.shouldSuppressForwardingFallback?.(params) ?? false;
},
};
const matrixNativeAdapter = matrixBaseNativeApprovalAdapter && {
describeDeliveryCapabilities: (
params: Parameters<typeof matrixBaseNativeApprovalAdapter.describeDeliveryCapabilities>[0],
) => {
const capabilities = matrixBaseNativeApprovalAdapter.describeDeliveryCapabilities(params);
const hasApprovers = hasMatrixApprovalApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
approvalKind: params.approvalKind,
});
const clientEnabled = isMatrixApprovalClientEnabled({
cfg: params.cfg,
accountId: params.accountId,
approvalKind: params.approvalKind,
});
return {
...capabilities,
enabled: capabilities.enabled && hasApprovers && clientEnabled,
};
},
resolveOriginTarget: matrixBaseNativeApprovalAdapter.resolveOriginTarget,
resolveApproverDmTargets: matrixBaseNativeApprovalAdapter.resolveApproverDmTargets,
};
export const matrixApprovalCapability = createChannelApprovalCapability({
authorizeActorAction: (
params: Parameters<NonNullable<ChannelApprovalCapability["authorizeActorAction"]>>[0],
) => {
if (params.approvalKind !== "plugin") {
return matrixNativeApprovalCapability.authorizeActorAction?.(params) ?? { authorized: true };
}
if (
!hasMatrixPluginApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
})
) {
return {
authorized: false,
reason: "❌ Matrix plugin approvals are not enabled for this bot account.",
} as const;
}
return matrixApprovalAuth.authorizeActorAction(params);
},
getActionAvailabilityState: (
params: Parameters<NonNullable<ChannelApprovalCapability["getActionAvailabilityState"]>>[0],
) => {
if (params.approvalKind === "plugin") {
return availabilityState(
hasMatrixPluginApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
}),
);
}
return (
matrixNativeApprovalCapability.getActionAvailabilityState?.(params) ?? {
kind: "disabled",
}
);
},
getExecInitiatingSurfaceState: (
params: Parameters<NonNullable<ChannelApprovalCapability["getExecInitiatingSurfaceState"]>>[0],
) =>
matrixNativeApprovalCapability.getExecInitiatingSurfaceState?.(params) ??
({ kind: "disabled" } as const),
describeExecApprovalSetup: matrixNativeApprovalCapability.describeExecApprovalSetup,
delivery: matrixDeliveryAdapter,
nativeRuntime: matrixNativeApprovalCapability.nativeRuntime,
native: matrixNativeAdapter,
render: matrixNativeApprovalCapability.render,
});

View File

@@ -0,0 +1,46 @@
// Matrix plugin module implements approval reaction auth behavior.
import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-auth-runtime";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import type { CoreConfig } from "./types.js";
type MatrixApprovalReactionKind = "exec" | "plugin";
function normalizeMatrixExecApproverId(value: string | number): string | undefined {
const normalized = normalizeMatrixApproverId(value);
return normalized === "*" ? undefined : normalized;
}
function getMatrixApprovalReactionApprovers(params: {
cfg: CoreConfig;
accountId?: string | null;
approvalKind: MatrixApprovalReactionKind;
}): string[] {
const account = resolveMatrixAccount(params).config;
if (params.approvalKind === "plugin") {
return resolveApprovalApprovers({
allowFrom: account.dm?.allowFrom,
normalizeApprover: normalizeMatrixApproverId,
});
}
return resolveApprovalApprovers({
explicit: account.execApprovals?.approvers,
allowFrom: account.dm?.allowFrom,
normalizeApprover: normalizeMatrixExecApproverId,
});
}
export function isMatrixApprovalReactionAuthorizedSender(params: {
cfg: CoreConfig;
accountId?: string | null;
senderId?: string | null;
approvalKind: MatrixApprovalReactionKind;
}): boolean {
const normalizedSenderId = params.senderId
? normalizeMatrixApproverId(params.senderId)
: undefined;
if (!normalizedSenderId) {
return false;
}
return getMatrixApprovalReactionApprovers(params).includes(normalizedSenderId);
}

View File

@@ -0,0 +1,187 @@
// Matrix tests cover approval reactions plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildMatrixApprovalReactionHint,
clearMatrixApprovalReactionTargetsForTest,
listMatrixApprovalReactionBindings,
registerMatrixApprovalReactionTarget,
resolveMatrixApprovalReactionTargetWithPersistence,
unregisterMatrixApprovalReactionTarget,
} from "./approval-reactions.js";
import { setMatrixRuntime } from "./runtime.js";
afterEach(() => {
clearMatrixApprovalReactionTargetsForTest();
vi.restoreAllMocks();
});
describe("matrix approval reactions", () => {
it("lists reactions in stable decision order", () => {
expect(listMatrixApprovalReactionBindings(["allow-once", "deny", "allow-always"])).toEqual([
{ decision: "allow-once", emoji: "✅", label: "Allow once" },
{ decision: "allow-always", emoji: "♾️", label: "Allow always" },
{ decision: "deny", emoji: "❌", label: "Deny" },
]);
});
it("builds a compact reaction hint", () => {
expect(buildMatrixApprovalReactionHint(["allow-once", "deny"])).toBe(
"React here: ✅ Allow once, ❌ Deny",
);
});
it("resolves a registered approval anchor event back to an approval decision", async () => {
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
approvalId: "req-123",
allowedDecisions: ["allow-once", "allow-always", "deny"],
});
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!ops:example.org",
eventId: "$approval-msg",
reactionKey: "✅",
}),
).toEqual({
approvalId: "req-123",
decision: "allow-once",
});
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!ops:example.org",
eventId: "$approval-msg",
reactionKey: "♾️",
}),
).toEqual({
approvalId: "req-123",
decision: "allow-always",
});
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!ops:example.org",
eventId: "$approval-msg",
reactionKey: "❌",
}),
).toEqual({
approvalId: "req-123",
decision: "deny",
});
});
it("ignores reactions that are not allowed on the registered approval anchor event", async () => {
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
approvalId: "req-123",
allowedDecisions: ["allow-once", "deny"],
});
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!ops:example.org",
eventId: "$approval-msg",
reactionKey: "♾️",
}),
).toBeNull();
});
it("stops resolving reactions after the approval anchor event is unregistered", async () => {
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
approvalId: "req-123",
allowedDecisions: ["allow-once", "allow-always", "deny"],
});
unregisterMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
});
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!ops:example.org",
eventId: "$approval-msg",
reactionKey: "✅",
}),
).toBeNull();
});
it("persists approval reaction targets when runtime state is available", async () => {
const register = vi.fn().mockResolvedValue(undefined);
const lookup = vi.fn().mockResolvedValue({
version: 1,
target: { approvalId: "req-persisted", allowedDecisions: ["deny"] },
});
const openKeyedStore = vi.fn(() => ({
register,
lookup,
consume: vi.fn(),
delete: vi.fn(),
entries: vi.fn(),
clear: vi.fn(),
}));
setMatrixRuntime({
state: { openKeyedStore },
logging: { getChildLogger: () => ({ warn: vi.fn() }) },
} as never);
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg-2",
approvalId: "req-123",
allowedDecisions: ["allow-once", "deny"],
ttlMs: 1000,
});
await vi.waitFor(() => expect(register).toHaveBeenCalledTimes(1));
expect(register).toHaveBeenCalledWith(
"!ops:example.org:$approval-msg-2",
{
version: 1,
target: { approvalId: "req-123", allowedDecisions: ["allow-once", "deny"] },
},
{ ttlMs: 1000 },
);
clearMatrixApprovalReactionTargetsForTest();
await expect(
resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!ops:example.org",
eventId: "$approval-msg-2",
reactionKey: "❌",
}),
).resolves.toEqual({ approvalId: "req-persisted", decision: "deny" });
expect(openKeyedStore).toHaveBeenCalledTimes(2);
expect(lookup).toHaveBeenCalledWith("!ops:example.org:$approval-msg-2");
});
it("falls back to in-memory approval reaction targets when persistent state cannot open", async () => {
const warn = vi.fn();
setMatrixRuntime({
state: {
openKeyedStore: vi.fn(() => {
throw new Error("sqlite unavailable");
}),
},
logging: { getChildLogger: () => ({ warn }) },
} as never);
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg-3",
approvalId: "req-fallback",
allowedDecisions: ["deny"],
});
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!ops:example.org",
eventId: "$approval-msg-3",
reactionKey: "❌",
}),
).toEqual({ approvalId: "req-fallback", decision: "deny" });
expect(warn).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,208 @@
// Matrix plugin module implements approval reactions behavior.
import { createApprovalReactionTargetStore } from "openclaw/plugin-sdk/approval-reaction-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-runtime";
import { getOptionalMatrixRuntime } from "./runtime.js";
// Matrix keeps its own reaction emoji set (checkmark/cross render reliably across
// Matrix clients), so decision resolution stays local instead of using the SDK bindings.
const MATRIX_APPROVAL_REACTION_META = {
"allow-once": {
emoji: "✅",
label: "Allow once",
},
"allow-always": {
emoji: "♾️",
label: "Allow always",
},
deny: {
emoji: "❌",
label: "Deny",
},
} satisfies Record<ExecApprovalReplyDecision, { emoji: string; label: string }>;
const MATRIX_APPROVAL_REACTION_ORDER = [
"allow-once",
"allow-always",
"deny",
] as const satisfies readonly ExecApprovalReplyDecision[];
const PERSISTENT_NAMESPACE = "matrix.approval-reactions";
const PERSISTENT_MAX_ENTRIES = 1000;
const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000;
export type MatrixApprovalReactionBinding = {
decision: ExecApprovalReplyDecision;
emoji: string;
label: string;
};
type MatrixApprovalReactionResolution = {
approvalId: string;
decision: ExecApprovalReplyDecision;
};
type MatrixApprovalReactionTarget = {
approvalId: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
};
function reportPersistentApprovalReactionError(error: unknown): void {
try {
getOptionalMatrixRuntime()
?.logging.getChildLogger({ plugin: "matrix", feature: "approval-reaction-state" })
.warn("Matrix persistent approval reaction state failed", { error: String(error) });
} catch {
// Best effort only: persistent state must never break Matrix reactions.
}
}
function readPersistedTarget(target: unknown): MatrixApprovalReactionTarget | null {
const value = target as Partial<MatrixApprovalReactionTarget> | null | undefined;
if (!value || typeof value.approvalId !== "string" || !Array.isArray(value.allowedDecisions)) {
return null;
}
return {
approvalId: value.approvalId,
allowedDecisions: value.allowedDecisions,
};
}
const matrixApprovalReactionTargets =
createApprovalReactionTargetStore<MatrixApprovalReactionTarget>({
namespace: PERSISTENT_NAMESPACE,
maxEntries: PERSISTENT_MAX_ENTRIES,
defaultTtlMs: DEFAULT_REACTION_TARGET_TTL_MS,
openStore: (storeParams) => getOptionalMatrixRuntime()?.state.openKeyedStore(storeParams),
logPersistentError: reportPersistentApprovalReactionError,
readPersistedTarget,
});
function buildReactionTargetKey(roomId: string, eventId: string): string | null {
const normalizedRoomId = roomId.trim();
const normalizedEventId = eventId.trim();
if (!normalizedRoomId || !normalizedEventId) {
return null;
}
return `${normalizedRoomId}:${normalizedEventId}`;
}
export function listMatrixApprovalReactionBindings(
allowedDecisions: readonly ExecApprovalReplyDecision[],
): MatrixApprovalReactionBinding[] {
const allowed = new Set(allowedDecisions);
return MATRIX_APPROVAL_REACTION_ORDER.filter((decision) => allowed.has(decision)).map(
(decision) => ({
decision,
emoji: MATRIX_APPROVAL_REACTION_META[decision].emoji,
label: MATRIX_APPROVAL_REACTION_META[decision].label,
}),
);
}
export function buildMatrixApprovalReactionHint(
allowedDecisions: readonly ExecApprovalReplyDecision[],
): string | null {
const bindings = listMatrixApprovalReactionBindings(allowedDecisions);
if (bindings.length === 0) {
return null;
}
return `React here: ${bindings.map((binding) => `${binding.emoji} ${binding.label}`).join(", ")}`;
}
function resolveMatrixApprovalReactionDecision(
reactionKey: string,
allowedDecisions: readonly ExecApprovalReplyDecision[],
): ExecApprovalReplyDecision | null {
const normalizedReaction = reactionKey.trim();
if (!normalizedReaction) {
return null;
}
const allowed = new Set(allowedDecisions);
for (const decision of MATRIX_APPROVAL_REACTION_ORDER) {
if (!allowed.has(decision)) {
continue;
}
if (MATRIX_APPROVAL_REACTION_META[decision].emoji === normalizedReaction) {
return decision;
}
}
return null;
}
export function registerMatrixApprovalReactionTarget(params: {
roomId: string;
eventId: string;
approvalId: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
ttlMs?: number;
}): void {
const key = buildReactionTargetKey(params.roomId, params.eventId);
const approvalId = params.approvalId.trim();
const allowedDecisions = Array.from(
new Set(
params.allowedDecisions.filter(
(decision): decision is ExecApprovalReplyDecision =>
decision === "allow-once" || decision === "allow-always" || decision === "deny",
),
),
);
if (!key || !approvalId || allowedDecisions.length === 0) {
return;
}
matrixApprovalReactionTargets.register(
key,
{ approvalId, allowedDecisions },
{ ttlMs: params.ttlMs },
);
}
export function unregisterMatrixApprovalReactionTarget(params: {
roomId: string;
eventId: string;
}): void {
const key = buildReactionTargetKey(params.roomId, params.eventId);
if (!key) {
return;
}
matrixApprovalReactionTargets.delete(key);
}
function resolveTarget(params: {
target: MatrixApprovalReactionTarget | null | undefined;
reactionKey: string;
}): MatrixApprovalReactionResolution | null {
const target = params.target;
if (!target) {
return null;
}
const decision = resolveMatrixApprovalReactionDecision(
params.reactionKey,
target.allowedDecisions,
);
if (!decision) {
return null;
}
return {
approvalId: target.approvalId,
decision,
};
}
export async function resolveMatrixApprovalReactionTargetWithPersistence(params: {
roomId: string;
eventId: string;
reactionKey: string;
}): Promise<MatrixApprovalReactionResolution | null> {
const key = buildReactionTargetKey(params.roomId, params.eventId);
if (!key) {
return null;
}
return resolveTarget({
target: await matrixApprovalReactionTargets.lookup(key),
reactionKey: params.reactionKey,
});
}
export function clearMatrixApprovalReactionTargetsForTest(): void {
matrixApprovalReactionTargets.clearForTest();
}

View File

@@ -0,0 +1,62 @@
// Matrix plugin module implements auth precedence behavior.
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
export type MatrixResolvedStringField =
| "homeserver"
| "userId"
| "accessToken"
| "password"
| "deviceId"
| "deviceName";
export type MatrixResolvedStringValues = Record<MatrixResolvedStringField, string>;
type MatrixStringSourceMap = Partial<Record<MatrixResolvedStringField, string>>;
const MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS = new Set<MatrixResolvedStringField>([
"userId",
"accessToken",
"password",
"deviceId",
]);
function resolveMatrixStringSourceValue(value: string | undefined): string {
return typeof value === "string" ? value : "";
}
function shouldAllowBaseAuthFallback(accountId: string, field: MatrixResolvedStringField): boolean {
return (
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID ||
!MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS.has(field)
);
}
export function resolveMatrixAccountStringValues(params: {
accountId: string;
account?: MatrixStringSourceMap;
scopedEnv?: MatrixStringSourceMap;
channel?: MatrixStringSourceMap;
globalEnv?: MatrixStringSourceMap;
}): MatrixResolvedStringValues {
const fields: MatrixResolvedStringField[] = [
"homeserver",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
];
const resolved = {} as MatrixResolvedStringValues;
for (const field of fields) {
resolved[field] =
resolveMatrixStringSourceValue(params.account?.[field]) ||
resolveMatrixStringSourceValue(params.scopedEnv?.[field]) ||
(shouldAllowBaseAuthFallback(params.accountId, field)
? resolveMatrixStringSourceValue(params.channel?.[field]) ||
resolveMatrixStringSourceValue(params.globalEnv?.[field])
: "");
}
return resolved;
}

View File

@@ -0,0 +1,98 @@
// Matrix plugin module implements channel account paths behavior.
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
import type { PinnedDispatcherPolicy, SsrFPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
import { formatMatrixErrorMessage } from "./matrix/errors.js";
import type { MatrixProbe } from "./matrix/probe.js";
import type { CoreConfig } from "./types.js";
type ResolveMatrixAuth = (params: { cfg: CoreConfig; accountId?: string }) => Promise<{
homeserver: string;
accessToken: string;
userId: string;
deviceId?: string;
allowPrivateNetwork?: boolean;
ssrfPolicy?: SsrFPolicy;
dispatcherPolicy?: PinnedDispatcherPolicy;
}>;
type ProbeMatrix = (params: {
homeserver: string;
accessToken: string;
userId: string;
deviceId?: string;
timeoutMs: number;
accountId?: string;
allowPrivateNetwork?: boolean;
ssrfPolicy?: SsrFPolicy;
dispatcherPolicy?: PinnedDispatcherPolicy;
}) => Promise<MatrixProbe>;
type SendMessageMatrix = (
to: string,
message: string,
options: { cfg: CoreConfig; accountId?: string },
) => Promise<unknown>;
export function createMatrixProbeAccount(params: {
resolveMatrixAuth: ResolveMatrixAuth;
probeMatrix: ProbeMatrix;
}) {
return async ({
account,
timeoutMs,
cfg,
}: {
account: { accountId?: string };
timeoutMs?: number;
cfg: unknown;
}): Promise<MatrixProbe> => {
try {
const auth = await params.resolveMatrixAuth({
cfg: cfg as CoreConfig,
accountId: account.accountId,
});
return await params.probeMatrix({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
userId: auth.userId,
deviceId: auth.deviceId,
timeoutMs: timeoutMs ?? 5_000,
accountId: account.accountId,
allowPrivateNetwork: auth.allowPrivateNetwork,
ssrfPolicy: auth.ssrfPolicy,
dispatcherPolicy: auth.dispatcherPolicy,
});
} catch (err) {
return {
ok: false,
error: formatMatrixErrorMessage(err),
elapsedMs: 0,
};
}
};
}
export function createMatrixPairingText(sendMessageMatrix: SendMessageMatrix) {
return {
idLabel: "matrixUserId",
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^matrix:/i),
notify: async ({
id,
message,
cfg,
accountId,
}: {
id: string;
message: string;
cfg: CoreConfig;
accountId?: string;
}) => {
await sendMessageMatrix(`user:${id}`, message, {
cfg,
...(accountId ? { accountId } : {}),
});
},
};
}

View File

@@ -0,0 +1,105 @@
// Matrix tests cover channel.account paths plugin behavior.
import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createMatrixPairingText, createMatrixProbeAccount } from "./channel-account-paths.js";
const sendMessageMatrixMock = vi.hoisted(() => vi.fn());
const probeMatrixMock = vi.hoisted(() => vi.fn());
const resolveMatrixAuthMock = vi.hoisted(() => vi.fn());
vi.mock("./matrix/send.js", async () => {
const actual = await vi.importActual<typeof import("./matrix/send.js")>("./matrix/send.js");
return {
...actual,
sendMessageMatrix: (...args: unknown[]) => sendMessageMatrixMock(...args),
};
});
vi.mock("./matrix/probe.js", async () => {
const actual = await vi.importActual<typeof import("./matrix/probe.js")>("./matrix/probe.js");
return {
...actual,
probeMatrix: (...args: unknown[]) => probeMatrixMock(...args),
};
});
vi.mock("./matrix/client.js", async () => {
const actual = await vi.importActual<typeof import("./matrix/client.js")>("./matrix/client.js");
return {
...actual,
resolveMatrixAuth: (...args: unknown[]) => resolveMatrixAuthMock(...args),
};
});
describe("matrix account path propagation", () => {
beforeEach(() => {
vi.clearAllMocks();
sendMessageMatrixMock.mockResolvedValue({
messageId: "$sent",
roomId: "!room:example.org",
});
probeMatrixMock.mockResolvedValue({
ok: true,
error: null,
status: null,
elapsedMs: 5,
userId: "@poe:example.org",
});
resolveMatrixAuthMock.mockResolvedValue({
accountId: "poe",
homeserver: "https://matrix.example.org",
userId: "@poe:example.org",
accessToken: "poe-token",
deviceId: "POEDEVICE",
});
});
it("forwards accountId when notifying pairing approval", async () => {
const pairingText = createMatrixPairingText(sendMessageMatrixMock);
expect(pairingText.normalizeAllowEntry(" matrix:@user:example.org ")).toBe(
"@user:example.org",
);
await pairingText.notify({
cfg: {} as never,
id: "@user:example.org",
message: pairingText.message,
accountId: "poe",
});
expect(sendMessageMatrixMock).toHaveBeenCalledWith(
"user:@user:example.org",
PAIRING_APPROVED_MESSAGE,
{ cfg: {}, accountId: "poe" },
);
});
it("forwards accountId and deviceId to matrix probes", async () => {
const probeAccount = createMatrixProbeAccount({
resolveMatrixAuth: resolveMatrixAuthMock,
probeMatrix: probeMatrixMock,
});
await probeAccount({
cfg: {} as never,
timeoutMs: 500,
account: {
accountId: "poe",
} as never,
});
expect(resolveMatrixAuthMock).toHaveBeenCalledWith({
cfg: {},
accountId: "poe",
});
expect(probeMatrixMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
accessToken: "poe-token",
userId: "@poe:example.org",
deviceId: "POEDEVICE",
timeoutMs: 500,
accountId: "poe",
});
});
});

View File

@@ -0,0 +1,617 @@
// Matrix tests cover channelirectory plugin behavior.
import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
import { beforeEach, describe, expect, it } from "vitest";
import type { RuntimeEnv } from "../runtime-api.js";
import { matrixPlugin } from "./channel.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { resolveMatrixConfigForAccount } from "./matrix/client/config.js";
import { installMatrixTestRuntime } from "./test-runtime.js";
import type { CoreConfig } from "./types.js";
function requireMatrixDirectory() {
const directory = matrixPlugin.directory;
if (!directory?.listPeers || !directory.listGroups) {
throw new Error("expected Matrix directory listPeers/listGroups");
}
return {
listPeers: directory.listPeers,
listGroups: directory.listGroups,
};
}
function requireMatrixReplyToModeResolver() {
const resolveReplyToMode = matrixPlugin.threading?.resolveReplyToMode;
if (!resolveReplyToMode) {
throw new Error("expected Matrix replyToMode resolver");
}
return resolveReplyToMode;
}
function requireDirectoryEntry(
entries: readonly { kind: string; id: string; name?: string }[],
kind: string,
id: string,
) {
const entry = entries.find((candidate) => candidate.kind === kind && candidate.id === id);
if (!entry) {
throw new Error(`expected Matrix directory entry ${kind}:${id}`);
}
return entry;
}
describe("matrix directory", () => {
const runtimeEnv: RuntimeEnv = createRuntimeEnv();
beforeEach(() => {
installMatrixTestRuntime();
});
it("lists peers and groups from config", async () => {
const cfg = {
channels: {
matrix: {
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
groupAllowFrom: ["@dana:example.org"],
groups: {
"!room1:example.org": { users: ["@carol:example.org"] },
"#alias:example.org": { users: [] },
},
},
},
} as unknown as CoreConfig;
const directory = requireMatrixDirectory();
const peers = await directory.listPeers({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
});
expect(requireDirectoryEntry(peers, "user", "user:@alice:example.org").name).toBeUndefined();
expect(requireDirectoryEntry(peers, "user", "bob").name).toBe(
"incomplete id; expected @user:server",
);
expect(requireDirectoryEntry(peers, "user", "user:@carol:example.org").name).toBeUndefined();
expect(requireDirectoryEntry(peers, "user", "user:@dana:example.org").name).toBeUndefined();
const groups = await directory.listGroups({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
});
expect(requireDirectoryEntry(groups, "group", "room:!room1:example.org").name).toBeUndefined();
expect(requireDirectoryEntry(groups, "group", "#alias:example.org").name).toBeUndefined();
});
it("resolves replyToMode from account config", () => {
const cfg = {
channels: {
matrix: {
replyToMode: "off",
accounts: {
Assistant: {
replyToMode: "all",
},
},
},
},
} as unknown as CoreConfig;
const resolveReplyToMode = requireMatrixReplyToModeResolver();
expect(
resolveReplyToMode({
cfg,
accountId: "assistant",
chatType: "direct",
}),
).toBe("all");
expect(
resolveReplyToMode({
cfg,
accountId: "default",
chatType: "direct",
}),
).toBe("off");
});
it("only exposes real Matrix thread ids in tool context", () => {
expect(
matrixPlugin.threading?.buildToolContext?.({
cfg: {} as CoreConfig,
context: {
To: "room:!room:example.org",
ReplyToId: "$reply",
},
hasRepliedRef: { value: false },
}),
).toEqual({
currentChannelId: "room:!room:example.org",
currentThreadTs: undefined,
hasRepliedRef: { value: false },
});
expect(
matrixPlugin.threading?.buildToolContext?.({
cfg: {} as CoreConfig,
context: {
To: "room:!room:example.org",
ReplyToId: "$reply",
MessageThreadId: "$thread",
},
hasRepliedRef: { value: true },
}),
).toEqual({
currentChannelId: "room:!room:example.org",
currentThreadTs: "$thread",
hasRepliedRef: { value: true },
});
});
it("exposes Matrix direct user id in dm tool context", () => {
expect(
matrixPlugin.threading?.buildToolContext?.({
cfg: {} as CoreConfig,
context: {
From: "matrix:@alice:example.org",
To: "room:!dm:example.org",
ChatType: "direct",
MessageThreadId: "$thread",
},
hasRepliedRef: { value: false },
}),
).toEqual({
currentChannelId: "room:!dm:example.org",
currentThreadTs: "$thread",
currentDirectUserId: "@alice:example.org",
hasRepliedRef: { value: false },
});
});
it("accepts raw room ids when inferring Matrix direct user ids", () => {
expect(
matrixPlugin.threading?.buildToolContext?.({
cfg: {} as CoreConfig,
context: {
From: "user:@alice:example.org",
To: "!dm:example.org",
ChatType: "direct",
},
hasRepliedRef: { value: false },
}),
).toEqual({
currentChannelId: "!dm:example.org",
currentThreadTs: undefined,
currentDirectUserId: "@alice:example.org",
hasRepliedRef: { value: false },
});
});
it("resolves group mention policy from account config", () => {
const cfg = {
channels: {
matrix: {
groups: {
"!room:example.org": { requireMention: true },
},
accounts: {
Assistant: {
groups: {
"!room:example.org": { requireMention: false },
},
},
},
},
},
} as unknown as CoreConfig;
expect(matrixPlugin.groups!.resolveRequireMention!({ cfg, groupId: "!room:example.org" })).toBe(
true,
);
expect(
matrixPlugin.groups!.resolveRequireMention!({
cfg,
accountId: "assistant",
groupId: "!room:example.org",
}),
).toBe(false);
expect(
matrixPlugin.groups!.resolveRequireMention!({
cfg,
accountId: "assistant",
groupId: "matrix:room:!room:example.org",
}),
).toBe(false);
});
it("matches prefixed Matrix aliases in group context", () => {
const cfg = {
channels: {
matrix: {
groups: {
"#ops:example.org": { requireMention: false },
},
},
},
} as unknown as CoreConfig;
expect(
matrixPlugin.groups!.resolveRequireMention!({
cfg,
groupId: "matrix:room:!room:example.org",
groupChannel: "matrix:channel:#ops:example.org",
}),
).toBe(false);
});
it("reports room access warnings against the active Matrix config path", () => {
expect(
matrixPlugin.security?.collectWarnings?.({
cfg: {
channels: {
matrix: {
groupPolicy: "open",
},
},
} as CoreConfig,
account: resolveMatrixAccount({
cfg: {
channels: {
matrix: {
groupPolicy: "open",
},
},
} as CoreConfig,
accountId: "default",
}),
}),
).toEqual([
'- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.',
]);
expect(
matrixPlugin.security?.collectWarnings?.({
cfg: {
channels: {
matrix: {
defaultAccount: "assistant",
accounts: {
assistant: {
groupPolicy: "open",
},
},
},
},
} as CoreConfig,
account: resolveMatrixAccount({
cfg: {
channels: {
matrix: {
defaultAccount: "assistant",
accounts: {
assistant: {
groupPolicy: "open",
},
},
},
},
} as CoreConfig,
accountId: "assistant",
}),
}),
).toEqual([
'- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix.accounts.assistant.groupPolicy="allowlist" + channels.matrix.accounts.assistant.groups (and optionally channels.matrix.accounts.assistant.groupAllowFrom) to restrict rooms.',
]);
});
it("reports invite auto-join warnings only when explicitly enabled", () => {
expect(
matrixPlugin.security?.collectWarnings?.({
cfg: {
channels: {
matrix: {
groupPolicy: "allowlist",
autoJoin: "always",
},
},
} as CoreConfig,
account: resolveMatrixAccount({
cfg: {
channels: {
matrix: {
groupPolicy: "allowlist",
autoJoin: "always",
},
},
} as CoreConfig,
accountId: "default",
}),
}),
).toEqual([
'- Matrix invites: autoJoin="always" joins any invited room before message policy applies. Set channels.matrix.autoJoin="allowlist" + channels.matrix.autoJoinAllowlist (or channels.matrix.autoJoin="off") to restrict joins.',
]);
});
it("writes matrix non-default account credentials under channels.matrix.accounts", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://default.example.org",
accessToken: "default-token",
deviceId: "DEFAULTDEVICE",
avatarUrl: "mxc://server/avatar",
encryption: true,
threadReplies: "inbound",
groups: {
"!room:example.org": { requireMention: true },
},
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "ops",
input: {
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
},
}) as CoreConfig;
expect(updated.channels?.["matrix"]?.accessToken).toBeUndefined();
expect(updated.channels?.["matrix"]?.deviceId).toBeUndefined();
expect(updated.channels?.["matrix"]?.avatarUrl).toBeUndefined();
const defaultAccount = updated.channels?.["matrix"]?.accounts?.default;
expect(defaultAccount?.accessToken).toBe("default-token");
expect(defaultAccount?.homeserver).toBe("https://default.example.org");
expect(defaultAccount?.deviceId).toBe("DEFAULTDEVICE");
expect(defaultAccount?.avatarUrl).toBe("mxc://server/avatar");
expect(defaultAccount?.encryption).toBe(true);
expect(defaultAccount?.threadReplies).toBe("inbound");
expect(defaultAccount?.groups).toEqual({
"!room:example.org": { requireMention: true },
});
const opsAccount = updated.channels?.["matrix"]?.accounts?.ops;
expect(opsAccount?.enabled).toBe(true);
expect(opsAccount?.homeserver).toBe("https://matrix.example.org");
expect(opsAccount?.userId).toBe("@ops:example.org");
expect(opsAccount?.accessToken).toBe("ops-token");
const resolvedOps = resolveMatrixConfigForAccount(updated, "ops", {});
expect(resolvedOps.homeserver).toBe("https://matrix.example.org");
expect(resolvedOps.userId).toBe("@ops:example.org");
expect(resolvedOps.accessToken).toBe("ops-token");
expect(resolvedOps.deviceId).toBeUndefined();
});
it("writes default matrix account credentials under channels.matrix.accounts.default", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://legacy.example.org",
accessToken: "legacy-token",
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "default",
input: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "bot-token",
},
}) as CoreConfig;
const matrixConfig = updated.channels?.["matrix"];
expect(matrixConfig?.enabled).toBe(true);
expect(matrixConfig?.homeserver).toBe("https://matrix.example.org");
expect(matrixConfig?.userId).toBe("@bot:example.org");
expect(matrixConfig?.accessToken).toBe("bot-token");
expect(updated.channels?.["matrix"]?.accounts).toBeUndefined();
});
it("requires account-scoped env vars when --use-env is set for non-default accounts", () => {
const envKeys = [
"MATRIX_OPS_HOMESERVER",
"MATRIX_OPS_USER_ID",
"MATRIX_OPS_ACCESS_TOKEN",
"MATRIX_OPS_PASSWORD",
] as const;
const previousEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])) as Record<
(typeof envKeys)[number],
string | undefined
>;
for (const key of envKeys) {
delete process.env[key];
}
try {
const error = matrixPlugin.setup!.validateInput?.({
cfg: {} as CoreConfig,
accountId: "ops",
input: { useEnv: true },
});
expect(error).toBe(
'Set per-account env vars for "ops" (for example MATRIX_OPS_HOMESERVER + MATRIX_OPS_ACCESS_TOKEN or MATRIX_OPS_USER_ID + MATRIX_OPS_PASSWORD).',
);
} finally {
for (const key of envKeys) {
if (previousEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = previousEnv[key];
}
}
}
});
it("accepts --use-env for non-default account when scoped env vars are present", () => {
const envKeys = {
MATRIX_OPS_HOMESERVER: process.env.MATRIX_OPS_HOMESERVER,
MATRIX_OPS_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN,
};
process.env.MATRIX_OPS_HOMESERVER = "https://ops.example.org";
process.env.MATRIX_OPS_ACCESS_TOKEN = "ops-token";
try {
const error = matrixPlugin.setup!.validateInput?.({
cfg: {} as CoreConfig,
accountId: "ops",
input: { useEnv: true },
});
expect(error).toBeNull();
} finally {
for (const [key, value] of Object.entries(envKeys)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
});
it("clears stored auth fields when switching a Matrix account to env-backed auth", () => {
const envKeys = {
MATRIX_OPS_HOMESERVER: process.env.MATRIX_OPS_HOMESERVER,
MATRIX_OPS_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN,
MATRIX_OPS_DEVICE_ID: process.env.MATRIX_OPS_DEVICE_ID,
MATRIX_OPS_DEVICE_NAME: process.env.MATRIX_OPS_DEVICE_NAME,
};
process.env.MATRIX_OPS_HOMESERVER = "https://ops.env.example.org";
process.env.MATRIX_OPS_ACCESS_TOKEN = "ops-env-token";
process.env.MATRIX_OPS_DEVICE_ID = "OPSENVDEVICE";
process.env.MATRIX_OPS_DEVICE_NAME = "Ops Env Device";
try {
const cfg = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://ops.inline.example.org",
userId: "@ops:inline.example.org",
accessToken: "ops-inline-token",
password: "ops-inline-password", // pragma: allowlist secret
deviceId: "OPSINLINEDEVICE",
deviceName: "Ops Inline Device",
encryption: true,
},
},
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "ops",
input: {
useEnv: true,
name: "Ops",
},
}) as CoreConfig;
const opsAccount = updated.channels?.["matrix"]?.accounts?.ops;
expect(opsAccount?.name).toBe("Ops");
expect(opsAccount?.enabled).toBe(true);
expect(opsAccount?.encryption).toBe(true);
expect(opsAccount?.homeserver).toBeUndefined();
expect(opsAccount?.userId).toBeUndefined();
expect(opsAccount?.accessToken).toBeUndefined();
expect(opsAccount?.password).toBeUndefined();
expect(opsAccount?.deviceId).toBeUndefined();
expect(opsAccount?.deviceName).toBeUndefined();
const resolvedOps = resolveMatrixConfigForAccount(updated, "ops", process.env);
expect(resolvedOps.homeserver).toBe("https://ops.env.example.org");
expect(resolvedOps.accessToken).toBe("ops-env-token");
expect(resolvedOps.deviceId).toBe("OPSENVDEVICE");
expect(resolvedOps.deviceName).toBe("Ops Env Device");
} finally {
for (const [key, value] of Object.entries(envKeys)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
});
it("resolves account id from input name when explicit account id is missing", () => {
const accountId = matrixPlugin.setup!.resolveAccountId?.({
cfg: {} as CoreConfig,
accountId: undefined,
input: { name: "Main Bot" },
});
expect(accountId).toBe("main-bot");
});
it("resolves binding account id from agent id when omitted", () => {
const accountId = matrixPlugin.setup!.resolveBindingAccountId?.({
cfg: {} as CoreConfig,
agentId: "Ops",
accountId: undefined,
});
expect(accountId).toBe("ops");
});
it("clears stale access token when switching an account to password auth", () => {
const cfg = {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.example.org",
accessToken: "old-token",
},
},
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "default",
input: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "new-password", // pragma: allowlist secret
},
}) as CoreConfig;
expect(updated.channels?.["matrix"]?.accounts?.default?.password).toBe("new-password");
expect(updated.channels?.["matrix"]?.accounts?.default?.accessToken).toBeUndefined();
});
it("clears stale password when switching an account to token auth", () => {
const cfg = {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "old-password", // pragma: allowlist secret
},
},
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "default",
input: {
homeserver: "https://matrix.example.org",
accessToken: "new-token",
},
}) as CoreConfig;
expect(updated.channels?.["matrix"]?.accounts?.default?.accessToken).toBe("new-token");
expect(updated.channels?.["matrix"]?.accounts?.default?.password).toBeUndefined();
});
});

View File

@@ -0,0 +1,265 @@
// Matrix tests cover channel.message adapter plugin behavior.
import {
verifyChannelMessageAdapterCapabilityProofs,
verifyChannelMessageLiveCapabilityAdapterProofs,
verifyChannelMessageLiveFinalizerProofs,
} from "openclaw/plugin-sdk/channel-outbound";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
const mocks = vi.hoisted(() => ({
sendMessageMatrix: vi.fn(),
}));
vi.mock("./matrix/send.js", () => ({
sendMessageMatrix: mocks.sendMessageMatrix,
sendPollMatrix: vi.fn(),
sendTypingMatrix: vi.fn(),
}));
vi.mock("./runtime.js", () => ({
getMatrixRuntime: () => ({
channel: {
text: {
chunkMarkdownText: (text: string) => [text],
},
},
}),
}));
import { matrixPlugin } from "./channel.js";
const cfg = {
channels: {
matrix: {
accessToken: "resolved-token",
},
},
} as OpenClawConfig;
function lastMatrixSendOptions() {
const options = mocks.sendMessageMatrix.mock.lastCall?.[2];
if (!options || typeof options !== "object") {
throw new Error("Expected Matrix send options");
}
return options as Record<string, unknown>;
}
describe("matrix channel message adapter", () => {
beforeAll(async () => {
mocks.sendMessageMatrix.mockResolvedValue({ messageId: "$warmup", roomId: "!room:example" });
const sendText = matrixPlugin.message?.send?.text;
if (!sendText) {
throw new Error("Expected Matrix message adapter text sender");
}
await sendText({
cfg,
to: "room:!room:example",
text: "warmup",
accountId: "default",
});
mocks.sendMessageMatrix.mockReset();
});
it("declares Matrix markdown rendering support for shared reply payloads", () => {
expect(matrixPlugin.meta.markdownCapable).toBe(true);
});
beforeEach(() => {
mocks.sendMessageMatrix.mockReset();
mocks.sendMessageMatrix.mockResolvedValue({ messageId: "$event-1", roomId: "!room:example" });
});
it("backs declared durable-final capabilities with runtime outbound proofs", async () => {
const adapter = matrixPlugin.message;
if (!adapter?.send?.text || !adapter.send.media) {
throw new Error("Expected Matrix message adapter send capabilities.");
}
const sendText = adapter.send.text;
const sendMedia = adapter.send.media;
const proveText = async () => {
mocks.sendMessageMatrix.mockClear();
const result = await sendText({
cfg,
to: "room:!room:example",
text: "hello",
accountId: "default",
});
expect(mocks.sendMessageMatrix).toHaveBeenCalledTimes(1);
expect(mocks.sendMessageMatrix.mock.lastCall?.[0]).toBe("room:!room:example");
expect(mocks.sendMessageMatrix.mock.lastCall?.[1]).toBe("hello");
const options = lastMatrixSendOptions();
expect(options.cfg).toBe(cfg);
expect(options.accountId).toBe("default");
expect(result.receipt.platformMessageIds).toEqual(["$event-1"]);
expect(result.receipt.parts[0]?.kind).toBe("text");
};
const proveMedia = async () => {
mocks.sendMessageMatrix.mockClear();
const result = await sendMedia({
cfg,
to: "room:!room:example",
text: "caption",
mediaUrl: "file:///tmp/cat.png",
mediaLocalRoots: ["/tmp/openclaw"],
accountId: "default",
audioAsVoice: true,
});
expect(mocks.sendMessageMatrix).toHaveBeenCalledTimes(1);
expect(mocks.sendMessageMatrix.mock.lastCall?.[0]).toBe("room:!room:example");
expect(mocks.sendMessageMatrix.mock.lastCall?.[1]).toBe("caption");
const options = lastMatrixSendOptions();
expect(options.cfg).toBe(cfg);
expect(options.mediaUrl).toBe("file:///tmp/cat.png");
expect(options.mediaLocalRoots).toEqual(["/tmp/openclaw"]);
expect(options.audioAsVoice).toBe(true);
expect(result.receipt.parts[0]?.kind).toBe("voice");
};
const proveReplyThread = async () => {
mocks.sendMessageMatrix.mockClear();
const result = await sendText({
cfg,
to: "room:!room:example",
text: "threaded",
accountId: "default",
replyToId: "$reply",
threadId: "$thread",
});
expect(mocks.sendMessageMatrix).toHaveBeenCalledTimes(1);
expect(mocks.sendMessageMatrix.mock.lastCall?.[0]).toBe("room:!room:example");
expect(mocks.sendMessageMatrix.mock.lastCall?.[1]).toBe("threaded");
const options = lastMatrixSendOptions();
expect(options.cfg).toBe(cfg);
expect(options.replyToId).toBe("$reply");
expect(options.threadId).toBe("$thread");
expect(result.receipt.replyToId).toBe("$reply");
expect(result.receipt.threadId).toBe("$thread");
};
await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "matrixMessageAdapter",
adapter,
proofs: {
text: proveText,
media: proveMedia,
replyTo: proveReplyThread,
thread: proveReplyThread,
messageSendingHooks: () => {
expect(adapter.send?.text).toBeTypeOf("function");
},
},
});
});
it("forwards presentation payload hooks through the registered outbound adapter", async () => {
const outbound = matrixPlugin.outbound;
expect(outbound?.presentationCapabilities?.supported).toBe(true);
expect(outbound?.presentationCapabilities?.buttons).toBe(true);
expect(outbound?.presentationCapabilities?.selects).toBe(true);
expect(outbound?.presentationCapabilities?.context).toBe(true);
expect(outbound?.presentationCapabilities?.divider).toBe(true);
if (!outbound?.renderPresentation || !outbound.sendPayload) {
throw new Error("Expected Matrix outbound presentation payload hooks.");
}
const presentation = {
title: "Select thinking level",
tone: "info" as const,
blocks: [
{
type: "buttons" as const,
buttons: [{ label: "Low", value: "/think low" }],
},
],
};
const rendered = await outbound.renderPresentation({
payload: { text: "fallback", presentation },
presentation,
ctx: {} as never,
});
const matrixChannelData = rendered?.channelData?.matrix as
| { extraContent?: Record<string, unknown> }
| undefined;
expect(matrixChannelData?.extraContent).toEqual({
"com.openclaw.presentation": {
...presentation,
version: 1,
type: "message.presentation",
},
});
await outbound.sendPayload({
cfg,
to: "room:!room:example",
text: rendered?.text ?? "",
payload: rendered!,
accountId: "default",
threadId: "$thread",
});
expect(mocks.sendMessageMatrix).toHaveBeenCalledTimes(1);
expect(mocks.sendMessageMatrix.mock.lastCall?.[0]).toBe("room:!room:example");
expect(mocks.sendMessageMatrix.mock.lastCall?.[1]).toBe(rendered?.text);
const options = lastMatrixSendOptions();
expect(options.cfg).toBe(cfg);
expect(options.accountId).toBe("default");
expect(options.threadId).toBe("$thread");
expect(options.extraContent).toEqual({
"com.openclaw.presentation": {
...presentation,
version: 1,
type: "message.presentation",
},
});
});
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
const adapter = matrixPlugin.message;
await verifyChannelMessageLiveCapabilityAdapterProofs({
adapterName: "matrixMessageAdapter",
adapter: adapter!,
proofs: {
draftPreview: () => {
expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true);
},
previewFinalization: () => {
expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true);
},
progressUpdates: () => {
expect(adapter!.live?.capabilities?.draftPreview).toBe(true);
},
quietFinalization: () => {
expect(adapter!.live?.finalizer?.capabilities?.previewReceipt).toBe(true);
},
},
});
await verifyChannelMessageLiveFinalizerProofs({
adapterName: "matrixMessageAdapter",
adapter: adapter!,
proofs: {
finalEdit: () => {
expect(adapter!.live?.capabilities?.previewFinalization).toBe(true);
},
normalFallback: () => {
expect(adapter!.send!.text).toBeTypeOf("function");
},
discardPending: () => {
expect(adapter!.live?.capabilities?.draftPreview).toBe(true);
},
previewReceipt: () => {
expect(adapter!.live?.capabilities?.quietFinalization).toBe(true);
},
},
});
});
it("declares bullets as the markdown table default", () => {
expect(matrixPlugin.messaging?.defaultMarkdownTableMode).toBe("bullets");
});
});

View File

@@ -0,0 +1,50 @@
// Matrix tests cover channel.resolve plugin behavior.
import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
const resolveMatrixTargetsMock = vi.hoisted(() => vi.fn(async () => []));
vi.mock("./resolver.runtime.js", () => ({
matrixResolverRuntime: {
resolveMatrixTargets: resolveMatrixTargetsMock,
},
}));
import { matrixResolverAdapter } from "./resolver.js";
describe("matrix resolver adapter", () => {
beforeEach(() => {
resolveMatrixTargetsMock.mockClear();
});
it("forwards accountId into Matrix target resolution", async () => {
await matrixResolverAdapter.resolveTargets({
cfg: { channels: { matrix: {} } },
accountId: "ops",
inputs: ["Alice"],
kind: "user",
runtime: createNonExitingRuntimeEnv(),
});
expect(resolveMatrixTargetsMock).toHaveBeenCalledTimes(1);
const [forwarded] = resolveMatrixTargetsMock.mock.calls.at(0) as unknown as [
{
accountId: string;
cfg: { channels: { matrix: Record<string, never> } };
inputs: string[];
kind: string;
runtime: { error: unknown; exit: unknown; log: unknown };
},
];
expect(forwarded).toEqual({
cfg: { channels: { matrix: {} } },
accountId: "ops",
inputs: ["Alice"],
kind: "user",
runtime: forwarded?.runtime,
});
expect(forwarded?.runtime.log).toBeTypeOf("function");
expect(forwarded?.runtime.error).toBeTypeOf("function");
expect(forwarded?.runtime.exit).toBeTypeOf("function");
});
});

View File

@@ -0,0 +1,18 @@
// Matrix plugin module implements channel behavior.
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
import { resolveMatrixAuth } from "./matrix/client.js";
import { probeMatrix } from "./matrix/probe.js";
import { sendMessageMatrix, sendTypingMatrix } from "./matrix/send.js";
import { matrixOutbound } from "./outbound.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
export const matrixChannelRuntime = {
listMatrixDirectoryGroupsLive,
listMatrixDirectoryPeersLive,
matrixOutbound,
probeMatrix,
resolveMatrixAuth,
resolveMatrixTargets,
sendMessageMatrix,
sendTypingMatrix,
};

View File

@@ -0,0 +1,310 @@
// Matrix tests cover channel.setup plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime-api.js";
const verificationMocks = vi.hoisted(() => ({
bootstrapMatrixVerification: vi.fn(),
}));
vi.mock("./matrix/actions/verification.js", () => ({
bootstrapMatrixVerification: verificationMocks.bootstrapMatrixVerification,
}));
import { matrixConfigAdapter } from "./config-adapter.js";
import { runMatrixSetupBootstrapAfterConfigWrite } from "./setup-bootstrap.js";
import { matrixSetupAdapter } from "./setup-core.js";
import { installMatrixTestRuntime } from "./test-runtime.js";
import type { CoreConfig } from "./types.js";
describe("matrix setup post-write bootstrap", () => {
const log = vi.fn();
const error = vi.fn();
const exit = vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
});
const encryptedDefaultCfg = {
channels: {
matrix: {
encryption: true,
},
},
} as CoreConfig;
const defaultPasswordInput = {
homeserver: "https://matrix.example.org",
userId: "@flurry:example.org",
password: "secret", // pragma: allowlist secret
} as const;
const runtime: RuntimeEnv = {
log,
error,
exit,
};
function applyAccountConfig(params: {
previousCfg: CoreConfig;
accountId: string;
input: Record<string, unknown>;
}) {
return {
previousCfg: params.previousCfg,
accountId: params.accountId,
input: params.input,
nextCfg: matrixSetupAdapter.applyAccountConfig({
cfg: params.previousCfg,
accountId: params.accountId,
input: params.input,
}) as CoreConfig,
};
}
function applyDefaultAccountConfig(input: Record<string, unknown> = defaultPasswordInput) {
return applyAccountConfig({
previousCfg: encryptedDefaultCfg,
accountId: "default",
input,
});
}
function mockBootstrapResult(params: {
success: boolean;
backupVersion?: string | null;
error?: string;
}) {
verificationMocks.bootstrapMatrixVerification.mockResolvedValue({
success: params.success,
...(params.error ? { error: params.error } : {}),
verification: {
backupVersion: params.backupVersion ?? null,
},
crossSigning: {},
pendingVerifications: 0,
cryptoBootstrap: null,
});
}
async function runAfterAccountConfigWritten(params: {
previousCfg: CoreConfig;
nextCfg: CoreConfig;
accountId: string;
input: Record<string, unknown>;
}) {
await runMatrixSetupBootstrapAfterConfigWrite({
previousCfg: params.previousCfg,
cfg: params.nextCfg,
accountId: params.accountId,
runtime,
});
}
async function withSavedEnv<T>(
values: Record<string, string | undefined>,
run: () => Promise<T> | T,
) {
const previousEnv = Object.fromEntries(
Object.keys(values).map((key) => [key, process.env[key]]),
) as Record<string, string | undefined>;
for (const [key, value] of Object.entries(values)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
try {
return await run();
} finally {
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
beforeEach(() => {
verificationMocks.bootstrapMatrixVerification.mockReset();
log.mockClear();
error.mockClear();
exit.mockClear();
installMatrixTestRuntime();
});
it("bootstraps verification for newly added encrypted accounts", async () => {
const { previousCfg, nextCfg, accountId, input } = applyDefaultAccountConfig();
mockBootstrapResult({ success: true, backupVersion: "7" });
await runAfterAccountConfigWritten({ previousCfg, nextCfg, accountId, input });
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
accountId: "default",
cfg: nextCfg,
});
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
expect(log).toHaveBeenCalledWith('Matrix backup version for "default": 7');
expect(error).not.toHaveBeenCalled();
});
it("does not bootstrap verification for already configured accounts", async () => {
const previousCfg = {
channels: {
matrix: {
accounts: {
flurry: {
encryption: true,
homeserver: "https://matrix.example.org",
userId: "@flurry:example.org",
accessToken: "token",
},
},
},
},
} as CoreConfig;
const input = {
homeserver: "https://matrix.example.org",
userId: "@flurry:example.org",
accessToken: "new-token",
};
const { nextCfg, accountId } = applyAccountConfig({
previousCfg,
accountId: "flurry",
input,
});
await runAfterAccountConfigWritten({ previousCfg, nextCfg, accountId, input });
expect(verificationMocks.bootstrapMatrixVerification).not.toHaveBeenCalled();
expect(log).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
});
it("bootstraps verification when setup enables encryption for an existing account", async () => {
const previousCfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@flurry:example.org",
accessToken: "token",
encryption: false,
},
},
} as CoreConfig;
const nextCfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@flurry:example.org",
accessToken: "token",
encryption: true,
},
},
} as CoreConfig;
mockBootstrapResult({ success: true, backupVersion: "8" });
await runAfterAccountConfigWritten({
previousCfg,
nextCfg,
accountId: "default",
input: {},
});
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
accountId: "default",
cfg: nextCfg,
});
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
expect(log).toHaveBeenCalledWith('Matrix backup version for "default": 8');
});
it("logs a warning when verification bootstrap fails", async () => {
const { previousCfg, nextCfg, accountId, input } = applyDefaultAccountConfig();
mockBootstrapResult({
success: false,
error: "no room-key backup exists on the homeserver",
});
await runAfterAccountConfigWritten({ previousCfg, nextCfg, accountId, input });
expect(error).toHaveBeenCalledWith(
'Matrix verification bootstrap warning for "default": no room-key backup exists on the homeserver',
);
});
it("bootstraps a newly added env-backed default account when encryption is already enabled", async () => {
await withSavedEnv(
{
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_ACCESS_TOKEN: "env-token",
},
async () => {
const { previousCfg, nextCfg, accountId, input } = applyDefaultAccountConfig({
useEnv: true,
});
mockBootstrapResult({ success: true, backupVersion: "9" });
await runAfterAccountConfigWritten({ previousCfg, nextCfg, accountId, input });
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
accountId: "default",
cfg: nextCfg,
});
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
},
);
});
it("rejects default useEnv setup when no Matrix auth env vars are available", () => {
return withSavedEnv(
{
MATRIX_HOMESERVER: undefined,
MATRIX_USER_ID: undefined,
MATRIX_ACCESS_TOKEN: undefined,
MATRIX_PASSWORD: undefined,
MATRIX_DEFAULT_HOMESERVER: undefined,
MATRIX_DEFAULT_USER_ID: undefined,
MATRIX_DEFAULT_ACCESS_TOKEN: undefined,
MATRIX_DEFAULT_PASSWORD: undefined,
},
() => {
expect(
matrixSetupAdapter.validateInput?.({
cfg: {} as CoreConfig,
accountId: "default",
input: { useEnv: true },
}),
).toContain("Set Matrix env vars for the default account");
},
);
});
it("clears allowPrivateNetwork and proxy when deleting the default Matrix account config", () => {
const updated = matrixConfigAdapter.deleteAccount?.({
cfg: {
channels: {
matrix: {
homeserver: "http://localhost.localdomain:8008",
network: {
dangerouslyAllowPrivateNetwork: true,
},
proxy: "http://127.0.0.1:7890",
accounts: {
ops: {
enabled: true,
},
},
},
},
} as CoreConfig,
accountId: "default",
}) as CoreConfig;
expect(updated.channels?.matrix).toEqual({
accounts: {
ops: {
enabled: true,
},
},
});
});
});

View File

@@ -0,0 +1,49 @@
// Matrix plugin module implements channel.setup behavior.
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { matrixConfigAdapter } from "./config-adapter.js";
import { MatrixChannelConfigSchema } from "./config-schema.js";
import { resolveMatrixAccount, type ResolvedMatrixAccount } from "./matrix/accounts.js";
import { createMatrixSetupWizardProxy, matrixSetupAdapter } from "./setup-core.js";
const matrixSetupWizard = createMatrixSetupWizardProxy(async () => ({
matrixSetupWizard: (await import("./setup-surface.js")).matrixSetupWizard,
}));
export const matrixSetupPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
id: "matrix",
meta: {
id: "matrix",
label: "Matrix",
selectionLabel: "Matrix (plugin)",
docsPath: "/channels/matrix",
docsLabel: "matrix",
blurb: "open protocol; configure a homeserver + access token.",
order: 70,
quickstartAllowFrom: true,
},
setupWizard: matrixSetupWizard,
setup: matrixSetupAdapter,
capabilities: {
chatTypes: ["direct", "group", "thread"],
polls: true,
reactions: true,
threads: true,
media: true,
},
reload: { configPrefixes: ["channels.matrix"] },
configSchema: MatrixChannelConfigSchema,
config: {
...matrixConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
extra: {
baseUrl: account.homeserver,
},
}),
hasConfiguredState: ({ cfg }) => resolveMatrixAccount({ cfg }).configured,
},
};

View File

@@ -0,0 +1,680 @@
// Matrix plugin module implements channel behavior.
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import {
adaptScopedAccountAccessor,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import {
createChannelMessageAdapterFromOutbound,
createRuntimeOutboundDelegates,
} from "openclaw/plugin-sdk/channel-outbound";
import {
createAllowlistProviderOpenWarningCollector,
projectAccountConfigWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result";
import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
import {
createChannelDirectoryAdapter,
createResolvedDirectoryEntriesLister,
createRuntimeDirectoryLiveAdapter,
} from "openclaw/plugin-sdk/directory-runtime";
import {
createLazyRuntimeNamedExport,
createLazyRuntimeModule,
} from "openclaw/plugin-sdk/lazy-runtime";
import {
buildProbeChannelStatusSummary,
collectStatusIssuesFromLastError,
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import {
chunkTextForOutbound,
sanitizeAssistantVisibleText,
} from "openclaw/plugin-sdk/text-chunking";
import { matrixMessageActions } from "./actions.js";
import { matrixApprovalCapability } from "./approval-native.js";
import { createMatrixPairingText, createMatrixProbeAccount } from "./channel-account-paths.js";
import { DEFAULT_ACCOUNT_ID, matrixConfigAdapter } from "./config-adapter.js";
import { MatrixChannelConfigSchema } from "./config-schema.js";
import {
legacyConfigRules as MATRIX_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig as normalizeMatrixCompatibilityConfig,
} from "./doctor-contract.js";
import { shouldSuppressLocalMatrixExecApprovalPrompt } from "./exec-approvals.js";
import {
resolveMatrixGroupRequireMention,
resolveMatrixGroupToolPolicy,
} from "./group-mentions.js";
import {
resolveMatrixAccount,
resolveMatrixAccountConfig,
type ResolvedMatrixAccount,
} from "./matrix/accounts.js";
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
import type { MatrixProbe } from "./matrix/probe.js";
import {
normalizeMatrixMessagingTarget,
resolveMatrixDirectUserId,
resolveMatrixTargetIdentity,
} from "./matrix/target-ids.js";
import {
setMatrixThreadBindingIdleTimeoutBySessionKey,
setMatrixThreadBindingMaxAgeBySessionKey,
} from "./matrix/thread-bindings-shared.js";
import { matrixResolverAdapter } from "./resolver.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
import {
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
singleAccountKeysToMove,
} from "./setup-contract.js";
import { createMatrixSetupWizardProxy, matrixSetupAdapter } from "./setup-core.js";
import { runMatrixStartupMaintenance } from "./startup-maintenance.js";
import { resolveMatrixInboundConversation } from "./thread-binding-api.js";
import type { CoreConfig } from "./types.js";
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
let matrixStartupLock: Promise<void> = Promise.resolve();
const loadMatrixSetupWizard = createLazyRuntimeNamedExport(
() => import("./setup-surface.js"),
"matrixSetupWizard",
);
const loadMatrixChannelRuntime = createLazyRuntimeNamedExport(
() => import("./channel.runtime.js"),
"matrixChannelRuntime",
);
const loadMatrixDoctorModule = createLazyRuntimeModule(() => import("./doctor.js"));
const meta = {
id: "matrix",
label: "Matrix",
selectionLabel: "Matrix (plugin)",
docsPath: "/channels/matrix",
docsLabel: "matrix",
blurb: "open protocol; configure a homeserver + access token.",
order: 70,
markdownCapable: true,
quickstartAllowFrom: true,
};
function buildMatrixTrafficStatusSummary(
snapshot?: {
lastInboundAt?: number | null;
lastOutboundAt?: number | null;
} | null,
) {
return {
lastInboundAt: snapshot?.lastInboundAt ?? null,
lastOutboundAt: snapshot?.lastOutboundAt ?? null,
};
}
const matrixDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "nestedOnly",
groupModel: "sender",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: true,
legacyConfigRules: MATRIX_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: normalizeMatrixCompatibilityConfig,
runConfigSequence: async ({ cfg, env, shouldRepair }) =>
await (await loadMatrixDoctorModule()).runMatrixDoctorSequence({ cfg, env, shouldRepair }),
cleanStaleConfig: async ({ cfg }) =>
await (await loadMatrixDoctorModule()).cleanStaleMatrixPluginConfig(cfg),
};
const listMatrixDirectoryPeersFromConfig =
createResolvedDirectoryEntriesLister<ResolvedMatrixAccount>({
kind: "user",
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
resolveSources: (account) => [
account.config.dm?.allowFrom ?? [],
account.config.groupAllowFrom ?? [],
...Object.values(account.config.groups ?? account.config.rooms ?? {}).map(
(room) => room.users ?? [],
),
],
normalizeId: (entry) => {
const raw = entry.replace(/^matrix:/i, "").trim();
if (!raw || raw === "*") {
return null;
}
const lowered = normalizeLowercaseStringOrEmpty(raw);
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
return cleaned.startsWith("@") ? `user:${cleaned}` : cleaned;
},
});
const listMatrixDirectoryGroupsFromConfig =
createResolvedDirectoryEntriesLister<ResolvedMatrixAccount>({
kind: "group",
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
resolveSources: (account) => [Object.keys(account.config.groups ?? account.config.rooms ?? {})],
normalizeId: (entry) => {
const raw = entry.replace(/^matrix:/i, "").trim();
if (!raw || raw === "*") {
return null;
}
const lowered = normalizeLowercaseStringOrEmpty(raw);
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) {
return raw;
}
return raw.startsWith("!") ? `room:${raw}` : raw;
},
});
function projectMatrixConversationBinding(binding: {
boundAt: number;
metadata?: {
lastActivityAt?: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
};
}) {
return {
boundAt: binding.boundAt,
lastActivityAt:
typeof binding.metadata?.lastActivityAt === "number"
? binding.metadata.lastActivityAt
: binding.boundAt,
idleTimeoutMs:
typeof binding.metadata?.idleTimeoutMs === "number"
? binding.metadata.idleTimeoutMs
: undefined,
maxAgeMs:
typeof binding.metadata?.maxAgeMs === "number" ? binding.metadata.maxAgeMs : undefined,
};
}
const resolveMatrixDmPolicy = createScopedDmSecurityResolver<ResolvedMatrixAccount>({
channelKey: "matrix",
resolvePolicy: (account) => account.config.dm?.policy,
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
allowFromPathSuffix: "dm.",
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
});
const collectMatrixSecurityWarnings =
createAllowlistProviderOpenWarningCollector<ResolvedMatrixAccount>({
providerConfigPresent: (cfg) => (cfg as CoreConfig).channels?.matrix !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
buildOpenWarning: {
surface: "Matrix rooms",
openBehavior: "allows any room to trigger (mention-gated)",
remediation:
'Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms',
},
});
function resolveMatrixAccountConfigPath(accountId: string, field: string): string {
return accountId === DEFAULT_ACCOUNT_ID
? `channels.matrix.${field}`
: `channels.matrix.accounts.${accountId}.${field}`;
}
function collectMatrixSecurityWarningsForAccount(params: {
account: ResolvedMatrixAccount;
cfg: CoreConfig;
}): string[] {
const warnings = collectMatrixSecurityWarnings(params);
if (params.account.accountId !== DEFAULT_ACCOUNT_ID) {
const groupPolicyPath = resolveMatrixAccountConfigPath(params.account.accountId, "groupPolicy");
const groupsPath = resolveMatrixAccountConfigPath(params.account.accountId, "groups");
const groupAllowFromPath = resolveMatrixAccountConfigPath(
params.account.accountId,
"groupAllowFrom",
);
return warnings.map((warning) =>
warning
.replace("channels.matrix.groupPolicy", groupPolicyPath)
.replace("channels.matrix.groups", groupsPath)
.replace("channels.matrix.groupAllowFrom", groupAllowFromPath),
);
}
if (params.account.config.autoJoin !== "always") {
return warnings;
}
const autoJoinPath = resolveMatrixAccountConfigPath(params.account.accountId, "autoJoin");
const autoJoinAllowlistPath = resolveMatrixAccountConfigPath(
params.account.accountId,
"autoJoinAllowlist",
);
return [
...warnings,
`- Matrix invites: autoJoin="always" joins any invited room before message policy applies. Set ${autoJoinPath}="allowlist" + ${autoJoinAllowlistPath} (or ${autoJoinPath}="off") to restrict joins.`,
];
}
function normalizeMatrixAcpConversationId(conversationId: string) {
const target = resolveMatrixTargetIdentity(conversationId);
if (!target || target.kind !== "room") {
return null;
}
return { conversationId: target.id };
}
function matchMatrixAcpConversation(params: {
bindingConversationId: string;
conversationId: string;
parentConversationId?: string;
}) {
const binding = normalizeMatrixAcpConversationId(params.bindingConversationId);
if (!binding) {
return null;
}
if (binding.conversationId === params.conversationId) {
return { conversationId: params.conversationId, matchPriority: 2 };
}
if (
params.parentConversationId &&
params.parentConversationId !== params.conversationId &&
binding.conversationId === params.parentConversationId
) {
return {
conversationId: params.parentConversationId,
matchPriority: 1,
};
}
return null;
}
function resolveMatrixCommandConversation(params: {
threadId?: string;
originatingTo?: string;
commandTo?: string;
fallbackTo?: string;
}) {
const parentConversationId = [params.originatingTo, params.commandTo, params.fallbackTo]
.map((candidate) => {
const trimmed = candidate?.trim();
if (!trimmed) {
return undefined;
}
const target = resolveMatrixTargetIdentity(trimmed);
return target?.kind === "room" ? target.id : undefined;
})
.find((candidate): candidate is string => Boolean(candidate));
if (params.threadId) {
return {
conversationId: params.threadId,
...(parentConversationId ? { parentConversationId } : {}),
};
}
return parentConversationId ? { conversationId: parentConversationId } : null;
}
function resolveMatrixDeliveryTarget(params: {
conversationId: string;
parentConversationId?: string;
}) {
const parentConversationId = params.parentConversationId?.trim();
if (parentConversationId && parentConversationId !== params.conversationId.trim()) {
const parentTarget = resolveMatrixTargetIdentity(parentConversationId);
if (parentTarget?.kind === "room") {
return {
to: `room:${parentTarget.id}`,
threadId: params.conversationId.trim(),
};
}
}
const conversationTarget = resolveMatrixTargetIdentity(params.conversationId);
if (conversationTarget?.kind === "room") {
return { to: `room:${conversationTarget.id}` };
}
return null;
}
const matrixChannelOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkTextForOutbound,
chunkerMode: "markdown",
textChunkLimit: 4000,
sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text),
deliveryCapabilities: {
durableFinal: {
text: true,
media: true,
replyTo: true,
thread: true,
messageSendingHooks: true,
},
},
presentationCapabilities: {
supported: true,
buttons: true,
selects: true,
context: true,
divider: true,
limits: {
text: {
markdownDialect: "markdown",
supportsEdit: true,
},
},
},
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) =>
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg,
accountId,
payload,
}),
...createRuntimeOutboundDelegates({
getRuntime: loadMatrixChannelRuntime,
renderPresentation: {
resolve: (runtime) => runtime.matrixOutbound.renderPresentation,
unavailableMessage: "Matrix outbound presentation rendering is unavailable",
},
sendPayload: {
resolve: (runtime) => runtime.matrixOutbound.sendPayload,
unavailableMessage: "Matrix outbound payload delivery is unavailable",
},
sendText: {
resolve: (runtime) => runtime.matrixOutbound.sendText,
unavailableMessage: "Matrix outbound text delivery is unavailable",
},
sendMedia: {
resolve: (runtime) => runtime.matrixOutbound.sendMedia,
unavailableMessage: "Matrix outbound media delivery is unavailable",
},
sendPoll: {
resolve: (runtime) => runtime.matrixOutbound.sendPoll,
unavailableMessage: "Matrix outbound poll delivery is unavailable",
},
}),
};
const matrixMessageAdapter = createChannelMessageAdapterFromOutbound({
id: "matrix",
outbound: matrixChannelOutbound,
live: {
capabilities: {
draftPreview: true,
previewFinalization: true,
progressUpdates: true,
quietFinalization: true,
},
finalizer: {
capabilities: {
finalEdit: true,
normalFallback: true,
discardPending: true,
previewReceipt: true,
},
},
},
});
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
createChatChannelPlugin<ResolvedMatrixAccount, MatrixProbe>({
base: {
id: "matrix",
meta,
setupWizard: createMatrixSetupWizardProxy(async () => ({
matrixSetupWizard: await loadMatrixSetupWizard(),
})),
capabilities: {
chatTypes: ["direct", "group", "thread"],
polls: true,
reactions: true,
threads: true,
media: true,
tts: {
voice: {
synthesisTarget: "voice-note",
},
},
},
reload: { configPrefixes: ["channels.matrix"] },
configSchema: MatrixChannelConfigSchema,
config: {
...matrixConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
extra: {
baseUrl: account.homeserver,
},
}),
},
approvalCapability: matrixApprovalCapability,
groups: {
resolveRequireMention: resolveMatrixGroupRequireMention,
resolveToolPolicy: resolveMatrixGroupToolPolicy,
},
conversationBindings: {
supportsCurrentConversationBinding: true,
defaultTopLevelPlacement: "child",
setIdleTimeoutBySessionKey: ({ targetSessionKey, accountId, idleTimeoutMs }) =>
setMatrixThreadBindingIdleTimeoutBySessionKey({
targetSessionKey,
accountId: accountId ?? "",
idleTimeoutMs,
}).map(projectMatrixConversationBinding),
setMaxAgeBySessionKey: ({ targetSessionKey, accountId, maxAgeMs }) =>
setMatrixThreadBindingMaxAgeBySessionKey({
targetSessionKey,
accountId: accountId ?? "",
maxAgeMs,
}).map(projectMatrixConversationBinding),
},
messaging: {
defaultMarkdownTableMode: "bullets",
targetPrefixes: ["matrix"],
normalizeTarget: normalizeMatrixMessagingTarget,
resolveInboundConversation: ({ to, conversationId, threadId }) =>
resolveMatrixInboundConversation({ to, conversationId, threadId }),
resolveDeliveryTarget: ({ conversationId, parentConversationId }) =>
resolveMatrixDeliveryTarget({ conversationId, parentConversationId }),
resolveOutboundSessionRoute: (params) => resolveMatrixOutboundSessionRoute(params),
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^(matrix:)?[!#@]/i.test(trimmed)) {
return true;
}
return trimmed.includes(":");
},
hint: "<room|alias|user>",
},
},
directory: createChannelDirectoryAdapter({
listPeers: async (params) => {
const entries = await listMatrixDirectoryPeersFromConfig(params);
return entries.map((entry) => {
const raw = entry.id.startsWith("user:") ? entry.id.slice("user:".length) : entry.id;
const incomplete = !raw.startsWith("@") || !raw.includes(":");
return incomplete
? Object.assign({}, entry, { name: `incomplete id; expected @user:server` })
: entry;
});
},
listGroups: async (params) => await listMatrixDirectoryGroupsFromConfig(params),
...createRuntimeDirectoryLiveAdapter({
getRuntime: loadMatrixChannelRuntime,
listPeersLive: (runtime) => runtime.listMatrixDirectoryPeersLive,
listGroupsLive: (runtime) => runtime.listMatrixDirectoryGroupsLive,
}),
}),
resolver: matrixResolverAdapter,
actions: matrixMessageActions,
message: matrixMessageAdapter,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
setup: {
...matrixSetupAdapter,
singleAccountKeysToMove,
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
},
bindings: {
compileConfiguredBinding: ({ conversationId }) =>
normalizeMatrixAcpConversationId(conversationId),
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) =>
matchMatrixAcpConversation({
bindingConversationId: compiledBinding.conversationId,
conversationId,
parentConversationId,
}),
resolveCommandConversation: ({ threadId, originatingTo, commandTo, fallbackTo }) =>
resolveMatrixCommandConversation({
threadId,
originatingTo,
commandTo,
fallbackTo,
}),
},
status: createComputedAccountStatusAdapter<ResolvedMatrixAccount, MatrixProbe>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("matrix", accounts),
buildChannelSummary: ({ snapshot }) =>
buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
probeAccount: async ({ account, timeoutMs, cfg }) =>
await createMatrixProbeAccount({
resolveMatrixAuth: async ({ cfg: cfgLocal, accountId }) =>
(await loadMatrixChannelRuntime()).resolveMatrixAuth({
cfg: cfgLocal,
accountId,
}),
probeMatrix: async (params) =>
await (await loadMatrixChannelRuntime()).probeMatrix(params),
})({
account,
timeoutMs,
cfg,
}),
resolveAccountSnapshot: ({ account, runtime }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
extra: {
baseUrl: account.homeserver,
lastProbeAt: runtime?.lastProbeAt ?? null,
...buildMatrixTrafficStatusSummary(runtime),
},
}),
}),
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.setStatus({
accountId: account.accountId,
baseUrl: account.homeserver,
});
ctx.log?.info(
`[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`,
);
// Serialize startup: wait for any previous startup to complete import phase.
// This works around a race condition with concurrent dynamic imports.
//
// 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/monitor/index.js)
// 3. The lock only serializes the import phase, not the provider startup
const previousLock = matrixStartupLock;
let releaseLock: () => void = () => {};
matrixStartupLock = new Promise<void>((resolve) => {
releaseLock = resolve;
});
await previousLock;
// 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/monitor/index.js").monitorMatrixProvider;
try {
const module = await import("./matrix/monitor/index.js");
monitorMatrixProvider = module.monitorMatrixProvider;
} finally {
// Release lock after import completes or fails
releaseLock();
}
return monitorMatrixProvider({
runtime: ctx.runtime,
channelRuntime: ctx.channelRuntime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
initialSyncLimit: account.config.initialSyncLimit,
replyToMode: account.config.replyToMode,
accountId: account.accountId,
setStatus: ctx.setStatus,
});
},
},
doctor: matrixDoctor,
lifecycle: {
runStartupMaintenance: runMatrixStartupMaintenance,
},
heartbeat: {
sendTyping: async ({ cfg, to, accountId }) => {
await (
await loadMatrixChannelRuntime()
).sendTypingMatrix(to, true, {
cfg: cfg as CoreConfig,
...(accountId ? { accountId } : {}),
});
},
clearTyping: async ({ cfg, to, accountId }) => {
await (
await loadMatrixChannelRuntime()
).sendTypingMatrix(to, false, {
cfg: cfg as CoreConfig,
...(accountId ? { accountId } : {}),
});
},
},
},
security: {
resolveDmPolicy: resolveMatrixDmPolicy,
collectWarnings: projectAccountConfigWarningCollector(
(cfg) => cfg as CoreConfig,
collectMatrixSecurityWarningsForAccount,
),
},
pairing: {
text: createMatrixPairingText(
async (to, message, options) =>
await (await loadMatrixChannelRuntime()).sendMessageMatrix(to, message, options),
),
},
threading: {
resolveReplyToMode: createScopedAccountReplyToModeResolver<
ReturnType<typeof resolveMatrixAccountConfig>
>({
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccountConfig),
resolveReplyToMode: (account) => account.replyToMode,
}),
buildToolContext: ({ context, hasRepliedRef }) => {
const currentTarget = context.To;
return {
currentChannelId: normalizeOptionalString(currentTarget),
currentThreadTs:
context.MessageThreadId != null ? String(context.MessageThreadId) : undefined,
currentDirectUserId: resolveMatrixDirectUserId({
from: context.From,
to: context.To,
chatType: context.ChatType,
}),
hasRepliedRef,
};
},
},
outbound: matrixChannelOutbound,
});

View File

@@ -0,0 +1,20 @@
// Matrix plugin module implements cli metadata behavior.
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-plugin-common";
export function registerMatrixCliMetadata(api: OpenClawPluginApi) {
api.registerCli(
async ({ program }) => {
const { registerMatrixCli } = await import("./cli.js");
registerMatrixCli({ program });
},
{
descriptors: [
{
name: "matrix",
description: "Manage Matrix accounts, verification, devices, and profile state",
hasSubcommands: true,
},
],
},
);
}

File diff suppressed because it is too large Load Diff

2318
extensions/matrix/src/cli.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
// Matrix helper module supports config adapter behavior.
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import {
listMatrixAccountIds,
resolveDefaultMatrixAccountId,
resolveMatrixAccount,
resolveMatrixAccountConfig,
type ResolvedMatrixAccount,
} from "./matrix/accounts.js";
import { normalizeMatrixAllowList } from "./matrix/monitor/allowlist.js";
export { DEFAULT_ACCOUNT_ID };
export const matrixConfigAdapter = createScopedChannelConfigAdapter<
ResolvedMatrixAccount,
ReturnType<typeof resolveMatrixAccountConfig>
>({
sectionKey: "matrix",
listAccountIds: listMatrixAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
resolveAccessorAccount: ({ cfg, accountId }) => resolveMatrixAccountConfig({ cfg, accountId }),
defaultAccountId: resolveDefaultMatrixAccountId,
clearBaseFields: [
"name",
"homeserver",
"network",
"proxy",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
"avatarUrl",
"initialSyncLimit",
],
resolveAllowFrom: (account) => account.dm?.allowFrom,
formatAllowFrom: (allowFrom) => normalizeMatrixAllowList(allowFrom),
});

View File

@@ -0,0 +1,128 @@
// Matrix tests cover config schema plugin behavior.
import { describe, expect, it } from "vitest";
import { MatrixConfigSchema } from "./config-schema.js";
describe("MatrixConfigSchema SecretInput", () => {
it("accepts SecretRef accessToken at top-level", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
});
expect(result.success).toBe(true);
});
it("accepts SecretRef password at top-level", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: { source: "env", provider: "default", id: "MATRIX_PASSWORD" },
});
expect(result.success).toBe(true);
});
it("accepts dm threadReplies overrides", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
dm: {
policy: "pairing",
threadReplies: "off",
},
});
expect(result.success).toBe(true);
});
it("accepts dm sessionScope overrides", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
dm: {
policy: "pairing",
sessionScope: "per-room",
},
});
expect(result.success).toBe(true);
});
it("accepts the Matrix name matching compatibility flag", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
dangerouslyAllowNameMatching: true,
});
expect(result.success).toBe(true);
});
it("accepts room-level account assignments", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
groups: {
"!room:example.org": {
enabled: true,
account: "axis",
},
},
});
expect(result.success).toBe(true);
if (!result.success) {
throw new Error("expected schema parse to succeed");
}
expect(result.data.groups?.["!room:example.org"]?.account).toBe("axis");
});
it("accepts legacy room-level account assignments", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
rooms: {
"!room:example.org": {
enabled: true,
account: "axis",
},
},
});
expect(result.success).toBe(true);
if (!result.success) {
throw new Error("expected schema parse to succeed");
}
expect(result.data.rooms?.["!room:example.org"]?.account).toBe("axis");
});
it("accepts quiet Matrix streaming mode", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
streaming: "quiet",
});
expect(result.success).toBe(true);
});
it("accepts scalar progress Matrix streaming mode", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
streaming: "progress",
});
expect(result.success).toBe(true);
});
it("accepts Matrix streaming preview tool progress config", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
streaming: {
mode: "progress",
progress: {
label: "Shelling",
maxLines: 4,
toolProgress: false,
},
preview: {
toolProgress: true,
},
},
});
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,162 @@
// Matrix helper module supports config schema behavior.
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
import {
AllowFromListSchema,
buildNestedDmConfigSchema,
ContextVisibilityModeSchema,
GroupPolicySchema,
MarkdownConfigSchema,
MentionPatternsPolicySchema,
ToolPolicySchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
import { z } from "zod";
import { matrixChannelConfigUiHints } from "./config-ui-hints.js";
const matrixActionSchema = z
.object({
reactions: z.boolean().optional(),
messages: z.boolean().optional(),
pins: z.boolean().optional(),
profile: z.boolean().optional(),
memberInfo: z.boolean().optional(),
channelInfo: z.boolean().optional(),
verification: z.boolean().optional(),
})
.optional();
const matrixThreadBindingsSchema = z
.object({
enabled: z.boolean().optional(),
idleHours: z.number().nonnegative().optional(),
maxAgeHours: z.number().nonnegative().optional(),
spawnSessions: z.boolean().optional(),
defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
spawnSubagentSessions: z.boolean().optional(),
spawnAcpSessions: z.boolean().optional(),
})
.optional();
const matrixExecApprovalsSchema = z
.object({
enabled: z.boolean().optional(),
approvers: AllowFromListSchema,
agentFilter: z.array(z.string()).optional(),
sessionFilter: z.array(z.string()).optional(),
target: z.enum(["dm", "channel", "both"]).optional(),
})
.optional();
const botLoopProtectionSchema = z
.object({
enabled: z.boolean().optional(),
maxEventsPerWindow: z.number().int().positive().optional(),
windowSeconds: z.number().int().positive().optional(),
cooldownSeconds: z.number().int().positive().optional(),
})
.strict()
.optional();
const matrixRoomSchema = z
.object({
account: z.string().optional(),
enabled: z.boolean().optional(),
requireMention: z.boolean().optional(),
allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(),
botLoopProtection: botLoopProtectionSchema,
tools: ToolPolicySchema,
autoReply: z.boolean().optional(),
users: AllowFromListSchema,
skills: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),
})
.optional();
const matrixNetworkSchema = z
.object({
dangerouslyAllowPrivateNetwork: z.boolean().optional(),
})
.strict()
.optional();
const matrixStreamingSchema = z
.object({
mode: z.enum(["partial", "quiet", "progress", "off"]).optional(),
progress: z
.object({
label: z.union([z.string(), z.literal(false)]).optional(),
labels: z.array(z.string()).optional(),
maxLines: z.number().int().positive().optional(),
maxLineChars: z.number().int().positive().optional(),
toolProgress: z.boolean().optional(),
})
.strict()
.optional(),
preview: z
.object({
toolProgress: z.boolean().optional(),
})
.strict()
.optional(),
})
.strict();
export const MatrixConfigSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
defaultAccount: z.string().optional(),
accounts: z.record(z.string(), z.unknown()).optional(),
markdown: MarkdownConfigSchema,
homeserver: z.string().optional(),
network: matrixNetworkSchema,
proxy: z.string().optional(),
userId: z.string().optional(),
accessToken: buildSecretInputSchema().optional(),
password: buildSecretInputSchema().optional(),
deviceId: z.string().optional(),
deviceName: z.string().optional(),
avatarUrl: z.string().optional(),
initialSyncLimit: z.number().optional(),
encryption: z.boolean().optional(),
allowlistOnly: z.boolean().optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(),
botLoopProtection: botLoopProtectionSchema,
groupPolicy: GroupPolicySchema.optional(),
mentionPatterns: MentionPatternsPolicySchema.optional(),
contextVisibility: ContextVisibilityModeSchema.optional(),
blockStreaming: z.boolean().optional(),
streaming: z
.union([z.enum(["partial", "quiet", "progress", "off"]), z.boolean(), matrixStreamingSchema])
.optional(),
replyToMode: z.enum(["off", "first", "all", "batched"]).optional(),
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
textChunkLimit: z.number().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
responsePrefix: z.string().optional(),
ackReaction: z.string().optional(),
ackReactionScope: z
.enum(["group-mentions", "group-all", "direct", "all", "none", "off"])
.optional(),
reactionNotifications: z.enum(["off", "own"]).optional(),
threadBindings: matrixThreadBindingsSchema,
startupVerification: z.enum(["off", "if-unverified"]).optional(),
startupVerificationCooldownHours: z.number().optional(),
mediaMaxMb: z.number().optional(),
historyLimit: z.number().int().min(0).optional(),
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
autoJoinAllowlist: AllowFromListSchema,
groupAllowFrom: AllowFromListSchema,
dm: buildNestedDmConfigSchema({
sessionScope: z.enum(["per-user", "per-room"]).optional(),
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
}),
execApprovals: matrixExecApprovalsSchema,
groups: z.object({}).catchall(matrixRoomSchema).optional(),
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
actions: matrixActionSchema,
});
export const MatrixChannelConfigSchema = buildChannelConfigSchema(MatrixConfigSchema, {
uiHints: matrixChannelConfigUiHints,
});

View File

@@ -0,0 +1,73 @@
// Matrix helper module supports config ui hints behavior.
import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/channel-core";
export const matrixChannelConfigUiHints = {
mentionPatterns: {
label: "Matrix Mention Pattern Policy",
help: "Scopes configured groupChat mentionPatterns to selected Matrix room IDs. Native Matrix mention evidence still triggers even when regex patterns are denied.",
},
"mentionPatterns.mode": {
label: "Matrix Mention Pattern Mode",
help: '"allow" enables configured regex mention patterns unless denyIn matches; "deny" disables them unless allowIn matches.',
},
"mentionPatterns.allowIn": {
label: "Matrix Mention Pattern Allowlist",
help: "Matrix room IDs where configured regex mention patterns are enabled when mode is deny.",
},
"mentionPatterns.denyIn": {
label: "Matrix Mention Pattern Denylist",
help: "Matrix room IDs where configured regex mention patterns are disabled. Native mention evidence still triggers.",
},
allowBots: {
label: "Matrix Allow Bot Messages",
help: 'Allow messages from other configured Matrix bot accounts to trigger replies (default: false). Set "mentions" to require a visible room mention.',
},
botLoopProtection: {
label: "Matrix Bot Loop Protection",
help: "Sliding-window guard for accepted Matrix configured-bot loops. Default is enabled whenever allowBots lets configured bot messages reach dispatch.",
},
"botLoopProtection.enabled": {
label: "Matrix Bot Loop Protection Enabled",
help: 'Enable the bot-pair loop guard. Defaults to true when allowBots is true or "mentions", and false when configured bot messages are ignored.',
},
"botLoopProtection.maxEventsPerWindow": {
label: "Matrix Bot Loop Events per Window",
help: "Maximum accepted bot-pair messages within the sliding window before suppression starts. Default: 20.",
},
"botLoopProtection.windowSeconds": {
label: "Matrix Bot Loop Window Seconds",
help: "Sliding window length for counting bot-pair messages. Default: 60.",
},
"botLoopProtection.cooldownSeconds": {
label: "Matrix Bot Loop Cooldown Seconds",
help: "How long to suppress the bot pair after it exceeds the budget. Default: 60.",
},
dangerouslyAllowNameMatching: {
label: "Matrix Display Name Matching",
help: "Compatibility opt-in for resolving Matrix display names and joined room names in allowlists. Prefer full @user:server IDs and room IDs or aliases because names are mutable.",
},
"streaming.progress.label": {
label: "Matrix Progress Label",
help: 'Initial progress draft title. Use "auto" for built-in single-word labels, a custom string, or false to hide the title.',
},
"streaming.progress.labels": {
label: "Matrix Progress Label Pool",
help: 'Candidate labels for streaming.progress.label="auto". Leave unset to use OpenClaw built-in progress labels.',
},
"streaming.progress.maxLines": {
label: "Matrix Progress Max Lines",
help: "Maximum number of compact progress lines to keep below the draft label (default: 8).",
},
"streaming.progress.maxLineChars": {
label: "Matrix Progress Max Line Chars",
help: "Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes.",
},
"streaming.progress.toolProgress": {
label: "Matrix Progress Tool Lines",
help: "Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery.",
},
"streaming.progress.commandText": {
label: "Matrix Progress Command Text",
help: 'Command/exec detail in progress draft lines: "raw" preserves released behavior; "status" shows only the tool label.',
},
} satisfies Record<string, ChannelConfigUiHint>;

View File

@@ -0,0 +1,198 @@
// Matrix tests cover directory live plugin behavior.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const { matrixAuthedHttpClientCtorMock, requestJsonMock } = vi.hoisted(() => ({
matrixAuthedHttpClientCtorMock: vi.fn(),
requestJsonMock: vi.fn(),
}));
vi.mock("./matrix/client.js", () => ({
resolveMatrixAuth: vi.fn(),
}));
vi.mock("./matrix/sdk/http-client.js", () => ({
MatrixAuthedHttpClient: class {
constructor(params: unknown) {
matrixAuthedHttpClientCtorMock(params);
}
requestJson(params: unknown) {
return requestJsonMock(params);
}
},
}));
let listMatrixDirectoryGroupsLive: typeof import("./directory-live.js").listMatrixDirectoryGroupsLive;
let listMatrixDirectoryPeersLive: typeof import("./directory-live.js").listMatrixDirectoryPeersLive;
let resolveMatrixAuth: typeof import("./matrix/client.js").resolveMatrixAuth;
describe("matrix directory live", () => {
const cfg = { channels: { matrix: {} } };
beforeAll(async () => {
({ listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } =
await import("./directory-live.js"));
({ resolveMatrixAuth } = await import("./matrix/client.js"));
});
beforeEach(() => {
vi.mocked(resolveMatrixAuth).mockReset();
vi.mocked(resolveMatrixAuth).mockResolvedValue({
accountId: "assistant",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "test-token",
});
matrixAuthedHttpClientCtorMock.mockReset();
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({ results: [] });
});
it("passes accountId to peer directory auth resolution", async () => {
await listMatrixDirectoryPeersLive({
cfg,
accountId: "assistant",
query: "alice",
limit: 10,
});
expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" });
});
it("passes accountId to group directory auth resolution", async () => {
await listMatrixDirectoryGroupsLive({
cfg,
accountId: "assistant",
query: "channel:#room:example.org",
limit: 10,
});
expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" });
});
it("passes dispatcherPolicy through to the live directory client", async () => {
vi.mocked(resolveMatrixAuth).mockResolvedValue({
accountId: "assistant",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "test-token",
dispatcherPolicy: {
mode: "explicit-proxy",
proxyUrl: "http://proxy.internal:8080",
},
});
await listMatrixDirectoryPeersLive({
cfg,
accountId: "assistant",
query: "alice",
});
expect(matrixAuthedHttpClientCtorMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
accessToken: "test-token",
ssrfPolicy: undefined,
dispatcherPolicy: {
mode: "explicit-proxy",
proxyUrl: "http://proxy.internal:8080",
},
});
});
it("returns no peer results for empty query without resolving auth", async () => {
const result = await listMatrixDirectoryPeersLive({
cfg,
query: " ",
});
expect(result).toStrictEqual([]);
expect(resolveMatrixAuth).not.toHaveBeenCalled();
expect(requestJsonMock).not.toHaveBeenCalled();
});
it("returns no group results for empty query without resolving auth", async () => {
const result = await listMatrixDirectoryGroupsLive({
cfg,
query: "",
});
expect(result).toStrictEqual([]);
expect(resolveMatrixAuth).not.toHaveBeenCalled();
expect(requestJsonMock).not.toHaveBeenCalled();
});
it("preserves query casing when searching the Matrix user directory", async () => {
await listMatrixDirectoryPeersLive({
cfg,
query: "Alice",
limit: 3,
});
expect(requestJsonMock).toHaveBeenCalledWith({
method: "POST",
endpoint: "/_matrix/client/v3/user_directory/search",
timeoutMs: 10_000,
body: {
search_term: "Alice",
limit: 3,
},
});
});
it("accepts prefixed fully qualified user ids without hitting Matrix", async () => {
const results = await listMatrixDirectoryPeersLive({
cfg,
query: "matrix:user:@Alice:Example.org",
});
expect(results).toEqual([
{
kind: "user",
id: "@Alice:Example.org",
},
]);
expect(requestJsonMock).not.toHaveBeenCalled();
});
it("resolves prefixed room aliases through the hardened Matrix HTTP client", async () => {
requestJsonMock.mockResolvedValueOnce({
room_id: "!team:example.org",
});
const results = await listMatrixDirectoryGroupsLive({
cfg,
query: "channel:#Team:Example.org",
});
expect(results).toEqual([
{
kind: "group",
id: "!team:example.org",
name: "#Team:Example.org",
handle: "#Team:Example.org",
},
]);
expect(requestJsonMock).toHaveBeenCalledWith({
method: "GET",
endpoint: "/_matrix/client/v3/directory/room/%23Team%3AExample.org",
timeoutMs: 10_000,
body: undefined,
});
});
it("accepts prefixed room ids without additional Matrix lookups", async () => {
const results = await listMatrixDirectoryGroupsLive({
cfg,
query: "matrix:room:!team:example.org",
});
expect(results).toEqual([
{
kind: "group",
id: "!team:example.org",
name: "!team:example.org",
},
]);
expect(requestJsonMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,239 @@
// Matrix plugin module implements directory live behavior.
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveMatrixAuth } from "./matrix/client.js";
import { MatrixAuthedHttpClient } from "./matrix/sdk/http-client.js";
import { isMatrixQualifiedUserId, normalizeMatrixMessagingTarget } from "./matrix/target-ids.js";
import type { ChannelDirectoryEntry } from "./runtime-api.js";
type MatrixUserResult = {
user_id?: string;
display_name?: string;
};
type MatrixUserDirectoryResponse = {
results?: MatrixUserResult[];
};
type MatrixJoinedRoomsResponse = {
joined_rooms?: string[];
};
type MatrixRoomNameState = {
name?: string;
};
type MatrixAliasLookup = {
room_id?: string;
};
type MatrixDirectoryLiveParams = {
cfg: unknown;
accountId?: string | null;
query?: string | null;
limit?: number | null;
};
type MatrixResolvedAuth = Awaited<ReturnType<typeof resolveMatrixAuth>>;
const MATRIX_DIRECTORY_TIMEOUT_MS = 10_000;
function resolveMatrixDirectoryLimit(limit?: number | null): number {
return typeof limit === "number" && Number.isFinite(limit) && limit > 0
? Math.max(1, Math.floor(limit))
: 20;
}
function createMatrixDirectoryClient(auth: MatrixResolvedAuth): MatrixAuthedHttpClient {
return new MatrixAuthedHttpClient({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
ssrfPolicy: auth.ssrfPolicy,
dispatcherPolicy: auth.dispatcherPolicy,
});
}
async function resolveMatrixDirectoryContext(params: MatrixDirectoryLiveParams): Promise<{
auth: MatrixResolvedAuth;
client: MatrixAuthedHttpClient;
query: string;
queryLower: string;
} | null> {
const query = normalizeOptionalString(params.query) ?? "";
if (!query) {
return null;
}
const auth = await resolveMatrixAuth({ cfg: params.cfg as never, accountId: params.accountId });
return {
auth,
client: createMatrixDirectoryClient(auth),
query,
queryLower: normalizeLowercaseStringOrEmpty(query),
};
}
function createGroupDirectoryEntry(params: {
id: string;
name: string;
handle?: string;
}): ChannelDirectoryEntry {
return {
kind: "group",
id: params.id,
name: params.name,
handle: params.handle,
} satisfies ChannelDirectoryEntry;
}
async function requestMatrixJson<T>(
client: MatrixAuthedHttpClient,
params: {
method: "GET" | "POST";
endpoint: string;
body?: unknown;
},
): Promise<T> {
return (await client.requestJson({
method: params.method,
endpoint: params.endpoint,
body: params.body,
timeoutMs: MATRIX_DIRECTORY_TIMEOUT_MS,
})) as T;
}
export async function listMatrixDirectoryPeersLive(
params: MatrixDirectoryLiveParams,
): Promise<ChannelDirectoryEntry[]> {
const query = normalizeOptionalString(params.query) ?? "";
if (!query) {
return [];
}
const directUserId = normalizeMatrixMessagingTarget(query);
if (directUserId && isMatrixQualifiedUserId(directUserId)) {
return [{ kind: "user", id: directUserId }];
}
const context = await resolveMatrixDirectoryContext({
...params,
query,
});
if (!context) {
return [];
}
const res = await requestMatrixJson<MatrixUserDirectoryResponse>(context.client, {
method: "POST",
endpoint: "/_matrix/client/v3/user_directory/search",
body: {
search_term: context.query,
limit: resolveMatrixDirectoryLimit(params.limit),
},
});
const results = res.results ?? [];
return results
.map((entry) => {
const userId = normalizeOptionalString(entry.user_id);
if (!userId) {
return null;
}
const displayName = normalizeOptionalString(entry.display_name);
return {
kind: "user",
id: userId,
name: displayName,
handle: displayName ? `@${displayName}` : undefined,
raw: entry,
} satisfies ChannelDirectoryEntry;
})
.filter(Boolean) as ChannelDirectoryEntry[];
}
async function resolveMatrixRoomAlias(
client: MatrixAuthedHttpClient,
alias: string,
): Promise<string | null> {
try {
const res = await requestMatrixJson<MatrixAliasLookup>(client, {
method: "GET",
endpoint: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
});
return normalizeOptionalString(res.room_id) ?? null;
} catch {
return null;
}
}
async function fetchMatrixRoomName(
client: MatrixAuthedHttpClient,
roomId: string,
): Promise<string | null> {
try {
const res = await requestMatrixJson<MatrixRoomNameState>(client, {
method: "GET",
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`,
});
return normalizeOptionalString(res.name) ?? null;
} catch {
return null;
}
}
export async function listMatrixDirectoryGroupsLive(
params: MatrixDirectoryLiveParams,
): Promise<ChannelDirectoryEntry[]> {
const query = normalizeOptionalString(params.query) ?? "";
if (!query) {
return [];
}
const directTarget = normalizeMatrixMessagingTarget(query);
if (directTarget?.startsWith("!")) {
return [createGroupDirectoryEntry({ id: directTarget, name: directTarget })];
}
const context = await resolveMatrixDirectoryContext({
...params,
query,
});
if (!context) {
return [];
}
const { client, queryLower } = context;
const limit = resolveMatrixDirectoryLimit(params.limit);
if (directTarget?.startsWith("#")) {
const roomId = await resolveMatrixRoomAlias(client, directTarget);
if (!roomId) {
return [];
}
return [createGroupDirectoryEntry({ id: roomId, name: directTarget, handle: directTarget })];
}
const joined = await requestMatrixJson<MatrixJoinedRoomsResponse>(client, {
method: "GET",
endpoint: "/_matrix/client/v3/joined_rooms",
});
const rooms = (joined.joined_rooms ?? [])
.map((roomId) => normalizeOptionalString(roomId))
.filter((roomId): roomId is string => Boolean(roomId));
const results: ChannelDirectoryEntry[] = [];
for (const roomId of rooms) {
const name = await fetchMatrixRoomName(client, roomId);
if (!name || !normalizeLowercaseStringOrEmpty(name).includes(queryLower)) {
continue;
}
results.push({
kind: "group",
id: roomId,
name,
handle: `#${name}`,
});
if (results.length >= limit) {
break;
}
}
return results;
}

View File

@@ -0,0 +1,288 @@
// Matrix plugin module implements doctor contract behavior.
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
hasLegacyFlatAllowPrivateNetworkAlias,
migrateLegacyFlatAllowPrivateNetworkAlias,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { isRecord } from "./record-shared.js";
function hasLegacyMatrixRoomAllowAlias(value: unknown): boolean {
const room = isRecord(value) ? value : null;
return Boolean(room && typeof room.allow === "boolean");
}
function hasLegacyMatrixRoomMapAllowAliases(value: unknown): boolean {
const rooms = isRecord(value) ? value : null;
return Boolean(rooms && Object.values(rooms).some((room) => hasLegacyMatrixRoomAllowAlias(room)));
}
function hasLegacyMatrixAccountRoomAllowAliases(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => {
if (!isRecord(account)) {
return false;
}
return (
hasLegacyMatrixRoomMapAllowAliases(account.groups) ||
hasLegacyMatrixRoomMapAllowAliases(account.rooms)
);
});
}
function hasLegacyMatrixAccountPrivateNetworkAliases(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
);
}
function hasLegacyTrustedDmPolicy(value: unknown): boolean {
const root = isRecord(value) ? value : null;
if (!root) {
return false;
}
const dm = isRecord(root.dm) ? root.dm : null;
return dm?.policy === "trusted";
}
function hasLegacyMatrixAccountTrustedDmPolicies(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacyTrustedDmPolicy(account));
}
function migrateLegacyTrustedDmPolicy(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
const dm = isRecord(params.entry.dm) ? params.entry.dm : null;
if (!dm || dm.policy !== "trusted") {
return { entry: params.entry, changed: false };
}
const allowFromRaw = dm.allowFrom;
// Trim before counting: downstream allowlist normalization drops whitespace-only
// entries, so a config like [" "] must still fall back to "pairing"
// instead of becoming an effectively empty allowlist.
const allowFromEntries = Array.isArray(allowFromRaw)
? allowFromRaw.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
).length
: 0;
// Preserve the operator's existing trust boundary when an explicit allowFrom
// list is present; only fall back to pairing when the effective allowlist is
// empty.
const nextPolicy: "allowlist" | "pairing" = allowFromEntries > 0 ? "allowlist" : "pairing";
const nextDm = { ...dm, policy: nextPolicy };
params.changes.push(
`Migrated ${params.pathPrefix}.dm.policy "trusted" → "${nextPolicy}" (legacy alias removed; ` +
`${allowFromEntries > 0 ? `preserved ${allowFromEntries} ${params.pathPrefix}.dm.allowFrom ${allowFromEntries === 1 ? "entry" : "entries"}` : "no allowFrom entries present, defaulting to pairing for safety"}).`,
);
return { entry: { ...params.entry, dm: nextDm }, changed: true };
}
function normalizeMatrixRoomAllowAliases(params: {
rooms: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { rooms: Record<string, unknown>; changed: boolean } {
let changed = false;
const nextRooms: Record<string, unknown> = { ...params.rooms };
for (const [roomId, roomValue] of Object.entries(params.rooms)) {
const room = isRecord(roomValue) ? roomValue : null;
if (!room || typeof room.allow !== "boolean") {
continue;
}
const nextRoom = { ...room };
if (typeof nextRoom.enabled !== "boolean") {
nextRoom.enabled = room.allow;
}
delete nextRoom.allow;
nextRooms[roomId] = nextRoom;
changed = true;
params.changes.push(
`Moved ${params.pathPrefix}.${roomId}.allow → ${params.pathPrefix}.${roomId}.enabled (${String(nextRoom.enabled)}).`,
);
}
return { rooms: nextRooms, changed };
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "matrix"],
message:
'channels.matrix.allowPrivateNetwork is legacy; use channels.matrix.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "matrix", "accounts"],
message:
'channels.matrix.accounts.<id>.allowPrivateNetwork is legacy; use channels.matrix.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixAccountPrivateNetworkAliases,
},
{
path: ["channels", "matrix", "groups"],
message:
'channels.matrix.groups.<room>.allow is legacy; use channels.matrix.groups.<room>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixRoomMapAllowAliases,
},
{
path: ["channels", "matrix", "rooms"],
message:
'channels.matrix.rooms.<room>.allow is legacy; use channels.matrix.rooms.<room>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixRoomMapAllowAliases,
},
{
path: ["channels", "matrix", "accounts"],
message:
'channels.matrix.accounts.<id>.{groups,rooms}.<room>.allow is legacy; use channels.matrix.accounts.<id>.{groups,rooms}.<room>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixAccountRoomAllowAliases,
},
{
path: ["channels", "matrix"],
message:
'channels.matrix.dm.policy "trusted" is legacy; use "allowlist" (with allowFrom entries) or "pairing" instead. Run "openclaw doctor --fix".',
match: hasLegacyTrustedDmPolicy,
},
{
path: ["channels", "matrix", "accounts"],
message:
'channels.matrix.accounts.<id>.dm.policy "trusted" is legacy; use "allowlist" (with allowFrom entries) or "pairing" instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixAccountTrustedDmPolicies,
},
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const channels = isRecord(cfg.channels) ? cfg.channels : null;
const matrix = isRecord(channels?.matrix) ? channels.matrix : null;
if (!matrix) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updatedMatrix: Record<string, unknown> = matrix;
let changed = false;
const topLevelPrivateNetwork = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: updatedMatrix,
pathPrefix: "channels.matrix",
changes,
});
updatedMatrix = topLevelPrivateNetwork.entry;
changed = changed || topLevelPrivateNetwork.changed;
const topLevelTrustedDmPolicy = migrateLegacyTrustedDmPolicy({
entry: updatedMatrix,
pathPrefix: "channels.matrix",
changes,
});
updatedMatrix = topLevelTrustedDmPolicy.entry;
changed = changed || topLevelTrustedDmPolicy.changed;
const normalizeTopLevelRoomScope = (key: "groups" | "rooms") => {
const rooms = isRecord(updatedMatrix[key]) ? updatedMatrix[key] : null;
if (!rooms) {
return;
}
const normalized = normalizeMatrixRoomAllowAliases({
rooms,
pathPrefix: `channels.matrix.${key}`,
changes,
});
if (normalized.changed) {
updatedMatrix = { ...updatedMatrix, [key]: normalized.rooms };
changed = true;
}
};
normalizeTopLevelRoomScope("groups");
normalizeTopLevelRoomScope("rooms");
const accounts = isRecord(updatedMatrix.accounts) ? updatedMatrix.accounts : null;
if (accounts) {
let accountsChanged = false;
const nextAccounts: Record<string, unknown> = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = isRecord(accountValue) ? accountValue : null;
if (!account) {
continue;
}
let nextAccount: Record<string, unknown> = account;
let accountChanged = false;
const privateNetworkMigration = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: nextAccount,
pathPrefix: `channels.matrix.accounts.${accountId}`,
changes,
});
if (privateNetworkMigration.changed) {
nextAccount = privateNetworkMigration.entry;
accountChanged = true;
}
const accountTrustedDmPolicy = migrateLegacyTrustedDmPolicy({
entry: nextAccount,
pathPrefix: `channels.matrix.accounts.${accountId}`,
changes,
});
if (accountTrustedDmPolicy.changed) {
nextAccount = accountTrustedDmPolicy.entry;
accountChanged = true;
}
for (const key of ["groups", "rooms"] as const) {
const rooms = isRecord(nextAccount[key]) ? nextAccount[key] : null;
if (!rooms) {
continue;
}
const normalized = normalizeMatrixRoomAllowAliases({
rooms,
pathPrefix: `channels.matrix.accounts.${accountId}.${key}`,
changes,
});
if (normalized.changed) {
nextAccount = { ...nextAccount, [key]: normalized.rooms };
accountChanged = true;
}
}
if (accountChanged) {
nextAccounts[accountId] = nextAccount;
accountsChanged = true;
}
}
if (accountsChanged) {
updatedMatrix = { ...updatedMatrix, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
matrix: updatedMatrix as NonNullable<OpenClawConfig["channels"]>["matrix"],
},
},
changes,
};
}

View File

@@ -0,0 +1,406 @@
// Matrix tests cover doctor plugin behavior.
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
applyMatrixDoctorRepair,
cleanStaleMatrixPluginConfig,
collectMatrixInstallPathWarnings,
formatMatrixLegacyCryptoPreview,
formatMatrixLegacyStatePreview,
matrixDoctor,
runMatrixDoctorSequence,
} from "./doctor.js";
vi.mock("./matrix-migration.runtime.js", async () => {
const actual = await vi.importActual<typeof import("./matrix-migration.runtime.js")>(
"./matrix-migration.runtime.js",
);
return {
...actual,
maybeCreateMatrixMigrationSnapshot: vi.fn(),
autoMigrateLegacyMatrixState: vi.fn(async () => ({ changes: [], warnings: [] })),
autoPrepareLegacyMatrixCrypto: vi.fn(async () => ({ changes: [], warnings: [] })),
resolveMatrixMigrationStatus: vi.fn(() => ({
legacyState: null,
legacyCrypto: { inspectorAvailable: true, warnings: [], plans: [] },
pending: false,
actionable: false,
})),
};
});
describe("matrix doctor", () => {
beforeEach(() => {
vi.clearAllMocks();
});
function runMatrixCompatibilityNormalize(
params: Parameters<NonNullable<typeof matrixDoctor.normalizeCompatibilityConfig>>[0],
) {
const normalize = matrixDoctor.normalizeCompatibilityConfig;
if (!normalize) {
throw new Error("expected Matrix doctor compatibility normalizer");
}
return normalize(params);
}
function normalizeMatrixDmConfig(dm: Record<string, unknown>) {
return runMatrixCompatibilityNormalize({
cfg: {
channels: {
matrix: {
dm,
},
},
} as never,
});
}
function expectChangeContaining(changes: readonly string[], fragment: string): void {
expect(changes.join("\n")).toContain(fragment);
}
it("formats state and crypto previews", () => {
expect(
formatMatrixLegacyStatePreview({
accountId: "default",
legacyStoragePath: "/tmp/legacy-sync.json",
targetStoragePath: "/tmp/new-sync.json",
legacyCryptoPath: "/tmp/legacy-crypto.json",
targetCryptoPath: "/tmp/new-crypto.json",
selectionNote: "Picked the newest account.",
targetRootDir: "/tmp/account-root",
}),
).toContain("Matrix plugin upgraded in place.");
const previews = formatMatrixLegacyCryptoPreview({
inspectorAvailable: true,
warnings: ["matrix warning"],
plans: [
{
accountId: "default",
rootDir: "/tmp/account-root",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
legacyCryptoPath: "/tmp/legacy-crypto.json",
recoveryKeyPath: "/tmp/recovery-key.txt",
statePath: "/tmp/state.json",
},
],
});
expect(previews[0]).toBe("- matrix warning");
expect(previews[1]).toContain("/tmp/recovery-key.txt");
expect(previews[1]).toContain("Recovery key state: Matrix SQLite state");
expect(previews[1]).toContain("Migration state: Matrix SQLite state");
});
it("warns on stale custom Matrix plugin paths and cleans them", async () => {
const missingPath = path.join(tmpdir(), `openclaw-matrix-missing-${Date.now()}`);
await fs.rm(missingPath, { recursive: true, force: true });
const warnings = await collectMatrixInstallPathWarnings({
plugins: {
installs: {
matrix: { source: "path", sourcePath: missingPath, installPath: missingPath },
},
},
});
expect(warnings[0]).toContain("custom path that no longer exists");
const cleaned = await cleanStaleMatrixPluginConfig({
plugins: {
installs: {
matrix: { source: "path", sourcePath: missingPath, installPath: missingPath },
},
load: { paths: [missingPath, "/other/path"] },
allow: ["matrix", "other-plugin"],
},
});
expect(cleaned.changes[0]).toContain("Removed stale Matrix plugin references");
expect(cleaned.config.plugins?.load?.paths).toEqual(["/other/path"]);
expect(cleaned.config.plugins?.allow).toEqual(["other-plugin"]);
});
it("surfaces matrix sequence warnings and repair changes", async () => {
const runtimeApi = await import("./matrix-migration.runtime.js");
vi.mocked(runtimeApi.resolveMatrixMigrationStatus).mockReturnValue({
legacyState: null,
legacyCrypto: { inspectorAvailable: true, warnings: [], plans: [] },
pending: true,
actionable: true,
});
vi.mocked(runtimeApi.maybeCreateMatrixMigrationSnapshot).mockResolvedValue({
archivePath: "/tmp/matrix-backup.tgz",
created: true,
markerPath: "/tmp/marker.json",
});
vi.mocked(runtimeApi.autoMigrateLegacyMatrixState).mockResolvedValue({
migrated: true,
changes: ["Migrated legacy sync state"],
warnings: [],
});
vi.mocked(runtimeApi.autoPrepareLegacyMatrixCrypto).mockResolvedValue({
migrated: true,
changes: ["Prepared recovery key export"],
warnings: [],
});
const cfg = {
channels: {
matrix: {},
},
} as never;
const repair = await applyMatrixDoctorRepair({ cfg, env: process.env });
expect(repair.changes.join("\n")).toContain("Matrix migration snapshot");
const sequence = await runMatrixDoctorSequence({
cfg,
env: process.env,
shouldRepair: true,
});
expect(sequence.changeNotes.join("\n")).toContain("Matrix migration snapshot");
});
it("normalizes legacy Matrix room allow aliases to enabled", () => {
const result = runMatrixCompatibilityNormalize({
cfg: {
channels: {
matrix: {
groups: {
"!ops:example.org": {
allow: true,
},
},
accounts: {
work: {
rooms: {
"!legacy:example.org": {
allow: false,
},
},
},
},
},
},
} as never,
});
const matrixConfig = result.config.channels?.matrix as
| {
groups?: Record<string, unknown>;
accounts?: Record<string, unknown>;
network?: { dangerouslyAllowPrivateNetwork?: boolean };
}
| undefined;
const workAccount = matrixConfig?.accounts?.work as
| {
rooms?: Record<string, unknown>;
network?: { dangerouslyAllowPrivateNetwork?: boolean };
}
| undefined;
expect(matrixConfig?.groups?.["!ops:example.org"]).toEqual({
enabled: true,
});
expect(workAccount?.rooms?.["!legacy:example.org"]).toEqual({
enabled: false,
});
expect(result.changes).toContain(
"Moved channels.matrix.groups.!ops:example.org.allow → channels.matrix.groups.!ops:example.org.enabled (true).",
);
expect(result.changes).toContain(
"Moved channels.matrix.accounts.work.rooms.!legacy:example.org.allow → channels.matrix.accounts.work.rooms.!legacy:example.org.enabled (false).",
);
});
it("normalizes legacy Matrix private-network aliases", () => {
const result = runMatrixCompatibilityNormalize({
cfg: {
channels: {
matrix: {
allowPrivateNetwork: true,
accounts: {
work: {
allowPrivateNetwork: false,
},
},
},
},
} as never,
});
const matrixConfig = result.config.channels?.matrix as
| {
accounts?: Record<string, unknown>;
network?: { dangerouslyAllowPrivateNetwork?: boolean };
}
| undefined;
const workAccount = matrixConfig?.accounts?.work as
| {
network?: { dangerouslyAllowPrivateNetwork?: boolean };
}
| undefined;
expect(matrixConfig?.network).toEqual({
dangerouslyAllowPrivateNetwork: true,
});
expect(workAccount?.network).toEqual({
dangerouslyAllowPrivateNetwork: false,
});
expect(result.changes).toContain(
"Moved channels.matrix.allowPrivateNetwork → channels.matrix.network.dangerouslyAllowPrivateNetwork (true).",
);
expect(result.changes).toContain(
"Moved channels.matrix.accounts.work.allowPrivateNetwork → channels.matrix.accounts.work.network.dangerouslyAllowPrivateNetwork (false).",
);
});
it("migrates legacy channels.matrix.dm.policy 'trusted' with allowFrom to 'allowlist'", () => {
const result = runMatrixCompatibilityNormalize({
cfg: {
channels: {
matrix: {
dm: {
enabled: true,
policy: "trusted",
allowFrom: ["@alice:example.org", "@bob:example.org"],
},
},
},
} as never,
});
const matrixDm = (
result.config.channels?.matrix as { dm?: { policy?: string; allowFrom?: string[] } }
)?.dm;
expect(matrixDm?.policy).toBe("allowlist");
expect(matrixDm?.allowFrom).toEqual(["@alice:example.org", "@bob:example.org"]);
expectChangeContaining(
result.changes,
'Migrated channels.matrix.dm.policy "trusted" → "allowlist"',
);
expectChangeContaining(result.changes, "preserved 2 channels.matrix.dm.allowFrom entries");
});
it("migrates legacy 'trusted' policy with whitespace-only allowFrom entries to 'pairing'", () => {
// Whitespace-only entries are dropped by downstream allowlist normalization,
// so they must not count toward the allowFrom population check — otherwise
// the migration would emit policy="allowlist" with an effectively empty
// allowlist, silently blocking all DMs.
const result = normalizeMatrixDmConfig({
enabled: true,
policy: "trusted",
allowFrom: [" ", "\t", ""],
});
const matrixDm = (result.config.channels?.matrix as { dm?: { policy?: string } })?.dm;
expect(matrixDm?.policy).toBe("pairing");
expectChangeContaining(
result.changes,
'Migrated channels.matrix.dm.policy "trusted" → "pairing"',
);
});
it("migrates legacy channels.matrix.dm.policy 'trusted' without allowFrom to 'pairing'", () => {
const result = normalizeMatrixDmConfig({
enabled: true,
policy: "trusted",
});
const matrixDm = (result.config.channels?.matrix as { dm?: { policy?: string } })?.dm;
expect(matrixDm?.policy).toBe("pairing");
expectChangeContaining(
result.changes,
'Migrated channels.matrix.dm.policy "trusted" → "pairing"',
);
});
it("migrates legacy per-account channels.matrix.accounts.<id>.dm.policy 'trusted'", () => {
const result = runMatrixCompatibilityNormalize({
cfg: {
channels: {
matrix: {
accounts: {
work: {
dm: {
enabled: true,
policy: "trusted",
allowFrom: ["@boss:example.org"],
},
},
personal: {
dm: {
enabled: true,
policy: "trusted",
},
},
},
},
},
} as never,
});
const accounts = (
result.config.channels?.matrix as {
accounts?: Record<string, { dm?: { policy?: string; allowFrom?: string[] } }>;
}
)?.accounts;
expect(accounts?.work?.dm?.policy).toBe("allowlist");
expect(accounts?.work?.dm?.allowFrom).toEqual(["@boss:example.org"]);
expect(accounts?.personal?.dm?.policy).toBe("pairing");
expectChangeContaining(
result.changes,
'Migrated channels.matrix.accounts.work.dm.policy "trusted" → "allowlist"',
);
expectChangeContaining(
result.changes,
'Migrated channels.matrix.accounts.personal.dm.policy "trusted" → "pairing"',
);
});
it("leaves modern dm.policy values untouched", () => {
const result = runMatrixCompatibilityNormalize({
cfg: {
channels: {
matrix: {
dm: {
enabled: true,
policy: "allowlist",
allowFrom: ["@alice:example.org"],
},
accounts: {
work: {
dm: { enabled: true, policy: "pairing" },
},
},
},
},
} as never,
});
expect(result.changes).toStrictEqual([]);
expect(result.config).toEqual({
channels: {
matrix: {
dm: {
enabled: true,
policy: "allowlist",
allowFrom: ["@alice:example.org"],
},
accounts: {
work: {
dm: { enabled: true, policy: "pairing" },
},
},
},
},
});
});
});

View File

@@ -0,0 +1,263 @@
// Matrix plugin module implements doctor behavior.
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
detectPluginInstallPathIssue,
formatPluginInstallPathIssue,
removePluginFromConfig,
} from "openclaw/plugin-sdk/runtime-doctor";
import {
legacyConfigRules as MATRIX_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig as normalizeMatrixCompatibilityConfig,
} from "./doctor-contract.js";
import {
autoMigrateLegacyMatrixState,
autoPrepareLegacyMatrixCrypto,
detectLegacyMatrixCrypto,
detectLegacyMatrixState,
maybeCreateMatrixMigrationSnapshot,
resolveMatrixMigrationStatus,
} from "./matrix-migration.runtime.js";
import { isRecord } from "./record-shared.js";
function hasConfiguredMatrixChannel(cfg: OpenClawConfig): boolean {
const channels = cfg.channels as Record<string, unknown> | undefined;
return isRecord(channels?.matrix);
}
function hasConfiguredMatrixPluginSurface(cfg: OpenClawConfig): boolean {
return Boolean(
cfg.plugins?.installs?.matrix ||
cfg.plugins?.entries?.matrix ||
cfg.plugins?.allow?.includes("matrix") ||
cfg.plugins?.deny?.includes("matrix"),
);
}
function hasConfiguredMatrixEnv(env: NodeJS.ProcessEnv): boolean {
return Object.entries(env).some(
([key, value]) => key.startsWith("MATRIX_") && typeof value === "string" && value.trim(),
);
}
function configMayNeedMatrixDoctorSequence(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
return (
hasConfiguredMatrixChannel(cfg) ||
hasConfiguredMatrixPluginSurface(cfg) ||
hasConfiguredMatrixEnv(env)
);
}
export function formatMatrixLegacyStatePreview(
detection: Exclude<ReturnType<typeof detectLegacyMatrixState>, null | { warning: string }>,
): string {
return [
"- Matrix plugin upgraded in place.",
`- Legacy sync store: ${detection.legacyStoragePath} -> ${detection.targetStoragePath}`,
`- Legacy crypto store: ${detection.legacyCryptoPath} -> ${detection.targetCryptoPath}`,
...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []),
'- Run "openclaw doctor --fix" to migrate this Matrix state now.',
].join("\n");
}
export function formatMatrixLegacyCryptoPreview(
detection: ReturnType<typeof detectLegacyMatrixCrypto>,
): string[] {
const notes: string[] = [];
for (const warning of detection.warnings) {
notes.push(`- ${warning}`);
}
for (const plan of detection.plans) {
notes.push(
[
`- Matrix encrypted-state migration is pending for account "${plan.accountId}".`,
`- Legacy crypto store: ${plan.legacyCryptoPath}`,
`- Recovery key state: Matrix SQLite state (imports ${plan.recoveryKeyPath} if present)`,
`- Migration state: Matrix SQLite state (imports ${plan.statePath} if present)`,
'- Run "openclaw doctor --fix" to extract any saved backup key now. Backed-up room keys will restore automatically on next gateway start.',
].join("\n"),
);
}
return notes;
}
export async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise<string[]> {
const issue = await detectPluginInstallPathIssue({
pluginId: "matrix",
install: cfg.plugins?.installs?.matrix,
});
if (!issue) {
return [];
}
return formatPluginInstallPathIssue({
issue,
pluginLabel: "Matrix",
defaultInstallCommand: "openclaw plugins install @openclaw/matrix",
}).map((entry) => `- ${entry}`);
}
export async function cleanStaleMatrixPluginConfig(cfg: OpenClawConfig) {
const issue = await detectPluginInstallPathIssue({
pluginId: "matrix",
install: cfg.plugins?.installs?.matrix,
});
if (!issue || issue.kind !== "missing-path") {
return { config: cfg, changes: [] };
}
const { config, actions } = removePluginFromConfig(cfg, "matrix");
const removed: string[] = [];
if (actions.install) {
removed.push("install record");
}
if (actions.loadPath) {
removed.push("load path");
}
if (actions.entry) {
removed.push("plugin entry");
}
if (actions.allowlist) {
removed.push("allowlist entry");
}
if (removed.length === 0) {
return { config: cfg, changes: [] };
}
return {
config,
changes: [
`Removed stale Matrix plugin references (${removed.join(", ")}). The previous install path no longer exists: ${issue.path}`,
],
};
}
export async function applyMatrixDoctorRepair(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Promise<{ changes: string[]; warnings: string[] }> {
const changes: string[] = [];
const warnings: string[] = [];
const migrationStatus = resolveMatrixMigrationStatus({
cfg: params.cfg,
env: params.env,
});
let matrixSnapshotReady = true;
if (migrationStatus.actionable) {
try {
const snapshot = await maybeCreateMatrixMigrationSnapshot({
trigger: "doctor-fix",
env: params.env,
});
changes.push(
`Matrix migration snapshot ${snapshot.created ? "created" : "reused"} before applying Matrix upgrades.\n- ${snapshot.archivePath}`,
);
} catch (error) {
matrixSnapshotReady = false;
warnings.push(
`- Failed creating a Matrix migration snapshot before repair: ${String(error)}`,
);
warnings.push(
'- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".',
);
}
} else if (migrationStatus.pending) {
warnings.push(
"- Matrix migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.",
);
}
if (!matrixSnapshotReady) {
return { changes, warnings };
}
const matrixStateRepair = await autoMigrateLegacyMatrixState({
cfg: params.cfg,
env: params.env,
});
if (matrixStateRepair.changes.length > 0) {
changes.push(
[
"Matrix plugin upgraded in place.",
...matrixStateRepair.changes.map((entry) => `- ${entry}`),
"- No user action required.",
].join("\n"),
);
}
if (matrixStateRepair.warnings.length > 0) {
warnings.push(matrixStateRepair.warnings.map((entry) => `- ${entry}`).join("\n"));
}
const matrixCryptoRepair = await autoPrepareLegacyMatrixCrypto({
cfg: params.cfg,
env: params.env,
});
if (matrixCryptoRepair.changes.length > 0) {
changes.push(
[
"Matrix encrypted-state migration prepared.",
...matrixCryptoRepair.changes.map((entry) => `- ${entry}`),
].join("\n"),
);
}
if (matrixCryptoRepair.warnings.length > 0) {
warnings.push(matrixCryptoRepair.warnings.map((entry) => `- ${entry}`).join("\n"));
}
return { changes, warnings };
}
export async function runMatrixDoctorSequence(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
shouldRepair: boolean;
}): Promise<{ changeNotes: string[]; warningNotes: string[] }> {
const warningNotes: string[] = [];
const changeNotes: string[] = [];
const installWarnings = await collectMatrixInstallPathWarnings(params.cfg);
if (installWarnings.length > 0) {
warningNotes.push(installWarnings.join("\n"));
}
if (!configMayNeedMatrixDoctorSequence(params.cfg, params.env)) {
return { changeNotes, warningNotes };
}
if (params.shouldRepair) {
const repair = await applyMatrixDoctorRepair({
cfg: params.cfg,
env: params.env,
});
changeNotes.push(...repair.changes);
warningNotes.push(...repair.warnings);
} else {
const migrationStatus = resolveMatrixMigrationStatus({
cfg: params.cfg,
env: params.env,
});
if (migrationStatus.legacyState) {
if ("warning" in migrationStatus.legacyState) {
warningNotes.push(`- ${migrationStatus.legacyState.warning}`);
} else {
warningNotes.push(formatMatrixLegacyStatePreview(migrationStatus.legacyState));
}
}
if (
migrationStatus.legacyCrypto.warnings.length > 0 ||
migrationStatus.legacyCrypto.plans.length > 0
) {
warningNotes.push(...formatMatrixLegacyCryptoPreview(migrationStatus.legacyCrypto));
}
}
return { changeNotes, warningNotes };
}
export const matrixDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "nestedOnly",
groupModel: "sender",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: true,
legacyConfigRules: MATRIX_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: normalizeMatrixCompatibilityConfig,
runConfigSequence: async ({ cfg, env, shouldRepair }) =>
await runMatrixDoctorSequence({ cfg, env, shouldRepair }),
cleanStaleConfig: async ({ cfg }) => await cleanStaleMatrixPluginConfig(cfg),
};

View File

@@ -0,0 +1,93 @@
// Matrix plugin module implements env vars behavior.
import { normalizeAccountId, normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
const MATRIX_SCOPED_ENV_SUFFIXES = [
"HOMESERVER",
"USER_ID",
"ACCESS_TOKEN",
"PASSWORD",
"DEVICE_ID",
"DEVICE_NAME",
] as const;
const MATRIX_GLOBAL_ENV_KEYS = MATRIX_SCOPED_ENV_SUFFIXES.map((suffix) => `MATRIX_${suffix}`);
const MATRIX_SCOPED_ENV_RE = new RegExp(`^MATRIX_(.+)_(${MATRIX_SCOPED_ENV_SUFFIXES.join("|")})$`);
export function resolveMatrixEnvAccountToken(accountId: string): string {
return Array.from(normalizeAccountId(accountId))
.map((char) =>
/[a-z0-9]/.test(char)
? char.toUpperCase()
: `_X${char.codePointAt(0)?.toString(16).toUpperCase() ?? "00"}_`,
)
.join("");
}
export function getMatrixScopedEnvVarNames(accountId: string): {
homeserver: string;
userId: string;
accessToken: string;
password: string;
deviceId: string;
deviceName: string;
} {
const token = resolveMatrixEnvAccountToken(accountId);
return {
homeserver: `MATRIX_${token}_HOMESERVER`,
userId: `MATRIX_${token}_USER_ID`,
accessToken: `MATRIX_${token}_ACCESS_TOKEN`,
password: `MATRIX_${token}_PASSWORD`,
deviceId: `MATRIX_${token}_DEVICE_ID`,
deviceName: `MATRIX_${token}_DEVICE_NAME`,
};
}
function decodeMatrixEnvAccountToken(token: string): string | undefined {
let decoded = "";
for (let index = 0; index < token.length; ) {
const hexEscape = /^_X([0-9A-F]+)_/.exec(token.slice(index));
if (hexEscape) {
const hex = hexEscape[1];
const codePoint = hex ? Number.parseInt(hex, 16) : Number.NaN;
if (!Number.isFinite(codePoint)) {
return undefined;
}
const char = String.fromCodePoint(codePoint);
decoded += char;
index += hexEscape[0].length;
continue;
}
const char = token[index];
if (!char || !/[A-Z0-9]/.test(char)) {
return undefined;
}
decoded += char.toLowerCase();
index += 1;
}
const normalized = normalizeOptionalAccountId(decoded);
if (!normalized) {
return undefined;
}
return resolveMatrixEnvAccountToken(normalized) === token ? normalized : undefined;
}
export function listMatrixEnvAccountIds(env: NodeJS.ProcessEnv = process.env): string[] {
const ids = new Set<string>();
for (const key of MATRIX_GLOBAL_ENV_KEYS) {
if (typeof env[key] === "string" && env[key]?.trim()) {
ids.add(normalizeAccountId("default"));
break;
}
}
for (const key of Object.keys(env)) {
const match = MATRIX_SCOPED_ENV_RE.exec(key);
if (!match) {
continue;
}
const accountId = decodeMatrixEnvAccountToken(match[1]);
if (accountId) {
ids.add(accountId);
}
}
return Array.from(ids).toSorted((a, b) => a.localeCompare(b));
}

View File

@@ -0,0 +1,69 @@
// Matrix tests cover exec approval resolver plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
const approvalRuntimeHoisted = vi.hoisted(() => ({
resolveApprovalOverGatewaySpy: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({
resolveApprovalOverGateway: (...args: unknown[]) =>
approvalRuntimeHoisted.resolveApprovalOverGatewaySpy(...args),
}));
describe("resolveMatrixApproval", () => {
beforeEach(() => {
approvalRuntimeHoisted.resolveApprovalOverGatewaySpy.mockReset();
});
it("submits exec approval resolutions through the shared gateway resolver", async () => {
const { resolveMatrixApproval } = await import("./exec-approval-resolver.js");
await resolveMatrixApproval({
cfg: {} as never,
approvalId: "req-123",
decision: "allow-once",
senderId: "@owner:example.org",
});
expect(approvalRuntimeHoisted.resolveApprovalOverGatewaySpy).toHaveBeenCalledWith({
cfg: {} as never,
approvalId: "req-123",
decision: "allow-once",
senderId: "@owner:example.org",
gatewayUrl: undefined,
clientDisplayName: "Matrix approval (@owner:example.org)",
});
});
it("passes plugin approval ids through unchanged", async () => {
const { resolveMatrixApproval } = await import("./exec-approval-resolver.js");
await resolveMatrixApproval({
cfg: {} as never,
approvalId: "plugin:req-123",
decision: "deny",
senderId: "@owner:example.org",
});
expect(approvalRuntimeHoisted.resolveApprovalOverGatewaySpy).toHaveBeenCalledWith({
cfg: {} as never,
approvalId: "plugin:req-123",
decision: "deny",
senderId: "@owner:example.org",
gatewayUrl: undefined,
clientDisplayName: "Matrix approval (@owner:example.org)",
});
});
it("recognizes structured approval-not-found errors", async () => {
const { isApprovalNotFoundError } = await import("./exec-approval-resolver.js");
const err = new Error("approval not found");
(err as Error & { gatewayCode?: string; details?: { reason?: string } }).gatewayCode =
"INVALID_REQUEST";
(err as Error & { gatewayCode?: string; details?: { reason?: string } }).details = {
reason: "APPROVAL_NOT_FOUND",
};
expect(isApprovalNotFoundError(err)).toBe(true);
});
});

View File

@@ -0,0 +1,24 @@
// Matrix plugin module implements exec approval resolver behavior.
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
export { isApprovalNotFoundError };
export async function resolveMatrixApproval(params: {
cfg: OpenClawConfig;
approvalId: string;
decision: ExecApprovalReplyDecision;
senderId?: string | null;
gatewayUrl?: string;
}): Promise<void> {
await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: params.approvalId,
decision: params.decision,
senderId: params.senderId,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `Matrix approval (${params.senderId?.trim() || "unknown"})`,
});
}

View File

@@ -0,0 +1,485 @@
// Matrix tests cover exec approvals plugin behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
import { afterEach, describe, expect, it } from "vitest";
import {
getMatrixExecApprovalApprovers,
isMatrixExecApprovalApprover,
isMatrixExecApprovalAuthorizedSender,
isMatrixExecApprovalClientEnabled,
isMatrixExecApprovalTargetRecipient,
normalizeMatrixApproverId,
resolveMatrixExecApprovalTarget,
shouldHandleMatrixExecApprovalRequest,
shouldSuppressLocalMatrixExecApprovalPrompt,
} from "./exec-approvals.js";
import type { MatrixAccountConfig, MatrixExecApprovalConfig } from "./types.js";
const tempDirs: string[] = [];
type MatrixExecApprovalRequest = Parameters<
typeof shouldHandleMatrixExecApprovalRequest
>[0]["request"];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function createTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-exec-approvals-"));
tempDirs.push(dir);
return dir;
}
function buildConfig(
execApprovals?: NonNullable<NonNullable<OpenClawConfig["channels"]>["matrix"]>["execApprovals"],
channelOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["matrix"]>>,
): OpenClawConfig {
return {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
...channelOverrides,
execApprovals,
},
},
} as OpenClawConfig;
}
function matrixAccount(
accountId: string,
execApprovals: MatrixExecApprovalConfig,
overrides: Partial<MatrixAccountConfig> = {},
): MatrixAccountConfig {
return {
homeserver: "https://matrix.example.org",
userId: `@bot-${accountId}:example.org`,
accessToken: `tok-${accountId}`,
...overrides,
execApprovals,
};
}
function buildMultiAccountMatrixConfig(params: {
sessionStorePath?: string;
defaultExecApprovals?: MatrixExecApprovalConfig;
opsExecApprovals?: MatrixExecApprovalConfig;
defaultOverrides?: Partial<MatrixAccountConfig>;
opsOverrides?: Partial<MatrixAccountConfig>;
}): OpenClawConfig {
return {
...(params.sessionStorePath ? { session: { store: params.sessionStorePath } } : {}),
channels: {
matrix: {
accounts: {
default: matrixAccount(
"default",
params.defaultExecApprovals ?? {
enabled: true,
approvers: ["@owner:example.org"],
},
params.defaultOverrides,
),
ops: matrixAccount(
"ops",
params.opsExecApprovals ?? {
enabled: true,
approvers: ["@owner:example.org"],
},
params.opsOverrides,
),
},
},
},
} as OpenClawConfig;
}
function makeForeignChannelApprovalRequest(params: {
id: string;
sessionKey?: string;
agentId?: string;
}): MatrixExecApprovalRequest {
return {
id: params.id,
request: {
command: "echo hi",
agentId: params.agentId ?? "ops-agent",
sessionKey: params.sessionKey ?? "agent:ops-agent:missing",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123",
},
createdAtMs: 0,
expiresAtMs: 1000,
};
}
describe("matrix exec approvals", () => {
it("requires enablement and approvers before enabling the client", () => {
expect(isMatrixExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
expect(
isMatrixExecApprovalClientEnabled({
cfg: buildConfig(undefined, { dm: { allowFrom: ["@owner:example.org"] } }),
}),
).toBe(false);
expect(isMatrixExecApprovalClientEnabled({ cfg: buildConfig({ enabled: true }) })).toBe(false);
expect(
isMatrixExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true }, { dm: { allowFrom: ["@owner:example.org"] } }),
}),
).toBe(true);
expect(
isMatrixExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true, approvers: ["@owner:example.org"] }),
}),
).toBe(true);
});
it("prefers explicit approvers when configured", () => {
const cfg = buildConfig(
{ enabled: true, approvers: ["user:@override:example.org"] },
{ dm: { allowFrom: ["@owner:example.org"] } },
);
expect(getMatrixExecApprovalApprovers({ cfg })).toEqual(["@override:example.org"]);
expect(isMatrixExecApprovalApprover({ cfg, senderId: "@override:example.org" })).toBe(true);
expect(isMatrixExecApprovalApprover({ cfg, senderId: "@owner:example.org" })).toBe(false);
});
it("ignores wildcard allowlist entries when inferring exec approvers", () => {
const cfg = buildConfig({ enabled: true }, { dm: { allowFrom: ["*"] } });
expect(getMatrixExecApprovalApprovers({ cfg })).toStrictEqual([]);
expect(isMatrixExecApprovalClientEnabled({ cfg })).toBe(false);
});
it("defaults target to dm", () => {
expect(
resolveMatrixExecApprovalTarget({
cfg: buildConfig({ enabled: true, approvers: ["@owner:example.org"] }),
}),
).toBe("dm");
});
it("matches matrix target recipients from generic approval forwarding targets", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
},
},
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [
{ channel: "matrix", to: "user:@target:example.org" },
{ channel: "matrix", to: "room:!ops:example.org" },
],
},
},
} as OpenClawConfig;
expect(isMatrixExecApprovalTargetRecipient({ cfg, senderId: "@target:example.org" })).toBe(
true,
);
expect(isMatrixExecApprovalTargetRecipient({ cfg, senderId: "@other:example.org" })).toBe(
false,
);
expect(isMatrixExecApprovalAuthorizedSender({ cfg, senderId: "@target:example.org" })).toBe(
true,
);
});
it("suppresses local prompts only when the native client is enabled", () => {
const payload = {
channelData: {
execApproval: {
approvalId: "req-1",
approvalSlug: "req-1",
agentId: "ops-agent",
sessionKey: "agent:ops-agent:matrix:channel:!ops:example.org",
},
},
};
expect(
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg: buildConfig({ enabled: true, approvers: ["@owner:example.org"] }),
payload,
}),
).toBe(true);
expect(
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg: buildConfig(),
payload,
}),
).toBe(false);
});
it("keeps local prompts when filters exclude the request", () => {
const payload = {
channelData: {
execApproval: {
approvalId: "req-1",
approvalSlug: "req-1",
agentId: "other-agent",
sessionKey: "agent:other-agent:matrix:channel:!ops:example.org",
},
},
};
expect(
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg: buildConfig({
enabled: true,
approvers: ["@owner:example.org"],
agentFilter: ["ops-agent"],
}),
payload,
}),
).toBe(false);
});
it("suppresses local prompts for generic exec payloads when metadata matches filters", () => {
const payload = {
channelData: {
execApproval: {
approvalId: "req-1",
approvalSlug: "req-1",
approvalKind: "exec",
agentId: "ops-agent",
sessionKey: "agent:ops-agent:matrix:channel:!ops:example.org",
},
},
};
expect(
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg: buildConfig({
enabled: true,
approvers: ["@owner:example.org"],
agentFilter: ["ops-agent"],
sessionFilter: ["matrix:channel:"],
}),
payload,
}),
).toBe(true);
});
it("suppresses local prompts for plugin approval payloads when DM approvers are configured", () => {
const payload = {
channelData: {
execApproval: {
approvalId: "plugin:req-1",
approvalSlug: "plugin:r",
approvalKind: "plugin",
},
},
};
expect(
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg: buildConfig(
{ enabled: true, approvers: ["@owner:example.org"] },
{ dm: { allowFrom: ["@owner:example.org"] } },
),
payload,
}),
).toBe(true);
});
it("normalizes prefixed approver ids", () => {
expect(normalizeMatrixApproverId("matrix:@owner:example.org")).toBe("@owner:example.org");
expect(normalizeMatrixApproverId("user:@owner:example.org")).toBe("@owner:example.org");
});
it("applies agent and session filters to request handling", () => {
const cfg = buildConfig({
enabled: true,
approvers: ["@owner:example.org"],
agentFilter: ["ops-agent"],
sessionFilter: ["matrix:channel:", "ops$"],
});
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
request: {
id: "req-1",
request: {
command: "echo hi",
agentId: "ops-agent",
sessionKey: "agent:ops-agent:matrix:channel:!room:example.org:ops",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(true);
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
request: {
id: "req-2",
request: {
command: "echo hi",
agentId: "other-agent",
sessionKey: "agent:other-agent:matrix:channel:!room:example.org:ops",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(false);
});
it("scopes non-matrix turn sources to the stored matrix account", async () => {
const tmpDir = createTempDir();
const storePath = path.join(tmpDir, "sessions.json");
await saveSessionStore(
storePath,
{
"agent:ops-agent:matrix:channel:!room:example.org": {
sessionId: "main",
updatedAt: 1,
origin: {
provider: "matrix",
accountId: "ops",
},
lastChannel: "slack",
lastTo: "channel:C999",
lastAccountId: "work",
},
},
{ skipMaintenance: true },
);
const cfg = buildMultiAccountMatrixConfig({ sessionStorePath: storePath });
const request = makeForeignChannelApprovalRequest({
id: "req-3",
sessionKey: "agent:ops-agent:matrix:channel:!room:example.org",
});
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "default",
request,
}),
).toBe(false);
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "ops",
request,
}),
).toBe(true);
});
it("rejects unbound foreign-channel approvals in multi-account matrix configs", () => {
const cfg = buildMultiAccountMatrixConfig({});
const request = makeForeignChannelApprovalRequest({ id: "req-4" });
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "default",
request,
}),
).toBe(false);
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "ops",
request,
}),
).toBe(false);
});
it("allows unbound foreign-channel approvals when only one matrix account can handle them", () => {
const cfg = buildMultiAccountMatrixConfig({
opsExecApprovals: {
enabled: false,
approvers: ["@owner:example.org"],
},
});
const request = makeForeignChannelApprovalRequest({ id: "req-5" });
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "default",
request,
}),
).toBe(true);
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "ops",
request,
}),
).toBe(false);
});
it("uses request filters when checking foreign-channel matrix ambiguity", () => {
const cfg = buildMultiAccountMatrixConfig({
defaultExecApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
agentFilter: ["ops-agent"],
},
opsExecApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
agentFilter: ["other-agent"],
},
});
const request = makeForeignChannelApprovalRequest({ id: "req-6" });
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "default",
request,
}),
).toBe(true);
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "ops",
request,
}),
).toBe(false);
});
it("ignores disabled matrix accounts when checking foreign-channel ambiguity", () => {
const cfg = buildMultiAccountMatrixConfig({
opsOverrides: { enabled: false },
});
const request = makeForeignChannelApprovalRequest({ id: "req-7" });
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "default",
request,
}),
).toBe(true);
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "ops",
request,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,294 @@
// Matrix plugin module implements exec approvals behavior.
import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-auth-runtime";
import {
createChannelExecApprovalProfile,
getExecApprovalReplyMetadata,
isChannelExecApprovalClientEnabledFromConfig,
isChannelExecApprovalTargetRecipient,
matchesApprovalRequestFilters,
} from "openclaw/plugin-sdk/approval-client-runtime";
import { resolveApprovalRequestChannelAccountId } from "openclaw/plugin-sdk/approval-native-runtime";
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { getMatrixApprovalAuthApprovers } from "./approval-auth.js";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import { listMatrixAccountIds, resolveMatrixAccount } from "./matrix/accounts.js";
import type { CoreConfig } from "./types.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalKind = "exec" | "plugin";
export { normalizeMatrixApproverId };
function normalizeMatrixExecApproverId(value: string | number): string | undefined {
const normalized = normalizeMatrixApproverId(value);
return normalized === "*" ? undefined : normalized;
}
function resolveMatrixExecApprovalConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) {
const account = resolveMatrixAccount(params);
const config = account.config.execApprovals;
if (!config) {
return undefined;
}
return {
...config,
enabled: account.enabled && account.configured ? config.enabled : false,
};
}
function countMatrixExecApprovalEligibleAccounts(params: {
cfg: OpenClawConfig;
request: ApprovalRequest;
approvalKind: ApprovalKind;
}): number {
return listMatrixAccountIds(params.cfg).filter((accountId) => {
const account = resolveMatrixAccount({ cfg: params.cfg, accountId });
if (!account.enabled || !account.configured) {
return false;
}
const config = resolveMatrixExecApprovalConfig({
cfg: params.cfg,
accountId,
});
const filters = config?.enabled
? {
agentFilter: config.agentFilter,
sessionFilter: config.sessionFilter,
}
: {
agentFilter: undefined,
sessionFilter: undefined,
};
return (
isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getMatrixApprovalApprovers({
cfg: params.cfg,
accountId,
approvalKind: params.approvalKind,
}).length,
}) &&
matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: filters.agentFilter,
sessionFilter: filters.sessionFilter,
})
);
}).length;
}
function matchesMatrixRequestAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
approvalKind: ApprovalKind;
}): boolean {
const turnSourceChannel = normalizeLowercaseStringOrEmpty(
params.request.request.turnSourceChannel,
);
const boundAccountId = resolveApprovalRequestChannelAccountId({
cfg: params.cfg,
request: params.request,
channel: "matrix",
});
if (turnSourceChannel && turnSourceChannel !== "matrix" && !boundAccountId) {
return (
countMatrixExecApprovalEligibleAccounts({
cfg: params.cfg,
request: params.request,
approvalKind: params.approvalKind,
}) <= 1
);
}
return (
!boundAccountId ||
!params.accountId ||
normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId)
);
}
export function getMatrixExecApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveMatrixAccount(params).config;
return resolveApprovalApprovers({
explicit: account.execApprovals?.approvers,
allowFrom: account.dm?.allowFrom,
normalizeApprover: normalizeMatrixExecApproverId,
});
}
function resolveMatrixApprovalKind(request: ApprovalRequest): ApprovalKind {
return request.id.startsWith("plugin:") ? "plugin" : "exec";
}
export function getMatrixApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
}): string[] {
if (params.approvalKind === "plugin") {
return getMatrixApprovalAuthApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
});
}
return getMatrixExecApprovalApprovers(params);
}
export function isMatrixExecApprovalTargetRecipient(params: {
cfg: OpenClawConfig;
senderId?: string | null;
accountId?: string | null;
}): boolean {
return isChannelExecApprovalTargetRecipient({
...params,
channel: "matrix",
normalizeSenderId: normalizeMatrixApproverId,
matchTarget: ({ target, normalizedSenderId }) =>
normalizeMatrixApproverId(target.to) === normalizedSenderId,
});
}
const matrixExecApprovalProfile = createChannelExecApprovalProfile({
resolveConfig: resolveMatrixExecApprovalConfig,
resolveApprovers: getMatrixExecApprovalApprovers,
normalizeSenderId: normalizeMatrixApproverId,
isTargetRecipient: isMatrixExecApprovalTargetRecipient,
matchesRequestAccount: (params) =>
matchesMatrixRequestAccount({
...params,
approvalKind: "exec",
}),
});
export const isMatrixExecApprovalClientEnabled = matrixExecApprovalProfile.isClientEnabled;
export const isMatrixExecApprovalApprover = matrixExecApprovalProfile.isApprover;
export const isMatrixExecApprovalAuthorizedSender = matrixExecApprovalProfile.isAuthorizedSender;
export const resolveMatrixExecApprovalTarget = matrixExecApprovalProfile.resolveTarget;
export const shouldHandleMatrixExecApprovalRequest = matrixExecApprovalProfile.shouldHandleRequest;
export function isMatrixApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
}): boolean {
if (params.approvalKind === "exec") {
return isMatrixExecApprovalClientEnabled(params);
}
const config = resolveMatrixExecApprovalConfig(params);
return isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getMatrixApprovalApprovers(params).length,
});
}
export function isMatrixAnyApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
return (
isMatrixApprovalClientEnabled({
...params,
approvalKind: "exec",
}) ||
isMatrixApprovalClientEnabled({
...params,
approvalKind: "plugin",
})
);
}
export function shouldHandleMatrixApprovalRequest(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
}): boolean {
const approvalKind = resolveMatrixApprovalKind(params.request);
if (
!matchesMatrixRequestAccount({
...params,
approvalKind,
})
) {
return false;
}
const config = resolveMatrixExecApprovalConfig(params);
if (
!isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getMatrixApprovalApprovers({
...params,
approvalKind,
}).length,
})
) {
return false;
}
return matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
});
}
function buildFilterCheckRequest(params: {
metadata: NonNullable<ReturnType<typeof getExecApprovalReplyMetadata>>;
}): ApprovalRequest {
if (params.metadata.approvalKind === "plugin") {
return {
id: params.metadata.approvalId,
request: {
title: "Plugin Approval Required",
description: "",
agentId: params.metadata.agentId ?? null,
sessionKey: params.metadata.sessionKey ?? null,
},
createdAtMs: 0,
expiresAtMs: 0,
};
}
return {
id: params.metadata.approvalId,
request: {
command: "",
agentId: params.metadata.agentId ?? null,
sessionKey: params.metadata.sessionKey ?? null,
},
createdAtMs: 0,
expiresAtMs: 0,
};
}
export function shouldSuppressLocalMatrixExecApprovalPrompt(params: {
cfg: OpenClawConfig;
accountId?: string | null;
payload: ReplyPayload;
}): boolean {
if (!matrixExecApprovalProfile.shouldSuppressLocalPrompt(params)) {
return false;
}
const metadata = getExecApprovalReplyMetadata(params.payload);
if (!metadata) {
return false;
}
const request = buildFilterCheckRequest({
metadata,
});
return shouldHandleMatrixApprovalRequest({
cfg: params.cfg,
accountId: params.accountId,
request,
});
}

View File

@@ -0,0 +1,30 @@
// Matrix tests cover group mentions plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveMatrixGroupToolPolicy } from "./group-mentions.js";
describe("Matrix group policy", () => {
it("resolves room tool policy from the case-preserved Matrix room id", () => {
const policy = resolveMatrixGroupToolPolicy({
accountId: "default",
cfg: {
channels: {
matrix: {
accounts: {
default: {
groups: {
"!RoomABC:example.org": {
tools: { allow: ["sessions_spawn"] },
},
},
},
},
},
},
},
groupId: "!roomabc:example.org",
groupChannel: "!RoomABC:example.org",
});
expect(policy).toEqual({ allow: ["sessions_spawn"] });
});
});

View File

@@ -0,0 +1,42 @@
// Matrix plugin module implements group mentions behavior.
import { resolveMatrixAccountConfig } from "./matrix/accounts.js";
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
import { normalizeMatrixResolvableTarget } from "./matrix/target-ids.js";
import type { ChannelGroupContext, GroupToolPolicyConfig } from "./runtime-api.js";
import type { CoreConfig } from "./types.js";
function resolveMatrixRoomConfigForGroup(params: ChannelGroupContext) {
const roomId = normalizeMatrixResolvableTarget(params.groupId?.trim() ?? "");
const groupChannel = params.groupChannel?.trim() ?? "";
const aliases = groupChannel ? [normalizeMatrixResolvableTarget(groupChannel)] : [];
const cfg = params.cfg as CoreConfig;
const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId });
return resolveMatrixRoomConfig({
rooms: matrixConfig.groups ?? matrixConfig.rooms,
roomId,
aliases,
}).config;
}
export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean {
const resolved = resolveMatrixRoomConfigForGroup(params);
if (resolved) {
if (resolved.autoReply === true) {
return false;
}
if (resolved.autoReply === false) {
return true;
}
if (typeof resolved.requireMention === "boolean") {
return resolved.requireMention;
}
}
return true;
}
export function resolveMatrixGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
const resolved = resolveMatrixRoomConfigForGroup(params);
return resolved?.tools;
}

View File

@@ -0,0 +1,84 @@
// Matrix tests cover legacy crypto inspector availability plugin behavior.
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const availabilityState = vi.hoisted(() => ({
currentFilePath: "/virtual/dist/matrix-migration.runtime.js",
existingPaths: new Set<string>(),
dirEntries: [] as Array<{ name: string; isFile: () => boolean }>,
}));
vi.mock("node:fs", async () => {
const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:fs")>("node:fs"),
{
existsSync: (candidate: unknown) => availabilityState.existingPaths.has(String(candidate)),
readdirSync: () => availabilityState.dirEntries as never,
},
{ mirrorToDefault: true },
);
});
vi.mock("node:url", async () => {
const actual = await vi.importActual<typeof import("node:url")>("node:url");
return {
...actual,
fileURLToPath: () => availabilityState.currentFilePath,
};
});
const { isMatrixLegacyCryptoInspectorAvailable } =
await import("./legacy-crypto-inspector-availability.js");
describe("isMatrixLegacyCryptoInspectorAvailable", () => {
beforeEach(() => {
availabilityState.currentFilePath = "/virtual/dist/matrix-migration.runtime.js";
availabilityState.existingPaths.clear();
availabilityState.dirEntries = [];
});
it("detects the source inspector module directly", () => {
availabilityState.currentFilePath = path.resolve(
"/virtual/extensions/matrix/src/legacy-crypto-inspector-availability.js",
);
availabilityState.existingPaths.add(
path.resolve("/virtual/extensions/matrix/src/matrix/legacy-crypto-inspector.ts"),
);
expect(isMatrixLegacyCryptoInspectorAvailable()).toBe(true);
});
it("detects hashed built inspector chunks", () => {
availabilityState.dirEntries = [
{
name: "legacy-crypto-inspector-TPlLnFSE.js",
isFile: () => true,
},
];
expect(isMatrixLegacyCryptoInspectorAvailable()).toBe(true);
});
it("does not confuse the availability helper artifact with the real inspector", () => {
availabilityState.dirEntries = [
{
name: "legacy-crypto-inspector-availability.js",
isFile: () => true,
},
];
expect(isMatrixLegacyCryptoInspectorAvailable()).toBe(false);
});
it("does not confuse hashed availability helper chunks with the real inspector", () => {
availabilityState.dirEntries = [
{
name: "legacy-crypto-inspector-availability-TPlLnFSE.js",
isFile: () => true,
},
];
expect(isMatrixLegacyCryptoInspectorAvailable()).toBe(false);
});
});

View File

@@ -0,0 +1,61 @@
// Matrix plugin module implements legacy crypto inspector availability behavior.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const LEGACY_CRYPTO_INSPECTOR_FILE = "legacy-crypto-inspector.js";
const LEGACY_CRYPTO_INSPECTOR_CHUNK_PREFIX = "legacy-crypto-inspector-";
const LEGACY_CRYPTO_INSPECTOR_HELPER_CHUNK_PREFIX = "availability-";
const JAVASCRIPT_MODULE_SUFFIX = ".js";
function isLegacyCryptoInspectorArtifactName(name: string): boolean {
if (name === LEGACY_CRYPTO_INSPECTOR_FILE) {
return true;
}
if (
!name.startsWith(LEGACY_CRYPTO_INSPECTOR_CHUNK_PREFIX) ||
!name.endsWith(JAVASCRIPT_MODULE_SUFFIX)
) {
return false;
}
const chunkSuffix = name.slice(
LEGACY_CRYPTO_INSPECTOR_CHUNK_PREFIX.length,
-JAVASCRIPT_MODULE_SUFFIX.length,
);
return (
chunkSuffix.length > 0 &&
chunkSuffix !== "availability" &&
!chunkSuffix.startsWith(LEGACY_CRYPTO_INSPECTOR_HELPER_CHUNK_PREFIX)
);
}
function hasSourceInspectorArtifact(currentDir: string): boolean {
return [
path.resolve(currentDir, "matrix", "legacy-crypto-inspector.ts"),
path.resolve(currentDir, "matrix", "legacy-crypto-inspector.js"),
].some((candidate) => fs.existsSync(candidate));
}
function hasBuiltInspectorArtifact(currentDir: string): boolean {
if (fs.existsSync(path.join(currentDir, "legacy-crypto-inspector.js"))) {
return true;
}
if (fs.existsSync(path.join(currentDir, "extensions", "matrix", "legacy-crypto-inspector.js"))) {
return true;
}
return fs
.readdirSync(currentDir, { withFileTypes: true })
.some((entry) => entry.isFile() && isLegacyCryptoInspectorArtifactName(entry.name));
}
export function isMatrixLegacyCryptoInspectorAvailable(): boolean {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
if (hasSourceInspectorArtifact(currentDir)) {
return true;
}
try {
return hasBuiltInspectorArtifact(currentDir);
} catch {
return false;
}
}

View File

@@ -0,0 +1,243 @@
// Matrix tests cover legacy crypto plugin behavior.
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { withTempHome } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const legacyCryptoInspectorAvailability = vi.hoisted(() => ({
available: true,
}));
vi.mock("./legacy-crypto-inspector-availability.js", () => ({
isMatrixLegacyCryptoInspectorAvailable: () => legacyCryptoInspectorAvailability.available,
}));
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
import {
readMatrixLegacyCryptoMigrationState,
readMatrixRecoveryKeyState,
} from "./matrix/crypto-state-store.js";
import { resolveMatrixAccountStorageRoot } from "./storage-paths.js";
import {
MATRIX_DEFAULT_ACCESS_TOKEN,
MATRIX_DEFAULT_DEVICE_ID,
MATRIX_DEFAULT_USER_ID,
MATRIX_OPS_ACCESS_TOKEN,
MATRIX_OPS_ACCOUNT_ID,
MATRIX_OPS_DEVICE_ID,
MATRIX_OPS_USER_ID,
MATRIX_TEST_HOMESERVER,
writeFile,
writeMatrixCredentials,
} from "./test-helpers.js";
import { installMatrixTestRuntime } from "./test-runtime.js";
function createDefaultMatrixConfig(): OpenClawConfig {
return {
channels: {
matrix: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_DEFAULT_USER_ID,
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
},
},
};
}
function writeDefaultLegacyCryptoFixture(home: string) {
const stateDir = path.join(home, ".openclaw");
const cfg = createDefaultMatrixConfig();
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_DEFAULT_USER_ID,
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
});
writeFile(
path.join(rootDir, "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: MATRIX_DEFAULT_DEVICE_ID }),
);
return { cfg, rootDir };
}
function createOpsLegacyCryptoFixture(params: {
home: string;
accessToken?: string;
includeStoredCredentials?: boolean;
}) {
const stateDir = path.join(params.home, ".openclaw");
writeFile(
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: MATRIX_OPS_DEVICE_ID }),
);
if (params.includeStoredCredentials) {
writeMatrixCredentials(stateDir, {
accountId: MATRIX_OPS_ACCOUNT_ID,
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
deviceId: MATRIX_OPS_DEVICE_ID,
});
}
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_OPS_USER_ID,
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
accountId: MATRIX_OPS_ACCOUNT_ID,
});
return { rootDir };
}
describe("matrix legacy encrypted-state migration", () => {
beforeEach(() => {
resetPluginStateStoreForTests();
installMatrixTestRuntime();
});
afterEach(() => {
legacyCryptoInspectorAvailability.available = true;
resetPluginStateStoreForTests();
});
it("extracts a saved backup key into the new recovery-key path", async () => {
await withTempHome(async (home) => {
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.inspectorAvailable).toBe(true);
expect(detection.warnings).toStrictEqual([]);
expect(detection.plans).toHaveLength(1);
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: MATRIX_DEFAULT_DEVICE_ID,
roomKeyCounts: { total: 12, backedUp: 12 },
backupVersion: "1",
decryptionKeyBase64: "YWJjZA==",
}),
},
});
expect(result.migrated).toBe(true);
expect(result.warnings).toStrictEqual([]);
expect(readMatrixRecoveryKeyState(rootDir)?.privateKeyBase64).toBe("YWJjZA==");
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(false);
});
});
it("skips migration when no legacy Matrix plans exist", async () => {
await withTempHome(async () => {
const result = await autoPrepareLegacyMatrixCrypto({
cfg: createDefaultMatrixConfig(),
env: process.env,
});
expect(result).toEqual({
migrated: false,
changes: [],
warnings: [],
});
});
});
it("warns when legacy local-only room keys cannot be recovered automatically", async () => {
await withTempHome(async (home) => {
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: MATRIX_DEFAULT_DEVICE_ID,
roomKeyCounts: { total: 15, backedUp: 10 },
backupVersion: null,
decryptionKeyBase64: null,
}),
},
});
expect(result.migrated).toBe(true);
expect(result.warnings).toContain(
'Legacy Matrix encrypted state for account "default" contains 5 room key(s) that were never backed up. Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.',
);
expect(result.warnings).toContain(
'Legacy Matrix encrypted state for account "default" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.',
);
expect(readMatrixLegacyCryptoMigrationState(rootDir)?.restoreStatus).toBe(
"manual-action-required",
);
});
});
it("prefers stored credentials for named accounts when config is token-only", async () => {
await withTempHome(async (home) => {
const { rootDir } = createOpsLegacyCryptoFixture({
home,
includeStoredCredentials: true,
});
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
accessToken: MATRIX_OPS_ACCESS_TOKEN,
},
},
},
},
};
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: MATRIX_OPS_DEVICE_ID,
roomKeyCounts: { total: 1, backedUp: 1 },
backupVersion: "1",
decryptionKeyBase64: "b3Bz",
}),
},
});
expect(result.migrated).toBe(true);
expect(readMatrixRecoveryKeyState(rootDir)?.privateKeyBase64).toBe("b3Bz");
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(false);
});
});
it("stays warning-only when the legacy crypto inspector artifact is unavailable", async () => {
legacyCryptoInspectorAvailability.available = false;
await withTempHome(async (home) => {
const { cfg } = writeDefaultLegacyCryptoFixture(home);
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.inspectorAvailable).toBe(false);
expect(detection.plans).toHaveLength(1);
expect(detection.warnings).toContain(
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",
);
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
});
expect(result).toEqual({
migrated: false,
changes: [],
warnings: [
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",
],
});
});
});
});

View File

@@ -0,0 +1,502 @@
// Matrix plugin module implements legacy crypto behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { loadJsonFile } from "openclaw/plugin-sdk/json-store";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { resolveConfiguredMatrixAccountIds } from "./account-selection.js";
import { isMatrixLegacyCryptoInspectorAvailable } from "./legacy-crypto-inspector-availability.js";
import {
migrateLegacyMatrixLegacyCryptoMigrationFileToStore,
migrateLegacyMatrixRecoveryKeyFileToStore,
readMatrixLegacyCryptoMigrationState,
readMatrixRecoveryKeyState,
writeMatrixLegacyCryptoMigrationState,
writeMatrixRecoveryKeyState,
type MatrixLegacyCryptoMigrationState,
} from "./matrix/crypto-state-store.js";
import { formatMatrixErrorMessage } from "./matrix/errors.js";
import type { MatrixStoredRecoveryKey } from "./matrix/sdk/types.js";
import {
resolveLegacyMatrixFlatStoreTarget,
resolveMatrixMigrationAccountTarget,
} from "./migration-config.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js";
const MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE =
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.";
type MatrixLegacyCryptoCounts = {
total: number;
backedUp: number;
};
type MatrixLegacyCryptoSummary = {
deviceId: string | null;
roomKeyCounts: MatrixLegacyCryptoCounts | null;
backupVersion: string | null;
decryptionKeyBase64: string | null;
};
type MatrixLegacyCryptoPlan = {
accountId: string;
rootDir: string;
recoveryKeyPath: string;
statePath: string;
legacyCryptoPath: string;
homeserver: string;
userId: string;
accessToken: string;
deviceId: string | null;
};
type MatrixLegacyCryptoDetection = {
inspectorAvailable: boolean;
plans: MatrixLegacyCryptoPlan[];
warnings: string[];
};
type MatrixLegacyCryptoPreparationResult = {
migrated: boolean;
changes: string[];
warnings: string[];
};
type MatrixLegacyCryptoPrepareDeps = {
inspectLegacyStore: MatrixLegacyCryptoInspector;
};
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;
};
type MatrixLegacyCryptoInspector = (
params: MatrixLegacyCryptoInspectorParams,
) => Promise<MatrixLegacyCryptoInspectorResult>;
type MatrixLegacyBotSdkMetadata = {
deviceId: string | null;
};
async function loadMatrixLegacyCryptoInspector(): Promise<MatrixLegacyCryptoInspector> {
const module = await import("./matrix/legacy-crypto-inspector.js");
return module.inspectLegacyMatrixCryptoStore as MatrixLegacyCryptoInspector;
}
function detectLegacyBotSdkCryptoStore(cryptoRootDir: string): {
detected: boolean;
warning?: string;
} {
try {
const stat = fs.statSync(cryptoRootDir);
if (!stat.isDirectory()) {
return {
detected: false,
warning:
`Legacy Matrix encrypted state path exists but is not a directory: ${cryptoRootDir}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
} catch (err) {
return {
detected: false,
warning:
`Failed reading legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
try {
return {
detected:
fs.existsSync(path.join(cryptoRootDir, "bot-sdk.json")) ||
fs.existsSync(path.join(cryptoRootDir, "matrix-sdk-crypto.sqlite3")) ||
fs
.readdirSync(cryptoRootDir, { withFileTypes: true })
.some(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")),
),
};
} catch (err) {
return {
detected: false,
warning:
`Failed scanning legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
}
function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] {
return resolveConfiguredMatrixAccountIds(cfg);
}
function resolveLegacyMatrixFlatStorePlan(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): MatrixLegacyCryptoPlan | { warning: string } | null {
const legacy = resolveMatrixLegacyFlatStoragePaths(resolveStateDir(params.env, os.homedir));
if (!fs.existsSync(legacy.cryptoPath)) {
return null;
}
const legacyStore = detectLegacyBotSdkCryptoStore(legacy.cryptoPath);
if (legacyStore.warning) {
return { warning: legacyStore.warning };
}
if (!legacyStore.detected) {
return null;
}
const target = resolveLegacyMatrixFlatStoreTarget({
cfg: params.cfg,
env: params.env,
detectedPath: legacy.cryptoPath,
detectedKind: "encrypted state",
});
if ("warning" in target) {
return target;
}
const metadata = loadLegacyBotSdkMetadata(legacy.cryptoPath);
return {
accountId: target.accountId,
rootDir: target.rootDir,
recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"),
statePath: path.join(target.rootDir, "legacy-crypto-migration.json"),
legacyCryptoPath: legacy.cryptoPath,
homeserver: target.homeserver,
userId: target.userId,
accessToken: target.accessToken,
deviceId: metadata.deviceId ?? target.storedDeviceId,
};
}
function loadLegacyBotSdkMetadata(cryptoRootDir: string): MatrixLegacyBotSdkMetadata {
const metadataPath = path.join(cryptoRootDir, "bot-sdk.json");
const fallback: MatrixLegacyBotSdkMetadata = { deviceId: null };
const parsed = loadJsonFile<{ deviceId?: unknown }>(metadataPath);
return {
deviceId:
typeof parsed?.deviceId === "string" && parsed.deviceId.trim()
? parsed.deviceId
: fallback.deviceId,
};
}
function resolveMatrixLegacyCryptoPlans(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Omit<MatrixLegacyCryptoDetection, "inspectorAvailable"> {
const warnings: string[] = [];
const plans: MatrixLegacyCryptoPlan[] = [];
const flatPlan = resolveLegacyMatrixFlatStorePlan(params);
if (flatPlan) {
if ("warning" in flatPlan) {
warnings.push(flatPlan.warning);
} else {
plans.push(flatPlan);
}
}
for (const accountId of resolveMatrixAccountIds(params.cfg)) {
const target = resolveMatrixMigrationAccountTarget({
cfg: params.cfg,
env: params.env,
accountId,
});
if (!target) {
continue;
}
const legacyCryptoPath = path.join(target.rootDir, "crypto");
if (!fs.existsSync(legacyCryptoPath)) {
continue;
}
const detectedStore = detectLegacyBotSdkCryptoStore(legacyCryptoPath);
if (detectedStore.warning) {
warnings.push(detectedStore.warning);
continue;
}
if (!detectedStore.detected) {
continue;
}
if (
plans.some(
(plan) =>
plan.accountId === accountId &&
path.resolve(plan.legacyCryptoPath) === path.resolve(legacyCryptoPath),
)
) {
continue;
}
const metadata = loadLegacyBotSdkMetadata(legacyCryptoPath);
plans.push({
accountId: target.accountId,
rootDir: target.rootDir,
recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"),
statePath: path.join(target.rootDir, "legacy-crypto-migration.json"),
legacyCryptoPath,
homeserver: target.homeserver,
userId: target.userId,
accessToken: target.accessToken,
deviceId: metadata.deviceId ?? target.storedDeviceId,
});
}
return { plans, warnings };
}
export function detectLegacyMatrixCrypto(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): MatrixLegacyCryptoDetection {
const detection = resolveMatrixLegacyCryptoPlans({
cfg: params.cfg,
env: params.env ?? process.env,
});
const inspectorAvailable =
detection.plans.length === 0 || isMatrixLegacyCryptoInspectorAvailable();
if (!inspectorAvailable && detection.plans.length > 0) {
return {
inspectorAvailable,
plans: detection.plans,
warnings: [...detection.warnings, MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE],
};
}
return {
inspectorAvailable,
plans: detection.plans,
warnings: detection.warnings,
};
}
export async function autoPrepareLegacyMatrixCrypto(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
log?: { info?: (message: string) => void; warn?: (message: string) => void };
deps?: Partial<MatrixLegacyCryptoPrepareDeps>;
}): Promise<MatrixLegacyCryptoPreparationResult> {
const env = params.env ?? process.env;
const detection = params.deps?.inspectLegacyStore
? resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env })
: detectLegacyMatrixCrypto({ cfg: params.cfg, env });
const inspectorAvailable =
"inspectorAvailable" in detection ? detection.inspectorAvailable : true;
const warnings = [...detection.warnings];
const changes: string[] = [];
if (detection.plans.length === 0) {
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: false,
changes,
warnings,
};
}
if (!params.deps?.inspectLegacyStore && !inspectorAvailable) {
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: false,
changes,
warnings,
};
}
let inspectLegacyStore = params.deps?.inspectLegacyStore;
if (!inspectLegacyStore) {
try {
inspectLegacyStore = await loadMatrixLegacyCryptoInspector();
} catch (err) {
const message = formatMatrixErrorMessage(err);
if (!warnings.includes(message)) {
warnings.push(message);
}
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: false,
changes,
warnings,
};
}
}
if (!inspectLegacyStore) {
return {
migrated: false,
changes,
warnings,
};
}
for (const plan of detection.plans) {
try {
migrateLegacyMatrixLegacyCryptoMigrationFileToStore(plan.rootDir);
migrateLegacyMatrixRecoveryKeyFileToStore(plan.rootDir);
} catch (err) {
warnings.push(
`Failed migrating Matrix crypto sidecar state for account "${plan.accountId}" (${plan.rootDir}): ${String(err)}`,
);
}
const existingState = readMatrixLegacyCryptoMigrationState(plan.rootDir);
if (existingState?.version === 1) {
continue;
}
if (!plan.deviceId) {
warnings.push(
`Legacy Matrix encrypted state detected at ${plan.legacyCryptoPath}, but no device ID was found for account "${plan.accountId}". ` +
`OpenClaw will continue, but old encrypted history cannot be recovered automatically.`,
);
continue;
}
let summary: MatrixLegacyCryptoSummary;
try {
summary = await inspectLegacyStore({
cryptoRootDir: plan.legacyCryptoPath,
userId: plan.userId,
deviceId: plan.deviceId,
log: params.log?.info,
});
} catch (err) {
warnings.push(
`Failed inspecting legacy Matrix encrypted state for account "${plan.accountId}" (${plan.legacyCryptoPath}): ${String(err)}`,
);
continue;
}
let decryptionKeyImported = false;
if (summary.decryptionKeyBase64) {
const existingRecoveryKey = readMatrixRecoveryKeyState(plan.rootDir);
if (
existingRecoveryKey?.privateKeyBase64 &&
existingRecoveryKey.privateKeyBase64 !== summary.decryptionKeyBase64
) {
warnings.push(
`Legacy Matrix backup key was found for account "${plan.accountId}", but Matrix SQLite state already contains a different recovery key. Leaving the existing state unchanged.`,
);
} else if (!existingRecoveryKey?.privateKeyBase64) {
const payload: MatrixStoredRecoveryKey = {
version: 1,
createdAt: new Date().toISOString(),
keyId: null,
privateKeyBase64: summary.decryptionKeyBase64,
};
try {
writeMatrixRecoveryKeyState({
storageRootDir: plan.rootDir,
payload,
});
changes.push(
`Imported Matrix legacy backup key for account "${plan.accountId}" into SQLite`,
);
decryptionKeyImported = true;
} catch (err) {
warnings.push(
`Failed writing Matrix recovery key for account "${plan.accountId}" to SQLite: ${String(err)}`,
);
}
} else {
decryptionKeyImported = true;
}
}
const localOnlyKeys =
summary.roomKeyCounts && summary.roomKeyCounts.total > summary.roomKeyCounts.backedUp
? summary.roomKeyCounts.total - summary.roomKeyCounts.backedUp
: 0;
if (localOnlyKeys > 0) {
warnings.push(
`Legacy Matrix encrypted state for account "${plan.accountId}" contains ${localOnlyKeys} room key(s) that were never backed up. ` +
"Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.",
);
}
if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.backedUp ?? 0) > 0) {
warnings.push(
`Legacy Matrix encrypted state for account "${plan.accountId}" has backed-up room keys, but no local backup decryption key was found. ` +
`Ask the operator to run "openclaw matrix verify backup restore --recovery-key <key>" after upgrade if they have the recovery key.`,
);
}
if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.total ?? 0) > 0) {
warnings.push(
`Legacy Matrix encrypted state for account "${plan.accountId}" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.`,
);
}
// If recovery-key persistence failed, leave the migration state absent so the next startup can retry.
if (
summary.decryptionKeyBase64 &&
!decryptionKeyImported &&
!readMatrixRecoveryKeyState(plan.rootDir)
) {
continue;
}
const state: MatrixLegacyCryptoMigrationState = {
version: 1,
source: "matrix-bot-sdk-rust",
accountId: plan.accountId,
deviceId: summary.deviceId,
roomKeyCounts: summary.roomKeyCounts,
backupVersion: summary.backupVersion,
decryptionKeyImported,
restoreStatus: decryptionKeyImported ? "pending" : "manual-action-required",
detectedAt: new Date().toISOString(),
lastError: null,
};
try {
writeMatrixLegacyCryptoMigrationState({
storageRootDir: plan.rootDir,
state,
});
changes.push(
`Prepared Matrix legacy encrypted-state migration for account "${plan.accountId}" in SQLite`,
);
} catch (err) {
warnings.push(
`Failed writing Matrix legacy encrypted-state migration record for account "${plan.accountId}" to SQLite: ${String(err)}`,
);
}
}
if (changes.length > 0) {
params.log?.info?.(
`matrix: prepared encrypted-state upgrade.\n${changes.map((entry) => `- ${entry}`).join("\n")}`,
);
}
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: changes.length > 0,
changes,
warnings,
};
}

View File

@@ -0,0 +1,87 @@
// Matrix tests cover legacy state plugin behavior.
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { withTempHome } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js";
function writeFile(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value, "utf-8");
}
describe("matrix legacy state migration", () => {
it("migrates the flat legacy Matrix store into account-scoped storage", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
};
const detection = detectLegacyMatrixState({ cfg, env: process.env });
expect(detection && "warning" in detection).toBe(false);
if (!detection || "warning" in detection) {
throw new Error("expected a migratable Matrix legacy state plan");
}
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
expect(result.migrated).toBe(true);
expect(result.warnings).toStrictEqual([]);
expect(fs.existsSync(path.join(stateDir, "matrix", "bot-storage.json"))).toBe(false);
expect(fs.existsSync(path.join(stateDir, "matrix", "crypto"))).toBe(false);
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
});
});
it("uses cached Matrix credentials when the config no longer stores an access token", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
writeFile(
path.join(stateDir, "credentials", "matrix", "credentials.json"),
JSON.stringify(
{
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-from-cache",
},
null,
2,
),
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
};
const detection = detectLegacyMatrixState({ cfg, env: process.env });
expect(detection && "warning" in detection).toBe(false);
if (!detection || "warning" in detection) {
throw new Error("expected cached credentials to make Matrix migration resolvable");
}
expect(detection.targetRootDir).toContain("matrix.example.org__bot_example.org");
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
expect(result.migrated).toBe(true);
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
});
});
});

View File

@@ -0,0 +1,157 @@
// Matrix plugin module implements legacy state behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { resolveLegacyMatrixFlatStoreTarget } from "./migration-config.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js";
type MatrixLegacyStateMigrationResult = {
migrated: boolean;
changes: string[];
warnings: string[];
};
type MatrixLegacyStatePlan = {
accountId: string;
legacyStoragePath: string;
legacyCryptoPath: string;
targetRootDir: string;
targetStoragePath: string;
targetCryptoPath: string;
selectionNote?: string;
};
function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): {
rootDir: string;
storagePath: string;
cryptoPath: string;
} {
const stateDir = resolveStateDir(env, os.homedir);
return resolveMatrixLegacyFlatStoragePaths(stateDir);
}
function resolveMatrixMigrationPlan(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): MatrixLegacyStatePlan | { warning: string } | null {
const legacy = resolveLegacyMatrixPaths(params.env);
if (!fs.existsSync(legacy.storagePath) && !fs.existsSync(legacy.cryptoPath)) {
return null;
}
const target = resolveLegacyMatrixFlatStoreTarget({
cfg: params.cfg,
env: params.env,
detectedPath: legacy.rootDir,
detectedKind: "state",
});
if ("warning" in target) {
return target;
}
return {
accountId: target.accountId,
legacyStoragePath: legacy.storagePath,
legacyCryptoPath: legacy.cryptoPath,
targetRootDir: target.rootDir,
targetStoragePath: path.join(target.rootDir, "bot-storage.json"),
targetCryptoPath: path.join(target.rootDir, "crypto"),
selectionNote: target.selectionNote,
};
}
export function detectLegacyMatrixState(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): MatrixLegacyStatePlan | { warning: string } | null {
return resolveMatrixMigrationPlan({
cfg: params.cfg,
env: params.env ?? process.env,
});
}
function moveLegacyPath(params: {
sourcePath: string;
targetPath: string;
label: string;
changes: string[];
warnings: string[];
}): void {
if (!fs.existsSync(params.sourcePath)) {
return;
}
if (fs.existsSync(params.targetPath)) {
params.warnings.push(
`Matrix legacy ${params.label} not migrated because the target already exists (${params.targetPath}).`,
);
return;
}
try {
fs.mkdirSync(path.dirname(params.targetPath), { recursive: true });
fs.renameSync(params.sourcePath, params.targetPath);
params.changes.push(
`Migrated Matrix legacy ${params.label}: ${params.sourcePath} -> ${params.targetPath}`,
);
} catch (err) {
params.warnings.push(
`Failed migrating Matrix legacy ${params.label} (${params.sourcePath} -> ${params.targetPath}): ${String(err)}`,
);
}
}
export async function autoMigrateLegacyMatrixState(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
log?: { info?: (message: string) => void; warn?: (message: string) => void };
}): Promise<MatrixLegacyStateMigrationResult> {
const env = params.env ?? process.env;
const detection = detectLegacyMatrixState({ cfg: params.cfg, env });
if (!detection) {
return { migrated: false, changes: [], warnings: [] };
}
if ("warning" in detection) {
params.log?.warn?.(`matrix: ${detection.warning}`);
return { migrated: false, changes: [], warnings: [detection.warning] };
}
const changes: string[] = [];
const warnings: string[] = [];
moveLegacyPath({
sourcePath: detection.legacyStoragePath,
targetPath: detection.targetStoragePath,
label: "sync store",
changes,
warnings,
});
moveLegacyPath({
sourcePath: detection.legacyCryptoPath,
targetPath: detection.targetCryptoPath,
label: "crypto store",
changes,
warnings,
});
if (changes.length > 0) {
const details = [
...changes.map((entry) => `- ${entry}`),
...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []),
"- No user action required.",
];
params.log?.info?.(
`matrix: plugin upgraded in place for account "${detection.accountId}".\n${details.join("\n")}`,
);
}
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: changes.length > 0,
changes,
warnings,
};
}

View File

@@ -0,0 +1,10 @@
// Matrix plugin module implements matrix migration behavior.
export { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js";
export { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
export {
hasActionableMatrixMigration,
hasPendingMatrixMigration,
resolveMatrixMigrationStatus,
type MatrixMigrationStatus,
} from "./migration-snapshot.js";
export { maybeCreateMatrixMigrationSnapshot } from "./migration-snapshot-backup.js";

View File

@@ -0,0 +1,176 @@
// Matrix helper module supports account config behavior.
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import {
listConfiguredAccountIds,
resolveMergedAccountConfig,
resolveNormalizedAccountEntry,
} from "openclaw/plugin-sdk/account-resolution-runtime";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
type MatrixRoomEntries = Record<string, NonNullable<MatrixConfig["groups"]>[string]>;
export function resolveMatrixBaseConfig(cfg: CoreConfig): MatrixConfig {
return cfg.channels?.matrix ?? {};
}
function resolveMatrixAccountsMap(cfg: CoreConfig): Readonly<Record<string, MatrixAccountConfig>> {
const accounts = resolveMatrixBaseConfig(cfg).accounts;
if (!accounts || typeof accounts !== "object") {
return {};
}
return accounts;
}
function selectInheritedMatrixRoomEntries(params: {
entries: MatrixRoomEntries | undefined;
accountId: string;
}): MatrixRoomEntries | undefined {
const entries = params.entries;
if (!entries) {
return undefined;
}
const selected = Object.fromEntries(
Object.entries(entries).filter(([, value]) => {
const scopedAccount =
typeof value?.account === "string" ? normalizeAccountId(value.account) : undefined;
return scopedAccount === undefined || scopedAccount === params.accountId;
}),
) as MatrixRoomEntries;
return Object.keys(selected).length > 0 ? selected : undefined;
}
function mergeMatrixRoomEntries(
inherited: MatrixRoomEntries | undefined,
accountEntries: MatrixRoomEntries | undefined,
hasAccountOverride: boolean,
): MatrixRoomEntries | undefined {
if (!inherited && !accountEntries) {
return undefined;
}
if (hasAccountOverride && Object.keys(accountEntries ?? {}).length === 0) {
return undefined;
}
const merged: MatrixRoomEntries = {
...inherited,
};
for (const [key, value] of Object.entries(accountEntries ?? {})) {
const inheritedValue = merged[key];
merged[key] =
inheritedValue && value
? {
...inheritedValue,
...value,
}
: (value ?? inheritedValue);
}
return Object.keys(merged).length > 0 ? merged : undefined;
}
export function listNormalizedMatrixAccountIds(cfg: CoreConfig): string[] {
return listConfiguredAccountIds({
accounts: resolveMatrixAccountsMap(cfg),
normalizeAccountId,
});
}
export function findMatrixAccountConfig(
cfg: CoreConfig,
accountId: string,
): MatrixAccountConfig | undefined {
return resolveNormalizedAccountEntry(
resolveMatrixAccountsMap(cfg),
accountId,
normalizeAccountId,
);
}
export function hasExplicitMatrixAccountConfig(cfg: CoreConfig, accountId: string): boolean {
const normalized = normalizeAccountId(accountId);
if (findMatrixAccountConfig(cfg, normalized)) {
return true;
}
if (normalized !== DEFAULT_ACCOUNT_ID) {
return false;
}
const matrix = resolveMatrixBaseConfig(cfg);
return (
typeof matrix.enabled === "boolean" ||
typeof matrix.name === "string" ||
typeof matrix.homeserver === "string" ||
typeof matrix.userId === "string" ||
hasConfiguredSecretInput(matrix.accessToken) ||
hasConfiguredSecretInput(matrix.password) ||
typeof matrix.deviceId === "string" ||
typeof matrix.deviceName === "string" ||
typeof matrix.avatarUrl === "string"
);
}
export function resolveMatrixAccountConfig(params: {
cfg: CoreConfig;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): MatrixConfig {
const accountId = normalizeAccountId(params.accountId);
const base = resolveMatrixBaseConfig(params.cfg);
const merged = resolveMergedAccountConfig<MatrixConfig>({
channelConfig: base,
accounts: params.cfg.channels?.matrix?.accounts as
| Record<string, Partial<MatrixConfig>>
| undefined,
accountId,
normalizeAccountId,
nestedObjectKeys: ["dm", "actions", "execApprovals", "botLoopProtection"],
});
const accountConfig = findMatrixAccountConfig(params.cfg, accountId);
const groups = mergeMatrixRoomEntries(
selectInheritedMatrixRoomEntries({
entries: base.groups,
accountId,
}),
accountConfig?.groups,
Boolean(accountConfig && Object.hasOwn(accountConfig, "groups")),
);
const rooms = mergeMatrixRoomEntries(
selectInheritedMatrixRoomEntries({
entries: base.rooms,
accountId,
}),
accountConfig?.rooms,
Boolean(accountConfig && Object.hasOwn(accountConfig, "rooms")),
);
// Room maps need custom scoping, so keep the generic merge for all other fields.
const { groups: _ignoredGroups, rooms: _ignoredRooms, ...rest } = merged;
return {
...rest,
...(groups ? { groups } : {}),
...(rooms ? { rooms } : {}),
};
}
export function resolveMatrixAccountAllowlistConfig(params: {
cfg: CoreConfig;
accountId?: string | null;
}): {
dmAllowFrom?: NonNullable<MatrixConfig["dm"]>["allowFrom"];
groupAllowFrom?: MatrixConfig["groupAllowFrom"];
} {
const accountId = normalizeAccountId(params.accountId);
const base = resolveMatrixBaseConfig(params.cfg);
const accountConfig = findMatrixAccountConfig(params.cfg, accountId);
const accountDm = accountConfig?.dm;
let dmAllowFrom = base.dm?.allowFrom;
if (accountDm && Object.hasOwn(accountDm, "allowFrom")) {
dmAllowFrom = accountDm.allowFrom;
}
let groupAllowFrom = base.groupAllowFrom;
if (accountConfig && Object.hasOwn(accountConfig, "groupAllowFrom")) {
groupAllowFrom = accountConfig.groupAllowFrom;
}
return { dmAllowFrom, groupAllowFrom };
}

View File

@@ -0,0 +1,28 @@
// Matrix tests cover accounts.readiness plugin behavior.
import { describe, expect, it } from "vitest";
import { installMatrixTestRuntime } from "../test-runtime.js";
import type { CoreConfig } from "../types.js";
import { resolveMatrixAccount } from "./accounts.js";
describe("resolveMatrixAccount readiness", () => {
it("does not treat inherited base auth as configured for named accounts", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "base-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
},
},
},
},
};
installMatrixTestRuntime({ cfg });
expect(resolveMatrixAccount({ cfg, accountId: "default" }).configured).toBe(true);
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).configured).toBe(false);
});
});

View File

@@ -0,0 +1,790 @@
// Matrix tests cover accounts plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getMatrixScopedEnvVarNames } from "../env-vars.js";
import type { CoreConfig } from "../types.js";
import {
listMatrixAccountIds,
resolveConfiguredMatrixBotUserIds,
resolveDefaultMatrixAccountId,
resolveMatrixAccount,
} from "./accounts.js";
import type { MatrixStoredCredentials } from "./credentials-read.js";
const loadMatrixCredentialsMock = vi.hoisted(() =>
vi.fn<(env?: NodeJS.ProcessEnv, accountId?: string | null) => MatrixStoredCredentials | null>(
() => null,
),
);
vi.mock("./credentials-read.js", () => ({
loadMatrixCredentials: (env?: NodeJS.ProcessEnv, accountId?: string | null) =>
loadMatrixCredentialsMock(env, accountId),
credentialsMatchConfig: () => false,
}));
const envKeys = [
"MATRIX_HOMESERVER",
"MATRIX_USER_ID",
"MATRIX_ACCESS_TOKEN",
"MATRIX_PASSWORD",
"MATRIX_DEVICE_NAME",
"MATRIX_DEFAULT_HOMESERVER",
"MATRIX_DEFAULT_ACCESS_TOKEN",
getMatrixScopedEnvVarNames("team-ops").homeserver,
getMatrixScopedEnvVarNames("team-ops").accessToken,
];
type MatrixRoomScopeKey = "groups" | "rooms";
function createMatrixAccountConfig(accessToken: string) {
return {
homeserver: "https://matrix.example.org",
accessToken,
};
}
function createMatrixScopedEntriesConfig(scopeKey: MatrixRoomScopeKey): CoreConfig {
return {
channels: {
matrix: {
[scopeKey]: {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!axis-room:example.org": {
enabled: true,
account: "axis",
},
"!unassigned-room:example.org": {
enabled: true,
},
},
accounts: {
default: createMatrixAccountConfig("default-token"),
axis: createMatrixAccountConfig("axis-token"),
},
},
},
} as unknown as CoreConfig;
}
function createMatrixTopLevelDefaultScopedEntriesConfig(scopeKey: MatrixRoomScopeKey): CoreConfig {
return {
channels: {
matrix: {
...createMatrixAccountConfig("default-token"),
[scopeKey]: {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!ops-room:example.org": {
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
enabled: true,
},
},
accounts: {
ops: createMatrixAccountConfig("ops-token"),
},
},
},
} as unknown as CoreConfig;
}
function expectMatrixScopedEntries(
cfg: CoreConfig,
scopeKey: MatrixRoomScopeKey,
accountId: string,
expected: Record<string, { enabled: true; account?: string }>,
): void {
expect(resolveMatrixAccount({ cfg, accountId }).config[scopeKey]).toEqual(expected);
}
function expectMultiAccountMatrixScopedEntries(
cfg: CoreConfig,
scopeKey: MatrixRoomScopeKey,
): void {
expectMatrixScopedEntries(cfg, scopeKey, "default", {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!unassigned-room:example.org": {
enabled: true,
},
});
expectMatrixScopedEntries(cfg, scopeKey, "axis", {
"!axis-room:example.org": {
enabled: true,
account: "axis",
},
"!unassigned-room:example.org": {
enabled: true,
},
});
}
function expectTopLevelDefaultMatrixScopedEntries(
cfg: CoreConfig,
scopeKey: MatrixRoomScopeKey,
): void {
expectMatrixScopedEntries(cfg, scopeKey, "default", {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!shared-room:example.org": {
enabled: true,
},
});
expectMatrixScopedEntries(cfg, scopeKey, "ops", {
"!ops-room:example.org": {
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
enabled: true,
},
});
}
describe("resolveMatrixAccount", () => {
let prevEnv: Record<string, string | undefined> = {};
beforeEach(() => {
loadMatrixCredentialsMock.mockReset().mockReturnValue(null);
prevEnv = {};
for (const key of envKeys) {
prevEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of envKeys) {
const value = prevEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it("treats access-token-only config as configured", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "tok-access",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
it("treats SecretRef access-token config as configured", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: { source: "file", provider: "matrix-file", id: "value" },
},
},
secrets: {
providers: {
"matrix-file": {
source: "file",
path: "/tmp/matrix-token",
},
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
it("treats accounts.default SecretRef access-token config as configured", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.example.org",
accessToken: { source: "file", provider: "matrix-file", id: "value" },
},
},
},
},
secrets: {
providers: {
"matrix-file": {
source: "file",
path: "/tmp/matrix-token",
},
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
it("treats accounts.default SecretRef password config as configured", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: { source: "file", provider: "matrix-file", id: "value" },
},
},
},
},
secrets: {
providers: {
"matrix-file": {
source: "file",
path: "/tmp/matrix-password",
},
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
it("requires userId + password when no access token is set", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(false);
});
it("marks password auth as configured when userId is present", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
it("merges account bot loop protection over top-level defaults field-by-field", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "top-token",
botLoopProtection: {
maxEventsPerWindow: 8,
windowSeconds: 120,
cooldownSeconds: 240,
},
accounts: {
ops: {
accessToken: "ops-token",
botLoopProtection: {
maxEventsPerWindow: 3,
},
},
},
},
},
};
const account = resolveMatrixAccount({ cfg, accountId: "ops" });
expect(account.config.botLoopProtection).toEqual({
maxEventsPerWindow: 3,
windowSeconds: 120,
cooldownSeconds: 240,
});
});
it("normalizes and de-duplicates configured account ids", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
defaultAccount: "Main Bot",
accounts: {
"Main Bot": {
homeserver: "https://matrix.example.org",
accessToken: "main-token",
},
"main-bot": {
homeserver: "https://matrix.example.org",
accessToken: "duplicate-token",
},
OPS: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
};
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 configured defaultAccount when accountId is omitted", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
defaultAccount: "ops",
homeserver: "https://matrix.example.org",
accessToken: "default-token",
accounts: {
ops: {
homeserver: "https://ops.example.org",
accessToken: "ops-token",
},
},
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.accountId).toBe("ops");
expect(account.homeserver).toBe("https://ops.example.org");
expect(account.configured).toBe(true);
});
it("includes env-backed named accounts in plugin account enumeration", () => {
const keys = getMatrixScopedEnvVarNames("team-ops");
process.env[keys.homeserver] = "https://matrix.example.org";
process.env[keys.accessToken] = "ops-token";
const cfg: CoreConfig = {
channels: {
matrix: {},
},
};
expect(listMatrixAccountIds(cfg)).toEqual(["team-ops"]);
expect(resolveDefaultMatrixAccountId(cfg)).toBe("team-ops");
});
it("includes default accounts backed only by global env vars in plugin account enumeration", () => {
process.env.MATRIX_HOMESERVER = "https://matrix.example.org";
process.env.MATRIX_ACCESS_TOKEN = "default-token";
const cfg: CoreConfig = {};
expect(listMatrixAccountIds(cfg)).toEqual(["default"]);
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
});
it("treats mixed default and named env-backed accounts as multi-account", () => {
const keys = getMatrixScopedEnvVarNames("team-ops");
process.env.MATRIX_HOMESERVER = "https://matrix.example.org";
process.env.MATRIX_ACCESS_TOKEN = "default-token";
process.env[keys.homeserver] = "https://matrix.example.org";
process.env[keys.accessToken] = "ops-token";
const cfg: CoreConfig = {
channels: {
matrix: {},
},
};
expect(listMatrixAccountIds(cfg)).toEqual(["default", "team-ops"]);
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
});
it("includes a top-level configured default account alongside named accounts", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "default-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
};
expect(listMatrixAccountIds(cfg)).toEqual(["default", "ops"]);
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
});
it("does not materialize a default account from shared top-level defaults alone", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
name: "Shared Defaults",
enabled: true,
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
};
expect(listMatrixAccountIds(cfg)).toEqual(["ops"]);
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");
});
it("collects other configured Matrix account user ids for bot detection", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
userId: "@main:example.org",
homeserver: "https://matrix.example.org",
accessToken: "main-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
},
alerts: {
homeserver: "https://matrix.example.org",
userId: "@alerts:example.org",
accessToken: "alerts-token",
},
},
},
},
};
expect(
Array.from(resolveConfiguredMatrixBotUserIds({ cfg, accountId: "ops" })).toSorted(),
).toEqual(["@alerts:example.org", "@main:example.org"]);
});
it("honors injected env when detecting configured bot accounts", () => {
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_USER_ID: "@main:example.org",
MATRIX_ACCESS_TOKEN: "main-token",
MATRIX_ALERTS_HOMESERVER: "https://matrix.example.org",
MATRIX_ALERTS_USER_ID: "@alerts:example.org",
MATRIX_ALERTS_ACCESS_TOKEN: "alerts-token",
} as NodeJS.ProcessEnv;
const cfg: CoreConfig = {
channels: {
matrix: {},
},
};
expect(
Array.from(resolveConfiguredMatrixBotUserIds({ cfg, accountId: "ops", env })).toSorted(),
).toEqual(["@alerts:example.org", "@main:example.org"]);
});
it("falls back to stored credentials when an access-token-only account omits userId", () => {
loadMatrixCredentialsMock.mockImplementation(
(env?: NodeJS.ProcessEnv, accountId?: string | null) =>
accountId === "ops"
? {
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
createdAt: "2026-03-19T00:00:00.000Z",
}
: null,
);
const cfg: CoreConfig = {
channels: {
matrix: {
userId: "@main:example.org",
homeserver: "https://matrix.example.org",
accessToken: "main-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
};
expect(Array.from(resolveConfiguredMatrixBotUserIds({ cfg, accountId: "default" }))).toEqual([
"@ops:example.org",
]);
});
it("preserves shared nested dm and actions config when an account overrides one field", () => {
const account = resolveMatrixAccount({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "main-token",
dm: {
enabled: true,
policy: "pairing",
},
actions: {
reactions: true,
messages: true,
},
accounts: {
ops: {
accessToken: "ops-token",
dm: {
allowFrom: ["@ops:example.org"],
},
actions: {
messages: false,
},
},
},
},
},
},
accountId: "ops",
});
expect(account.config.dm).toEqual({
enabled: true,
policy: "pairing",
allowFrom: ["@ops:example.org"],
});
expect(account.config.actions).toEqual({
reactions: true,
messages: false,
});
});
it("filters channel-level groups by room account in multi-account setups", () => {
expectMultiAccountMatrixScopedEntries(createMatrixScopedEntriesConfig("groups"), "groups");
});
it("filters channel-level groups when the default account is configured at the top level", () => {
expectTopLevelDefaultMatrixScopedEntries(
createMatrixTopLevelDefaultScopedEntriesConfig("groups"),
"groups",
);
});
it("filters legacy channel-level rooms by room account in multi-account setups", () => {
expectMultiAccountMatrixScopedEntries(createMatrixScopedEntriesConfig("rooms"), "rooms");
});
it("filters legacy channel-level rooms when the default account is configured at the top level", () => {
expectTopLevelDefaultMatrixScopedEntries(
createMatrixTopLevelDefaultScopedEntriesConfig("rooms"),
"rooms",
);
});
it("honors injected env when scoping room entries in multi-account setups", () => {
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_ACCESS_TOKEN: "default-token",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
const cfg = {
channels: {
matrix: {
groups: {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!ops-room:example.org": {
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
enabled: true,
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "ops", env }).config.groups).toEqual({
"!ops-room:example.org": {
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
enabled: true,
},
});
});
it("keeps scoped groups bound to their account even when only one account is active", () => {
const cfg = {
channels: {
matrix: {
groups: {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!shared-room:example.org": {
enabled: true,
},
},
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.groups).toEqual({
"!shared-room:example.org": {
enabled: true,
},
});
});
it("keeps scoped legacy rooms bound to their account even when only one account is active", () => {
const cfg = {
channels: {
matrix: {
rooms: {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!shared-room:example.org": {
enabled: true,
},
},
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.rooms).toEqual({
"!shared-room:example.org": {
enabled: true,
},
});
});
it("lets an account clear inherited groups with an explicit empty map", () => {
const cfg = {
channels: {
matrix: {
groups: {
"!shared-room:example.org": {
enabled: true,
},
},
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
groups: {},
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.groups).toBeUndefined();
});
it("lets an account clear inherited legacy rooms with an explicit empty map", () => {
const cfg = {
channels: {
matrix: {
rooms: {
"!shared-room:example.org": {
enabled: true,
},
},
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
rooms: {},
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.rooms).toBeUndefined();
});
});

View File

@@ -0,0 +1,195 @@
// Matrix plugin module implements accounts behavior.
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
resolveConfiguredMatrixAccountIds,
resolveMatrixDefaultOrOnlyAccountId,
} from "../account-selection.js";
import { resolveMatrixAccountStringValues } from "../auth-precedence.js";
import type { CoreConfig, MatrixConfig } from "../types.js";
import {
findMatrixAccountConfig,
resolveMatrixAccountConfig,
resolveMatrixBaseConfig,
} from "./account-config.js";
import { resolveGlobalMatrixEnvConfig, resolveScopedMatrixEnvConfig } from "./client/env-auth.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials-read.js";
export type ResolvedMatrixAccount = {
accountId: string;
enabled: boolean;
name?: string;
configured: boolean;
homeserver?: string;
userId?: string;
config: MatrixConfig;
};
function clean(value: unknown): string {
return normalizeOptionalString(value) ?? "";
}
function resolveMatrixAccountAuthView(params: {
cfg: CoreConfig;
accountId: string;
env: NodeJS.ProcessEnv;
}): {
homeserver: string;
userId: string;
accessToken?: string;
password?: string;
} {
const normalizedAccountId = normalizeAccountId(params.accountId);
const matrix = resolveMatrixBaseConfig(params.cfg);
const account = findMatrixAccountConfig(params.cfg, normalizedAccountId) ?? {};
const resolvedStrings = resolveMatrixAccountStringValues({
accountId: normalizedAccountId,
account: {
homeserver: clean(account.homeserver),
userId: clean(account.userId),
accessToken: typeof account.accessToken === "string" ? clean(account.accessToken) : "",
password: typeof account.password === "string" ? clean(account.password) : "",
deviceId: clean(account.deviceId),
deviceName: clean(account.deviceName),
},
scopedEnv: resolveScopedMatrixEnvConfig(normalizedAccountId, params.env),
channel: {
homeserver: clean(matrix.homeserver),
userId: clean(matrix.userId),
accessToken: typeof matrix.accessToken === "string" ? clean(matrix.accessToken) : "",
password: typeof matrix.password === "string" ? clean(matrix.password) : "",
deviceId: clean(matrix.deviceId),
deviceName: clean(matrix.deviceName),
},
globalEnv: resolveGlobalMatrixEnvConfig(params.env),
});
return {
homeserver: resolvedStrings.homeserver,
userId: resolvedStrings.userId,
accessToken: resolvedStrings.accessToken || undefined,
password: resolvedStrings.password || undefined,
};
}
function resolveMatrixAccountUserId(params: {
cfg: CoreConfig;
accountId: string;
env?: NodeJS.ProcessEnv;
}): string | null {
const env = params.env ?? process.env;
const authView = resolveMatrixAccountAuthView({
cfg: params.cfg,
accountId: params.accountId,
env,
});
const configuredUserId = authView.userId.trim();
if (configuredUserId) {
return configuredUserId;
}
const stored = loadMatrixCredentials(env, params.accountId);
if (!stored) {
return null;
}
if (authView.homeserver && stored.homeserver !== authView.homeserver) {
return null;
}
if (authView.accessToken && stored.accessToken !== authView.accessToken) {
return null;
}
return stored.userId.trim() || null;
}
export function listMatrixAccountIds(cfg: CoreConfig): string[] {
const ids = resolveConfiguredMatrixAccountIds(cfg, process.env);
return ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID];
}
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg));
}
export function resolveConfiguredMatrixBotUserIds(params: {
cfg: CoreConfig;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): Set<string> {
const env = params.env ?? process.env;
const currentAccountId = normalizeAccountId(params.accountId);
const accountIds = new Set(resolveConfiguredMatrixAccountIds(params.cfg, env));
if (resolveMatrixAccount({ cfg: params.cfg, accountId: DEFAULT_ACCOUNT_ID, env }).configured) {
accountIds.add(DEFAULT_ACCOUNT_ID);
}
const ids = new Set<string>();
for (const accountId of accountIds) {
if (normalizeAccountId(accountId) === currentAccountId) {
continue;
}
if (!resolveMatrixAccount({ cfg: params.cfg, accountId, env }).configured) {
continue;
}
const userId = resolveMatrixAccountUserId({
cfg: params.cfg,
accountId,
env,
});
if (userId) {
ids.add(userId);
}
}
return ids;
}
export function resolveMatrixAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): ResolvedMatrixAccount {
const env = params.env ?? process.env;
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultMatrixAccountId(params.cfg),
);
const matrixBase = resolveMatrixBaseConfig(params.cfg);
const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId, env });
const explicitAuthConfig =
accountId === DEFAULT_ACCOUNT_ID
? base
: (findMatrixAccountConfig(params.cfg, accountId) ?? {});
const enabled = base.enabled !== false && matrixBase.enabled !== false;
const authView = resolveMatrixAccountAuthView({
cfg: params.cfg,
accountId,
env,
});
const hasHomeserver = Boolean(authView.homeserver);
const hasUserId = Boolean(authView.userId);
const hasAccessToken =
Boolean(authView.accessToken) || hasConfiguredSecretInput(explicitAuthConfig.accessToken);
const hasPassword = Boolean(authView.password);
const hasPasswordAuth =
hasUserId && (hasPassword || hasConfiguredSecretInput(explicitAuthConfig.password));
const stored = loadMatrixCredentials(env, accountId);
const hasStored =
stored && authView.homeserver
? credentialsMatchConfig(stored, {
homeserver: authView.homeserver,
userId: authView.userId || "",
})
: false;
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || hasStored);
return {
accountId,
enabled,
name: normalizeOptionalString(base.name),
configured,
homeserver: authView.homeserver || undefined,
userId: authView.userId || undefined,
config: base,
};
}
export { resolveMatrixAccountConfig } from "./account-config.js";

View File

@@ -0,0 +1,38 @@
// Matrix plugin module implements actions behavior.
export type {
MatrixActionClientOpts,
MatrixMessageSummary,
MatrixReactionSummary,
} from "./actions/types.js";
export {
sendMatrixMessage,
editMatrixMessage,
deleteMatrixMessage,
readMatrixMessages,
} from "./actions/messages.js";
export { voteMatrixPoll } from "./actions/polls.js";
export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js";
export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js";
export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js";
export { updateMatrixOwnProfile } from "./actions/profile.js";
export {
bootstrapMatrixVerification,
acceptMatrixVerification,
cancelMatrixVerification,
confirmMatrixVerificationReciprocateQr,
confirmMatrixVerificationSas,
generateMatrixVerificationQr,
getMatrixEncryptionStatus,
getMatrixRoomKeyBackupStatus,
getMatrixVerificationStatus,
getMatrixVerificationSas,
listMatrixVerifications,
mismatchMatrixVerificationSas,
requestMatrixVerification,
resetMatrixRoomKeyBackup,
restoreMatrixRoomKeyBackup,
scanMatrixVerificationQr,
startMatrixVerification,
verifyMatrixRecoveryKey,
} from "./actions/verification.js";
export { reactMatrixMessage } from "./send.js";

View File

@@ -0,0 +1,247 @@
// Matrix tests cover client plugin behavior.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
createMockMatrixClient,
expectExplicitMatrixClientConfig,
expectOneOffSharedMatrixClient,
matrixClientResolverMocks,
primeMatrixClientResolverMocks,
} from "../client-resolver.test-helpers.js";
const resolveMatrixRoomIdMock = vi.fn();
const {
loadConfigMock,
getMatrixRuntimeMock,
getActiveMatrixClientMock,
acquireSharedMatrixClientMock,
releaseSharedClientInstanceMock,
isBunRuntimeMock,
resolveMatrixAuthContextMock,
} = matrixClientResolverMocks;
const TEST_CFG = {};
vi.mock("../../runtime.js", () => ({
getMatrixRuntime: () => getMatrixRuntimeMock(),
}));
vi.mock("../active-client.js", () => ({
getActiveMatrixClient: getActiveMatrixClientMock,
}));
vi.mock("../client.js", () => ({
acquireSharedMatrixClient: acquireSharedMatrixClientMock,
isBunRuntime: () => isBunRuntimeMock(),
resolveMatrixAuthContext: resolveMatrixAuthContextMock,
}));
vi.mock("../client/shared.js", () => ({
releaseSharedClientInstance: (...args: unknown[]) => releaseSharedClientInstanceMock(...args),
}));
vi.mock("../send.js", () => ({
resolveMatrixRoomId: (...args: unknown[]) => resolveMatrixRoomIdMock(...args),
}));
let withResolvedActionClient: typeof import("./client.js").withResolvedActionClient;
let withResolvedRoomAction: typeof import("./client.js").withResolvedRoomAction;
let withStartedActionClient: typeof import("./client.js").withStartedActionClient;
describe("action client helpers", () => {
beforeAll(async () => {
({ withResolvedActionClient, withResolvedRoomAction, withStartedActionClient } =
await import("./client.js"));
});
beforeEach(() => {
primeMatrixClientResolverMocks();
resolveMatrixRoomIdMock
.mockReset()
.mockImplementation(async (clientForTest, roomId: string) => roomId);
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("stops one-off shared clients when no active monitor client is registered", async () => {
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
const result = await withResolvedActionClient(
{ cfg: TEST_CFG, accountId: "default" },
async () => "ok",
);
await expectOneOffSharedMatrixClient();
expect(result).toBe("ok");
});
it("skips one-off room preparation when readiness is disabled", async () => {
await withResolvedActionClient(
{ cfg: TEST_CFG, accountId: "default", readiness: "none" },
async () => {},
);
const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value;
expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled();
expect(sharedClient.start).not.toHaveBeenCalled();
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
});
it("starts one-off clients when started readiness is required", async () => {
await withStartedActionClient({ cfg: TEST_CFG, accountId: "default" }, async () => {});
const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value;
expect(sharedClient.start).toHaveBeenCalledTimes(1);
expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled();
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "persist");
});
it("reuses active monitor client when available", async () => {
const activeClient = createMockMatrixClient();
getActiveMatrixClientMock.mockReturnValue(activeClient);
const result = await withResolvedActionClient(
{ cfg: TEST_CFG, accountId: "default" },
async (client) => {
expect(client).toBe(activeClient);
return "ok";
},
);
expect(result).toBe("ok");
expect(acquireSharedMatrixClientMock).not.toHaveBeenCalled();
expect(activeClient["stop"]).not.toHaveBeenCalled();
});
it("starts active clients when started readiness is required", async () => {
const activeClient = createMockMatrixClient();
getActiveMatrixClientMock.mockReturnValue(activeClient);
await withStartedActionClient({ cfg: TEST_CFG, accountId: "default" }, async (client) => {
expect(client).toBe(activeClient);
});
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 () => {
loadConfigMock.mockReturnValue({
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
},
},
},
},
});
resolveMatrixAuthContextMock.mockReturnValue({
cfg: loadConfigMock(),
env: process.env,
accountId: "ops",
resolved: {
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
deviceId: "OPSDEVICE",
encryption: true,
},
});
await withResolvedActionClient({ cfg: loadConfigMock() as never }, async () => {});
await expectOneOffSharedMatrixClient({
cfg: loadConfigMock(),
accountId: "ops",
});
});
it("uses explicit cfg instead of loading runtime config", async () => {
const explicitCfg = {
channels: {
matrix: {
defaultAccount: "ops",
},
},
};
await withResolvedActionClient({ cfg: explicitCfg, accountId: "ops" }, async () => {});
expectExplicitMatrixClientConfig({
cfg: explicitCfg,
accountId: "ops",
});
});
it("stops shared action clients after wrapped calls succeed", async () => {
const sharedClient = createMockMatrixClient();
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
const result = await withResolvedActionClient(
{ cfg: TEST_CFG, accountId: "default" },
async (client) => {
expect(client).toBe(sharedClient);
return "ok";
},
);
expect(result).toBe("ok");
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
});
it("can discard read-only shared action clients without persisting crypto state", async () => {
const sharedClient = createMockMatrixClient();
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
const result = await withResolvedActionClient(
{ cfg: TEST_CFG, accountId: "default" },
async (client) => {
expect(client).toBe(sharedClient);
return "ok";
},
"discard",
);
expect(result).toBe("ok");
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "discard");
});
it("stops shared action clients when the wrapped call throws", async () => {
const sharedClient = createMockMatrixClient();
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
await expect(
withResolvedActionClient({ cfg: TEST_CFG, accountId: "default" }, async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
});
it("resolves room ids before running wrapped room actions", async () => {
const sharedClient = createMockMatrixClient();
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
resolveMatrixRoomIdMock.mockResolvedValue("!room:example.org");
const result = await withResolvedRoomAction(
"room:#ops:example.org",
{ cfg: TEST_CFG, accountId: "default" },
async (client, resolvedRoom) => {
expect(client).toBe(sharedClient);
return resolvedRoom;
},
);
expect(resolveMatrixRoomIdMock).toHaveBeenCalledWith(sharedClient, "room:#ops:example.org");
expect(result).toBe("!room:example.org");
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
});
});

View File

@@ -0,0 +1,32 @@
// Matrix plugin module implements client behavior.
import { withResolvedRuntimeMatrixClient } from "../client-bootstrap.js";
import { resolveMatrixRoomId } from "../send.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
type MatrixActionClientStopMode = "stop" | "persist" | "discard";
export async function withResolvedActionClient<T>(
opts: MatrixActionClientOpts,
run: (client: MatrixActionClient["client"]) => Promise<T>,
mode: MatrixActionClientStopMode = "stop",
): Promise<T> {
return await withResolvedRuntimeMatrixClient(opts, run, mode);
}
export async function withStartedActionClient<T>(
opts: MatrixActionClientOpts,
run: (client: MatrixActionClient["client"]) => Promise<T>,
): Promise<T> {
return await withResolvedActionClient({ ...opts, readiness: "started" }, run, "persist");
}
export async function withResolvedRoomAction<T>(
roomId: string,
opts: MatrixActionClientOpts,
run: (client: MatrixActionClient["client"], resolvedRoom: string) => Promise<T>,
): Promise<T> {
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
return await run(client, resolvedRoom);
});
}

View File

@@ -0,0 +1,221 @@
// Matrix tests cover devices plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
const withResolvedActionClientMock = vi.fn();
const withStartedActionClientMock = vi.fn();
vi.mock("./client.js", () => ({
withResolvedActionClient: (...args: unknown[]) => withResolvedActionClientMock(...args),
withStartedActionClient: (...args: unknown[]) => withStartedActionClientMock(...args),
}));
const { getMatrixDeviceHealth, listMatrixOwnDevices, pruneMatrixStaleGatewayDevices } =
await import("./devices.js");
function expectResolvedActionClientCall(): void {
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(1);
const call = withResolvedActionClientMock.mock.calls[0];
if (!call) {
throw new Error("Expected resolved action client call");
}
expect(call[0]).toEqual({ accountId: "poe" });
expect(call[1]).toBeTypeOf("function");
expect(withStartedActionClientMock).not.toHaveBeenCalled();
}
describe("matrix device actions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("lists own devices without starting a sync client", async () => {
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
listOwnDevices: vi.fn(async () => [
{
deviceId: "A7hWrQ70ea",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
]),
});
});
const result = await listMatrixOwnDevices({ accountId: "poe" });
expectResolvedActionClientCall();
expect(result).toEqual([
{
deviceId: "A7hWrQ70ea",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
]);
});
it("computes device health without starting a sync client", async () => {
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
listOwnDevices: vi.fn(async () => [
{
deviceId: "du314Zpw3A",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
{
deviceId: "old123",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
]),
});
});
const result = await getMatrixDeviceHealth({ accountId: "poe" });
expect(result).toEqual({
currentDeviceId: "du314Zpw3A",
staleOpenClawDevices: [
{
deviceId: "old123",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
],
currentOpenClawDevices: [
{
deviceId: "du314Zpw3A",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
],
});
expectResolvedActionClientCall();
});
it("prunes stale OpenClaw-managed devices but preserves the current device", async () => {
const deleteOwnDevices = vi.fn(async () => ({
currentDeviceId: "du314Zpw3A",
deletedDeviceIds: ["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"],
remainingDevices: [
{
deviceId: "du314Zpw3A",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
],
}));
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
listOwnDevices: vi.fn(async () => [
{
deviceId: "du314Zpw3A",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
{
deviceId: "BritdXC6iL",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
{
deviceId: "G6NJU9cTgs",
displayName: "OpenClaw Debug",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
{
deviceId: "My3T0hkTE0",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
{
deviceId: "phone123",
displayName: "Element iPhone",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
]),
deleteOwnDevices,
});
});
const result = await pruneMatrixStaleGatewayDevices({ accountId: "poe" });
expect(deleteOwnDevices).toHaveBeenCalledWith(["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"]);
expect(result).toEqual({
before: [
{
deviceId: "du314Zpw3A",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
{
deviceId: "BritdXC6iL",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
{
deviceId: "G6NJU9cTgs",
displayName: "OpenClaw Debug",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
{
deviceId: "My3T0hkTE0",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
{
deviceId: "phone123",
displayName: "Element iPhone",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
],
staleGatewayDeviceIds: ["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"],
currentDeviceId: "du314Zpw3A",
deletedDeviceIds: ["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"],
remainingDevices: [
{
deviceId: "du314Zpw3A",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
],
});
expectResolvedActionClientCall();
});
});

View File

@@ -0,0 +1,35 @@
// Matrix plugin module implements devices behavior.
import { summarizeMatrixDeviceHealth } from "../device-health.js";
import { withResolvedActionClient } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js";
export async function listMatrixOwnDevices(opts: MatrixActionClientOpts = {}) {
return await withResolvedActionClient(opts, async (client) => await client.listOwnDevices());
}
export async function pruneMatrixStaleGatewayDevices(opts: MatrixActionClientOpts = {}) {
return await withResolvedActionClient(opts, async (client) => {
const devices = await client.listOwnDevices();
const health = summarizeMatrixDeviceHealth(devices);
const staleGatewayDeviceIds = health.staleOpenClawDevices.map((device) => device.deviceId);
const deleted =
staleGatewayDeviceIds.length > 0
? await client.deleteOwnDevices(staleGatewayDeviceIds)
: {
currentDeviceId: devices.find((device) => device.current)?.deviceId ?? null,
deletedDeviceIds: [] as string[],
remainingDevices: devices,
};
return {
before: devices,
staleGatewayDeviceIds,
...deleted,
};
});
}
export async function getMatrixDeviceHealth(opts: MatrixActionClientOpts = {}) {
return await withResolvedActionClient(opts, async (client) =>
summarizeMatrixDeviceHealth(await client.listOwnDevices()),
);
}

View File

@@ -0,0 +1,16 @@
// Matrix tests cover limits plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveMatrixActionLimit } from "./limits.js";
describe("resolveMatrixActionLimit", () => {
it("uses fallback for non-finite values", () => {
expect(resolveMatrixActionLimit(undefined, 20)).toBe(20);
expect(resolveMatrixActionLimit(Number.NaN, 20)).toBe(20);
});
it("normalizes finite numbers to positive integers", () => {
expect(resolveMatrixActionLimit(7.9, 20)).toBe(7);
expect(resolveMatrixActionLimit(0, 20)).toBe(1);
expect(resolveMatrixActionLimit(-3, 20)).toBe(1);
});
});

View File

@@ -0,0 +1,6 @@
// Matrix plugin module implements limits behavior.
import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime";
export function resolveMatrixActionLimit(raw: unknown, fallback: number): number {
return resolveIntegerOption(raw, fallback, { min: 1 });
}

View File

@@ -0,0 +1,642 @@
// Matrix tests cover messages plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { setMatrixRuntime } from "../../runtime.js";
import type { MatrixClient } from "../sdk.js";
import * as sendModule from "../send.js";
import { editMatrixMessage, readMatrixMessages } from "./messages.js";
const MATRIX_ACTION_TEST_CFG = {
channels: {
matrix: {},
},
};
function installMatrixActionTestRuntime(): void {
setMatrixRuntime({
config: {
current: () => ({}),
},
channel: {
text: {
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},
},
} as unknown as import("../../runtime-api.js").PluginRuntime);
}
function createPollResponseEvent(): Record<string, unknown> {
return {
event_id: "$vote",
sender: "@bob:example.org",
type: "m.poll.response",
origin_server_ts: 20,
content: {
"m.poll.response": { answers: ["a1"] },
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
},
};
}
function createPollStartEvent(params?: {
answers?: Array<Record<string, unknown>>;
includeDisclosedKind?: boolean;
maxSelections?: number;
}): Record<string, unknown> {
return {
event_id: "$poll",
sender: "@alice:example.org",
type: "m.poll.start",
origin_server_ts: 1,
content: {
"m.poll.start": {
question: { "m.text": "Favorite fruit?" },
...(params?.includeDisclosedKind ? { kind: "m.poll.disclosed" } : {}),
...(params?.maxSelections !== undefined ? { max_selections: params.maxSelections } : {}),
answers: params?.answers ?? [{ id: "a1", "m.text": "Apple" }],
},
},
};
}
function createMessagesClient(params: {
chunk: Array<Record<string, unknown>>;
hydratedChunk?: Array<Record<string, unknown>>;
pollRoot?: Record<string, unknown>;
pollRelations?: Array<Record<string, unknown>>;
threadRelations?: Array<Record<string, unknown>>;
}) {
const doRequest = vi.fn(async () => ({
chunk: params.chunk,
start: "start-token",
end: "end-token",
}));
const hydrateEvents = vi.fn(
async (_roomId: string, _events: Array<Record<string, unknown>>) =>
(params.hydratedChunk ?? _events) as unknown,
);
const getEvent = vi.fn(async (_roomId: string, eventId: string) => {
if (params.pollRoot?.event_id === eventId) {
return params.pollRoot;
}
return null;
});
const getRelations = vi.fn(async (_roomId: string, _eventId: string, relType: string) => ({
events:
relType === "m.thread"
? (params.threadRelations ?? params.pollRelations ?? [])
: (params.pollRelations ?? []),
nextBatch: null,
prevBatch: null,
}));
return {
client: {
doRequest,
hydrateEvents,
getEvent,
getRelations,
stop: vi.fn(),
} as unknown as MatrixClient,
doRequest,
hydrateEvents,
getEvent,
getRelations,
};
}
function createEditClient(originalContent: Record<string, unknown>) {
const sendMessage = vi.fn().mockResolvedValue("evt-edit");
const client = {
getEvent: vi.fn().mockResolvedValue({ content: originalContent }),
getJoinedRoomMembers: vi.fn().mockResolvedValue([]),
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
sendMessage,
prepareForOneOff: vi.fn(async () => undefined),
start: vi.fn(async () => undefined),
stop: vi.fn(() => undefined),
stopAndPersist: vi.fn(async () => undefined),
} as unknown as MatrixClient;
return { client, sendMessage };
}
function expectRecordFields(value: unknown, expected: Record<string, unknown>) {
if (!value || typeof value !== "object") {
throw new Error("Expected record");
}
const actual = value as Record<string, unknown>;
for (const [key, expectedValue] of Object.entries(expected)) {
expect(actual[key]).toEqual(expectedValue);
}
return actual;
}
function mockCallArg(
mockFn: { mock: { calls: unknown[][] } },
callIndex: number,
argIndex: number,
) {
const call = mockFn.mock.calls.at(callIndex);
if (!call) {
throw new Error(`Expected mock call ${callIndex} to exist`);
}
if (!(argIndex in call)) {
throw new Error(`Expected mock call ${callIndex} argument ${argIndex} to exist`);
}
return call[argIndex];
}
describe("matrix message actions", () => {
it("forwards timeoutMs to the shared Matrix edit helper", async () => {
const editSpy = vi.spyOn(sendModule, "editMessageMatrix").mockResolvedValue("evt-edit");
try {
const cfg = {} as never;
const result = await editMatrixMessage("!room:example.org", "$original", "hello", {
cfg,
timeoutMs: 12_345,
});
expect(result).toEqual({ eventId: "evt-edit" });
expect(editSpy).toHaveBeenCalledWith("!room:example.org", "$original", "hello", {
cfg,
accountId: undefined,
client: undefined,
timeoutMs: 12_345,
});
} finally {
editSpy.mockRestore();
}
});
it("routes edits through the shared Matrix edit helper so mentions are preserved", async () => {
installMatrixActionTestRuntime();
const { client, sendMessage } = createEditClient({
body: "hello @alice:example.org",
"m.mentions": { user_ids: ["@alice:example.org"] },
});
const result = await editMatrixMessage(
"!room:example.org",
"$original",
"hello @alice:example.org and @bob:example.org",
{ cfg: MATRIX_ACTION_TEST_CFG, client },
);
expect(result).toEqual({ eventId: "evt-edit" });
expect(mockCallArg(sendMessage, 0, 0)).toBe("!room:example.org");
const content = expectRecordFields(mockCallArg(sendMessage, 0, 1), {
"m.mentions": { user_ids: ["@bob:example.org"] },
});
expectRecordFields(content["m.new_content"], {
"m.mentions": { user_ids: ["@alice:example.org", "@bob:example.org"] },
});
});
it("does not re-notify legacy mentions when action edits target pre-m.mentions messages", async () => {
installMatrixActionTestRuntime();
const { client, sendMessage } = createEditClient({
body: "hello @alice:example.org",
});
const result = await editMatrixMessage(
"!room:example.org",
"$original",
"hello again @alice:example.org",
{ cfg: MATRIX_ACTION_TEST_CFG, client },
);
expect(result).toEqual({ eventId: "evt-edit" });
expect(mockCallArg(sendMessage, 0, 0)).toBe("!room:example.org");
const content = expectRecordFields(mockCallArg(sendMessage, 0, 1), {
"m.mentions": {},
});
expectRecordFields(content["m.new_content"], {
body: "hello again @alice:example.org",
"m.mentions": { user_ids: ["@alice:example.org"] },
});
});
it("includes poll snapshots when reading message history", async () => {
const { client, doRequest, getEvent, getRelations } = createMessagesClient({
chunk: [
createPollResponseEvent(),
{
event_id: "$msg",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 10,
content: {
msgtype: "m.text",
body: "hello",
},
},
],
pollRoot: createPollStartEvent({
includeDisclosedKind: true,
maxSelections: 1,
answers: [
{ id: "a1", "m.text": "Apple" },
{ id: "a2", "m.text": "Strawberry" },
],
}),
pollRelations: [createPollResponseEvent()],
});
const result = await readMatrixMessages("room:!room:example.org", { client, limit: 2.9 });
expect(mockCallArg(doRequest, 0, 0)).toBe("GET");
expect(String(mockCallArg(doRequest, 0, 1))).toContain("/rooms/!room%3Aexample.org/messages");
expectRecordFields(mockCallArg(doRequest, 0, 2), { limit: 2 });
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
expect(getRelations).toHaveBeenCalledWith(
"!room:example.org",
"$poll",
"m.reference",
undefined,
{
from: undefined,
},
);
expect(result.messages).toHaveLength(2);
expectRecordFields(result.messages[0], {
eventId: "$poll",
msgtype: "m.text",
});
expect(result.messages[0]?.body).toContain("1. Apple (1 vote)");
expectRecordFields(result.messages[1], {
eventId: "$msg",
body: "hello",
});
});
it("dedupes multiple poll events for the same poll within one read page", async () => {
const { client, getEvent } = createMessagesClient({
chunk: [createPollResponseEvent(), createPollStartEvent()],
pollRoot: createPollStartEvent(),
pollRelations: [],
});
const result = await readMatrixMessages("room:!room:example.org", { client });
expect(result.messages).toHaveLength(1);
expectRecordFields(result.messages[0], { eventId: "$poll" });
expect(result.messages[0]?.body).toContain("[Poll]");
expect(getEvent).toHaveBeenCalledTimes(2);
});
it("uses hydrated history events so encrypted poll entries can be read", async () => {
const { client, hydrateEvents } = createMessagesClient({
chunk: [
{
event_id: "$enc",
sender: "@bob:example.org",
type: "m.room.encrypted",
origin_server_ts: 20,
content: {},
},
],
hydratedChunk: [createPollResponseEvent()],
pollRoot: createPollStartEvent(),
pollRelations: [],
});
const result = await readMatrixMessages("room:!room:example.org", { client });
expect(mockCallArg(hydrateEvents, 0, 0)).toBe("!room:example.org");
expect(
(mockCallArg(hydrateEvents, 0, 1) as Array<Record<string, unknown>>).some(
(event) => event.event_id === "$enc",
),
).toBe(true);
expect(result.messages).toHaveLength(1);
expect(result.messages[0]?.eventId).toBe("$poll");
});
it("filters Matrix thread events out of main-room reads", async () => {
const { client } = createMessagesClient({
chunk: [
{
event_id: "$thread-reply",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 20,
content: {
msgtype: "m.text",
body: "thread reply",
"m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" },
},
},
{
event_id: "$main",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 10,
content: {
msgtype: "m.text",
body: "main room",
},
},
],
});
const result = await readMatrixMessages("room:!room:example.org", { client });
expect(result.messages.map((message) => message.eventId)).toEqual(["$main"]);
});
it("filters threaded poll roots out of main-room reads", async () => {
const threadedPollRoot = createPollStartEvent();
const threadedPollContent = threadedPollRoot.content as Record<string, unknown>;
threadedPollRoot.content = {
...threadedPollContent,
"m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" },
};
const { client, getEvent } = createMessagesClient({
chunk: [createPollResponseEvent()],
pollRoot: threadedPollRoot,
pollRelations: [createPollResponseEvent()],
});
const result = await readMatrixMessages("room:!room:example.org", { client });
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
expect(result.messages).toEqual([]);
});
it("uses the thread relations endpoint and includes the thread root once", async () => {
const { client, doRequest, getEvent, getRelations } = createMessagesClient({
chunk: [],
pollRelations: [
{
event_id: "$thread-reply",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 20,
content: {
msgtype: "m.text",
body: "thread reply",
"m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" },
},
},
],
pollRoot: {
event_id: "$thread-root",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 10,
content: {
msgtype: "m.text",
body: "thread root",
},
},
});
const result = await readMatrixMessages("room:!room:example.org", {
client,
threadId: "$thread-root",
limit: 5,
});
expect(doRequest).not.toHaveBeenCalled();
expect(getRelations).toHaveBeenCalledWith(
"!room:example.org",
"$thread-root",
"m.thread",
undefined,
{ dir: "b", from: undefined, limit: 4 },
);
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$thread-root");
expect(result.messages.map((message) => message.eventId)).toEqual([
"$thread-root",
"$thread-reply",
]);
});
it("includes poll snapshots from threaded reads", async () => {
const { client, getEvent, getRelations } = createMessagesClient({
chunk: [],
pollRoot: createPollStartEvent({
includeDisclosedKind: true,
maxSelections: 1,
answers: [
{ id: "a1", "m.text": "Apple" },
{ id: "a2", "m.text": "Strawberry" },
],
}),
pollRelations: [createPollResponseEvent()],
});
const result = await readMatrixMessages("room:!room:example.org", {
client,
threadId: "$thread-root",
limit: 5,
});
expect(getRelations).toHaveBeenCalledWith(
"!room:example.org",
"$thread-root",
"m.thread",
undefined,
{ dir: "b", from: undefined, limit: 5 },
);
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
expect(result.messages[0]?.body).toContain("1. Apple (1 vote)");
});
it("includes poll roots when reading the thread they start", async () => {
const { client, getEvent, getRelations } = createMessagesClient({
chunk: [],
pollRoot: createPollStartEvent({
includeDisclosedKind: true,
maxSelections: 1,
answers: [
{ id: "a1", "m.text": "Apple" },
{ id: "a2", "m.text": "Strawberry" },
],
}),
pollRelations: [createPollResponseEvent()],
threadRelations: [
{
event_id: "$thread-reply",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 20,
content: {
msgtype: "m.text",
body: "thread reply",
"m.relates_to": { rel_type: "m.thread", event_id: "$poll" },
},
},
],
});
const result = await readMatrixMessages("room:!room:example.org", {
client,
threadId: "$poll",
limit: 5,
});
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
expect(getRelations).toHaveBeenCalledWith(
"!room:example.org",
"$poll",
"m.reference",
undefined,
{
from: undefined,
},
);
expect(getRelations).toHaveBeenCalledWith("!room:example.org", "$poll", "m.thread", undefined, {
dir: "b",
from: undefined,
limit: 4,
});
expect(result.messages.map((message) => message.eventId)).toEqual(["$poll", "$thread-reply"]);
expect(result.messages[0]?.body).toContain("1. Apple (1 vote)");
});
it("does not summarize non-start poll events as thread roots", async () => {
const { client, getRelations } = createMessagesClient({
chunk: [],
pollRoot: createPollResponseEvent(),
threadRelations: [
{
event_id: "$thread-reply",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 20,
content: {
msgtype: "m.text",
body: "thread reply",
"m.relates_to": { rel_type: "m.thread", event_id: "$vote" },
},
},
],
});
const result = await readMatrixMessages("room:!room:example.org", {
client,
threadId: "$vote",
limit: 5,
});
expect(getRelations).toHaveBeenCalledWith("!room:example.org", "$vote", "m.thread", undefined, {
dir: "b",
from: undefined,
limit: 5,
});
expect(result.messages.map((message) => message.eventId)).toEqual(["$thread-reply"]);
});
it("counts the thread root toward the requested first-page limit", async () => {
const { client, doRequest, getEvent, getRelations } = createMessagesClient({
chunk: [],
pollRelations: [
{
event_id: "$thread-reply",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 20,
content: {
msgtype: "m.text",
body: "thread reply",
"m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" },
},
},
],
pollRoot: {
event_id: "$thread-root",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 10,
content: {
msgtype: "m.text",
body: "thread root",
},
},
});
const result = await readMatrixMessages("room:!room:example.org", {
client,
threadId: "$thread-root",
limit: 1,
});
expect(getRelations).toHaveBeenCalledWith(
"!room:example.org",
"$thread-root",
"m.thread",
undefined,
{ dir: "b", from: undefined, limit: 1 },
);
expect(doRequest).not.toHaveBeenCalled();
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$thread-root");
expect(result.messages.map((message) => message.eventId)).toEqual(["$thread-root"]);
expect(result.nextBatch).toEqual(
expect.stringContaining("openclaw.matrix.thread-relations-start:"),
);
const next = await readMatrixMessages("room:!room:example.org", {
client,
threadId: "$thread-root",
limit: 1,
before: result.nextBatch ?? undefined,
});
expect(getRelations).toHaveBeenLastCalledWith(
"!room:example.org",
"$thread-root",
"m.thread",
undefined,
{ dir: "b", from: undefined, limit: 1 },
);
expect(next.messages.map((message) => message.eventId)).toEqual(["$thread-reply"]);
});
it("does not reserve first-page thread capacity for a redacted root", async () => {
const { client, doRequest, getEvent, getRelations } = createMessagesClient({
chunk: [],
pollRelations: [
{
event_id: "$thread-reply",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 20,
content: {
msgtype: "m.text",
body: "thread reply",
"m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" },
},
},
],
pollRoot: {
event_id: "$thread-root",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 10,
unsigned: { redacted_because: {} },
content: {},
},
});
const result = await readMatrixMessages("room:!room:example.org", {
client,
threadId: "$thread-root",
limit: 1,
});
expect(getRelations).toHaveBeenCalledWith(
"!room:example.org",
"$thread-root",
"m.thread",
undefined,
{ dir: "b", from: undefined, limit: 1 },
);
expect(doRequest).not.toHaveBeenCalled();
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$thread-root");
expect(result.messages.map((message) => message.eventId)).toEqual(["$thread-reply"]);
expect(result.nextBatch).toBeNull();
});
});

View File

@@ -0,0 +1,261 @@
import type { Direction } from "matrix-js-sdk/lib/models/event-timeline.js";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { fetchMatrixPollMessageSummary, resolveMatrixPollRootEventId } from "../poll-summary.js";
import { isPollEventType, isPollStartType } from "../poll-types.js";
import { editMessageMatrix, sendMessageMatrix } from "../send.js";
import { withResolvedRoomAction } from "./client.js";
import { resolveMatrixActionLimit } from "./limits.js";
import { summarizeMatrixRawEvent } from "./summary.js";
import {
EventType,
type MatrixActionClientOpts,
type MatrixMessageSummary,
type MatrixRawEvent,
} from "./types.js";
const MATRIX_THREAD_RELATIONS_START_CURSOR_PREFIX = "openclaw.matrix.thread-relations-start:";
export async function sendMatrixMessage(
to: string,
content: string | undefined,
opts: MatrixActionClientOpts & {
mediaUrl?: string;
replyToId?: string;
threadId?: string;
audioAsVoice?: boolean;
} = {},
) {
if (!opts.cfg) {
throw new Error("Matrix message actions require a resolved runtime config.");
}
return await sendMessageMatrix(to, content, {
cfg: opts.cfg,
mediaUrl: opts.mediaUrl,
mediaLocalRoots: opts.mediaLocalRoots,
replyToId: opts.replyToId,
threadId: opts.threadId,
audioAsVoice: opts.audioAsVoice,
accountId: opts.accountId ?? undefined,
client: opts.client,
timeoutMs: opts.timeoutMs,
});
}
export async function editMatrixMessage(
roomId: string,
messageId: string,
content: string,
opts: MatrixActionClientOpts = {},
) {
if (!opts.cfg) {
throw new Error("Matrix message actions require a resolved runtime config.");
}
const trimmed = content.trim();
if (!trimmed) {
throw new Error("Matrix edit requires content");
}
const eventId = await editMessageMatrix(roomId, messageId, trimmed, {
cfg: opts.cfg,
accountId: opts.accountId ?? undefined,
client: opts.client,
timeoutMs: opts.timeoutMs,
});
return { eventId: eventId || null };
}
export async function deleteMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { reason?: string } = {},
) {
await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
await client.redactEvent(resolvedRoom, messageId, opts.reason);
});
}
export async function readMatrixMessages(
roomId: string,
opts: MatrixActionClientOpts & {
limit?: number;
before?: string;
after?: string;
threadId?: string;
} = {},
): Promise<{
messages: MatrixMessageSummary[];
nextBatch?: string | null;
prevBatch?: string | null;
}> {
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
const limit = resolveMatrixActionLimit(opts.limit, 20);
const rawBefore = normalizeOptionalString(opts.before);
const rawAfter = normalizeOptionalString(opts.after);
const dir = opts.after ? "f" : "b";
const threadId = normalizeOptionalString(opts.threadId);
const isThreadRelationsStartCursor = threadId
? isMatrixThreadRelationsStartCursor(rawBefore, threadId)
: false;
const token = isThreadRelationsStartCursor ? undefined : (rawBefore ?? rawAfter);
const includeThreadRoot = threadId !== undefined && !token && !isThreadRelationsStartCursor;
const threadRootSummary =
includeThreadRoot && threadId
? await fetchDisplayableThreadRootSummary(client, resolvedRoom, threadId)
: undefined;
const rootCountsTowardLimit = threadRootSummary !== undefined;
const rootFillsThreadPage = rootCountsTowardLimit && limit === 1;
const relationLimit = rootCountsTowardLimit ? Math.max(limit - 1, 1) : limit;
const seenPollRoots = new Set<string>();
const threadRootEventId = normalizeOptionalString(threadRootSummary?.eventId);
if (threadRootEventId) {
seenPollRoots.add(threadRootEventId);
}
const relationPage =
threadId && relationLimit > 0
? await client.getRelations(resolvedRoom, threadId, "m.thread", undefined, {
dir: dir as Direction,
from: token,
limit: relationLimit,
})
: null;
// Flat room history uses the low-level endpoint for compatibility; threaded reads use
// the SDK relations helper so encrypted rooms get the SDK's event-type translation.
const flatPage = threadId
? null
: ((await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
{
dir,
limit,
from: token,
},
)) as { chunk: MatrixRawEvent[]; start?: string; end?: string });
const hydratedChunk = await client.hydrateEvents(
resolvedRoom,
relationPage ? (rootFillsThreadPage ? [] : relationPage.events) : (flatPage?.chunk ?? []),
);
const messages: MatrixMessageSummary[] = [];
if (threadRootSummary) {
messages.push(threadRootSummary);
}
for (const event of hydratedChunk) {
if (event.unsigned?.redacted_because) {
continue;
}
if (!threadId && isMatrixThreadEvent(event)) {
continue;
}
if (event.type === EventType.RoomMessage) {
if (threadId && event.event_id === threadId) {
continue;
}
messages.push(summarizeMatrixRawEvent(event));
continue;
}
if (!isPollEventType(event.type)) {
continue;
}
const pollRootId = resolveMatrixPollRootEventId(event);
if (!pollRootId || seenPollRoots.has(pollRootId)) {
continue;
}
if (
!threadId &&
(await isMatrixPollRootThreaded({
client,
event,
pollRootId,
resolvedRoom,
}))
) {
continue;
}
seenPollRoots.add(pollRootId);
const pollSummary = await fetchMatrixPollMessageSummary(client, resolvedRoom, event);
if (pollSummary) {
messages.push(pollSummary);
}
}
const nextBatch =
rootFillsThreadPage && threadId && relationPage?.events.length
? encodeMatrixThreadRelationsStartCursor(threadId)
: (relationPage?.nextBatch ?? flatPage?.end ?? null);
return {
messages,
nextBatch,
prevBatch: relationPage?.prevBatch ?? flatPage?.start ?? null,
};
});
}
function encodeMatrixThreadRelationsStartCursor(threadId: string): string {
const payload = Buffer.from(JSON.stringify({ v: 1, threadId }), "utf8").toString("base64url");
return `${MATRIX_THREAD_RELATIONS_START_CURSOR_PREFIX}${payload}`;
}
function isMatrixThreadRelationsStartCursor(raw: string | undefined, threadId: string): boolean {
if (!raw?.startsWith(MATRIX_THREAD_RELATIONS_START_CURSOR_PREFIX)) {
return false;
}
const encoded = raw.slice(MATRIX_THREAD_RELATIONS_START_CURSOR_PREFIX.length);
try {
const decoded = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as {
v?: unknown;
threadId?: unknown;
};
return decoded.v === 1 && decoded.threadId === threadId;
} catch {
return false;
}
}
async function fetchDisplayableThreadRootSummary(
client: MatrixActionClientOpts["client"] & NonNullable<MatrixActionClientOpts["client"]>,
resolvedRoom: string,
threadId: string,
): Promise<MatrixMessageSummary | undefined> {
const rawRootEvent = (await client
.getEvent(resolvedRoom, threadId)
.catch(() => null)) as MatrixRawEvent | null;
if (!rawRootEvent) {
return undefined;
}
const rootEvent = (await client.hydrateEvents(resolvedRoom, [rawRootEvent]))[0];
if (!rootEvent || rootEvent.unsigned?.redacted_because) {
return undefined;
}
if (rootEvent.type === EventType.RoomMessage) {
return summarizeMatrixRawEvent(rootEvent);
}
if (isPollStartType(rootEvent.type)) {
return (await fetchMatrixPollMessageSummary(client, resolvedRoom, rootEvent)) ?? undefined;
}
return undefined;
}
function isMatrixThreadEvent(event: MatrixRawEvent): boolean {
const relates = event.content?.["m.relates_to"];
if (!relates || typeof relates !== "object") {
return false;
}
return (relates as { rel_type?: unknown }).rel_type === "m.thread";
}
async function isMatrixPollRootThreaded(params: {
client: MatrixActionClientOpts["client"] & NonNullable<MatrixActionClientOpts["client"]>;
event: MatrixRawEvent;
pollRootId: string;
resolvedRoom: string;
}): Promise<boolean> {
if (isMatrixThreadEvent(params.event)) {
return true;
}
const rootEvent = (await params.client
.getEvent(params.resolvedRoom, params.pollRootId)
.catch(() => null)) as MatrixRawEvent | null;
if (!rootEvent) {
return false;
}
const hydratedRoot = (await params.client.hydrateEvents(params.resolvedRoom, [rootEvent]))[0];
return hydratedRoot ? isMatrixThreadEvent(hydratedRoot) : false;
}

View File

@@ -0,0 +1,80 @@
// Matrix tests cover pins plugin behavior.
import { describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { listMatrixPins, pinMatrixMessage, unpinMatrixMessage } from "./pins.js";
function createPinsClient(seedPinned: string[], knownBodies: Record<string, string> = {}) {
let pinned = [...seedPinned];
const getRoomStateEvent = vi.fn(async () => ({ pinned: [...pinned] }));
const sendStateEvent = vi.fn(
async (_roomId: string, _type: string, _key: string, payload: unknown) => {
pinned = [...((payload as { pinned: string[] }).pinned ?? [])];
},
);
const getEvent = vi.fn(async (_roomId: string, eventId: string) => {
const body = knownBodies[eventId];
if (!body) {
throw new Error("missing");
}
return {
event_id: eventId,
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 123,
content: { msgtype: "m.text", body },
};
});
return {
client: {
getRoomStateEvent,
sendStateEvent,
getEvent,
stop: vi.fn(),
} as unknown as MatrixClient,
getPinned: () => pinned,
sendStateEvent,
};
}
describe("matrix pins actions", () => {
it("pins a message once even when asked twice", async () => {
const { client, getPinned, sendStateEvent } = createPinsClient(["$a"]);
const first = await pinMatrixMessage("!room:example.org", "$b", { client });
const second = await pinMatrixMessage("!room:example.org", "$b", { client });
expect(first.pinned).toEqual(["$a", "$b"]);
expect(second.pinned).toEqual(["$a", "$b"]);
expect(getPinned()).toEqual(["$a", "$b"]);
expect(sendStateEvent).toHaveBeenCalledTimes(2);
});
it("unpinds only the selected message id", async () => {
const { client, getPinned } = createPinsClient(["$a", "$b", "$c"]);
const result = await unpinMatrixMessage("!room:example.org", "$b", { client });
expect(result.pinned).toEqual(["$a", "$c"]);
expect(getPinned()).toEqual(["$a", "$c"]);
});
it("lists pinned ids and summarizes only resolvable events", async () => {
const { client } = createPinsClient(["$a", "$missing"], { $a: "hello" });
const result = await listMatrixPins("!room:example.org", { client });
expect(result.pinned).toEqual(["$a", "$missing"]);
expect(result.events).toEqual([
{
attachment: undefined,
body: "hello",
eventId: "$a",
msgtype: "m.text",
relatesTo: undefined,
sender: "@alice:example.org",
timestamp: 123,
},
]);
});
});

View File

@@ -0,0 +1,64 @@
// Matrix plugin module implements pins behavior.
import { withResolvedRoomAction } from "./client.js";
import { fetchEventSummary, readPinnedEvents } from "./summary.js";
import {
EventType,
type MatrixActionClientOpts,
type MatrixMessageSummary,
type RoomPinnedEventsEventContent,
} from "./types.js";
async function updateMatrixPins(
roomId: string,
opts: MatrixActionClientOpts,
update: (current: string[]) => string[],
): Promise<{ pinned: string[] }> {
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
const current = await readPinnedEvents(client, resolvedRoom);
const next = update(current);
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
});
}
export async function pinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
return await updateMatrixPins(roomId, opts, (current) =>
current.includes(messageId) ? current : [...current, messageId],
);
}
export async function unpinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
return await updateMatrixPins(roomId, opts, (current) =>
current.filter((id) => id !== messageId),
);
}
export async function listMatrixPins(
roomId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
const pinned = await readPinnedEvents(client, resolvedRoom);
const events = (
await Promise.all(
pinned.map(async (eventId) => {
try {
return await fetchEventSummary(client, resolvedRoom, eventId);
} catch {
return null;
}
}),
)
).filter((event): event is MatrixMessageSummary => Boolean(event));
return { pinned, events };
});
}

View File

@@ -0,0 +1,73 @@
// Matrix tests cover polls plugin behavior.
import { describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { voteMatrixPoll } from "./polls.js";
function createPollClient(pollContent?: Record<string, unknown>) {
const getEvent = vi.fn(async () => ({
type: "m.poll.start",
content: pollContent ?? {
"m.poll.start": {
question: { "m.text": "Favorite fruit?" },
max_selections: 1,
answers: [
{ id: "apple", "m.text": "Apple" },
{ id: "berry", "m.text": "Berry" },
],
},
},
}));
const sendEvent = vi.fn(async () => "$vote1");
return {
client: {
getEvent,
sendEvent,
stop: vi.fn(),
} as unknown as MatrixClient,
getEvent,
sendEvent,
};
}
describe("matrix poll actions", () => {
it("votes by option index against the resolved room id", async () => {
const { client, getEvent, sendEvent } = createPollClient();
const result = await voteMatrixPoll("room:!room:example.org", "$poll", {
client,
optionIndex: 2,
});
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
expect(sendEvent).toHaveBeenCalledWith("!room:example.org", "m.poll.response", {
"m.poll.response": { answers: ["berry"] },
"m.relates_to": {
event_id: "$poll",
rel_type: "m.reference",
},
"org.matrix.msc3381.poll.response": { answers: ["berry"] },
});
expect(result).toEqual({
eventId: "$vote1",
roomId: "!room:example.org",
pollId: "$poll",
answerIds: ["berry"],
labels: ["Berry"],
maxSelections: 1,
});
});
it("rejects option indexes that are outside the poll range", async () => {
const { client, sendEvent } = createPollClient();
await expect(
voteMatrixPoll("room:!room:example.org", "$poll", {
client,
optionIndex: 3,
}),
).rejects.toThrow("out of range");
expect(sendEvent).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,111 @@
// Matrix plugin module implements polls behavior.
import { uniqueStrings, uniqueValues } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
buildPollResponseContent,
isPollStartType,
parsePollStart,
type PollStartContent,
} from "../poll-types.js";
import { withResolvedRoomAction } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js";
function normalizeOptionIndexes(indexes: number[]): number[] {
const normalized = indexes
.map((index) => Math.trunc(index))
.filter((index) => Number.isFinite(index) && index > 0);
return uniqueValues(normalized);
}
function normalizeOptionIds(optionIds: string[]): string[] {
return uniqueStrings(
optionIds.map((optionId) => optionId.trim()).filter((optionId) => optionId.length > 0),
);
}
function resolveSelectedAnswerIds(params: {
optionIds?: string[];
optionIndexes?: number[];
pollContent: PollStartContent;
}): { answerIds: string[]; labels: string[]; maxSelections: number } {
const parsed = parsePollStart(params.pollContent);
if (!parsed) {
throw new Error("Matrix poll vote requires a valid poll start event.");
}
const selectedById = normalizeOptionIds(params.optionIds ?? []);
const selectedByIndex = normalizeOptionIndexes(params.optionIndexes ?? []).map((index) => {
const answer = parsed.answers[index - 1];
if (!answer) {
throw new Error(
`Matrix poll option index ${index} is out of range for a poll with ${parsed.answers.length} options.`,
);
}
return answer.id;
});
const answerIds = normalizeOptionIds([...selectedById, ...selectedByIndex]);
if (answerIds.length === 0) {
throw new Error("Matrix poll vote requires at least one poll option id or index.");
}
if (answerIds.length > parsed.maxSelections) {
throw new Error(
`Matrix poll allows at most ${parsed.maxSelections} selection${parsed.maxSelections === 1 ? "" : "s"}.`,
);
}
const answerMap = new Map(parsed.answers.map((answer) => [answer.id, answer.text] as const));
const labels = answerIds.map((answerId) => {
const label = answerMap.get(answerId);
if (!label) {
throw new Error(
`Matrix poll option id "${answerId}" is not valid for poll ${parsed.question}.`,
);
}
return label;
});
return {
answerIds,
labels,
maxSelections: parsed.maxSelections,
};
}
export async function voteMatrixPoll(
roomId: string,
pollId: string,
opts: MatrixActionClientOpts & {
optionId?: string;
optionIds?: string[];
optionIndex?: number;
optionIndexes?: number[];
} = {},
) {
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
const pollEvent = await client.getEvent(resolvedRoom, pollId);
const eventType = typeof pollEvent.type === "string" ? pollEvent.type : "";
if (!isPollStartType(eventType)) {
throw new Error(`Event ${pollId} is not a Matrix poll start event.`);
}
const { answerIds, labels, maxSelections } = resolveSelectedAnswerIds({
optionIds: [...(opts.optionIds ?? []), ...(opts.optionId ? [opts.optionId] : [])],
optionIndexes: [
...(opts.optionIndexes ?? []),
...(opts.optionIndex !== undefined ? [opts.optionIndex] : []),
],
pollContent: pollEvent.content as PollStartContent,
});
const content = buildPollResponseContent(pollId, answerIds);
const eventId = await client.sendEvent(resolvedRoom, "m.poll.response", content);
return {
eventId: eventId ?? null,
roomId: resolvedRoom,
pollId,
answerIds,
labels,
maxSelections,
};
});
}

View File

@@ -0,0 +1,149 @@
// Matrix tests cover profile plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadWebMediaMock = vi.fn();
const syncMatrixOwnProfileMock = vi.fn();
const withResolvedActionClientMock = vi.fn();
vi.mock("../../runtime.js", () => ({
getMatrixRuntime: () => ({
media: {
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
},
}),
}));
vi.mock("../profile.js", () => ({
syncMatrixOwnProfile: (...args: unknown[]) => syncMatrixOwnProfileMock(...args),
}));
vi.mock("./client.js", () => ({
withResolvedActionClient: (...args: unknown[]) => withResolvedActionClientMock(...args),
}));
const { updateMatrixOwnProfile } = await import("./profile.js");
function mockCallAt(
mock: { mock: { calls: Array<readonly unknown[]> } },
index: number,
label: string,
): readonly unknown[] {
const call = mock.mock.calls[index];
if (!call) {
throw new Error(`expected ${label} call`);
}
return call;
}
function firstMockArg(mock: { mock: { calls: Array<readonly unknown[]> } }, label: string) {
return mockCallAt(mock, 0, label)[0];
}
describe("matrix profile actions", () => {
beforeEach(() => {
vi.clearAllMocks();
loadWebMediaMock.mockResolvedValue({
buffer: Buffer.from("avatar"),
contentType: "image/png",
fileName: "avatar.png",
});
syncMatrixOwnProfileMock.mockResolvedValue({
skipped: false,
displayNameUpdated: true,
avatarUpdated: true,
resolvedAvatarUrl: "mxc://example/avatar",
convertedAvatarFromHttp: true,
uploadedAvatarSource: "http",
});
});
it("trims profile fields and persists through the action client wrapper", async () => {
const actionClient = {
getUserId: vi.fn(async () => "@bot:example.org"),
};
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run(actionClient);
});
await updateMatrixOwnProfile({
accountId: "ops",
displayName: " Ops Bot ",
avatarUrl: " mxc://example/avatar ",
avatarPath: " /tmp/avatar.png ",
});
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(1);
const [wrapperOpts, run, mode] = mockCallAt(
withResolvedActionClientMock,
0,
"Matrix action client wrapper",
);
expect(wrapperOpts).toEqual({
accountId: "ops",
displayName: " Ops Bot ",
avatarUrl: " mxc://example/avatar ",
avatarPath: " /tmp/avatar.png ",
});
expect(typeof run).toBe("function");
expect(mode).toBe("persist");
expect(syncMatrixOwnProfileMock).toHaveBeenCalledTimes(1);
const syncCall = firstMockArg(syncMatrixOwnProfileMock, "Matrix profile sync") as
| {
client: unknown;
userId: string;
displayName: string;
avatarUrl: string;
avatarPath: string;
loadAvatarFromUrl: unknown;
loadAvatarFromPath: unknown;
}
| undefined;
if (!syncCall) {
throw new Error("syncMatrixOwnProfile was not called");
}
const { client, loadAvatarFromUrl, loadAvatarFromPath, ...profileFields } = syncCall;
expect(client).toBe(actionClient);
expect(typeof loadAvatarFromUrl).toBe("function");
expect(typeof loadAvatarFromPath).toBe("function");
expect(profileFields).toEqual({
userId: "@bot:example.org",
displayName: "Ops Bot",
avatarUrl: "mxc://example/avatar",
avatarPath: "/tmp/avatar.png",
});
});
it("bridges avatar loaders through Matrix runtime media helpers", async () => {
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
getUserId: vi.fn(async () => "@bot:example.org"),
});
});
await updateMatrixOwnProfile({
avatarUrl: "https://cdn.example.org/avatar.png",
avatarPath: "/tmp/avatar.png",
});
const call = firstMockArg(syncMatrixOwnProfileMock, "Matrix profile sync") as
| {
loadAvatarFromUrl: (url: string, maxBytes: number) => Promise<unknown>;
loadAvatarFromPath: (path: string, maxBytes: number) => Promise<unknown>;
}
| undefined;
if (!call) {
throw new Error("syncMatrixOwnProfile was not called");
}
await call.loadAvatarFromUrl("https://cdn.example.org/avatar.png", 123);
await call.loadAvatarFromPath("/tmp/avatar.png", 456);
expect(loadWebMediaMock).toHaveBeenNthCalledWith(1, "https://cdn.example.org/avatar.png", 123);
expect(loadWebMediaMock).toHaveBeenNthCalledWith(2, "/tmp/avatar.png", {
maxBytes: 456,
localRoots: undefined,
});
});
});

View File

@@ -0,0 +1,38 @@
// Matrix plugin module implements profile behavior.
import { getMatrixRuntime } from "../../runtime.js";
import { syncMatrixOwnProfile, type MatrixProfileSyncResult } from "../profile.js";
import { withResolvedActionClient } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js";
export async function updateMatrixOwnProfile(
opts: MatrixActionClientOpts & {
displayName?: string;
avatarUrl?: string;
avatarPath?: string;
} = {},
): Promise<MatrixProfileSyncResult> {
const displayName = opts.displayName?.trim();
const avatarUrl = opts.avatarUrl?.trim();
const avatarPath = opts.avatarPath?.trim();
const runtime = getMatrixRuntime();
return await withResolvedActionClient(
opts,
async (client) => {
const userId = await client.getUserId();
return await syncMatrixOwnProfile({
client,
userId,
displayName: displayName || undefined,
avatarUrl: avatarUrl || undefined,
avatarPath: avatarPath || undefined,
loadAvatarFromUrl: async (url, maxBytes) => await runtime.media.loadWebMedia(url, maxBytes),
loadAvatarFromPath: async (path, maxBytes) =>
await runtime.media.loadWebMedia(path, {
maxBytes,
localRoots: opts.mediaLocalRoots,
}),
});
},
"persist",
);
}

View File

@@ -0,0 +1,134 @@
// Matrix tests cover reactions plugin behavior.
import { describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { listMatrixReactions, removeMatrixReactions } from "./reactions.js";
function createReactionsClient(params: {
chunk: Array<{
event_id?: string;
sender?: string;
key?: string;
}>;
userId?: string | null;
}) {
const doRequest = vi.fn(async (_method: string, _path: string, _query: unknown) => ({
chunk: params.chunk.map((item) => ({
event_id: item.event_id ?? "",
sender: item.sender ?? "",
content: item.key
? {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$target",
key: item.key,
},
}
: {},
})),
}));
const getUserId = vi.fn(async () => params.userId ?? null);
const redactEvent = vi.fn(async () => undefined);
return {
client: {
doRequest,
getUserId,
redactEvent,
stop: vi.fn(),
} as unknown as MatrixClient,
doRequest,
redactEvent,
};
}
describe("matrix reaction actions", () => {
it("aggregates reactions by key and unique sender", async () => {
const { client, doRequest } = createReactionsClient({
chunk: [
{ event_id: "$1", sender: "@alice:example.org", key: "👍" },
{ event_id: "$2", sender: "@bob:example.org", key: "👍" },
{ event_id: "$3", sender: "@alice:example.org", key: "👎" },
{ event_id: "$4", sender: "@bot:example.org" },
],
userId: "@bot:example.org",
});
const result = await listMatrixReactions("!room:example.org", "$msg", { client, limit: 2.9 });
expect(doRequest).toHaveBeenCalledWith(
"GET",
"/_matrix/client/v1/rooms/!room%3Aexample.org/relations/%24msg/m.annotation/m.reaction",
{ dir: "b", limit: 2 },
);
expect(result).toStrictEqual([
{
key: "👍",
count: 2,
users: ["@alice:example.org", "@bob:example.org"],
},
{
key: "👎",
count: 1,
users: ["@alice:example.org"],
},
]);
});
it("removes only current-user reactions matching emoji filter", async () => {
const { client, redactEvent } = createReactionsClient({
chunk: [
{ event_id: "$1", sender: "@me:example.org", key: "👍" },
{ event_id: "$2", sender: "@me:example.org", key: "👎" },
{ event_id: "$3", sender: "@other:example.org", key: "👍" },
],
userId: "@me:example.org",
});
const result = await removeMatrixReactions("!room:example.org", "$msg", {
client,
emoji: "👍",
});
expect(result).toEqual({ removed: 1 });
expect(redactEvent).toHaveBeenCalledTimes(1);
expect(redactEvent).toHaveBeenCalledWith("!room:example.org", "$1");
});
it("returns removed=0 when current user id is unavailable", async () => {
const { client, redactEvent } = createReactionsClient({
chunk: [{ event_id: "$1", sender: "@me:example.org", key: "👍" }],
userId: null,
});
const result = await removeMatrixReactions("!room:example.org", "$msg", { client });
expect(result).toEqual({ removed: 0 });
expect(redactEvent).not.toHaveBeenCalled();
});
it("returns an empty list when the relations response is malformed", async () => {
const doRequest = vi.fn(async () => ({ chunk: null }));
const client = {
doRequest,
getUserId: vi.fn(async () => "@me:example.org"),
redactEvent: vi.fn(async () => undefined),
stop: vi.fn(),
} as unknown as MatrixClient;
const result = await listMatrixReactions("!room:example.org", "$msg", { client });
expect(result).toStrictEqual([]);
});
it("rejects blank message ids before querying Matrix relations", async () => {
const { client, doRequest } = createReactionsClient({
chunk: [],
userId: "@me:example.org",
});
await expect(listMatrixReactions("!room:example.org", " ", { client })).rejects.toThrow(
"messageId",
);
expect(doRequest).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,56 @@
// Matrix plugin module implements reactions behavior.
import {
buildMatrixReactionRelationsPath,
selectOwnMatrixReactionEventIds,
summarizeMatrixReactionEvents,
} from "../reaction-common.js";
import { withResolvedRoomAction } from "./client.js";
import { resolveMatrixActionLimit } from "./limits.js";
import type { MatrixActionClientOpts, MatrixRawEvent, MatrixReactionSummary } from "./types.js";
type ActionClient = NonNullable<MatrixActionClientOpts["client"]>;
async function listMatrixReactionEvents(
client: ActionClient,
roomId: string,
messageId: string,
limit: number,
): Promise<MatrixRawEvent[]> {
const res = (await client.doRequest("GET", buildMatrixReactionRelationsPath(roomId, messageId), {
dir: "b",
limit,
})) as { chunk?: MatrixRawEvent[] };
return Array.isArray(res.chunk) ? res.chunk : [];
}
export async function listMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { limit?: number } = {},
): Promise<MatrixReactionSummary[]> {
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
const limit = resolveMatrixActionLimit(opts.limit, 100);
const chunk = await listMatrixReactionEvents(client, resolvedRoom, messageId, limit);
return summarizeMatrixReactionEvents(chunk);
});
}
export async function removeMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { emoji?: string } = {},
): Promise<{ removed: number }> {
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
const chunk = await listMatrixReactionEvents(client, resolvedRoom, messageId, 200);
const userId = await client.getUserId();
if (!userId) {
return { removed: 0 };
}
const toRemove = selectOwnMatrixReactionEventIds(chunk, userId, opts.emoji);
if (toRemove.length === 0) {
return { removed: 0 };
}
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
return { removed: toRemove.length };
});
}

View File

@@ -0,0 +1,80 @@
// Matrix tests cover room plugin behavior.
import { describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { getMatrixMemberInfo, getMatrixRoomInfo } from "./room.js";
function createRoomClient() {
const getRoomStateEvent = vi.fn(async (_roomId: string, eventType: string) => {
switch (eventType) {
case "m.room.name":
return { name: "Ops Room" };
case "m.room.topic":
return { topic: "Incidents" };
case "m.room.canonical_alias":
return { alias: "#ops:example.org" };
default:
throw new Error(`unexpected state event ${eventType}`);
}
});
const getJoinedRoomMembers = vi.fn(async () => [
{ user_id: "@alice:example.org" },
{ user_id: "@bot:example.org" },
]);
const getUserProfile = vi.fn(async () => ({
displayname: "Alice",
avatar_url: "mxc://example.org/alice",
}));
return {
client: {
getRoomStateEvent,
getJoinedRoomMembers,
getUserProfile,
stop: vi.fn(),
} as unknown as MatrixClient,
getRoomStateEvent,
getJoinedRoomMembers,
getUserProfile,
};
}
describe("matrix room actions", () => {
it("returns room details from the resolved Matrix room id", async () => {
const { client, getJoinedRoomMembers, getRoomStateEvent } = createRoomClient();
const result = await getMatrixRoomInfo("room:!ops:example.org", { client });
expect(getRoomStateEvent).toHaveBeenCalledWith("!ops:example.org", "m.room.name", "");
expect(getJoinedRoomMembers).toHaveBeenCalledWith("!ops:example.org");
expect(result).toEqual({
roomId: "!ops:example.org",
name: "Ops Room",
topic: "Incidents",
canonicalAlias: "#ops:example.org",
altAliases: [],
memberCount: 2,
});
});
it("resolves optional room ids when looking up member info", async () => {
const { client, getUserProfile } = createRoomClient();
const result = await getMatrixMemberInfo("@alice:example.org", {
client,
roomId: "room:!ops:example.org",
});
expect(getUserProfile).toHaveBeenCalledWith("@alice:example.org");
expect(result).toEqual({
userId: "@alice:example.org",
profile: {
displayName: "Alice",
avatarUrl: "mxc://example.org/alice",
},
membership: null,
powerLevel: null,
displayName: "Alice",
roomId: "!ops:example.org",
});
});
});

View File

@@ -0,0 +1,72 @@
// Matrix plugin module implements room behavior.
import { resolveMatrixRoomId } from "../send.js";
import { withResolvedActionClient, withResolvedRoomAction } from "./client.js";
import { EventType, type MatrixActionClientOpts } from "./types.js";
export async function getMatrixMemberInfo(
userId: string,
opts: MatrixActionClientOpts & { roomId?: string } = {},
) {
return await withResolvedActionClient(opts, async (client) => {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
const profile = await client.getUserProfile(userId);
// Membership and power levels are not included in profile calls; fetch state separately if needed.
return {
userId,
profile: {
displayName: profile?.displayname ?? null,
avatarUrl: profile?.avatar_url ?? null,
},
membership: null, // Would need separate room state query
powerLevel: null, // Would need separate power levels state query
displayName: profile?.displayname ?? null,
roomId: roomId ?? null,
};
});
}
export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) {
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
let name: string | null = null;
let topic: string | null = null;
let canonicalAlias: string | null = null;
let memberCount: number | null = null;
try {
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
name = typeof nameState?.name === "string" ? nameState.name : null;
} catch {
// ignore
}
try {
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
topic = typeof topicState?.topic === "string" ? topicState.topic : null;
} catch {
// ignore
}
try {
const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", "");
canonicalAlias = typeof aliasState?.alias === "string" ? aliasState.alias : null;
} catch {
// ignore
}
try {
const members = await client.getJoinedRoomMembers(resolvedRoom);
memberCount = members.length;
} catch {
// ignore
}
return {
roomId: resolvedRoom,
name,
topic,
canonicalAlias,
altAliases: [], // Would need separate query
memberCount,
};
});
}

View File

@@ -0,0 +1,101 @@
// Matrix tests cover summary plugin behavior.
import { describe, expect, it } from "vitest";
import { summarizeMatrixRawEvent } from "./summary.js";
describe("summarizeMatrixRawEvent", () => {
it("replaces bare media filenames with a media marker", () => {
const summary = summarizeMatrixRawEvent({
event_id: "$image",
sender: "@gum:matrix.example.org",
type: "m.room.message",
origin_server_ts: 123,
content: {
msgtype: "m.image",
body: "photo.jpg",
},
});
expect(summary).toEqual({
eventId: "$image",
sender: "@gum:matrix.example.org",
body: undefined,
msgtype: "m.image",
attachment: {
kind: "image",
filename: "photo.jpg",
},
timestamp: 123,
relatesTo: undefined,
});
});
it("preserves captions while marking media summaries", () => {
const summary = summarizeMatrixRawEvent({
event_id: "$image",
sender: "@gum:matrix.example.org",
type: "m.room.message",
origin_server_ts: 123,
content: {
msgtype: "m.image",
body: "can you see this?",
filename: "photo.jpg",
},
});
expect(summary).toEqual({
eventId: "$image",
sender: "@gum:matrix.example.org",
body: "can you see this?",
msgtype: "m.image",
attachment: {
kind: "image",
caption: "can you see this?",
filename: "photo.jpg",
},
timestamp: 123,
relatesTo: undefined,
});
});
it("does not treat a sentence ending in a file extension as a bare filename", () => {
const summary = summarizeMatrixRawEvent({
event_id: "$image",
sender: "@gum:matrix.example.org",
type: "m.room.message",
origin_server_ts: 123,
content: {
msgtype: "m.image",
body: "see image.png",
},
});
expect(summary).toEqual({
eventId: "$image",
sender: "@gum:matrix.example.org",
body: "see image.png",
msgtype: "m.image",
attachment: {
kind: "image",
caption: "see image.png",
},
timestamp: 123,
relatesTo: undefined,
});
});
it("leaves text messages unchanged", () => {
const summary = summarizeMatrixRawEvent({
event_id: "$text",
sender: "@gum:matrix.example.org",
type: "m.room.message",
origin_server_ts: 123,
content: {
msgtype: "m.text",
body: "hello",
},
});
expect(summary.body).toBe("hello");
expect(summary.attachment).toBeUndefined();
});
});

View File

@@ -0,0 +1,89 @@
// Matrix plugin module implements summary behavior.
import { isMatrixNotFoundError } from "../errors.js";
import { resolveMatrixMessageAttachment, resolveMatrixMessageBody } from "../media-text.js";
import { fetchMatrixPollMessageSummary } from "../poll-summary.js";
import type { MatrixClient } from "../sdk.js";
import {
EventType,
type MatrixMessageSummary,
type MatrixRawEvent,
type RoomMessageEventContent,
type RoomPinnedEventsEventContent,
} from "./types.js";
export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary {
const content = event.content as RoomMessageEventContent;
const relates = content["m.relates_to"];
let relType: string | undefined;
let eventId: string | undefined;
if (relates) {
if ("rel_type" in relates) {
relType = relates.rel_type;
eventId = relates.event_id;
} else if ("m.in_reply_to" in relates) {
eventId = relates["m.in_reply_to"]?.event_id;
}
}
const relatesTo =
relType || eventId
? {
relType,
eventId,
}
: undefined;
return {
eventId: event.event_id,
sender: event.sender,
body: resolveMatrixMessageBody({
body: content.body,
filename: content.filename,
msgtype: content.msgtype,
}),
msgtype: content.msgtype,
attachment: resolveMatrixMessageAttachment({
body: content.body,
filename: content.filename,
msgtype: content.msgtype,
}),
timestamp: event.origin_server_ts,
relatesTo,
};
}
export async function readPinnedEvents(client: MatrixClient, roomId: string): Promise<string[]> {
try {
const content = (await client.getRoomStateEvent(
roomId,
EventType.RoomPinnedEvents,
"",
)) as RoomPinnedEventsEventContent;
const pinned = content.pinned;
return pinned.filter((id) => id.trim().length > 0);
} catch (err: unknown) {
if (isMatrixNotFoundError(err)) {
return [];
}
throw err;
}
}
export async function fetchEventSummary(
client: MatrixClient,
roomId: string,
eventId: string,
): Promise<MatrixMessageSummary | null> {
try {
const raw = (await client.getEvent(roomId, eventId)) as unknown as MatrixRawEvent;
if (raw.unsigned?.redacted_because) {
return null;
}
const pollSummary = await fetchMatrixPollMessageSummary(client, roomId, raw);
if (pollSummary) {
return pollSummary;
}
return summarizeMatrixRawEvent(raw);
} catch {
// Event not found, redacted, or inaccessible - return null
return null;
}
}

View File

@@ -0,0 +1,64 @@
// Matrix type declarations define plugin contracts.
import type { CoreConfig } from "../../types.js";
import { MATRIX_REACTION_EVENT_TYPE } from "../reaction-common.js";
import type { MatrixClient, MessageEventContent } from "../sdk.js";
export type { MatrixRawEvent } from "../sdk.js";
export type { MatrixReactionSummary } from "../reaction-common.js";
export const EventType = {
RoomMessage: "m.room.message",
RoomPinnedEvents: "m.room.pinned_events",
RoomTopic: "m.room.topic",
Reaction: MATRIX_REACTION_EVENT_TYPE,
} as const;
export type RoomMessageEventContent = MessageEventContent & {
msgtype: string;
body: string;
"m.new_content"?: RoomMessageEventContent;
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};
export type RoomPinnedEventsEventContent = {
pinned: string[];
};
export type MatrixActionClientOpts = {
client?: MatrixClient;
cfg?: CoreConfig;
mediaLocalRoots?: readonly string[];
timeoutMs?: number;
accountId?: string | null;
readiness?: "none" | "prepared" | "started";
};
export type MatrixMessageSummary = {
eventId?: string;
sender?: string;
body?: string;
msgtype?: string;
attachment?: MatrixMessageAttachmentSummary;
timestamp?: number;
relatesTo?: {
relType?: string;
eventId?: string;
key?: string;
};
};
export type MatrixMessageAttachmentKind = "audio" | "file" | "image" | "sticker" | "video";
export type MatrixMessageAttachmentSummary = {
kind: MatrixMessageAttachmentKind;
caption?: string;
filename?: string;
};
export type MatrixActionClient = {
client: MatrixClient;
stopOnDone: boolean;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,590 @@
// Matrix plugin module implements verification behavior.
import { setTimeout as sleep } from "node:timers/promises";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { CoreConfig } from "../../types.js";
import { formatMatrixEncryptionUnavailableError } from "../encryption-guidance.js";
import type { MatrixDeviceVerificationStatus, MatrixOwnDeviceVerificationStatus } from "../sdk.js";
import type { MatrixVerificationSummary } from "../sdk/verification-manager.js";
import { withResolvedActionClient, withStartedActionClient } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js";
const DEFAULT_MATRIX_SELF_VERIFICATION_TIMEOUT_MS = 180_000;
type MatrixCryptoActionFacade = NonNullable<import("../sdk.js").MatrixClient["crypto"]>;
type MatrixActionClient = import("../sdk.js").MatrixClient;
type MatrixVerificationDmLookupOpts = {
verificationDmRoomId?: string;
verificationDmUserId?: string;
};
export type MatrixSelfVerificationResult = MatrixVerificationSummary & {
deviceOwnerVerified: boolean;
ownerVerification: MatrixOwnDeviceVerificationStatus;
};
function requireCrypto(
client: import("../sdk.js").MatrixClient,
opts: MatrixActionClientOpts,
): NonNullable<import("../sdk.js").MatrixClient["crypto"]> {
if (!client.crypto) {
if (!opts.cfg) {
throw new Error(
"Matrix verification actions requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
);
}
const cfg = requireRuntimeConfig(opts.cfg, "Matrix verification actions") as CoreConfig;
throw new Error(formatMatrixEncryptionUnavailableError(cfg, opts.accountId));
}
return client.crypto;
}
function resolveVerificationId(input: string): string {
const normalized = input.trim();
if (!normalized) {
throw new Error("Matrix verification request id is required");
}
return normalized;
}
async function ensureMatrixVerificationDmTracked(
crypto: MatrixCryptoActionFacade,
opts: MatrixVerificationDmLookupOpts,
): Promise<void> {
const roomId = normalizeOptionalString(opts.verificationDmRoomId);
const userId = normalizeOptionalString(opts.verificationDmUserId);
if (Boolean(roomId) !== Boolean(userId)) {
throw new Error("--user-id and --room-id must be provided together for Matrix DM verification");
}
if (!roomId || !userId) {
return;
}
const tracked = await crypto.ensureVerificationDmTracked({ roomId, userId });
if (!tracked) {
throw new Error(
`Matrix DM verification request not found for room ${roomId} and user ${userId}`,
);
}
}
function isSameMatrixVerification(
left: MatrixVerificationSummary,
right: MatrixVerificationSummary,
): boolean {
return (
left.id === right.id ||
Boolean(left.transactionId && left.transactionId === right.transactionId)
);
}
function isMatrixVerificationReadyForSas(summary: MatrixVerificationSummary): boolean {
return (
summary.completed ||
summary.hasSas ||
summary.phaseName === "ready" ||
summary.phaseName === "started"
);
}
function shouldStartMatrixSasVerification(summary: MatrixVerificationSummary): boolean {
return !summary.hasSas && summary.phaseName !== "started" && !summary.completed;
}
function isMatrixVerificationCancelled(summary: MatrixVerificationSummary): boolean {
return summary.phaseName === "cancelled";
}
function isMatrixSasMethod(method: string | null | undefined): boolean {
return method === "m.sas.v1" || method === "sas";
}
function getMatrixVerificationSasWaitFailure(
summary: MatrixVerificationSummary,
label: string,
): string | null {
if (summary.hasSas || summary.phaseName === "cancelled") {
return null;
}
const method = summary.chosenMethod ? ` (method: ${summary.chosenMethod})` : "";
if (summary.completed) {
return `Matrix self-verification completed without SAS while waiting to ${label}${method}`;
}
if (
summary.phaseName === "started" &&
summary.chosenMethod &&
!isMatrixSasMethod(summary.chosenMethod)
) {
return `Matrix self-verification started without SAS while waiting to ${label}${method}`;
}
return null;
}
async function waitForMatrixVerificationSummary(params: {
crypto: MatrixCryptoActionFacade;
label: string;
request: MatrixVerificationSummary;
timeoutMs: number;
predicate: (summary: MatrixVerificationSummary) => boolean;
reject?: (summary: MatrixVerificationSummary) => string | null;
}): Promise<MatrixVerificationSummary> {
const startedAt = Date.now();
let last: MatrixVerificationSummary | undefined;
while (Date.now() - startedAt < params.timeoutMs) {
const summaries = await params.crypto.listVerifications();
const found = summaries.find((summary) => isSameMatrixVerification(summary, params.request));
if (found) {
last = found;
if (params.predicate(found)) {
return found;
}
if (isMatrixVerificationCancelled(found)) {
throw new Error(
`Matrix self-verification was cancelled${
found.error ? `: ${found.error}` : ` while waiting to ${params.label}`
}`,
);
}
const rejection = params.reject?.(found);
if (rejection) {
throw new Error(rejection);
}
}
await sleep(Math.min(250, Math.max(25, params.timeoutMs - (Date.now() - startedAt))));
}
throw new Error(
`Timed out waiting for Matrix self-verification to ${params.label}${
last ? ` (last phase: ${last.phaseName})` : ""
}`,
);
}
function formatMatrixOwnerVerificationDiagnostics(
status: MatrixDeviceVerificationStatus | MatrixOwnDeviceVerificationStatus | undefined,
): string {
if (!status) {
return "Matrix identity trust status was unavailable";
}
return `cross-signing verified: ${status.crossSigningVerified ? "yes" : "no"}, signed by owner: ${
status.signedByOwner ? "yes" : "no"
}, locally trusted: ${status.localVerified ? "yes" : "no"}`;
}
async function waitForMatrixSelfVerificationTrustStatus(params: {
client: MatrixActionClient;
timeoutMs: number;
}): Promise<MatrixOwnDeviceVerificationStatus> {
const startedAt = Date.now();
let last: MatrixOwnDeviceVerificationStatus | undefined;
let crossSigningPublished = false;
while (Date.now() - startedAt < params.timeoutMs) {
const [status, crossSigning] = await Promise.all([
params.client.getOwnDeviceVerificationStatus(),
params.client.getOwnCrossSigningPublicationStatus(),
]);
last = status;
crossSigningPublished = crossSigning.published;
if (status.verified && crossSigningPublished) {
return status;
}
await sleep(Math.min(250, Math.max(25, params.timeoutMs - (Date.now() - startedAt))));
}
throw new Error(
`Timed out waiting for Matrix self-verification to establish full Matrix identity trust for this device (${formatMatrixOwnerVerificationDiagnostics(
last,
)}, cross-signing keys published: ${crossSigningPublished ? "yes" : "no"}). Complete self-verification from another Matrix client, then check Matrix verification status for details.`,
);
}
async function cancelMatrixSelfVerificationOnFailure(params: {
crypto: MatrixCryptoActionFacade;
request: MatrixVerificationSummary | undefined;
}): Promise<void> {
if (!params.request || typeof params.crypto.cancelVerification !== "function") {
return;
}
await params.crypto
.cancelVerification(params.request.id, {
reason: "OpenClaw self-verification did not complete",
code: "m.user",
})
.catch(() => undefined);
}
async function completeMatrixSelfVerification(params: {
client: MatrixActionClient;
completed: MatrixVerificationSummary;
timeoutMs: number;
}): Promise<MatrixSelfVerificationResult> {
const initial = await Promise.all([
params.client.getOwnDeviceVerificationStatus(),
params.client.getOwnCrossSigningPublicationStatus(),
]);
let ownerVerification = initial[0];
if (!ownerVerification.verified || !initial[1].published) {
if (!ownerVerification.verified) {
await params.client.trustOwnIdentityAfterSelfVerification?.();
}
ownerVerification = await waitForMatrixSelfVerificationTrustStatus({
client: params.client,
timeoutMs: params.timeoutMs,
});
}
return {
...params.completed,
deviceOwnerVerified: ownerVerification.verified,
ownerVerification,
};
}
export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
return await crypto.listVerifications();
});
}
export async function requestMatrixVerification(
params: MatrixActionClientOpts & {
ownUser?: boolean;
userId?: string;
deviceId?: string;
roomId?: string;
} = {},
) {
return await withStartedActionClient(params, async (client) => {
const crypto = requireCrypto(client, params);
const ownUser = params.ownUser ?? (!params.userId && !params.deviceId && !params.roomId);
return await crypto.requestVerification({
ownUser,
userId: normalizeOptionalString(params.userId),
deviceId: normalizeOptionalString(params.deviceId),
roomId: normalizeOptionalString(params.roomId),
});
});
}
export async function runMatrixSelfVerification(
params: MatrixActionClientOpts & {
confirmSas: (
sas: NonNullable<MatrixVerificationSummary["sas"]>,
summary: MatrixVerificationSummary,
) => Promise<boolean>;
onReady?: (summary: MatrixVerificationSummary) => void | Promise<void>;
onRequested?: (summary: MatrixVerificationSummary) => void | Promise<void>;
onSas?: (summary: MatrixVerificationSummary) => void | Promise<void>;
timeoutMs?: number;
},
): Promise<MatrixSelfVerificationResult> {
return await withStartedActionClient(params, async (client) => {
const crypto = requireCrypto(client, params);
const timeoutMs = params.timeoutMs ?? DEFAULT_MATRIX_SELF_VERIFICATION_TIMEOUT_MS;
let requested: MatrixVerificationSummary | undefined;
let requestCompleted = false;
let handledByMismatch = false;
try {
requested = await crypto.requestVerification({ ownUser: true });
await params.onRequested?.(requested);
const ready = isMatrixVerificationReadyForSas(requested)
? requested
: await waitForMatrixVerificationSummary({
crypto,
label: "be accepted in another Matrix client",
request: requested,
timeoutMs,
predicate: isMatrixVerificationReadyForSas,
});
await params.onReady?.(ready);
if (ready.completed) {
requestCompleted = true;
return await completeMatrixSelfVerification({ client, completed: ready, timeoutMs });
}
const started = shouldStartMatrixSasVerification(ready)
? await crypto.startVerification(ready.id, "sas")
: ready;
let sasSummary = started;
if (!sasSummary.hasSas) {
const sasFailure = getMatrixVerificationSasWaitFailure(
sasSummary,
"show SAS emoji or decimals",
);
if (sasFailure) {
throw new Error(sasFailure);
}
sasSummary = await waitForMatrixVerificationSummary({
crypto,
label: "show SAS emoji or decimals",
request: started,
timeoutMs,
predicate: (summary) => summary.hasSas,
reject: (summary) =>
getMatrixVerificationSasWaitFailure(summary, "show SAS emoji or decimals"),
});
}
if (!sasSummary.sas) {
throw new Error("Matrix SAS data is not available for this verification request");
}
await params.onSas?.(sasSummary);
const matched = await params.confirmSas(sasSummary.sas, sasSummary);
if (!matched) {
await crypto.mismatchVerificationSas(sasSummary.id);
handledByMismatch = true;
throw new Error("Matrix SAS verification was not confirmed.");
}
const confirmed = await crypto.confirmVerificationSas(sasSummary.id);
const completed = confirmed.completed
? confirmed
: await waitForMatrixVerificationSummary({
crypto,
label: "complete",
request: confirmed,
timeoutMs,
predicate: (summary) => summary.completed,
});
requestCompleted = true;
return await completeMatrixSelfVerification({ client, completed, timeoutMs });
} catch (error) {
if (!requestCompleted && !handledByMismatch) {
await cancelMatrixSelfVerificationOnFailure({ crypto, request: requested });
}
throw error;
}
});
}
export async function acceptMatrixVerification(
requestId: string,
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.acceptVerification(resolveVerificationId(requestId));
});
}
export async function cancelMatrixVerification(
requestId: string,
opts: MatrixActionClientOpts &
MatrixVerificationDmLookupOpts & { reason?: string; code?: string } = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.cancelVerification(resolveVerificationId(requestId), {
reason: normalizeOptionalString(opts.reason),
code: normalizeOptionalString(opts.code),
});
});
}
export async function startMatrixVerification(
requestId: string,
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts & { method?: "sas" } = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas");
});
}
export async function generateMatrixVerificationQr(
requestId: string,
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.generateVerificationQr(resolveVerificationId(requestId));
});
}
export async function scanMatrixVerificationQr(
requestId: string,
qrDataBase64: string,
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
const payload = qrDataBase64.trim();
if (!payload) {
throw new Error("Matrix QR data is required");
}
return await crypto.scanVerificationQr(resolveVerificationId(requestId), payload);
});
}
export async function getMatrixVerificationSas(
requestId: string,
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.getVerificationSas(resolveVerificationId(requestId));
});
}
export async function confirmMatrixVerificationSas(
requestId: string,
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
const summary = await crypto.confirmVerificationSas(resolveVerificationId(requestId));
// For self-verifications, mirror the trust-own-identity step that the
// higher-level runMatrixSelfVerification path already performs at
// completeMatrixSelfVerification: cross-sign the operator's master key
// from the bot side so Element X clears the "Verify" prompt without
// waiting for a passive sync tick. Non-self verifications are a no-op.
if (summary.isSelfVerification && summary.completed && !summary.error) {
await client.trustOwnIdentityAfterSelfVerification?.();
}
return summary;
});
}
export async function mismatchMatrixVerificationSas(
requestId: string,
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.mismatchVerificationSas(resolveVerificationId(requestId));
});
}
export async function confirmMatrixVerificationReciprocateQr(
requestId: string,
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId));
});
}
export async function getMatrixEncryptionStatus(
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
) {
return await withResolvedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
const recoveryKey = await crypto.getRecoveryKey();
return {
encryptionEnabled: true,
recoveryKeyStored: Boolean(recoveryKey),
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
...(opts.includeRecoveryKey ? { recoveryKey: recoveryKey?.encodedPrivateKey ?? null } : {}),
pendingVerifications: (await crypto.listVerifications()).length,
};
});
}
export async function getMatrixVerificationStatus(
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
) {
const readiness = opts.readiness ?? "prepared";
return await withResolvedActionClient(
{ ...opts, readiness: "none" },
async (client) => {
const preflight = await readMatrixVerificationStatus(client, opts);
if (readiness === "none" || preflight.serverDeviceKnown === false) {
return preflight;
}
if (readiness === "started") {
await client.start();
} else {
await client.prepareForOneOff();
}
return await readMatrixVerificationStatus(client, opts);
},
"discard",
);
}
async function readMatrixVerificationStatus(
client: MatrixActionClient,
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean },
) {
const status = await client.getOwnDeviceVerificationStatus();
const payload = {
...status,
pendingVerifications: client.crypto ? (await client.crypto.listVerifications()).length : 0,
};
if (!opts.includeRecoveryKey) {
return payload;
}
const recoveryKey = client.crypto ? await client.crypto.getRecoveryKey() : null;
return {
...payload,
recoveryKey: recoveryKey?.encodedPrivateKey ?? null,
};
}
export async function getMatrixRoomKeyBackupStatus(opts: MatrixActionClientOpts = {}) {
return await withResolvedActionClient(
opts,
async (client) => await client.getRoomKeyBackupStatus(),
);
}
export async function verifyMatrixRecoveryKey(
recoveryKey: string,
opts: MatrixActionClientOpts = {},
) {
return await withStartedActionClient(
opts,
async (client) => await client.verifyWithRecoveryKey(recoveryKey),
);
}
export async function restoreMatrixRoomKeyBackup(
opts: MatrixActionClientOpts & {
recoveryKey?: string;
} = {},
) {
return await withResolvedActionClient(
opts,
async (client) =>
await client.restoreRoomKeyBackup({
recoveryKey: normalizeOptionalString(opts.recoveryKey),
}),
);
}
export async function resetMatrixRoomKeyBackup(
opts: MatrixActionClientOpts & { rotateRecoveryKey?: boolean } = {},
) {
return await withStartedActionClient(
opts,
async (client) =>
await client.resetRoomKeyBackup({
rotateRecoveryKey: opts.rotateRecoveryKey,
}),
);
}
export async function bootstrapMatrixVerification(
opts: MatrixActionClientOpts & {
recoveryKey?: string;
forceResetCrossSigning?: boolean;
} = {},
) {
return await withStartedActionClient(
opts,
async (client) =>
await client.bootstrapOwnDeviceVerification({
recoveryKey: normalizeOptionalString(opts.recoveryKey),
forceResetCrossSigning: opts.forceResetCrossSigning === true,
}),
);
}

View File

@@ -0,0 +1,27 @@
// Matrix plugin module implements active client behavior.
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { MatrixClient } from "./sdk.js";
const activeClients = new Map<string, MatrixClient>();
function resolveAccountKey(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId);
return normalized || DEFAULT_ACCOUNT_ID;
}
export function setActiveMatrixClient(
client: MatrixClient | null,
accountId?: string | null,
): void {
const key = resolveAccountKey(accountId);
if (!client) {
activeClients.delete(key);
return;
}
activeClients.set(key, client);
}
export function getActiveMatrixClient(accountId?: string | null): MatrixClient | null {
const key = resolveAccountKey(accountId);
return activeClients.get(key) ?? null;
}

View File

@@ -0,0 +1,19 @@
// Matrix plugin module implements async lock behavior.
export type AsyncLock = <T>(fn: () => Promise<T>) => Promise<T>;
export function createAsyncLock(): AsyncLock {
let lock: Promise<void> = Promise.resolve();
return async function withLock<T>(fn: () => Promise<T>): Promise<T> {
const previous = lock;
let release: (() => void) | undefined;
lock = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await fn();
} finally {
release?.();
}
};
}

View File

@@ -0,0 +1,125 @@
// Matrix plugin module implements backup health behavior.
type MatrixRoomKeyBackupStatusLike = {
serverVersion: string | null;
activeVersion: string | null;
trusted: boolean | null;
matchesDecryptionKey: boolean | null;
decryptionKeyCached: boolean | null;
keyLoadAttempted: boolean;
keyLoadError: string | null;
};
type MatrixRoomKeyBackupIssueCode =
| "missing-server-backup"
| "key-load-failed"
| "key-not-loaded"
| "key-mismatch"
| "untrusted-signature"
| "inactive"
| "indeterminate"
| "ok";
type MatrixRoomKeyBackupIssue = {
code: MatrixRoomKeyBackupIssueCode;
summary: string;
message: string | null;
};
export function resolveMatrixRoomKeyBackupIssue(
backup: MatrixRoomKeyBackupStatusLike,
): MatrixRoomKeyBackupIssue {
if (!backup.serverVersion) {
return {
code: "missing-server-backup",
summary: "missing on server",
message: "no room-key backup exists on the homeserver",
};
}
if (backup.decryptionKeyCached === false) {
if (backup.keyLoadError) {
return {
code: "key-load-failed",
summary: "present but backup key unavailable on this device",
message: `backup decryption key could not be loaded from secret storage (${backup.keyLoadError})`,
};
}
if (backup.keyLoadAttempted) {
return {
code: "key-not-loaded",
summary: "present but backup key unavailable on this device",
message:
"backup decryption key is not loaded on this device (secret storage did not return a key)",
};
}
return {
code: "key-not-loaded",
summary: "present but backup key unavailable on this device",
message: "backup decryption key is not loaded on this device",
};
}
if (backup.matchesDecryptionKey === false) {
return {
code: "key-mismatch",
summary: "present but backup key mismatch on this device",
message: "backup key mismatch (this device does not have the matching backup decryption key)",
};
}
if (backup.trusted === false) {
return {
code: "untrusted-signature",
summary: "present but not trusted on this device",
message: "backup signature chain is not trusted by this device",
};
}
if (!backup.activeVersion) {
return {
code: "inactive",
summary: "present on server but inactive on this device",
message: "backup exists but is not active on this device",
};
}
if (
backup.trusted === null ||
backup.matchesDecryptionKey === null ||
backup.decryptionKeyCached === null
) {
return {
code: "indeterminate",
summary: "present but trust state unknown",
message: "backup trust state could not be fully determined",
};
}
return {
code: "ok",
summary: "active and trusted on this device",
message: null,
};
}
export function resolveMatrixRoomKeyBackupReadinessError(
backup: MatrixRoomKeyBackupStatusLike,
opts: {
allowUntrustedMatchingKey?: boolean;
requireServerBackup: boolean;
},
): string | null {
const issue = resolveMatrixRoomKeyBackupIssue(backup);
if (issue.code === "missing-server-backup") {
return opts.requireServerBackup ? "Matrix room key backup is missing on the homeserver." : null;
}
if (issue.code === "ok") {
return null;
}
if (
issue.code === "untrusted-signature" &&
opts.allowUntrustedMatchingKey === true &&
backup.matchesDecryptionKey === true &&
backup.decryptionKeyCached === true
) {
return null;
}
if (issue.message) {
return `Matrix room key backup is not usable: ${issue.message}.`;
}
return "Matrix room key backup is not usable on this device.";
}

View File

@@ -0,0 +1,89 @@
// Matrix tests cover client bootstrap plugin behavior.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
createMockMatrixClient,
matrixClientResolverMocks,
primeMatrixClientResolverMocks,
} from "./client-resolver.test-helpers.js";
const {
getMatrixRuntimeMock,
getActiveMatrixClientMock,
acquireSharedMatrixClientMock,
releaseSharedClientInstanceMock,
isBunRuntimeMock,
resolveMatrixAuthContextMock,
} = matrixClientResolverMocks;
const TEST_CFG = {};
vi.mock("../runtime.js", () => ({
getMatrixRuntime: () => getMatrixRuntimeMock(),
}));
vi.mock("./active-client.js", () => ({
getActiveMatrixClient: (...args: unknown[]) => getActiveMatrixClientMock(...args),
}));
vi.mock("./client.js", () => ({
acquireSharedMatrixClient: (...args: unknown[]) => acquireSharedMatrixClientMock(...args),
isBunRuntime: () => isBunRuntimeMock(),
resolveMatrixAuthContext: resolveMatrixAuthContextMock,
}));
vi.mock("./client/shared.js", () => ({
releaseSharedClientInstance: (...args: unknown[]) => releaseSharedClientInstanceMock(...args),
}));
let resolveRuntimeMatrixClientWithReadiness: typeof import("./client-bootstrap.js").resolveRuntimeMatrixClientWithReadiness;
let withResolvedRuntimeMatrixClient: typeof import("./client-bootstrap.js").withResolvedRuntimeMatrixClient;
describe("client bootstrap", () => {
beforeAll(async () => {
({ resolveRuntimeMatrixClientWithReadiness, withResolvedRuntimeMatrixClient } =
await import("./client-bootstrap.js"));
});
beforeEach(() => {
primeMatrixClientResolverMocks({ resolved: {} });
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("releases leased shared clients when readiness setup fails", async () => {
const sharedClient = createMockMatrixClient();
vi.mocked(sharedClient["prepareForOneOff"]).mockRejectedValue(new Error("prepare failed"));
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
await expect(
resolveRuntimeMatrixClientWithReadiness({
cfg: TEST_CFG,
accountId: "default",
readiness: "prepared",
}),
).rejects.toThrow("prepare failed");
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
});
it("releases leased shared clients when the wrapped action throws during readiness", async () => {
const sharedClient = createMockMatrixClient();
vi.mocked(sharedClient["start"]).mockRejectedValue(new Error("start failed"));
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
await expect(
withResolvedRuntimeMatrixClient(
{
cfg: TEST_CFG,
accountId: "default",
readiness: "started",
},
async () => "ok",
),
).rejects.toThrow("start failed");
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
});
});

View File

@@ -0,0 +1,165 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
// Matrix plugin module implements client bootstrap behavior.
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { CoreConfig } from "../types.js";
import { getActiveMatrixClient } from "./active-client.js";
import { isBunRuntime } from "./client/runtime.js";
import type { MatrixClient } from "./sdk.js";
type ResolvedRuntimeMatrixClient = {
client: MatrixClient;
stopOnDone: boolean;
cleanup?: (mode: ResolvedRuntimeMatrixClientStopMode) => Promise<void>;
};
type MatrixRuntimeClientReadiness = "none" | "prepared" | "started";
type ResolvedRuntimeMatrixClientStopMode = "stop" | "persist" | "discard";
type MatrixResolvedClientHook = (
client: MatrixClient,
context: { preparedByDefault: boolean },
) => Promise<void> | void;
const loadMatrixSharedClientRuntimeDeps = createLazyRuntimeModule(() =>
Promise.all([import("./client.js"), import("./client/shared.js")]).then(
([clientModule, sharedModule]) => ({
acquireSharedMatrixClient: clientModule.acquireSharedMatrixClient,
resolveMatrixAuthContext: clientModule.resolveMatrixAuthContext,
releaseSharedClientInstance: sharedModule.releaseSharedClientInstance,
}),
),
);
async function ensureResolvedClientReadiness(params: {
client: MatrixClient;
readiness?: MatrixRuntimeClientReadiness;
preparedByDefault: boolean;
}): Promise<void> {
if (params.readiness === "started") {
await params.client.start();
return;
}
if (params.readiness === "prepared" || (!params.readiness && params.preparedByDefault)) {
await params.client.prepareForOneOff();
}
}
function ensureMatrixNodeRuntime() {
if (isBunRuntime()) {
throw new Error("Matrix support requires Node (bun runtime not supported)");
}
}
async function resolveRuntimeMatrixClient(opts: {
client?: MatrixClient;
cfg?: CoreConfig;
timeoutMs?: number;
accountId?: string | null;
onResolved?: MatrixResolvedClientHook;
}): Promise<ResolvedRuntimeMatrixClient> {
ensureMatrixNodeRuntime();
if (opts.client) {
await opts.onResolved?.(opts.client, { preparedByDefault: false });
return { client: opts.client, stopOnDone: false };
}
if (!opts.cfg) {
throw new Error(
"Matrix runtime client requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
);
}
const cfg = requireRuntimeConfig(opts.cfg, "Matrix runtime client") as CoreConfig;
const { acquireSharedMatrixClient, releaseSharedClientInstance, resolveMatrixAuthContext } =
await loadMatrixSharedClientRuntimeDeps();
const authContext = resolveMatrixAuthContext({
cfg,
accountId: opts.accountId,
});
const active = getActiveMatrixClient(authContext.accountId);
if (active) {
await opts.onResolved?.(active, { preparedByDefault: false });
return { client: active, stopOnDone: false };
}
const client = await acquireSharedMatrixClient({
cfg,
timeoutMs: opts.timeoutMs,
accountId: authContext.accountId,
startClient: false,
});
try {
await opts.onResolved?.(client, { preparedByDefault: true });
} catch (err) {
await releaseSharedClientInstance(client, "stop");
throw err;
}
return {
client,
stopOnDone: true,
cleanup: async (mode) => {
await releaseSharedClientInstance(client, mode);
},
};
}
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,
preparedByDefault: context.preparedByDefault,
});
},
});
}
export async function stopResolvedRuntimeMatrixClient(
resolved: ResolvedRuntimeMatrixClient,
mode: ResolvedRuntimeMatrixClientStopMode = "stop",
): Promise<void> {
if (!resolved.stopOnDone) {
return;
}
if (resolved.cleanup) {
await resolved.cleanup(mode);
return;
}
if (mode === "persist") {
await resolved.client.stopAndPersist();
return;
}
if (mode === "discard") {
resolved.client.stopWithoutPersist();
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

@@ -0,0 +1,164 @@
// Matrix helper module supports client resolver helpers behavior.
import { expect, vi, type Mock } from "vitest";
import type { MatrixClient } from "./sdk.js";
type MatrixClientResolverMocks = {
loadConfigMock: Mock<() => unknown>;
getMatrixRuntimeMock: Mock<() => unknown>;
getActiveMatrixClientMock: Mock<(...args: unknown[]) => MatrixClient | null>;
acquireSharedMatrixClientMock: Mock<(...args: unknown[]) => Promise<MatrixClient>>;
releaseSharedClientInstanceMock: Mock<(...args: unknown[]) => Promise<boolean>>;
isBunRuntimeMock: Mock<() => boolean>;
resolveMatrixAuthContextMock: Mock<
(params: { cfg: unknown; accountId?: string | null }) => unknown
>;
};
export const matrixClientResolverMocks: MatrixClientResolverMocks = {
loadConfigMock: vi.fn(() => ({})),
getMatrixRuntimeMock: vi.fn(),
getActiveMatrixClientMock: vi.fn(),
acquireSharedMatrixClientMock: vi.fn(),
releaseSharedClientInstanceMock: vi.fn(),
isBunRuntimeMock: vi.fn(() => false),
resolveMatrixAuthContextMock: vi.fn(),
};
vi.mock("openclaw/plugin-sdk/plugin-config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-config-runtime")>(
"openclaw/plugin-sdk/plugin-config-runtime",
);
return {
...actual,
requireRuntimeConfig: vi.fn((cfg: unknown) => {
if (cfg) {
return cfg;
}
return matrixClientResolverMocks.loadConfigMock();
}),
};
});
export function createMockMatrixClient(): MatrixClient {
return {
prepareForOneOff: vi.fn(async () => undefined),
start: vi.fn(async () => undefined),
stop: vi.fn(() => undefined),
stopAndPersist: vi.fn(async () => undefined),
stopWithoutPersist: vi.fn(() => undefined),
} as unknown as MatrixClient;
}
export function primeMatrixClientResolverMocks(params?: {
cfg?: unknown;
accountId?: string;
resolved?: Record<string, unknown>;
auth?: Record<string, unknown>;
client?: MatrixClient;
}): MatrixClient {
const {
loadConfigMock,
getMatrixRuntimeMock,
getActiveMatrixClientMock,
acquireSharedMatrixClientMock,
releaseSharedClientInstanceMock,
isBunRuntimeMock,
resolveMatrixAuthContextMock,
} = matrixClientResolverMocks;
const cfg = params?.cfg ?? {};
const accountId = params?.accountId ?? "default";
const defaultResolved = {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
password: undefined,
deviceId: "DEVICE123",
encryption: false,
};
const client = params?.client ?? createMockMatrixClient();
vi.clearAllMocks();
loadConfigMock.mockReturnValue(cfg);
getMatrixRuntimeMock.mockReturnValue({
config: {
current: loadConfigMock,
},
});
getActiveMatrixClientMock.mockReturnValue(null);
isBunRuntimeMock.mockReturnValue(false);
releaseSharedClientInstanceMock.mockReset().mockResolvedValue(true);
resolveMatrixAuthContextMock.mockImplementation(
({
cfg: explicitCfg,
accountId: explicitAccountId,
}: {
cfg: unknown;
accountId?: string | null;
}) => ({
cfg: explicitCfg,
env: process.env,
accountId: explicitAccountId ?? accountId,
resolved: {
...defaultResolved,
...params?.resolved,
},
}),
);
acquireSharedMatrixClientMock.mockResolvedValue(client);
return client;
}
export async function expectOneOffSharedMatrixClient(params?: {
cfg?: unknown;
accountId?: string;
timeoutMs?: number;
prepareForOneOffCalls?: number;
startCalls?: number;
releaseMode?: "persist" | "stop" | "discard";
}) {
const {
getActiveMatrixClientMock,
acquireSharedMatrixClientMock,
releaseSharedClientInstanceMock,
} = matrixClientResolverMocks;
const accountId = params?.accountId ?? "default";
const prepareForOneOffCalls = params?.prepareForOneOffCalls ?? 1;
const startCalls = params?.startCalls ?? 0;
const releaseMode = params?.releaseMode ?? "stop";
expect(getActiveMatrixClientMock).toHaveBeenCalledWith(accountId);
expect(acquireSharedMatrixClientMock).toHaveBeenCalledTimes(1);
expect(acquireSharedMatrixClientMock).toHaveBeenCalledWith({
cfg: params?.cfg ?? {},
timeoutMs: params?.timeoutMs,
accountId,
startClient: false,
});
const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value;
expect(sharedClient.prepareForOneOff).toHaveBeenCalledTimes(prepareForOneOffCalls);
expect(sharedClient.start).toHaveBeenCalledTimes(startCalls);
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, releaseMode);
return sharedClient;
}
export function expectExplicitMatrixClientConfig(params: { cfg: unknown; accountId?: string }) {
const { getMatrixRuntimeMock, resolveMatrixAuthContextMock, acquireSharedMatrixClientMock } =
matrixClientResolverMocks;
const accountId = params.accountId ?? "default";
expect(getMatrixRuntimeMock).not.toHaveBeenCalled();
expect(resolveMatrixAuthContextMock).toHaveBeenCalledWith({
cfg: params.cfg,
accountId,
});
expect(acquireSharedMatrixClientMock).toHaveBeenCalledWith({
cfg: params.cfg,
timeoutMs: undefined,
accountId,
startClient: false,
});
}

View File

@@ -0,0 +1,863 @@
// Matrix tests cover client plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installMatrixTestRuntime } from "../test-runtime.js";
import type { CoreConfig } from "../types.js";
import {
backfillMatrixAuthDeviceIdAfterStartup,
resolveMatrixAuth,
setMatrixAuthClientDepsForTest,
} from "./client/config.js";
import * as credentialsReadModule from "./credentials-read.js";
const saveMatrixCredentialsMock = vi.hoisted(() => vi.fn());
const saveBackfilledMatrixDeviceIdMock = vi.hoisted(() => vi.fn(async () => "saved"));
const touchMatrixCredentialsMock = vi.hoisted(() => vi.fn());
const repairCurrentTokenStorageMetaDeviceIdMock = vi.hoisted(() => vi.fn());
const resolveConfiguredSecretInputStringMock = vi.hoisted(() => vi.fn());
vi.mock("./credentials-read.js", () => ({
loadMatrixCredentials: vi.fn(() => null),
credentialsMatchConfig: vi.fn(() => false),
}));
vi.mock("./credentials-write.runtime.js", () => ({
saveBackfilledMatrixDeviceId: saveBackfilledMatrixDeviceIdMock,
saveMatrixCredentials: saveMatrixCredentialsMock,
touchMatrixCredentials: touchMatrixCredentialsMock,
}));
vi.mock("./client/storage.js", async () => {
const actual = await vi.importActual<typeof import("./client/storage.js")>("./client/storage.js");
return {
...actual,
repairCurrentTokenStorageMetaDeviceId: repairCurrentTokenStorageMetaDeviceIdMock,
};
});
vi.mock("./client/config-secret-input.runtime.js", () => ({
resolveConfiguredSecretInputString: resolveConfiguredSecretInputStringMock,
}));
const ensureMatrixSdkLoggingConfiguredMock = vi.fn();
const matrixDoRequestMock = vi.fn();
class MockMatrixClient {
async doRequest(...args: unknown[]) {
return await matrixDoRequestMock(...args);
}
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null) {
throw new Error(`${label} was not an object`);
}
return value as Record<string, unknown>;
}
function expectRecordFields(record: Record<string, unknown>, fields: Record<string, unknown>) {
for (const [key, value] of Object.entries(fields)) {
expect(record[key]).toEqual(value);
}
}
function expectAuthFields(auth: unknown, fields: Record<string, unknown>) {
expectRecordFields(requireRecord(auth, "Matrix auth"), fields);
}
function mockCall(mock: ReturnType<typeof vi.fn>, index = 0): unknown[] {
const call = mock.mock.calls.at(index);
if (!call) {
throw new Error(`missing mock call ${index}`);
}
return call;
}
function expectSavedCredentials(
mock: ReturnType<typeof vi.fn>,
fields: Record<string, unknown>,
accountId: string,
) {
const call = mockCall(mock);
expectRecordFields(requireRecord(call[0], "Matrix credentials"), fields);
requireRecord(call[1], "Matrix credential save options");
expect(call[2]).toBe(accountId);
}
function expectMatrixLoginCall(fields: Record<string, unknown>) {
const call = mockCall(matrixDoRequestMock);
expect(call[0]).toBe("POST");
expect(call[1]).toBe("/_matrix/client/v3/login");
expect(call[2]).toBeUndefined();
expectRecordFields(requireRecord(call[3], "Matrix login body"), fields);
}
describe("resolveMatrixAuth", () => {
beforeEach(() => {
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReset();
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue(null);
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReset();
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(false);
saveMatrixCredentialsMock.mockReset();
saveBackfilledMatrixDeviceIdMock.mockReset().mockResolvedValue("saved");
touchMatrixCredentialsMock.mockReset();
repairCurrentTokenStorageMetaDeviceIdMock.mockReset().mockReturnValue(true);
resolveConfiguredSecretInputStringMock.mockReset().mockResolvedValue({});
ensureMatrixSdkLoggingConfiguredMock.mockReset();
matrixDoRequestMock.mockReset();
setMatrixAuthClientDepsForTest({
MatrixClient: MockMatrixClient as unknown as typeof import("./sdk.js").MatrixClient,
ensureMatrixSdkLoggingConfigured: ensureMatrixSdkLoggingConfiguredMock,
retryMinDelayMs: 0,
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
setMatrixAuthClientDepsForTest(undefined);
});
it("uses the hardened client request path for password login and persists deviceId", async () => {
matrixDoRequestMock.mockResolvedValue({
access_token: "tok-123",
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret", // pragma: allowlist secret
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expectMatrixLoginCall({
type: "m.login.password",
identifier: { type: "m.id.user", user: "@bot:example.org" },
password: "secret",
});
expectAuthFields(auth, {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
});
expectSavedCredentials(
saveMatrixCredentialsMock,
{
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
},
"default",
);
});
it("surfaces password login errors when account credentials are invalid", async () => {
matrixDoRequestMock.mockRejectedValueOnce(new Error("Invalid username or password"));
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret", // pragma: allowlist secret
},
},
} as CoreConfig;
await expect(
resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
}),
).rejects.toThrow("Invalid username or password");
expectMatrixLoginCall({
type: "m.login.password",
identifier: { type: "m.id.user", user: "@bot:example.org" },
password: "secret",
});
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
});
it("uses cached matching credentials when access token is not configured", async () => {
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret", // pragma: allowlist secret
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expectAuthFields(auth, {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
});
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
});
it("uses cached matching credentials for env-backed named accounts without fresh auth", async () => {
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_USER_ID: "@ops:example.org",
} as NodeJS.ProcessEnv;
const auth = await resolveMatrixAuth({
cfg,
env,
accountId: "ops",
});
expectAuthFields(auth, {
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
});
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
});
it("rejects embedded credentials in Matrix homeserver URLs", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://user:pass@matrix.example.org",
accessToken: "tok-123",
},
},
} as CoreConfig;
await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
"Matrix homeserver URL must not include embedded credentials",
);
});
it("falls back to config deviceId when cached credentials are missing it", async () => {
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(auth.deviceId).toBe("DEVICE123");
expect(auth.accountId).toBe("default");
expectSavedCredentials(
saveMatrixCredentialsMock,
{
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
},
"default",
);
});
it("carries the private-network opt-in through Matrix auth resolution", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "http://127.0.0.1:8008",
allowPrivateNetwork: true,
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expectAuthFields(auth, {
homeserver: "http://127.0.0.1:8008",
allowPrivateNetwork: true,
ssrfPolicy: { allowPrivateNetwork: true },
});
});
it("resolves token-only non-default account userId from whoami instead of inheriting the base user", async () => {
matrixDoRequestMock.mockResolvedValue({
user_id: "@ops:example.org",
device_id: "OPSDEVICE",
});
const cfg = {
channels: {
matrix: {
userId: "@base:example.org",
homeserver: "https://matrix.example.org",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
accountId: "ops",
});
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expect(auth.userId).toBe("@ops:example.org");
expect(auth.deviceId).toBe("OPSDEVICE");
});
it("uses named-account password auth instead of inheriting the base access token", async () => {
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue(null);
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(false);
matrixDoRequestMock.mockResolvedValue({
access_token: "ops-token",
user_id: "@ops:example.org",
device_id: "OPSDEVICE",
});
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "legacy-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
password: "ops-pass", // pragma: allowlist secret
},
},
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
accountId: "ops",
});
expectMatrixLoginCall({
type: "m.login.password",
identifier: { type: "m.id.user", user: "@ops:example.org" },
password: "ops-pass",
});
expectAuthFields(auth, {
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
deviceId: "OPSDEVICE",
});
});
it("resolves missing whoami identity fields for token auth", async () => {
matrixDoRequestMock.mockResolvedValue({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "tok-123",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expectAuthFields(auth, {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
});
});
it("retries token whoami when startup auth hits a transient network error", async () => {
matrixDoRequestMock
.mockRejectedValueOnce(
Object.assign(new TypeError("fetch failed"), {
cause: Object.assign(new Error("read ECONNRESET"), {
code: "ECONNRESET",
}),
}),
)
.mockResolvedValue({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "tok-123",
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(matrixDoRequestMock).toHaveBeenCalledTimes(2);
expectAuthFields(auth, {
userId: "@bot:example.org",
deviceId: "DEVICE123",
});
});
it("does not call whoami when token auth already has a userId and only deviceId is missing", async () => {
matrixDoRequestMock.mockRejectedValue(new Error("whoami should not be called"));
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(matrixDoRequestMock).not.toHaveBeenCalled();
expectAuthFields(auth, {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: undefined,
encryption: true,
});
});
it("retries password login when startup auth hits a transient network error", async () => {
matrixDoRequestMock
.mockRejectedValueOnce(
Object.assign(new TypeError("fetch failed"), {
cause: Object.assign(new Error("socket hang up"), {
code: "ECONNRESET",
}),
}),
)
.mockResolvedValue({
access_token: "tok-123",
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret", // pragma: allowlist secret
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(matrixDoRequestMock).toHaveBeenCalledTimes(2);
expectAuthFields(auth, {
accessToken: "tok-123",
deviceId: "DEVICE123",
});
});
it("best-effort backfills a missing deviceId after startup", async () => {
matrixDoRequestMock.mockResolvedValue({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const deviceId = await backfillMatrixAuthDeviceIdAfterStartup({
auth: {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
env: {} as NodeJS.ProcessEnv,
});
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expectSavedCredentials(
saveBackfilledMatrixDeviceIdMock,
{
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
},
"default",
);
const repairMeta = requireRecord(
mockCall(repairCurrentTokenStorageMetaDeviceIdMock).at(0),
"repair metadata",
);
expectRecordFields(repairMeta, {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
accountId: "default",
deviceId: "DEVICE123",
});
requireRecord(repairMeta.env, "repair env");
expect(repairCurrentTokenStorageMetaDeviceIdMock.mock.invocationCallOrder[0]).toBeLessThan(
saveBackfilledMatrixDeviceIdMock.mock.invocationCallOrder[0],
);
expect(deviceId).toBe("DEVICE123");
});
it("skips deviceId backfill when auth already includes it", async () => {
const deviceId = await backfillMatrixAuthDeviceIdAfterStartup({
auth: {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
},
env: {} as NodeJS.ProcessEnv,
});
expect(matrixDoRequestMock).not.toHaveBeenCalled();
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
expect(saveBackfilledMatrixDeviceIdMock).not.toHaveBeenCalled();
expect(repairCurrentTokenStorageMetaDeviceIdMock).not.toHaveBeenCalled();
expect(deviceId).toBe("DEVICE123");
});
it("fails before saving repaired credentials when storage metadata repair fails", async () => {
matrixDoRequestMock.mockResolvedValue({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
repairCurrentTokenStorageMetaDeviceIdMock.mockReturnValue(false);
await expect(
backfillMatrixAuthDeviceIdAfterStartup({
auth: {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
env: {} as NodeJS.ProcessEnv,
}),
).rejects.toThrow("Matrix deviceId backfill failed to repair current-token storage metadata");
expect(saveBackfilledMatrixDeviceIdMock).not.toHaveBeenCalled();
});
it("skips stale deviceId backfill writes after newer credentials take over", async () => {
matrixDoRequestMock.mockResolvedValue({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-new",
deviceId: "DEVICE999",
createdAt: "2026-03-01T00:00:00.000Z",
});
const deviceId = await backfillMatrixAuthDeviceIdAfterStartup({
auth: {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-old",
},
env: {} as NodeJS.ProcessEnv,
});
expect(deviceId).toBeUndefined();
expect(repairCurrentTokenStorageMetaDeviceIdMock).not.toHaveBeenCalled();
expect(saveBackfilledMatrixDeviceIdMock).not.toHaveBeenCalled();
});
it("skips persistence when startup backfill is aborted before whoami resolves", async () => {
let resolveWhoami: ((value: { user_id: string; device_id: string }) => void) | undefined;
matrixDoRequestMock.mockImplementation(
() =>
new Promise((resolve) => {
resolveWhoami = resolve;
}),
);
const abortController = new AbortController();
const backfillPromise = backfillMatrixAuthDeviceIdAfterStartup({
auth: {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
env: {} as NodeJS.ProcessEnv,
abortSignal: abortController.signal,
});
await vi.waitFor(() => {
expect(resolveWhoami).toBeTypeOf("function");
});
abortController.abort();
resolveWhoami?.({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
await expect(backfillPromise).resolves.toBeUndefined();
expect(repairCurrentTokenStorageMetaDeviceIdMock).not.toHaveBeenCalled();
expect(saveBackfilledMatrixDeviceIdMock).not.toHaveBeenCalled();
});
it("resolves configured accessToken SecretRefs during Matrix auth", async () => {
matrixDoRequestMock.mockResolvedValue({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
resolveConfiguredSecretInputStringMock.mockResolvedValue({ value: "resolved-token" });
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: { source: "file", provider: "matrix-file", id: "value" },
},
},
secrets: {
providers: {
"matrix-file": {
source: "file",
path: "/tmp/matrix-token.txt",
mode: "singleValue",
},
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expectRecordFields(
requireRecord(mockCall(resolveConfiguredSecretInputStringMock).at(0), "secret request"),
{
config: cfg,
value: { source: "file", provider: "matrix-file", id: "value" },
path: "channels.matrix.accessToken",
},
);
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expectAuthFields(auth, {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "resolved-token",
deviceId: "DEVICE123",
});
});
it("does not resolve inactive password SecretRefs when scoped token auth wins", async () => {
matrixDoRequestMock.mockResolvedValue({
user_id: "@ops:example.org",
device_id: "OPSDEVICE",
});
const cfg = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://matrix.example.org",
password: { source: "env", provider: "default", id: "MATRIX_OPS_PASSWORD" },
},
},
},
},
secrets: {
defaults: {
env: "default",
},
},
} as CoreConfig;
installMatrixTestRuntime({ cfg });
const auth = await resolveMatrixAuth({
cfg,
env: {
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv,
accountId: "ops",
});
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expectAuthFields(auth, {
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
deviceId: "OPSDEVICE",
password: undefined,
});
});
it("uses config deviceId with cached credentials when token is loaded from cache", async () => {
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
deviceId: "DEVICE123",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expectAuthFields(auth, {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
});
});
it("falls back to the sole configured account when no global homeserver is set", async () => {
const cfg = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
deviceId: "OPSDEVICE",
encryption: true,
},
},
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expectAuthFields(auth, {
accountId: "ops",
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
deviceId: "OPSDEVICE",
encryption: true,
});
expectSavedCredentials(
saveMatrixCredentialsMock,
{
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
deviceId: "OPSDEVICE",
},
"ops",
);
});
});

View File

@@ -0,0 +1,24 @@
// Matrix plugin module implements client behavior.
export type { MatrixAuth } from "./client/types.js";
export { isBunRuntime } from "./client/runtime.js";
export { getMatrixScopedEnvVarNames } from "../env-vars.js";
export {
backfillMatrixAuthDeviceIdAfterStartup,
hasReadyMatrixEnvAuth,
resolveMatrixEnvAuthReadiness,
resolveMatrixConfigForAccount,
resolveScopedMatrixEnvConfig,
resolveMatrixAuth,
resolveMatrixAuthContext,
resolveValidatedMatrixHomeserverUrl,
validateMatrixHomeserverUrl,
} from "./client/config.js";
export { createMatrixClient } from "./client/create-client.js";
export {
acquireSharedMatrixClient,
removeSharedClientInstance,
releaseSharedClientInstance,
resolveSharedMatrixClient,
stopSharedClientForAccount,
stopSharedClientInstance,
} from "./client/shared.js";

View File

@@ -0,0 +1,10 @@
// Matrix API module exposes the plugin public contract.
export {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
export {
isPrivateNetworkOptInEnabled,
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
} from "openclaw/plugin-sdk/ssrf-runtime";

View File

@@ -0,0 +1,2 @@
// Matrix helper module supports config secret input behavior.
export { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime";

View File

@@ -0,0 +1,741 @@
// Matrix tests cover config plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LookupFn } from "../../runtime-api.js";
import { installMatrixTestRuntime } from "../../test-runtime.js";
import type { CoreConfig } from "../../types.js";
import {
getMatrixScopedEnvVarNames,
resolveMatrixConfigForAccount,
resolveMatrixAuthContext,
resolveValidatedMatrixHomeserverUrl,
validateMatrixHomeserverUrl,
} from "./config.js";
function createLookupFn(addresses: Array<{ address: string; family: number }>): LookupFn {
return vi.fn(async (_hostname: string, options?: unknown) => {
if (typeof options === "number" || !options || !(options as { all?: boolean }).all) {
return addresses[0];
}
return addresses;
}) as unknown as LookupFn;
}
function resolveDefaultMatrixAuthContext(
cfg: CoreConfig,
env: NodeJS.ProcessEnv = {} as NodeJS.ProcessEnv,
) {
return resolveMatrixAuthContext({ cfg, env });
}
beforeEach(() => {
installMatrixTestRuntime();
});
describe("Matrix auth/config live surfaces", () => {
it("prefers config over env", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
deviceName: "CfgDevice",
initialSyncLimit: 5,
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_ACCESS_TOKEN: "env-token",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
expect(resolved).toEqual({
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
deviceId: undefined,
deviceName: "CfgDevice",
initialSyncLimit: 5,
encryption: false,
});
});
it("uses env when config is missing", () => {
const cfg = {} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_ACCESS_TOKEN: "env-token",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_ID: "ENVDEVICE",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
expect(resolved.homeserver).toBe("https://env.example.org");
expect(resolved.userId).toBe("@env:example.org");
expect(resolved.accessToken).toBe("env-token");
expect(resolved.password).toBe("env-pass");
expect(resolved.deviceId).toBe("ENVDEVICE");
expect(resolved.deviceName).toBe("EnvDevice");
expect(resolved.initialSyncLimit).toBeUndefined();
expect(resolved.encryption).toBe(false);
});
it("ignores non-finite initial sync limits", () => {
const cfg = {
channels: {
matrix: {
initialSyncLimit: Number.NaN,
accounts: {
ops: {
initialSyncLimit: Number.POSITIVE_INFINITY,
},
},
},
},
} as unknown as CoreConfig;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
expect(resolved.initialSyncLimit).toBeUndefined();
});
it("resolves accessToken SecretRef against the provided env", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
},
},
secrets: {
defaults: {
env: "default",
},
},
} as CoreConfig;
const env = {
MATRIX_ACCESS_TOKEN: "env-token",
} as NodeJS.ProcessEnv;
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
expect(resolved.accessToken).toBe("env-token");
});
it("resolves password SecretRef against the provided env", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
password: { source: "env", provider: "default", id: "MATRIX_PASSWORD" },
},
},
secrets: {
defaults: {
env: "default",
},
},
} as CoreConfig;
const env = {
MATRIX_PASSWORD: "env-pass",
} as NodeJS.ProcessEnv;
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
expect(resolved.password).toBe("env-pass");
});
it("resolves account accessToken SecretRef against the provided env", () => {
const cfg = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://ops.example.org",
accessToken: { source: "env", provider: "default", id: "MATRIX_OPS_ACCESS_TOKEN" },
},
},
},
},
secrets: {
defaults: {
env: "default",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.accessToken).toBe("ops-token");
});
it("does not resolve account password SecretRefs when scoped token auth is configured", () => {
const cfg = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://ops.example.org",
password: { source: "env", provider: "default", id: "MATRIX_OPS_PASSWORD" },
},
},
},
},
secrets: {
defaults: {
env: "default",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.accessToken).toBe("ops-token");
expect(resolved.password).toBeUndefined();
});
it("keeps unresolved accessToken SecretRef errors when env fallback is missing", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
},
},
secrets: {
defaults: {
env: "default",
},
},
} as CoreConfig;
expect(() => resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv)).toThrow(
/channels\.matrix\.accessToken: unresolved SecretRef "env:default:MATRIX_ACCESS_TOKEN"/i,
);
});
it("does not bypass env provider allowlists during startup fallback", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
accessToken: { source: "env", provider: "matrix-env", id: "MATRIX_ACCESS_TOKEN" },
},
},
secrets: {
providers: {
"matrix-env": {
source: "env",
allowlist: ["OTHER_MATRIX_ACCESS_TOKEN"],
},
},
},
} as CoreConfig;
expect(() =>
resolveDefaultMatrixAuthContext(cfg, {
MATRIX_ACCESS_TOKEN: "env-token",
} as NodeJS.ProcessEnv),
).toThrow(/not allowlisted in secrets\.providers\.matrix-env\.allowlist/i);
});
it("leaves non-env SecretRef access tokens unresolved", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
accessToken: { source: "file", provider: "matrix-file", id: "value" },
},
},
secrets: {
providers: {
"matrix-file": {
source: "file",
path: "/tmp/matrix-token",
},
},
},
} as CoreConfig;
expect(
resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv).resolved.accessToken,
).toBeUndefined();
});
it("uses account-scoped env vars for non-default accounts before global env", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://global.example.org",
MATRIX_ACCESS_TOKEN: "global-token",
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
MATRIX_OPS_DEVICE_NAME: "Ops Device",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.homeserver).toBe("https://ops.example.org");
expect(resolved.accessToken).toBe("ops-token");
expect(resolved.deviceName).toBe("Ops Device");
});
it("uses collision-free scoped env var names for normalized account ids", () => {
expect(getMatrixScopedEnvVarNames("ops-prod").accessToken).toBe(
"MATRIX_OPS_X2D_PROD_ACCESS_TOKEN",
);
expect(getMatrixScopedEnvVarNames("ops_prod").accessToken).toBe(
"MATRIX_OPS_X5F_PROD_ACCESS_TOKEN",
);
});
it("prefers channels.matrix.accounts.default over global env for the default account", () => {
const cfg = {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.gumadeiras.com",
userId: "@pinguini:matrix.gumadeiras.com",
password: "cfg-pass", // pragma: allowlist secret
deviceName: "OpenClaw Gateway Pinguini",
encryption: true,
},
},
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixAuthContext({ cfg, env });
expect(resolved.accountId).toBe("default");
expect(resolved.resolved).toEqual({
homeserver: "https://matrix.gumadeiras.com",
userId: "@pinguini:matrix.gumadeiras.com",
accessToken: undefined,
password: "cfg-pass",
deviceId: undefined,
deviceName: "OpenClaw Gateway Pinguini",
initialSyncLimit: undefined,
encryption: true,
allowPrivateNetwork: undefined,
ssrfPolicy: undefined,
dispatcherPolicy: undefined,
});
});
it("ignores typoed defaultAccount values that do not map to a real Matrix account", () => {
const cfg = {
channels: {
matrix: {
defaultAccount: "ops",
homeserver: "https://legacy.example.org",
accessToken: "legacy-token",
},
},
} as CoreConfig;
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(() => resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv })).toThrow(
/channels\.matrix\.defaultAccount.*--account <id>/i,
);
});
it('uses a named "default" account implicitly when multiple Matrix accounts exist', () => {
const cfg = {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.default.example.org",
accessToken: "default-token",
},
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
"default",
);
});
it("does not materialize a default account from shared top-level defaults alone", () => {
const cfg = {
channels: {
matrix: {
name: "Shared Defaults",
accounts: {
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
});
it("does not materialize a default account from partial top-level auth defaults", () => {
const cfg = {
channels: {
matrix: {
accessToken: "shared-token",
accounts: {
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
});
it('uses the injected env-backed "default" Matrix account when implicit selection is available', () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_ACCESS_TOKEN: "default-token",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("default");
});
it("does not materialize a default env account from partial global auth fields", () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
const env = {
MATRIX_ACCESS_TOKEN: "shared-token",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
});
it("does not materialize a default account from top-level homeserver plus userId alone", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@default:example.org",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
});
it("does not materialize a default env account from global homeserver plus userId alone", () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_USER_ID: "@default:example.org",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
});
it("keeps implicit selection for env-backed accounts that can use cached credentials", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_USER_ID: "@ops:example.org",
} as NodeJS.ProcessEnv;
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
});
it("rejects explicit non-default account ids that are neither configured nor scoped in env", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://legacy.example.org",
accessToken: "legacy-token",
accounts: {
ops: {
homeserver: "https://ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(() =>
resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv, accountId: "typo" }),
).toThrow(/Matrix account "typo" is not configured/i);
});
it("allows explicit non-default account ids backed only by scoped env vars", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://legacy.example.org",
accessToken: "legacy-token",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveMatrixAuthContext({ cfg, env, accountId: "ops" }).accountId).toBe("ops");
});
it("does not inherit the base deviceId for non-default accounts", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
accessToken: "base-token",
deviceId: "BASEDEVICE",
accounts: {
ops: {
homeserver: "https://ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
expect(resolved.deviceId).toBeUndefined();
});
it("does not inherit the base userId for non-default accounts", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
userId: "@base:example.org",
accessToken: "base-token",
accounts: {
ops: {
homeserver: "https://ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
expect(resolved.userId).toBe("");
});
it("does not inherit base or global auth secrets for non-default accounts", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
accessToken: "base-token",
password: "base-pass", // pragma: allowlist secret
deviceId: "BASEDEVICE",
accounts: {
ops: {
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
password: "ops-pass", // pragma: allowlist secret
},
},
},
},
} as CoreConfig;
const env = {
MATRIX_ACCESS_TOKEN: "global-token",
MATRIX_PASSWORD: "global-pass",
MATRIX_DEVICE_ID: "GLOBALDEVICE",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.accessToken).toBeUndefined();
expect(resolved.password).toBe("ops-pass");
expect(resolved.deviceId).toBeUndefined();
});
it("does not inherit a base password for non-default accounts", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
password: "base-pass", // pragma: allowlist secret
accounts: {
ops: {
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
},
},
},
},
} as CoreConfig;
const env = {
MATRIX_PASSWORD: "global-pass",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.password).toBeUndefined();
});
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",
);
expect(validateMatrixHomeserverUrl("http://127.0.0.1:8008")).toBe("http://127.0.0.1:8008");
expect(validateMatrixHomeserverUrl("http://[::ffff:127.0.0.1]:8008")).toBe(
"http://[::ffff:127.0.0.1]:8008",
);
});
it("accepts internal http homeservers only when private-network access is enabled", () => {
expect(() => validateMatrixHomeserverUrl("http://matrix-synapse:8008")).toThrow(
"Matrix homeserver must use https:// unless it targets a private or loopback host",
);
expect(
validateMatrixHomeserverUrl("http://matrix-synapse:8008", {
allowPrivateNetwork: true,
}),
).toBe("http://matrix-synapse:8008");
});
it("resolves an explicit proxy dispatcher from top-level Matrix config", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "tok-123",
proxy: "http://127.0.0.1:7890",
},
},
} as CoreConfig;
const resolved = resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv).resolved;
expect(resolved.dispatcherPolicy).toEqual({
mode: "explicit-proxy",
proxyUrl: "http://127.0.0.1:7890",
});
});
it("prefers account proxy overrides over top-level Matrix proxy config", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "base-token",
proxy: "http://127.0.0.1:7890",
accounts: {
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
proxy: "http://127.0.0.1:7891",
},
},
},
},
} as CoreConfig;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
expect(resolved.dispatcherPolicy).toEqual({
mode: "explicit-proxy",
proxyUrl: "http://127.0.0.1:7891",
});
});
it("rejects public http homeservers even when private-network access is enabled", async () => {
await expect(
resolveValidatedMatrixHomeserverUrl("http://matrix.example.org:8008", {
allowPrivateNetwork: true,
lookupFn: createLookupFn([{ address: "93.184.216.34", family: 4 }]),
}),
).rejects.toThrow(
"Matrix homeserver must use https:// unless it targets a private or loopback host",
);
});
it("accepts internal http hostnames when the private-network opt-in is explicit", async () => {
await expect(
resolveValidatedMatrixHomeserverUrl("http://localhost.localdomain:8008", {
dangerouslyAllowPrivateNetwork: true,
lookupFn: createLookupFn([{ address: "127.0.0.1", family: 4 }]),
}),
).resolves.toBe("http://localhost.localdomain:8008");
});
});

View File

@@ -0,0 +1,836 @@
// Matrix helper module supports config behavior.
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveOptionalIntegerOption } from "openclaw/plugin-sdk/number-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import { retryAsync } from "openclaw/plugin-sdk/retry-runtime";
import {
coerceSecretRef,
normalizeResolvedSecretInputString,
} from "openclaw/plugin-sdk/secret-input-runtime";
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
import {
requiresExplicitMatrixDefaultAccount,
resolveMatrixDefaultOrOnlyAccountId,
} from "../../account-selection.js";
import { resolveMatrixAccountStringValues } from "../../auth-precedence.js";
import { getMatrixScopedEnvVarNames } from "../../env-vars.js";
import type { CoreConfig } from "../../types.js";
import {
findMatrixAccountConfig,
resolveMatrixBaseConfig,
listNormalizedMatrixAccountIds,
} from "../account-config.js";
import { resolveMatrixConfigFieldPath } from "../config-paths.js";
import type { MatrixStoredCredentials } from "../credentials-read.js";
import {
DEFAULT_ACCOUNT_ID,
isPrivateNetworkOptInEnabled,
normalizeAccountId,
normalizeOptionalAccountId,
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
} from "./config-runtime-api.js";
import { resolveGlobalMatrixEnvConfig, resolveScopedMatrixEnvConfig } from "./env-auth.js";
import { repairCurrentTokenStorageMetaDeviceId } from "./storage.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
import { resolveValidatedMatrixHomeserverUrl } from "./url-validation.js";
type MatrixAuthClientDeps = {
MatrixClient: typeof import("../sdk.js").MatrixClient;
ensureMatrixSdkLoggingConfigured: typeof import("./logging.js").ensureMatrixSdkLoggingConfigured;
retryMinDelayMs?: number;
};
const loadDefaultMatrixAuthClientDeps = createLazyRuntimeModule(() =>
Promise.all([import("../sdk.js"), import("./logging.js")]).then(([sdkModule, loggingModule]) => ({
MatrixClient: sdkModule.MatrixClient,
ensureMatrixSdkLoggingConfigured: loggingModule.ensureMatrixSdkLoggingConfigured,
})),
);
let matrixAuthClientDepsForTest: MatrixAuthClientDeps | undefined;
const MATRIX_AUTH_REQUEST_RETRY_RE =
/\b(fetch failed|econnreset|econnrefused|enotfound|etimedout|ehostunreach|enetunreach|eai_again|und_err_|socket hang up|network|headers timeout|body timeout|connect timeout)\b/i;
export function setMatrixAuthClientDepsForTest(deps?: {
MatrixClient: typeof import("../sdk.js").MatrixClient;
ensureMatrixSdkLoggingConfigured: typeof import("./logging.js").ensureMatrixSdkLoggingConfigured;
retryMinDelayMs?: number;
}): void {
matrixAuthClientDepsForTest = deps;
}
async function loadMatrixAuthClientDeps(): Promise<MatrixAuthClientDeps> {
if (matrixAuthClientDepsForTest) {
return matrixAuthClientDepsForTest;
}
return await loadDefaultMatrixAuthClientDeps();
}
const loadMatrixCredentialsReadDeps = createLazyRuntimeModule(() =>
import("../credentials-read.js").then((credentialsReadModule) => ({
loadMatrixCredentials: credentialsReadModule.loadMatrixCredentials,
credentialsMatchConfig: credentialsReadModule.credentialsMatchConfig,
})),
);
const loadMatrixCredentialsWriteRuntime = createLazyRuntimeModule(
() => import("../credentials-write.runtime.js"),
);
const loadMatrixSecretInputDeps = createLazyRuntimeModule(() =>
import("./config-secret-input.runtime.js").then((runtime) => ({
resolveConfiguredSecretInputString: runtime.resolveConfiguredSecretInputString,
})),
);
function shouldRetryMatrixAuthRequest(err: unknown): boolean {
return MATRIX_AUTH_REQUEST_RETRY_RE.test(formatErrorMessage(err));
}
function isAbortSignalTriggered(signal?: AbortSignal): boolean {
return signal?.aborted === true;
}
function credentialsMatchBackfillAuthLineage(params: {
stored: MatrixStoredCredentials | null;
auth: Pick<MatrixAuth, "homeserver" | "userId" | "accessToken">;
}): boolean {
if (!params.stored) {
return true;
}
return (
params.stored.homeserver === params.auth.homeserver &&
params.stored.userId === params.auth.userId &&
params.stored.accessToken === params.auth.accessToken
);
}
async function retryMatrixAuthRequest<T>(label: string, run: () => Promise<T>): Promise<T> {
return await retryAsync(run, {
attempts: 3,
minDelayMs: matrixAuthClientDepsForTest?.retryMinDelayMs ?? 250,
maxDelayMs: 1_500,
jitter: 0.1,
label,
shouldRetry: (err) => shouldRetryMatrixAuthRequest(err),
});
}
async function fetchMatrixWhoamiIdentity(params: {
homeserver: string;
accessToken: string;
userId?: string;
ssrfPolicy?: MatrixResolvedConfig["ssrfPolicy"];
dispatcherPolicy?: PinnedDispatcherPolicy;
}): Promise<{
user_id?: string;
device_id?: string;
}> {
const { MatrixClient, ensureMatrixSdkLoggingConfigured } = await loadMatrixAuthClientDeps();
ensureMatrixSdkLoggingConfigured();
const tempClient = new MatrixClient(params.homeserver, params.accessToken, {
userId: params.userId,
ssrfPolicy: params.ssrfPolicy,
dispatcherPolicy: params.dispatcherPolicy,
});
return (await retryMatrixAuthRequest("matrix auth whoami", async () => {
return (await tempClient.doRequest("GET", "/_matrix/client/v3/account/whoami")) as {
user_id?: string;
device_id?: string;
};
})) as {
user_id?: string;
device_id?: string;
};
}
function readEnvSecretRefFallback(params: {
value: unknown;
env?: NodeJS.ProcessEnv;
config?: Pick<CoreConfig, "secrets">;
}): string | undefined {
const ref = coerceSecretRef(params.value, params.config?.secrets?.defaults);
if (!ref || ref.source !== "env" || !params.env) {
return undefined;
}
const providerConfig = params.config?.secrets?.providers?.[ref.provider];
if (providerConfig) {
if (providerConfig.source !== "env") {
throw new Error(
`Secret provider "${ref.provider}" has source "${providerConfig.source}" but ref requests "env".`,
);
}
if (providerConfig.allowlist && !providerConfig.allowlist.includes(ref.id)) {
throw new Error(
`Environment variable "${ref.id}" is not allowlisted in secrets.providers.${ref.provider}.allowlist.`,
);
}
} else if (ref.provider !== (params.config?.secrets?.defaults?.env?.trim() || "default")) {
throw new Error(
`Secret provider "${ref.provider}" is not configured (ref: ${ref.source}:${ref.provider}:${ref.id}).`,
);
}
const resolved = params.env[ref.id];
if (typeof resolved !== "string") {
return undefined;
}
const trimmed = resolved.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function clean(
value: unknown,
path: string,
opts?: {
env?: NodeJS.ProcessEnv;
config?: Pick<CoreConfig, "secrets">;
allowEnvSecretRefFallback?: boolean;
suppressSecretRef?: boolean;
},
): string {
const ref = coerceSecretRef(value, opts?.config?.secrets?.defaults);
if (opts?.suppressSecretRef && ref) {
return "";
}
const normalizedValue = opts?.allowEnvSecretRefFallback
? ref?.source === "env"
? (readEnvSecretRefFallback({
value,
env: opts.env,
config: opts.config,
}) ?? value)
: ref
? ""
: value
: value;
return (
normalizeResolvedSecretInputString({
value: normalizedValue,
path,
defaults: opts?.config?.secrets?.defaults,
}) ?? ""
);
}
type MatrixConfigStringField =
| "homeserver"
| "userId"
| "accessToken"
| "password"
| "deviceId"
| "deviceName";
function resolveMatrixBaseConfigFieldPath(field: MatrixConfigStringField): string {
return `channels.matrix.${field}`;
}
function shouldAllowEnvSecretRefFallback(field: MatrixConfigStringField): boolean {
return field === "accessToken" || field === "password";
}
type MatrixAuthSecretField = "accessToken" | "password";
type MatrixConfiguredAuthInput = {
value: unknown;
path: string;
};
function hasConfiguredSecretInputValue(value: unknown, cfg: Pick<CoreConfig, "secrets">): boolean {
return (
(typeof value === "string" && value.trim().length > 0) ||
Boolean(coerceSecretRef(value, cfg.secrets?.defaults))
);
}
function hasConfiguredMatrixAccessTokenSource(params: {
cfg: CoreConfig;
env: NodeJS.ProcessEnv;
accountId: string;
}): boolean {
const normalizedAccountId = normalizeAccountId(params.accountId);
const account = findMatrixAccountConfig(params.cfg, normalizedAccountId) ?? {};
const scopedAccessTokenVar = getMatrixScopedEnvVarNames(normalizedAccountId).accessToken;
if (
hasConfiguredSecretInputValue(account.accessToken, params.cfg) ||
clean(params.env[scopedAccessTokenVar], scopedAccessTokenVar).length > 0
) {
return true;
}
if (normalizedAccountId !== DEFAULT_ACCOUNT_ID) {
return false;
}
const matrix = resolveMatrixBaseConfig(params.cfg);
return (
hasConfiguredSecretInputValue(matrix.accessToken, params.cfg) ||
clean(params.env.MATRIX_ACCESS_TOKEN, "MATRIX_ACCESS_TOKEN").length > 0
);
}
function resolveConfiguredMatrixAuthInput(params: {
cfg: CoreConfig;
env: NodeJS.ProcessEnv;
accountId: string;
field: MatrixAuthSecretField;
}): MatrixConfiguredAuthInput | undefined {
const normalizedAccountId = normalizeAccountId(params.accountId);
const account = findMatrixAccountConfig(params.cfg, normalizedAccountId) ?? {};
const accountValue = account[params.field];
if (accountValue !== undefined) {
return {
value: accountValue,
path: resolveMatrixConfigFieldPath(params.cfg, normalizedAccountId, params.field),
};
}
const scopedKeys = getMatrixScopedEnvVarNames(normalizedAccountId);
const scopedEnv = resolveScopedMatrixEnvConfig(normalizedAccountId, params.env);
const scopedValue = scopedEnv[params.field];
if (scopedValue !== undefined) {
return {
value: scopedValue,
path: params.field === "accessToken" ? scopedKeys.accessToken : scopedKeys.password,
};
}
if (normalizedAccountId !== DEFAULT_ACCOUNT_ID) {
return undefined;
}
const matrix = resolveMatrixBaseConfig(params.cfg);
const baseValue = matrix[params.field];
if (baseValue !== undefined) {
return {
value: baseValue,
path: resolveMatrixBaseConfigFieldPath(params.field),
};
}
const globalValue =
params.field === "accessToken" ? params.env.MATRIX_ACCESS_TOKEN : params.env.MATRIX_PASSWORD;
if (globalValue !== undefined) {
return {
value: globalValue,
path: params.field === "accessToken" ? "MATRIX_ACCESS_TOKEN" : "MATRIX_PASSWORD",
};
}
return undefined;
}
async function resolveConfiguredMatrixAuthSecretInput(params: {
cfg: CoreConfig;
env: NodeJS.ProcessEnv;
accountId: string;
field: MatrixAuthSecretField;
}): Promise<string | undefined> {
const configured = resolveConfiguredMatrixAuthInput(params);
if (!configured) {
return undefined;
}
const ref = coerceSecretRef(configured.value, params.cfg.secrets?.defaults);
if (!ref) {
return normalizeResolvedSecretInputString({
value: configured.value,
path: configured.path,
defaults: params.cfg.secrets?.defaults,
});
}
const { resolveConfiguredSecretInputString } = await loadMatrixSecretInputDeps();
const resolved = await resolveConfiguredSecretInputString({
config: params.cfg,
env: params.env,
value: configured.value,
path: configured.path,
unresolvedReasonStyle: "detailed",
});
if (resolved.value !== undefined) {
return resolved.value;
}
throw new Error(
resolved.unresolvedRefReason ?? `${configured.path} SecretRef could not be resolved.`,
);
}
function readMatrixBaseConfigField(
matrix: ReturnType<typeof resolveMatrixBaseConfig>,
field: MatrixConfigStringField,
opts?: {
env?: NodeJS.ProcessEnv;
config?: Pick<CoreConfig, "secrets">;
suppressSecretRef?: boolean;
},
): string {
return clean(matrix[field], resolveMatrixBaseConfigFieldPath(field), {
env: opts?.env,
config: opts?.config,
allowEnvSecretRefFallback: shouldAllowEnvSecretRefFallback(field),
suppressSecretRef: opts?.suppressSecretRef,
});
}
function readMatrixAccountConfigField(
cfg: CoreConfig,
accountId: string,
account: Partial<Record<MatrixConfigStringField, unknown>>,
field: MatrixConfigStringField,
opts?: {
env?: NodeJS.ProcessEnv;
config?: Pick<CoreConfig, "secrets">;
suppressSecretRef?: boolean;
},
): string {
return clean(account[field], resolveMatrixConfigFieldPath(cfg, accountId, field), {
env: opts?.env,
config: opts?.config,
allowEnvSecretRefFallback: shouldAllowEnvSecretRefFallback(field),
suppressSecretRef: opts?.suppressSecretRef,
});
}
function clampMatrixInitialSyncLimit(value: unknown): number | undefined {
return resolveOptionalIntegerOption(value, { min: 0 });
}
function buildMatrixNetworkFields(params: {
allowPrivateNetwork: boolean | undefined;
proxy?: string;
dispatcherPolicy?: PinnedDispatcherPolicy;
}): Pick<MatrixResolvedConfig, "allowPrivateNetwork" | "ssrfPolicy" | "dispatcherPolicy"> {
const dispatcherPolicy: PinnedDispatcherPolicy | undefined =
params.dispatcherPolicy ??
(params.proxy ? { mode: "explicit-proxy", proxyUrl: params.proxy } : undefined);
if (!params.allowPrivateNetwork && !dispatcherPolicy) {
return {};
}
return {
...(params.allowPrivateNetwork
? {
allowPrivateNetwork: true,
ssrfPolicy: ssrfPolicyFromDangerouslyAllowPrivateNetwork(true),
}
: {}),
...(dispatcherPolicy ? { dispatcherPolicy } : {}),
};
}
export { getMatrixScopedEnvVarNames } from "../../env-vars.js";
export {
hasReadyMatrixEnvAuth,
resolveMatrixEnvAuthReadiness,
resolveScopedMatrixEnvConfig,
} from "./env-auth.js";
export {
resolveValidatedMatrixHomeserverUrl,
validateMatrixHomeserverUrl,
} from "./url-validation.js";
function hasScopedMatrixEnvConfig(accountId: string, env: NodeJS.ProcessEnv): boolean {
const scoped = resolveScopedMatrixEnvConfig(accountId, env);
return Boolean(
scoped.homeserver ||
scoped.userId ||
scoped.accessToken ||
scoped.password ||
scoped.deviceId ||
scoped.deviceName,
);
}
export function resolveMatrixConfigForAccount(
cfg: CoreConfig,
accountId: string,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = resolveMatrixBaseConfig(cfg);
const account = findMatrixAccountConfig(cfg, accountId) ?? {};
const normalizedAccountId = normalizeAccountId(accountId);
const suppressInactivePasswordSecretRef = hasConfiguredMatrixAccessTokenSource({
cfg,
env,
accountId: normalizedAccountId,
});
const fieldReadOptions = {
env,
config: cfg,
};
const scopedEnv = resolveScopedMatrixEnvConfig(normalizedAccountId, env);
const globalEnv = resolveGlobalMatrixEnvConfig(env);
const accountField = (field: MatrixConfigStringField) =>
readMatrixAccountConfigField(cfg, normalizedAccountId, account, field, {
...fieldReadOptions,
suppressSecretRef: field === "password" ? suppressInactivePasswordSecretRef : undefined,
});
const resolvedStrings = resolveMatrixAccountStringValues({
accountId: normalizedAccountId,
account: {
homeserver: accountField("homeserver"),
userId: accountField("userId"),
accessToken: accountField("accessToken"),
password: accountField("password"),
deviceId: accountField("deviceId"),
deviceName: accountField("deviceName"),
},
scopedEnv,
channel: {
homeserver: readMatrixBaseConfigField(matrix, "homeserver", fieldReadOptions),
userId: readMatrixBaseConfigField(matrix, "userId", fieldReadOptions),
accessToken: readMatrixBaseConfigField(matrix, "accessToken", fieldReadOptions),
password: readMatrixBaseConfigField(matrix, "password", {
...fieldReadOptions,
suppressSecretRef: suppressInactivePasswordSecretRef,
}),
deviceId: readMatrixBaseConfigField(matrix, "deviceId", fieldReadOptions),
deviceName: readMatrixBaseConfigField(matrix, "deviceName", fieldReadOptions),
},
globalEnv,
});
const accountInitialSyncLimit = clampMatrixInitialSyncLimit(account.initialSyncLimit);
const initialSyncLimit =
accountInitialSyncLimit ?? clampMatrixInitialSyncLimit(matrix.initialSyncLimit);
const encryption =
typeof account.encryption === "boolean" ? account.encryption : (matrix.encryption ?? false);
const allowPrivateNetwork =
isPrivateNetworkOptInEnabled(account) || isPrivateNetworkOptInEnabled(matrix)
? true
: undefined;
return {
homeserver: resolvedStrings.homeserver,
userId: resolvedStrings.userId,
accessToken: resolvedStrings.accessToken || undefined,
password: resolvedStrings.password || undefined,
deviceId: resolvedStrings.deviceId || undefined,
deviceName: resolvedStrings.deviceName || undefined,
initialSyncLimit,
encryption,
...buildMatrixNetworkFields({
allowPrivateNetwork,
proxy: account.proxy ?? matrix.proxy,
}),
};
}
function resolveImplicitMatrixAccountId(
cfg: CoreConfig,
env: NodeJS.ProcessEnv = process.env,
): string | null {
if (requiresExplicitMatrixDefaultAccount(cfg, env)) {
return null;
}
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg, env));
}
export function resolveMatrixAuthContext(params: {
cfg: CoreConfig;
env?: NodeJS.ProcessEnv;
accountId?: string | null;
}): {
cfg: CoreConfig;
env: NodeJS.ProcessEnv;
accountId: string;
resolved: MatrixResolvedConfig;
} {
const cfg = requireRuntimeConfig(params.cfg, "Matrix auth context") as CoreConfig;
const env = params?.env ?? process.env;
const explicitAccountId = normalizeOptionalAccountId(params?.accountId);
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>.',
);
}
if (
explicitAccountId &&
explicitAccountId !== DEFAULT_ACCOUNT_ID &&
!listNormalizedMatrixAccountIds(cfg).includes(explicitAccountId) &&
!hasScopedMatrixEnvConfig(explicitAccountId, env)
) {
throw new Error(
`Matrix account "${explicitAccountId}" is not configured. Add channels.matrix.accounts.${explicitAccountId} or define scoped ${getMatrixScopedEnvVarNames(explicitAccountId).accessToken.replace(/_ACCESS_TOKEN$/, "")}_* variables.`,
);
}
const resolved = resolveMatrixConfigForAccount(cfg, effectiveAccountId, env);
return {
cfg,
env,
accountId: effectiveAccountId,
resolved,
};
}
export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
accountId?: string | null;
}): Promise<MatrixAuth> {
if (!params?.cfg) {
throw new Error(
"Matrix auth requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
);
}
const { cfg, env, accountId, resolved } = resolveMatrixAuthContext({
cfg: params.cfg,
env: params.env,
accountId: params.accountId,
});
const accessToken =
(await resolveConfiguredMatrixAuthSecretInput({
cfg,
env,
accountId,
field: "accessToken",
})) ?? resolved.accessToken;
const tokenAuthPassword = resolved.password;
const homeserver = await resolveValidatedMatrixHomeserverUrl(resolved.homeserver, {
dangerouslyAllowPrivateNetwork: resolved.allowPrivateNetwork,
});
const { loadMatrixCredentials, credentialsMatchConfig } = await loadMatrixCredentialsReadDeps();
const cached = loadMatrixCredentials(env, accountId);
const cachedCredentials =
cached &&
credentialsMatchConfig(cached, {
homeserver,
userId: resolved.userId || "",
accessToken,
})
? cached
: null;
// If we have an access token, we can fetch userId via whoami if not provided
if (accessToken) {
let userId = resolved.userId;
const hasMatchingCachedToken = cachedCredentials?.accessToken === accessToken;
let knownDeviceId = hasMatchingCachedToken
? cachedCredentials?.deviceId || resolved.deviceId
: resolved.deviceId;
if (!userId) {
// Only block startup on whoami when token auth still needs the user ID.
// A missing device ID alone is optional and should not force a network round-trip.
const whoami = await fetchMatrixWhoamiIdentity({
homeserver,
accessToken,
userId,
ssrfPolicy: resolved.ssrfPolicy,
dispatcherPolicy: resolved.dispatcherPolicy,
});
const fetchedUserId = whoami.user_id?.trim();
if (!fetchedUserId) {
throw new Error("Matrix whoami did not return user_id");
}
userId = fetchedUserId;
knownDeviceId = knownDeviceId || whoami.device_id?.trim() || resolved.deviceId;
}
const shouldRefreshCachedCredentials =
!cachedCredentials ||
!hasMatchingCachedToken ||
cachedCredentials.userId !== userId ||
(cachedCredentials.deviceId || undefined) !== knownDeviceId;
if (shouldRefreshCachedCredentials) {
const { saveMatrixCredentials } = await loadMatrixCredentialsWriteRuntime();
await saveMatrixCredentials(
{
homeserver,
userId,
accessToken,
deviceId: knownDeviceId,
},
env,
accountId,
);
} else if (hasMatchingCachedToken) {
const { touchMatrixCredentials } = await loadMatrixCredentialsWriteRuntime();
await touchMatrixCredentials(env, accountId);
}
return {
accountId,
homeserver,
userId,
accessToken,
password: tokenAuthPassword,
deviceId: knownDeviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
...buildMatrixNetworkFields({
allowPrivateNetwork: resolved.allowPrivateNetwork,
dispatcherPolicy: resolved.dispatcherPolicy,
}),
};
}
if (cachedCredentials) {
const { touchMatrixCredentials } = await loadMatrixCredentialsWriteRuntime();
await touchMatrixCredentials(env, accountId);
return {
accountId,
homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId,
accessToken: cachedCredentials.accessToken,
password: tokenAuthPassword,
deviceId: cachedCredentials.deviceId || resolved.deviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
...buildMatrixNetworkFields({
allowPrivateNetwork: resolved.allowPrivateNetwork,
dispatcherPolicy: resolved.dispatcherPolicy,
}),
};
}
if (!resolved.userId) {
throw new Error("Matrix userId is required when no access token is configured (matrix.userId)");
}
const password =
(await resolveConfiguredMatrixAuthSecretInput({
cfg,
env,
accountId,
field: "password",
})) ?? resolved.password;
if (!password) {
throw new Error(
"Matrix password is required when no access token is configured (matrix.password)",
);
}
// Login with password using the same hardened request path as other Matrix HTTP calls.
const { MatrixClient, ensureMatrixSdkLoggingConfigured } = await loadMatrixAuthClientDeps();
ensureMatrixSdkLoggingConfigured();
const loginClient = new MatrixClient(homeserver, "", {
ssrfPolicy: resolved.ssrfPolicy,
dispatcherPolicy: resolved.dispatcherPolicy,
});
const login = (await retryMatrixAuthRequest("matrix auth login", async () => {
return (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, {
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password,
device_id: resolved.deviceId,
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
})) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
})) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
const loginAccessToken = login.access_token?.trim();
if (!loginAccessToken) {
throw new Error("Matrix login did not return an access token");
}
const auth: MatrixAuth = {
accountId,
homeserver,
userId: login.user_id ?? resolved.userId,
accessToken: loginAccessToken,
password,
deviceId: login.device_id ?? resolved.deviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
...buildMatrixNetworkFields({
allowPrivateNetwork: resolved.allowPrivateNetwork,
dispatcherPolicy: resolved.dispatcherPolicy,
}),
};
const { saveMatrixCredentials } = await loadMatrixCredentialsWriteRuntime();
await saveMatrixCredentials(
{
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: auth.deviceId,
},
env,
accountId,
);
return auth;
}
export async function backfillMatrixAuthDeviceIdAfterStartup(params: {
auth: MatrixAuth;
env?: NodeJS.ProcessEnv;
abortSignal?: AbortSignal;
}): Promise<string | undefined> {
const knownDeviceId = params.auth.deviceId?.trim();
if (knownDeviceId) {
return knownDeviceId;
}
if (isAbortSignalTriggered(params.abortSignal)) {
return undefined;
}
const whoami = await fetchMatrixWhoamiIdentity({
homeserver: params.auth.homeserver,
accessToken: params.auth.accessToken,
userId: params.auth.userId,
ssrfPolicy: params.auth.ssrfPolicy,
dispatcherPolicy: params.auth.dispatcherPolicy,
});
const deviceId = whoami.device_id?.trim();
if (!deviceId) {
return undefined;
}
if (isAbortSignalTriggered(params.abortSignal)) {
return undefined;
}
const env = params.env ?? process.env;
const { loadMatrixCredentials } = await loadMatrixCredentialsReadDeps();
if (
!credentialsMatchBackfillAuthLineage({
stored: loadMatrixCredentials(env, params.auth.accountId),
auth: params.auth,
})
) {
return undefined;
}
const repairedStorageMeta = repairCurrentTokenStorageMetaDeviceId({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
accountId: params.auth.accountId,
deviceId,
env: params.env,
});
if (!repairedStorageMeta) {
throw new Error("Matrix deviceId backfill failed to repair current-token storage metadata");
}
if (isAbortSignalTriggered(params.abortSignal)) {
return undefined;
}
const credentialsWriter = await loadMatrixCredentialsWriteRuntime();
const saved = await credentialsWriter.saveBackfilledMatrixDeviceId(
{
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
deviceId,
},
env,
params.auth.accountId,
);
return saved === "saved" ? deviceId : undefined;
}

View File

@@ -0,0 +1,194 @@
// Matrix tests cover create client plugin behavior.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const ensureMatrixSdkLoggingConfiguredMock = vi.hoisted(() => vi.fn());
const resolveValidatedMatrixHomeserverUrlMock = vi.hoisted(() => vi.fn());
const maybeMigrateLegacyStorageMock = vi.hoisted(() => vi.fn(async () => undefined));
const resolveMatrixStoragePathsMock = vi.hoisted(() => vi.fn());
const writeStorageMetaMock = vi.hoisted(() => vi.fn());
const MatrixClientMock = vi.hoisted(() => vi.fn());
vi.mock("./logging.js", () => ({
ensureMatrixSdkLoggingConfigured: ensureMatrixSdkLoggingConfiguredMock,
}));
vi.mock("./config.js", () => ({
resolveValidatedMatrixHomeserverUrl: resolveValidatedMatrixHomeserverUrlMock,
}));
vi.mock("./storage.js", () => ({
maybeMigrateLegacyStorage: maybeMigrateLegacyStorageMock,
resolveMatrixStoragePaths: resolveMatrixStoragePathsMock,
writeStorageMeta: writeStorageMetaMock,
}));
vi.mock("../sdk.js", () => ({
MatrixClient: MatrixClientMock,
}));
let createMatrixClient: typeof import("./create-client.js").createMatrixClient;
describe("createMatrixClient", () => {
const storagePaths = {
rootDir: "/tmp/openclaw-matrix-create-client-test",
storagePath: "/tmp/openclaw-matrix-create-client-test/storage.json",
recoveryKeyPath: "/tmp/openclaw-matrix-create-client-test/recovery.key",
idbSnapshotPath: "/tmp/openclaw-matrix-create-client-test/idb.snapshot",
accountKey: "default",
tokenHash: "token-hash",
};
beforeAll(async () => {
({ createMatrixClient } = await import("./create-client.js"));
});
beforeEach(() => {
vi.clearAllMocks();
ensureMatrixSdkLoggingConfiguredMock.mockReturnValue(undefined);
resolveValidatedMatrixHomeserverUrlMock.mockResolvedValue("https://matrix.example.org");
resolveMatrixStoragePathsMock.mockReturnValue(storagePaths);
MatrixClientMock.mockImplementation(function MockMatrixClient() {
return {
stop: vi.fn(),
};
});
});
it("persists storage metadata by default", async () => {
await createMatrixClient({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
});
expect(writeStorageMetaMock).toHaveBeenCalledWith({
storagePaths,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accountId: undefined,
deviceId: undefined,
});
expect(resolveMatrixStoragePathsMock).toHaveBeenCalledTimes(1);
expect(MatrixClientMock).toHaveBeenCalledWith("https://matrix.example.org", "tok", {
userId: "@bot:example.org",
password: undefined,
deviceId: undefined,
encryption: undefined,
localTimeoutMs: undefined,
initialSyncLimit: undefined,
storageRootDir: storagePaths.rootDir,
recoveryKeyPath: storagePaths.recoveryKeyPath,
idbSnapshotPath: storagePaths.idbSnapshotPath,
cryptoDatabasePrefix: "openclaw-matrix-default-token-hash",
autoBootstrapCrypto: undefined,
ssrfPolicy: undefined,
dispatcherPolicy: undefined,
});
});
it("derives ssrfPolicy from allowPrivateNetwork when no explicit policy is provided", async () => {
await createMatrixClient({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
persistStorage: false,
allowPrivateNetwork: true,
});
expect(MatrixClientMock).toHaveBeenCalledWith("https://matrix.example.org", "tok", {
userId: "@bot:example.org",
password: undefined,
deviceId: undefined,
encryption: undefined,
localTimeoutMs: undefined,
initialSyncLimit: undefined,
storageRootDir: undefined,
recoveryKeyPath: undefined,
idbSnapshotPath: undefined,
cryptoDatabasePrefix: undefined,
autoBootstrapCrypto: undefined,
ssrfPolicy: { allowPrivateNetwork: true },
dispatcherPolicy: undefined,
});
});
it("prefers explicit ssrfPolicy over allowPrivateNetwork", async () => {
const explicitPolicy = { allowPrivateNetwork: true, customField: "test" };
await createMatrixClient({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
persistStorage: false,
allowPrivateNetwork: false,
ssrfPolicy: explicitPolicy as never,
});
expect(MatrixClientMock).toHaveBeenCalledWith("https://matrix.example.org", "tok", {
userId: "@bot:example.org",
password: undefined,
deviceId: undefined,
encryption: undefined,
localTimeoutMs: undefined,
initialSyncLimit: undefined,
storageRootDir: undefined,
recoveryKeyPath: undefined,
idbSnapshotPath: undefined,
cryptoDatabasePrefix: undefined,
autoBootstrapCrypto: undefined,
ssrfPolicy: explicitPolicy,
dispatcherPolicy: undefined,
});
});
it("leaves ssrfPolicy undefined when allowPrivateNetwork is falsy and no explicit policy", async () => {
await createMatrixClient({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
persistStorage: false,
});
expect(MatrixClientMock).toHaveBeenCalledWith("https://matrix.example.org", "tok", {
userId: "@bot:example.org",
password: undefined,
deviceId: undefined,
encryption: undefined,
localTimeoutMs: undefined,
initialSyncLimit: undefined,
storageRootDir: undefined,
recoveryKeyPath: undefined,
idbSnapshotPath: undefined,
cryptoDatabasePrefix: undefined,
autoBootstrapCrypto: undefined,
ssrfPolicy: undefined,
dispatcherPolicy: undefined,
});
});
it("skips persistent storage wiring when persistence is disabled", async () => {
await createMatrixClient({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
persistStorage: false,
});
expect(resolveMatrixStoragePathsMock).not.toHaveBeenCalled();
expect(writeStorageMetaMock).not.toHaveBeenCalled();
expect(MatrixClientMock).toHaveBeenCalledWith("https://matrix.example.org", "tok", {
userId: "@bot:example.org",
password: undefined,
deviceId: undefined,
encryption: undefined,
localTimeoutMs: undefined,
initialSyncLimit: undefined,
storageRootDir: undefined,
recoveryKeyPath: undefined,
idbSnapshotPath: undefined,
cryptoDatabasePrefix: undefined,
autoBootstrapCrypto: undefined,
ssrfPolicy: undefined,
dispatcherPolicy: undefined,
});
});
});

View File

@@ -0,0 +1,96 @@
// Matrix plugin module implements create client behavior.
import fs from "node:fs";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
import {
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
type SsrFPolicy,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { MatrixClient } from "../sdk.js";
import { resolveValidatedMatrixHomeserverUrl } from "./config.js";
import {
maybeMigrateLegacyStorage,
resolveMatrixStoragePaths,
writeStorageMeta,
} from "./storage.js";
const loadMatrixCreateClientRuntimeDeps = createLazyRuntimeModule(() =>
Promise.all([import("../sdk.js"), import("./logging.js")]).then(([sdkModule, loggingModule]) => ({
MatrixClient: sdkModule.MatrixClient,
ensureMatrixSdkLoggingConfigured: loggingModule.ensureMatrixSdkLoggingConfigured,
})),
);
export async function createMatrixClient(params: {
homeserver: string;
userId?: string;
accessToken: string;
password?: string;
deviceId?: string;
persistStorage?: boolean;
encryption?: boolean;
localTimeoutMs?: number;
initialSyncLimit?: number;
accountId?: string | null;
autoBootstrapCrypto?: boolean;
allowPrivateNetwork?: boolean;
ssrfPolicy?: SsrFPolicy;
dispatcherPolicy?: PinnedDispatcherPolicy;
}): Promise<MatrixClient> {
const { MatrixClient, ensureMatrixSdkLoggingConfigured } =
await loadMatrixCreateClientRuntimeDeps();
ensureMatrixSdkLoggingConfigured();
const homeserver = await resolveValidatedMatrixHomeserverUrl(params.homeserver, {
dangerouslyAllowPrivateNetwork: params.allowPrivateNetwork,
});
const matrixClientUserId = normalizeOptionalString(params.userId);
const userId = matrixClientUserId ?? "unknown";
const persistStorage = params.persistStorage !== false;
const storagePaths = persistStorage
? resolveMatrixStoragePaths({
homeserver,
userId,
accessToken: params.accessToken,
accountId: params.accountId,
deviceId: params.deviceId,
env: process.env,
})
: null;
if (storagePaths) {
await maybeMigrateLegacyStorage({
storagePaths,
env: process.env,
});
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
writeStorageMeta({
storagePaths,
homeserver,
userId,
accountId: params.accountId,
deviceId: params.deviceId,
});
}
const cryptoDatabasePrefix = storagePaths
? `openclaw-matrix-${storagePaths.accountKey}-${storagePaths.tokenHash}`
: undefined;
return new MatrixClient(homeserver, params.accessToken, {
userId: matrixClientUserId,
password: params.password,
deviceId: params.deviceId,
encryption: params.encryption,
localTimeoutMs: params.localTimeoutMs,
initialSyncLimit: params.initialSyncLimit,
storageRootDir: storagePaths?.rootDir,
recoveryKeyPath: storagePaths?.recoveryKeyPath,
idbSnapshotPath: storagePaths?.idbSnapshotPath,
cryptoDatabasePrefix,
autoBootstrapCrypto: params.autoBootstrapCrypto,
ssrfPolicy:
params.ssrfPolicy ?? ssrfPolicyFromDangerouslyAllowPrivateNetwork(params.allowPrivateNetwork),
dispatcherPolicy: params.dispatcherPolicy,
});
}

View File

@@ -0,0 +1,96 @@
// Matrix plugin module implements env auth behavior.
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { getMatrixScopedEnvVarNames } from "../../env-vars.js";
type MatrixEnvConfig = {
homeserver: string;
userId: string;
accessToken?: string;
password?: string;
deviceId?: string;
deviceName?: string;
};
function cleanEnv(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
export function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig {
return {
homeserver: cleanEnv(env.MATRIX_HOMESERVER),
userId: cleanEnv(env.MATRIX_USER_ID),
accessToken: cleanEnv(env.MATRIX_ACCESS_TOKEN) || undefined,
password: cleanEnv(env.MATRIX_PASSWORD) || undefined,
deviceId: cleanEnv(env.MATRIX_DEVICE_ID) || undefined,
deviceName: cleanEnv(env.MATRIX_DEVICE_NAME) || undefined,
};
}
export function hasReadyMatrixEnvAuth(config: {
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
}): boolean {
const homeserver = cleanEnv(config.homeserver);
const userId = cleanEnv(config.userId);
const accessToken = cleanEnv(config.accessToken);
const password = cleanEnv(config.password);
return Boolean(homeserver && (accessToken || (userId && password)));
}
export function resolveScopedMatrixEnvConfig(
accountId: string,
env: NodeJS.ProcessEnv = process.env,
): MatrixEnvConfig {
const keys = getMatrixScopedEnvVarNames(accountId);
return {
homeserver: cleanEnv(env[keys.homeserver]),
userId: cleanEnv(env[keys.userId]),
accessToken: cleanEnv(env[keys.accessToken]) || undefined,
password: cleanEnv(env[keys.password]) || undefined,
deviceId: cleanEnv(env[keys.deviceId]) || undefined,
deviceName: cleanEnv(env[keys.deviceName]) || undefined,
};
}
export function resolveMatrixEnvAuthReadiness(
accountId: string,
env: NodeJS.ProcessEnv = process.env,
): {
ready: boolean;
homeserver?: string;
userId?: string;
sourceHint: string;
missingMessage: string;
} {
const normalizedAccountId = normalizeAccountId(accountId);
const scoped = resolveScopedMatrixEnvConfig(normalizedAccountId, env);
const scopedReady = hasReadyMatrixEnvAuth(scoped);
if (normalizedAccountId !== DEFAULT_ACCOUNT_ID) {
const keys = getMatrixScopedEnvVarNames(normalizedAccountId);
return {
ready: scopedReady,
homeserver: scoped.homeserver || undefined,
userId: scoped.userId || undefined,
sourceHint: `${keys.homeserver} (+ auth vars)`,
missingMessage: `Set per-account env vars for "${normalizedAccountId}" (for example ${keys.homeserver} + ${keys.accessToken} or ${keys.userId} + ${keys.password}).`,
};
}
const defaultScoped = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, env);
const global = resolveGlobalMatrixEnvConfig(env);
const defaultScopedReady = hasReadyMatrixEnvAuth(defaultScoped);
const globalReady = hasReadyMatrixEnvAuth(global);
const defaultKeys = getMatrixScopedEnvVarNames(DEFAULT_ACCOUNT_ID);
return {
ready: defaultScopedReady || globalReady,
homeserver: defaultScoped.homeserver || global.homeserver || undefined,
userId: defaultScoped.userId || global.userId || undefined,
sourceHint: "MATRIX_* or MATRIX_DEFAULT_*",
missingMessage:
`Set Matrix env vars for the default account ` +
`(for example MATRIX_HOMESERVER + MATRIX_ACCESS_TOKEN, MATRIX_USER_ID + MATRIX_PASSWORD, ` +
`or ${defaultKeys.homeserver} + ${defaultKeys.accessToken}).`,
};
}

View File

@@ -0,0 +1,311 @@
// Matrix tests cover sync cache plugin behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ISyncResponse } from "matrix-js-sdk/lib/matrix.js";
import {
createPluginStateSyncKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getMatrixRuntime } from "../../runtime.js";
import { installMatrixTestRuntime } from "../../test-runtime.js";
import {
openMatrixSyncCacheStoreOptions,
SqliteBackedMatrixSyncStore,
type MatrixSyncCacheRecord,
} from "./file-sync-store.js";
import { openMatrixStorageMetaStoreOptions } from "./storage.js";
function createSyncResponse(nextBatch: string): ISyncResponse {
return {
next_batch: nextBatch,
rooms: {
join: {
"!room:example.org": {
summary: {
"m.heroes": [],
},
state: { events: [] },
timeline: {
events: [
{
content: {
body: "hello",
msgtype: "m.text",
},
event_id: "$message",
origin_server_ts: 1,
sender: "@user:example.org",
type: "m.room.message",
},
],
prev_batch: "t0",
},
ephemeral: { events: [] },
account_data: { events: [] },
unread_notifications: {},
},
},
invite: {},
leave: {},
knock: {},
},
account_data: {
events: [
{
content: { theme: "dark" },
type: "com.openclaw.test",
},
],
},
};
}
describe("SqliteBackedMatrixSyncStore", () => {
const tempDirs: string[] = [];
function createStorageRoot(): string {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-sync-store-"));
tempDirs.push(tempDir);
return tempDir;
}
beforeEach(() => {
resetPluginStateStoreForTests();
installMatrixTestRuntime();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
resetPluginStateStoreForTests();
});
it("persists sync data so restart resumes from the saved cursor", async () => {
const storageRoot = createStorageRoot();
const syncResponse = createSyncResponse("s123");
const firstStore = new SqliteBackedMatrixSyncStore(storageRoot);
expect(firstStore.hasSavedSync()).toBe(false);
await firstStore.setSyncData(syncResponse);
await firstStore.flush();
expect(fs.existsSync(path.join(storageRoot, "bot-storage.json"))).toBe(false);
const secondStore = new SqliteBackedMatrixSyncStore(storageRoot);
expect(secondStore.hasSavedSync()).toBe(true);
await expect(secondStore.getSavedSyncToken()).resolves.toBe("s123");
const savedSync = await secondStore.getSavedSync();
expect(savedSync).toEqual({
nextBatch: "s123",
accountData: syncResponse.account_data.events,
roomsData: {
join: {
"!room:example.org": {
summary: {
"m.heroes": [],
},
state: { events: [] },
"org.matrix.msc4222.state_after": { events: [] },
timeline: {
events: [
{
content: {
body: "hello",
msgtype: "m.text",
},
event_id: "$message",
origin_server_ts: 1,
sender: "@user:example.org",
type: "m.room.message",
},
],
prev_batch: "t0",
},
ephemeral: { events: [] },
account_data: { events: [] },
unread_notifications: {},
},
},
invite: {},
leave: {},
knock: {},
},
});
expect(secondStore.hasSavedSyncFromCleanShutdown()).toBe(false);
});
it("restores the sync cache after the storage root moves", async () => {
const storageRoot = createStorageRoot();
const movedStorageRoot = `${storageRoot}-moved`;
const firstStore = new SqliteBackedMatrixSyncStore(storageRoot);
await firstStore.setSyncData(createSyncResponse("portable-token"));
await firstStore.flush();
resetPluginStateStoreForTests();
fs.renameSync(storageRoot, movedStorageRoot);
tempDirs.push(movedStorageRoot);
const secondStore = new SqliteBackedMatrixSyncStore(movedStorageRoot);
expect(secondStore.hasSavedSync()).toBe(true);
await expect(secondStore.getSavedSyncToken()).resolves.toBe("portable-token");
});
it("ignores metadata with impossible chunk counts", async () => {
const storageRoot = createStorageRoot();
const store = createPluginStateSyncKeyedStoreForTests<MatrixSyncCacheRecord>(
"matrix",
openMatrixSyncCacheStoreOptions(storageRoot),
);
store.register("current:meta", {
kind: "meta",
version: 1,
generation: "corrupt",
chunkCount: 20_000,
cleanShutdown: true,
});
const syncStore = new SqliteBackedMatrixSyncStore(storageRoot);
expect(syncStore.hasSavedSync()).toBe(false);
await expect(syncStore.getSavedSyncToken()).resolves.toBe(null);
});
it("fails persistence instead of silently dropping sync data when sqlite is unavailable", async () => {
const storageRoot = createStorageRoot();
const runtime = getMatrixRuntime();
vi.spyOn(runtime.state, "openSyncKeyedStore").mockImplementation(() => {
throw new Error("sqlite unavailable");
});
const syncStore = new SqliteBackedMatrixSyncStore(storageRoot);
await syncStore.setSyncData(createSyncResponse("unavailable-token"));
await expect(syncStore.flush()).rejects.toThrow(/sqlite store is unavailable/i);
});
it("claims current-token storage ownership when sync state is persisted", async () => {
const storageRoot = createStorageRoot();
createPluginStateSyncKeyedStoreForTests<Record<string, unknown>>(
"matrix",
openMatrixStorageMetaStoreOptions(storageRoot),
).register("current", {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accountId: "default",
accessTokenHash: "token-hash",
deviceId: null,
});
const store = new SqliteBackedMatrixSyncStore(storageRoot);
await store.setSyncData(createSyncResponse("claimed-token"));
await store.flush();
const meta = createPluginStateSyncKeyedStoreForTests<Record<string, unknown>>(
"matrix",
openMatrixStorageMetaStoreOptions(storageRoot),
).lookup("current");
expect(meta).toMatchObject({ currentTokenStateClaimed: true });
});
it("only treats sync state as restart-safe after a clean shutdown persist", async () => {
const storageRoot = createStorageRoot();
const firstStore = new SqliteBackedMatrixSyncStore(storageRoot);
await firstStore.setSyncData(createSyncResponse("s123"));
await firstStore.flush();
const afterDirtyPersist = new SqliteBackedMatrixSyncStore(storageRoot);
expect(afterDirtyPersist.hasSavedSync()).toBe(true);
expect(afterDirtyPersist.hasSavedSyncFromCleanShutdown()).toBe(false);
firstStore.markCleanShutdown();
await firstStore.flush();
const afterCleanShutdown = new SqliteBackedMatrixSyncStore(storageRoot);
expect(afterCleanShutdown.hasSavedSync()).toBe(true);
expect(afterCleanShutdown.hasSavedSyncFromCleanShutdown()).toBe(true);
});
it("clears the clean-shutdown marker once fresh sync data arrives", async () => {
const storageRoot = createStorageRoot();
const firstStore = new SqliteBackedMatrixSyncStore(storageRoot);
await firstStore.setSyncData(createSyncResponse("s123"));
firstStore.markCleanShutdown();
await firstStore.flush();
const restartedStore = new SqliteBackedMatrixSyncStore(storageRoot);
expect(restartedStore.hasSavedSyncFromCleanShutdown()).toBe(true);
await restartedStore.setSyncData(createSyncResponse("s456"));
await restartedStore.flush();
const afterNewSync = new SqliteBackedMatrixSyncStore(storageRoot);
expect(afterNewSync.hasSavedSync()).toBe(true);
expect(afterNewSync.hasSavedSyncFromCleanShutdown()).toBe(false);
await expect(afterNewSync.getSavedSyncToken()).resolves.toBe("s456");
});
it("coalesces background persistence until the debounce window elapses", async () => {
vi.useFakeTimers();
const storageRoot = createStorageRoot();
const store = new SqliteBackedMatrixSyncStore(storageRoot);
await store.setSyncData(createSyncResponse("s111"));
await store.setSyncData(createSyncResponse("s222"));
await store.storeClientOptions({ lazyLoadMembers: true });
const beforeDebounce = new SqliteBackedMatrixSyncStore(storageRoot);
expect(beforeDebounce.hasSavedSync()).toBe(false);
await vi.advanceTimersByTimeAsync(249);
const beforeElapsed = new SqliteBackedMatrixSyncStore(storageRoot);
expect(beforeElapsed.hasSavedSync()).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await Promise.resolve();
await store.flush();
const persisted = new SqliteBackedMatrixSyncStore(storageRoot);
expect(persisted.hasSavedSync()).toBe(true);
await expect(persisted.getSavedSyncToken()).resolves.toBe("s222");
await expect(persisted.getClientOptions()).resolves.toEqual({ lazyLoadMembers: true });
});
it("persists client options alongside sync state", async () => {
const storageRoot = createStorageRoot();
const firstStore = new SqliteBackedMatrixSyncStore(storageRoot);
await firstStore.storeClientOptions({ lazyLoadMembers: true });
await firstStore.flush();
const secondStore = new SqliteBackedMatrixSyncStore(storageRoot);
await expect(secondStore.getClientOptions()).resolves.toEqual({ lazyLoadMembers: true });
});
it("ignores legacy raw sync cache files", async () => {
const storageRoot = createStorageRoot();
fs.writeFileSync(
path.join(storageRoot, "bot-storage.json"),
JSON.stringify({
next_batch: "legacy-token",
rooms: {
join: {},
},
account_data: {
events: [],
},
}),
"utf8",
);
const store = new SqliteBackedMatrixSyncStore(storageRoot);
expect(store.hasSavedSync()).toBe(false);
await expect(store.getSavedSyncToken()).resolves.toBe(null);
});
});

View File

@@ -0,0 +1,604 @@
// Matrix plugin module implements SQLite sync cache behavior.
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
Category,
MemoryStore,
SyncAccumulator,
type ISyncData,
type IRooms,
type ISyncResponse,
type IStoredClientOpts,
} from "matrix-js-sdk/lib/matrix.js";
import type {
PluginStateKeyedStore,
PluginStateSyncKeyedStore,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import { isRecord } from "../../record-shared.js";
import { getMatrixRuntime } from "../../runtime.js";
import { createAsyncLock } from "../async-lock.js";
import { LogService } from "../sdk/logger.js";
import { resolveMatrixSqliteStateEnv } from "../sqlite-state.js";
import { claimCurrentTokenStorageState } from "./storage.js";
const STORE_VERSION = 1;
const PERSIST_DEBOUNCE_MS = 250;
const SYNC_CACHE_NAMESPACE = "sync-cache";
const SYNC_CACHE_MAX_ENTRIES = 20_000;
const SYNC_CACHE_MAX_CHUNKS = Math.floor((SYNC_CACHE_MAX_ENTRIES - 1) / 2);
const SYNC_CACHE_STATE_KEY = "current";
// PluginState serializes this string inside a row object; 24KB leaves room for JSON escaping.
const SYNC_CACHE_CHUNK_BYTES = 24_000;
export type PersistedMatrixSyncStore = {
version: number;
savedSync: ISyncData | null;
clientOptions?: IStoredClientOpts;
cleanShutdown?: boolean;
};
type MatrixSyncCacheMeta = {
kind: "meta";
version: number;
generation: string;
chunkCount: number;
syncDigest?: string;
clientOptions?: IStoredClientOpts;
cleanShutdown?: boolean;
};
type MatrixSyncCacheChunk = {
kind: "sync-chunk";
index: number;
data: string;
};
export type MatrixSyncCacheRecord = MatrixSyncCacheMeta | MatrixSyncCacheChunk;
type MatrixSyncCacheAsyncStore = Pick<
PluginStateKeyedStore<MatrixSyncCacheRecord>,
"delete" | "entries" | "lookup" | "register"
>;
function normalizeRoomsData(value: unknown): IRooms | null {
if (!isRecord(value)) {
return null;
}
return {
[Category.Join]: isRecord(value[Category.Join]) ? (value[Category.Join] as IRooms["join"]) : {},
[Category.Invite]: isRecord(value[Category.Invite])
? (value[Category.Invite] as IRooms["invite"])
: {},
[Category.Leave]: isRecord(value[Category.Leave])
? (value[Category.Leave] as IRooms["leave"])
: {},
[Category.Knock]: isRecord(value[Category.Knock])
? (value[Category.Knock] as IRooms["knock"])
: {},
};
}
function toPersistedSyncData(value: unknown): ISyncData | null {
if (!isRecord(value)) {
return null;
}
if (typeof value.nextBatch === "string" && value.nextBatch.trim()) {
const roomsData = normalizeRoomsData(value.roomsData);
if (!Array.isArray(value.accountData) || !roomsData) {
return null;
}
return {
nextBatch: value.nextBatch,
accountData: value.accountData,
roomsData,
};
}
// Older Matrix state files stored the raw /sync-shaped payload directly.
if (typeof value.next_batch === "string" && value.next_batch.trim()) {
const roomsData = normalizeRoomsData(value.rooms);
if (!roomsData) {
return null;
}
return {
nextBatch: value.next_batch,
accountData:
isRecord(value.account_data) && Array.isArray(value.account_data.events)
? value.account_data.events
: [],
roomsData,
};
}
return null;
}
function normalizePersistedStore(value: unknown): PersistedMatrixSyncStore | null {
if (!isRecord(value) || value.version !== STORE_VERSION) {
return null;
}
return {
version: STORE_VERSION,
savedSync: toPersistedSyncData(value.savedSync),
clientOptions: isRecord(value.clientOptions)
? (value.clientOptions as IStoredClientOpts)
: undefined,
cleanShutdown: value.cleanShutdown === true,
};
}
function normalizeLegacyPersistedStore(value: unknown): PersistedMatrixSyncStore | null {
const persisted = normalizePersistedStore(value);
if (persisted) {
return persisted;
}
return {
version: STORE_VERSION,
savedSync: toPersistedSyncData(value),
cleanShutdown: false,
};
}
function cloneJson<T>(value: T): T {
return structuredClone(value);
}
function syncDataToSyncResponse(syncData: ISyncData): ISyncResponse {
return {
next_batch: syncData.nextBatch,
rooms: syncData.roomsData,
account_data: {
events: syncData.accountData,
},
};
}
export class SqliteBackedMatrixSyncStore extends MemoryStore {
private readonly persistLock = createAsyncLock();
private readonly accumulator = new SyncAccumulator();
private readonly stateKey: string;
private readonly store: PluginStateSyncKeyedStore<MatrixSyncCacheRecord>;
private readonly storeUnavailableError: unknown;
private savedSync: ISyncData | null = null;
private savedClientOptions: IStoredClientOpts | undefined;
private readonly hadSavedSyncOnLoad: boolean;
private readonly hadCleanShutdownOnLoad: boolean;
private cleanShutdown = false;
private dirty = false;
private persistTimer: NodeJS.Timeout | null = null;
private persistPromise: Promise<void> | null = null;
constructor(private readonly storageRootDir: string) {
super();
this.stateKey = SYNC_CACHE_STATE_KEY;
let restoredSavedSync: ISyncData | null = null;
let restoredClientOptions: IStoredClientOpts | undefined;
let restoredCleanShutdown = false;
let syncCacheStore = createNoopMatrixSyncCacheStore();
let syncCacheStoreUnavailableError: unknown;
try {
syncCacheStore = openMatrixSyncCacheStore(storageRootDir);
const persisted = readPersistedStoreFromSyncStore(syncCacheStore, this.stateKey);
if (persisted) {
restoredSavedSync = persisted.savedSync;
restoredClientOptions = persisted.clientOptions;
restoredCleanShutdown = persisted.cleanShutdown === true;
}
} catch (err) {
syncCacheStoreUnavailableError = err;
LogService.warn("MatrixSyncCacheStore", "Failed to load Matrix sync cache:", err);
}
this.store = syncCacheStore;
this.storeUnavailableError = syncCacheStoreUnavailableError;
this.savedSync = restoredSavedSync;
this.savedClientOptions = restoredClientOptions;
this.hadSavedSyncOnLoad = restoredSavedSync !== null;
this.hadCleanShutdownOnLoad = this.hadSavedSyncOnLoad && restoredCleanShutdown;
this.cleanShutdown = this.hadCleanShutdownOnLoad;
if (this.savedSync) {
this.accumulator.accumulate(syncDataToSyncResponse(this.savedSync), true);
super.setSyncToken(this.savedSync.nextBatch);
}
if (this.savedClientOptions) {
void super.storeClientOptions(this.savedClientOptions);
}
}
hasSavedSync(): boolean {
return this.hadSavedSyncOnLoad;
}
hasSavedSyncFromCleanShutdown(): boolean {
return this.hadCleanShutdownOnLoad;
}
override getSavedSync(): Promise<ISyncData | null> {
return Promise.resolve(this.savedSync ? cloneJson(this.savedSync) : null);
}
override getSavedSyncToken(): Promise<string | null> {
return Promise.resolve(this.savedSync?.nextBatch ?? null);
}
override setSyncData(syncData: ISyncResponse): Promise<void> {
this.accumulator.accumulate(syncData);
this.savedSync = this.accumulator.getJSON();
this.markDirtyAndSchedulePersist();
return Promise.resolve();
}
override getClientOptions() {
return Promise.resolve(
this.savedClientOptions ? cloneJson(this.savedClientOptions) : undefined,
);
}
override storeClientOptions(options: IStoredClientOpts) {
this.savedClientOptions = cloneJson(options);
void super.storeClientOptions(options);
this.markDirtyAndSchedulePersist();
return Promise.resolve();
}
override save(force = false) {
if (force) {
return this.flush();
}
return Promise.resolve();
}
override wantsSave(): boolean {
// We persist directly from setSyncData/storeClientOptions so the SDK's
// periodic save hook stays disabled. Shutdown uses flush() for a final sync.
return false;
}
override async deleteAllData(): Promise<void> {
this.assertStoreAvailable();
if (this.persistTimer) {
clearTimeout(this.persistTimer);
this.persistTimer = null;
}
this.dirty = false;
await this.persistPromise?.catch(() => undefined);
await super.deleteAllData();
this.savedSync = null;
this.savedClientOptions = undefined;
this.cleanShutdown = false;
this.store.delete(metaKey(this.stateKey));
for (const row of this.store.entries()) {
if (row.key.startsWith(chunkKeyPrefix(this.stateKey))) {
this.store.delete(row.key);
}
}
await fs
.rm(resolveLegacySyncCachePath(this.storageRootDir), { force: true })
.catch(() => undefined);
}
markCleanShutdown(): void {
this.cleanShutdown = true;
this.dirty = true;
}
async flush(): Promise<void> {
if (this.persistTimer) {
clearTimeout(this.persistTimer);
this.persistTimer = null;
}
while (this.dirty || this.persistPromise) {
if (this.dirty && !this.persistPromise) {
this.persistPromise = this.persist().finally(() => {
this.persistPromise = null;
});
}
await this.persistPromise;
}
}
private markDirtyAndSchedulePersist(): void {
this.cleanShutdown = false;
this.dirty = true;
if (this.persistTimer) {
return;
}
this.persistTimer = setTimeout(() => {
this.persistTimer = null;
void this.flush().catch((err: unknown) => {
LogService.warn("MatrixSyncCacheStore", "Failed to persist Matrix sync store:", err);
});
}, PERSIST_DEBOUNCE_MS);
this.persistTimer.unref?.();
}
private async persist(): Promise<void> {
this.assertStoreAvailable();
this.dirty = false;
const payload: PersistedMatrixSyncStore = {
version: STORE_VERSION,
savedSync: this.savedSync ? cloneJson(this.savedSync) : null,
cleanShutdown: this.cleanShutdown,
...(this.savedClientOptions ? { clientOptions: cloneJson(this.savedClientOptions) } : {}),
};
try {
await this.persistLock(async () => {
this.writePersistedStore(payload);
claimCurrentTokenStorageState({
rootDir: this.storageRootDir,
});
});
} catch (err) {
this.dirty = true;
throw err;
}
}
private writePersistedStore(payload: PersistedMatrixSyncStore): void {
const rows = buildSyncCacheRows(this.stateKey, payload);
for (const row of rows.chunks) {
this.store.register(row.key, row.value);
}
this.store.register(rows.meta.key, rows.meta.value);
for (const row of this.store.entries()) {
if (row.key.startsWith(chunkKeyPrefix(this.stateKey)) && !rows.nextChunkKeys.has(row.key)) {
this.store.delete(row.key);
}
}
}
private assertStoreAvailable(): void {
if (this.storeUnavailableError == null) {
return;
}
throw new Error("Matrix sync cache SQLite store is unavailable; cannot persist sync state", {
cause: this.storeUnavailableError,
});
}
}
function createNoopMatrixSyncCacheStore(): PluginStateSyncKeyedStore<MatrixSyncCacheRecord> {
return {
register: () => {},
registerIfAbsent: () => false,
lookup: () => undefined,
consume: () => undefined,
delete: () => false,
entries: () => [],
clear: () => {},
};
}
function readPersistedStoreFromSyncStore(
store: PluginStateSyncKeyedStore<MatrixSyncCacheRecord>,
stateKey: string,
): PersistedMatrixSyncStore | null {
const meta = store.lookup(metaKey(stateKey));
if (!isSyncCacheMeta(meta)) {
return null;
}
const chunks: string[] = [];
for (let index = 0; index < meta.chunkCount; index += 1) {
const chunk = store.lookup(chunkKey(stateKey, meta.generation, index));
if (!isSyncCacheChunk(chunk) || chunk.index !== index) {
return normalizePersistedStore({
version: STORE_VERSION,
savedSync: null,
clientOptions: meta.clientOptions,
cleanShutdown: false,
});
}
chunks.push(chunk.data);
}
let savedSync: ISyncData | null = null;
if (chunks.length > 0) {
const syncJson = chunks.join("");
if (meta.syncDigest !== digestText(syncJson)) {
return normalizePersistedStore({
version: STORE_VERSION,
savedSync: null,
clientOptions: meta.clientOptions,
cleanShutdown: false,
});
}
try {
savedSync = toPersistedSyncData(JSON.parse(syncJson));
} catch {
savedSync = null;
}
}
return normalizePersistedStore({
version: STORE_VERSION,
savedSync,
clientOptions: meta.clientOptions,
cleanShutdown: meta.cleanShutdown,
});
}
function openMatrixSyncCacheStore(
storageRootDir: string,
): PluginStateSyncKeyedStore<MatrixSyncCacheRecord> {
return getMatrixRuntime().state.openSyncKeyedStore<MatrixSyncCacheRecord>(
openMatrixSyncCacheStoreOptions(storageRootDir),
);
}
function metaKey(stateKey: string): string {
return `${stateKey}:meta`;
}
function chunkKeyPrefix(stateKey: string): string {
return `${stateKey}:sync:`;
}
function chunkKey(stateKey: string, generation: string, index: number): string {
return `${chunkKeyPrefix(stateKey)}${generation}:${index}`;
}
function resolveLegacySyncCachePath(storageRootDir: string): string {
return path.join(storageRootDir, "bot-storage.json");
}
function digestText(value: string): string {
return createHash("sha256").update(value, "utf8").digest("hex");
}
function isSyncCacheMeta(value: unknown): value is MatrixSyncCacheMeta {
return (
isRecord(value) &&
value.kind === "meta" &&
value.version === STORE_VERSION &&
typeof value.generation === "string" &&
value.generation.trim() !== "" &&
typeof value.chunkCount === "number" &&
Number.isSafeInteger(value.chunkCount) &&
value.chunkCount >= 0 &&
value.chunkCount <= SYNC_CACHE_MAX_CHUNKS
);
}
function isSyncCacheChunk(value: unknown): value is MatrixSyncCacheChunk {
return (
isRecord(value) &&
value.kind === "sync-chunk" &&
typeof value.index === "number" &&
Number.isSafeInteger(value.index) &&
value.index >= 0 &&
typeof value.data === "string"
);
}
function chunkSyncCacheJson(value: string): string[] {
const chunks: string[] = [];
const pushChunk = (chunk: string) => {
if (chunks.length >= SYNC_CACHE_MAX_CHUNKS) {
throw new Error("Matrix sync cache exceeds SQLite chunk limit");
}
chunks.push(chunk);
};
let current = "";
let currentBytes = 0;
for (const char of value) {
const charBytes = Buffer.byteLength(char, "utf8");
if (current && currentBytes + charBytes > SYNC_CACHE_CHUNK_BYTES) {
pushChunk(current);
current = "";
currentBytes = 0;
}
current += char;
currentBytes += charBytes;
}
if (current) {
pushChunk(current);
}
return chunks;
}
function buildSyncCacheRows(
stateKey: string,
payload: PersistedMatrixSyncStore,
): {
meta: { key: string; value: MatrixSyncCacheMeta };
chunks: { key: string; value: MatrixSyncCacheChunk }[];
nextChunkKeys: Set<string>;
} {
const generation = randomUUID().replaceAll("-", "");
const syncJson = payload.savedSync ? JSON.stringify(payload.savedSync) : "";
const chunkValues = syncJson ? chunkSyncCacheJson(syncJson) : [];
const chunks = chunkValues.map((data, index) => ({
key: chunkKey(stateKey, generation, index),
value: {
kind: "sync-chunk" as const,
index,
data,
},
}));
return {
chunks,
nextChunkKeys: new Set(chunks.map((chunk) => chunk.key)),
meta: {
key: metaKey(stateKey),
value: {
kind: "meta",
version: STORE_VERSION,
generation,
chunkCount: chunks.length,
...(syncJson ? { syncDigest: digestText(syncJson) } : {}),
...(payload.clientOptions ? { clientOptions: payload.clientOptions } : {}),
cleanShutdown: payload.cleanShutdown === true,
},
},
};
}
export async function readLegacyMatrixSyncCacheState(
storageRootDir: string,
): Promise<PersistedMatrixSyncStore | null> {
try {
const raw = await fs.readFile(resolveLegacySyncCachePath(storageRootDir), "utf8");
const persisted = normalizeLegacyPersistedStore(JSON.parse(raw));
if (!persisted?.savedSync && !persisted?.clientOptions) {
return null;
}
return persisted;
} catch {
return null;
}
}
export async function hasMatrixSyncCacheStateInStore(params: {
storageRootDir: string;
store: Pick<PluginStateKeyedStore<MatrixSyncCacheRecord>, "lookup">;
}): Promise<boolean> {
const stateKey = SYNC_CACHE_STATE_KEY;
const meta = await params.store.lookup(metaKey(stateKey));
if (!isSyncCacheMeta(meta) || meta.chunkCount <= 0) {
return false;
}
const chunks: string[] = [];
for (let index = 0; index < meta.chunkCount; index += 1) {
const chunk = await params.store.lookup(chunkKey(stateKey, meta.generation, index));
if (!isSyncCacheChunk(chunk) || chunk.index !== index) {
return false;
}
chunks.push(chunk.data);
}
const syncJson = chunks.join("");
if (meta.syncDigest !== digestText(syncJson)) {
return false;
}
try {
return toPersistedSyncData(JSON.parse(syncJson)) !== null;
} catch {
return false;
}
}
export async function writeMatrixSyncCacheStateToStore(params: {
storageRootDir: string;
payload: PersistedMatrixSyncStore;
store: MatrixSyncCacheAsyncStore;
}): Promise<void> {
const stateKey = SYNC_CACHE_STATE_KEY;
const rows = buildSyncCacheRows(stateKey, params.payload);
for (const row of rows.chunks) {
await params.store.register(row.key, row.value);
}
await params.store.register(rows.meta.key, rows.meta.value);
for (const row of await params.store.entries()) {
if (row.key.startsWith(chunkKeyPrefix(stateKey)) && !rows.nextChunkKeys.has(row.key)) {
await params.store.delete(row.key);
}
}
}
export function openMatrixSyncCacheStoreOptions(storageRootDir: string) {
return {
namespace: SYNC_CACHE_NAMESPACE,
maxEntries: SYNC_CACHE_MAX_ENTRIES,
env: resolveMatrixSqliteStateEnv({ stateDir: storageRootDir }),
};
}

View File

@@ -0,0 +1,51 @@
// Matrix tests cover logging plugin behavior.
import { logger as matrixJsSdkRootLogger } from "matrix-js-sdk/lib/logger.js";
import { describe, expect, it, vi } from "vitest";
import { ensureMatrixSdkLoggingConfigured, setMatrixSdkLogMode } from "./logging.js";
type MatrixJsSdkTestLogger = typeof matrixJsSdkRootLogger & {
getLevel?: () => number | string;
levels: { WARN: number };
methodFactory?: unknown;
rebuild?: () => void;
setLevel?: (level: number | string, persist?: boolean) => void;
};
describe("Matrix SDK logging", () => {
it("restores the Matrix JS SDK global logger level after quiet mode", () => {
const logger = matrixJsSdkRootLogger as MatrixJsSdkTestLogger;
const originalLevel = logger.getLevel?.();
const originalMethodFactory = logger.methodFactory;
try {
logger.setLevel?.("warn", false);
ensureMatrixSdkLoggingConfigured();
setMatrixSdkLogMode("quiet");
setMatrixSdkLogMode("default");
expect(logger.getLevel?.()).toBe(logger.levels.WARN);
expect(logger.methodFactory).toBe(originalMethodFactory);
} finally {
if (typeof originalLevel === "string" || typeof originalLevel === "number") {
logger.setLevel?.(originalLevel, false);
}
logger.methodFactory = originalMethodFactory;
logger.rebuild?.();
setMatrixSdkLogMode("default");
}
});
it("quiets the Matrix JS SDK global logger for JSON-safe CLI commands", () => {
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => undefined);
try {
ensureMatrixSdkLoggingConfigured();
setMatrixSdkLogMode("quiet");
matrixJsSdkRootLogger.getChild("[MatrixRTCSession test]").debug("noisy diagnostic");
expect(debugSpy).not.toHaveBeenCalled();
} finally {
setMatrixSdkLogMode("default");
debugSpy.mockRestore();
}
});
});

View File

@@ -0,0 +1,141 @@
// Matrix plugin module implements logging behavior.
import { logger as matrixJsSdkRootLogger } from "matrix-js-sdk/lib/logger.js";
import { ConsoleLogger, LogService, setMatrixConsoleLogging } from "../sdk/logger.js";
let matrixSdkLoggingConfigured = false;
let matrixSdkLogMode: "default" | "quiet" = "default";
const matrixSdkBaseLogger = new ConsoleLogger();
let matrixJsSdkRootLoggerSnapshot: MatrixJsSdkRootLoggerSnapshot | null = null;
type MatrixJsSdkLogger = {
trace: (...messageOrObject: unknown[]) => void;
debug: (...messageOrObject: unknown[]) => void;
info: (...messageOrObject: unknown[]) => void;
warn: (...messageOrObject: unknown[]) => void;
error: (...messageOrObject: unknown[]) => void;
getChild: (namespace: string) => MatrixJsSdkLogger;
};
type MatrixJsSdkLoglevelLogger = {
getLevel?: () => number | string;
methodFactory?: unknown;
rebuild?: () => void;
setLevel?: (level: number | string, persist?: boolean) => void;
};
type MatrixJsSdkRootLoggerSnapshot = {
level?: number | string;
methodFactory?: unknown;
};
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
if (!module.includes("MatrixHttpClient")) {
return false;
}
return messageOrObject.some((entry) => {
if (!entry || typeof entry !== "object") {
return false;
}
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
});
}
export function ensureMatrixSdkLoggingConfigured(): void {
if (!matrixSdkLoggingConfigured) {
matrixSdkLoggingConfigured = true;
}
applyMatrixSdkLogger();
}
export function setMatrixSdkLogMode(mode: "default" | "quiet"): void {
matrixSdkLogMode = mode;
if (!matrixSdkLoggingConfigured) {
return;
}
applyMatrixSdkLogger();
}
export function setMatrixSdkConsoleLogging(enabled: boolean): void {
setMatrixConsoleLogging(enabled);
}
export function createMatrixJsSdkClientLogger(prefix = "matrix"): MatrixJsSdkLogger {
return createMatrixJsSdkLoggerInstance(prefix);
}
function applyMatrixSdkLogger(): void {
if (matrixSdkLogMode === "quiet") {
setMatrixJsSdkRootLoggerLevel("silent");
LogService.setLogger({
trace: () => {},
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
});
return;
}
setMatrixJsSdkRootLoggerLevel("debug");
LogService.setLogger({
trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject),
debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject),
info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject),
warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject),
error: (module, ...messageOrObject) => {
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) {
return;
}
matrixSdkBaseLogger.error(module, ...messageOrObject);
},
});
}
function setMatrixJsSdkRootLoggerLevel(level: "debug" | "silent"): void {
const logger = matrixJsSdkRootLogger as unknown as MatrixJsSdkLoglevelLogger;
matrixJsSdkRootLoggerSnapshot ??= {
level: logger.getLevel?.(),
methodFactory: logger.methodFactory,
};
if (level === "silent") {
logger.methodFactory = () => () => undefined;
logger.setLevel?.("silent", false);
logger.rebuild?.();
return;
}
logger.methodFactory = matrixJsSdkRootLoggerSnapshot.methodFactory;
const previousLevel = matrixJsSdkRootLoggerSnapshot.level;
if (typeof previousLevel === "string" || typeof previousLevel === "number") {
logger.setLevel?.(previousLevel, false);
}
logger.rebuild?.();
}
function createMatrixJsSdkLoggerInstance(prefix: string): MatrixJsSdkLogger {
const log = (method: keyof ConsoleLogger, ...messageOrObject: unknown[]): void => {
if (matrixSdkLogMode === "quiet") {
return;
}
(matrixSdkBaseLogger[method] as (module: string, ...args: unknown[]) => void)(
prefix,
...messageOrObject,
);
};
return {
trace: (...messageOrObject) => log("trace", ...messageOrObject),
debug: (...messageOrObject) => log("debug", ...messageOrObject),
info: (...messageOrObject) => log("info", ...messageOrObject),
warn: (...messageOrObject) => log("warn", ...messageOrObject),
error: (...messageOrObject) => {
if (shouldSuppressMatrixHttpNotFound(prefix, messageOrObject)) {
return;
}
log("error", ...messageOrObject);
},
getChild: (namespace: string) => {
const nextNamespace = namespace.trim();
return createMatrixJsSdkLoggerInstance(nextNamespace ? `${prefix}.${nextNamespace}` : prefix);
},
};
}

View File

@@ -0,0 +1,2 @@
// Matrix plugin module implements migration snapshot behavior.
export { maybeCreateMatrixMigrationSnapshot } from "../../migration-snapshot-backup.js";

View File

@@ -0,0 +1,2 @@
// Matrix plugin module implements private network host behavior.
export { isPrivateOrLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime";

Some files were not shown because too many files have changed in this diff Show More