Matrix: replace legacy plugin with new implementation

This commit is contained in:
Gustavo Madeira Santana
2026-03-08 21:45:57 -04:00
parent 455c4f3436
commit 8e962668ce
273 changed files with 7226 additions and 16101 deletions

View File

@@ -21,7 +21,7 @@ import {
function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null {
const normalized = (value ?? "").trim().toLowerCase();
if (normalized === "discord" || normalized === "matrix-js" || normalized === "telegram") {
if (normalized === "discord" || normalized === "matrix" || normalized === "telegram") {
return normalized;
}
return null;
@@ -146,7 +146,7 @@ export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
if (!targetConversationId) {
continue;
}
if (channel === "discord" || channel === "matrix-js") {
if (channel === "discord" || channel === "matrix") {
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel,
@@ -205,7 +205,7 @@ export function resolveConfiguredAcpBindingRecord(params: {
return null;
}
if (channel === "discord" || channel === "matrix-js") {
if (channel === "discord" || channel === "matrix") {
const bindings = listAcpBindings(params.cfg);
const resolveChannelBindingForConversation = (
targetConversationId: string,

View File

@@ -3,7 +3,7 @@ import type { SessionBindingRecord } from "../infra/outbound/session-binding-ser
import { sanitizeAgentId } from "../routing/session-key.js";
import type { AcpRuntimeSessionMode } from "./runtime/types.js";
export type ConfiguredAcpBindingChannel = "discord" | "matrix-js" | "telegram";
export type ConfiguredAcpBindingChannel = "discord" | "matrix" | "telegram";
export type ConfiguredAcpBindingSpec = {
channel: ConfiguredAcpBindingChannel;

View File

@@ -25,7 +25,7 @@ export function isTelegramSurface(params: DiscordSurfaceParams): boolean {
}
export function isMatrixSurface(params: DiscordSurfaceParams): boolean {
return resolveCommandSurfaceChannel(params) === "matrix-js";
return resolveCommandSurfaceChannel(params) === "matrix";
}
export function resolveCommandSurfaceChannel(params: DiscordSurfaceParams): string {

View File

@@ -63,6 +63,8 @@ function createAcpCommandSessionBindingService() {
listBySession: (targetSessionKey: string) =>
hoisted.sessionBindingListBySessionMock(targetSessionKey),
resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
setIdleTimeoutBySession: vi.fn(async () => []),
setMaxAgeBySession: vi.fn(async () => []),
touch: vi.fn(),
unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input),
};
@@ -118,7 +120,7 @@ type FakeBinding = {
targetSessionKey: string;
targetKind: "subagent" | "session";
conversation: {
channel: "discord" | "telegram";
channel: "discord" | "matrix" | "telegram";
accountId: string;
conversationId: string;
parentConversationId?: string;
@@ -243,7 +245,7 @@ function createSessionBindingCapabilities() {
type AcpBindInput = {
targetSessionKey: string;
conversation: {
channel?: "discord" | "matrix-js" | "telegram";
channel?: "discord" | "matrix" | "telegram";
accountId: string;
conversationId: string;
parentConversationId?: string;
@@ -267,9 +269,9 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding {
conversationId: nextConversationId,
parentConversationId: "parent-1",
}
: channel === "matrix-js"
: channel === "matrix"
? {
channel: "matrix-js",
channel: "matrix",
accountId: input.conversation.accountId,
conversationId: nextConversationId,
parentConversationId: input.conversation.parentConversationId ?? "!room:example",
@@ -344,9 +346,9 @@ function createTelegramDmParams(commandBody: string, cfg: OpenClawConfig = baseC
function createMatrixRoomParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "matrix-js",
Surface: "matrix-js",
OriginatingChannel: "matrix-js",
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example",
AccountId: "default",
});
@@ -651,9 +653,7 @@ describe("/acp command", () => {
it("rejects Matrix thread-bound ACP spawn when spawnAcpSessions is not enabled", async () => {
const result = await runMatrixRoomAcpCommand("/acp spawn codex --thread auto");
expect(result?.reply?.text).toContain(
"channels.matrix-js.threadBindings.spawnAcpSessions=true",
);
expect(result?.reply?.text).toContain("channels.matrix.threadBindings.spawnAcpSessions=true");
expect(hoisted.closeMock).toHaveBeenCalledTimes(1);
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
});
@@ -663,7 +663,7 @@ describe("/acp command", () => {
...baseCfg,
channels: {
...baseCfg.channels,
"matrix-js": {
matrix: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
@@ -680,7 +680,7 @@ describe("/acp command", () => {
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "matrix-js",
channel: "matrix",
accountId: "default",
conversationId: "$thread-42",
parentConversationId: "!room:example",

View File

@@ -129,16 +129,16 @@ describe("commands-acp context", () => {
it("resolves Matrix thread conversation ids from room targets", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "matrix-js",
Surface: "matrix-js",
OriginatingChannel: "matrix-js",
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example",
MessageThreadId: "$thread-42",
AccountId: "work",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "matrix-js",
channel: "matrix",
accountId: "work",
threadId: "$thread-42",
conversationId: "$thread-42",

View File

@@ -44,7 +44,7 @@ export function resolveAcpCommandThreadId(params: HandleCommandsParams): string
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
const channel = resolveAcpCommandChannel(params);
if (channel === "matrix-js") {
if (channel === "matrix") {
return resolveMatrixConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
@@ -112,7 +112,7 @@ export function resolveAcpCommandParentConversationId(
params: HandleCommandsParams,
): string | undefined {
const channel = resolveAcpCommandChannel(params);
if (channel === "matrix-js") {
if (channel === "matrix") {
return resolveMatrixParentConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,

View File

@@ -142,9 +142,9 @@ function createTelegramCommandParams(commandBody: string, overrides?: Record<str
function createMatrixCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
return buildCommandTestParams(commandBody, baseCfg, {
Provider: "matrix-js",
Surface: "matrix-js",
OriginatingChannel: "matrix-js",
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example",
To: "room:!room:example",
AccountId: "default",
@@ -203,7 +203,7 @@ function createMatrixBinding(overrides?: Partial<SessionBindingRecord>): Session
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
conversation: {
channel: "matrix-js",
channel: "matrix",
accountId: "default",
conversationId: "$thread-1",
parentConversationId: "!room:example",
@@ -241,8 +241,10 @@ describe("/session idle and /session max-age", () => {
{
targetSessionKey: binding.targetSessionKey,
boundAt: Date.now(),
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
metadata: {
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
},
},
]);
@@ -287,7 +289,9 @@ describe("/session idle and /session max-age", () => {
{
targetSessionKey: binding.targetSessionKey,
boundAt: Date.now(),
maxAgeMs: 3 * 60 * 60 * 1000,
metadata: {
maxAgeMs: 3 * 60 * 60 * 1000,
},
},
]);
@@ -315,8 +319,10 @@ describe("/session idle and /session max-age", () => {
{
targetSessionKey: "agent:main:subagent:child",
boundAt: Date.now(),
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
metadata: {
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
},
},
]);
@@ -347,8 +353,10 @@ describe("/session idle and /session max-age", () => {
{
targetSessionKey: "agent:main:subagent:child",
boundAt,
lastActivityAt: Date.now(),
maxAgeMs: 3 * 60 * 60 * 1000,
metadata: {
lastActivityAt: Date.now(),
maxAgeMs: 3 * 60 * 60 * 1000,
},
},
]);
@@ -376,8 +384,10 @@ describe("/session idle and /session max-age", () => {
{
targetSessionKey: "agent:main:subagent:child",
boundAt: Date.now(),
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
metadata: {
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
},
},
]);

View File

@@ -352,7 +352,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
const accountId = resolveChannelAccountId(params);
const sessionBindingService = getSessionBindingService();
const channel = onDiscord ? "discord" : onTelegram ? "telegram" : "matrix-js";
const channel = onDiscord ? "discord" : onTelegram ? "telegram" : "matrix";
const threadId =
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
const conversationId = onTelegram

View File

@@ -107,9 +107,9 @@ function createTelegramTopicCommandParams(commandBody: string) {
function createMatrixCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "matrix-js",
Surface: "matrix-js",
OriginatingChannel: "matrix-js",
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example",
To: "room:!room:example",
AccountId: "default",
@@ -239,7 +239,7 @@ describe("/focus, /unfocus, /agents", () => {
const cfg = {
...baseCfg,
channels: {
"matrix-js": {
matrix: {
threadBindings: {
spawnAcpSessions: true,
},
@@ -253,7 +253,7 @@ describe("/focus, /unfocus, /agents", () => {
expect.objectContaining({
placement: "child",
conversation: expect.objectContaining({
channel: "matrix-js",
channel: "matrix",
conversationId: "!room:example",
parentConversationId: "!room:example",
}),
@@ -264,9 +264,7 @@ describe("/focus, /unfocus, /agents", () => {
it("/focus rejects Matrix child thread creation when spawn config is not enabled", async () => {
const result = await focusCodexAcp(createMatrixCommandParams("/focus codex-acp"));
expect(result?.reply?.text).toContain(
"channels.matrix-js.threadBindings.spawnAcpSessions=true",
);
expect(result?.reply?.text).toContain("channels.matrix.threadBindings.spawnAcpSessions=true");
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
});

View File

@@ -15,7 +15,7 @@ function formatConversationBindingText(params: {
if (params.channel === "discord") {
return `thread:${params.conversationId}`;
}
if (params.channel === "matrix-js") {
if (params.channel === "matrix") {
return `thread:${params.conversationId}`;
}
if (params.channel === "telegram") {
@@ -67,9 +67,9 @@ export function handleSubagentsAgentsAction(ctx: SubagentsCommandContext): Comma
channel,
conversationId: binding.conversation.conversationId,
})
: channel === "discord" || channel === "telegram" || channel === "matrix-js"
: channel === "discord" || channel === "telegram" || channel === "matrix"
? "unbound"
: "bindings available on discord/matrix-js/telegram";
: "bindings available on discord/matrix/telegram";
lines.push(`${index}. ${formatRunLabel(entry)} (${bindingText})`);
index += 1;
}

View File

@@ -31,7 +31,7 @@ import {
} from "./shared.js";
type FocusBindingContext = {
channel: "discord" | "telegram" | "matrix-js";
channel: "discord" | "telegram" | "matrix";
accountId: string;
conversationId: string;
parentConversationId?: string;
@@ -98,7 +98,7 @@ function resolveFocusBindingContext(
},
});
return {
channel: "matrix-js",
channel: "matrix",
accountId: resolveChannelAccountId(params),
conversationId,
...(parentConversationId ? { parentConversationId } : {}),
@@ -114,7 +114,7 @@ export async function handleSubagentsFocusAction(
): Promise<CommandHandlerResult> {
const { params, runs, restTokens } = ctx;
const channel = resolveCommandSurfaceChannel(params);
if (channel !== "discord" && channel !== "telegram" && channel !== "matrix-js") {
if (channel !== "discord" && channel !== "telegram" && channel !== "matrix") {
return stopWithText("⚠️ /focus is only available on Discord, Matrix, and Telegram.");
}
@@ -151,7 +151,7 @@ export async function handleSubagentsFocusAction(
"⚠️ /focus on Telegram requires a topic context in groups, or a direct-message conversation.",
);
}
if (channel === "matrix-js") {
if (channel === "matrix") {
return stopWithText("⚠️ Could not resolve a Matrix conversation for /focus.");
}
return stopWithText("⚠️ Could not resolve a Discord channel for /focus.");
@@ -185,7 +185,7 @@ export async function handleSubagentsFocusAction(
if (!capabilities.placements.includes(bindingContext.placement)) {
return stopWithText(`⚠️ ${channel} bindings are unavailable for this account.`);
}
if (bindingContext.channel === "matrix-js" && bindingContext.placement === "child") {
if (bindingContext.channel === "matrix" && bindingContext.placement === "child") {
const spawnPolicy = resolveThreadBindingSpawnPolicy({
cfg: params.cfg,
channel: bindingContext.channel,

View File

@@ -18,7 +18,7 @@ export async function handleSubagentsUnfocusAction(
): Promise<CommandHandlerResult> {
const { params } = ctx;
const channel = resolveCommandSurfaceChannel(params);
if (channel !== "discord" && channel !== "telegram" && channel !== "matrix-js") {
if (channel !== "discord" && channel !== "telegram" && channel !== "matrix") {
return stopWithText("⚠️ /unfocus is only available on Discord, Matrix, and Telegram.");
}
@@ -64,7 +64,7 @@ export async function handleSubagentsUnfocusAction(
if (channel === "discord") {
return stopWithText("⚠️ /unfocus must be run inside a Discord thread.");
}
if (channel === "matrix-js") {
if (channel === "matrix") {
return stopWithText("⚠️ /unfocus must be run inside a focused Matrix thread.");
}
return stopWithText(
@@ -82,7 +82,7 @@ export async function handleSubagentsUnfocusAction(
return stopWithText(
channel === "discord"
? " This thread is not currently focused."
: channel === "matrix-js"
: channel === "matrix"
? " This thread is not currently focused."
: " This conversation is not currently focused.",
);
@@ -95,7 +95,7 @@ export async function handleSubagentsUnfocusAction(
return stopWithText(
channel === "discord"
? `⚠️ Only ${boundBy} can unfocus this thread.`
: channel === "matrix-js"
: channel === "matrix"
? `⚠️ Only ${boundBy} can unfocus this thread.`
: `⚠️ Only ${boundBy} can unfocus this conversation.`,
);
@@ -106,7 +106,7 @@ export async function handleSubagentsUnfocusAction(
reason: "manual",
});
return stopWithText(
channel === "discord" || channel === "matrix-js"
channel === "discord" || channel === "matrix"
? "✅ Thread unfocused."
: "✅ Conversation unfocused.",
);

View File

@@ -235,6 +235,29 @@ const COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([
]);
const SINGLE_ACCOUNT_KEYS_TO_MOVE_BY_CHANNEL: Record<string, ReadonlySet<string>> = {
matrix: new Set([
"initialSyncLimit",
"encryption",
"allowlistOnly",
"replyToMode",
"threadReplies",
"textChunkLimit",
"chunkMode",
"responsePrefix",
"ackReaction",
"ackReactionScope",
"reactionNotifications",
"threadBindings",
"startupVerification",
"startupVerificationCooldownHours",
"mediaMaxMb",
"autoJoin",
"autoJoinAllowlist",
"dm",
"groups",
"rooms",
"actions",
]),
telegram: new Set(["streaming"]),
};

View File

@@ -18,7 +18,7 @@ describe("resolveThreadBindingSpawnPolicy", () => {
expect(
resolveThreadBindingSpawnPolicy({
cfg: baseCfg,
channel: "matrix-js",
channel: "matrix",
kind: "subagent",
}).spawnEnabled,
).toBe(false);
@@ -35,7 +35,7 @@ describe("resolveThreadBindingSpawnPolicy", () => {
const cfg = {
...baseCfg,
channels: {
"matrix-js": {
matrix: {
threadBindings: {
spawnSubagentSessions: true,
spawnAcpSessions: true,
@@ -47,14 +47,14 @@ describe("resolveThreadBindingSpawnPolicy", () => {
expect(
resolveThreadBindingSpawnPolicy({
cfg,
channel: "matrix-js",
channel: "matrix",
kind: "subagent",
}).spawnEnabled,
).toBe(true);
expect(
resolveThreadBindingSpawnPolicy({
cfg,
channel: "matrix-js",
channel: "matrix",
kind: "acp",
}).spawnEnabled,
).toBe(true);

View File

@@ -2,7 +2,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { normalizeAccountId } from "../routing/session-key.js";
export const DISCORD_THREAD_BINDING_CHANNEL = "discord";
export const MATRIX_JS_THREAD_BINDING_CHANNEL = "matrix-js";
export const MATRIX_THREAD_BINDING_CHANNEL = "matrix";
export const TELEGRAM_THREAD_BINDING_CHANNEL = "telegram";
const DEFAULT_THREAD_BINDING_IDLE_HOURS = 24;
const DEFAULT_THREAD_BINDING_MAX_AGE_HOURS = 0;
@@ -116,7 +116,7 @@ function resolveSpawnConfigPath(params: {
params.kind === "subagent" ? "spawnSubagentSessions=true" : "spawnAcpSessions=true";
if (
params.channel === DISCORD_THREAD_BINDING_CHANNEL ||
params.channel === MATRIX_JS_THREAD_BINDING_CHANNEL ||
params.channel === MATRIX_THREAD_BINDING_CHANNEL ||
params.channel === TELEGRAM_THREAD_BINDING_CHANNEL
) {
return `channels.${params.channel}.threadBindings.${suffix}`;
@@ -215,8 +215,8 @@ export function formatThreadBindingSpawnDisabledError(params: {
const label =
params.channel === DISCORD_THREAD_BINDING_CHANNEL
? "Discord"
: params.channel === MATRIX_JS_THREAD_BINDING_CHANNEL
? "Matrix-js"
: params.channel === MATRIX_THREAD_BINDING_CHANNEL
? "Matrix"
: params.channel === TELEGRAM_THREAD_BINDING_CHANNEL
? "Telegram"
: params.channel;

View File

@@ -174,7 +174,7 @@ describe("registerAgentCommands", () => {
"--agent",
"ops",
"--bind",
"matrix-js:ops",
"matrix:ops",
"--bind",
"telegram",
"--json",
@@ -182,7 +182,7 @@ describe("registerAgentCommands", () => {
expect(agentsBindCommandMock).toHaveBeenCalledWith(
{
agent: "ops",
bind: ["matrix-js:ops", "telegram"],
bind: ["matrix:ops", "telegram"],
json: true,
},
runtime,

View File

@@ -15,9 +15,9 @@ vi.mock("../channels/plugins/index.js", async (importOriginal) => {
return {
...actual,
getChannelPlugin: (channel: string) => {
if (channel === "matrix-js") {
if (channel === "matrix") {
return {
id: "matrix-js",
id: "matrix",
setup: {
resolveBindingAccountId: ({ agentId }: { agentId: string }) => agentId.toLowerCase(),
},
@@ -26,8 +26,8 @@ vi.mock("../channels/plugins/index.js", async (importOriginal) => {
return actual.getChannelPlugin(channel);
},
normalizeChannelId: (channel: string) => {
if (channel.trim().toLowerCase() === "matrix-js") {
return "matrix-js";
if (channel.trim().toLowerCase() === "matrix") {
return "matrix";
}
return actual.normalizeChannelId(channel);
},
@@ -52,7 +52,7 @@ describe("agents bind/unbind commands", () => {
...baseConfigSnapshot,
config: {
bindings: [
{ agentId: "main", match: { channel: "matrix-js" } },
{ agentId: "main", match: { channel: "matrix" } },
{ agentId: "ops", match: { channel: "telegram", accountId: "work" } },
],
},
@@ -60,7 +60,7 @@ describe("agents bind/unbind commands", () => {
await agentsBindingsCommand({}, runtime);
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("main <- matrix-js"));
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("main <- matrix"));
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("ops <- telegram accountId=work"),
);
@@ -76,23 +76,25 @@ describe("agents bind/unbind commands", () => {
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [{ agentId: "main", match: { channel: "telegram" } }],
bindings: [{ type: "route", agentId: "main", match: { channel: "telegram" } }],
}),
);
expect(runtime.exit).not.toHaveBeenCalled();
});
it("defaults matrix-js accountId to the target agent id when omitted", async () => {
it("defaults matrix accountId to the target agent id when omitted", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {},
});
await agentsBindCommand({ agent: "main", bind: ["matrix-js"] }, runtime);
await agentsBindCommand({ agent: "main", bind: ["matrix"] }, runtime);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [{ agentId: "main", match: { channel: "matrix-js", accountId: "main" } }],
bindings: [
{ type: "route", agentId: "main", match: { channel: "matrix", accountId: "main" } },
],
}),
);
expect(runtime.exit).not.toHaveBeenCalled();
@@ -123,7 +125,7 @@ describe("agents bind/unbind commands", () => {
config: {
agents: { list: [{ id: "ops", workspace: "/tmp/ops" }] },
bindings: [
{ agentId: "main", match: { channel: "matrix-js" } },
{ agentId: "main", match: { channel: "matrix" } },
{ agentId: "ops", match: { channel: "telegram", accountId: "work" } },
],
},
@@ -133,7 +135,7 @@ describe("agents bind/unbind commands", () => {
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [{ agentId: "main", match: { channel: "matrix-js" } }],
bindings: [{ agentId: "main", match: { channel: "matrix" } }],
}),
);
expect(runtime.exit).not.toHaveBeenCalled();

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { matrixPlugin } from "../../extensions/matrix-js/src/channel.js";
import { matrixPlugin } from "../../extensions/matrix/src/channel.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { agentsBindCommand } from "./agents.js";
@@ -15,7 +15,7 @@ vi.mock("../config/config.js", async (importOriginal) => ({
writeConfigFile: writeConfigFileMock,
}));
describe("agents bind matrix-js integration", () => {
describe("agents bind matrix integration", () => {
const runtime = createTestRuntime();
beforeEach(() => {
@@ -26,7 +26,7 @@ describe("agents bind matrix-js integration", () => {
runtime.exit.mockClear();
setActivePluginRegistry(
createTestRegistry([{ pluginId: "matrix-js", plugin: matrixPlugin, source: "test" }]),
createTestRegistry([{ pluginId: "matrix", plugin: matrixPlugin, source: "test" }]),
);
});
@@ -34,17 +34,19 @@ describe("agents bind matrix-js integration", () => {
setDefaultChannelPluginRegistryForTests();
});
it("uses matrix-js plugin binding resolver when accountId is omitted", async () => {
it("uses matrix plugin binding resolver when accountId is omitted", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {},
});
await agentsBindCommand({ agent: "main", bind: ["matrix-js"] }, runtime);
await agentsBindCommand({ agent: "main", bind: ["matrix"] }, runtime);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [{ agentId: "main", match: { channel: "matrix-js", accountId: "main" } }],
bindings: [
{ type: "route", agentId: "main", match: { channel: "matrix", accountId: "main" } },
],
}),
);
expect(runtime.exit).not.toHaveBeenCalled();

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
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";
@@ -145,6 +146,185 @@ describe("doctor config flow", () => {
});
});
it("previews Matrix legacy state migration in read-only mode", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
await fs.writeFile(
path.join(stateDir, "openclaw.json"),
JSON.stringify({
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
}),
);
await fs.writeFile(
path.join(stateDir, "matrix", "bot-storage.json"),
'{"next_batch":"s1"}',
);
await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true },
confirm: async () => false,
});
});
const warning = noteSpy.mock.calls.find(
(call) =>
call[1] === "Doctor warnings" &&
String(call[0]).includes("Matrix plugin upgraded in place."),
);
expect(warning?.[0]).toContain("Legacy sync store:");
expect(warning?.[0]).toContain(
'Run "openclaw doctor --fix" to migrate this Matrix state now.',
);
} finally {
noteSpy.mockRestore();
}
});
it("previews Matrix encrypted-state migration in read-only mode", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
const { rootDir: accountRoot } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
});
await fs.mkdir(path.join(accountRoot, "crypto"), { recursive: true });
await fs.writeFile(
path.join(stateDir, "openclaw.json"),
JSON.stringify({
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
}),
);
await fs.writeFile(
path.join(accountRoot, "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: "DEVICE123" }),
);
await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true },
confirm: async () => false,
});
});
const warning = noteSpy.mock.calls.find(
(call) =>
call[1] === "Doctor warnings" &&
String(call[0]).includes("Matrix encrypted-state migration is pending"),
);
expect(warning?.[0]).toContain("Legacy crypto store:");
expect(warning?.[0]).toContain("New recovery key file:");
} finally {
noteSpy.mockRestore();
}
});
it("migrates Matrix legacy state on doctor repair", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
await fs.writeFile(
path.join(stateDir, "openclaw.json"),
JSON.stringify({
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
}),
);
await fs.writeFile(
path.join(stateDir, "matrix", "bot-storage.json"),
'{"next_batch":"s1"}',
);
await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true, repair: true },
confirm: async () => false,
});
const migratedRoot = path.join(
stateDir,
"matrix",
"accounts",
"default",
"matrix.example.org__bot_example.org",
);
const migratedChildren = await fs.readdir(migratedRoot);
expect(migratedChildren.length).toBe(1);
expect(
await fs
.access(path.join(migratedRoot, migratedChildren[0] ?? "", "bot-storage.json"))
.then(() => true)
.catch(() => false),
).toBe(true);
expect(
await fs
.access(path.join(stateDir, "matrix", "bot-storage.json"))
.then(() => true)
.catch(() => false),
).toBe(false);
});
expect(
noteSpy.mock.calls.some(
(call) =>
call[1] === "Doctor changes" &&
String(call[0]).includes("Matrix plugin upgraded in place."),
),
).toBe(true);
} finally {
noteSpy.mockRestore();
}
});
it("warns when Matrix is installed from a stale custom path", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "tok-123",
},
},
plugins: {
installs: {
matrix: {
source: "path",
sourcePath: "/tmp/openclaw-matrix-missing",
installPath: "/tmp/openclaw-matrix-missing",
},
},
},
});
expect(
doctorWarnings.some((line) =>
line.includes("Matrix is installed from a custom path that no longer exists"),
),
).toBe(true);
expect(
doctorWarnings.some((line) => line.includes("openclaw plugins install @openclaw/matrix")),
).toBe(true);
});
it("preserves discord streaming intent while stripping unsupported keys on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,

View File

@@ -26,6 +26,14 @@ import {
isTrustedSafeBinPath,
normalizeTrustedSafeBinDirs,
} from "../infra/exec-safe-bin-trust.js";
import {
autoPrepareLegacyMatrixCrypto,
detectLegacyMatrixCrypto,
} from "../infra/matrix-legacy-crypto.js";
import {
autoMigrateLegacyMatrixState,
detectLegacyMatrixState,
} from "../infra/matrix-legacy-state.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import {
formatChannelAccountsDefaultPath,
@@ -285,6 +293,68 @@ function collectTelegramAllowFromLists(
return refs;
}
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}`,
'- Run "openclaw doctor --fix" to migrate this Matrix state now.',
].join("\n");
}
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}`,
`- New recovery key file: ${plan.recoveryKeyPath}`,
`- Migration state file: ${plan.statePath}`,
'- 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;
}
async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise<string[]> {
const install = cfg.plugins?.installs?.matrix;
if (!install || install.source !== "path") {
return [];
}
const candidatePaths = [install.sourcePath, install.installPath]
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean);
if (candidatePaths.length === 0) {
return [];
}
for (const candidatePath of candidatePaths) {
try {
await fs.access(path.resolve(candidatePath));
return [];
} catch {
// keep checking remaining candidates
}
}
const missingPath = candidatePaths[0] ?? "(unknown)";
return [
`- Matrix is installed from a custom path that no longer exists: ${missingPath}`,
`- Reinstall with "${formatCliCommand("openclaw plugins install @openclaw/matrix")}".`,
`- If you are running from a repo checkout, you can also use "${formatCliCommand("openclaw plugins install ./extensions/matrix")}".`,
];
}
function scanTelegramAllowFromUsernameEntries(cfg: OpenClawConfig): TelegramAllowFromUsernameHit[] {
const hits: TelegramAllowFromUsernameHit[] = [];
@@ -1733,6 +1803,69 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
}
}
const matrixLegacyState = detectLegacyMatrixState({
cfg: candidate,
env: process.env,
});
const matrixLegacyCrypto = detectLegacyMatrixCrypto({
cfg: candidate,
env: process.env,
});
if (shouldRepair) {
const matrixStateRepair = await autoMigrateLegacyMatrixState({
cfg: candidate,
env: process.env,
});
if (matrixStateRepair.changes.length > 0) {
note(
[
"Matrix plugin upgraded in place.",
...matrixStateRepair.changes.map((entry) => `- ${entry}`),
"- No user action required.",
].join("\n"),
"Doctor changes",
);
}
if (matrixStateRepair.warnings.length > 0) {
note(matrixStateRepair.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
}
const matrixCryptoRepair = await autoPrepareLegacyMatrixCrypto({
cfg: candidate,
env: process.env,
});
if (matrixCryptoRepair.changes.length > 0) {
note(
[
"Matrix encrypted-state migration prepared.",
...matrixCryptoRepair.changes.map((entry) => `- ${entry}`),
].join("\n"),
"Doctor changes",
);
}
if (matrixCryptoRepair.warnings.length > 0) {
note(matrixCryptoRepair.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
}
} else if (matrixLegacyState) {
if ("warning" in matrixLegacyState) {
note(`- ${matrixLegacyState.warning}`, "Doctor warnings");
} else {
note(formatMatrixLegacyStatePreview(matrixLegacyState), "Doctor warnings");
}
}
if (
!shouldRepair &&
(matrixLegacyCrypto.warnings.length > 0 || matrixLegacyCrypto.plans.length > 0)
) {
for (const preview of formatMatrixLegacyCryptoPreview(matrixLegacyCrypto)) {
note(preview, "Doctor warnings");
}
}
const matrixInstallWarnings = await collectMatrixInstallPathWarnings(candidate);
if (matrixInstallWarnings.length > 0) {
note(matrixInstallWarnings.join("\n"), "Doctor warnings");
}
const missingDefaultAccountBindingWarnings =
collectMissingDefaultAccountBindingWarnings(candidate);
if (missingDefaultAccountBindingWarnings.length > 0) {

View File

@@ -96,6 +96,25 @@ describe("legacy migrate mention routing", () => {
});
});
describe("legacy migrate Matrix config", () => {
it("removes the obsolete channels.matrix.register toggle", () => {
const res = migrateLegacyConfig({
channels: {
matrix: {
register: false,
homeserver: "https://matrix.example.org",
},
},
});
expect(res.changes).toContain("Removed obsolete channels.matrix.register.");
expect(
(res.config?.channels?.matrix as { register?: unknown } | undefined)?.register,
).toBeUndefined();
expect(res.config?.channels?.matrix?.homeserver).toBe("https://matrix.example.org");
});
});
describe("legacy migrate heartbeat config", () => {
it("moves top-level heartbeat into agents.defaults.heartbeat", () => {
const res = migrateLegacyConfig({

View File

@@ -97,6 +97,19 @@ function mergeLegacyIntoDefaults(params: {
// tools.alsoAllow legacy migration intentionally omitted (field not shipped in prod).
export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
{
id: "channels.matrix.register-remove",
describe: "Remove obsolete Matrix registration toggle",
apply: (raw, changes) => {
const channels = getRecord(raw.channels);
const matrix = getRecord(channels?.matrix);
if (!matrix || !("register" in matrix)) {
return;
}
delete matrix.register;
changes.push("Removed obsolete channels.matrix.register.");
},
},
{
// v2026.2.26 added a startup guard requiring gateway.controlUi.allowedOrigins (or the
// host-header fallback flag) for any non-loopback bind. The onboarding wizard was updated

View File

@@ -459,7 +459,7 @@ export const FIELD_HELP: Record<string, string> = {
"bindings[].match":
"Match rule object for deciding when a binding applies, including channel and optional account/peer constraints. Keep rules narrow to avoid accidental agent takeover across contexts.",
"bindings[].match.channel":
"Channel/provider identifier this binding applies to, such as `telegram`, `discord`, `matrix-js`, or another plugin channel ID. Use the configured channel key exactly so binding evaluation works reliably.",
"Channel/provider identifier this binding applies to, such as `telegram`, `discord`, `matrix`, or another plugin channel ID. Use the configured channel key exactly so binding evaluation works reliably.",
"bindings[].match.accountId":
"Optional account selector for multi-account channel setups so the binding applies only to one identity. Use this when account scoping is required for the route and leave unset otherwise.",
"bindings[].match.peer":
@@ -1553,15 +1553,15 @@ export const FIELD_HELP: Record<string, string> = {
"Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel.",
"channels.discord.threadBindings.spawnAcpSessions":
"Allow /acp spawn to auto-create and bind Discord threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.",
"channels.matrix-js.threadBindings.enabled":
"Enable Matrix-js thread binding features (/focus, /unfocus, /agents, /session idle|max-age, and thread-bound routing). Overrides session.threadBindings.enabled when set.",
"channels.matrix-js.threadBindings.idleHours":
"Inactivity window in hours for Matrix-js thread-bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.",
"channels.matrix-js.threadBindings.maxAgeHours":
"Optional hard max age in hours for Matrix-js thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.",
"channels.matrix-js.threadBindings.spawnSubagentSessions":
"channels.matrix.threadBindings.enabled":
"Enable Matrix thread binding features (/focus, /unfocus, /agents, /session idle|max-age, and thread-bound routing). Overrides session.threadBindings.enabled when set.",
"channels.matrix.threadBindings.idleHours":
"Inactivity window in hours for Matrix thread-bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.",
"channels.matrix.threadBindings.maxAgeHours":
"Optional hard max age in hours for Matrix thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.",
"channels.matrix.threadBindings.spawnSubagentSessions":
"Allow top-level /focus flows to auto-create and bind Matrix threads for subagent/session targets (default: false; opt-in). Set true to enable Matrix thread creation/binding from room or DM contexts.",
"channels.matrix-js.threadBindings.spawnAcpSessions":
"channels.matrix.threadBindings.spawnAcpSessions":
"Allow /acp spawn to create or bind Matrix threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.",
"channels.discord.ui.components.accentColor":
"Accent color for Discord component containers (hex). Set per account via channels.discord.accounts.<id>.ui.components.accentColor.",

View File

@@ -776,12 +776,11 @@ export const FIELD_LABELS: Record<string, string> = {
"channels.discord.threadBindings.maxAgeHours": "Discord Thread Binding Max Age (hours)",
"channels.discord.threadBindings.spawnSubagentSessions": "Discord Thread-Bound Subagent Spawn",
"channels.discord.threadBindings.spawnAcpSessions": "Discord Thread-Bound ACP Spawn",
"channels.matrix-js.threadBindings.enabled": "Matrix-js Thread Binding Enabled",
"channels.matrix-js.threadBindings.idleHours": "Matrix-js Thread Binding Idle Timeout (hours)",
"channels.matrix-js.threadBindings.maxAgeHours": "Matrix-js Thread Binding Max Age (hours)",
"channels.matrix-js.threadBindings.spawnSubagentSessions":
"Matrix-js Thread-Bound Subagent Spawn",
"channels.matrix-js.threadBindings.spawnAcpSessions": "Matrix-js Thread-Bound ACP Spawn",
"channels.matrix.threadBindings.enabled": "Matrix Thread Binding Enabled",
"channels.matrix.threadBindings.idleHours": "Matrix Thread Binding Idle Timeout (hours)",
"channels.matrix.threadBindings.maxAgeHours": "Matrix Thread Binding Max Age (hours)",
"channels.matrix.threadBindings.spawnSubagentSessions": "Matrix Thread-Bound Subagent Spawn",
"channels.matrix.threadBindings.spawnAcpSessions": "Matrix Thread-Bound ACP Spawn",
"channels.discord.ui.components.accentColor": "Discord Component Accent Color",
"channels.discord.intents.presence": "Discord Presence Intent",
"channels.discord.intents.guildMembers": "Discord Guild Members Intent",

View File

@@ -100,6 +100,16 @@ export type SessionThreadBindingsConfig = {
* Session auto-unfocuses once this age is reached even if active. Set to 0 to disable. Default: 0.
*/
maxAgeHours?: number;
/**
* Allow thread-capable channels to create and bind child conversations for subagent sessions.
* Channels that support this use explicit opt-in. Default: false.
*/
spawnSubagentSessions?: boolean;
/**
* Allow thread-capable channels to create and bind child conversations for ACP sessions.
* Channels that support this use explicit opt-in. Default: false.
*/
spawnAcpSessions?: boolean;
};
export type SessionConfig = {

View File

@@ -71,12 +71,12 @@ const AcpBindingSchema = z
return;
}
const channel = value.match.channel.trim().toLowerCase();
if (channel !== "discord" && channel !== "matrix-js" && channel !== "telegram") {
if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["match", "channel"],
message:
'ACP bindings currently support only "discord", "matrix-js", and "telegram" channels.',
'ACP bindings currently support only "discord", "matrix", and "telegram" channels.',
});
return;
}

View File

@@ -34,6 +34,8 @@ import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js
import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { autoPrepareLegacyMatrixCrypto } from "../infra/matrix-legacy-crypto.js";
import { autoMigrateLegacyMatrixState } from "../infra/matrix-legacy-state.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js";
import {
@@ -331,6 +333,16 @@ export async function startGatewayServer(
}
let secretsDegraded = false;
await autoMigrateLegacyMatrixState({
cfg: autoEnable.changes.length > 0 ? autoEnable.config : configSnapshot.config,
env: process.env,
log,
});
await autoPrepareLegacyMatrixCrypto({
cfg: autoEnable.changes.length > 0 ? autoEnable.config : configSnapshot.config,
env: process.env,
log,
});
const emitSecretsStateEvent = (
code: "SECRETS_RELOADER_DEGRADED" | "SECRETS_RELOADER_RECOVERED",
message: string,

View File

@@ -0,0 +1,122 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import type { OpenClawConfig } from "../config/config.js";
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js";
import { resolveMatrixAccountStorageRoot } from "./matrix-storage-paths.js";
function writeFile(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value, "utf8");
}
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",
},
},
};
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 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 },
});
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 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);
});
});
it("warns when legacy local-only room keys cannot be recovered automatically", 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",
},
},
};
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 result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: "DEVICE123",
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.',
);
const state = JSON.parse(
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
) as {
restoreStatus: string;
};
expect(state.restoreStatus).toBe("manual-action-required");
});
});
});

View File

@@ -0,0 +1,576 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { writeJsonFileAtomically } from "../plugin-sdk/json-store.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "../routing/session-key.js";
import {
resolveMatrixAccountStorageRoot,
resolveMatrixCredentialsPath,
resolveMatrixLegacyFlatStoragePaths,
} from "./matrix-storage-paths.js";
type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
deviceId?: string;
};
type MatrixLegacyCryptoCounts = {
total: number;
backedUp: number;
};
type MatrixLegacyCryptoSummary = {
deviceId: string | null;
roomKeyCounts: MatrixLegacyCryptoCounts | null;
backupVersion: string | null;
decryptionKeyBase64: string | null;
};
export type MatrixLegacyCryptoMigrationState = {
version: 1;
source: "matrix-bot-sdk-rust";
accountId: string;
deviceId: string | null;
roomKeyCounts: MatrixLegacyCryptoCounts | null;
backupVersion: string | null;
decryptionKeyImported: boolean;
restoreStatus: "pending" | "completed" | "manual-action-required";
detectedAt: string;
restoredAt?: string;
importedCount?: number;
totalCount?: number;
lastError?: 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 = {
plans: MatrixLegacyCryptoPlan[];
warnings: string[];
};
export type MatrixLegacyCryptoPreparationResult = {
migrated: boolean;
changes: string[];
warnings: string[];
};
export type MatrixLegacyCryptoPrepareDeps = {
inspectLegacyStore: (params: {
cryptoRootDir: string;
userId: string;
deviceId: string;
}) => Promise<MatrixLegacyCryptoSummary>;
};
type MatrixLegacyBotSdkMetadata = {
deviceId: string | null;
};
type MatrixStoredRecoveryKey = {
version: 1;
createdAt: string;
keyId?: string | null;
encodedPrivateKey?: string;
privateKeyBase64: string;
keyInfo?: {
passphrase?: unknown;
name?: string;
};
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isLegacyBotSdkCryptoStore(cryptoRootDir: string): boolean {
return (
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")),
)
);
}
function loadStoredMatrixCredentials(
env: NodeJS.ProcessEnv,
accountId: string,
): MatrixStoredCredentials | null {
const stateDir = resolveStateDir(env, os.homedir);
const credentialsPath = resolveMatrixCredentialsPath({
stateDir,
accountId: normalizeAccountId(accountId),
});
try {
if (!fs.existsSync(credentialsPath)) {
return null;
}
const parsed = JSON.parse(
fs.readFileSync(credentialsPath, "utf8"),
) as Partial<MatrixStoredCredentials>;
if (
typeof parsed.homeserver !== "string" ||
typeof parsed.userId !== "string" ||
typeof parsed.accessToken !== "string"
) {
return null;
}
return {
homeserver: parsed.homeserver,
userId: parsed.userId,
accessToken: parsed.accessToken,
deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : undefined,
};
} catch {
return null;
}
}
function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record<string, unknown> | null {
return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null;
}
function resolveMatrixAccountIds(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.keys(accounts).map((accountId) => normalizeAccountId(accountId));
return Array.from(new Set(ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID])).toSorted((a, b) =>
a.localeCompare(b),
);
}
function resolveMatrixAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): Record<string, unknown> {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return {};
}
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
const accountEntry = accounts && isRecord(accounts[accountId]) ? accounts[accountId] : null;
const merged = {
...channel,
...accountEntry,
};
delete merged.accounts;
return merged;
}
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) || !isLegacyBotSdkCryptoStore(legacy.cryptoPath)) {
return null;
}
const channel = resolveMatrixChannelConfig(params.cfg);
if (!channel) {
return {
warning:
`Legacy Matrix encrypted state detected at ${legacy.cryptoPath}, but channels.matrix is not configured yet. ` +
'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.',
};
}
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
const configuredDefault = normalizeOptionalAccountId(
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
);
const accountId =
configuredDefault && accounts && isRecord(accounts[configuredDefault])
? configuredDefault
: DEFAULT_ACCOUNT_ID;
const stored = loadStoredMatrixCredentials(params.env, accountId);
const account = resolveMatrixAccountConfig(params.cfg, accountId);
const homeserver = typeof account.homeserver === "string" ? account.homeserver.trim() : "";
const userId =
(typeof account.userId === "string" ? account.userId.trim() : "") || stored?.userId || "";
const accessToken =
(typeof account.accessToken === "string" ? account.accessToken.trim() : "") ||
stored?.accessToken ||
"";
if (!homeserver || !userId || !accessToken) {
return {
warning:
`Legacy Matrix encrypted state detected at ${legacy.cryptoPath}, but the account-scoped target could not be resolved yet ` +
`(need homeserver, userId, and access token for channels.matrix${accountId === DEFAULT_ACCOUNT_ID ? "" : `.accounts.${accountId}`}). ` +
'Start the gateway once with a working Matrix login, or rerun "openclaw doctor --fix" after cached credentials are available.',
};
}
const stateDir = resolveStateDir(params.env, os.homedir);
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver,
userId,
accessToken,
accountId,
});
const metadata = loadLegacyBotSdkMetadata(legacy.cryptoPath);
return {
accountId,
rootDir,
recoveryKeyPath: path.join(rootDir, "recovery-key.json"),
statePath: path.join(rootDir, "legacy-crypto-migration.json"),
legacyCryptoPath: legacy.cryptoPath,
homeserver,
userId,
accessToken,
deviceId: metadata.deviceId ?? stored?.deviceId ?? null,
};
}
function loadLegacyBotSdkMetadata(cryptoRootDir: string): MatrixLegacyBotSdkMetadata {
const metadataPath = path.join(cryptoRootDir, "bot-sdk.json");
const fallback: MatrixLegacyBotSdkMetadata = { deviceId: null };
try {
if (!fs.existsSync(metadataPath)) {
return fallback;
}
const parsed = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as {
deviceId?: unknown;
};
return {
deviceId:
typeof parsed.deviceId === "string" && parsed.deviceId.trim() ? parsed.deviceId : null,
};
} catch {
return fallback;
}
}
function resolveMatrixLegacyCryptoPlans(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): MatrixLegacyCryptoDetection {
const warnings: string[] = [];
const plans: MatrixLegacyCryptoPlan[] = [];
const flatPlan = resolveLegacyMatrixFlatStorePlan(params);
if (flatPlan) {
if ("warning" in flatPlan) {
warnings.push(flatPlan.warning);
} else {
plans.push(flatPlan);
}
}
const stateDir = resolveStateDir(params.env, os.homedir);
for (const accountId of resolveMatrixAccountIds(params.cfg)) {
const account = resolveMatrixAccountConfig(params.cfg, accountId);
const stored = loadStoredMatrixCredentials(params.env, accountId);
const homeserver =
(typeof account.homeserver === "string" ? account.homeserver.trim() : "") ||
stored?.homeserver ||
"";
const userId =
(typeof account.userId === "string" ? account.userId.trim() : "") || stored?.userId || "";
const accessToken =
(typeof account.accessToken === "string" ? account.accessToken.trim() : "") ||
stored?.accessToken ||
"";
if (!homeserver || !userId || !accessToken) {
continue;
}
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver,
userId,
accessToken,
accountId,
});
const legacyCryptoPath = path.join(rootDir, "crypto");
if (!fs.existsSync(legacyCryptoPath) || !isLegacyBotSdkCryptoStore(legacyCryptoPath)) {
continue;
}
if (
plans.some(
(plan) =>
plan.accountId === accountId &&
path.resolve(plan.legacyCryptoPath) === path.resolve(legacyCryptoPath),
)
) {
continue;
}
const metadata = loadLegacyBotSdkMetadata(legacyCryptoPath);
plans.push({
accountId,
rootDir,
recoveryKeyPath: path.join(rootDir, "recovery-key.json"),
statePath: path.join(rootDir, "legacy-crypto-migration.json"),
legacyCryptoPath,
homeserver,
userId,
accessToken,
deviceId: metadata.deviceId ?? stored?.deviceId ?? null,
});
}
return { plans, warnings };
}
function loadStoredRecoveryKey(filePath: string): MatrixStoredRecoveryKey | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixStoredRecoveryKey;
} catch {
return null;
}
}
function loadLegacyCryptoMigrationState(filePath: string): MatrixLegacyCryptoMigrationState | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixLegacyCryptoMigrationState;
} catch {
return null;
}
}
function resolveLegacyMachineStorePath(params: {
cryptoRootDir: string;
deviceId: string;
}): string | null {
const hashedDir = path.join(
params.cryptoRootDir,
crypto.createHash("sha256").update(params.deviceId).digest("hex"),
);
if (fs.existsSync(path.join(hashedDir, "matrix-sdk-crypto.sqlite3"))) {
return hashedDir;
}
if (fs.existsSync(path.join(params.cryptoRootDir, "matrix-sdk-crypto.sqlite3"))) {
return params.cryptoRootDir;
}
const match = fs
.readdirSync(params.cryptoRootDir, { withFileTypes: true })
.find(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(params.cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")),
);
return match ? path.join(params.cryptoRootDir, match.name) : null;
}
async function inspectLegacyStoreWithCryptoNodejs(params: {
cryptoRootDir: string;
userId: string;
deviceId: string;
}): Promise<MatrixLegacyCryptoSummary> {
const machineStorePath = resolveLegacyMachineStorePath(params);
if (!machineStorePath) {
throw new Error(`Matrix legacy crypto store not found for device ${params.deviceId}`);
}
const { DeviceId, OlmMachine, StoreType, UserId } =
await import("@matrix-org/matrix-sdk-crypto-nodejs");
const machine = await OlmMachine.initialize(
new UserId(params.userId),
new DeviceId(params.deviceId),
machineStorePath,
"",
StoreType.Sqlite,
);
try {
const [backupKeys, roomKeyCounts] = await Promise.all([
machine.getBackupKeys(),
machine.roomKeyCounts(),
]);
return {
deviceId: params.deviceId,
roomKeyCounts: roomKeyCounts
? {
total: typeof roomKeyCounts.total === "number" ? roomKeyCounts.total : 0,
backedUp: typeof roomKeyCounts.backedUp === "number" ? roomKeyCounts.backedUp : 0,
}
: null,
backupVersion:
typeof backupKeys?.backupVersion === "string" && backupKeys.backupVersion.trim()
? backupKeys.backupVersion
: null,
decryptionKeyBase64:
typeof backupKeys?.decryptionKeyBase64 === "string" && backupKeys.decryptionKeyBase64.trim()
? backupKeys.decryptionKeyBase64
: null,
};
} finally {
machine.close();
}
}
async function persistLegacyMigrationState(params: {
filePath: string;
state: MatrixLegacyCryptoMigrationState;
}): Promise<void> {
await writeJsonFileAtomically(params.filePath, params.state);
}
export function detectLegacyMatrixCrypto(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): MatrixLegacyCryptoDetection {
return resolveMatrixLegacyCryptoPlans({
cfg: params.cfg,
env: params.env ?? process.env,
});
}
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 = resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env });
const warnings = [...detection.warnings];
const changes: string[] = [];
const inspectLegacyStore = params.deps?.inspectLegacyStore ?? inspectLegacyStoreWithCryptoNodejs;
for (const plan of detection.plans) {
const existingState = loadLegacyCryptoMigrationState(plan.statePath);
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,
});
} 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 = loadStoredRecoveryKey(plan.recoveryKeyPath);
if (
existingRecoveryKey?.privateKeyBase64 &&
existingRecoveryKey.privateKeyBase64 !== summary.decryptionKeyBase64
) {
warnings.push(
`Legacy Matrix backup key was found for account "${plan.accountId}", but ${plan.recoveryKeyPath} already contains a different recovery key. Leaving the existing file unchanged.`,
);
} else if (!existingRecoveryKey?.privateKeyBase64) {
const payload: MatrixStoredRecoveryKey = {
version: 1,
createdAt: new Date().toISOString(),
keyId: null,
privateKeyBase64: summary.decryptionKeyBase64,
};
await writeJsonFileAtomically(plan.recoveryKeyPath, payload);
changes.push(
`Imported Matrix legacy backup key for account "${plan.accountId}": ${plan.recoveryKeyPath}`,
);
decryptionKeyImported = true;
} 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.`,
);
}
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,
};
await persistLegacyMigrationState({ filePath: plan.statePath, state });
changes.push(
`Prepared Matrix legacy encrypted-state migration for account "${plan.accountId}": ${plan.statePath}`,
);
}
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,86 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import type { OpenClawConfig } from "../config/config.js";
import { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./matrix-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).toEqual([]);
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,286 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "../routing/session-key.js";
import {
resolveMatrixAccountStorageRoot,
resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath,
resolveMatrixLegacyFlatStoragePaths,
} from "./matrix-storage-paths.js";
type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
};
export type MatrixLegacyStateMigrationResult = {
migrated: boolean;
changes: string[];
warnings: string[];
};
type MatrixLegacyStatePlan = {
accountId: string;
legacyStoragePath: string;
legacyCryptoPath: string;
targetRootDir: string;
targetStoragePath: string;
targetCryptoPath: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): {
rootDir: string;
storagePath: string;
cryptoPath: string;
} {
const stateDir = resolveStateDir(env, os.homedir);
return resolveMatrixLegacyFlatStoragePaths(stateDir);
}
function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv, accountId: string): string {
const stateDir = resolveStateDir(env, os.homedir);
return resolveSharedMatrixCredentialsPath({
stateDir,
accountId: normalizeAccountId(accountId),
});
}
function loadStoredMatrixCredentials(
env: NodeJS.ProcessEnv,
accountId: string,
): MatrixStoredCredentials | null {
const credentialsPath = resolveMatrixCredentialsPath(env, accountId);
try {
if (!fs.existsSync(credentialsPath)) {
return null;
}
const parsed = JSON.parse(
fs.readFileSync(credentialsPath, "utf-8"),
) as Partial<MatrixStoredCredentials>;
if (
typeof parsed.homeserver !== "string" ||
typeof parsed.userId !== "string" ||
typeof parsed.accessToken !== "string"
) {
return null;
}
return {
homeserver: parsed.homeserver,
userId: parsed.userId,
accessToken: parsed.accessToken,
};
} catch {
return null;
}
}
function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record<string, unknown> | null {
return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null;
}
function resolveMatrixAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): Record<string, unknown> {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return {};
}
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
const accountEntry = accounts && isRecord(accounts[accountId]) ? accounts[accountId] : null;
const merged = {
...channel,
...accountEntry,
};
delete merged.accounts;
return merged;
}
function resolveMatrixTargetAccountId(cfg: OpenClawConfig): string {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return DEFAULT_ACCOUNT_ID;
}
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
const configuredDefault = normalizeOptionalAccountId(
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
);
if (configuredDefault && accounts && isRecord(accounts[configuredDefault])) {
return configuredDefault;
}
if (accounts && isRecord(accounts[DEFAULT_ACCOUNT_ID])) {
return DEFAULT_ACCOUNT_ID;
}
return DEFAULT_ACCOUNT_ID;
}
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 channel = resolveMatrixChannelConfig(params.cfg);
if (!channel) {
return {
warning:
`Legacy Matrix state detected at ${legacy.rootDir}, but channels.matrix is not configured yet. ` +
'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.',
};
}
const accountId = resolveMatrixTargetAccountId(params.cfg);
const account = resolveMatrixAccountConfig(params.cfg, accountId);
const stored = loadStoredMatrixCredentials(params.env, accountId);
const homeserver = typeof account.homeserver === "string" ? account.homeserver.trim() : "";
const configUserId = typeof account.userId === "string" ? account.userId.trim() : "";
const configAccessToken =
typeof account.accessToken === "string" ? account.accessToken.trim() : "";
const storedMatchesHomeserver =
stored && homeserver ? stored.homeserver === homeserver : Boolean(stored);
const storedMatchesUser =
stored && configUserId ? stored.userId === configUserId : Boolean(stored);
const userId =
configUserId || (storedMatchesHomeserver && storedMatchesUser ? (stored?.userId ?? "") : "");
const accessToken =
configAccessToken ||
(storedMatchesHomeserver && storedMatchesUser ? (stored?.accessToken ?? "") : "");
if (!homeserver || !userId || !accessToken) {
return {
warning:
`Legacy Matrix state detected at ${legacy.rootDir}, but the new account-scoped target could not be resolved yet ` +
`(need homeserver, userId, and access token for channels.matrix${accountId === DEFAULT_ACCOUNT_ID ? "" : `.accounts.${accountId}`}). ` +
'Start the gateway once with a working Matrix login, or rerun "openclaw doctor --fix" after cached credentials are available.',
};
}
const stateDir = resolveStateDir(params.env, os.homedir);
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver,
userId,
accessToken,
accountId,
});
return {
accountId,
legacyStoragePath: legacy.storagePath,
legacyCryptoPath: legacy.cryptoPath,
targetRootDir: rootDir,
targetStoragePath: path.join(rootDir, "bot-storage.json"),
targetCryptoPath: path.join(rootDir, "crypto"),
};
}
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) {
params.log?.info?.(
`matrix: plugin upgraded in place for account "${detection.accountId}".\n${changes
.map((entry) => `- ${entry}`)
.join("\n")}\n- No user action required.`,
);
}
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,93 @@
import crypto from "node:crypto";
import path from "node:path";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
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

@@ -1,5 +1,5 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { matrixPlugin } from "../../../extensions/matrix-js/src/channel.js";
import { matrixPlugin } from "../../../extensions/matrix/src/channel.js";
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -52,7 +52,7 @@ const telegramConfig = {
const matrixConfig = {
channels: {
"matrix-js": {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "matrix-test",
},
@@ -102,14 +102,14 @@ const defaultMatrixDmToolContext = {
} as const;
let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime;
let setMatrixRuntime: typeof import("../../../extensions/matrix-js/src/runtime.js").setMatrixRuntime;
let setMatrixRuntime: typeof import("../../../extensions/matrix/src/runtime.js").setMatrixRuntime;
let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime;
let setTelegramRuntime: typeof import("../../../extensions/telegram/src/runtime.js").setTelegramRuntime;
describe("runMessageAction threading auto-injection", () => {
beforeAll(async () => {
({ createPluginRuntime } = await import("../../plugins/runtime/index.js"));
({ setMatrixRuntime } = await import("../../../extensions/matrix-js/src/runtime.js"));
({ setMatrixRuntime } = await import("../../../extensions/matrix/src/runtime.js"));
({ setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js"));
({ setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js"));
});
@@ -122,7 +122,7 @@ describe("runMessageAction threading auto-injection", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix-js",
pluginId: "matrix",
source: "test",
plugin: matrixPlugin,
},
@@ -278,7 +278,7 @@ describe("runMessageAction threading auto-injection", () => {
const call = await runThreadingAction({
cfg: matrixConfig,
actionParams: {
channel: "matrix-js",
channel: "matrix",
target: testCase.target,
message: "hi",
},
@@ -297,7 +297,7 @@ describe("runMessageAction threading auto-injection", () => {
const call = await runThreadingAction({
cfg: matrixConfig,
actionParams: {
channel: "matrix-js",
channel: "matrix",
target: "room:!room:example.org",
message: "hi",
threadId: "$explicit",
@@ -315,7 +315,7 @@ describe("runMessageAction threading auto-injection", () => {
const call = await runThreadingAction({
cfg: matrixConfig,
actionParams: {
channel: "matrix-js",
channel: "matrix",
target: "user:@alice:example.org",
message: "hi",
},
@@ -332,7 +332,7 @@ describe("runMessageAction threading auto-injection", () => {
const call = await runThreadingAction({
cfg: matrixConfig,
actionParams: {
channel: "matrix-js",
channel: "matrix",
target: "user:@bob:example.org",
message: "hi",
},

View File

@@ -80,7 +80,7 @@ function resolveAndApplyOutboundThreadId(
? resolveTelegramAutoThreadId({ to: ctx.to, toolContext: ctx.toolContext })
: undefined;
const matrixAutoThreadId =
ctx.channel === "matrix-js" && !threadId
ctx.channel === "matrix" && !threadId
? resolveMatrixAutoThreadId({ to: ctx.to, toolContext: ctx.toolContext })
: undefined;
const resolved = threadId ?? slackAutoThreadId ?? telegramAutoThreadId ?? matrixAutoThreadId;

View File

@@ -1,123 +0,0 @@
// Narrow plugin-sdk surface for the bundled matrix-js plugin.
// Keep this list additive and scoped to symbols used under extensions/matrix-js.
export {
createActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringArrayParam,
readStringParam,
} from "../agents/tools/common.js";
export type { ReplyPayload } from "../auto-reply/types.js";
export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js";
export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js";
export { resolveControlCommandGate } from "../channels/command-gating.js";
export type { NormalizedLocation } from "../channels/location.js";
export { formatLocationText, toLocationContext } from "../channels/location.js";
export { logInboundDrop, logTypingFailure } from "../channels/logging.js";
export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js";
export { formatAllowlistMatchMeta } from "../channels/plugins/allowlist-match.js";
export {
buildChannelKeyCandidates,
resolveChannelEntryMatch,
} from "../channels/plugins/channel-config.js";
export {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "../channels/plugins/config-helpers.js";
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
export { formatPairingApproveHint } from "../channels/plugins/helpers.js";
export type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../channels/plugins/onboarding-types.js";
export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js";
export {
addWildcardAllowFrom,
mergeAllowFromEntries,
promptSingleChannelSecretInput,
} from "../channels/plugins/onboarding/helpers.js";
export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js";
export { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js";
export { migrateBaseNameToDefaultAccount } from "../channels/plugins/setup-helpers.js";
export type {
BaseProbeResult,
ChannelDirectoryEntry,
ChannelGroupContext,
ChannelMessageActionAdapter,
ChannelMessageActionContext,
ChannelMessageActionName,
ChannelOutboundAdapter,
ChannelResolveKind,
ChannelResolveResult,
ChannelToolSend,
} from "../channels/plugins/types.js";
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
export type { ChannelSetupInput } from "../channels/plugins/types.js";
export { createReplyPrefixOptions } from "../channels/reply-prefix.js";
export { resolveThreadBindingFarewellText } from "../channels/thread-bindings-messages.js";
export {
resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel,
} from "../channels/thread-bindings-policy.js";
export { createTypingCallbacks } from "../channels/typing.js";
export { resolveAckReaction } from "../agents/identity.js";
export type { OpenClawConfig } from "../config/config.js";
export {
GROUP_POLICY_BLOCKED_LABEL,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "../config/runtime-group-policy.js";
export type {
DmPolicy,
GroupPolicy,
GroupToolPolicyConfig,
MarkdownTableMode,
} from "../config/types.js";
export type { SecretInput } from "../config/types.secrets.js";
export {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "../config/types.secrets.js";
export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js";
export { MarkdownConfigSchema } from "../config/zod-schema.core.js";
export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js";
export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
export {
getSessionBindingService,
registerSessionBindingAdapter,
unregisterSessionBindingAdapter,
} from "../infra/outbound/session-binding-service.js";
export type {
BindingTargetKind,
SessionBindingRecord,
SessionBindingAdapter,
} from "../infra/outbound/session-binding-service.js";
export { issuePairingChallenge } from "../pairing/pairing-challenge.js";
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js";
export type { OpenClawPluginApi } from "../plugins/types.js";
export type { PollInput } from "../polls.js";
export { normalizePollInput } from "../polls.js";
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
export { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
export { resolveConfiguredAcpRoute } from "../acp/persistent-bindings.route.js";
export { ensureConfiguredAcpRouteReady } from "../acp/persistent-bindings.route.js";
export type { RuntimeEnv } from "../runtime.js";
export {
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
} from "../security/dm-policy-shared.js";
export { formatDocsLink } from "../terminal/links.js";
export type { WizardPrompter } from "../wizard/prompts.js";
export { createScopedPairingAccess } from "./pairing-access.js";
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
export { runPluginCommandWithTimeout } from "./run-command.js";
export { createLoggerBackedRuntime } from "./runtime.js";
export { buildProbeChannelStatusSummary } from "./status-helpers.js";
export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js";
export { promptAccountId } from "../channels/plugins/onboarding/helpers.js";

View File

@@ -10,12 +10,20 @@ export {
readStringParam,
} from "../agents/tools/common.js";
export type { ReplyPayload } from "../auto-reply/types.js";
export { resolveAckReaction } from "../agents/identity.js";
export {
compileAllowlist,
resolveAllowlistCandidates,
resolveAllowlistMatchByCandidates,
} from "../channels/allowlist-match.js";
export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js";
resolveConfiguredAcpRoute,
ensureConfiguredAcpRouteReady,
} from "../acp/persistent-bindings.route.js";
export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js";
export {
addAllowlistUserEntriesFromConfigEntry,
buildAllowlistResolutionSummary,
canonicalizeAllowlistWithResolvedIds,
mergeAllowlist,
patchAllowlistUsersInConfigEntries,
summarizeMapping,
} from "../channels/allowlists/resolve-utils.js";
export { resolveControlCommandGate } from "../channels/command-gating.js";
export type { NormalizedLocation } from "../channels/location.js";
export { formatLocationText, toLocationContext } from "../channels/location.js";
@@ -41,11 +49,16 @@ export {
buildSingleChannelSecretPromptState,
addWildcardAllowFrom,
mergeAllowFromEntries,
promptAccountId,
promptSingleChannelSecretInput,
setTopLevelChannelGroupPolicy,
} from "../channels/plugins/onboarding/helpers.js";
export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js";
export { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js";
export {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
moveSingleAccountChannelSectionToDefaultAccount,
} from "../channels/plugins/setup-helpers.js";
export { createAccountListHelpers } from "../channels/plugins/account-helpers.js";
export type {
BaseProbeResult,
@@ -57,10 +70,17 @@ export type {
ChannelOutboundAdapter,
ChannelResolveKind,
ChannelResolveResult,
ChannelSetupInput,
ChannelToolSend,
} from "../channels/plugins/types.js";
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
export { createReplyPrefixOptions } from "../channels/reply-prefix.js";
export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
export { resolveThreadBindingFarewellText } from "../channels/thread-bindings-messages.js";
export {
resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel,
} from "../channels/thread-bindings-policy.js";
export { createTypingCallbacks } from "../channels/typing.js";
export type { OpenClawConfig } from "../config/config.js";
export {
@@ -84,33 +104,66 @@ export {
export { buildSecretInputSchema } from "./secret-input-schema.js";
export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js";
export { MarkdownConfigSchema } from "../config/zod-schema.core.js";
export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js";
export {
hashMatrixAccessToken,
resolveMatrixAccountStorageRoot,
resolveMatrixCredentialsDir,
resolveMatrixCredentialsFilename,
resolveMatrixCredentialsPath,
resolveMatrixHomeserverKey,
resolveMatrixLegacyFlatStoragePaths,
sanitizeMatrixPathSegment,
} from "../infra/matrix-storage-paths.js";
export {
hasActionableMatrixMigration,
hasPendingMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
resolveMatrixMigrationSnapshotMarkerPath,
resolveMatrixMigrationSnapshotOutputDir,
} from "../infra/matrix-migration-snapshot.js";
export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
export {
getSessionBindingService,
registerSessionBindingAdapter,
unregisterSessionBindingAdapter,
} from "../infra/outbound/session-binding-service.js";
export type {
BindingTargetKind,
SessionBindingAdapter,
SessionBindingRecord,
} from "../infra/outbound/session-binding-service.js";
export { issuePairingChallenge } from "../pairing/pairing-challenge.js";
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js";
export type { OpenClawPluginApi } from "../plugins/types.js";
export type { PollInput } from "../polls.js";
export { normalizePollInput } from "../polls.js";
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
export {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
resolveAgentIdFromSessionKey,
} from "../routing/session-key.js";
export type { RuntimeEnv } from "../runtime.js";
export {
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
} from "../security/dm-policy-shared.js";
export { formatDocsLink } from "../terminal/links.js";
export { normalizeStringEntries } from "../shared/string-normalization.js";
export { formatDocsLink } from "../terminal/links.js";
export type { WizardPrompter } from "../wizard/prompts.js";
export {
evaluateGroupRouteAccessForPolicy,
resolveSenderScopedGroupPolicy,
} from "./group-access.js";
export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js";
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
export { createScopedPairingAccess } from "./pairing-access.js";
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
export { runPluginCommandWithTimeout } from "./run-command.js";
export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js";
export { createLoggerBackedRuntime, resolveRuntimeEnv } from "./runtime.js";
export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
export {
buildProbeChannelStatusSummary,
collectStatusIssuesFromLastError,
} from "./status-helpers.js";
export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js";

View File

@@ -26,7 +26,6 @@ const bundledExtensionSubpathLoaders = [
{ id: "llm-task", load: () => import("openclaw/plugin-sdk/llm-task") },
{ id: "lobster", load: () => import("openclaw/plugin-sdk/lobster") },
{ id: "matrix", load: () => import("openclaw/plugin-sdk/matrix") },
{ id: "matrix-js", load: () => import("openclaw/plugin-sdk/matrix-js") },
{ id: "mattermost", load: () => import("openclaw/plugin-sdk/mattermost") },
{ id: "memory-core", load: () => import("openclaw/plugin-sdk/memory-core") },
{ id: "memory-lancedb", load: () => import("openclaw/plugin-sdk/memory-lancedb") },