mirror of https://github.com/openclaw/openclaw.git
Matrix: simplify plugin migration plumbing
This commit is contained in:
parent
efc07dc6ca
commit
0298bc01fb
|
|
@ -262,6 +262,7 @@ All `verify` commands are concise by default (including quiet internal SDK loggi
|
|||
Use `--json` for full machine-readable output when scripting.
|
||||
|
||||
In multi-account setups, Matrix CLI commands use the implicit Matrix default account unless you pass `--account <id>`.
|
||||
If you configure multiple named accounts, set `channels.matrix.defaultAccount` first or those implicit CLI operations will stop and ask you to choose an account explicitly.
|
||||
Use `--account` whenever you want verification or device operations to target a named account explicitly:
|
||||
|
||||
```bash
|
||||
|
|
@ -531,6 +532,7 @@ See [Pairing](/channels/pairing) for the shared DM pairing flow and storage layo
|
|||
|
||||
Top-level `channels.matrix` values act as defaults for named accounts unless an account overrides them.
|
||||
Set `defaultAccount` when you want OpenClaw to prefer one named Matrix account for implicit routing, probing, and CLI operations.
|
||||
If you configure multiple named accounts, set `defaultAccount` or pass `--account <id>` for CLI commands that rely on implicit account selection.
|
||||
Pass `--account <id>` to `openclaw matrix verify ...` and `openclaw matrix devices ...` when you want to override that implicit selection for one command.
|
||||
|
||||
## Target resolution
|
||||
|
|
@ -550,6 +552,7 @@ Live directory lookup uses the logged-in Matrix account:
|
|||
## Configuration reference
|
||||
|
||||
- `enabled`: enable or disable the channel.
|
||||
- `name`: optional label for the account.
|
||||
- `defaultAccount`: preferred account ID when multiple Matrix accounts are configured.
|
||||
- `homeserver`: homeserver URL, for example `https://matrix.example.org`.
|
||||
- `userId`: full Matrix user ID, for example `@bot:example.org`.
|
||||
|
|
@ -557,6 +560,7 @@ Live directory lookup uses the logged-in Matrix account:
|
|||
- `password`: password for password-based login.
|
||||
- `deviceId`: explicit Matrix device ID.
|
||||
- `deviceName`: device display name for password login.
|
||||
- `avatarUrl`: stored self-avatar URL for profile sync and `set-profile` updates.
|
||||
- `initialSyncLimit`: startup sync event limit.
|
||||
- `encryption`: enable E2EE.
|
||||
- `allowlistOnly`: force allowlist-only behavior for DMs and rooms.
|
||||
|
|
@ -582,4 +586,4 @@ Live directory lookup uses the logged-in Matrix account:
|
|||
- `accounts`: named per-account overrides. Top-level `channels.matrix` values act as defaults for these entries.
|
||||
- `groups`: per-room policy map. Prefer room IDs or aliases; unresolved room names are ignored at runtime. Session/group identity uses the stable room ID after resolution, while human-readable labels still come from room names.
|
||||
- `rooms`: legacy alias for `groups`.
|
||||
- `actions`: per-action tool gating (`messages`, `reactions`, `pins`, `memberInfo`, `channelInfo`, `verification`).
|
||||
- `actions`: per-action tool gating (`messages`, `reactions`, `pins`, `profile`, `memberInfo`, `channelInfo`, `verification`).
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ If your old installation had local-only encrypted history that was never backed
|
|||
Encrypted migration is a two-stage process:
|
||||
|
||||
1. Startup or `openclaw doctor --fix` creates or reuses the pre-migration snapshot if encrypted migration is actionable.
|
||||
2. Startup or `openclaw doctor --fix` inspects the old Matrix crypto store.
|
||||
2. Startup or `openclaw doctor --fix` inspects the old Matrix crypto store through the active Matrix plugin install.
|
||||
3. If a backup decryption key is found, OpenClaw writes it into the new recovery-key flow and marks room-key restore as pending.
|
||||
4. On the next Matrix startup, OpenClaw restores backed-up room keys into the new crypto store automatically.
|
||||
|
||||
|
|
@ -194,6 +194,16 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
|
|||
- Meaning: OpenClaw detected old Matrix state, but the migration is still blocked on missing identity or credential data.
|
||||
- What to do: finish Matrix login or config setup, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`Legacy Matrix encrypted state was detected, but the Matrix plugin helper is unavailable. Install or repair @openclaw/matrix so OpenClaw can inspect the old rust crypto store before upgrading.`
|
||||
|
||||
- Meaning: OpenClaw found old encrypted Matrix state, but it could not load the helper entrypoint from the Matrix plugin that normally inspects that store.
|
||||
- What to do: reinstall or repair the Matrix plugin (`openclaw plugins install @openclaw/matrix`, or `openclaw plugins install ./extensions/matrix` for a repo checkout), then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`Matrix plugin helper path is unsafe: ... Reinstall @openclaw/matrix and try again.`
|
||||
|
||||
- Meaning: OpenClaw found a helper file path that escapes the plugin root or fails plugin boundary checks, so it refused to import it.
|
||||
- What to do: reinstall the Matrix plugin from a trusted path, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ...`
|
||||
|
||||
- Meaning: OpenClaw refused to mutate Matrix state because it could not create the recovery snapshot first.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
export type { MatrixLegacyCryptoInspectionResult } from "./src/matrix/legacy-crypto-inspector.js";
|
||||
export { inspectLegacyMatrixCryptoStore } from "./src/matrix/legacy-crypto-inspector.js";
|
||||
|
|
@ -118,4 +118,27 @@ describe("matrixMessageActions", () => {
|
|||
|
||||
expect(actions).toEqual(["poll", "poll-vote"]);
|
||||
});
|
||||
|
||||
it("hides actions until defaultAccount is set for ambiguous multi-account configs", () => {
|
||||
const actions = matrixMessageActions.listActions!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
} as never);
|
||||
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
createActionGate,
|
||||
readNumberParam,
|
||||
readStringParam,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionContext,
|
||||
type ChannelMessageActionName,
|
||||
|
|
@ -66,6 +67,9 @@ function createMatrixExposedActions(params: {
|
|||
export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
const resolvedCfg = cfg as CoreConfig;
|
||||
if (requiresExplicitMatrixDefaultAccount(resolvedCfg)) {
|
||||
return [];
|
||||
}
|
||||
const account = resolveMatrixAccount({
|
||||
cfg: resolvedCfg,
|
||||
accountId: resolveDefaultMatrixAccountId(resolvedCfg),
|
||||
|
|
|
|||
|
|
@ -478,7 +478,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||
//
|
||||
// INVARIANT: The import() below cannot hang because:
|
||||
// 1. It only loads local ESM modules with no circular awaits
|
||||
// 2. Module initialization is synchronous (no top-level await in ./matrix/index.js)
|
||||
// 2. Module initialization is synchronous (no top-level await in ./matrix/monitor/index.js)
|
||||
// 3. The lock only serializes the import phase, not the provider startup
|
||||
const previousLock = matrixStartupLock;
|
||||
let releaseLock: () => void = () => {};
|
||||
|
|
@ -489,9 +489,9 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
// Wrap in try/finally to ensure lock is released even if import fails.
|
||||
let monitorMatrixProvider: typeof import("./matrix/index.js").monitorMatrixProvider;
|
||||
let monitorMatrixProvider: typeof import("./matrix/monitor/index.js").monitorMatrixProvider;
|
||||
try {
|
||||
const module = await import("./matrix/index.js");
|
||||
const module = await import("./matrix/monitor/index.js");
|
||||
monitorMatrixProvider = module.monitorMatrixProvider;
|
||||
} finally {
|
||||
// Release lock after import completes or fails
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ import {
|
|||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
} from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/matrix";
|
||||
import {
|
||||
buildSecretInputSchema,
|
||||
MarkdownConfigSchema,
|
||||
ToolPolicySchema,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
import { z } from "zod";
|
||||
import { buildSecretInputSchema } from "./secret-input.js";
|
||||
|
||||
const matrixActionSchema = z
|
||||
.object({
|
||||
|
|
@ -81,5 +84,4 @@ export const MatrixConfigSchema = z.object({
|
|||
groups: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||
actions: matrixActionSchema,
|
||||
register: z.boolean().optional(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -110,4 +110,42 @@ describe("resolveMatrixAccount", () => {
|
|||
expect(listMatrixAccountIds(cfg)).toEqual(["main-bot", "ops"]);
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("main-bot");
|
||||
});
|
||||
|
||||
it("returns the only named account when no explicit default is set", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("ops");
|
||||
});
|
||||
|
||||
it('uses the synthetic "default" account when multiple named accounts need explicit selection', () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
alpha: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "alpha-token",
|
||||
},
|
||||
beta: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "beta-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "openclaw/plugin-sdk/account-id";
|
||||
import { hasConfiguredSecretInput } from "../secret-input.js";
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
import type { CoreConfig, MatrixConfig } from "../types.js";
|
||||
import {
|
||||
findMatrixAccountConfig,
|
||||
|
|
@ -49,15 +49,7 @@ export function listMatrixAccountIds(cfg: CoreConfig): string[] {
|
|||
}
|
||||
|
||||
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
|
||||
const configuredDefault = normalizeOptionalAccountId(cfg.channels?.matrix?.defaultAccount);
|
||||
const ids = listMatrixAccountIds(cfg);
|
||||
if (configuredDefault && ids.includes(configuredDefault)) {
|
||||
return configuredDefault;
|
||||
}
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg));
|
||||
}
|
||||
|
||||
export function resolveMatrixAccount(params: {
|
||||
|
|
|
|||
|
|
@ -36,12 +36,11 @@ vi.mock("../send.js", () => ({
|
|||
resolveMatrixRoomId: (...args: unknown[]) => resolveMatrixRoomIdMock(...args),
|
||||
}));
|
||||
|
||||
let resolveActionClient: typeof import("./client.js").resolveActionClient;
|
||||
let withResolvedActionClient: typeof import("./client.js").withResolvedActionClient;
|
||||
let withResolvedRoomAction: typeof import("./client.js").withResolvedRoomAction;
|
||||
let withStartedActionClient: typeof import("./client.js").withStartedActionClient;
|
||||
|
||||
describe("resolveActionClient", () => {
|
||||
describe("action client helpers", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
primeMatrixClientResolverMocks();
|
||||
|
|
@ -49,12 +48,8 @@ describe("resolveActionClient", () => {
|
|||
.mockReset()
|
||||
.mockImplementation(async (_client, roomId: string) => roomId);
|
||||
|
||||
({
|
||||
resolveActionClient,
|
||||
withResolvedActionClient,
|
||||
withResolvedRoomAction,
|
||||
withStartedActionClient,
|
||||
} = await import("./client.js"));
|
||||
({ withResolvedActionClient, withResolvedRoomAction, withStartedActionClient } =
|
||||
await import("./client.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -64,7 +59,7 @@ describe("resolveActionClient", () => {
|
|||
it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => {
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
|
||||
|
||||
const result = await resolveActionClient({ accountId: "default" });
|
||||
const result = await withResolvedActionClient({ accountId: "default" }, async () => "ok");
|
||||
|
||||
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default");
|
||||
expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -76,56 +71,56 @@ describe("resolveActionClient", () => {
|
|||
);
|
||||
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
|
||||
expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1);
|
||||
expect(result.stopOnDone).toBe(true);
|
||||
expect(oneOffClient.stop).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
|
||||
it("skips one-off room preparation when readiness is disabled", async () => {
|
||||
const result = await resolveActionClient({
|
||||
accountId: "default",
|
||||
readiness: "none",
|
||||
});
|
||||
await withResolvedActionClient({ accountId: "default", readiness: "none" }, async () => {});
|
||||
|
||||
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
|
||||
expect(oneOffClient.prepareForOneOff).not.toHaveBeenCalled();
|
||||
expect(oneOffClient.start).not.toHaveBeenCalled();
|
||||
expect(result.stopOnDone).toBe(true);
|
||||
expect(oneOffClient.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts one-off clients when started readiness is required", async () => {
|
||||
const result = await resolveActionClient({
|
||||
accountId: "default",
|
||||
readiness: "started",
|
||||
});
|
||||
await withStartedActionClient({ accountId: "default" }, async () => {});
|
||||
|
||||
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
|
||||
expect(oneOffClient.start).toHaveBeenCalledTimes(1);
|
||||
expect(oneOffClient.prepareForOneOff).not.toHaveBeenCalled();
|
||||
expect(result.stopOnDone).toBe(true);
|
||||
expect(oneOffClient.stop).not.toHaveBeenCalled();
|
||||
expect(oneOffClient.stopAndPersist).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reuses active monitor client when available", async () => {
|
||||
const activeClient = createMockMatrixClient();
|
||||
getActiveMatrixClientMock.mockReturnValue(activeClient);
|
||||
|
||||
const result = await resolveActionClient({ accountId: "default" });
|
||||
const result = await withResolvedActionClient({ accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(activeClient);
|
||||
return "ok";
|
||||
});
|
||||
|
||||
expect(result).toEqual({ client: activeClient, stopOnDone: false });
|
||||
expect(result).toBe("ok");
|
||||
expect(resolveMatrixAuthMock).not.toHaveBeenCalled();
|
||||
expect(createMatrixClientMock).not.toHaveBeenCalled();
|
||||
expect(activeClient.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts active clients when started readiness is required", async () => {
|
||||
const activeClient = createMockMatrixClient();
|
||||
getActiveMatrixClientMock.mockReturnValue(activeClient);
|
||||
|
||||
const result = await resolveActionClient({
|
||||
accountId: "default",
|
||||
readiness: "started",
|
||||
await withStartedActionClient({ accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(activeClient);
|
||||
});
|
||||
|
||||
expect(result).toEqual({ client: activeClient, stopOnDone: false });
|
||||
expect(activeClient.start).toHaveBeenCalledTimes(1);
|
||||
expect(activeClient.prepareForOneOff).not.toHaveBeenCalled();
|
||||
expect(activeClient.stop).not.toHaveBeenCalled();
|
||||
expect(activeClient.stopAndPersist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the implicit resolved account id for active client lookup and storage", async () => {
|
||||
|
|
@ -164,7 +159,7 @@ describe("resolveActionClient", () => {
|
|||
encryption: true,
|
||||
});
|
||||
|
||||
await resolveActionClient({});
|
||||
await withResolvedActionClient({}, async () => {});
|
||||
|
||||
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("ops");
|
||||
expect(resolveMatrixAuthMock).toHaveBeenCalledWith(
|
||||
|
|
@ -189,10 +184,7 @@ describe("resolveActionClient", () => {
|
|||
},
|
||||
};
|
||||
|
||||
await resolveActionClient({
|
||||
cfg: explicitCfg,
|
||||
accountId: "ops",
|
||||
});
|
||||
await withResolvedActionClient({ cfg: explicitCfg, accountId: "ops" }, async () => {});
|
||||
|
||||
expect(getMatrixRuntimeMock).not.toHaveBeenCalled();
|
||||
expect(resolveMatrixAuthContextMock).toHaveBeenCalledWith({
|
||||
|
|
@ -233,20 +225,6 @@ describe("resolveActionClient", () => {
|
|||
expect(oneOffClient.stopAndPersist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists one-off action clients after started wrappers complete", async () => {
|
||||
const oneOffClient = createMockMatrixClient();
|
||||
createMatrixClientMock.mockResolvedValue(oneOffClient);
|
||||
|
||||
await withStartedActionClient({ accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(oneOffClient);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
expect(oneOffClient.start).toHaveBeenCalledTimes(1);
|
||||
expect(oneOffClient.stop).not.toHaveBeenCalled();
|
||||
expect(oneOffClient.stopAndPersist).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves room ids before running wrapped room actions", async () => {
|
||||
const oneOffClient = createMockMatrixClient();
|
||||
createMatrixClientMock.mockResolvedValue(oneOffClient);
|
||||
|
|
|
|||
|
|
@ -1,64 +1,15 @@
|
|||
import { resolveRuntimeMatrixClient } from "../client-bootstrap.js";
|
||||
import { withResolvedRuntimeMatrixClient } from "../client-bootstrap.js";
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
async function ensureActionClientReadiness(
|
||||
client: MatrixActionClient["client"],
|
||||
readiness: MatrixActionClientOpts["readiness"],
|
||||
opts: { createdForOneOff: boolean },
|
||||
): Promise<void> {
|
||||
if (readiness === "started") {
|
||||
await client.start();
|
||||
return;
|
||||
}
|
||||
if (readiness === "prepared" || (!readiness && opts.createdForOneOff)) {
|
||||
await client.prepareForOneOff();
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveActionClient(
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<MatrixActionClient> {
|
||||
return await resolveRuntimeMatrixClient({
|
||||
client: opts.client,
|
||||
cfg: opts.cfg,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
onResolved: async (client, context) => {
|
||||
await ensureActionClientReadiness(client, opts.readiness, {
|
||||
createdForOneOff: context.createdForOneOff,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type MatrixActionClientStopMode = "stop" | "persist";
|
||||
|
||||
export async function stopActionClient(
|
||||
resolved: MatrixActionClient,
|
||||
mode: MatrixActionClientStopMode = "stop",
|
||||
): Promise<void> {
|
||||
if (!resolved.stopOnDone) {
|
||||
return;
|
||||
}
|
||||
if (mode === "persist") {
|
||||
await resolved.client.stopAndPersist();
|
||||
return;
|
||||
}
|
||||
resolved.client.stop();
|
||||
}
|
||||
|
||||
export async function withResolvedActionClient<T>(
|
||||
opts: MatrixActionClientOpts,
|
||||
run: (client: MatrixActionClient["client"]) => Promise<T>,
|
||||
mode: MatrixActionClientStopMode = "stop",
|
||||
): Promise<T> {
|
||||
const resolved = await resolveActionClient(opts);
|
||||
try {
|
||||
return await run(resolved.client);
|
||||
} finally {
|
||||
await stopActionClient(resolved, mode);
|
||||
}
|
||||
return await withResolvedRuntimeMatrixClient(opts, run, mode);
|
||||
}
|
||||
|
||||
export async function withStartedActionClient<T>(
|
||||
|
|
|
|||
|
|
@ -9,23 +9,40 @@ import {
|
|||
} from "./client.js";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
|
||||
export type ResolvedRuntimeMatrixClient = {
|
||||
type ResolvedRuntimeMatrixClient = {
|
||||
client: MatrixClient;
|
||||
stopOnDone: boolean;
|
||||
};
|
||||
|
||||
type MatrixRuntimeClientReadiness = "none" | "prepared" | "started";
|
||||
type ResolvedRuntimeMatrixClientStopMode = "stop" | "persist";
|
||||
|
||||
type MatrixResolvedClientHook = (
|
||||
client: MatrixClient,
|
||||
context: { createdForOneOff: boolean },
|
||||
) => Promise<void> | void;
|
||||
|
||||
export function ensureMatrixNodeRuntime() {
|
||||
async function ensureResolvedClientReadiness(params: {
|
||||
client: MatrixClient;
|
||||
readiness?: MatrixRuntimeClientReadiness;
|
||||
createdForOneOff: boolean;
|
||||
}): Promise<void> {
|
||||
if (params.readiness === "started") {
|
||||
await params.client.start();
|
||||
return;
|
||||
}
|
||||
if (params.readiness === "prepared" || (!params.readiness && params.createdForOneOff)) {
|
||||
await params.client.prepareForOneOff();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureMatrixNodeRuntime() {
|
||||
if (isBunRuntime()) {
|
||||
throw new Error("Matrix support requires Node (bun runtime not supported)");
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveRuntimeMatrixClient(opts: {
|
||||
async function resolveRuntimeMatrixClient(opts: {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
timeoutMs?: number;
|
||||
|
|
@ -67,3 +84,58 @@ export async function resolveRuntimeMatrixClient(opts: {
|
|||
await opts.onResolved?.(client, { createdForOneOff: true });
|
||||
return { client, stopOnDone: true };
|
||||
}
|
||||
|
||||
export async function resolveRuntimeMatrixClientWithReadiness(opts: {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
readiness?: MatrixRuntimeClientReadiness;
|
||||
}): Promise<ResolvedRuntimeMatrixClient> {
|
||||
return await resolveRuntimeMatrixClient({
|
||||
client: opts.client,
|
||||
cfg: opts.cfg,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
onResolved: async (client, context) => {
|
||||
await ensureResolvedClientReadiness({
|
||||
client,
|
||||
readiness: opts.readiness,
|
||||
createdForOneOff: context.createdForOneOff,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopResolvedRuntimeMatrixClient(
|
||||
resolved: ResolvedRuntimeMatrixClient,
|
||||
mode: ResolvedRuntimeMatrixClientStopMode = "stop",
|
||||
): Promise<void> {
|
||||
if (!resolved.stopOnDone) {
|
||||
return;
|
||||
}
|
||||
if (mode === "persist") {
|
||||
await resolved.client.stopAndPersist();
|
||||
return;
|
||||
}
|
||||
resolved.client.stop();
|
||||
}
|
||||
|
||||
export async function withResolvedRuntimeMatrixClient<T>(
|
||||
opts: {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
readiness?: MatrixRuntimeClientReadiness;
|
||||
},
|
||||
run: (client: MatrixClient) => Promise<T>,
|
||||
stopMode: ResolvedRuntimeMatrixClientStopMode = "stop",
|
||||
): Promise<T> {
|
||||
const resolved = await resolveRuntimeMatrixClientWithReadiness(opts);
|
||||
try {
|
||||
return await run(resolved.client);
|
||||
} finally {
|
||||
await stopResolvedRuntimeMatrixClient(resolved, stopMode);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||
import type { CoreConfig } from "../types.js";
|
||||
import {
|
||||
resolveImplicitMatrixAccountId,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
resolveMatrixConfig,
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./client.js";
|
||||
} from "./client/config.js";
|
||||
import * as credentialsModule from "./credentials.js";
|
||||
import * as sdkModule from "./sdk.js";
|
||||
|
||||
|
|
@ -142,12 +142,36 @@ describe("resolveMatrixConfig", () => {
|
|||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveImplicitMatrixAccountId(cfg, {} as NodeJS.ProcessEnv)).toBeNull();
|
||||
expect(resolveImplicitMatrixAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("default");
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires explicit defaultAccount selection when multiple named Matrix accounts exist", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.assistant.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveImplicitMatrixAccountId(cfg, {} as NodeJS.ProcessEnv)).toBeNull();
|
||||
expect(() => resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv })).toThrow(
|
||||
/channels\.matrix\.defaultAccount.*--account <id>/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects insecure public http Matrix homeservers", () => {
|
||||
expect(() => validateMatrixHomeserverUrl("http://matrix.example.org")).toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
|
|
|
|||
|
|
@ -1,20 +1,13 @@
|
|||
export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js";
|
||||
export type { MatrixAuth } from "./client/types.js";
|
||||
export { isBunRuntime } from "./client/runtime.js";
|
||||
export {
|
||||
getMatrixScopedEnvVarNames,
|
||||
hasReadyMatrixEnvAuth,
|
||||
resolveMatrixConfig,
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveScopedMatrixEnvConfig,
|
||||
resolveImplicitMatrixAccountId,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./client/config.js";
|
||||
export { createMatrixClient } from "./client/create-client.js";
|
||||
export {
|
||||
resolveSharedMatrixClient,
|
||||
waitForMatrixSync,
|
||||
stopSharedClient,
|
||||
stopSharedClientForAccount,
|
||||
} from "./client/shared.js";
|
||||
export { resolveSharedMatrixClient, stopSharedClientForAccount } from "./client/shared.js";
|
||||
|
|
|
|||
|
|
@ -3,15 +3,13 @@ import {
|
|||
isPrivateOrLoopbackHost,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
normalizeResolvedSecretInputString,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { normalizeResolvedSecretInputString } from "../../secret-input.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import {
|
||||
findMatrixAccountConfig,
|
||||
listNormalizedMatrixAccountIds,
|
||||
resolveMatrixBaseConfig,
|
||||
} from "../account-config.js";
|
||||
import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "../account-config.js";
|
||||
import { resolveMatrixConfigFieldPath } from "../config-update.js";
|
||||
import { MatrixClient } from "../sdk.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
|
|
@ -315,39 +313,14 @@ export function resolveMatrixConfigForAccount(
|
|||
};
|
||||
}
|
||||
|
||||
function hasMatrixAuthInputs(config: MatrixResolvedConfig): boolean {
|
||||
return Boolean(config.homeserver && (config.accessToken || (config.userId && config.password)));
|
||||
}
|
||||
|
||||
export function resolveImplicitMatrixAccountId(
|
||||
cfg: CoreConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
_env: NodeJS.ProcessEnv = process.env,
|
||||
): string | null {
|
||||
const accountIds = listNormalizedMatrixAccountIds(cfg);
|
||||
const configuredDefault = normalizeOptionalAccountId(cfg.channels?.matrix?.defaultAccount);
|
||||
if (configuredDefault && accountIds.includes(configuredDefault)) {
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, configuredDefault, env);
|
||||
if (hasMatrixAuthInputs(resolved)) {
|
||||
return configuredDefault;
|
||||
}
|
||||
}
|
||||
|
||||
if (accountIds.length === 0) {
|
||||
if (requiresExplicitMatrixDefaultAccount(cfg)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const readyIds = accountIds.filter((accountId) =>
|
||||
hasMatrixAuthInputs(resolveMatrixConfigForAccount(cfg, accountId, env)),
|
||||
);
|
||||
if (readyIds.length === 1) {
|
||||
return readyIds[0] ?? null;
|
||||
}
|
||||
|
||||
if (readyIds.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
return null;
|
||||
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg));
|
||||
}
|
||||
|
||||
export function resolveMatrixAuthContext(params?: {
|
||||
|
|
@ -363,8 +336,12 @@ export function resolveMatrixAuthContext(params?: {
|
|||
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||
const env = params?.env ?? process.env;
|
||||
const explicitAccountId = normalizeOptionalAccountId(params?.accountId);
|
||||
const effectiveAccountId =
|
||||
explicitAccountId ?? resolveImplicitMatrixAccountId(cfg, env) ?? DEFAULT_ACCOUNT_ID;
|
||||
const effectiveAccountId = explicitAccountId ?? resolveImplicitMatrixAccountId(cfg, env);
|
||||
if (!effectiveAccountId) {
|
||||
throw new Error(
|
||||
'Multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. Set "channels.matrix.defaultAccount" to the intended account or pass --account <id>.',
|
||||
);
|
||||
}
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, effectiveAccountId, env);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -175,15 +175,6 @@ export async function resolveSharedMatrixClient(
|
|||
}
|
||||
}
|
||||
|
||||
export async function waitForMatrixSync(_params: {
|
||||
client: MatrixClient;
|
||||
timeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
// matrix-js-sdk handles sync lifecycle in start() for this integration.
|
||||
// This is kept for API compatibility but is essentially a no-op now
|
||||
}
|
||||
|
||||
export function stopSharedClient(): void {
|
||||
for (const state of sharedClientStates.values()) {
|
||||
state.client.stop();
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
export { monitorMatrixProvider } from "./monitor/index.js";
|
||||
export { probeMatrix } from "./probe.js";
|
||||
export {
|
||||
reactMatrixMessage,
|
||||
resolveMatrixRoomId,
|
||||
sendReadReceiptMatrix,
|
||||
sendMessageMatrix,
|
||||
sendPollMatrix,
|
||||
sendTypingMatrix,
|
||||
} from "./send.js";
|
||||
export { resolveMatrixAuth, resolveSharedMatrixClient } from "./client.js";
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { ensureMatrixCryptoRuntime } from "./deps.js";
|
||||
|
||||
export type MatrixLegacyCryptoInspectionResult = {
|
||||
deviceId: string | null;
|
||||
roomKeyCounts: {
|
||||
total: number;
|
||||
backedUp: number;
|
||||
} | null;
|
||||
backupVersion: string | null;
|
||||
decryptionKeyBase64: string | null;
|
||||
};
|
||||
|
||||
function resolveLegacyMachineStorePath(params: {
|
||||
cryptoRootDir: string;
|
||||
deviceId: string;
|
||||
}): string | null {
|
||||
const hashedDir = path.join(
|
||||
params.cryptoRootDir,
|
||||
crypto.createHash("sha256").update(params.deviceId).digest("hex"),
|
||||
);
|
||||
if (fs.existsSync(path.join(hashedDir, "matrix-sdk-crypto.sqlite3"))) {
|
||||
return hashedDir;
|
||||
}
|
||||
if (fs.existsSync(path.join(params.cryptoRootDir, "matrix-sdk-crypto.sqlite3"))) {
|
||||
return params.cryptoRootDir;
|
||||
}
|
||||
const match = fs
|
||||
.readdirSync(params.cryptoRootDir, { withFileTypes: true })
|
||||
.find(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
fs.existsSync(path.join(params.cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")),
|
||||
);
|
||||
return match ? path.join(params.cryptoRootDir, match.name) : null;
|
||||
}
|
||||
|
||||
export async function inspectLegacyMatrixCryptoStore(params: {
|
||||
cryptoRootDir: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
log?: (message: string) => void;
|
||||
}): Promise<MatrixLegacyCryptoInspectionResult> {
|
||||
const machineStorePath = resolveLegacyMachineStorePath(params);
|
||||
if (!machineStorePath) {
|
||||
throw new Error(`Matrix legacy crypto store not found for device ${params.deviceId}`);
|
||||
}
|
||||
|
||||
const requireFn = createRequire(import.meta.url);
|
||||
await ensureMatrixCryptoRuntime({
|
||||
requireFn,
|
||||
resolveFn: requireFn.resolve.bind(requireFn),
|
||||
log: params.log,
|
||||
});
|
||||
|
||||
const { DeviceId, OlmMachine, StoreType, UserId } = requireFn(
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs",
|
||||
) as typeof import("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||
const machine = await OlmMachine.initialize(
|
||||
new UserId(params.userId),
|
||||
new DeviceId(params.deviceId),
|
||||
machineStorePath,
|
||||
"",
|
||||
StoreType.Sqlite,
|
||||
);
|
||||
|
||||
try {
|
||||
const [backupKeys, roomKeyCounts] = await Promise.all([
|
||||
machine.getBackupKeys(),
|
||||
machine.roomKeyCounts(),
|
||||
]);
|
||||
return {
|
||||
deviceId: params.deviceId,
|
||||
roomKeyCounts: roomKeyCounts
|
||||
? {
|
||||
total: typeof roomKeyCounts.total === "number" ? roomKeyCounts.total : 0,
|
||||
backedUp: typeof roomKeyCounts.backedUp === "number" ? roomKeyCounts.backedUp : 0,
|
||||
}
|
||||
: null,
|
||||
backupVersion:
|
||||
typeof backupKeys?.backupVersion === "string" && backupKeys.backupVersion.trim()
|
||||
? backupKeys.backupVersion
|
||||
: null,
|
||||
decryptionKeyBase64:
|
||||
typeof backupKeys?.decryptionKeyBase64 === "string" && backupKeys.decryptionKeyBase64.trim()
|
||||
? backupKeys.decryptionKeyBase64
|
||||
: null,
|
||||
};
|
||||
} finally {
|
||||
machine.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -29,17 +29,16 @@ vi.mock("../../runtime.js", () => ({
|
|||
getMatrixRuntime: () => getMatrixRuntimeMock(),
|
||||
}));
|
||||
|
||||
let resolveMatrixClient: typeof import("./client.js").resolveMatrixClient;
|
||||
let withResolvedMatrixClient: typeof import("./client.js").withResolvedMatrixClient;
|
||||
|
||||
describe("resolveMatrixClient", () => {
|
||||
describe("withResolvedMatrixClient", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
primeMatrixClientResolverMocks({
|
||||
resolved: {},
|
||||
});
|
||||
|
||||
({ resolveMatrixClient, withResolvedMatrixClient } = await import("./client.js"));
|
||||
({ withResolvedMatrixClient } = await import("./client.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -49,7 +48,7 @@ describe("resolveMatrixClient", () => {
|
|||
it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => {
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
|
||||
|
||||
const result = await resolveMatrixClient({ accountId: "default" });
|
||||
const result = await withResolvedMatrixClient({ accountId: "default" }, async () => "ok");
|
||||
|
||||
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default");
|
||||
expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -61,18 +60,23 @@ describe("resolveMatrixClient", () => {
|
|||
);
|
||||
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
|
||||
expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1);
|
||||
expect(result.stopOnDone).toBe(true);
|
||||
expect(oneOffClient.stop).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
|
||||
it("reuses active monitor client when available", async () => {
|
||||
const activeClient = createMockMatrixClient();
|
||||
getActiveMatrixClientMock.mockReturnValue(activeClient);
|
||||
|
||||
const result = await resolveMatrixClient({ accountId: "default" });
|
||||
const result = await withResolvedMatrixClient({ accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(activeClient);
|
||||
return "ok";
|
||||
});
|
||||
|
||||
expect(result).toEqual({ client: activeClient, stopOnDone: false });
|
||||
expect(result).toBe("ok");
|
||||
expect(resolveMatrixAuthMock).not.toHaveBeenCalled();
|
||||
expect(createMatrixClientMock).not.toHaveBeenCalled();
|
||||
expect(activeClient.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the effective account id when auth resolution is implicit", async () => {
|
||||
|
|
@ -92,7 +96,7 @@ describe("resolveMatrixClient", () => {
|
|||
encryption: false,
|
||||
});
|
||||
|
||||
await resolveMatrixClient({});
|
||||
await withResolvedMatrixClient({}, async () => {});
|
||||
|
||||
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("ops");
|
||||
expect(resolveMatrixAuthMock).toHaveBeenCalledWith({
|
||||
|
|
@ -115,10 +119,7 @@ describe("resolveMatrixClient", () => {
|
|||
},
|
||||
};
|
||||
|
||||
await resolveMatrixClient({
|
||||
cfg: explicitCfg,
|
||||
accountId: "ops",
|
||||
});
|
||||
await withResolvedMatrixClient({ cfg: explicitCfg, accountId: "ops" }, async () => {});
|
||||
|
||||
expect(getMatrixRuntimeMock).not.toHaveBeenCalled();
|
||||
expect(resolveMatrixAuthContextMock).toHaveBeenCalledWith({
|
||||
|
|
@ -131,19 +132,6 @@ describe("resolveMatrixClient", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("stops one-off matrix clients after wrapped sends succeed", async () => {
|
||||
const oneOffClient = createMockMatrixClient();
|
||||
createMatrixClientMock.mockResolvedValue(oneOffClient);
|
||||
|
||||
const result = await withResolvedMatrixClient({ accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(oneOffClient);
|
||||
return "ok";
|
||||
});
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(oneOffClient.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still stops one-off matrix clients when wrapped sends fail", async () => {
|
||||
const oneOffClient = createMockMatrixClient();
|
||||
createMatrixClientMock.mockResolvedValue(oneOffClient);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { resolveMatrixAccountConfig } from "../accounts.js";
|
||||
import {
|
||||
resolveRuntimeMatrixClient,
|
||||
type ResolvedRuntimeMatrixClient,
|
||||
} from "../client-bootstrap.js";
|
||||
import { withResolvedRuntimeMatrixClient } from "../client-bootstrap.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
const getCore = () => getMatrixRuntime();
|
||||
|
|
@ -22,31 +19,6 @@ export function resolveMediaMaxBytes(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export async function resolveMatrixClient(opts: {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
|
||||
return await resolveRuntimeMatrixClient({
|
||||
client: opts.client,
|
||||
cfg: opts.cfg,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
onResolved: async (client, context) => {
|
||||
if (context.createdForOneOff) {
|
||||
await client.prepareForOneOff();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function stopResolvedMatrixClient(resolved: ResolvedRuntimeMatrixClient): void {
|
||||
if (resolved.stopOnDone) {
|
||||
resolved.client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export async function withResolvedMatrixClient<T>(
|
||||
opts: {
|
||||
client?: MatrixClient;
|
||||
|
|
@ -56,10 +28,11 @@ export async function withResolvedMatrixClient<T>(
|
|||
},
|
||||
run: (client: MatrixClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const resolved = await resolveMatrixClient(opts);
|
||||
try {
|
||||
return await run(resolved.client);
|
||||
} finally {
|
||||
stopResolvedMatrixClient(resolved);
|
||||
}
|
||||
return await withResolvedRuntimeMatrixClient(
|
||||
{
|
||||
...opts,
|
||||
readiness: "prepared",
|
||||
},
|
||||
run,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -356,4 +356,42 @@ describe("matrix onboarding", () => {
|
|||
expect(status.statusLines).toContain("Matrix: configured");
|
||||
expect(status.selectionHint).toBe("configured");
|
||||
});
|
||||
|
||||
it("asks for defaultAccount when multiple named Matrix accounts exist", async () => {
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) =>
|
||||
(homeDir ?? (() => "/tmp"))(),
|
||||
},
|
||||
config: {
|
||||
loadConfig: () => ({}),
|
||||
},
|
||||
} as never);
|
||||
|
||||
const status = await matrixOnboardingAdapter.getStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.assistant.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
accountOverrides: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
expect(status.statusLines).toEqual([
|
||||
'Matrix: set "channels.matrix.defaultAccount" to select a named account',
|
||||
]);
|
||||
expect(status.selectionHint).toBe("set defaultAccount");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
mergeAllowFromEntries,
|
||||
normalizeAccountId,
|
||||
promptAccountId,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
type RuntimeEnv,
|
||||
type WizardPrompter,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
|
|
@ -494,12 +495,21 @@ async function runMatrixConfigure(params: {
|
|||
export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg, accountOverrides }) => {
|
||||
const resolvedCfg = cfg as CoreConfig;
|
||||
const sdkReady = isMatrixSdkAvailable();
|
||||
if (!accountOverrides[channel] && requiresExplicitMatrixDefaultAccount(resolvedCfg)) {
|
||||
return {
|
||||
channel,
|
||||
configured: false,
|
||||
statusLines: ['Matrix: set "channels.matrix.defaultAccount" to select a named account'],
|
||||
selectionHint: !sdkReady ? "install matrix-js-sdk" : "set defaultAccount",
|
||||
};
|
||||
}
|
||||
const account = resolveMatrixAccount({
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: resolveMatrixOnboardingAccountId(cfg as CoreConfig, accountOverrides[channel]),
|
||||
cfg: resolvedCfg,
|
||||
accountId: resolveMatrixOnboardingAccountId(resolvedCfg, accountOverrides[channel]),
|
||||
});
|
||||
const configured = account.configured;
|
||||
const sdkReady = isMatrixSdkAvailable();
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
import {
|
||||
buildSecretInputSchema,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
|
||||
export {
|
||||
buildSecretInputSchema,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
};
|
||||
|
|
@ -4,6 +4,7 @@ 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 { MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE } from "./matrix-plugin-helper.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "./matrix-storage-paths.js";
|
||||
|
||||
function writeFile(filePath: string, value: string) {
|
||||
|
|
@ -318,4 +319,43 @@ describe("matrix legacy encrypted-state migration", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a missing matrix plugin helper once when encrypted-state migration cannot run", async () => {
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(
|
||||
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
|
||||
'{"deviceId":"DEVICE123"}',
|
||||
);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(false);
|
||||
expect(
|
||||
result.warnings.filter(
|
||||
(warning) => warning === MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
{
|
||||
env: {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
|
@ -10,6 +9,12 @@ import {
|
|||
resolveLegacyMatrixFlatStoreTarget,
|
||||
resolveMatrixMigrationAccountTarget,
|
||||
} from "./matrix-migration-config.js";
|
||||
import {
|
||||
MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE,
|
||||
isMatrixLegacyCryptoInspectorAvailable,
|
||||
loadMatrixLegacyCryptoInspector,
|
||||
type MatrixLegacyCryptoInspector,
|
||||
} from "./matrix-plugin-helper.js";
|
||||
import { resolveMatrixLegacyFlatStoragePaths } from "./matrix-storage-paths.js";
|
||||
|
||||
type MatrixLegacyCryptoCounts = {
|
||||
|
|
@ -24,7 +29,7 @@ type MatrixLegacyCryptoSummary = {
|
|||
decryptionKeyBase64: string | null;
|
||||
};
|
||||
|
||||
export type MatrixLegacyCryptoMigrationState = {
|
||||
type MatrixLegacyCryptoMigrationState = {
|
||||
version: 1;
|
||||
source: "matrix-bot-sdk-rust";
|
||||
accountId: string;
|
||||
|
|
@ -57,18 +62,14 @@ type MatrixLegacyCryptoDetection = {
|
|||
warnings: string[];
|
||||
};
|
||||
|
||||
export type MatrixLegacyCryptoPreparationResult = {
|
||||
type MatrixLegacyCryptoPreparationResult = {
|
||||
migrated: boolean;
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type MatrixLegacyCryptoPrepareDeps = {
|
||||
inspectLegacyStore: (params: {
|
||||
cryptoRootDir: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
}) => Promise<MatrixLegacyCryptoSummary>;
|
||||
type MatrixLegacyCryptoPrepareDeps = {
|
||||
inspectLegacyStore: MatrixLegacyCryptoInspector;
|
||||
};
|
||||
|
||||
type MatrixLegacyBotSdkMetadata = {
|
||||
|
|
@ -281,75 +282,6 @@ function loadLegacyCryptoMigrationState(filePath: string): MatrixLegacyCryptoMig
|
|||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -361,10 +293,23 @@ export function detectLegacyMatrixCrypto(params: {
|
|||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): MatrixLegacyCryptoDetection {
|
||||
return resolveMatrixLegacyCryptoPlans({
|
||||
const detection = resolveMatrixLegacyCryptoPlans({
|
||||
cfg: params.cfg,
|
||||
env: params.env ?? process.env,
|
||||
});
|
||||
if (
|
||||
detection.plans.length > 0 &&
|
||||
!isMatrixLegacyCryptoInspectorAvailable({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
plans: detection.plans,
|
||||
warnings: [...detection.warnings, MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE],
|
||||
};
|
||||
}
|
||||
return detection;
|
||||
}
|
||||
|
||||
export async function autoPrepareLegacyMatrixCrypto(params: {
|
||||
|
|
@ -374,10 +319,35 @@ export async function autoPrepareLegacyMatrixCrypto(params: {
|
|||
deps?: Partial<MatrixLegacyCryptoPrepareDeps>;
|
||||
}): Promise<MatrixLegacyCryptoPreparationResult> {
|
||||
const env = params.env ?? process.env;
|
||||
const detection = resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env });
|
||||
const detection = params.deps?.inspectLegacyStore
|
||||
? resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env })
|
||||
: detectLegacyMatrixCrypto({ cfg: params.cfg, env });
|
||||
const warnings = [...detection.warnings];
|
||||
const changes: string[] = [];
|
||||
const inspectLegacyStore = params.deps?.inspectLegacyStore ?? inspectLegacyStoreWithCryptoNodejs;
|
||||
let inspectLegacyStore = params.deps?.inspectLegacyStore;
|
||||
if (!inspectLegacyStore) {
|
||||
try {
|
||||
inspectLegacyStore = await loadMatrixLegacyCryptoInspector({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (!warnings.includes(message)) {
|
||||
warnings.push(message);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
params.log?.warn?.(
|
||||
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
migrated: false,
|
||||
changes,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const plan of detection.plans) {
|
||||
const existingState = loadLegacyCryptoMigrationState(plan.statePath);
|
||||
|
|
@ -398,6 +368,7 @@ export async function autoPrepareLegacyMatrixCrypto(params: {
|
|||
cryptoRootDir: plan.legacyCryptoPath,
|
||||
userId: plan.userId,
|
||||
deviceId: plan.deviceId,
|
||||
log: params.log?.info,
|
||||
});
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import fs from "node:fs";
|
|||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
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());
|
||||
|
||||
|
|
@ -195,4 +197,55 @@ describe("matrix migration snapshots", () => {
|
|||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("treats legacy Matrix crypto as warning-only until the plugin helper is available", async () => {
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
fs.mkdirSync(path.join(home, "empty-bundled"), { recursive: true });
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
});
|
||||
fs.mkdirSync(path.join(rootDir, "crypto"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: "DEVICE123" }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
});
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
expect(detection.warnings).toContain(
|
||||
"Legacy Matrix encrypted state was detected, but the Matrix plugin helper is unavailable. Install or repair @openclaw/matrix so OpenClaw can inspect the old rust crypto store before upgrading.",
|
||||
);
|
||||
expect(
|
||||
hasActionableMatrixMigration({
|
||||
cfg,
|
||||
env: process.env,
|
||||
}),
|
||||
).toBe(false);
|
||||
},
|
||||
{
|
||||
env: {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { createBackupArchive } from "./backup-create.js";
|
|||
import { resolveRequiredHomeDir } from "./home-dir.js";
|
||||
import { detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js";
|
||||
import { detectLegacyMatrixState } from "./matrix-legacy-state.js";
|
||||
import { isMatrixLegacyCryptoInspectorAvailable } from "./matrix-plugin-helper.js";
|
||||
|
||||
const MATRIX_MIGRATION_SNAPSHOT_DIRNAME = "openclaw-migrations";
|
||||
|
||||
|
|
@ -90,7 +91,13 @@ export function hasActionableMatrixMigration(params: {
|
|||
return true;
|
||||
}
|
||||
const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env });
|
||||
return legacyCrypto.plans.length > 0;
|
||||
return (
|
||||
legacyCrypto.plans.length > 0 &&
|
||||
isMatrixLegacyCryptoInspectorAvailable({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function maybeCreateMatrixMigrationSnapshot(params: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,186 @@
|
|||
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 {
|
||||
isMatrixLegacyCryptoInspectorAvailable,
|
||||
loadMatrixLegacyCryptoInspector,
|
||||
} from "./matrix-plugin-helper.js";
|
||||
|
||||
function writeMatrixPluginFixture(rootDir: string, helperBody: 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"), helperBody, "utf8");
|
||||
}
|
||||
|
||||
describe("matrix plugin helper resolution", () => {
|
||||
it("loads the legacy crypto inspector from the bundled matrix plugin", async () => {
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
const bundledRoot = path.join(home, "bundled", "matrix");
|
||||
writeMatrixPluginFixture(
|
||||
bundledRoot,
|
||||
[
|
||||
"export async function inspectLegacyMatrixCryptoStore() {",
|
||||
' return { deviceId: "BUNDLED", roomKeyCounts: { total: 7, backedUp: 6 }, backupVersion: "1", decryptionKeyBase64: "YWJjZA==" };',
|
||||
"}",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const cfg = {} as const;
|
||||
|
||||
expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(true);
|
||||
const inspectLegacyStore = await loadMatrixLegacyCryptoInspector({
|
||||
cfg,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
await expect(
|
||||
inspectLegacyStore({
|
||||
cryptoRootDir: "/tmp/legacy",
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
deviceId: "BUNDLED",
|
||||
roomKeyCounts: { total: 7, backedUp: 6 },
|
||||
backupVersion: "1",
|
||||
decryptionKeyBase64: "YWJjZA==",
|
||||
});
|
||||
},
|
||||
{
|
||||
env: {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "bundled"),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers configured plugin load paths over bundled matrix plugins", async () => {
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
const bundledRoot = path.join(home, "bundled", "matrix");
|
||||
const customRoot = path.join(home, "plugins", "matrix-local");
|
||||
writeMatrixPluginFixture(
|
||||
bundledRoot,
|
||||
[
|
||||
"export async function inspectLegacyMatrixCryptoStore() {",
|
||||
' return { deviceId: "BUNDLED", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null };',
|
||||
"}",
|
||||
].join("\n"),
|
||||
);
|
||||
writeMatrixPluginFixture(
|
||||
customRoot,
|
||||
[
|
||||
"export default async function inspectLegacyMatrixCryptoStore() {",
|
||||
' return { deviceId: "CONFIG", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null };',
|
||||
"}",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const cfg = {
|
||||
plugins: {
|
||||
load: {
|
||||
paths: [customRoot],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(true);
|
||||
const inspectLegacyStore = await loadMatrixLegacyCryptoInspector({
|
||||
cfg,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
await expect(
|
||||
inspectLegacyStore({
|
||||
cryptoRootDir: "/tmp/legacy",
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
deviceId: "CONFIG",
|
||||
roomKeyCounts: null,
|
||||
backupVersion: null,
|
||||
decryptionKeyBase64: null,
|
||||
});
|
||||
},
|
||||
{
|
||||
env: {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "bundled"),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects helper files that escape the plugin root", async () => {
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
const customRoot = path.join(home, "plugins", "matrix-local");
|
||||
const outsideRoot = path.join(home, "outside");
|
||||
fs.mkdirSync(customRoot, { recursive: true });
|
||||
fs.mkdirSync(outsideRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(customRoot, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "matrix",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(customRoot, "index.js"), "export default {};\n", "utf8");
|
||||
const outsideHelper = path.join(outsideRoot, "legacy-crypto-inspector.js");
|
||||
fs.writeFileSync(
|
||||
outsideHelper,
|
||||
'export default async function inspectLegacyMatrixCryptoStore() { return { deviceId: "ESCAPE", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null }; }\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
fs.symlinkSync(
|
||||
outsideHelper,
|
||||
path.join(customRoot, "legacy-crypto-inspector.js"),
|
||||
process.platform === "win32" ? "file" : undefined,
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
plugins: {
|
||||
load: {
|
||||
paths: [customRoot],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(false);
|
||||
await expect(
|
||||
loadMatrixLegacyCryptoInspector({
|
||||
cfg,
|
||||
env: process.env,
|
||||
}),
|
||||
).rejects.toThrow("Matrix plugin helper path is unsafe");
|
||||
},
|
||||
{
|
||||
env: {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createJiti } from "jiti";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
loadPluginManifestRegistry,
|
||||
type PluginManifestRecord,
|
||||
} from "../plugins/manifest-registry.js";
|
||||
import { openBoundaryFileSync } from "./boundary-file-read.js";
|
||||
|
||||
const MATRIX_PLUGIN_ID = "matrix";
|
||||
const MATRIX_HELPER_CANDIDATES = [
|
||||
"legacy-crypto-inspector.ts",
|
||||
"legacy-crypto-inspector.js",
|
||||
path.join("dist", "legacy-crypto-inspector.js"),
|
||||
] as const;
|
||||
|
||||
export const MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE =
|
||||
"Legacy Matrix encrypted state was detected, but the Matrix plugin helper is unavailable. Install or repair @openclaw/matrix so OpenClaw can inspect the old rust crypto store before upgrading.";
|
||||
|
||||
type MatrixLegacyCryptoInspectorParams = {
|
||||
cryptoRootDir: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
log?: (message: string) => void;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoInspectorResult = {
|
||||
deviceId: string | null;
|
||||
roomKeyCounts: {
|
||||
total: number;
|
||||
backedUp: number;
|
||||
} | null;
|
||||
backupVersion: string | null;
|
||||
decryptionKeyBase64: string | null;
|
||||
};
|
||||
|
||||
export type MatrixLegacyCryptoInspector = (
|
||||
params: MatrixLegacyCryptoInspectorParams,
|
||||
) => Promise<MatrixLegacyCryptoInspectorResult>;
|
||||
|
||||
function resolveMatrixPluginRecord(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
workspaceDir?: string;
|
||||
}): PluginManifestRecord | null {
|
||||
const registry = loadPluginManifestRegistry({
|
||||
config: params.cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
});
|
||||
return registry.plugins.find((plugin) => plugin.id === MATRIX_PLUGIN_ID) ?? null;
|
||||
}
|
||||
|
||||
type MatrixLegacyCryptoInspectorPathResolution =
|
||||
| { status: "ok"; helperPath: string }
|
||||
| { status: "missing" }
|
||||
| { status: "unsafe"; candidatePath: string };
|
||||
|
||||
function resolveMatrixLegacyCryptoInspectorPath(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
workspaceDir?: string;
|
||||
}): MatrixLegacyCryptoInspectorPathResolution {
|
||||
const plugin = resolveMatrixPluginRecord(params);
|
||||
if (!plugin) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
for (const relativePath of MATRIX_HELPER_CANDIDATES) {
|
||||
const candidatePath = path.join(plugin.rootDir, relativePath);
|
||||
const opened = openBoundaryFileSync({
|
||||
absolutePath: candidatePath,
|
||||
rootPath: plugin.rootDir,
|
||||
boundaryLabel: "plugin root",
|
||||
rejectHardlinks: plugin.origin !== "bundled",
|
||||
allowedType: "file",
|
||||
});
|
||||
if (opened.ok) {
|
||||
fs.closeSync(opened.fd);
|
||||
return { status: "ok", helperPath: opened.path };
|
||||
}
|
||||
if (opened.reason !== "path") {
|
||||
return { status: "unsafe", candidatePath };
|
||||
}
|
||||
}
|
||||
return { status: "missing" };
|
||||
}
|
||||
|
||||
export function isMatrixLegacyCryptoInspectorAvailable(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
workspaceDir?: string;
|
||||
}): boolean {
|
||||
return resolveMatrixLegacyCryptoInspectorPath(params).status === "ok";
|
||||
}
|
||||
|
||||
let jitiLoader: ReturnType<typeof createJiti> | null = null;
|
||||
const inspectorCache = new Map<string, Promise<MatrixLegacyCryptoInspector>>();
|
||||
|
||||
function getJiti() {
|
||||
if (!jitiLoader) {
|
||||
jitiLoader = createJiti(import.meta.url, {
|
||||
interopDefault: false,
|
||||
extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"],
|
||||
});
|
||||
}
|
||||
return jitiLoader;
|
||||
}
|
||||
|
||||
type LoadedMatrixLegacyCryptoInspectorModule = {
|
||||
inspectLegacyMatrixCryptoStore?: unknown;
|
||||
default?: unknown;
|
||||
};
|
||||
|
||||
export async function loadMatrixLegacyCryptoInspector(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
workspaceDir?: string;
|
||||
}): Promise<MatrixLegacyCryptoInspector> {
|
||||
const resolution = resolveMatrixLegacyCryptoInspectorPath(params);
|
||||
if (resolution.status === "missing") {
|
||||
throw new Error(MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
if (resolution.status === "unsafe") {
|
||||
throw new Error(
|
||||
`Matrix plugin helper path is unsafe: ${resolution.candidatePath}. Reinstall @openclaw/matrix and try again.`,
|
||||
);
|
||||
}
|
||||
const helperPath = resolution.helperPath;
|
||||
|
||||
const cached = inspectorCache.get(helperPath);
|
||||
if (cached) {
|
||||
return await cached;
|
||||
}
|
||||
|
||||
const pending = (async () => {
|
||||
const loaded = await getJiti().import(helperPath);
|
||||
const defaultExport =
|
||||
loaded.default && typeof loaded.default === "object"
|
||||
? (loaded.default as LoadedMatrixLegacyCryptoInspectorModule)
|
||||
: null;
|
||||
const inspectLegacyMatrixCryptoStore =
|
||||
typeof loaded?.inspectLegacyMatrixCryptoStore === "function"
|
||||
? loaded.inspectLegacyMatrixCryptoStore
|
||||
: typeof loaded?.default === "function"
|
||||
? loaded.default
|
||||
: typeof defaultExport?.inspectLegacyMatrixCryptoStore === "function"
|
||||
? defaultExport.inspectLegacyMatrixCryptoStore
|
||||
: null;
|
||||
if (!inspectLegacyMatrixCryptoStore) {
|
||||
throw new Error(
|
||||
`Matrix plugin helper at ${helperPath} does not export inspectLegacyMatrixCryptoStore(). Reinstall @openclaw/matrix and try again.`,
|
||||
);
|
||||
}
|
||||
return inspectLegacyMatrixCryptoStore as MatrixLegacyCryptoInspector;
|
||||
})();
|
||||
inspectorCache.set(helperPath, pending);
|
||||
try {
|
||||
return await pending;
|
||||
} catch (err) {
|
||||
inspectorCache.delete(helperPath);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,6 @@ export {
|
|||
addAllowlistUserEntriesFromConfigEntry,
|
||||
buildAllowlistResolutionSummary,
|
||||
canonicalizeAllowlistWithResolvedIds,
|
||||
mergeAllowlist,
|
||||
patchAllowlistUsersInConfigEntries,
|
||||
summarizeMapping,
|
||||
} from "../channels/allowlists/resolve-utils.js";
|
||||
|
|
@ -44,7 +43,6 @@ export {
|
|||
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
||||
export { formatPairingApproveHint } from "../channels/plugins/helpers.js";
|
||||
export {
|
||||
buildSingleChannelSecretPromptState,
|
||||
addWildcardAllowFrom,
|
||||
mergeAllowFromEntries,
|
||||
promptAccountId,
|
||||
|
|
@ -55,10 +53,8 @@ export { promptChannelAccessConfig } from "../channels/plugins/setup-group-acces
|
|||
export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js";
|
||||
export {
|
||||
applyAccountNameToChannelSection,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
moveSingleAccountChannelSectionToDefaultAccount,
|
||||
} from "../channels/plugins/setup-helpers.js";
|
||||
export { createAccountListHelpers } from "../channels/plugins/account-helpers.js";
|
||||
export type {
|
||||
BaseProbeResult,
|
||||
ChannelDirectoryEntry,
|
||||
|
|
@ -74,7 +70,6 @@ export type {
|
|||
} 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,
|
||||
|
|
@ -105,28 +100,17 @@ 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 {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../infra/matrix-account-selection.js";
|
||||
export {
|
||||
hasActionableMatrixMigration,
|
||||
hasPendingMatrixMigration,
|
||||
maybeCreateMatrixMigrationSnapshot,
|
||||
resolveMatrixMigrationSnapshotMarkerPath,
|
||||
resolveMatrixMigrationSnapshotOutputDir,
|
||||
} from "../infra/matrix-migration-snapshot.js";
|
||||
export { maybeCreateMatrixMigrationSnapshot } from "../infra/matrix-migration-snapshot.js";
|
||||
export { getAgentScopedMediaLocalRoots } from "../media/local-roots.js";
|
||||
export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
|
||||
export { isPrivateOrLoopbackHost } from "../gateway/net.js";
|
||||
export {
|
||||
getSessionBindingService,
|
||||
|
|
@ -136,10 +120,8 @@ export {
|
|||
export { resolveOutboundSendDep } from "../infra/outbound/send-deps.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";
|
||||
|
|
@ -152,19 +134,10 @@ export {
|
|||
resolveAgentIdFromSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
export type { RuntimeEnv } from "../runtime.js";
|
||||
export {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
} from "../security/dm-policy-shared.js";
|
||||
export { normalizeStringEntries } from "../shared/string-normalization.js";
|
||||
export { formatDocsLink } from "../terminal/links.js";
|
||||
export { redactSensitiveText } from "../logging/redact.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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue