fix: polish matrix e2ee storage (#1298) (thanks @sibbl)

This commit is contained in:
Peter Steinberger
2026-01-20 11:51:08 +00:00
parent 9b71382efb
commit d91f0ceeb3
13 changed files with 1196 additions and 140 deletions

View File

@@ -9,6 +9,7 @@ Docs: https://docs.clawd.bot
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
### Fixes
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.

View File

@@ -118,7 +118,11 @@ Enable with `channels.matrix.encryption: true`:
- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt;
Clawdbot logs a warning.
Crypto state is stored in `~/.clawdbot/matrix/crypto/` (SQLite database).
Crypto state is stored per account + access token in
`~/.clawdbot/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/crypto/`
(SQLite database). Sync state lives alongside it in `bot-storage.json`.
If the access token (device) changes, a new store is created and the bot must be
re-verified for encrypted rooms.
**Device verification:**
When E2EE is enabled, the bot will request verification from your other sessions on startup.

View File

@@ -409,6 +409,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
mediaMaxMb: account.config.mediaMaxMb,
initialSyncLimit: account.config.initialSyncLimit,
replyToMode: account.config.replyToMode,
accountId: account.accountId,
});
},
},

View File

@@ -129,6 +129,14 @@ async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<M
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
});
if (auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await client.crypto.prepare(joinedRooms);
} catch {
// Ignore crypto prep failures for one-off actions.
}
}
await client.start();
return { client, stopOnDone: true };
}

View File

@@ -1,3 +1,8 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
ConsoleLogger,
LogService,
@@ -90,6 +95,139 @@ function clean(value?: string): string {
return value?.trim() ?? "";
}
const DEFAULT_ACCOUNT_KEY = "default";
const STORAGE_META_FILENAME = "storage-meta.json";
type MatrixStoragePaths = {
rootDir: string;
storagePath: string;
cryptoPath: string;
metaPath: string;
accountKey: string;
tokenHash: string;
};
function sanitizePathSegment(value: string): string {
const cleaned = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "_")
.replace(/^_+|_+$/g, "");
return cleaned || "unknown";
}
function resolveHomeserverKey(homeserver: string): string {
try {
const url = new URL(homeserver);
if (url.host) return sanitizePathSegment(url.host);
} catch {
// fall through
}
return sanitizePathSegment(homeserver);
}
function hashAccessToken(accessToken: string): string {
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
}
function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
storagePath: string;
cryptoPath: string;
} {
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
return {
storagePath: path.join(stateDir, "matrix", "bot-storage.json"),
cryptoPath: path.join(stateDir, "matrix", "crypto"),
};
}
function resolveMatrixStoragePaths(params: {
homeserver: string;
userId: string;
accessToken: string;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): MatrixStoragePaths {
const env = params.env ?? process.env;
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY);
const userKey = sanitizePathSegment(params.userId);
const serverKey = resolveHomeserverKey(params.homeserver);
const tokenHash = hashAccessToken(params.accessToken);
const rootDir = path.join(
stateDir,
"matrix",
"accounts",
accountKey,
`${serverKey}__${userKey}`,
tokenHash,
);
return {
rootDir,
storagePath: path.join(rootDir, "bot-storage.json"),
cryptoPath: path.join(rootDir, "crypto"),
metaPath: path.join(rootDir, STORAGE_META_FILENAME),
accountKey,
tokenHash,
};
}
function maybeMigrateLegacyStorage(params: {
storagePaths: MatrixStoragePaths;
env?: NodeJS.ProcessEnv;
}): void {
const legacy = resolveLegacyStoragePaths(params.env);
const hasLegacyStorage = fs.existsSync(legacy.storagePath);
const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath);
const hasNewStorage =
fs.existsSync(params.storagePaths.storagePath) ||
fs.existsSync(params.storagePaths.cryptoPath);
if (!hasLegacyStorage && !hasLegacyCrypto) return;
if (hasNewStorage) return;
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
if (hasLegacyStorage) {
try {
fs.renameSync(legacy.storagePath, params.storagePaths.storagePath);
} catch {
// Ignore migration failures; new store will be created.
}
}
if (hasLegacyCrypto) {
try {
fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath);
} catch {
// Ignore migration failures; new store will be created.
}
}
}
function writeStorageMeta(params: {
storagePaths: MatrixStoragePaths;
homeserver: string;
userId: string;
accountId?: string | null;
}): void {
try {
const payload = {
homeserver: params.homeserver,
userId: params.userId,
accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY,
accessTokenHash: params.storagePaths.tokenHash,
createdAt: new Date().toISOString(),
};
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
fs.writeFileSync(
params.storagePaths.metaPath,
JSON.stringify(payload, null, 2),
"utf-8",
);
} catch {
// ignore meta write failures
}
}
function sanitizeUserIdList(input: unknown, label: string): string[] {
if (input == null) return [];
if (!Array.isArray(input)) {
@@ -139,22 +277,6 @@ export function resolveMatrixConfig(
};
}
export function resolveCryptoStorePath(env: NodeJS.ProcessEnv = process.env): string {
const stateDir = getMatrixRuntime().state.resolveStateDir(env, () =>
require("node:os").homedir(),
);
const path = require("node:path");
return path.join(stateDir, "matrix", "crypto");
}
export function resolveStoragePath(env: NodeJS.ProcessEnv = process.env): string {
const stateDir = getMatrixRuntime().state.resolveStateDir(env, () =>
require("node:os").homedir(),
);
const path = require("node:path");
return path.join(stateDir, "matrix", "bot-storage.json");
}
export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
@@ -288,31 +410,46 @@ export async function createMatrixClient(params: {
accessToken: string;
encryption?: boolean;
localTimeoutMs?: number;
accountId?: string | null;
}): Promise<MatrixClient> {
ensureMatrixSdkLoggingConfigured();
const env = process.env;
// Create storage provider
const storagePath = resolveStoragePath(env);
const fs = await import("node:fs");
const path = await import("node:path");
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePath);
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.homeserver,
userId: params.userId,
accessToken: params.accessToken,
accountId: params.accountId,
env,
});
maybeMigrateLegacyStorage({ storagePaths, env });
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath);
// Create crypto storage if encryption is enabled
let cryptoStorage: ICryptoStorageProvider | undefined;
if (params.encryption) {
const cryptoPath = resolveCryptoStorePath(env);
fs.mkdirSync(cryptoPath, { recursive: true });
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
try {
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
cryptoStorage = new RustSdkCryptoStorageProvider(cryptoPath, StoreType.Sqlite);
cryptoStorage = new RustSdkCryptoStorageProvider(
storagePaths.cryptoPath,
StoreType.Sqlite,
);
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err);
}
}
writeStorageMeta({
storagePaths,
homeserver: params.homeserver,
userId: params.userId,
accountId: params.accountId,
});
const client = new MatrixClient(
params.homeserver,
params.accessToken,
@@ -357,13 +494,20 @@ export async function createMatrixClient(params: {
return client;
}
function buildSharedClientKey(auth: MatrixAuth): string {
return [auth.homeserver, auth.userId, auth.accessToken, auth.encryption ? "e2ee" : "plain"].join("|");
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
return [
auth.homeserver,
auth.userId,
auth.accessToken,
auth.encryption ? "e2ee" : "plain",
accountId ?? DEFAULT_ACCOUNT_KEY,
].join("|");
}
async function createSharedMatrixClient(params: {
auth: MatrixAuth;
timeoutMs?: number;
accountId?: string | null;
}): Promise<SharedMatrixClientState> {
const client = await createMatrixClient({
homeserver: params.auth.homeserver,
@@ -371,10 +515,11 @@ async function createSharedMatrixClient(params: {
accessToken: params.auth.accessToken,
encryption: params.auth.encryption,
localTimeoutMs: params.timeoutMs,
accountId: params.accountId,
});
return {
client,
key: buildSharedClientKey(params.auth),
key: buildSharedClientKey(params.auth, params.accountId),
started: false,
cryptoReady: false,
};
@@ -424,10 +569,11 @@ export async function resolveSharedMatrixClient(
timeoutMs?: number;
auth?: MatrixAuth;
startClient?: boolean;
accountId?: string | null;
} = {},
): Promise<MatrixClient> {
const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env }));
const key = buildSharedClientKey(auth);
const key = buildSharedClientKey(auth, params.accountId);
const shouldStart = params.startClient !== false;
if (sharedClientState?.key === key) {
@@ -463,6 +609,7 @@ export async function resolveSharedMatrixClient(
sharedClientPromise = createSharedMatrixClient({
auth,
timeoutMs: params.timeoutMs,
accountId: params.accountId,
});
try {
const created = await sharedClientPromise;

View File

@@ -36,9 +36,13 @@ export function registerMatrixAutoJoin(params: {
// Get room alias if available
let alias: string | undefined;
let altAliases: string[] = [];
try {
const aliasState = await client.getRoomStateEvent(roomId, "m.room.canonical_alias", "").catch(() => null);
const aliasState = await client
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
.catch(() => null);
alias = aliasState?.alias;
altAliases = Array.isArray(aliasState?.alt_aliases) ? aliasState.alt_aliases : [];
} catch {
// Ignore errors
}
@@ -46,7 +50,8 @@ export function registerMatrixAutoJoin(params: {
const allowed =
autoJoinAllowlist.includes("*") ||
autoJoinAllowlist.includes(roomId) ||
(alias ? autoJoinAllowlist.includes(alias) : false);
(alias ? autoJoinAllowlist.includes(alias) : false) ||
altAliases.some((value) => autoJoinAllowlist.includes(value));
if (!allowed) {
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);

View File

@@ -1,4 +1,5 @@
import type {
EncryptedFile,
LocationMessageEventContent,
MatrixClient,
MessageEventContent,
@@ -77,6 +78,7 @@ type MatrixRawEvent = {
type RoomMessageEventContent = MessageEventContent & {
url?: string;
file?: EncryptedFile;
info?: {
mimetype?: string;
};
@@ -168,6 +170,7 @@ export type MonitorMatrixOpts = {
mediaMaxMb?: number;
initialSyncLimit?: number;
replyToMode?: ReplyToMode;
accountId?: string | null;
};
const DEFAULT_MEDIA_MAX_MB = 20;
@@ -316,6 +319,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
cfg,
auth: authWithLimit,
startClient: false,
accountId: opts.accountId,
});
setActiveMatrixClient(client);
@@ -592,7 +596,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
} | null = null;
const contentUrl =
"url" in content && typeof content.url === "string" ? content.url : undefined;
if (!rawBody && !contentUrl) {
const contentFile =
"file" in content && content.file && typeof content.file === "object"
? (content.file as EncryptedFile)
: undefined;
const mediaUrl = contentUrl ?? contentFile?.url;
if (!rawBody && !mediaUrl) {
return;
}
@@ -600,13 +609,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
"info" in content && content.info && "mimetype" in content.info
? (content.info as { mimetype?: string }).mimetype
: undefined;
if (contentUrl?.startsWith("mxc://")) {
if (mediaUrl?.startsWith("mxc://")) {
try {
media = await downloadMatrixMedia({
client,
mxcUrl: contentUrl,
mxcUrl: mediaUrl,
contentType,
maxBytes: mediaMaxBytes,
file: contentFile,
});
} catch (err) {
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
@@ -937,6 +947,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
await resolveSharedMatrixClient({
cfg,
auth: authWithLimit,
accountId: opts.accountId,
});
logVerboseMessage("matrix: client started");

View File

@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import { setMatrixRuntime } from "../../runtime.js";
import { downloadMatrixMedia } from "./media.js";
describe("downloadMatrixMedia", () => {
const saveMediaBuffer = vi.fn().mockResolvedValue({
path: "/tmp/media",
contentType: "image/png",
});
const runtimeStub = {
channel: {
media: {
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
},
},
} as unknown as PluginRuntime;
beforeEach(() => {
vi.clearAllMocks();
setMatrixRuntime(runtimeStub);
});
it("decrypts encrypted media when file payloads are present", async () => {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const downloadContent = vi.fn().mockResolvedValue(Buffer.from("encrypted"));
const client = {
downloadContent,
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("matrix-bot-sdk").MatrixClient;
const file = {
url: "mxc://example/file",
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
};
const result = await downloadMatrixMedia({
client,
mxcUrl: "mxc://example/file",
contentType: "image/png",
maxBytes: 1024,
file,
});
expect(decryptMedia).toHaveBeenCalled();
expect(saveMediaBuffer).toHaveBeenCalledWith(
Buffer.from("decrypted"),
"image/png",
"inbound",
1024,
);
expect(result?.path).toBe("/tmp/media");
});
});

View File

@@ -131,3 +131,38 @@ describe("sendMessageMatrix media", () => {
expect(content.file?.url).toBe("mxc://example/file");
});
});
describe("sendMessageMatrix threads", () => {
beforeAll(async () => {
setMatrixRuntime(runtimeStub);
({ sendMessageMatrix } = await import("./send.js"));
});
beforeEach(() => {
vi.clearAllMocks();
setMatrixRuntime(runtimeStub);
});
it("includes thread relation metadata when threadId is set", async () => {
const { client, sendMessage } = makeClient();
await sendMessageMatrix("room:!room:example", "hello thread", {
client,
threadId: "$thread",
});
const content = sendMessage.mock.calls[0]?.[1] as {
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};
expect(content["m.relates_to"]).toMatchObject({
rel_type: "m.thread",
event_id: "$thread",
"m.in_reply_to": { event_id: "$thread" },
});
});
});

View File

@@ -56,8 +56,17 @@ type MatrixReplyRelation = {
"m.in_reply_to": { event_id: string };
};
type MatrixThreadRelation = {
rel_type: typeof RelationType.Thread;
event_id: string;
is_falling_back?: boolean;
"m.in_reply_to"?: { event_id: string };
};
type MatrixRelation = MatrixReplyRelation | MatrixThreadRelation;
type MatrixReplyMeta = {
"m.relates_to"?: MatrixReplyRelation;
"m.relates_to"?: MatrixRelation;
};
type MatrixMediaInfo = FileWithThumbnailInfo | DimensionalFileInfo | TimedFileInfo | VideoFileInfo;
@@ -234,7 +243,7 @@ function buildMediaContent(params: {
filename?: string;
mimetype?: string;
size: number;
relation?: MatrixReplyRelation;
relation?: MatrixRelation;
isVoice?: boolean;
durationMs?: number;
imageInfo?: DimensionalFileInfo;
@@ -275,7 +284,7 @@ function buildMediaContent(params: {
return base;
}
function buildTextContent(body: string, relation?: MatrixReplyRelation): MatrixTextContent {
function buildTextContent(body: string, relation?: MatrixRelation): MatrixTextContent {
const content: MatrixTextContent = relation
? {
msgtype: MsgType.Text,
@@ -303,6 +312,16 @@ function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined
return { "m.in_reply_to": { event_id: trimmed } };
}
function buildThreadRelation(threadId: string, replyToId?: string): MatrixThreadRelation {
const trimmed = threadId.trim();
return {
rel_type: RelationType.Thread,
event_id: trimmed,
is_falling_back: true,
"m.in_reply_to": { event_id: (replyToId?.trim() || trimmed) },
};
}
function resolveMatrixMsgType(
contentType?: string,
fileName?: string,
@@ -468,6 +487,14 @@ async function resolveMatrixClient(opts: {
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
});
if (auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await client.crypto.prepare(joinedRooms);
} catch {
// Ignore crypto prep failures for one-off sends; normal sync will retry.
}
}
// matrix-bot-sdk uses start() instead of startClient()
await client.start();
return { client, stopOnDone: true };
@@ -493,7 +520,9 @@ export async function sendMessageMatrix(
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
const threadId = normalizeThreadId(opts.threadId);
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
const relation = threadId
? buildThreadRelation(threadId, opts.replyToId)
: buildReplyRelation(opts.replyToId);
const sendContent = async (content: MatrixOutboundContent) => {
// matrix-bot-sdk uses sendMessage differently
const eventId = await client.sendMessage(roomId, content);
@@ -541,10 +570,11 @@ export async function sendMessageMatrix(
const eventId = await sendContent(content);
lastMessageId = eventId ?? lastMessageId;
const textChunks = useVoice ? chunks : rest;
const followupRelation = threadId ? relation : undefined;
for (const chunk of textChunks) {
const text = chunk.trim();
if (!text) continue;
const followup = buildTextContent(text);
const followup = buildTextContent(text, followupRelation);
const followupEventId = await sendContent(followup);
lastMessageId = followupEventId ?? lastMessageId;
}
@@ -588,8 +618,12 @@ export async function sendPollMatrix(
try {
const roomId = await resolveMatrixRoomId(client, to);
const pollContent = buildPollStartContent(poll);
const threadId = normalizeThreadId(opts.threadId);
const pollPayload = threadId
? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) }
: pollContent;
// matrix-bot-sdk sendEvent returns eventId string directly
const eventId = await client.sendEvent(roomId, M_POLL_START, pollContent);
const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload);
return {
eventId: eventId ?? "unknown",

937
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
function resolvePowerShellPath(): string {

View File

@@ -112,7 +112,8 @@ export function enqueueCommand<T>(
}
export function getQueueSize(lane: string = CommandLane.Main) {
const state = lanes.get(lane);
const resolved = lane.trim() || CommandLane.Main;
const state = lanes.get(resolved);
if (!state) return 0;
return state.queue.length + state.active;
}