Matrix: move helper seams and fix routing

This commit is contained in:
Gustavo Madeira Santana
2026-03-18 03:08:29 +00:00
parent 9deb90a6ff
commit 2d41d5e98b
29 changed files with 687 additions and 218 deletions

View File

@@ -0,0 +1,3 @@
export * from "./src/account-selection.js";
export * from "./src/env-vars.js";
export * from "./src/storage-paths.js";

View File

@@ -0,0 +1,96 @@
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
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 normalizedAccountId = normalizeAccountId(accountId);
for (const [rawAccountId, value] of Object.entries(accounts)) {
if (normalizeAccountId(rawAccountId) === normalizedAccountId && isRecord(value)) {
return value;
}
}
return null;
}
export function resolveConfiguredMatrixAccountIds(cfg: OpenClawConfig): string[] {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return [];
}
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
if (!accounts) {
return [DEFAULT_ACCOUNT_ID];
}
const ids = Object.entries(accounts)
.filter(([, value]) => isRecord(value))
.map(([accountId]) => normalizeAccountId(accountId));
return Array.from(new Set(ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID])).toSorted((a, b) =>
a.localeCompare(b),
);
}
export function resolveMatrixDefaultOrOnlyAccountId(cfg: OpenClawConfig): 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);
if (configuredDefault && configuredAccountIds.includes(configuredDefault)) {
return configuredDefault;
}
if (configuredAccountIds.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
if (configuredAccountIds.length === 1) {
return configuredAccountIds[0] ?? DEFAULT_ACCOUNT_ID;
}
return DEFAULT_ACCOUNT_ID;
}
export function requiresExplicitMatrixDefaultAccount(cfg: OpenClawConfig): boolean {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return false;
}
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg);
if (configuredAccountIds.length <= 1) {
return false;
}
const configuredDefault = normalizeOptionalAccountId(
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
);
return !(configuredDefault && configuredAccountIds.includes(configuredDefault));
}

View File

@@ -3,13 +3,13 @@ import {
createActionGate,
readNumberParam,
readStringParam,
requiresExplicitMatrixDefaultAccount,
type ChannelMessageActionAdapter,
type ChannelMessageActionContext,
type ChannelMessageActionName,
type ChannelMessageToolDiscovery,
type ChannelToolSend,
} from "openclaw/plugin-sdk/matrix";
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
import { resolveDefaultMatrixAccountId, resolveMatrixAccount } from "./matrix/accounts.js";
import type { CoreConfig } from "./types.js";

View File

@@ -217,4 +217,37 @@ describe("matrix setup post-write bootstrap", () => {
}
}
});
it("rejects default useEnv setup when no Matrix auth env vars are available", () => {
const previousEnv = {
MATRIX_HOMESERVER: process.env.MATRIX_HOMESERVER,
MATRIX_USER_ID: process.env.MATRIX_USER_ID,
MATRIX_ACCESS_TOKEN: process.env.MATRIX_ACCESS_TOKEN,
MATRIX_PASSWORD: process.env.MATRIX_PASSWORD,
MATRIX_DEFAULT_HOMESERVER: process.env.MATRIX_DEFAULT_HOMESERVER,
MATRIX_DEFAULT_USER_ID: process.env.MATRIX_DEFAULT_USER_ID,
MATRIX_DEFAULT_ACCESS_TOKEN: process.env.MATRIX_DEFAULT_ACCESS_TOKEN,
MATRIX_DEFAULT_PASSWORD: process.env.MATRIX_DEFAULT_PASSWORD,
};
for (const key of Object.keys(previousEnv)) {
delete process.env[key];
}
try {
expect(
matrixPlugin.setup!.validateInput?.({
cfg: {} as CoreConfig,
accountId: "default",
input: { useEnv: true },
}),
).toContain("Set Matrix env vars for the default account");
} finally {
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
});
});

View File

@@ -28,12 +28,7 @@ import {
resolveMatrixAccount,
type ResolvedMatrixAccount,
} from "./matrix/accounts.js";
import {
getMatrixScopedEnvVarNames,
hasReadyMatrixEnvAuth,
resolveMatrixAuth,
resolveScopedMatrixEnvConfig,
} from "./matrix/client.js";
import { resolveMatrixEnvAuthReadiness, resolveMatrixAuth } from "./matrix/client.js";
import { updateMatrixAccountConfig } from "./matrix/config-update.js";
import { resolveMatrixConfigFieldPath, resolveMatrixConfigPath } from "./matrix/config-update.js";
import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
@@ -382,13 +377,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
return "Matrix avatar URL must be an mxc:// URI or an http(s) URL";
}
if (input.useEnv) {
const scopedEnv = resolveScopedMatrixEnvConfig(accountId, process.env);
const scopedReady = hasReadyMatrixEnvAuth(scopedEnv);
if (accountId !== DEFAULT_ACCOUNT_ID && !scopedReady) {
const keys = getMatrixScopedEnvVarNames(accountId);
return `Set per-account env vars for "${accountId}" (for example ${keys.homeserver} + ${keys.accessToken} or ${keys.userId} + ${keys.password}).`;
}
return null;
const envReadiness = resolveMatrixEnvAuthReadiness(accountId, process.env);
return envReadiness.ready ? null : envReadiness.missingMessage;
}
if (!input.homeserver?.trim()) {
return "Matrix requires --homeserver";

View File

@@ -0,0 +1,30 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
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`,
};
}

View File

@@ -2,8 +2,8 @@ import {
DEFAULT_ACCOUNT_ID,
hasConfiguredSecretInput,
normalizeAccountId,
resolveMatrixDefaultOrOnlyAccountId,
} from "openclaw/plugin-sdk/matrix";
import { resolveMatrixDefaultOrOnlyAccountId } from "../account-selection.js";
import type { CoreConfig, MatrixConfig } from "../types.js";
import {
findMatrixAccountConfig,

View File

@@ -1,8 +1,9 @@
export type { MatrixAuth } from "./client/types.js";
export { isBunRuntime } from "./client/runtime.js";
export { getMatrixScopedEnvVarNames } from "openclaw/plugin-sdk/matrix";
export { getMatrixScopedEnvVarNames } from "../env-vars.js";
export {
hasReadyMatrixEnvAuth,
resolveMatrixEnvAuthReadiness,
resolveMatrixConfigForAccount,
resolveScopedMatrixEnvConfig,
resolveMatrixAuth,

View File

@@ -1,13 +1,15 @@
import {
DEFAULT_ACCOUNT_ID,
getMatrixScopedEnvVarNames,
isPrivateOrLoopbackHost,
normalizeAccountId,
normalizeOptionalAccountId,
normalizeResolvedSecretInputString,
} from "openclaw/plugin-sdk/matrix";
import {
requiresExplicitMatrixDefaultAccount,
resolveMatrixDefaultOrOnlyAccountId,
} from "openclaw/plugin-sdk/matrix";
} from "../../account-selection.js";
import { getMatrixScopedEnvVarNames } from "../../env-vars.js";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import {
@@ -111,7 +113,48 @@ function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig {
};
}
export { getMatrixScopedEnvVarNames } from "openclaw/plugin-sdk/matrix";
export { getMatrixScopedEnvVarNames } from "../../env-vars.js";
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}).`,
};
}
export function resolveScopedMatrixEnvConfig(
accountId: string,

View File

@@ -1,15 +1,16 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { maybeCreateMatrixMigrationSnapshot, normalizeAccountId } from "openclaw/plugin-sdk/matrix";
import {
maybeCreateMatrixMigrationSnapshot,
normalizeAccountId,
requiresExplicitMatrixDefaultAccount,
resolveMatrixAccountStorageRoot,
resolveMatrixDefaultOrOnlyAccountId,
resolveMatrixLegacyFlatStoragePaths,
} from "openclaw/plugin-sdk/matrix";
} from "../../account-selection.js";
import { getMatrixRuntime } from "../../runtime.js";
import {
resolveMatrixAccountStorageRoot,
resolveMatrixLegacyFlatStoragePaths,
} from "../../storage-paths.js";
import type { MatrixStoragePaths } from "./types.js";
export const DEFAULT_ACCOUNT_KEY = "default";

View File

@@ -2,14 +2,16 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { writeJsonFileAtomically } from "openclaw/plugin-sdk/matrix";
import {
requiresExplicitMatrixDefaultAccount,
resolveMatrixDefaultOrOnlyAccountId,
} from "../account-selection.js";
import { getMatrixRuntime } from "../runtime.js";
import {
resolveMatrixCredentialsDir as resolveSharedMatrixCredentialsDir,
resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath,
writeJsonFileAtomically,
} from "openclaw/plugin-sdk/matrix";
import { getMatrixRuntime } from "../runtime.js";
} from "../storage-paths.js";
export type MatrixStoredCredentials = {
homeserver: string;

View File

@@ -117,6 +117,85 @@ describe("matrix onboarding", () => {
).toBe(true);
});
it("promotes legacy top-level Matrix config before adding a named account", async () => {
setMatrixRuntime({
state: {
resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) =>
(homeDir ?? (() => "/tmp"))(),
},
config: {
loadConfig: () => ({}),
},
} as never);
const prompter = {
note: vi.fn(async () => {}),
select: vi.fn(async ({ message }: { message: string }) => {
if (message === "Matrix already configured. What do you want to do?") {
return "add-account";
}
if (message === "Matrix auth method") {
return "token";
}
throw new Error(`unexpected select prompt: ${message}`);
}),
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "Matrix account name") {
return "ops";
}
if (message === "Matrix homeserver URL") {
return "https://matrix.ops.example.org";
}
if (message === "Matrix access token") {
return "ops-token";
}
if (message === "Matrix device name (optional)") {
return "";
}
throw new Error(`unexpected text prompt: ${message}`);
}),
confirm: vi.fn(async () => false),
} as unknown as WizardPrompter;
const result = await matrixOnboardingAdapter.configureInteractive!({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.main.example.org",
userId: "@main:example.org",
accessToken: "main-token",
},
},
} as CoreConfig,
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv,
prompter,
options: undefined,
accountOverrides: {},
shouldPromptAccountIds: true,
forceAllowFrom: false,
configured: true,
label: "Matrix",
});
expect(result).not.toBe("skip");
if (result === "skip") {
return;
}
expect(result.cfg.channels?.matrix?.homeserver).toBeUndefined();
expect(result.cfg.channels?.matrix?.accessToken).toBeUndefined();
expect(result.cfg.channels?.matrix?.accounts?.default).toMatchObject({
homeserver: "https://matrix.main.example.org",
userId: "@main:example.org",
accessToken: "main-token",
});
expect(result.cfg.channels?.matrix?.accounts?.ops).toMatchObject({
name: "ops",
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
});
});
it("includes device env var names in auth help text", async () => {
setMatrixRuntime({
state: {

View File

@@ -4,9 +4,9 @@ import {
addWildcardAllowFrom,
formatDocsLink,
mergeAllowFromEntries,
moveSingleAccountChannelSectionToDefaultAccount,
normalizeAccountId,
promptAccountId,
requiresExplicitMatrixDefaultAccount,
type RuntimeEnv,
type WizardPrompter,
} from "openclaw/plugin-sdk/matrix";
@@ -15,6 +15,7 @@ import {
type ChannelSetupDmPolicy,
type ChannelSetupWizardAdapter,
} from "openclaw/plugin-sdk/setup";
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
import {
listMatrixAccountIds,
@@ -22,12 +23,7 @@ import {
resolveMatrixAccount,
resolveMatrixAccountConfig,
} from "./matrix/accounts.js";
import {
getMatrixScopedEnvVarNames,
hasReadyMatrixEnvAuth,
resolveScopedMatrixEnvConfig,
validateMatrixHomeserverUrl,
} from "./matrix/client.js";
import { resolveMatrixEnvAuthReadiness, validateMatrixHomeserverUrl } from "./matrix/client.js";
import {
resolveMatrixConfigFieldPath,
resolveMatrixConfigPath,
@@ -238,6 +234,12 @@ async function runMatrixConfigure(params: {
if (enteredName !== accountId) {
await params.prompter.note(`Account id will be "${accountId}".`, "Matrix account");
}
if (accountId !== DEFAULT_ACCOUNT_ID) {
next = moveSingleAccountChannelSectionToDefaultAccount({
cfg: next,
channelKey: channel,
}) as CoreConfig;
}
next = updateMatrixAccountConfig(next, accountId, { name: enteredName, enabled: true });
} else {
const override = params.accountOverrides?.[channel]?.trim();
@@ -261,27 +263,10 @@ async function runMatrixConfigure(params: {
await noteMatrixAuthHelp(params.prompter);
}
const scopedEnv = resolveScopedMatrixEnvConfig(accountId, process.env);
const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, process.env);
const globalEnv = {
homeserver: process.env.MATRIX_HOMESERVER?.trim() ?? "",
userId: process.env.MATRIX_USER_ID?.trim() ?? "",
accessToken: process.env.MATRIX_ACCESS_TOKEN?.trim() || undefined,
password: process.env.MATRIX_PASSWORD?.trim() || undefined,
};
const scopedReady = hasReadyMatrixEnvAuth(scopedEnv);
const defaultScopedReady = hasReadyMatrixEnvAuth(defaultScopedEnv);
const globalReady = hasReadyMatrixEnvAuth(globalEnv);
const envReady =
scopedReady || (accountId === DEFAULT_ACCOUNT_ID && (defaultScopedReady || globalReady));
const envHomeserver =
scopedEnv.homeserver ||
(accountId === DEFAULT_ACCOUNT_ID
? defaultScopedEnv.homeserver || globalEnv.homeserver
: undefined);
const envUserId =
scopedEnv.userId ||
(accountId === DEFAULT_ACCOUNT_ID ? defaultScopedEnv.userId || globalEnv.userId : undefined);
const envReadiness = resolveMatrixEnvAuthReadiness(accountId, process.env);
const envReady = envReadiness.ready;
const envHomeserver = envReadiness.homeserver;
const envUserId = envReadiness.userId;
if (
envReady &&
@@ -290,13 +275,8 @@ async function runMatrixConfigure(params: {
!existing.accessToken &&
!existing.password
) {
const scopedEnvNames = getMatrixScopedEnvVarNames(accountId);
const envSourceHint =
accountId === DEFAULT_ACCOUNT_ID
? "MATRIX_* or MATRIX_DEFAULT_*"
: `${scopedEnvNames.homeserver} (+ auth vars)`;
const useEnv = await params.prompter.confirm({
message: `Matrix env vars detected (${envSourceHint}). Use env values?`,
message: `Matrix env vars detected (${envReadiness.sourceHint}). Use env values?`,
initialValue: true,
});
if (useEnv) {

View File

@@ -4,6 +4,7 @@ import {
prepareScopedSetupConfig,
type ChannelSetupAdapter,
} from "openclaw/plugin-sdk/setup";
import { resolveMatrixEnvAuthReadiness } from "./matrix/client.js";
import type { CoreConfig } from "./types.js";
const channel = "matrix" as const;
@@ -49,9 +50,10 @@ export const matrixSetupAdapter: ChannelSetupAdapter = {
accountId,
name,
}) as CoreConfig,
validateInput: ({ input }) => {
validateInput: ({ accountId, input }) => {
if (input.useEnv) {
return null;
const envReadiness = resolveMatrixEnvAuthReadiness(accountId, process.env);
return envReadiness.ready ? null : envReadiness.missingMessage;
}
if (!input.homeserver?.trim()) {
return "Matrix requires --homeserver";

View File

@@ -0,0 +1,93 @@
import crypto from "node:crypto";
import path from "node:path";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
export function sanitizeMatrixPathSegment(value: string): string {
const cleaned = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "_")
.replace(/^_+|_+$/g, "");
return cleaned || "unknown";
}
export function resolveMatrixHomeserverKey(homeserver: string): string {
try {
const url = new URL(homeserver);
if (url.host) {
return sanitizeMatrixPathSegment(url.host);
}
} catch {
// fall through
}
return sanitizeMatrixPathSegment(homeserver);
}
export function hashMatrixAccessToken(accessToken: string): string {
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
}
export function resolveMatrixCredentialsFilename(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId);
return normalized === DEFAULT_ACCOUNT_ID ? "credentials.json" : `credentials-${normalized}.json`;
}
export function resolveMatrixCredentialsDir(stateDir: string): string {
return path.join(stateDir, "credentials", "matrix");
}
export function resolveMatrixCredentialsPath(params: {
stateDir: string;
accountId?: string | null;
}): string {
return path.join(
resolveMatrixCredentialsDir(params.stateDir),
resolveMatrixCredentialsFilename(params.accountId),
);
}
export function resolveMatrixLegacyFlatStoreRoot(stateDir: string): string {
return path.join(stateDir, "matrix");
}
export function resolveMatrixLegacyFlatStoragePaths(stateDir: string): {
rootDir: string;
storagePath: string;
cryptoPath: string;
} {
const rootDir = resolveMatrixLegacyFlatStoreRoot(stateDir);
return {
rootDir,
storagePath: path.join(rootDir, "bot-storage.json"),
cryptoPath: path.join(rootDir, "crypto"),
};
}
export function resolveMatrixAccountStorageRoot(params: {
stateDir: string;
homeserver: string;
userId: string;
accessToken: string;
accountId?: string | null;
}): {
rootDir: string;
accountKey: string;
tokenHash: string;
} {
const accountKey = sanitizeMatrixPathSegment(params.accountId ?? DEFAULT_ACCOUNT_ID);
const userKey = sanitizeMatrixPathSegment(params.userId);
const serverKey = resolveMatrixHomeserverKey(params.homeserver);
const tokenHash = hashMatrixAccessToken(params.accessToken);
return {
rootDir: path.join(
params.stateDir,
"matrix",
"accounts",
accountKey,
`${serverKey}__${userKey}`,
tokenHash,
),
accountKey,
tokenHash,
};
}

View File

@@ -452,7 +452,7 @@ describe("spawnAcpDirect", () => {
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
.find((request) => request.method === "agent");
expect(agentCall?.params?.channel).toBe("matrix");
expect(agentCall?.params?.to).toBe("room:child-thread");
expect(agentCall?.params?.to).toBe("room:!room:example");
expect(agentCall?.params?.threadId).toBe("child-thread");
});

View File

@@ -45,6 +45,7 @@ import {
deliveryContextFromSession,
formatConversationTarget,
normalizeDeliveryContext,
resolveConversationDeliveryTarget,
} from "../utils/delivery-context.js";
import {
type AcpSpawnParentRelayHandle,
@@ -670,16 +671,19 @@ export async function spawnAcpDirect(
const fallbackThreadId =
fallbackThreadIdRaw != null ? String(fallbackThreadIdRaw).trim() || undefined : undefined;
const deliveryThreadId = boundThreadId ?? fallbackThreadId;
const boundDeliveryTarget = resolveConversationDeliveryTarget({
channel: requesterOrigin?.channel ?? binding?.conversation.channel,
conversationId: binding?.conversation.conversationId,
parentConversationId: binding?.conversation.parentConversationId,
});
const inferredDeliveryTo =
formatConversationTarget({
channel: requesterOrigin?.channel ?? binding?.conversation.channel,
conversationId: boundThreadId,
}) ??
boundDeliveryTarget.to ??
requesterOrigin?.to?.trim() ??
formatConversationTarget({
channel: requesterOrigin?.channel,
conversationId: deliveryThreadId,
});
const resolvedDeliveryThreadId = boundDeliveryTarget.threadId ?? deliveryThreadId;
const hasDeliveryTarget = Boolean(requesterOrigin?.channel && inferredDeliveryTo);
// Fresh one-shot ACP runs should bootstrap the worker first, then let higher layers
// decide how to relay status. Inline delivery is reserved for thread-bound sessions.
@@ -714,7 +718,7 @@ export async function spawnAcpDirect(
channel: useInlineDelivery ? requesterOrigin?.channel : undefined,
to: useInlineDelivery ? inferredDeliveryTo : undefined,
accountId: useInlineDelivery ? (requesterOrigin?.accountId ?? undefined) : undefined,
threadId: useInlineDelivery ? deliveryThreadId : undefined,
threadId: useInlineDelivery ? resolvedDeliveryThreadId : undefined,
idempotencyKey: childIdem,
deliver: useInlineDelivery,
label: params.label || undefined,

View File

@@ -897,7 +897,8 @@ describe("subagent announce formatting", () => {
expect(agentSpy).toHaveBeenCalledTimes(1);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.channel).toBe("matrix");
expect(call?.params?.to).toBe("room:$thread-bound-1");
expect(call?.params?.to).toBe("room:!room:example");
expect(call?.params?.threadId).toBe("$thread-bound-1");
});
it("includes completion status details for error and timeout outcomes", async () => {

View File

@@ -20,9 +20,9 @@ import { extractTextFromChatContent } from "../shared/chat-content.js";
import {
type DeliveryContext,
deliveryContextFromSession,
formatConversationTarget,
mergeDeliveryContext,
normalizeDeliveryContext,
resolveConversationDeliveryTarget,
} from "../utils/delivery-context.js";
import {
INTERNAL_MESSAGE_CHANNEL,
@@ -554,18 +554,21 @@ async function resolveSubagentCompletionOrigin(params: {
failClosed: false,
});
if (route.mode === "bound" && route.binding) {
const boundTarget = resolveConversationDeliveryTarget({
channel: route.binding.conversation.channel,
conversationId: route.binding.conversation.conversationId,
parentConversationId: route.binding.conversation.parentConversationId,
});
return mergeDeliveryContext(
{
channel: route.binding.conversation.channel,
accountId: route.binding.conversation.accountId,
to: formatConversationTarget({
channel: route.binding.conversation.channel,
conversationId: route.binding.conversation.conversationId,
}),
to: boundTarget.to,
threadId:
requesterOrigin?.threadId != null && requesterOrigin.threadId !== ""
boundTarget.threadId ??
(requesterOrigin?.threadId != null && requesterOrigin.threadId !== ""
? String(requesterOrigin.threadId)
: undefined,
: undefined),
},
requesterOrigin,
);

View File

@@ -1,8 +1,8 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { resolveMatrixAccountStorageRoot } from "../infra/matrix-storage-paths.js";
import * as noteModule from "../terminal/note.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";
@@ -411,8 +411,7 @@ describe("doctor config flow", () => {
expect(
doctorWarnings.some(
(line) =>
line.includes("custom path") && line.includes("/tmp/openclaw-matrix-missing"),
(line) => line.includes("custom path") && line.includes("/tmp/openclaw-matrix-missing"),
),
).toBe(true);
});

View File

@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
findMatrixAccountEntry,
requiresExplicitMatrixDefaultAccount,
resolveConfiguredMatrixAccountIds,
resolveMatrixDefaultOrOnlyAccountId,
} from "./matrix-account-selection.js";
} from "../../extensions/matrix/runtime-api.js";
import type { OpenClawConfig } from "../config/config.js";
describe("matrix account selection", () => {
it("resolves configured account ids from non-canonical account keys", () => {

View File

@@ -1,75 +1,108 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import type { OpenClawConfig } from "../config/config.js";
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js";
import { MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE } from "./matrix-plugin-helper.js";
import { resolveMatrixAccountStorageRoot } from "./matrix-storage-paths.js";
function writeFile(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value, "utf8");
}
function writeMatrixPluginFixture(rootDir: string): void {
fs.mkdirSync(rootDir, { recursive: true });
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "matrix",
configSchema: {
type: "object",
additionalProperties: false,
},
}),
"utf8",
);
fs.writeFileSync(path.join(rootDir, "index.js"), "export default {};\n", "utf8");
fs.writeFileSync(
path.join(rootDir, "legacy-crypto-inspector.js"),
[
"export async function inspectLegacyMatrixCryptoStore() {",
' return { deviceId: "FIXTURE", roomKeyCounts: { total: 1, backedUp: 1 }, backupVersion: "1", decryptionKeyBase64: null };',
"}",
].join("\n"),
"utf8",
);
}
const matrixHelperEnv = {
OPENCLAW_BUNDLED_PLUGINS_DIR: (home: string) => path.join(home, "bundled"),
};
describe("matrix legacy encrypted-state migration", () => {
it("extracts a saved backup key into the new recovery-key path", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
await withTempHome(
async (home) => {
writeMatrixPluginFixture(path.join(home, "bundled", "matrix"));
const stateDir = path.join(home, ".openclaw");
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
},
};
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
});
writeFile(path.join(rootDir, "crypto", "bot-sdk.json"), '{"deviceId":"DEVICE123"}');
};
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
});
writeFile(path.join(rootDir, "crypto", "bot-sdk.json"), '{"deviceId":"DEVICE123"}');
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.warnings).toEqual([]);
expect(detection.plans).toHaveLength(1);
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.warnings).toEqual([]);
expect(detection.plans).toHaveLength(1);
const inspectLegacyStore = vi.fn(async () => ({
deviceId: "DEVICE123",
roomKeyCounts: { total: 12, backedUp: 12 },
backupVersion: "1",
decryptionKeyBase64: "YWJjZA==",
}));
const inspectLegacyStore = vi.fn(async () => ({
deviceId: "DEVICE123",
roomKeyCounts: { total: 12, backedUp: 12 },
backupVersion: "1",
decryptionKeyBase64: "YWJjZA==",
}));
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: { inspectLegacyStore },
});
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: { inspectLegacyStore },
});
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
expect(inspectLegacyStore).toHaveBeenCalledOnce();
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
expect(inspectLegacyStore).toHaveBeenCalledOnce();
const recovery = JSON.parse(
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
) as {
privateKeyBase64: string;
};
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
const recovery = JSON.parse(
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
) as {
privateKeyBase64: string;
};
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
const state = JSON.parse(
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
) as {
restoreStatus: string;
decryptionKeyImported: boolean;
};
expect(state.restoreStatus).toBe("pending");
expect(state.decryptionKeyImported).toBe(true);
});
const state = JSON.parse(
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
) as {
restoreStatus: string;
decryptionKeyImported: boolean;
};
expect(state.restoreStatus).toBe("pending");
expect(state.decryptionKeyImported).toBe(true);
},
{ env: matrixHelperEnv },
);
});
it("warns when legacy local-only room keys cannot be recovered automatically", async () => {
@@ -170,84 +203,89 @@ describe("matrix legacy encrypted-state migration", () => {
});
it("prepares flat legacy crypto for the only configured non-default Matrix account", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: "DEVICEOPS" }),
);
writeFile(
path.join(stateDir, "credentials", "matrix", "credentials-ops.json"),
JSON.stringify(
{
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
accessToken: "tok-ops",
deviceId: "DEVICEOPS",
},
null,
2,
),
);
await withTempHome(
async (home) => {
writeMatrixPluginFixture(path.join(home, "bundled", "matrix"));
const stateDir = path.join(home, ".openclaw");
writeFile(
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: "DEVICEOPS" }),
);
writeFile(
path.join(stateDir, "credentials", "matrix", "credentials-ops.json"),
JSON.stringify(
{
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
accessToken: "tok-ops",
deviceId: "DEVICEOPS",
},
null,
2,
),
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
},
},
},
},
},
};
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
accessToken: "tok-ops",
accountId: "ops",
});
};
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
accessToken: "tok-ops",
accountId: "ops",
});
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.warnings).toEqual([]);
expect(detection.plans).toHaveLength(1);
expect(detection.plans[0]?.accountId).toBe("ops");
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.warnings).toEqual([]);
expect(detection.plans).toHaveLength(1);
expect(detection.plans[0]?.accountId).toBe("ops");
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: "DEVICEOPS",
roomKeyCounts: { total: 6, backedUp: 6 },
backupVersion: "21868",
decryptionKeyBase64: "YWJjZA==",
}),
},
});
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: "DEVICEOPS",
roomKeyCounts: { total: 6, backedUp: 6 },
backupVersion: "21868",
decryptionKeyBase64: "YWJjZA==",
}),
},
});
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
const recovery = JSON.parse(
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
) as {
privateKeyBase64: string;
};
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
const state = JSON.parse(
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
) as {
accountId: string;
};
expect(state.accountId).toBe("ops");
});
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
const recovery = JSON.parse(
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
) as {
privateKeyBase64: string;
};
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
const state = JSON.parse(
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
) as {
accountId: string;
};
expect(state.accountId).toBe("ops");
},
{ env: matrixHelperEnv },
);
});
it("uses scoped Matrix env vars when resolving flat legacy crypto migration", async () => {
await withTempHome(
async (home) => {
writeMatrixPluginFixture(path.join(home, "bundled", "matrix"));
const stateDir = path.join(home, ".openclaw");
writeFile(
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
@@ -300,6 +338,7 @@ describe("matrix legacy encrypted-state migration", () => {
},
{
env: {
...matrixHelperEnv,
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_USER_ID: "@ops-bot:example.org",
MATRIX_OPS_ACCESS_TOKEN: "tok-ops-env",

View File

@@ -1,10 +1,13 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
resolveConfiguredMatrixAccountIds,
resolveMatrixLegacyFlatStoragePaths,
} from "../../extensions/matrix/runtime-api.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "../plugin-sdk/json-store.js";
import { resolveConfiguredMatrixAccountIds } from "./matrix-account-selection.js";
import {
resolveLegacyMatrixFlatStoreTarget,
resolveMatrixMigrationAccountTarget,
@@ -15,7 +18,6 @@ import {
loadMatrixLegacyCryptoInspector,
type MatrixLegacyCryptoInspector,
} from "./matrix-plugin-helper.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./matrix-storage-paths.js";
type MatrixLegacyCryptoCounts = {
total: number;

View File

@@ -1,10 +1,10 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveMatrixLegacyFlatStoragePaths } from "../../extensions/matrix/runtime-api.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { resolveLegacyMatrixFlatStoreTarget } from "./matrix-migration-config.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./matrix-storage-paths.js";
export type MatrixLegacyStateMigrationResult = {
migrated: boolean;

View File

@@ -1,20 +1,18 @@
import fs from "node:fs";
import os from "node:os";
import {
findMatrixAccountEntry,
getMatrixScopedEnvVarNames,
requiresExplicitMatrixDefaultAccount,
resolveConfiguredMatrixAccountIds,
resolveMatrixAccountStorageRoot,
resolveMatrixChannelConfig,
resolveMatrixCredentialsPath,
resolveMatrixDefaultOrOnlyAccountId,
} from "../../extensions/matrix/runtime-api.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import {
findMatrixAccountEntry,
requiresExplicitMatrixDefaultAccount,
resolveConfiguredMatrixAccountIds,
resolveMatrixChannelConfig,
resolveMatrixDefaultOrOnlyAccountId,
} from "./matrix-account-selection.js";
import { getMatrixScopedEnvVarNames } from "./matrix-env-vars.js";
import {
resolveMatrixAccountStorageRoot,
resolveMatrixCredentialsPath,
} from "./matrix-storage-paths.js";
export type MatrixStoredCredentials = {
homeserver: string;

View File

@@ -1,9 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js";
import { resolveMatrixAccountStorageRoot } from "./matrix-storage-paths.js";
const createBackupArchiveMock = vi.hoisted(() => vi.fn());

View File

@@ -106,12 +106,12 @@ export {
resolveMatrixCredentialsDir,
resolveMatrixCredentialsPath,
resolveMatrixLegacyFlatStoragePaths,
} from "../infra/matrix-storage-paths.js";
export { getMatrixScopedEnvVarNames } from "../infra/matrix-env-vars.js";
} from "../../extensions/matrix/runtime-api.js";
export { getMatrixScopedEnvVarNames } from "../../extensions/matrix/runtime-api.js";
export {
requiresExplicitMatrixDefaultAccount,
resolveMatrixDefaultOrOnlyAccountId,
} from "../infra/matrix-account-selection.js";
} from "../../extensions/matrix/runtime-api.js";
export { maybeCreateMatrixMigrationSnapshot } from "../infra/matrix-migration-snapshot.js";
export { getAgentScopedMediaLocalRoots } from "../media/local-roots.js";
export { isPrivateOrLoopbackHost } from "../gateway/net.js";

View File

@@ -6,6 +6,7 @@ import {
mergeDeliveryContext,
normalizeDeliveryContext,
normalizeSessionDeliveryFields,
resolveConversationDeliveryTarget,
} from "./delivery-context.js";
describe("delivery context helpers", () => {
@@ -85,9 +86,29 @@ describe("delivery context helpers", () => {
expect(formatConversationTarget({ channel: "matrix", conversationId: "!room:example" })).toBe(
"room:!room:example",
);
expect(
formatConversationTarget({
channel: "matrix",
conversationId: "$thread",
parentConversationId: "!room:example",
}),
).toBe("room:!room:example");
expect(formatConversationTarget({ channel: "matrix", conversationId: " " })).toBeUndefined();
});
it("resolves delivery targets for Matrix child threads", () => {
expect(
resolveConversationDeliveryTarget({
channel: "matrix",
conversationId: "$thread",
parentConversationId: "!room:example",
}),
).toEqual({
to: "room:!room:example",
threadId: "$thread",
});
});
it("derives delivery context from a session entry", () => {
expect(
deliveryContextFromSession({

View File

@@ -52,6 +52,7 @@ export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryCon
export function formatConversationTarget(params: {
channel?: string;
conversationId?: string | number;
parentConversationId?: string | number;
}): string | undefined {
const channel =
typeof params.channel === "string"
@@ -66,7 +67,55 @@ export function formatConversationTarget(params: {
if (!channel || !conversationId) {
return undefined;
}
return channel === "matrix" ? `room:${conversationId}` : `channel:${conversationId}`;
if (channel === "matrix") {
const parentConversationId =
typeof params.parentConversationId === "number" &&
Number.isFinite(params.parentConversationId)
? String(Math.trunc(params.parentConversationId))
: typeof params.parentConversationId === "string"
? params.parentConversationId.trim()
: undefined;
const roomId =
parentConversationId && parentConversationId !== conversationId
? parentConversationId
: conversationId;
return `room:${roomId}`;
}
return `channel:${conversationId}`;
}
export function resolveConversationDeliveryTarget(params: {
channel?: string;
conversationId?: string | number;
parentConversationId?: string | number;
}): { to?: string; threadId?: string } {
const to = formatConversationTarget(params);
const channel =
typeof params.channel === "string"
? (normalizeMessageChannel(params.channel) ?? params.channel.trim())
: undefined;
const conversationId =
typeof params.conversationId === "number" && Number.isFinite(params.conversationId)
? String(Math.trunc(params.conversationId))
: typeof params.conversationId === "string"
? params.conversationId.trim()
: undefined;
const parentConversationId =
typeof params.parentConversationId === "number" && Number.isFinite(params.parentConversationId)
? String(Math.trunc(params.parentConversationId))
: typeof params.parentConversationId === "string"
? params.parentConversationId.trim()
: undefined;
if (
channel === "matrix" &&
to &&
conversationId &&
parentConversationId &&
parentConversationId !== conversationId
) {
return { to, threadId: conversationId };
}
return { to };
}
export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSource): {