test(plugins): finish moving contract coverage

This commit is contained in:
Peter Steinberger
2026-04-04 00:08:53 +01:00
parent e4b5027c5e
commit ab318de8b7
87 changed files with 2225 additions and 4607 deletions

View File

@@ -11,22 +11,34 @@ import { clearSessionStoreCacheForTest } from "openclaw/plugin-sdk/config-runtim
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
type ConversationRuntimeModule = typeof import("openclaw/plugin-sdk/conversation-runtime");
type ResolveConfiguredBindingRoute = ConversationRuntimeModule["resolveConfiguredBindingRoute"];
type ConfiguredBindingRouteResult = ReturnType<ResolveConfiguredBindingRoute>;
type EnsureConfiguredBindingRouteReady =
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
type ResolveConfiguredBindingRoute =
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
ConversationRuntimeModule["ensureConfiguredBindingRouteReady"];
function createUnboundConfiguredRouteResult(): ConfiguredBindingRouteResult {
return {
bindingResolution: null,
route: {
agentId: "main",
channel: "discord",
accountId: "default",
sessionKey: SESSION_KEY,
mainSessionKey: SESSION_KEY,
lastRoutePolicy: "main",
matchedBy: "default",
},
};
}
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() =>
vi.fn<EnsureConfiguredBindingRouteReady>(async () => ({ ok: true })),
);
const resolveConfiguredBindingRouteMock = vi.hoisted(() =>
vi.fn<ResolveConfiguredBindingRoute>(({ route }) => ({
bindingResolution: null,
route,
})),
vi.fn<ResolveConfiguredBindingRoute>(() => createUnboundConfiguredRouteResult()),
);
type ConfiguredBindingRoute = ReturnType<ResolveConfiguredBindingRoute>;
type ConfiguredBindingRoute = ConfiguredBindingRouteResult;
type ConfiguredBindingResolution = NonNullable<ConfiguredBindingRoute["bindingResolution"]>;
function createConfiguredRouteResult(
@@ -35,6 +47,11 @@ function createConfiguredRouteResult(
return {
bindingResolution: {
record: {
bindingId: "binding-1",
targetSessionKey: SESSION_KEY,
targetKind: "session",
status: "active",
boundAt: Date.now(),
conversation: {
channel: "discord",
accountId: "default",
@@ -87,10 +104,7 @@ describe("discord native /think autocomplete", () => {
ensureConfiguredBindingRouteReadyMock.mockReset();
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
resolveConfiguredBindingRouteMock.mockReset();
resolveConfiguredBindingRouteMock.mockImplementation(({ route }) => ({
bindingResolution: null,
route,
}));
resolveConfiguredBindingRouteMock.mockReturnValue(createUnboundConfiguredRouteResult());
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
fs.writeFileSync(
STORE_PATH,

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./normalize.js";
describe("normalizeIMessageMessagingTarget", () => {
it("normalizes blank inputs to undefined", () => {
expect(normalizeIMessageMessagingTarget(" ")).toBeUndefined();
});
it("preserves service prefixes for handles", () => {
expect(normalizeIMessageMessagingTarget("sms:+1 (555) 222-3333")).toBe("sms:+15552223333");
});
it("drops service prefixes for chat targets", () => {
expect(normalizeIMessageMessagingTarget("sms:chat_id:123")).toBe("chat_id:123");
expect(normalizeIMessageMessagingTarget("imessage:CHAT_GUID:abc")).toBe("chat_guid:abc");
expect(normalizeIMessageMessagingTarget("auto:ChatIdentifier:foo")).toBe("chatidentifier:foo");
});
});
describe("looksLikeIMessageTargetId", () => {
it("detects common iMessage target forms", () => {
expect(looksLikeIMessageTargetId("sms:+15555550123")).toBe(true);
expect(looksLikeIMessageTargetId("chat_id:123")).toBe(true);
expect(looksLikeIMessageTargetId("user@example.com")).toBe(true);
expect(looksLikeIMessageTargetId("+15555550123")).toBe(true);
expect(looksLikeIMessageTargetId("")).toBe(false);
});
});

View File

@@ -0,0 +1,20 @@
import { afterEach, describe, expect, it } from "vitest";
import {
listImportedBundledPluginFacadeIds,
resetFacadeRuntimeStateForTest,
} from "../../../src/plugin-sdk/facade-runtime.js";
import { createIMessageTestPlugin } from "./test-plugin.js";
afterEach(() => {
resetFacadeRuntimeStateForTest();
});
describe("createIMessageTestPlugin", () => {
it("does not load the bundled iMessage facade by default", () => {
expect(listImportedBundledPluginFacadeIds()).toEqual([]);
createIMessageTestPlugin();
expect(listImportedBundledPluginFacadeIds()).toEqual([]);
});
});

View File

@@ -0,0 +1,387 @@
import { describe, expect, it } from "vitest";
import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js";
const getLineData = (result: ReturnType<typeof parseLineDirectives>) =>
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
describe("hasLineDirectives", () => {
it("matches expected detection across directive patterns", () => {
const cases: Array<{ text: string; expected: boolean }> = [
{ text: "Here are options [[quick_replies: A, B, C]]", expected: true },
{ text: "[[location: Place | Address | 35.6 | 139.7]]", expected: true },
{ text: "[[confirm: Continue? | Yes | No]]", expected: true },
{ text: "[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]", expected: true },
{ text: "Just regular text", expected: false },
{ text: "[[not_a_directive: something]]", expected: false },
{ text: "[[media_player: Song | Artist | Speaker]]", expected: true },
{ text: "[[event: Meeting | Jan 24 | 2pm]]", expected: true },
{ text: "[[agenda: Today | Meeting:9am, Lunch:12pm]]", expected: true },
{ text: "[[device: TV | Room]]", expected: true },
{ text: "[[appletv_remote: Apple TV | Playing]]", expected: true },
];
for (const testCase of cases) {
expect(hasLineDirectives(testCase.text)).toBe(testCase.expected);
}
});
});
describe("parseLineDirectives", () => {
describe("quick_replies", () => {
it("parses quick replies variants", () => {
const cases: Array<{
text: string;
channelData?: { line: { quickReplies: string[] } };
quickReplies: string[];
outputText?: string;
}> = [
{
text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]",
quickReplies: ["Option A", "Option B", "Option C"],
outputText: "Choose one:",
},
{
text: "Before [[quick_replies: A, B]] After",
quickReplies: ["A", "B"],
outputText: "Before After",
},
{
text: "Text [[quick_replies: C, D]]",
channelData: { line: { quickReplies: ["A", "B"] } },
quickReplies: ["A", "B", "C", "D"],
outputText: "Text",
},
];
for (const testCase of cases) {
const result = parseLineDirectives({
text: testCase.text,
channelData: testCase.channelData,
});
expect(getLineData(result).quickReplies).toEqual(testCase.quickReplies);
if (testCase.outputText !== undefined) {
expect(result.text).toBe(testCase.outputText);
}
}
});
});
describe("location", () => {
it("parses location variants", () => {
const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 };
const cases: Array<{
text: string;
channelData?: { line: { location: typeof existing } };
location?: typeof existing;
outputText?: string;
}> = [
{
text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]",
location: {
title: "Tokyo Station",
address: "Tokyo, Japan",
latitude: 35.6812,
longitude: 139.7671,
},
outputText: "Here's the location:",
},
{
text: "[[location: Place | Address | invalid | 139.7]]",
location: undefined,
},
{
text: "[[location: New | New Addr | 35.6 | 139.7]]",
channelData: { line: { location: existing } },
location: existing,
},
];
for (const testCase of cases) {
const result = parseLineDirectives({
text: testCase.text,
channelData: testCase.channelData,
});
expect(getLineData(result).location).toEqual(testCase.location);
if (testCase.outputText !== undefined) {
expect(result.text).toBe(testCase.outputText);
}
}
});
});
describe("confirm", () => {
it("parses confirm directives with default and custom action payloads", () => {
const cases = [
{
name: "default yes/no data",
text: "[[confirm: Delete this item? | Yes | No]]",
expectedTemplate: {
type: "confirm",
text: "Delete this item?",
confirmLabel: "Yes",
confirmData: "yes",
cancelLabel: "No",
cancelData: "no",
altText: "Delete this item?",
},
expectedText: undefined,
},
{
name: "custom action data",
text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]",
expectedTemplate: {
type: "confirm",
text: "Proceed?",
confirmLabel: "OK",
confirmData: "action=confirm",
cancelLabel: "Cancel",
cancelData: "action=cancel",
altText: "Proceed?",
},
expectedText: undefined,
},
] as const;
for (const testCase of cases) {
const result = parseLineDirectives({ text: testCase.text });
expect(getLineData(result).templateMessage, testCase.name).toEqual(
testCase.expectedTemplate,
);
expect(result.text, testCase.name).toBe(testCase.expectedText);
}
});
});
describe("buttons", () => {
it("parses message/uri/postback button actions and enforces action caps", () => {
const cases = [
{
name: "message actions",
text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]",
expectedTemplate: {
type: "buttons",
title: "Menu",
text: "Select an option",
actions: [
{ type: "message", label: "Help", data: "/help" },
{ type: "message", label: "Status", data: "/status" },
],
altText: "Menu: Select an option",
},
},
{
name: "uri action",
text: "[[buttons: Links | Visit us | Site:https://example.com]]",
expectedFirstAction: {
type: "uri",
label: "Site",
uri: "https://example.com",
},
},
{
name: "postback action",
text: "[[buttons: Actions | Choose | Select:action=select&id=1]]",
expectedFirstAction: {
type: "postback",
label: "Select",
data: "action=select&id=1",
},
},
{
name: "action cap",
text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]",
expectedActionCount: 4,
},
] as const;
for (const testCase of cases) {
const result = parseLineDirectives({ text: testCase.text });
const templateMessage = getLineData(result).templateMessage as {
type?: string;
actions?: Array<Record<string, unknown>>;
};
expect(templateMessage?.type, testCase.name).toBe("buttons");
if ("expectedTemplate" in testCase) {
expect(templateMessage, testCase.name).toEqual(testCase.expectedTemplate);
}
if ("expectedFirstAction" in testCase) {
expect(templateMessage?.actions?.[0], testCase.name).toEqual(
testCase.expectedFirstAction,
);
}
if ("expectedActionCount" in testCase) {
expect(templateMessage?.actions?.length, testCase.name).toBe(
testCase.expectedActionCount,
);
}
}
});
});
describe("media_player", () => {
it("parses media_player directives across full/minimal/paused variants", () => {
const cases = [
{
name: "all fields",
text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]",
expectedAltText: "🎵 Bohemian Rhapsody - Queen",
expectedText: "Now playing:",
expectFooter: true,
expectBodyContents: false,
},
{
name: "minimal",
text: "[[media_player: Unknown Track]]",
expectedAltText: "🎵 Unknown Track",
expectedText: undefined,
expectFooter: false,
expectBodyContents: false,
},
{
name: "paused status",
text: "[[media_player: Song | Artist | Player | | paused]]",
expectedAltText: undefined,
expectedText: undefined,
expectFooter: false,
expectBodyContents: true,
},
] as const;
for (const testCase of cases) {
const result = parseLineDirectives({ text: testCase.text });
const flexMessage = getLineData(result).flexMessage as {
altText?: string;
contents?: { footer?: { contents?: unknown[] }; body?: { contents?: unknown[] } };
};
expect(flexMessage, testCase.name).toBeDefined();
if (testCase.expectedAltText !== undefined) {
expect(flexMessage?.altText, testCase.name).toBe(testCase.expectedAltText);
}
if (testCase.expectedText !== undefined) {
expect(result.text, testCase.name).toBe(testCase.expectedText);
}
if (testCase.expectFooter) {
expect(flexMessage?.contents?.footer?.contents?.length, testCase.name).toBeGreaterThan(0);
}
if ("expectBodyContents" in testCase && testCase.expectBodyContents) {
expect(flexMessage?.contents?.body?.contents, testCase.name).toBeDefined();
}
}
});
});
describe("event", () => {
it("parses event variants", () => {
const cases = [
{
text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]",
altText: "📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM",
},
{
text: "[[event: Birthday Party | March 15]]",
altText: "📅 Birthday Party - March 15",
},
];
for (const testCase of cases) {
const result = parseLineDirectives({ text: testCase.text });
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe(testCase.altText);
}
});
});
describe("agenda", () => {
it("parses agenda variants", () => {
const cases = [
{
text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]",
altText: "📋 Today's Schedule (3 events)",
},
{
text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]",
altText: "📋 Tasks (3 events)",
},
];
for (const testCase of cases) {
const result = parseLineDirectives({ text: testCase.text });
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe(testCase.altText);
}
});
});
describe("device", () => {
it("parses device variants", () => {
const cases = [
{
text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]",
altText: "📱 TV: Playing",
},
{
text: "[[device: Speaker]]",
altText: "📱 Speaker",
},
];
for (const testCase of cases) {
const result = parseLineDirectives({ text: testCase.text });
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe(testCase.altText);
}
});
});
describe("appletv_remote", () => {
it("parses appletv remote variants", () => {
const cases = [
{
text: "[[appletv_remote: Apple TV | Playing]]",
contains: "Apple TV",
},
{
text: "[[appletv_remote: Apple TV]]",
contains: undefined,
},
];
for (const testCase of cases) {
const result = parseLineDirectives({ text: testCase.text });
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
if (testCase.contains) {
expect(flexMessage?.altText).toContain(testCase.contains);
}
}
});
});
describe("combined directives", () => {
it("handles text with no directives", () => {
const result = parseLineDirectives({
text: "Just plain text here",
});
expect(result.text).toBe("Just plain text here");
expect(getLineData(result).quickReplies).toBeUndefined();
expect(getLineData(result).location).toBeUndefined();
expect(getLineData(result).templateMessage).toBeUndefined();
});
it("preserves other payload fields", () => {
const result = parseLineDirectives({
text: "Hello [[quick_replies: A, B]]",
mediaUrl: "https://example.com/image.jpg",
replyToId: "msg123",
});
expect(result.mediaUrl).toBe("https://example.com/image.jpg");
expect(result.replyToId).toBe("msg123");
expect(getLineData(result).quickReplies).toEqual(["A", "B"]);
});
});
});

View File

@@ -1,142 +1,124 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import {
findMatrixAccountEntry,
requiresExplicitMatrixDefaultAccount,
resolveConfiguredMatrixAccountIds,
resolveMatrixDefaultOrOnlyAccountId,
} from "./account-selection.js";
import type { CoreConfig } from "./types.js";
import { getMatrixScopedEnvVarNames } from "./env-vars.js";
describe("Matrix account selection topology", () => {
it("includes a top-level default account when its auth is actually complete", () => {
const cfg = {
describe("matrix account selection", () => {
it("resolves configured account ids from non-canonical account keys", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accessToken: "default-token",
accounts: {
ops: {
"Team Ops": { homeserver: "https://matrix.example.org" },
},
},
},
};
expect(resolveConfiguredMatrixAccountIds(cfg)).toEqual(["team-ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
});
it("matches the default account against normalized Matrix account keys", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
defaultAccount: "Team Ops",
accounts: {
"Ops Bot": { homeserver: "https://matrix.example.org" },
"Team Ops": { homeserver: "https://matrix.example.org" },
},
},
},
};
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false);
});
it("requires an explicit default when multiple Matrix accounts exist without one", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: { homeserver: "https://matrix.example.org" },
alerts: { homeserver: "https://matrix.example.org" },
},
},
},
};
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(true);
});
it("finds the raw Matrix account entry by normalized account id", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
"Team Ops": {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
userId: "@ops:example.org",
},
},
},
},
} as CoreConfig;
};
expect(findMatrixAccountEntry(cfg, "team-ops")).toEqual({
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
});
});
it("discovers env-backed named Matrix accounts during enumeration", () => {
const keys = getMatrixScopedEnvVarNames("team-ops");
const cfg: OpenClawConfig = {
channels: {
matrix: {},
},
};
const env = {
[keys.homeserver]: "https://matrix.example.org",
[keys.accessToken]: "secret",
} satisfies NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["team-ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("team-ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
it("treats mixed default and named env-backed Matrix accounts as multi-account", () => {
const keys = getMatrixScopedEnvVarNames("team-ops");
const cfg: OpenClawConfig = {
channels: {
matrix: {},
},
};
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
} as NodeJS.ProcessEnv;
MATRIX_ACCESS_TOKEN: "default-secret",
[keys.homeserver]: "https://matrix.example.org",
[keys.accessToken]: "team-secret",
} satisfies NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "team-ops"]);
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(true);
});
it("does not materialize a top-level default account from partial shared auth fields", () => {
const cfg = {
channels: {
matrix: {
accessToken: "shared-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveConfiguredMatrixAccountIds(cfg, {} as NodeJS.ProcessEnv)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, {} as NodeJS.ProcessEnv)).toBe(false);
});
it("does not materialize a default env account from partial global auth fields", () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
const env = {
MATRIX_ACCESS_TOKEN: "shared-token",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
it("does not materialize a top-level default account from homeserver plus userId alone", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@default:example.org",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveConfiguredMatrixAccountIds(cfg, {} as NodeJS.ProcessEnv)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, {} as NodeJS.ProcessEnv)).toBe(false);
});
it("does not materialize a default env account from global homeserver plus userId alone", () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
it("discovers default Matrix accounts backed only by global env vars", () => {
const cfg: OpenClawConfig = {};
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_USER_ID: "@default:example.org",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
MATRIX_ACCESS_TOKEN: "default-secret",
} satisfies NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
it("counts env-backed named accounts when shared homeserver comes from channel config", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
it("keeps env-backed named accounts that rely on cached credentials", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_USER_ID: "@ops:example.org",
} as NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
});
});

View File

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

View File

@@ -0,0 +1,86 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../../test/helpers/temp-home.js";
import { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js";
function writeFile(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value, "utf-8");
}
describe("matrix legacy state migration", () => {
it("migrates the flat legacy Matrix store into account-scoped storage", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
};
const detection = detectLegacyMatrixState({ cfg, env: process.env });
expect(detection && "warning" in detection).toBe(false);
if (!detection || "warning" in detection) {
throw new Error("expected a migratable Matrix legacy state plan");
}
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
expect(result.migrated).toBe(true);
expect(result.warnings).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,228 @@
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../../test/helpers/temp-home.js";
import { resolveMatrixMigrationAccountTarget } from "./migration-config.js";
import {
MATRIX_OPS_ACCESS_TOKEN,
MATRIX_OPS_ACCOUNT_ID,
MATRIX_OPS_USER_ID,
MATRIX_TEST_HOMESERVER,
writeMatrixCredentials,
} from "./test-helpers.js";
function resolveOpsTarget(cfg: OpenClawConfig, env = process.env) {
return resolveMatrixMigrationAccountTarget({
cfg,
env,
accountId: MATRIX_OPS_ACCOUNT_ID,
});
}
describe("resolveMatrixMigrationAccountTarget", () => {
it("reuses stored user identity for token-only configs when the access token matches", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeMatrixCredentials(stateDir, {
accountId: MATRIX_OPS_ACCOUNT_ID,
deviceId: "DEVICE-OPS",
accessToken: MATRIX_OPS_ACCESS_TOKEN,
});
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
accessToken: MATRIX_OPS_ACCESS_TOKEN,
},
},
},
},
};
const target = resolveOpsTarget(cfg);
expect(target).not.toBeNull();
expect(target?.userId).toBe(MATRIX_OPS_USER_ID);
expect(target?.storedDeviceId).toBe("DEVICE-OPS");
});
});
it("ignores stored device IDs from stale cached Matrix credentials", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeMatrixCredentials(stateDir, {
accountId: MATRIX_OPS_ACCOUNT_ID,
userId: "@old-bot:example.org",
accessToken: "tok-old",
deviceId: "DEVICE-OLD",
});
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: "@new-bot:example.org",
accessToken: "tok-new",
},
},
},
},
};
const target = resolveOpsTarget(cfg);
expect(target).not.toBeNull();
expect(target?.userId).toBe("@new-bot:example.org");
expect(target?.accessToken).toBe("tok-new");
expect(target?.storedDeviceId).toBeNull();
});
});
it("does not trust stale stored creds on the same homeserver when the token changes", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeMatrixCredentials(stateDir, {
accountId: MATRIX_OPS_ACCOUNT_ID,
userId: "@old-bot:example.org",
accessToken: "tok-old",
deviceId: "DEVICE-OLD",
});
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
accessToken: "tok-new",
},
},
},
},
};
const target = resolveOpsTarget(cfg);
expect(target).toBeNull();
});
});
it("does not inherit the base userId for non-default token-only accounts", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeMatrixCredentials(stateDir, {
accountId: MATRIX_OPS_ACCOUNT_ID,
deviceId: "DEVICE-OPS",
accessToken: MATRIX_OPS_ACCESS_TOKEN,
});
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: "@base-bot:example.org",
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
accessToken: MATRIX_OPS_ACCESS_TOKEN,
},
},
},
},
};
const target = resolveOpsTarget(cfg);
expect(target).not.toBeNull();
expect(target?.userId).toBe(MATRIX_OPS_USER_ID);
expect(target?.storedDeviceId).toBe("DEVICE-OPS");
});
});
it("does not inherit the base access token for non-default accounts", async () => {
await withTempHome(async () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: "@base-bot:example.org",
accessToken: "tok-base",
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_OPS_USER_ID,
},
},
},
},
};
const target = resolveOpsTarget(cfg);
expect(target).toBeNull();
});
});
it("does not inherit the global Matrix access token for non-default accounts", async () => {
await withTempHome(
async () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_OPS_USER_ID,
},
},
},
},
};
const target = resolveOpsTarget(cfg);
expect(target).toBeNull();
},
{
env: {
MATRIX_ACCESS_TOKEN: "tok-global",
},
},
);
});
it("uses the same scoped env token encoding as runtime account auth", async () => {
await withTempHome(async () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
"ops-prod": {},
},
},
},
};
const env = {
MATRIX_OPS_X2D_PROD_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_X2D_PROD_USER_ID: "@ops-prod:example.org",
MATRIX_OPS_X2D_PROD_ACCESS_TOKEN: "tok-ops-prod",
} as NodeJS.ProcessEnv;
const target = resolveMatrixMigrationAccountTarget({
cfg,
env,
accountId: "ops-prod",
});
expect(target).not.toBeNull();
expect(target?.homeserver).toBe("https://matrix.example.org");
expect(target?.userId).toBe("@ops-prod:example.org");
expect(target?.accessToken).toBe("tok-ops-prod");
});
});
});

View File

@@ -0,0 +1,98 @@
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 { detectLegacyMatrixCrypto } from "./legacy-crypto.js";
import {
hasActionableMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
resolveMatrixMigrationSnapshotMarkerPath,
resolveMatrixMigrationSnapshotOutputDir,
} from "./migration-snapshot.js";
import { resolveMatrixAccountStorageRoot } from "./storage-paths.js";
describe("matrix migration snapshots", () => {
it("creates a backup marker after writing a pre-migration snapshot", async () => {
await withTempHome(async (home) => {
fs.writeFileSync(path.join(home, ".openclaw", "openclaw.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(home, ".openclaw", "state.txt"), "state\n", "utf8");
const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" });
expect(result.created).toBe(true);
expect(result.markerPath).toBe(resolveMatrixMigrationSnapshotMarkerPath(process.env));
expect(
result.archivePath.startsWith(resolveMatrixMigrationSnapshotOutputDir(process.env)),
).toBe(true);
expect(fs.existsSync(result.archivePath)).toBe(true);
});
});
it("treats resolvable Matrix legacy state as actionable", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
fs.mkdirSync(path.join(stateDir, "matrix"), { recursive: true });
fs.writeFileSync(
path.join(stateDir, "matrix", "bot-storage.json"),
'{"legacy":true}',
"utf8",
);
expect(
hasActionableMatrixMigration({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
} as never,
env: process.env,
}),
).toBe(true);
});
});
it("treats legacy Matrix crypto as actionable when the extension inspector is present", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
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).toEqual([]);
expect(
hasActionableMatrixMigration({
cfg,
env: process.env,
}),
).toBe(true);
});
});
});

View File

@@ -1,8 +1,8 @@
export {
__testing,
createTestRegistry,
registerSessionBindingAdapter,
resolveAgentRoute,
setActivePluginRegistry,
type OpenClawConfig,
} from "../../../../test/helpers/plugins/matrix-monitor-route.js";
__testing,
} from "../../../../src/infra/outbound/session-binding-service.js";
export { setActivePluginRegistry } from "../../../../src/plugins/runtime.js";
export { resolveAgentRoute } from "../../../../src/routing/resolve-route.js";
export { createTestRegistry } from "../../../../src/test-utils/channel-plugins.js";
export type { OpenClawConfig } from "../../../../src/config/config.js";

View File

@@ -0,0 +1,193 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import * as tar from "tar";
import { describe, expect, it } from "vitest";
import type { ReleaseAsset } from "./install-signal-cli.js";
import { extractSignalCliArchive, looksLikeArchive, pickAsset } from "./install-signal-cli.js";
const SAMPLE_ASSETS: ReleaseAsset[] = [
{
name: "signal-cli-0.13.14-Linux-native.tar.gz",
browser_download_url: "https://example.com/linux-native.tar.gz",
},
{
name: "signal-cli-0.13.14-Linux-native.tar.gz.asc",
browser_download_url: "https://example.com/linux-native.tar.gz.asc",
},
{
name: "signal-cli-0.13.14-macOS-native.tar.gz",
browser_download_url: "https://example.com/macos-native.tar.gz",
},
{
name: "signal-cli-0.13.14-macOS-native.tar.gz.asc",
browser_download_url: "https://example.com/macos-native.tar.gz.asc",
},
{
name: "signal-cli-0.13.14-Windows-native.zip",
browser_download_url: "https://example.com/windows-native.zip",
},
{
name: "signal-cli-0.13.14-Windows-native.zip.asc",
browser_download_url: "https://example.com/windows-native.zip.asc",
},
{ name: "signal-cli-0.13.14.tar.gz", browser_download_url: "https://example.com/jvm.tar.gz" },
{
name: "signal-cli-0.13.14.tar.gz.asc",
browser_download_url: "https://example.com/jvm.tar.gz.asc",
},
];
describe("looksLikeArchive", () => {
it("recognises .tar.gz", () => {
expect(looksLikeArchive("foo.tar.gz")).toBe(true);
});
it("recognises .tgz", () => {
expect(looksLikeArchive("foo.tgz")).toBe(true);
});
it("recognises .zip", () => {
expect(looksLikeArchive("foo.zip")).toBe(true);
});
it("rejects signature files", () => {
expect(looksLikeArchive("foo.tar.gz.asc")).toBe(false);
});
it("rejects unrelated files", () => {
expect(looksLikeArchive("README.md")).toBe(false);
});
});
describe("pickAsset", () => {
describe("linux", () => {
it("selects the Linux-native asset on x64", () => {
const result = pickAsset(SAMPLE_ASSETS, "linux", "x64");
expect(result).toBeDefined();
expect(result!.name).toContain("Linux-native");
expect(result!.name).toMatch(/\.tar\.gz$/);
});
it("returns undefined on arm64 (triggers brew fallback)", () => {
const result = pickAsset(SAMPLE_ASSETS, "linux", "arm64");
expect(result).toBeUndefined();
});
it("returns undefined on arm (32-bit)", () => {
const result = pickAsset(SAMPLE_ASSETS, "linux", "arm");
expect(result).toBeUndefined();
});
});
describe("darwin", () => {
it("selects the macOS-native asset", () => {
const result = pickAsset(SAMPLE_ASSETS, "darwin", "arm64");
expect(result).toBeDefined();
expect(result!.name).toContain("macOS-native");
});
it("selects the macOS-native asset on x64", () => {
const result = pickAsset(SAMPLE_ASSETS, "darwin", "x64");
expect(result).toBeDefined();
expect(result!.name).toContain("macOS-native");
});
});
describe("win32", () => {
it("selects the Windows-native asset", () => {
const result = pickAsset(SAMPLE_ASSETS, "win32", "x64");
expect(result).toBeDefined();
expect(result!.name).toContain("Windows-native");
expect(result!.name).toMatch(/\.zip$/);
});
});
describe("edge cases", () => {
it("returns undefined for an empty asset list", () => {
expect(pickAsset([], "linux", "x64")).toBeUndefined();
});
it("skips assets with missing name or url", () => {
const partial: ReleaseAsset[] = [
{ name: "signal-cli.tar.gz" },
{ browser_download_url: "https://example.com/file.tar.gz" },
];
expect(pickAsset(partial, "linux", "x64")).toBeUndefined();
});
it("falls back to first archive for unknown platform", () => {
const result = pickAsset(SAMPLE_ASSETS, "freebsd" as NodeJS.Platform, "x64");
expect(result).toBeDefined();
expect(result!.name).toMatch(/\.tar\.gz$/);
});
it("never selects .asc signature files", () => {
const result = pickAsset(SAMPLE_ASSETS, "linux", "x64");
expect(result).toBeDefined();
expect(result!.name).not.toMatch(/\.asc$/);
});
});
});
describe("extractSignalCliArchive", () => {
async function withArchiveWorkspace(run: (workDir: string) => Promise<void>) {
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-install-"));
try {
await run(workDir);
} finally {
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
}
}
it("rejects zip slip path traversal", async () => {
await withArchiveWorkspace(async (workDir) => {
const archivePath = path.join(workDir, "bad.zip");
const extractDir = path.join(workDir, "extract");
await fs.mkdir(extractDir, { recursive: true });
const zip = new JSZip();
zip.file("../pwned.txt", "pwnd");
await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" }));
await expect(extractSignalCliArchive(archivePath, extractDir, 5_000)).rejects.toThrow(
/(escapes destination|absolute)/i,
);
});
});
it("extracts zip archives", async () => {
await withArchiveWorkspace(async (workDir) => {
const archivePath = path.join(workDir, "ok.zip");
const extractDir = path.join(workDir, "extract");
await fs.mkdir(extractDir, { recursive: true });
const zip = new JSZip();
zip.file("root/signal-cli", "bin");
await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" }));
await extractSignalCliArchive(archivePath, extractDir, 5_000);
const extracted = await fs.readFile(path.join(extractDir, "root", "signal-cli"), "utf-8");
expect(extracted).toBe("bin");
});
});
it("extracts tar.gz archives", async () => {
await withArchiveWorkspace(async (workDir) => {
const archivePath = path.join(workDir, "ok.tgz");
const extractDir = path.join(workDir, "extract");
const rootDir = path.join(workDir, "root");
await fs.mkdir(rootDir, { recursive: true });
await fs.writeFile(path.join(rootDir, "signal-cli"), "bin", "utf-8");
await tar.c({ cwd: workDir, file: archivePath, gzip: true }, ["root"]);
await fs.mkdir(extractDir, { recursive: true });
await extractSignalCliArchive(archivePath, extractDir, 5_000);
const extracted = await fs.readFile(path.join(extractDir, "root", "signal-cli"), "utf-8");
expect(extracted).toBe("bin");
});
});
});

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize.js";
describe("normalizeSignalMessagingTarget", () => {
it("normalizes uuid targets by stripping uuid:", () => {
expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
"123e4567-e89b-12d3-a456-426614174000",
);
});
it("normalizes signal:uuid targets", () => {
expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
"123e4567-e89b-12d3-a456-426614174000",
);
});
it("preserves case for group targets", () => {
expect(
normalizeSignalMessagingTarget("signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="),
).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=");
});
it("preserves case for base64-like group IDs without signal prefix", () => {
expect(
normalizeSignalMessagingTarget("group:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789+/ABCD="),
).toBe("group:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789+/ABCD=");
});
});
describe("looksLikeSignalTargetId", () => {
it("accepts uuid prefixes for target detection", () => {
expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
});
it("accepts signal-prefixed E.164 targets for detection", () => {
expect(looksLikeSignalTargetId("signal:+15551234567")).toBe(true);
expect(looksLikeSignalTargetId("signal:15551234567")).toBe(true);
});
it("accepts compact UUIDs for target detection", () => {
expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true);
expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true);
});
it("rejects invalid uuid prefixes", () => {
expect(looksLikeSignalTargetId("uuid:")).toBe(false);
expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false);
});
});

View File

@@ -1,6 +1,6 @@
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { describe, expect, it } from "vitest";
import { createSlackOutboundPayloadHarness } from "../../../src/channels/plugins/contracts/slack-outbound-harness.js";
import { createSlackOutboundPayloadHarness } from "../contract-api.js";
function createHarness(params: {
payload: ReplyPayload;

View File

@@ -1,9 +1,9 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { TELEGRAM_COMMAND_NAME_PATTERN } from "openclaw/plugin-sdk/config-runtime";
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { STATE_DIR } from "openclaw/plugin-sdk/state-paths";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js";
import { pluginCommandMocks, resetPluginCommandMocks } from "./test-support/plugin-command.js";
let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands;

View File

@@ -1,8 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import {
createWhatsAppPollFixture,
expectWhatsAppPollSent,
} from "../../../src/test-helpers/whatsapp-outbound.js";
import { createWhatsAppPollFixture, expectWhatsAppPollSent } from "../contract-api.js";
import { createWhatsAppOutboundBase } from "./outbound-base.js";
describe("createWhatsAppOutboundBase", () => {

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
isWhatsAppGroupJid,
looksLikeWhatsAppTargetId,
isWhatsAppUserTarget,
normalizeWhatsAppMessagingTarget,
normalizeWhatsAppTarget,
} from "./normalize-target.js";
@@ -71,3 +73,18 @@ describe("isWhatsAppGroupJid", () => {
expect(isWhatsAppGroupJid("+1555123")).toBe(false);
});
});
describe("normalizeWhatsAppMessagingTarget", () => {
it("normalizes blank inputs to undefined", () => {
expect(normalizeWhatsAppMessagingTarget(" ")).toBeUndefined();
});
});
describe("looksLikeWhatsAppTargetId", () => {
it("detects common WhatsApp target forms", () => {
expect(looksLikeWhatsAppTargetId("whatsapp:+15555550123")).toBe(true);
expect(looksLikeWhatsAppTargetId("15555550123@c.us")).toBe(true);
expect(looksLikeWhatsAppTargetId("+15555550123")).toBe(true);
expect(looksLikeWhatsAppTargetId("")).toBe(false);
});
});

View File

@@ -0,0 +1,152 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
assertWebChannel,
jidToE164,
markdownToWhatsApp,
resolveJidToE164,
toWhatsappJid,
} from "./text-runtime.js";
const CONFIG_DIR = path.join(process.env.HOME ?? os.tmpdir(), ".openclaw");
async function withTempDir<T>(
prefix: string,
run: (dir: string) => T | Promise<T>,
): Promise<Awaited<T>> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
try {
return await run(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
describe("markdownToWhatsApp", () => {
it.each([
["converts **bold** to *bold*", "**SOD Blast:**", "*SOD Blast:*"],
["converts __bold__ to *bold*", "__important__", "*important*"],
["converts ~~strikethrough~~ to ~strikethrough~", "~~deleted~~", "~deleted~"],
["leaves single *italic* unchanged (already WhatsApp bold)", "*text*", "*text*"],
["leaves _italic_ unchanged (already WhatsApp italic)", "_text_", "_text_"],
["preserves inline code", "Use `**not bold**` here", "Use `**not bold**` here"],
[
"handles mixed formatting",
"**bold** and ~~strike~~ and _italic_",
"*bold* and ~strike~ and _italic_",
],
["handles multiple bold segments", "**one** then **two**", "*one* then *two*"],
["returns empty string for empty input", "", ""],
["returns plain text unchanged", "no formatting here", "no formatting here"],
["handles bold inside a sentence", "This is **very** important", "This is *very* important"],
] as const)("handles markdown-to-whatsapp conversion: %s", (_name, input, expected) => {
expect(markdownToWhatsApp(input)).toBe(expected);
});
it("preserves fenced code blocks", () => {
const input = "```\nconst x = **bold**;\n```";
expect(markdownToWhatsApp(input)).toBe(input);
});
it("preserves code block with formatting inside", () => {
const input = "Before ```**bold** and ~~strike~~``` after **real bold**";
expect(markdownToWhatsApp(input)).toBe(
"Before ```**bold** and ~~strike~~``` after *real bold*",
);
});
});
describe("assertWebChannel", () => {
it("accepts valid channel", () => {
expect(() => assertWebChannel("web")).not.toThrow();
});
it("throws for invalid channel", () => {
expect(() => assertWebChannel("bad" as string)).toThrow();
});
});
describe("toWhatsappJid", () => {
it("strips formatting and prefixes", () => {
expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe("5551234567@s.whatsapp.net");
});
it("preserves existing JIDs", () => {
expect(toWhatsappJid("123456789-987654321@g.us")).toBe("123456789-987654321@g.us");
expect(toWhatsappJid("whatsapp:123456789-987654321@g.us")).toBe("123456789-987654321@g.us");
expect(toWhatsappJid("1555123@s.whatsapp.net")).toBe("1555123@s.whatsapp.net");
});
});
describe("jidToE164", () => {
it("maps @lid using reverse mapping file", () => {
const mappingPath = path.join(CONFIG_DIR, "credentials", "lid-mapping-123_reverse.json");
const original = fs.readFileSync;
const spy = vi.spyOn(fs, "readFileSync").mockImplementation((...args) => {
if (args[0] === mappingPath) {
return `"5551234"`;
}
return original(...args);
});
expect(jidToE164("123@lid")).toBe("+5551234");
spy.mockRestore();
});
it("maps @lid from authDir mapping files", async () => {
await withTempDir("openclaw-auth-", (authDir) => {
const mappingPath = path.join(authDir, "lid-mapping-456_reverse.json");
fs.writeFileSync(mappingPath, JSON.stringify("5559876"));
expect(jidToE164("456@lid", { authDir })).toBe("+5559876");
});
});
it("maps @hosted.lid from authDir mapping files", async () => {
await withTempDir("openclaw-auth-", (authDir) => {
const mappingPath = path.join(authDir, "lid-mapping-789_reverse.json");
fs.writeFileSync(mappingPath, JSON.stringify(4440001));
expect(jidToE164("789@hosted.lid", { authDir })).toBe("+4440001");
});
});
it("accepts hosted PN JIDs", () => {
expect(jidToE164("1555000:2@hosted")).toBe("+1555000");
});
it("falls back through lidMappingDirs in order", async () => {
await withTempDir("openclaw-lid-a-", async (first) => {
await withTempDir("openclaw-lid-b-", (second) => {
const mappingPath = path.join(second, "lid-mapping-321_reverse.json");
fs.writeFileSync(mappingPath, JSON.stringify("123321"));
expect(jidToE164("321@lid", { lidMappingDirs: [first, second] })).toBe("+123321");
});
});
});
});
describe("resolveJidToE164", () => {
it("resolves @lid via lidLookup when mapping file is missing", async () => {
const lidLookup = {
getPNForLID: vi.fn().mockResolvedValue("777:0@s.whatsapp.net"),
};
await expect(resolveJidToE164("777@lid", { lidLookup })).resolves.toBe("+777");
expect(lidLookup.getPNForLID).toHaveBeenCalledWith("777@lid");
});
it("skips lidLookup for non-lid JIDs", async () => {
const lidLookup = {
getPNForLID: vi.fn().mockResolvedValue("888:0@s.whatsapp.net"),
};
await expect(resolveJidToE164("888@s.whatsapp.net", { lidLookup })).resolves.toBe("+888");
expect(lidLookup.getPNForLID).not.toHaveBeenCalled();
});
it("returns null when lidLookup throws", async () => {
const lidLookup = {
getPNForLID: vi.fn().mockRejectedValue(new Error("lookup failed")),
};
await expect(resolveJidToE164("777@lid", { lidLookup })).resolves.toBeNull();
expect(lidLookup.getPNForLID).toHaveBeenCalledWith("777@lid");
});
});

View File

@@ -1,6 +1,6 @@
import { request as httpRequest } from "node:http";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/zalo";
import { expect, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import type { ResolvedZaloAccount } from "../src/types.js";
export function createLifecycleConfig(params: {

View File

@@ -1,4 +1,3 @@
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/zalo";
import { vi, type Mock } from "vitest";
import {
createEmptyPluginRegistry,
@@ -6,6 +5,7 @@ import {
} from "../../../test/helpers/plugins/plugin-registry.js";
import { createPluginRuntimeMock } from "../../../test/helpers/plugins/plugin-runtime-mock.js";
import { createRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import type { ResolvedZaloAccount } from "../src/types.js";
type MonitorModule = typeof import("../src/monitor.js");

View File

@@ -1,10 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { parseTelegramTopicConversation } from "../../extensions/telegram/api.js";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import type { ChannelConfiguredBindingProvider, ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import { parseTelegramTopicConversation } from "./conversation-id.js";
import { buildConfiguredAcpSessionKey } from "./persistent-bindings.types.js";
const managerMocks = vi.hoisted(() => ({
resolveSession: vi.fn(),

View File

@@ -8,7 +8,7 @@ import {
resolveAnthropicBetas,
resolveAnthropicFastMode,
resolveAnthropicServiceTier,
} from "../../extensions/anthropic/api.js";
} from "../../test/helpers/providers/anthropic-contract.js";
import { createConfiguredOllamaCompatNumCtxWrapper } from "../plugin-sdk/ollama.js";
import { __testing as extraParamsTesting } from "./pi-embedded-runner/extra-params.js";
import {

View File

@@ -2,8 +2,10 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { buildTelegramModelsProviderChannelData } from "../../../extensions/telegram/api.js";
import { whatsappCommandPolicy } from "../../../extensions/whatsapp/api.js";
import {
buildTelegramModelsProviderChannelData,
whatsappCommandPolicy,
} from "../../../test/helpers/channels/command-contract.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { updateSessionStore, type SessionEntry } from "../../config/sessions.js";

View File

@@ -1,342 +0,0 @@
import {
createAgendaCard,
createAppleTvRemoteCard,
createDeviceControlCard,
createMediaPlayerCard,
createEventCard,
} from "../../plugin-sdk/line.js";
import type { LineChannelData } from "../../plugin-sdk/line.js";
import type { ReplyPayload } from "../types.js";
/**
* Parse LINE-specific directives from text and extract them into ReplyPayload fields.
*
* Supported directives:
* - [[quick_replies: option1, option2, option3]]
* - [[location: title | address | latitude | longitude]]
* - [[confirm: question | yes_label | no_label]]
* - [[buttons: title | text | btn1:data1, btn2:data2]]
* - [[media_player: title | artist | source | imageUrl | playing/paused]]
* - [[event: title | date | time | location | description]]
* - [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
* - [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
* - [[appletv_remote: name | status]]
*
* Returns the modified payload with directives removed from text and fields populated.
*/
export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
let text = payload.text;
if (!text) {
return payload;
}
const result: ReplyPayload = { ...payload };
const lineData: LineChannelData = {
...(result.channelData?.line as LineChannelData | undefined),
};
const toSlug = (value: string): string =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "device";
const lineActionData = (action: string, extras?: Record<string, string>): string => {
const base = [`line.action=${encodeURIComponent(action)}`];
if (extras) {
for (const [key, value] of Object.entries(extras)) {
base.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
}
return base.join("&");
};
// Parse [[quick_replies: option1, option2, option3]]
const quickRepliesMatch = text.match(/\[\[quick_replies:\s*([^\]]+)\]\]/i);
if (quickRepliesMatch) {
const options = quickRepliesMatch[1]
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (options.length > 0) {
lineData.quickReplies = [...(lineData.quickReplies || []), ...options];
}
text = text.replace(quickRepliesMatch[0], "").trim();
}
// Parse [[location: title | address | latitude | longitude]]
const locationMatch = text.match(/\[\[location:\s*([^\]]+)\]\]/i);
if (locationMatch && !lineData.location) {
const parts = locationMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 4) {
const [title, address, latStr, lonStr] = parts;
const latitude = parseFloat(latStr);
const longitude = parseFloat(lonStr);
if (!isNaN(latitude) && !isNaN(longitude)) {
lineData.location = {
title: title || "Location",
address: address || "",
latitude,
longitude,
};
}
}
text = text.replace(locationMatch[0], "").trim();
}
// Parse [[confirm: question | yes_label | no_label]] or [[confirm: question | yes_label:yes_data | no_label:no_data]]
const confirmMatch = text.match(/\[\[confirm:\s*([^\]]+)\]\]/i);
if (confirmMatch && !lineData.templateMessage) {
const parts = confirmMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 3) {
const [question, yesPart, noPart] = parts;
// Parse yes_label:yes_data format
const [yesLabel, yesData] = yesPart.includes(":")
? yesPart.split(":").map((s) => s.trim())
: [yesPart, yesPart.toLowerCase()];
const [noLabel, noData] = noPart.includes(":")
? noPart.split(":").map((s) => s.trim())
: [noPart, noPart.toLowerCase()];
lineData.templateMessage = {
type: "confirm",
text: question,
confirmLabel: yesLabel,
confirmData: yesData,
cancelLabel: noLabel,
cancelData: noData,
altText: question,
};
}
text = text.replace(confirmMatch[0], "").trim();
}
// Parse [[buttons: title | text | btn1:data1, btn2:data2]]
const buttonsMatch = text.match(/\[\[buttons:\s*([^\]]+)\]\]/i);
if (buttonsMatch && !lineData.templateMessage) {
const parts = buttonsMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 3) {
const [title, bodyText, actionsStr] = parts;
const actions = actionsStr.split(",").map((actionStr) => {
const trimmed = actionStr.trim();
// Find first colon delimiter, ignoring URLs without a label.
const colonIndex = (() => {
const index = trimmed.indexOf(":");
if (index === -1) {
return -1;
}
const lower = trimmed.toLowerCase();
if (lower.startsWith("http://") || lower.startsWith("https://")) {
return -1;
}
return index;
})();
let label: string;
let data: string;
if (colonIndex === -1) {
label = trimmed;
data = trimmed;
} else {
label = trimmed.slice(0, colonIndex).trim();
data = trimmed.slice(colonIndex + 1).trim();
}
// Detect action type
if (data.startsWith("http://") || data.startsWith("https://")) {
return { type: "uri" as const, label, uri: data };
}
if (data.includes("=")) {
return { type: "postback" as const, label, data };
}
return { type: "message" as const, label, data: data || label };
});
if (actions.length > 0) {
lineData.templateMessage = {
type: "buttons",
title,
text: bodyText,
actions: actions.slice(0, 4), // LINE limit
altText: `${title}: ${bodyText}`,
};
}
}
text = text.replace(buttonsMatch[0], "").trim();
}
// Parse [[media_player: title | artist | source | imageUrl | playing/paused]]
const mediaPlayerMatch = text.match(/\[\[media_player:\s*([^\]]+)\]\]/i);
if (mediaPlayerMatch && !lineData.flexMessage) {
const parts = mediaPlayerMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 1) {
const [title, artist, source, imageUrl, statusStr] = parts;
const isPlaying = statusStr?.toLowerCase() === "playing";
// LINE requires HTTPS URLs for images - skip local/HTTP URLs
const validImageUrl = imageUrl?.startsWith("https://") ? imageUrl : undefined;
const deviceKey = toSlug(source || title || "media");
const card = createMediaPlayerCard({
title: title || "Unknown Track",
subtitle: artist || undefined,
source: source || undefined,
imageUrl: validImageUrl,
isPlaying: statusStr ? isPlaying : undefined,
controls: {
previous: { data: lineActionData("previous", { "line.device": deviceKey }) },
play: { data: lineActionData("play", { "line.device": deviceKey }) },
pause: { data: lineActionData("pause", { "line.device": deviceKey }) },
next: { data: lineActionData("next", { "line.device": deviceKey }) },
},
});
lineData.flexMessage = {
altText: `🎵 ${title}${artist ? ` - ${artist}` : ""}`,
contents: card,
};
}
text = text.replace(mediaPlayerMatch[0], "").trim();
}
// Parse [[event: title | date | time | location | description]]
const eventMatch = text.match(/\[\[event:\s*([^\]]+)\]\]/i);
if (eventMatch && !lineData.flexMessage) {
const parts = eventMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 2) {
const [title, date, time, location, description] = parts;
const card = createEventCard({
title: title || "Event",
date: date || "TBD",
time: time || undefined,
location: location || undefined,
description: description || undefined,
});
lineData.flexMessage = {
altText: `📅 ${title} - ${date}${time ? ` ${time}` : ""}`,
contents: card,
};
}
text = text.replace(eventMatch[0], "").trim();
}
// Parse [[appletv_remote: name | status]]
const appleTvMatch = text.match(/\[\[appletv_remote:\s*([^\]]+)\]\]/i);
if (appleTvMatch && !lineData.flexMessage) {
const parts = appleTvMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 1) {
const [deviceName, status] = parts;
const deviceKey = toSlug(deviceName || "apple_tv");
const card = createAppleTvRemoteCard({
deviceName: deviceName || "Apple TV",
status: status || undefined,
actionData: {
up: lineActionData("up", { "line.device": deviceKey }),
down: lineActionData("down", { "line.device": deviceKey }),
left: lineActionData("left", { "line.device": deviceKey }),
right: lineActionData("right", { "line.device": deviceKey }),
select: lineActionData("select", { "line.device": deviceKey }),
menu: lineActionData("menu", { "line.device": deviceKey }),
home: lineActionData("home", { "line.device": deviceKey }),
play: lineActionData("play", { "line.device": deviceKey }),
pause: lineActionData("pause", { "line.device": deviceKey }),
volumeUp: lineActionData("volume_up", { "line.device": deviceKey }),
volumeDown: lineActionData("volume_down", { "line.device": deviceKey }),
mute: lineActionData("mute", { "line.device": deviceKey }),
},
});
lineData.flexMessage = {
altText: `📺 ${deviceName || "Apple TV"} Remote`,
contents: card,
};
}
text = text.replace(appleTvMatch[0], "").trim();
}
// Parse [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
const agendaMatch = text.match(/\[\[agenda:\s*([^\]]+)\]\]/i);
if (agendaMatch && !lineData.flexMessage) {
const parts = agendaMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 2) {
const [title, eventsStr] = parts;
const events = eventsStr.split(",").map((eventStr) => {
const trimmed = eventStr.trim();
const colonIdx = trimmed.lastIndexOf(":");
if (colonIdx > 0) {
return {
title: trimmed.slice(0, colonIdx).trim(),
time: trimmed.slice(colonIdx + 1).trim(),
};
}
return { title: trimmed };
});
const card = createAgendaCard({
title: title || "Agenda",
events,
});
lineData.flexMessage = {
altText: `📋 ${title} (${events.length} events)`,
contents: card,
};
}
text = text.replace(agendaMatch[0], "").trim();
}
// Parse [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
const deviceMatch = text.match(/\[\[device:\s*([^\]]+)\]\]/i);
if (deviceMatch && !lineData.flexMessage) {
const parts = deviceMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 1) {
const [deviceName, deviceType, status, controlsStr] = parts;
const deviceKey = toSlug(deviceName || "device");
const controls = controlsStr
? controlsStr.split(",").map((ctrlStr) => {
const [label, data] = ctrlStr.split(":").map((s) => s.trim());
const action = data || label.toLowerCase().replace(/\s+/g, "_");
return { label, data: lineActionData(action, { "line.device": deviceKey }) };
})
: [];
const card = createDeviceControlCard({
deviceName: deviceName || "Device",
deviceType: deviceType || undefined,
status: status || undefined,
controls,
});
lineData.flexMessage = {
altText: `📱 ${deviceName}${status ? `: ${status}` : ""}`,
contents: card,
};
}
text = text.replace(deviceMatch[0], "").trim();
}
// Clean up multiple whitespace/newlines
text = text.replace(/\n{3,}/g, "\n\n").trim();
result.text = text || undefined;
if (Object.keys(lineData).length > 0) {
result.channelData = { ...result.channelData, line: lineData };
}
return result;
}
/**
* Check if text contains any LINE directives
*/
export function hasLineDirectives(text: string): boolean {
return /\[\[(quick_replies|location|confirm|buttons|media_player|event|agenda|device|appletv_remote):/i.test(
text,
);
}

View File

@@ -1,220 +0,0 @@
import { describe, expect, it } from "vitest";
import { hasSlackDirectives, parseSlackDirectives } from "./slack-directives.js";
const getSlackInteractive = (result: ReturnType<typeof parseSlackDirectives>) =>
result.interactive?.blocks ?? [];
describe("hasSlackDirectives", () => {
it("matches expected detection across Slack directive patterns", () => {
const cases: Array<{ text: string; expected: boolean }> = [
{ text: "Pick one [[slack_buttons: Approve:approve, Reject:reject]]", expected: true },
{
text: "[[slack_select: Choose a project | Alpha:alpha, Beta:beta]]",
expected: true,
},
{ text: "Just regular text", expected: false },
{ text: "[[buttons: Menu | Choose | A:a]]", expected: false },
];
for (const testCase of cases) {
expect(hasSlackDirectives(testCase.text)).toBe(testCase.expected);
}
});
});
describe("parseSlackDirectives", () => {
it("builds shared text and button blocks from slack_buttons directives", () => {
const result = parseSlackDirectives({
text: "Choose an action [[slack_buttons: Approve:approve, Reject:reject]]",
});
expect(result.text).toBe("Choose an action");
expect(getSlackInteractive(result)).toEqual([
{
type: "text",
text: "Choose an action",
},
{
type: "buttons",
buttons: [
{
label: "Approve",
value: "approve",
},
{
label: "Reject",
value: "reject",
},
],
},
]);
});
it("builds shared select blocks from slack_select directives", () => {
const result = parseSlackDirectives({
text: "[[slack_select: Choose a project | Alpha:alpha, Beta:beta]]",
});
expect(result.text).toBeUndefined();
expect(getSlackInteractive(result)).toEqual([
{
type: "select",
placeholder: "Choose a project",
options: [
{ label: "Alpha", value: "alpha" },
{ label: "Beta", value: "beta" },
],
},
]);
});
it("leaves existing slack blocks in channelData and appends shared interactive blocks", () => {
const result = parseSlackDirectives({
text: "Act now [[slack_buttons: Retry:retry]]",
channelData: {
slack: {
blocks: [{ type: "divider" }],
},
},
});
expect(result.text).toBe("Act now");
expect(result.channelData).toEqual({
slack: {
blocks: [{ type: "divider" }],
},
});
expect(getSlackInteractive(result)).toEqual([
{
type: "text",
text: "Act now",
},
{
type: "buttons",
buttons: [{ label: "Retry", value: "retry" }],
},
]);
});
it("preserves authored order for mixed Slack directives", () => {
const result = parseSlackDirectives({
text: "[[slack_select: Pick one | Alpha:alpha]] then [[slack_buttons: Retry:retry]]",
});
expect(getSlackInteractive(result)).toEqual([
{
type: "select",
placeholder: "Pick one",
options: [{ label: "Alpha", value: "alpha" }],
},
{
type: "text",
text: "then",
},
{
type: "buttons",
buttons: [{ label: "Retry", value: "retry" }],
},
]);
});
it("preserves long Slack directive values in the shared interactive model", () => {
const long = "x".repeat(120);
const result = parseSlackDirectives({
text: `${"y".repeat(3100)} [[slack_select: ${long} | ${long}:${long}]] [[slack_buttons: ${long}:${long}]]`,
});
expect(getSlackInteractive(result)).toEqual([
{
type: "text",
text: "y".repeat(3100),
},
{
type: "select",
placeholder: long,
options: [{ label: long, value: long }],
},
{
type: "buttons",
buttons: [{ label: long, value: long }],
},
]);
});
it("parses optional Slack button styles without truncating callback values", () => {
const result = parseSlackDirectives({
text: "[[slack_buttons: Approve:pluginbind:approval-123:o:primary, Reject:deny:danger, Skip:skip:secondary]]",
});
expect(getSlackInteractive(result)).toEqual([
{
type: "buttons",
buttons: [
{
label: "Approve",
value: "pluginbind:approval-123:o",
style: "primary",
},
{
label: "Reject",
value: "deny",
style: "danger",
},
{
label: "Skip",
value: "skip",
style: "secondary",
},
],
},
]);
});
it("preserves slack_select values that end in style-like suffixes", () => {
const result = parseSlackDirectives({
text: "[[slack_select: Choose one | Queue:queue:danger, Archive:archive:primary]]",
});
expect(getSlackInteractive(result)).toEqual([
{
type: "select",
placeholder: "Choose one",
options: [
{
label: "Queue",
value: "queue:danger",
},
{
label: "Archive",
value: "archive:primary",
},
],
},
]);
});
it("keeps existing interactive blocks when compiling additional Slack directives", () => {
const result = parseSlackDirectives({
text: "Choose [[slack_buttons: Retry:retry]]",
interactive: {
blocks: [{ type: "text", text: "Existing" }],
},
});
expect(getSlackInteractive(result)).toEqual([
{ type: "text", text: "Existing" },
{ type: "text", text: "Choose" },
{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] },
]);
});
it("ignores malformed directive choices when none remain", () => {
const result = parseSlackDirectives({
text: "Choose [[slack_buttons: : , : ]]",
});
expect(result).toEqual({
text: "Choose [[slack_buttons: : , : ]]",
});
});
});

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach } from "vitest";
import { normalizeWhatsAppAllowFromEntries } from "../../../extensions/whatsapp/api.js";
import { normalizeE164 } from "../../plugin-sdk/account-resolution.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
@@ -11,6 +11,34 @@ function formatDiscordAllowFromEntries(allowFrom: Array<string | number>): strin
.map((entry) => entry.toLowerCase());
}
function normalizePhoneAllowFromEntries(allowFrom: Array<string | number>): string[] {
return allowFrom
.map((entry) => String(entry).trim())
.filter((entry): entry is string => Boolean(entry))
.map((entry) => {
if (entry === "*") {
return entry;
}
const stripped = entry.replace(/^whatsapp:/i, "").trim();
if (/@g\.us$/i.test(stripped)) {
return stripped;
}
if (/^(\d+)(?::\d+)?@s\.whatsapp\.net$/i.test(stripped)) {
const match = stripped.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/i);
return match ? normalizeE164(match[1]) : null;
}
if (/^(\d+)@lid$/i.test(stripped)) {
const match = stripped.match(/^(\d+)@lid$/i);
return match ? normalizeE164(match[1]) : null;
}
if (stripped.includes("@")) {
return null;
}
return normalizeE164(stripped);
})
.filter((entry): entry is string => Boolean(entry));
}
function resolveChannelAllowFrom(
cfg: Record<string, unknown>,
channelId: string,
@@ -52,7 +80,7 @@ export const createCommandAuthRegistry = () =>
resolveAllowFrom: ({ cfg }: { cfg: Record<string, unknown> }) =>
resolveChannelAllowFrom(cfg, "whatsapp"),
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
normalizeWhatsAppAllowFromEntries(allowFrom),
normalizePhoneAllowFromEntries(allowFrom),
},
},
source: "test",

View File

@@ -133,12 +133,7 @@ const SETUP_BARREL_GUARDS: GuardedSource[] = [
},
{
path: bundledPluginFile("signal", "src/setup-surface.ts"),
forbiddenPatterns: [
/\bdetectBinary\b/,
/\binstallSignalCli\b/,
/\bformatCliCommand\b/,
/\bformatDocsLink\b/,
],
forbiddenPatterns: [/\bdetectBinary\b/, /\bformatCliCommand\b/, /\bformatDocsLink\b/],
},
{
path: bundledPluginFile("slack", "src/setup-core.ts"),

View File

@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { isSignalSenderAllowed, type SignalSender } from "../../../../extensions/signal/api.js";
import {
isSignalSenderAllowed,
type SignalSender,
} from "../../../../test/helpers/channels/policy-contract.js";
import { isAllowedBlueBubblesSender } from "../../../plugin-sdk/bluebubbles-policy.js";
import { isMattermostSenderAllowed } from "../../../plugin-sdk/mattermost-policy.js";
import {

View File

@@ -1,50 +0,0 @@
import { vi, type Mock } from "vitest";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import { loadBundledPluginTestApiSync } from "../../../test-utils/bundled-plugin-public-surface.js";
import type { ChannelOutboundAdapter } from "../types.js";
import { primeChannelOutboundSendMock } from "./test-helpers.js";
type OutboundSendMock = Mock<(...args: unknown[]) => Promise<Record<string, unknown>>>;
type SlackOutboundPayloadHarness = {
run: () => Promise<Record<string, unknown>>;
sendMock: OutboundSendMock;
to: string;
};
let slackOutboundCache: ChannelOutboundAdapter | undefined;
function getSlackOutbound(): ChannelOutboundAdapter {
if (!slackOutboundCache) {
({ slackOutbound: slackOutboundCache } = loadBundledPluginTestApiSync<{
slackOutbound: ChannelOutboundAdapter;
}>("slack"));
}
return slackOutboundCache;
}
export function createSlackOutboundPayloadHarness(params: {
payload: ReplyPayload;
sendResults?: Array<{ messageId: string }>;
}): SlackOutboundPayloadHarness {
const sendSlack: OutboundSendMock = vi.fn();
primeChannelOutboundSendMock(
sendSlack,
{ messageId: "sl-1", channelId: "C12345", ts: "1234.5678" },
params.sendResults,
);
const ctx = {
cfg: {},
to: "C12345",
text: "",
payload: params.payload,
deps: {
sendSlack,
},
};
return {
run: async () => await getSlackOutbound().sendPayload!(ctx),
sendMock: sendSlack,
to: ctx.to,
};
}

View File

@@ -1,32 +0,0 @@
import { describe, expect, it } from "vitest";
import { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./imessage.js";
import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./whatsapp.js";
describe("normalize target helpers", () => {
describe("iMessage", () => {
it("normalizes blank inputs to undefined", () => {
expect(normalizeIMessageMessagingTarget(" ")).toBeUndefined();
});
it("detects common iMessage target forms", () => {
expect(looksLikeIMessageTargetId("sms:+15555550123")).toBe(true);
expect(looksLikeIMessageTargetId("chat_id:123")).toBe(true);
expect(looksLikeIMessageTargetId("user@example.com")).toBe(true);
expect(looksLikeIMessageTargetId("+15555550123")).toBe(true);
expect(looksLikeIMessageTargetId("")).toBe(false);
});
});
describe("WhatsApp", () => {
it("normalizes blank inputs to undefined", () => {
expect(normalizeWhatsAppMessagingTarget(" ")).toBeUndefined();
});
it("detects common WhatsApp target forms", () => {
expect(looksLikeWhatsAppTargetId("whatsapp:+15555550123")).toBe(true);
expect(looksLikeWhatsAppTargetId("15555550123@c.us")).toBe(true);
expect(looksLikeWhatsAppTargetId("+15555550123")).toBe(true);
expect(looksLikeWhatsAppTargetId("")).toBe(false);
});
});
});

View File

@@ -3,17 +3,12 @@ import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TEST_BUNDLED_RUNTIME_SIDECAR_PATHS } from "../../test/helpers/bundled-runtime-sidecars.js";
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js";
import type { UpdateRunResult } from "../infra/update-runner.js";
import { withEnvAsync } from "../test-utils/env.js";
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
const TEST_BUNDLED_RUNTIME_SIDECAR_PATHS = [
"dist/extensions/discord/runtime-api.js",
"dist/extensions/slack/helper-api.js",
"dist/extensions/telegram/thread-bindings-runtime.js",
] as const;
const confirm = vi.fn();
const select = vi.fn();
const spinner = vi.fn(() => ({ start: vi.fn(), stop: vi.fn() }));

View File

@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createIMessageTestPlugin } from "../../test/helpers/channels/imessage-test-plugin.js";
import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js";
import { formatGatewayChannelsStatusLines } from "./channels/status.js";
const signalPlugin = {

View File

@@ -2,7 +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-config-helpers.js";
import { resolveMatrixAccountStorageRoot } from "../plugin-sdk/matrix.js";
import * as noteModule from "../terminal/note.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";

View File

@@ -304,10 +304,10 @@ describe("doctor legacy state migrations", () => {
it("migrates legacy Telegram pairing allowFrom store to account-scoped default file", async () => {
const { root, cfg } = await makeRootWithEmptyCfg();
const { oauthDir, detected, result } = await runTelegramAllowFromMigration({ root, cfg });
expect(detected.pairingAllowFrom.hasLegacyTelegram).toBe(true);
expect(
detected.pairingAllowFrom.copyPlans.map((plan) => path.basename(plan.targetPath)),
).toEqual(["telegram-default-allowFrom.json"]);
expect(detected.channelPlans.hasLegacy).toBe(true);
expect(detected.channelPlans.plans.map((plan) => path.basename(plan.targetPath))).toEqual([
"telegram-default-allowFrom.json",
]);
expect(result.warnings).toEqual([]);
const target = path.join(oauthDir, "telegram-default-allowFrom.json");
@@ -332,10 +332,10 @@ describe("doctor legacy state migrations", () => {
},
};
const { oauthDir, detected, result } = await runTelegramAllowFromMigration({ root, cfg });
expect(detected.pairingAllowFrom.hasLegacyTelegram).toBe(true);
expect(
detected.pairingAllowFrom.copyPlans.map((plan) => path.basename(plan.targetPath)),
).toEqual(["telegram-bot2-allowFrom.json"]);
expect(detected.channelPlans.hasLegacy).toBe(true);
expect(detected.channelPlans.plans.map((plan) => path.basename(plan.targetPath))).toEqual([
"telegram-bot2-allowFrom.json",
]);
expect(result.warnings).toEqual([]);
const bot1Target = path.join(oauthDir, "telegram-bot1-allowFrom.json");
@@ -368,10 +368,10 @@ describe("doctor legacy state migrations", () => {
};
const { oauthDir, detected, result } = await runTelegramAllowFromMigration({ root, cfg });
expect(detected.pairingAllowFrom.hasLegacyTelegram).toBe(true);
expect(
detected.pairingAllowFrom.copyPlans.map((plan) => path.basename(plan.targetPath)),
).toEqual(["telegram-alerts-allowFrom.json"]);
expect(detected.channelPlans.hasLegacy).toBe(true);
expect(detected.channelPlans.plans.map((plan) => path.basename(plan.targetPath))).toEqual([
"telegram-alerts-allowFrom.json",
]);
expect(result.warnings).toEqual([]);
const alertsTarget = path.join(oauthDir, "telegram-alerts-allowFrom.json");

View File

@@ -160,14 +160,9 @@ function createLegacyStateMigrationDetectionResult(params?: {
targetDir: "/tmp/state/agents/main/agent",
hasLegacy: false,
},
whatsappAuth: {
legacyDir: "/tmp/oauth",
targetDir: "/tmp/oauth/whatsapp/default",
channelPlans: {
hasLegacy: false,
},
pairingAllowFrom: {
hasLegacyTelegram: false,
copyPlans: [],
plans: [],
},
preview: params?.preview ?? [],
};

View File

@@ -1,85 +0,0 @@
import { describe, expect, it } from "vitest";
import {
collectMutableAllowlistWarnings,
scanMutableAllowlistEntries,
} from "./mutable-allowlist.js";
describe("doctor mutable allowlist scanner", () => {
it("finds mutable built-in allowlist entries when dangerous matching is disabled", () => {
const hits = scanMutableAllowlistEntries({
channels: {
irc: {
allowFrom: ["charlie"],
groups: {
"#ops": {
allowFrom: ["dana"],
},
},
},
googlechat: {
groupAllowFrom: ["engineering@example.com"],
},
},
});
expect(hits).toEqual(
expect.arrayContaining([
expect.objectContaining({
channel: "irc",
path: "channels.irc.allowFrom",
entry: "charlie",
}),
expect.objectContaining({
channel: "irc",
path: "channels.irc.groups.#ops.allowFrom",
entry: "dana",
}),
expect.objectContaining({
channel: "googlechat",
path: "channels.googlechat.groupAllowFrom",
entry: "engineering@example.com",
}),
]),
);
});
it("skips scopes that explicitly allow dangerous name matching", () => {
const hits = scanMutableAllowlistEntries({
channels: {
googlechat: {
dangerouslyAllowNameMatching: true,
groupAllowFrom: ["engineering@example.com"],
},
},
});
expect(hits).toEqual([]);
});
it("formats mutable allowlist warnings", () => {
const warnings = collectMutableAllowlistWarnings([
{
channel: "irc",
path: "channels.irc.allowFrom",
entry: "bob",
dangerousFlagPath: "channels.irc.dangerouslyAllowNameMatching",
},
{
channel: "googlechat",
path: "channels.googlechat.groupAllowFrom",
entry: "engineering@example.com",
dangerousFlagPath: "channels.googlechat.dangerouslyAllowNameMatching",
},
]);
expect(warnings).toEqual(
expect.arrayContaining([
expect.stringContaining("mutable allowlist entries across googlechat, irc"),
expect.stringContaining("channels.irc.allowFrom: bob"),
expect.stringContaining("channels.googlechat.groupAllowFrom: engineering@example.com"),
expect.stringContaining("Option A"),
expect.stringContaining("Option B"),
]),
);
});
});

View File

@@ -1,5 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { matrixSetupAdapter, matrixSetupWizard } from "../../test/helpers/plugins/matrix-setup.js";
import {
matrixSetupAdapter,
matrixSetupWizard,
} from "../../test/helpers/channels/matrix-setup-contract.js";
import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";

View File

@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("channel token and HTTP validation", () => {
describe("Slack token fields", () => {
it("accepts user token config fields", () => {
const res = validateConfigObject({
channels: {
slack: {
botToken: "xoxb-any",
appToken: "xapp-any",
userToken: "xoxp-any",
userTokenReadOnly: false,
},
},
});
expect(res.ok).toBe(true);
});
it("accepts account-level user token config", () => {
const res = validateConfigObject({
channels: {
slack: {
accounts: {
work: {
botToken: "xoxb-any",
appToken: "xapp-any",
userToken: "xoxp-any",
userTokenReadOnly: true,
},
},
},
},
});
expect(res.ok).toBe(true);
});
it("rejects invalid userTokenReadOnly types", () => {
const res = validateConfigObject({
channels: {
slack: {
botToken: "xoxb-any",
appToken: "xapp-any",
userToken: "xoxp-any",
// oxlint-disable-next-line typescript/no-explicit-any
userTokenReadOnly: "no" as any,
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((iss) => iss.path.includes("userTokenReadOnly"))).toBe(true);
}
});
it("rejects invalid userToken types", () => {
const res = validateConfigObject({
channels: {
slack: {
botToken: "xoxb-any",
appToken: "xapp-any",
// oxlint-disable-next-line typescript/no-explicit-any
userToken: 123 as any,
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((iss) => iss.path.includes("userToken"))).toBe(true);
}
});
});
describe("Slack HTTP mode", () => {
it("accepts HTTP mode when signing secret is configured", () => {
const res = validateConfigObject({
channels: {
slack: {
mode: "http",
signingSecret: "secret",
},
},
});
expect(res.ok).toBe(true);
});
it("accepts HTTP mode when signing secret is configured as SecretRef", () => {
const res = validateConfigObject({
channels: {
slack: {
mode: "http",
signingSecret: { source: "env", provider: "default", id: "SLACK_SIGNING_SECRET" },
},
},
});
expect(res.ok).toBe(true);
});
it("rejects HTTP mode without signing secret", () => {
const res = validateConfigObject({
channels: {
slack: {
mode: "http",
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.slack.signingSecret");
}
});
it("accepts account HTTP mode when base signing secret is set", () => {
const res = validateConfigObject({
channels: {
slack: {
signingSecret: "secret",
accounts: {
ops: {
mode: "http",
},
},
},
},
});
expect(res.ok).toBe(true);
});
it("accepts account HTTP mode when account signing secret is set as SecretRef", () => {
const res = validateConfigObject({
channels: {
slack: {
accounts: {
ops: {
mode: "http",
signingSecret: {
source: "env",
provider: "default",
id: "SLACK_OPS_SIGNING_SECRET",
},
},
},
},
},
});
expect(res.ok).toBe(true);
});
it("rejects account HTTP mode without signing secret", () => {
const res = validateConfigObject({
channels: {
slack: {
accounts: {
ops: {
mode: "http",
},
},
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.slack.accounts.ops.signingSecret");
}
});
});
});

View File

@@ -0,0 +1,175 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("channel webhook and actions validation", () => {
describe("Telegram poll actions", () => {
it("accepts channels.telegram.actions.poll", () => {
const res = validateConfigObject({
channels: {
telegram: {
actions: {
poll: false,
},
},
},
});
expect(res.ok).toBe(true);
});
it("accepts channels.telegram.accounts.<id>.actions.poll", () => {
const res = validateConfigObject({
channels: {
telegram: {
accounts: {
ops: {
actions: {
poll: false,
},
},
},
},
},
});
expect(res.ok).toBe(true);
});
});
describe("Telegram webhookPort", () => {
it("accepts a positive webhookPort", () => {
const res = validateConfigObject({
channels: {
telegram: {
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: "secret",
webhookPort: 8787,
},
},
});
expect(res.ok).toBe(true);
});
it("accepts webhookPort set to 0 for ephemeral port binding", () => {
const res = validateConfigObject({
channels: {
telegram: {
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: "secret",
webhookPort: 0,
},
},
});
expect(res.ok).toBe(true);
});
it("rejects negative webhookPort", () => {
const res = validateConfigObject({
channels: {
telegram: {
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: "secret",
webhookPort: -1,
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((issue) => issue.path === "channels.telegram.webhookPort")).toBe(
true,
);
}
});
});
describe("Telegram webhook secret", () => {
it.each([
{
name: "webhookUrl when webhookSecret is configured",
config: {
telegram: {
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: "secret",
},
},
},
{
name: "webhookUrl when webhookSecret is configured as SecretRef",
config: {
telegram: {
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: {
source: "env",
provider: "default",
id: "TELEGRAM_WEBHOOK_SECRET",
},
},
},
},
{
name: "account webhookUrl when base webhookSecret is configured",
config: {
telegram: {
webhookSecret: "secret",
accounts: {
ops: {
webhookUrl: "https://example.com/telegram-webhook",
},
},
},
},
},
{
name: "account webhookUrl when account webhookSecret is configured as SecretRef",
config: {
telegram: {
accounts: {
ops: {
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: {
source: "env",
provider: "default",
id: "TELEGRAM_OPS_WEBHOOK_SECRET",
},
},
},
},
},
},
] as const)("accepts $name", ({ config }) => {
expect(validateConfigObject({ channels: config }).ok).toBe(true);
});
it("rejects webhookUrl without webhookSecret", () => {
const res = validateConfigObject({
channels: {
telegram: {
webhookUrl: "https://example.com/telegram-webhook",
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.telegram.webhookSecret");
}
});
it("rejects account webhookUrl without webhookSecret", () => {
const res = validateConfigObject({
channels: {
telegram: {
accounts: {
ops: {
webhookUrl: "https://example.com/telegram-webhook",
},
},
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.telegram.accounts.ops.webhookSecret");
}
});
});
});

View File

@@ -1,14 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveDiscordPreviewStreamMode } from "./discord-preview-streaming.js";
describe("resolveDiscordPreviewStreamMode", () => {
it("defaults to off when unset", () => {
expect(resolveDiscordPreviewStreamMode({})).toBe("off");
});
it("preserves explicit off", () => {
expect(resolveDiscordPreviewStreamMode({ streaming: "off" })).toBe("off");
expect(resolveDiscordPreviewStreamMode({ streamMode: "off" })).toBe("off");
expect(resolveDiscordPreviewStreamMode({ streaming: false })).toBe("off");
});
});

View File

@@ -1,161 +0,0 @@
export type StreamingMode = "off" | "partial" | "block" | "progress";
export type DiscordPreviewStreamMode = "off" | "partial" | "block";
export type TelegramPreviewStreamMode = "off" | "partial" | "block";
export type SlackLegacyDraftStreamMode = "replace" | "status_final" | "append";
function normalizeStreamingMode(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
return normalized || null;
}
export function parseStreamingMode(value: unknown): StreamingMode | null {
const normalized = normalizeStreamingMode(value);
if (
normalized === "off" ||
normalized === "partial" ||
normalized === "block" ||
normalized === "progress"
) {
return normalized;
}
return null;
}
export function parseDiscordPreviewStreamMode(value: unknown): DiscordPreviewStreamMode | null {
const parsed = parseStreamingMode(value);
if (!parsed) {
return null;
}
return parsed === "progress" ? "partial" : parsed;
}
export function parseSlackLegacyDraftStreamMode(value: unknown): SlackLegacyDraftStreamMode | null {
const normalized = normalizeStreamingMode(value);
if (normalized === "replace" || normalized === "status_final" || normalized === "append") {
return normalized;
}
return null;
}
export function mapSlackLegacyDraftStreamModeToStreaming(
mode: SlackLegacyDraftStreamMode,
): StreamingMode {
if (mode === "append") {
return "block";
}
if (mode === "status_final") {
return "progress";
}
return "partial";
}
export function mapStreamingModeToSlackLegacyDraftStreamMode(mode: StreamingMode) {
if (mode === "block") {
return "append" as const;
}
if (mode === "progress") {
return "status_final" as const;
}
return "replace" as const;
}
export function resolveTelegramPreviewStreamMode(
params: {
streamMode?: unknown;
streaming?: unknown;
} = {},
): TelegramPreviewStreamMode {
const parsedStreaming = parseStreamingMode(params.streaming);
if (parsedStreaming) {
if (parsedStreaming === "progress") {
return "partial";
}
return parsedStreaming;
}
const legacy = parseDiscordPreviewStreamMode(params.streamMode);
if (legacy) {
return legacy;
}
if (typeof params.streaming === "boolean") {
return params.streaming ? "partial" : "off";
}
return "partial";
}
export function resolveDiscordPreviewStreamMode(
params: {
streamMode?: unknown;
streaming?: unknown;
} = {},
): DiscordPreviewStreamMode {
const parsedStreaming = parseDiscordPreviewStreamMode(params.streaming);
if (parsedStreaming) {
return parsedStreaming;
}
const legacy = parseDiscordPreviewStreamMode(params.streamMode);
if (legacy) {
return legacy;
}
if (typeof params.streaming === "boolean") {
return params.streaming ? "partial" : "off";
}
// Discord preview streaming edits can hit aggressive rate limits, especially
// when multiple gateways or multiple bots share the same account/server. Keep
// the default off unless the operator opts in explicitly.
return "off";
}
export function resolveSlackStreamingMode(
params: {
streamMode?: unknown;
streaming?: unknown;
} = {},
): StreamingMode {
const parsedStreaming = parseStreamingMode(params.streaming);
if (parsedStreaming) {
return parsedStreaming;
}
const legacyStreamMode = parseSlackLegacyDraftStreamMode(params.streamMode);
if (legacyStreamMode) {
return mapSlackLegacyDraftStreamModeToStreaming(legacyStreamMode);
}
// Legacy boolean `streaming` values map to the unified enum.
if (typeof params.streaming === "boolean") {
return params.streaming ? "partial" : "off";
}
return "partial";
}
export function resolveSlackNativeStreaming(
params: {
nativeStreaming?: unknown;
streaming?: unknown;
} = {},
): boolean {
if (typeof params.nativeStreaming === "boolean") {
return params.nativeStreaming;
}
if (typeof params.streaming === "boolean") {
return params.streaming;
}
return true;
}
export function formatSlackStreamModeMigrationMessage(
pathPrefix: string,
resolvedStreaming: string,
): string {
return `Moved ${pathPrefix}.streamMode → ${pathPrefix}.streaming (${resolvedStreaming}).`;
}
export function formatSlackStreamingBooleanMigrationMessage(
pathPrefix: string,
resolvedNativeStreaming: boolean,
): string {
return `Moved ${pathPrefix}.streaming (boolean) → ${pathPrefix}.nativeStreaming (${resolvedNativeStreaming}).`;
}

View File

@@ -1,96 +0,0 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("Slack HTTP mode config", () => {
it("accepts HTTP mode when signing secret is configured", () => {
const res = validateConfigObject({
channels: {
slack: {
mode: "http",
signingSecret: "secret",
},
},
});
expect(res.ok).toBe(true);
});
it("accepts HTTP mode when signing secret is configured as SecretRef", () => {
const res = validateConfigObject({
channels: {
slack: {
mode: "http",
signingSecret: { source: "env", provider: "default", id: "SLACK_SIGNING_SECRET" },
},
},
});
expect(res.ok).toBe(true);
});
it("rejects HTTP mode without signing secret", () => {
const res = validateConfigObject({
channels: {
slack: {
mode: "http",
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.slack.signingSecret");
}
});
it("accepts account HTTP mode when base signing secret is set", () => {
const res = validateConfigObject({
channels: {
slack: {
signingSecret: "secret",
accounts: {
ops: {
mode: "http",
},
},
},
},
});
expect(res.ok).toBe(true);
});
it("accepts account HTTP mode when account signing secret is set as SecretRef", () => {
const res = validateConfigObject({
channels: {
slack: {
accounts: {
ops: {
mode: "http",
signingSecret: {
source: "env",
provider: "default",
id: "SLACK_OPS_SIGNING_SECRET",
},
},
},
},
},
});
expect(res.ok).toBe(true);
});
it("rejects account HTTP mode without signing secret", () => {
const res = validateConfigObject({
channels: {
slack: {
accounts: {
ops: {
mode: "http",
},
},
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.slack.accounts.ops.signingSecret");
}
});
});

View File

@@ -1,71 +0,0 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("Slack token config fields", () => {
it("accepts user token config fields", () => {
const res = validateConfigObject({
channels: {
slack: {
botToken: "xoxb-any",
appToken: "xapp-any",
userToken: "xoxp-any",
userTokenReadOnly: false,
},
},
});
expect(res.ok).toBe(true);
});
it("accepts account-level user token config", () => {
const res = validateConfigObject({
channels: {
slack: {
accounts: {
work: {
botToken: "xoxb-any",
appToken: "xapp-any",
userToken: "xoxp-any",
userTokenReadOnly: true,
},
},
},
},
});
expect(res.ok).toBe(true);
});
it("rejects invalid userTokenReadOnly types", () => {
const res = validateConfigObject({
channels: {
slack: {
botToken: "xoxb-any",
appToken: "xapp-any",
userToken: "xoxp-any",
// oxlint-disable-next-line typescript/no-explicit-any
userTokenReadOnly: "no" as any,
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((iss) => iss.path.includes("userTokenReadOnly"))).toBe(true);
}
});
it("rejects invalid userToken types", () => {
const res = validateConfigObject({
channels: {
slack: {
botToken: "xoxb-any",
appToken: "xapp-any",
// oxlint-disable-next-line typescript/no-explicit-any
userToken: 123 as any,
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((iss) => iss.path.includes("userToken"))).toBe(true);
}
});
});

View File

@@ -1,36 +0,0 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("telegram poll action config", () => {
it("accepts channels.telegram.actions.poll", () => {
const res = validateConfigObject({
channels: {
telegram: {
actions: {
poll: false,
},
},
},
});
expect(res.ok).toBe(true);
});
it("accepts channels.telegram.accounts.<id>.actions.poll", () => {
const res = validateConfigObject({
channels: {
telegram: {
accounts: {
ops: {
actions: {
poll: false,
},
},
},
},
},
});
expect(res.ok).toBe(true);
});
});

View File

@@ -1,46 +0,0 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("Telegram webhookPort config", () => {
it("accepts a positive webhookPort", () => {
const res = validateConfigObject({
channels: {
telegram: {
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: "secret", // pragma: allowlist secret
webhookPort: 8787,
},
},
});
expect(res.ok).toBe(true);
});
it("accepts webhookPort set to 0 for ephemeral port binding", () => {
const res = validateConfigObject({
channels: {
telegram: {
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: "secret", // pragma: allowlist secret
webhookPort: 0,
},
},
});
expect(res.ok).toBe(true);
});
it("rejects negative webhookPort", () => {
const res = validateConfigObject({
channels: {
telegram: {
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: "secret", // pragma: allowlist secret
webhookPort: -1,
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((issue) => issue.path === "channels.telegram.webhookPort")).toBe(true);
}
});
});

View File

@@ -1,93 +0,0 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("Telegram webhook config", () => {
it.each([
{
name: "webhookUrl when webhookSecret is configured",
config: {
telegram: {
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: "secret",
},
},
},
{
name: "webhookUrl when webhookSecret is configured as SecretRef",
config: {
telegram: {
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: {
source: "env",
provider: "default",
id: "TELEGRAM_WEBHOOK_SECRET",
},
},
},
},
{
name: "account webhookUrl when base webhookSecret is configured",
config: {
telegram: {
webhookSecret: "secret",
accounts: {
ops: {
webhookUrl: "https://example.com/telegram-webhook",
},
},
},
},
},
{
name: "account webhookUrl when account webhookSecret is configured as SecretRef",
config: {
telegram: {
accounts: {
ops: {
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: {
source: "env",
provider: "default",
id: "TELEGRAM_OPS_WEBHOOK_SECRET",
},
},
},
},
},
},
] as const)("accepts $name", ({ config }) => {
expect(validateConfigObject({ channels: config }).ok).toBe(true);
});
it("rejects webhookUrl without webhookSecret", () => {
const res = validateConfigObject({
channels: {
telegram: {
webhookUrl: "https://example.com/telegram-webhook",
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.telegram.webhookSecret");
}
});
it("rejects account webhookUrl without webhookSecret", () => {
const res = validateConfigObject({
channels: {
telegram: {
accounts: {
ops: {
webhookUrl: "https://example.com/telegram-webhook",
},
},
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.telegram.accounts.ops.webhookSecret");
}
});
});

View File

@@ -1,9 +1,9 @@
import type { IncomingMessage } from "node:http";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createIMessageTestPlugin } from "../../test/helpers/channels/imessage-test-plugin.js";
import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js";
import {
extractHookToken,
isHookAgentAllowed,

View File

@@ -2,6 +2,10 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
isWhatsAppGroupJid,
normalizeWhatsAppTarget,
} from "../../test/helpers/channels/command-contract.js";
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
import type { OpenClawConfig } from "../config/config.js";
import {
@@ -10,7 +14,6 @@ import {
resolveMainSessionKey,
resolveStorePath,
} from "../config/sessions.js";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../plugin-sdk/whatsapp-targets.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import { buildAgentPeerSessionKey } from "../routing/session-key.js";
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";

View File

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

View File

@@ -1,440 +0,0 @@
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 type { OpenClawConfig } from "../config/config.js";
import { resolveMatrixAccountStorageRoot } from "./matrix-config-helpers.js";
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js";
import { MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE } from "./matrix-plugin-helper.js";
import {
MATRIX_DEFAULT_ACCESS_TOKEN,
MATRIX_DEFAULT_DEVICE_ID,
MATRIX_DEFAULT_USER_ID,
MATRIX_OPS_ACCESS_TOKEN,
MATRIX_OPS_ACCOUNT_ID,
MATRIX_OPS_DEVICE_ID,
MATRIX_OPS_USER_ID,
MATRIX_TEST_HOMESERVER,
matrixHelperEnv,
writeFile,
writeMatrixCredentials,
writeMatrixPluginFixture,
} from "./matrix.test-helpers.js";
vi.unmock("../version.js");
function createDefaultMatrixConfig(): OpenClawConfig {
return {
channels: {
matrix: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_DEFAULT_USER_ID,
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
},
},
};
}
function writeDefaultLegacyCryptoFixture(home: string) {
const stateDir = path.join(home, ".openclaw");
const cfg = createDefaultMatrixConfig();
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_DEFAULT_USER_ID,
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
});
writeFile(
path.join(rootDir, "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: MATRIX_DEFAULT_DEVICE_ID }),
);
return { cfg, rootDir, stateDir };
}
function createOpsLegacyCryptoFixture(params: {
home: string;
cfg: OpenClawConfig;
accessToken?: string;
includeStoredCredentials?: boolean;
}) {
const stateDir = path.join(params.home, ".openclaw");
writeMatrixPluginFixture(path.join(params.home, "bundled", "matrix"));
writeFile(
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: MATRIX_OPS_DEVICE_ID }),
);
if (params.includeStoredCredentials) {
writeMatrixCredentials(stateDir, {
accountId: MATRIX_OPS_ACCOUNT_ID,
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
deviceId: MATRIX_OPS_DEVICE_ID,
});
}
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_OPS_USER_ID,
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
accountId: MATRIX_OPS_ACCOUNT_ID,
});
return { rootDir, stateDir };
}
async function expectPreparedOpsLegacyMigration(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
rootDir: string;
inspectLegacyStore: {
deviceId: string;
roomKeyCounts: { total: number; backedUp: number };
backupVersion: string;
decryptionKeyBase64: string;
};
expectAccountId?: boolean;
}) {
const detection = detectLegacyMatrixCrypto({ cfg: params.cfg, env: params.env });
expect(detection.warnings).toEqual([]);
expect(detection.plans).toHaveLength(1);
expect(detection.plans[0]?.accountId).toBe("ops");
const result = await autoPrepareLegacyMatrixCrypto({
cfg: params.cfg,
env: params.env,
deps: {
inspectLegacyStore: async () => params.inspectLegacyStore,
},
});
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
const recovery = JSON.parse(
fs.readFileSync(path.join(params.rootDir, "recovery-key.json"), "utf8"),
) as {
privateKeyBase64: string;
};
expect(recovery.privateKeyBase64).toBe(params.inspectLegacyStore.decryptionKeyBase64);
if (!params.expectAccountId) {
return;
}
const state = JSON.parse(
fs.readFileSync(path.join(params.rootDir, "legacy-crypto-migration.json"), "utf8"),
) as {
accountId: string;
};
expect(state.accountId).toBe("ops");
}
describe("matrix legacy encrypted-state migration", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("extracts a saved backup key into the new recovery-key path", async () => {
await withTempHome(
async (home) => {
writeMatrixPluginFixture(path.join(home, "bundled", "matrix"));
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.warnings).toEqual([]);
expect(detection.plans).toHaveLength(1);
const inspectLegacyStore = vi.fn(async () => ({
deviceId: MATRIX_DEFAULT_DEVICE_ID,
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);
},
{ env: matrixHelperEnv },
);
});
it("skips inspector loading when no legacy Matrix plans exist", async () => {
await withTempHome(
async () => {
const matrixHelperModule = await import("./matrix-plugin-helper.js");
const loadInspectorSpy = vi.spyOn(matrixHelperModule, "loadMatrixLegacyCryptoInspector");
const result = await autoPrepareLegacyMatrixCrypto({
cfg: createDefaultMatrixConfig(),
env: process.env,
});
expect(result).toEqual({
migrated: false,
changes: [],
warnings: [],
});
expect(result.warnings).not.toContain(MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE);
expect(loadInspectorSpy).not.toHaveBeenCalled();
},
{
env: {
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"),
},
},
);
});
it("warns when legacy local-only room keys cannot be recovered automatically", async () => {
await withTempHome(async (home) => {
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: MATRIX_DEFAULT_DEVICE_ID,
roomKeyCounts: { total: 15, backedUp: 10 },
backupVersion: null,
decryptionKeyBase64: null,
}),
},
});
expect(result.migrated).toBe(true);
expect(result.warnings).toContain(
'Legacy Matrix encrypted state for account "default" contains 5 room key(s) that were never backed up. Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.',
);
expect(result.warnings).toContain(
'Legacy Matrix encrypted state for account "default" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.',
);
const state = JSON.parse(
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
) as {
restoreStatus: string;
};
expect(state.restoreStatus).toBe("manual-action-required");
});
});
it("warns instead of throwing when recovery-key persistence fails", async () => {
await withTempHome(async (home) => {
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: MATRIX_DEFAULT_DEVICE_ID,
roomKeyCounts: { total: 12, backedUp: 12 },
backupVersion: "1",
decryptionKeyBase64: "YWJjZA==",
}),
writeJsonFileAtomically: async (filePath) => {
if (filePath.endsWith("recovery-key.json")) {
throw new Error("disk full");
}
writeFile(filePath, JSON.stringify({ ok: true }, null, 2));
},
},
});
expect(result.migrated).toBe(false);
expect(result.warnings).toContain(
`Failed writing Matrix recovery key for account "default" (${path.join(rootDir, "recovery-key.json")}): Error: disk full`,
);
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(false);
expect(fs.existsSync(path.join(rootDir, "legacy-crypto-migration.json"))).toBe(false);
});
});
it("prepares flat legacy crypto for the only configured non-default Matrix account", async () => {
await withTempHome(
async (home) => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_OPS_USER_ID,
},
},
},
},
};
const { rootDir } = createOpsLegacyCryptoFixture({
home,
cfg,
includeStoredCredentials: true,
});
await expectPreparedOpsLegacyMigration({
cfg,
env: process.env,
rootDir,
inspectLegacyStore: {
deviceId: MATRIX_OPS_DEVICE_ID,
roomKeyCounts: { total: 6, backedUp: 6 },
backupVersion: "21868",
decryptionKeyBase64: "YWJjZA==",
},
expectAccountId: true,
});
},
{ env: matrixHelperEnv },
);
});
it("uses scoped Matrix env vars when resolving flat legacy crypto migration", async () => {
await withTempHome(
async (home) => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {},
},
},
},
};
const { rootDir } = createOpsLegacyCryptoFixture({
home,
cfg,
accessToken: "tok-ops-env",
});
await expectPreparedOpsLegacyMigration({
cfg,
env: process.env,
rootDir,
inspectLegacyStore: {
deviceId: MATRIX_OPS_DEVICE_ID,
roomKeyCounts: { total: 4, backedUp: 4 },
backupVersion: "9001",
decryptionKeyBase64: "YWJjZA==",
},
});
},
{
env: {
...matrixHelperEnv,
MATRIX_OPS_HOMESERVER: MATRIX_TEST_HOMESERVER,
MATRIX_OPS_USER_ID,
MATRIX_OPS_ACCESS_TOKEN: "tok-ops-env",
},
},
);
});
it("requires channels.matrix.defaultAccount before preparing flat legacy crypto for one of multiple accounts", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: MATRIX_OPS_DEVICE_ID }),
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_OPS_USER_ID,
accessToken: MATRIX_OPS_ACCESS_TOKEN,
},
alerts: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: "@alerts-bot:example.org",
accessToken: "tok-alerts",
},
},
},
},
};
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.plans).toHaveLength(0);
expect(detection.warnings).toContain(
"Legacy Matrix encrypted state detected at " +
path.join(stateDir, "matrix", "crypto") +
', but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. Set "channels.matrix.defaultAccount" to the intended target account before rerunning "openclaw doctor --fix" or restarting the gateway.',
);
});
});
it("warns instead of throwing when a legacy crypto path is a file", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "crypto"), "not-a-directory");
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
};
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.plans).toHaveLength(0);
expect(detection.warnings).toContain(
`Legacy Matrix encrypted state path exists but is not a directory: ${path.join(stateDir, "matrix", "crypto")}. OpenClaw skipped automatic crypto migration for that path.`,
);
});
});
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"),
JSON.stringify({ deviceId: MATRIX_DEFAULT_DEVICE_ID }),
);
const cfg = createDefaultMatrixConfig();
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"),
},
},
);
});
});

View File

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

View File

@@ -1,244 +0,0 @@
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", // pragma: allowlist 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);
});
});
it("records which account receives a flat legacy store when multiple Matrix accounts exist", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
const cfg: OpenClawConfig = {
channels: {
matrix: {
defaultAccount: "work",
accounts: {
work: {
homeserver: "https://matrix.example.org",
userId: "@work-bot:example.org",
accessToken: "tok-work",
},
alerts: {
homeserver: "https://matrix.example.org",
userId: "@alerts-bot:example.org",
accessToken: "tok-alerts",
},
},
},
},
};
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");
}
expect(detection.accountId).toBe("work");
expect(detection.selectionNote).toContain('account "work"');
});
});
it("requires channels.matrix.defaultAccount before migrating a flat store into one of multiple accounts", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
work: {
homeserver: "https://matrix.example.org",
userId: "@work-bot:example.org",
accessToken: "tok-work",
},
alerts: {
homeserver: "https://matrix.example.org",
userId: "@alerts-bot:example.org",
accessToken: "tok-alerts",
},
},
},
},
};
const detection = detectLegacyMatrixState({ cfg, env: process.env });
expect(detection && "warning" in detection).toBe(true);
if (!detection || !("warning" in detection)) {
throw new Error("expected a warning-only Matrix legacy state result");
}
expect(detection.warning).toContain("channels.matrix.defaultAccount is not set");
});
});
it("uses scoped Matrix env vars when resolving a flat-store migration target", 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: {
accounts: {
ops: {},
},
},
},
};
const detection = detectLegacyMatrixState({ cfg, env: process.env });
expect(detection && "warning" in detection).toBe(false);
if (!detection || "warning" in detection) {
throw new Error("expected scoped Matrix env vars to resolve a legacy state plan");
}
expect(detection.accountId).toBe("ops");
expect(detection.targetRootDir).toContain("matrix.example.org__ops-bot_example.org");
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
},
{
env: {
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_USER_ID: "@ops-bot:example.org",
MATRIX_OPS_ACCESS_TOKEN: "tok-ops-env",
},
},
);
});
it("migrates flat legacy Matrix state into the only configured non-default account", 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");
writeFile(
path.join(stateDir, "credentials", "matrix", "credentials-ops.json"),
JSON.stringify(
{
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
accessToken: "tok-ops",
},
null,
2,
),
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
},
},
},
},
};
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");
}
expect(detection.accountId).toBe("ops");
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
});
});
});

View File

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

View File

@@ -1,327 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import {
findMatrixAccountEntry,
getMatrixScopedEnvVarNames,
requiresExplicitMatrixDefaultAccount,
resolveConfiguredMatrixAccountIds,
resolveMatrixAccountStorageRoot,
resolveMatrixChannelConfig,
resolveMatrixCredentialsPath,
resolveMatrixDefaultOrOnlyAccountId,
} from "./matrix-config-helpers.js";
export type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
deviceId?: string;
};
export type MatrixMigrationAccountTarget = {
accountId: string;
homeserver: string;
userId: string;
accessToken: string;
rootDir: string;
storedDeviceId: string | null;
};
export type MatrixLegacyFlatStoreTarget = MatrixMigrationAccountTarget & {
selectionNote?: string;
};
type MatrixLegacyFlatStoreKind = "state" | "encrypted state";
type MatrixResolvedStringField =
| "homeserver"
| "userId"
| "accessToken"
| "password"
| "deviceId"
| "deviceName";
type MatrixResolvedStringValues = Record<MatrixResolvedStringField, string>;
type MatrixStringSourceMap = Partial<Record<MatrixResolvedStringField, string>>;
const MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS = new Set<MatrixResolvedStringField>([
"userId",
"accessToken",
"password",
"deviceId",
]);
function clean(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function resolveMatrixStringSourceValue(value: string | undefined): string {
return typeof value === "string" ? value : "";
}
function shouldAllowBaseAuthFallback(accountId: string, field: MatrixResolvedStringField): boolean {
return (
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID ||
!MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS.has(field)
);
}
function resolveMatrixAccountStringValues(params: {
accountId: string;
account?: MatrixStringSourceMap;
scopedEnv?: MatrixStringSourceMap;
channel?: MatrixStringSourceMap;
globalEnv?: MatrixStringSourceMap;
}): MatrixResolvedStringValues {
const fields: MatrixResolvedStringField[] = [
"homeserver",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
];
const resolved = {} as MatrixResolvedStringValues;
for (const field of fields) {
resolved[field] =
resolveMatrixStringSourceValue(params.account?.[field]) ||
resolveMatrixStringSourceValue(params.scopedEnv?.[field]) ||
(shouldAllowBaseAuthFallback(params.accountId, field)
? resolveMatrixStringSourceValue(params.channel?.[field]) ||
resolveMatrixStringSourceValue(params.globalEnv?.[field])
: "");
}
return resolved;
}
function resolveScopedMatrixEnvConfig(
accountId: string,
env: NodeJS.ProcessEnv,
): {
homeserver: string;
userId: string;
accessToken: string;
} {
const keys = getMatrixScopedEnvVarNames(accountId);
return {
homeserver: clean(env[keys.homeserver]),
userId: clean(env[keys.userId]),
accessToken: clean(env[keys.accessToken]),
};
}
function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): {
homeserver: string;
userId: string;
accessToken: string;
} {
return {
homeserver: clean(env.MATRIX_HOMESERVER),
userId: clean(env.MATRIX_USER_ID),
accessToken: clean(env.MATRIX_ACCESS_TOKEN),
};
}
function resolveMatrixAccountConfigEntry(
cfg: OpenClawConfig,
accountId: string,
): Record<string, unknown> | null {
return findMatrixAccountEntry(cfg, accountId);
}
function resolveMatrixFlatStoreSelectionNote(
cfg: OpenClawConfig,
accountId: string,
): string | undefined {
if (resolveConfiguredMatrixAccountIds(cfg).length <= 1) {
return undefined;
}
return (
`Legacy Matrix flat store uses one shared on-disk state, so it will be migrated into ` +
`account "${accountId}".`
);
}
export function resolveMatrixMigrationConfigFields(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
accountId: string;
}): {
homeserver: string;
userId: string;
accessToken: string;
} {
const channel = resolveMatrixChannelConfig(params.cfg);
const account = resolveMatrixAccountConfigEntry(params.cfg, params.accountId);
const scopedEnv = resolveScopedMatrixEnvConfig(params.accountId, params.env);
const globalEnv = resolveGlobalMatrixEnvConfig(params.env);
const normalizedAccountId = normalizeAccountId(params.accountId);
const resolvedStrings = resolveMatrixAccountStringValues({
accountId: normalizedAccountId,
account: {
homeserver: clean(account?.homeserver),
userId: clean(account?.userId),
accessToken: clean(account?.accessToken),
},
scopedEnv,
channel: {
homeserver: clean(channel?.homeserver),
userId: clean(channel?.userId),
accessToken: clean(channel?.accessToken),
},
globalEnv,
});
return {
homeserver: resolvedStrings.homeserver,
userId: resolvedStrings.userId,
accessToken: resolvedStrings.accessToken,
};
}
export 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;
}
}
export function credentialsMatchResolvedIdentity(
stored: MatrixStoredCredentials | null,
identity: {
homeserver: string;
userId: string;
accessToken: string;
},
): stored is MatrixStoredCredentials {
if (!stored || !identity.homeserver) {
return false;
}
if (!identity.userId) {
if (!identity.accessToken) {
return false;
}
return stored.homeserver === identity.homeserver && stored.accessToken === identity.accessToken;
}
return stored.homeserver === identity.homeserver && stored.userId === identity.userId;
}
export function resolveMatrixMigrationAccountTarget(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
accountId: string;
}): MatrixMigrationAccountTarget | null {
const stored = loadStoredMatrixCredentials(params.env, params.accountId);
const resolved = resolveMatrixMigrationConfigFields(params);
const matchingStored = credentialsMatchResolvedIdentity(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId,
accessToken: resolved.accessToken,
})
? stored
: null;
const homeserver = resolved.homeserver;
const userId = resolved.userId || matchingStored?.userId || "";
const accessToken = resolved.accessToken || matchingStored?.accessToken || "";
if (!homeserver || !userId || !accessToken) {
return null;
}
const stateDir = resolveStateDir(params.env, os.homedir);
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver,
userId,
accessToken,
accountId: params.accountId,
});
return {
accountId: params.accountId,
homeserver,
userId,
accessToken,
rootDir,
storedDeviceId: matchingStored?.deviceId ?? null,
};
}
export function resolveLegacyMatrixFlatStoreTarget(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
detectedPath: string;
detectedKind: MatrixLegacyFlatStoreKind;
}): MatrixLegacyFlatStoreTarget | { warning: string } {
const channel = resolveMatrixChannelConfig(params.cfg);
if (!channel) {
return {
warning:
`Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but channels.matrix is not configured yet. ` +
'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.',
};
}
if (requiresExplicitMatrixDefaultAccount(params.cfg)) {
return {
warning:
`Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. ` +
'Set "channels.matrix.defaultAccount" to the intended target account before rerunning "openclaw doctor --fix" or restarting the gateway.',
};
}
const accountId = resolveMatrixDefaultOrOnlyAccountId(params.cfg);
const target = resolveMatrixMigrationAccountTarget({
cfg: params.cfg,
env: params.env,
accountId,
});
if (!target) {
const targetDescription =
params.detectedKind === "state"
? "the new account-scoped target"
: "the account-scoped target";
return {
warning:
`Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but ${targetDescription} 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.',
};
}
return {
...target,
selectionNote: resolveMatrixFlatStoreSelectionNote(params.cfg, accountId),
};
}

View File

@@ -1,226 +0,0 @@
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 { resolveMatrixAccountStorageRoot } from "./matrix-config-helpers.js";
import { detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js";
import {
hasActionableMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
resolveMatrixMigrationSnapshotMarkerPath,
resolveMatrixMigrationSnapshotOutputDir,
} from "./matrix-migration-snapshot.js";
describe("matrix migration snapshots", () => {
it("creates a backup marker after writing a pre-migration snapshot", async () => {
await withTempHome(async (home) => {
fs.writeFileSync(path.join(home, ".openclaw", "openclaw.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(home, ".openclaw", "state.txt"), "state\n", "utf8");
const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" });
expect(result.created).toBe(true);
expect(result.markerPath).toBe(resolveMatrixMigrationSnapshotMarkerPath(process.env));
expect(
result.archivePath.startsWith(resolveMatrixMigrationSnapshotOutputDir(process.env)),
).toBe(true);
expect(fs.existsSync(result.archivePath)).toBe(true);
const marker = JSON.parse(
fs.readFileSync(resolveMatrixMigrationSnapshotMarkerPath(process.env), "utf8"),
) as {
archivePath: string;
trigger: string;
};
expect(marker.archivePath).toBe(result.archivePath);
expect(marker.trigger).toBe("unit-test");
});
});
it("reuses an existing snapshot marker when the archive still exists", async () => {
await withTempHome(async (home) => {
const archivePath = path.join(home, "Backups", "openclaw-migrations", "snapshot.tar.gz");
const markerPath = resolveMatrixMigrationSnapshotMarkerPath(process.env);
fs.mkdirSync(path.dirname(archivePath), { recursive: true });
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
fs.writeFileSync(archivePath, "archive", "utf8");
fs.writeFileSync(
markerPath,
JSON.stringify({
version: 1,
createdAt: "2026-03-10T18:00:00.000Z",
archivePath,
trigger: "older-run",
includeWorkspace: false,
}),
"utf8",
);
const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" });
expect(result.created).toBe(false);
expect(result.archivePath).toBe(archivePath);
});
});
it("recreates the snapshot when the marker exists but the archive is missing", async () => {
await withTempHome(async (home) => {
const markerPath = resolveMatrixMigrationSnapshotMarkerPath(process.env);
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
fs.mkdirSync(path.join(home, "Backups", "openclaw-migrations"), { recursive: true });
fs.writeFileSync(
markerPath,
JSON.stringify({
version: 1,
createdAt: "2026-03-10T18:00:00.000Z",
archivePath: path.join(home, "Backups", "openclaw-migrations", "missing.tar.gz"),
trigger: "older-run",
includeWorkspace: false,
}),
"utf8",
);
const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" });
expect(result.created).toBe(true);
expect(result.archivePath).not.toBe(
path.join(home, "Backups", "openclaw-migrations", "missing.tar.gz"),
);
expect(
result.archivePath.startsWith(resolveMatrixMigrationSnapshotOutputDir(process.env)),
).toBe(true);
expect(fs.existsSync(result.archivePath)).toBe(true);
const marker = JSON.parse(fs.readFileSync(markerPath, "utf8")) as { archivePath: string };
expect(marker.archivePath).toBe(result.archivePath);
});
});
it("surfaces backup creation failures without writing a marker", async () => {
await withTempHome(async (home) => {
const invalidOutputPath = path.join(home, "invalid-output");
fs.writeFileSync(invalidOutputPath, "occupied\n", "utf8");
await expect(
maybeCreateMatrixMigrationSnapshot({
trigger: "unit-test",
outputDir: invalidOutputPath,
}),
).rejects.toThrow();
expect(fs.existsSync(resolveMatrixMigrationSnapshotMarkerPath(process.env))).toBe(false);
});
});
it("does not treat warning-only Matrix migration as actionable", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
fs.mkdirSync(path.join(stateDir, "matrix", "crypto"), { recursive: true });
fs.writeFileSync(
path.join(stateDir, "matrix", "bot-storage.json"),
'{"legacy":true}',
"utf8",
);
fs.writeFileSync(
path.join(stateDir, "openclaw.json"),
JSON.stringify({
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
}),
"utf8",
);
expect(
hasActionableMatrixMigration({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as never,
env: process.env,
}),
).toBe(false);
});
});
it("treats resolvable Matrix legacy state as actionable", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
fs.mkdirSync(path.join(stateDir, "matrix"), { recursive: true });
fs.writeFileSync(
path.join(stateDir, "matrix", "bot-storage.json"),
'{"legacy":true}',
"utf8",
);
expect(
hasActionableMatrixMigration({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
} as never,
env: process.env,
}),
).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"),
},
},
);
});
});

View File

@@ -1,151 +0,0 @@
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 { 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";
type MatrixMigrationSnapshotMarker = {
version: 1;
createdAt: string;
archivePath: string;
trigger: string;
includeWorkspace: boolean;
};
export type MatrixMigrationSnapshotResult = {
created: boolean;
archivePath: string;
markerPath: string;
};
function loadSnapshotMarker(filePath: string): MatrixMigrationSnapshotMarker | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
const parsed = JSON.parse(
fs.readFileSync(filePath, "utf8"),
) as Partial<MatrixMigrationSnapshotMarker>;
if (
parsed.version !== 1 ||
typeof parsed.createdAt !== "string" ||
typeof parsed.archivePath !== "string" ||
typeof parsed.trigger !== "string"
) {
return null;
}
return {
version: 1,
createdAt: parsed.createdAt,
archivePath: parsed.archivePath,
trigger: parsed.trigger,
includeWorkspace: parsed.includeWorkspace === true,
};
} catch {
return null;
}
}
export function resolveMatrixMigrationSnapshotMarkerPath(
env: NodeJS.ProcessEnv = process.env,
): string {
const stateDir = resolveStateDir(env, os.homedir);
return path.join(stateDir, "matrix", "migration-snapshot.json");
}
export function resolveMatrixMigrationSnapshotOutputDir(
env: NodeJS.ProcessEnv = process.env,
): string {
const homeDir = resolveRequiredHomeDir(env, os.homedir);
return path.join(homeDir, "Backups", MATRIX_MIGRATION_SNAPSHOT_DIRNAME);
}
export function hasPendingMatrixMigration(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
const env = params.env ?? process.env;
const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env });
if (legacyState) {
return true;
}
const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env });
return legacyCrypto.plans.length > 0 || legacyCrypto.warnings.length > 0;
}
export function hasActionableMatrixMigration(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
const env = params.env ?? process.env;
const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env });
if (legacyState && !("warning" in legacyState)) {
return true;
}
const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env });
return (
legacyCrypto.plans.length > 0 &&
isMatrixLegacyCryptoInspectorAvailable({
cfg: params.cfg,
env,
})
);
}
export async function maybeCreateMatrixMigrationSnapshot(params: {
trigger: string;
env?: NodeJS.ProcessEnv;
outputDir?: string;
log?: { info?: (message: string) => void; warn?: (message: string) => void };
}): Promise<MatrixMigrationSnapshotResult> {
const env = params.env ?? process.env;
const markerPath = resolveMatrixMigrationSnapshotMarkerPath(env);
const existingMarker = loadSnapshotMarker(markerPath);
if (existingMarker?.archivePath && fs.existsSync(existingMarker.archivePath)) {
params.log?.info?.(
`matrix: reusing existing pre-migration backup snapshot: ${existingMarker.archivePath}`,
);
return {
created: false,
archivePath: existingMarker.archivePath,
markerPath,
};
}
if (existingMarker?.archivePath && !fs.existsSync(existingMarker.archivePath)) {
params.log?.warn?.(
`matrix: previous migration snapshot is missing (${existingMarker.archivePath}); creating a replacement backup before continuing`,
);
}
const snapshot = await createBackupArchive({
output: (() => {
const outputDir = params.outputDir ?? resolveMatrixMigrationSnapshotOutputDir(env);
fs.mkdirSync(outputDir, { recursive: true });
return outputDir;
})(),
includeWorkspace: false,
});
const marker: MatrixMigrationSnapshotMarker = {
version: 1,
createdAt: snapshot.createdAt,
archivePath: snapshot.archivePath,
trigger: params.trigger,
includeWorkspace: snapshot.includeWorkspace,
};
await writeJsonFileAtomically(markerPath, marker);
params.log?.info?.(`matrix: created pre-migration backup snapshot: ${snapshot.archivePath}`);
return {
created: true,
archivePath: snapshot.archivePath,
markerPath,
};
}

View File

@@ -1,221 +0,0 @@
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 type { OpenClawConfig } from "../config/config.js";
import {
isMatrixLegacyCryptoInspectorAvailable,
loadMatrixLegacyCryptoInspector,
} from "./matrix-plugin-helper.js";
import {
MATRIX_DEFAULT_DEVICE_ID,
MATRIX_DEFAULT_USER_ID,
matrixHelperEnv,
writeMatrixPluginFixture,
writeMatrixPluginManifest,
} from "./matrix.test-helpers.js";
vi.unmock("../version.js");
async function expectLoadedInspector(params: {
cfg: OpenClawConfig | Record<string, never>;
env: NodeJS.ProcessEnv;
expected: {
deviceId: string;
roomKeyCounts: { total: number; backedUp: number } | null;
backupVersion: string | null;
decryptionKeyBase64: string | null;
};
}) {
expect(isMatrixLegacyCryptoInspectorAvailable({ cfg: params.cfg, env: params.env })).toBe(true);
const inspectLegacyStore = await loadMatrixLegacyCryptoInspector({
cfg: params.cfg,
env: params.env,
});
await expect(
inspectLegacyStore({
cryptoRootDir: "/tmp/legacy",
userId: MATRIX_DEFAULT_USER_ID,
deviceId: MATRIX_DEFAULT_DEVICE_ID,
}),
).resolves.toEqual(params.expected);
}
describe("matrix plugin helper resolution", () => {
afterEach(() => {
vi.restoreAllMocks();
});
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;
await expectLoadedInspector({
cfg,
env: process.env,
expected: {
deviceId: "BUNDLED",
roomKeyCounts: { total: 7, backedUp: 6 },
backupVersion: "1",
decryptionKeyBase64: "YWJjZA==",
},
});
},
{ env: matrixHelperEnv },
);
});
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: OpenClawConfig = {
plugins: {
load: {
paths: [customRoot],
},
},
};
await expectLoadedInspector({
cfg,
env: process.env,
expected: {
deviceId: "CONFIG",
roomKeyCounts: null,
backupVersion: null,
decryptionKeyBase64: null,
},
});
},
{ env: matrixHelperEnv },
);
});
it("keeps source-style root helper shims on the Jiti fallback path", async () => {
await withTempHome(
async (home) => {
const customRoot = path.join(home, "plugins", "matrix-local");
writeMatrixPluginManifest(customRoot);
fs.mkdirSync(path.join(customRoot, "src", "matrix"), { recursive: true });
fs.writeFileSync(
path.join(customRoot, "legacy-crypto-inspector.js"),
'export { inspectLegacyMatrixCryptoStore } from "./src/matrix/legacy-crypto-inspector.js";\n',
"utf8",
);
fs.writeFileSync(
path.join(customRoot, "src", "matrix", "legacy-crypto-inspector.ts"),
[
"export async function inspectLegacyMatrixCryptoStore() {",
' return { deviceId: "SRCJS", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null };',
"}",
].join("\n"),
"utf8",
);
const cfg: OpenClawConfig = {
plugins: {
load: {
paths: [customRoot],
},
},
};
await expectLoadedInspector({
cfg,
env: process.env,
expected: {
deviceId: "SRCJS",
roomKeyCounts: null,
backupVersion: null,
decryptionKeyBase64: null,
},
});
},
{
env: {
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-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 });
writeMatrixPluginManifest(customRoot);
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: OpenClawConfig = {
plugins: {
load: {
paths: [customRoot],
},
},
};
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"),
},
},
);
});
});

View File

@@ -1,80 +0,0 @@
import fs from "node:fs";
import path from "node:path";
export const MATRIX_TEST_HOMESERVER = "https://matrix.example.org";
export const MATRIX_DEFAULT_USER_ID = "@bot:example.org";
export const MATRIX_DEFAULT_ACCESS_TOKEN = "tok-123";
export const MATRIX_DEFAULT_DEVICE_ID = "DEVICE123";
export const MATRIX_OPS_ACCOUNT_ID = "ops";
export const MATRIX_OPS_USER_ID = "@ops-bot:example.org";
export const MATRIX_OPS_ACCESS_TOKEN = "tok-ops";
export const MATRIX_OPS_DEVICE_ID = "DEVICEOPS";
export const matrixHelperEnv = {
OPENCLAW_BUNDLED_PLUGINS_DIR: (home: string) => path.join(home, "bundled"),
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
OPENCLAW_VERSION: undefined,
VITEST: "true",
} as const;
export function writeFile(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value, "utf8");
}
export function writeMatrixPluginManifest(rootDir: string): void {
fs.mkdirSync(rootDir, { recursive: true });
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "matrix",
configSchema: {
type: "object",
additionalProperties: false,
},
}),
"utf8",
);
fs.writeFileSync(path.join(rootDir, "index.js"), "export default {};\n", "utf8");
}
export function writeMatrixPluginFixture(rootDir: string, helperBody?: string): void {
writeMatrixPluginManifest(rootDir);
fs.writeFileSync(
path.join(rootDir, "legacy-crypto-inspector.js"),
helperBody ??
[
"export async function inspectLegacyMatrixCryptoStore() {",
' return { deviceId: "FIXTURE", roomKeyCounts: { total: 1, backedUp: 1 }, backupVersion: "1", decryptionKeyBase64: null };',
"}",
].join("\n"),
"utf8",
);
}
export function writeMatrixCredentials(
stateDir: string,
params?: {
accountId?: string;
homeserver?: string;
userId?: string;
accessToken?: string;
deviceId?: string;
},
) {
const accountId = params?.accountId ?? MATRIX_OPS_ACCOUNT_ID;
writeFile(
path.join(stateDir, "credentials", "matrix", `credentials-${accountId}.json`),
JSON.stringify(
{
homeserver: params?.homeserver ?? MATRIX_TEST_HOMESERVER,
userId: params?.userId ?? MATRIX_OPS_USER_ID,
accessToken: params?.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
deviceId: params?.deviceId ?? MATRIX_OPS_DEVICE_ID,
},
null,
2,
),
);
}

View File

@@ -1,5 +1,6 @@
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createIMessageTestPlugin } from "../../../test/helpers/channels/imessage-test-plugin.js";
import type { OpenClawConfig } from "../../config/config.js";
import { createHookRunner } from "../../plugins/hooks.js";
import { addTestHook } from "../../plugins/hooks.test-helpers.js";
@@ -10,7 +11,6 @@ import {
} from "../../plugins/runtime.js";
import type { PluginHookRegistration } from "../../plugins/types.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js";
import {

View File

@@ -1,17 +1,17 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTelegramRetryRunner } from "./retry-policy.js";
import { createChannelApiRetryRunner } from "./retry-policy.js";
const ZERO_DELAY_RETRY = { attempts: 3, minDelayMs: 0, maxDelayMs: 0, jitter: 0 };
async function runRetryCase(params: {
runnerOptions: Parameters<typeof createTelegramRetryRunner>[0];
runnerOptions: Parameters<typeof createChannelApiRetryRunner>[0];
fnSteps: Array<{ type: "reject" | "resolve"; value: unknown }>;
expectedCalls: number;
expectedValue?: unknown;
expectedError?: string;
}): Promise<void> {
vi.useFakeTimers();
const runner = createTelegramRetryRunner(params.runnerOptions);
const runner = createChannelApiRetryRunner(params.runnerOptions);
const fn = vi.fn();
const allRejects =
params.fnSteps.length > 0 && params.fnSteps.every((step) => step.type === "reject");
@@ -39,7 +39,7 @@ async function runRetryCase(params: {
expect(fn).toHaveBeenCalledTimes(params.expectedCalls);
}
describe("createTelegramRetryRunner", () => {
describe("createChannelApiRetryRunner", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();

View File

@@ -97,16 +97,18 @@ describe("state migrations", () => {
expect(detected.sessions.hasLegacy).toBe(true);
expect(detected.sessions.legacyKeys).toEqual(["group:123@g.us"]);
expect(detected.agentDir.hasLegacy).toBe(true);
expect(detected.whatsappAuth.hasLegacy).toBe(true);
expect(detected.pairingAllowFrom.hasLegacyTelegram).toBe(true);
expect(detected.pairingAllowFrom.copyPlans.map((plan) => plan.targetPath)).toEqual([
expect(detected.channelPlans.hasLegacy).toBe(true);
expect(detected.channelPlans.plans.map((plan) => plan.targetPath)).toEqual([
path.join(stateDir, "credentials", "whatsapp", "default", "creds.json"),
path.join(stateDir, "credentials", "whatsapp", "default", "pre-key-1.json"),
resolveChannelAllowFromPath("telegram", env, "alpha"),
]);
expect(detected.preview).toEqual([
`- Sessions: ${path.join(stateDir, "sessions")}${path.join(stateDir, "agents", "worker-1", "sessions")}`,
`- Sessions: canonicalize legacy keys in ${path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json")}`,
`- Agent dir: ${path.join(stateDir, "agent")}${path.join(stateDir, "agents", "worker-1", "agent")}`,
`- WhatsApp auth: ${path.join(stateDir, "credentials")}${path.join(stateDir, "credentials", "whatsapp", "default")} (keep oauth.json)`,
`- WhatsApp auth creds.json: ${path.join(stateDir, "credentials", "creds.json")}${path.join(stateDir, "credentials", "whatsapp", "default", "creds.json")}`,
`- WhatsApp auth pre-key-1.json: ${path.join(stateDir, "credentials", "pre-key-1.json")}${path.join(stateDir, "credentials", "whatsapp", "default", "pre-key-1.json")}`,
`- Telegram pairing allowFrom: ${resolveChannelAllowFromPath("telegram", env)}${resolveChannelAllowFromPath("telegram", env, "alpha")}`,
]);
});
@@ -131,8 +133,8 @@ describe("state migrations", () => {
"Canonicalized 1 legacy session key(s)",
"Moved trace.jsonl → agents/worker-1/sessions",
"Moved agent file settings.json → agents/worker-1/agent",
"Moved WhatsApp auth creds.json → whatsapp/default",
"Moved WhatsApp auth pre-key-1.json → whatsapp/default",
`Moved WhatsApp auth creds.json → ${path.join(stateDir, "credentials", "whatsapp", "default", "creds.json")}`,
`Moved WhatsApp auth pre-key-1.json → ${path.join(stateDir, "credentials", "whatsapp", "default", "pre-key-1.json")}`,
`Copied Telegram pairing allowFrom → ${resolveChannelAllowFromPath("telegram", env, "alpha")}`,
]);

View File

@@ -1,36 +0,0 @@
import { describe, expect, it } from "vitest";
import { markdownToWhatsApp } from "./whatsapp.js";
describe("markdownToWhatsApp", () => {
it.each([
["converts **bold** to *bold*", "**SOD Blast:**", "*SOD Blast:*"],
["converts __bold__ to *bold*", "__important__", "*important*"],
["converts ~~strikethrough~~ to ~strikethrough~", "~~deleted~~", "~deleted~"],
["leaves single *italic* unchanged (already WhatsApp bold)", "*text*", "*text*"],
["leaves _italic_ unchanged (already WhatsApp italic)", "_text_", "_text_"],
["preserves inline code", "Use `**not bold**` here", "Use `**not bold**` here"],
[
"handles mixed formatting",
"**bold** and ~~strike~~ and _italic_",
"*bold* and ~strike~ and _italic_",
],
["handles multiple bold segments", "**one** then **two**", "*one* then *two*"],
["returns empty string for empty input", "", ""],
["returns plain text unchanged", "no formatting here", "no formatting here"],
["handles bold inside a sentence", "This is **very** important", "This is *very* important"],
] as const)("handles markdown-to-whatsapp conversion: %s", (_name, input, expected) => {
expect(markdownToWhatsApp(input)).toBe(expected);
});
it("preserves fenced code blocks", () => {
const input = "```\nconst x = **bold**;\n```";
expect(markdownToWhatsApp(input)).toBe(input);
});
it("preserves code block with formatting inside", () => {
const input = "Before ```**bold** and ~~strike~~``` after **real bold**";
expect(markdownToWhatsApp(input)).toBe(
"Before ```**bold** and ~~strike~~``` after *real bold*",
);
});
});

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "../../test/helpers/channels/channel-media-roots-contract.js";
import type { OpenClawConfig } from "../config/config.js";
describe("channel-inbound-roots contract", () => {
function expectResolvedRootsCase(resolve: () => string[], expected: readonly string[]) {
expect(resolve()).toEqual(expected);
}
const accountOverrideCfg = {
channels: {
imessage: {
attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
remoteAttachmentRoots: ["/Volumes/shared/imessage"],
accounts: {
work: {
attachmentRoots: ["/Users/work/Library/Messages/Attachments"],
remoteAttachmentRoots: ["/srv/work/attachments"],
},
},
},
},
} as OpenClawConfig;
it("resolves configured attachment roots with account overrides", () => {
expectResolvedRootsCase(
() => resolveIMessageAttachmentRoots({ cfg: accountOverrideCfg, accountId: "work" }),
["/Users/work/Library/Messages/Attachments", "/Users/*/Library/Messages/Attachments"],
);
});
it("resolves configured remote attachment roots with account overrides", () => {
expectResolvedRootsCase(
() => resolveIMessageRemoteAttachmentRoots({ cfg: accountOverrideCfg, accountId: "work" }),
[
"/srv/work/attachments",
"/Volumes/shared/imessage",
"/Users/work/Library/Messages/Attachments",
"/Users/*/Library/Messages/Attachments",
],
);
});
it("matches iMessage account ids case-insensitively for attachment roots", () => {
const cfg = {
channels: {
imessage: {
accounts: {
Work: {
attachmentRoots: ["/Users/work/Library/Messages/Attachments"],
},
},
},
},
} as OpenClawConfig;
expectResolvedRootsCase(
() => resolveIMessageAttachmentRoots({ cfg, accountId: "work" }),
["/Users/work/Library/Messages/Attachments", ...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
);
});
it("falls back to default iMessage attachment roots", () => {
expectResolvedRootsCase(
() => resolveIMessageAttachmentRoots({ cfg: {} as OpenClawConfig }),
[...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
);
});
it("falls back to default iMessage remote attachment roots", () => {
expectResolvedRootsCase(
() => resolveIMessageRemoteAttachmentRoots({ cfg: {} as OpenClawConfig }),
[...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
);
});
});

View File

@@ -1,12 +1,8 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
isInboundPathAllowed,
isValidInboundPathRootPattern,
mergeInboundPathRoots,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "./inbound-path-policy.js";
describe("inbound-path-policy", () => {
@@ -20,10 +16,6 @@ describe("inbound-path-policy", () => {
).toBe(expected);
}
function expectResolvedIMessageRootsCase(resolve: () => string[], expected: readonly string[]) {
expect(resolve()).toEqual(expected);
}
function expectMergedInboundPathRootsCase(params: {
defaults: string[];
additions: string[];
@@ -54,21 +46,6 @@ describe("inbound-path-policy", () => {
expectInboundPathAllowedCase(filePath, expected);
});
const accountOverrideCfg = {
channels: {
imessage: {
attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
remoteAttachmentRoots: ["/Volumes/shared/imessage"],
accounts: {
work: {
attachmentRoots: ["/Users/work/Library/Messages/Attachments"],
remoteAttachmentRoots: ["/srv/work/attachments"],
},
},
},
},
} as OpenClawConfig;
it.each([
{
name: "normalizes and de-duplicates merged roots",
@@ -82,63 +59,7 @@ describe("inbound-path-policy", () => {
expected: ["/Users/*/Library/Messages/Attachments", "/Volumes/relay/attachments"],
}),
},
{
name: "resolves configured attachment roots with account overrides",
run: () =>
expectResolvedIMessageRootsCase(
() => resolveIMessageAttachmentRoots({ cfg: accountOverrideCfg, accountId: "work" }),
["/Users/work/Library/Messages/Attachments", "/Users/*/Library/Messages/Attachments"],
),
},
{
name: "resolves configured remote attachment roots with account overrides",
run: () =>
expectResolvedIMessageRootsCase(
() =>
resolveIMessageRemoteAttachmentRoots({ cfg: accountOverrideCfg, accountId: "work" }),
[
"/srv/work/attachments",
"/Volumes/shared/imessage",
"/Users/work/Library/Messages/Attachments",
"/Users/*/Library/Messages/Attachments",
],
),
},
] as const)("$name", ({ run }) => {
run();
});
it.each([
{
name: "matches iMessage account ids case-insensitively for attachment roots",
resolve: () => {
const cfg = {
channels: {
imessage: {
accounts: {
Work: {
attachmentRoots: ["/Users/work/Library/Messages/Attachments"],
},
},
},
},
} as OpenClawConfig;
return resolveIMessageAttachmentRoots({ cfg, accountId: "work" });
},
expected: ["/Users/work/Library/Messages/Attachments", ...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
},
{
name: "falls back to default iMessage attachment roots",
resolve: () => resolveIMessageAttachmentRoots({ cfg: {} as OpenClawConfig }),
expected: [...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
},
{
name: "falls back to default iMessage remote attachment roots",
resolve: () => resolveIMessageRemoteAttachmentRoots({ cfg: {} as OpenClawConfig }),
expected: [...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
},
] as const)("$name", ({ resolve, expected }) => {
expectResolvedIMessageRootsCase(resolve, expected);
});
});

View File

@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import { formatPairingApproveHint } from "../channels/plugins/helpers.js";
import type { GroupPolicy } from "../config/types.base.js";
import { createRestrictSendersChannelSecurity } from "./channel-policy.js";
import {
createDangerousNameMatchingMutableAllowlistWarningCollector,
createRestrictSendersChannelSecurity,
} from "./channel-policy.js";
describe("createRestrictSendersChannelSecurity", () => {
it("builds dm policy resolution and open-group warnings from one descriptor", async () => {
@@ -56,3 +59,50 @@ describe("createRestrictSendersChannelSecurity", () => {
]);
});
});
describe("createDangerousNameMatchingMutableAllowlistWarningCollector", () => {
const collectWarnings = createDangerousNameMatchingMutableAllowlistWarningCollector({
channel: "irc",
detector: (entry) => !entry.includes("@"),
collectLists: (scope) => [
{
pathLabel: `${scope.prefix}.allowFrom`,
list: scope.account.allowFrom,
},
],
});
it("collects mutable entries while dangerous matching is disabled", () => {
expect(
collectWarnings({
cfg: {
channels: {
irc: {
allowFrom: ["charlie"],
},
},
} as never,
}),
).toEqual(
expect.arrayContaining([
expect.stringContaining("mutable allowlist entry"),
expect.stringContaining("channels.irc.allowFrom: charlie"),
]),
);
});
it("skips scopes that explicitly allow dangerous name matching", () => {
expect(
collectWarnings({
cfg: {
channels: {
irc: {
dangerouslyAllowNameMatching: true,
allowFrom: ["charlie"],
},
},
} as never,
}),
).toEqual([]);
});
});

View File

@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isWhatsAppGroupJid,
isWhatsAppUserTarget,
normalizeWhatsAppTarget,
} from "./whatsapp-targets.js";
describe("plugin-sdk whatsapp-targets", () => {
it("normalizes user targets through the public facade", () => {
expect(normalizeWhatsAppTarget("1555123@s.whatsapp.net")).toBe("+1555123");
expect(normalizeWhatsAppTarget("whatsapp:+1555123")).toBe("+1555123");
});
it("preserves valid group JIDs through the public facade", () => {
expect(isWhatsAppGroupJid("120363401234567890@g.us")).toBe(true);
expect(normalizeWhatsAppTarget("120363401234567890@g.us")).toBe("120363401234567890@g.us");
});
it("detects WhatsApp user JIDs through the public facade", () => {
expect(isWhatsAppUserTarget("41796666864:0@s.whatsapp.net")).toBe(true);
expect(isWhatsAppUserTarget("123456789@lid")).toBe(true);
expect(isWhatsAppUserTarget("123456789-987654321@g.us")).toBe(false);
});
});

View File

@@ -700,12 +700,7 @@ describe("plugin-sdk subpath exports", () => {
"createTopLevelChannelDmPolicy",
"mergeAllowFromEntries",
]);
expectSourceMentions("setup-tools", [
"formatCliCommand",
"detectBinary",
"installSignalCli",
"formatDocsLink",
]);
expectSourceMentions("setup-tools", ["formatCliCommand", "detectBinary", "formatDocsLink"]);
expectSourceMentions("lazy-runtime", ["createLazyRuntimeSurface", "createLazyRuntimeModule"]);
expectSourceContract("self-hosted-provider-setup", {
mentions: [

View File

@@ -2,15 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } fr
import type {
DiscordInteractiveHandlerContext,
DiscordInteractiveHandlerRegistration,
} from "../../extensions/discord/api.js";
} from "../../test/helpers/channels/interactive-contract.js";
import type {
SlackInteractiveHandlerContext,
SlackInteractiveHandlerRegistration,
} from "../../extensions/slack/api.js";
} from "../../test/helpers/channels/interactive-contract.js";
import type {
TelegramInteractiveHandlerContext,
TelegramInteractiveHandlerRegistration,
} from "../../extensions/telegram/api.js";
} from "../../test/helpers/channels/interactive-contract.js";
import * as conversationBinding from "./conversation-binding.js";
import { createInteractiveConversationBindingHelpers } from "./interactive-binding-helpers.js";
import {
@@ -608,6 +608,16 @@ describe("plugin interactive handlers", () => {
});
});
it("preserves arbitrary plugin-owned channel ids", () => {
const result = registerPluginInteractiveHandler("plugin-a", {
channel: "msteams",
namespace: "codex",
handler: async () => ({ handled: true }),
});
expect(result).toEqual({ ok: true });
});
it("acknowledges matched Discord interactions before awaiting plugin handlers", async () => {
const callOrder: string[] = [];
const handler = vi.fn(async () => {

View File

@@ -1,302 +0,0 @@
import { createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import { request } from "node:https";
import os from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { extractArchive } from "../infra/archive.js";
import { resolveBrewExecutable } from "../infra/brew.js";
import { runCommandWithTimeout } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { CONFIG_DIR } from "../utils.js";
export type ReleaseAsset = {
name?: string;
browser_download_url?: string;
};
export type NamedAsset = {
name: string;
browser_download_url: string;
};
type ReleaseResponse = {
tag_name?: string;
assets?: ReleaseAsset[];
};
export type SignalInstallResult = {
ok: boolean;
cliPath?: string;
version?: string;
error?: string;
};
/** @internal Exported for testing. */
export async function extractSignalCliArchive(
archivePath: string,
installRoot: string,
timeoutMs: number,
): Promise<void> {
await extractArchive({ archivePath, destDir: installRoot, timeoutMs });
}
/** @internal Exported for testing. */
export function looksLikeArchive(name: string): boolean {
return name.endsWith(".tar.gz") || name.endsWith(".tgz") || name.endsWith(".zip");
}
/**
* Pick a native release asset from the official GitHub releases.
*
* The official signal-cli releases only publish native (GraalVM) binaries for
* x86-64 Linux. On architectures where no native asset is available this
* returns `undefined` so the caller can fall back to a different install
* strategy (e.g. Homebrew).
*/
/** @internal Exported for testing. */
export function pickAsset(
assets: ReleaseAsset[],
platform: NodeJS.Platform,
arch: string,
): NamedAsset | undefined {
const withName = assets.filter((asset): asset is NamedAsset =>
Boolean(asset.name && asset.browser_download_url),
);
// Archives only, excluding signature files (.asc)
const archives = withName.filter((a) => looksLikeArchive(a.name.toLowerCase()));
const byName = (pattern: RegExp) =>
archives.find((asset) => pattern.test(asset.name.toLowerCase()));
if (platform === "linux") {
// The official "Linux-native" asset is an x86-64 GraalVM binary.
// On non-x64 architectures it will fail with "Exec format error",
// so only select it when the host architecture matches.
if (arch === "x64") {
return byName(/linux-native/) || byName(/linux/) || archives[0];
}
// No native release for this arch — caller should fall back.
return undefined;
}
if (platform === "darwin") {
return byName(/macos|osx|darwin/) || archives[0];
}
if (platform === "win32") {
return byName(/windows|win/) || archives[0];
}
return archives[0];
}
async function downloadToFile(url: string, dest: string, maxRedirects = 5): Promise<void> {
await new Promise<void>((resolve, reject) => {
const req = request(url, (res) => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) {
const location = res.headers.location;
if (!location || maxRedirects <= 0) {
reject(new Error("Redirect loop or missing Location header"));
return;
}
const redirectUrl = new URL(location, url).href;
resolve(downloadToFile(redirectUrl, dest, maxRedirects - 1));
return;
}
if (!res.statusCode || res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading file`));
return;
}
const out = createWriteStream(dest);
pipeline(res, out).then(resolve).catch(reject);
});
req.on("error", reject);
req.end();
});
}
async function findSignalCliBinary(root: string): Promise<string | null> {
const candidates: string[] = [];
const enqueue = async (dir: string, depth: number) => {
if (depth > 3) {
return;
}
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await enqueue(full, depth + 1);
} else if (entry.isFile() && entry.name === "signal-cli") {
candidates.push(full);
}
}
};
await enqueue(root, 0);
return candidates[0] ?? null;
}
// ---------------------------------------------------------------------------
// Brew-based install (used on architectures without an official native build)
// ---------------------------------------------------------------------------
async function resolveBrewSignalCliPath(brewExe: string): Promise<string | null> {
try {
const result = await runCommandWithTimeout([brewExe, "--prefix", "signal-cli"], {
timeoutMs: 10_000,
});
if (result.code === 0 && result.stdout.trim()) {
const prefix = result.stdout.trim();
// Homebrew installs the wrapper script at <prefix>/bin/signal-cli
const candidate = path.join(prefix, "bin", "signal-cli");
try {
await fs.access(candidate);
return candidate;
} catch {
// Fall back to searching the prefix
return findSignalCliBinary(prefix);
}
}
} catch {
// ignore
}
return null;
}
async function installSignalCliViaBrew(runtime: RuntimeEnv): Promise<SignalInstallResult> {
const brewExe = resolveBrewExecutable();
if (!brewExe) {
return {
ok: false,
error:
`No native signal-cli build is available for ${process.arch}. ` +
"Install Homebrew (https://brew.sh) and try again, or install signal-cli manually.",
};
}
runtime.log(`Installing signal-cli via Homebrew (${brewExe})…`);
const result = await runCommandWithTimeout([brewExe, "install", "signal-cli"], {
timeoutMs: 15 * 60_000, // brew builds from source; can take a while
});
if (result.code !== 0) {
return {
ok: false,
error: `brew install signal-cli failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`,
};
}
const cliPath = await resolveBrewSignalCliPath(brewExe);
if (!cliPath) {
return {
ok: false,
error: "brew install succeeded but signal-cli binary was not found.",
};
}
// Extract version from the installed binary.
let version: string | undefined;
try {
const vResult = await runCommandWithTimeout([cliPath, "--version"], {
timeoutMs: 10_000,
});
// Output is typically "signal-cli 0.13.24"
version = vResult.stdout.trim().replace(/^signal-cli\s+/, "") || undefined;
} catch {
// non-critical; leave version undefined
}
return { ok: true, cliPath, version };
}
// ---------------------------------------------------------------------------
// Direct download install (used when an official native asset is available)
// ---------------------------------------------------------------------------
async function installSignalCliFromRelease(runtime: RuntimeEnv): Promise<SignalInstallResult> {
const apiUrl = "https://api.github.com/repos/AsamK/signal-cli/releases/latest";
const response = await fetch(apiUrl, {
headers: {
"User-Agent": "openclaw",
Accept: "application/vnd.github+json",
},
});
if (!response.ok) {
return {
ok: false,
error: `Failed to fetch release info (${response.status})`,
};
}
const payload = (await response.json()) as ReleaseResponse;
const version = payload.tag_name?.replace(/^v/, "") ?? "unknown";
const assets = payload.assets ?? [];
const asset = pickAsset(assets, process.platform, process.arch);
if (!asset) {
return {
ok: false,
error: "No compatible release asset found for this platform.",
};
}
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-"));
const archivePath = path.join(tmpDir, asset.name);
runtime.log(`Downloading signal-cli ${version} (${asset.name})…`);
await downloadToFile(asset.browser_download_url, archivePath);
const installRoot = path.join(CONFIG_DIR, "tools", "signal-cli", version);
await fs.mkdir(installRoot, { recursive: true });
if (!looksLikeArchive(asset.name.toLowerCase())) {
return { ok: false, error: `Unsupported archive type: ${asset.name}` };
}
try {
await extractSignalCliArchive(archivePath, installRoot, 60_000);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
error: `Failed to extract ${asset.name}: ${message}`,
};
}
const cliPath = await findSignalCliBinary(installRoot);
if (!cliPath) {
return {
ok: false,
error: `signal-cli binary not found after extracting ${asset.name}`,
};
}
await fs.chmod(cliPath, 0o755).catch(() => {});
return { ok: true, cliPath, version };
}
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
export async function installSignalCli(runtime: RuntimeEnv): Promise<SignalInstallResult> {
if (process.platform === "win32") {
return {
ok: false,
error: "Signal CLI auto-install is not supported on Windows yet.",
};
}
// The official signal-cli GitHub releases only ship a native binary for
// x86-64 Linux. On other architectures (arm64, armv7, etc.) we delegate
// to Homebrew which builds from source and bundles the JRE automatically.
const hasNativeRelease = process.platform !== "linux" || process.arch === "x64";
if (hasNativeRelease) {
return installSignalCliFromRelease(runtime);
}
return installSignalCliViaBrew(runtime);
}

View File

@@ -1,3 +1,4 @@
import type { execFile as execFileType } from "node:child_process";
import { EventEmitter } from "node:events";
import fs from "node:fs";
import path from "node:path";
@@ -16,7 +17,7 @@ vi.mock("node:child_process", async () => {
() => vi.importActual<typeof import("node:child_process")>("node:child_process"),
{
spawn: spawnMock,
execFile: execFileMock as unknown as typeof import("node:child_process").execFile,
execFile: execFileMock as unknown as typeof execFileType,
},
);
});

View File

@@ -78,6 +78,7 @@ describe("deriveSessionChatType", () => {
{ key: "agent:main:telegram:dm:123456", expected: "direct" },
{ key: "telegram:dm:123456", expected: "direct" },
{ key: "discord:acc-1:guild-123:channel-456", expected: "channel" },
{ key: "12345-678@g.us", expected: "group" },
{ key: "agent:main:main", expected: "unknown" },
{ key: "agent:main", expected: "unknown" },
{ key: "", expected: "unknown" },

View File

@@ -2,11 +2,13 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { collectDiscordSecurityAuditFindings } from "../../extensions/discord/api.js";
import { collectSlackSecurityAuditFindings } from "../../extensions/slack/api.js";
import { collectSynologyChatSecurityAuditFindings } from "../../extensions/synology-chat/api.js";
import { collectTelegramSecurityAuditFindings } from "../../extensions/telegram/api.js";
import { collectZalouserSecurityAuditFindings } from "../../extensions/zalouser/api.js";
import {
collectDiscordSecurityAuditFindings,
collectSlackSecurityAuditFindings,
collectSynologyChatSecurityAuditFindings,
collectTelegramSecurityAuditFindings,
collectZalouserSecurityAuditFindings,
} from "../../test/helpers/channels/security-audit-contract.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { saveExecApprovals } from "../infra/exec-approvals.js";

View File

@@ -1,33 +0,0 @@
import { expect, type MockInstance } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
export function createWhatsAppPollFixture() {
const cfg = { marker: "resolved-cfg" } as OpenClawConfig;
const poll = {
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 1,
};
return {
cfg,
poll,
to: "+1555",
accountId: "work",
};
}
export function expectWhatsAppPollSent(
sendPollWhatsApp: MockInstance,
params: {
cfg: OpenClawConfig;
poll: { question: string; options: string[]; maxSelections: number };
to?: string;
accountId?: string;
},
) {
expect(sendPollWhatsApp).toHaveBeenCalledWith(params.to ?? "+1555", params.poll, {
verbose: false,
accountId: params.accountId ?? "work",
cfg: params.cfg,
});
}

View File

@@ -1,82 +0,0 @@
import { normalizeIMessageHandle } from "../channels/plugins/normalize/imessage.js";
import type { ChannelOutboundAdapter, ChannelPlugin } from "../channels/plugins/types.js";
import { resolveOutboundSendDep } from "../infra/outbound/send-deps.js";
import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js";
const defaultIMessageOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
sendText: async ({ to, text, accountId, replyToId, deps, cfg }) => {
const sendIMessage = resolveOutboundSendDep<
(
target: string,
content: string,
opts?: Record<string, unknown>,
) => Promise<{ messageId: string }>
>(deps, "imessage");
const result = await sendIMessage?.(to, text, {
config: cfg,
accountId: accountId ?? undefined,
replyToId: replyToId ?? undefined,
});
return { channel: "imessage", messageId: result?.messageId ?? "imessage-test-stub" };
},
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, deps, cfg, mediaLocalRoots }) => {
const sendIMessage = resolveOutboundSendDep<
(
target: string,
content: string,
opts?: Record<string, unknown>,
) => Promise<{ messageId: string }>
>(deps, "imessage");
const result = await sendIMessage?.(to, text, {
config: cfg,
mediaUrl,
accountId: accountId ?? undefined,
replyToId: replyToId ?? undefined,
mediaLocalRoots,
});
return { channel: "imessage", messageId: result?.messageId ?? "imessage-test-stub" };
},
};
export const createIMessageTestPlugin = (params?: {
outbound?: ChannelOutboundAdapter;
}): ChannelPlugin => ({
id: "imessage",
meta: {
id: "imessage",
label: "iMessage",
selectionLabel: "iMessage (imsg)",
docsPath: "/channels/imessage",
blurb: "iMessage test stub.",
aliases: ["imsg"],
},
capabilities: { chatTypes: ["direct", "group"], media: true },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
status: {
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("imessage", accounts),
},
outbound: params?.outbound ?? defaultIMessageOutbound,
messaging: {
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^(imessage:|sms:|auto:|chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) {
return true;
}
if (trimmed.includes("@")) {
return true;
}
return /^\+?\d{3,}$/.test(trimmed);
},
hint: "<handle|chat_id:ID>",
},
normalizeTarget: (raw) => normalizeIMessageHandle(raw),
},
});

View File

@@ -3,19 +3,13 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
assertWebChannel,
CONFIG_DIR,
ensureDir,
jidToE164,
normalizeE164,
resolveConfigDir,
resolveHomeDir,
resolveJidToE164,
resolveUserPath,
shortenHomeInString,
shortenHomePath,
sleep,
toWhatsappJid,
} from "./utils.js";
async function withTempDir<T>(
@@ -50,74 +44,6 @@ describe("sleep", () => {
});
});
describe("assertWebChannel", () => {
it("accepts valid channel", () => {
expect(() => assertWebChannel("web")).not.toThrow();
});
it("throws for invalid channel", () => {
expect(() => assertWebChannel("bad" as string)).toThrow();
});
});
describe("normalizeE164 & toWhatsappJid", () => {
it("strips formatting and prefixes", () => {
expect(normalizeE164("whatsapp:(555) 123-4567")).toBe("+5551234567");
expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe("5551234567@s.whatsapp.net");
});
it("preserves existing JIDs", () => {
expect(toWhatsappJid("123456789-987654321@g.us")).toBe("123456789-987654321@g.us");
expect(toWhatsappJid("whatsapp:123456789-987654321@g.us")).toBe("123456789-987654321@g.us");
expect(toWhatsappJid("1555123@s.whatsapp.net")).toBe("1555123@s.whatsapp.net");
});
});
describe("jidToE164", () => {
it("maps @lid using reverse mapping file", () => {
const mappingPath = path.join(CONFIG_DIR, "credentials", "lid-mapping-123_reverse.json");
const original = fs.readFileSync;
const spy = vi.spyOn(fs, "readFileSync").mockImplementation((...args) => {
if (args[0] === mappingPath) {
return `"5551234"`;
}
return original(...args);
});
expect(jidToE164("123@lid")).toBe("+5551234");
spy.mockRestore();
});
it("maps @lid from authDir mapping files", async () => {
await withTempDir("openclaw-auth-", (authDir) => {
const mappingPath = path.join(authDir, "lid-mapping-456_reverse.json");
fs.writeFileSync(mappingPath, JSON.stringify("5559876"));
expect(jidToE164("456@lid", { authDir })).toBe("+5559876");
});
});
it("maps @hosted.lid from authDir mapping files", async () => {
await withTempDir("openclaw-auth-", (authDir) => {
const mappingPath = path.join(authDir, "lid-mapping-789_reverse.json");
fs.writeFileSync(mappingPath, JSON.stringify(4440001));
expect(jidToE164("789@hosted.lid", { authDir })).toBe("+4440001");
});
});
it("accepts hosted PN JIDs", () => {
expect(jidToE164("1555000:2@hosted")).toBe("+1555000");
});
it("falls back through lidMappingDirs in order", async () => {
await withTempDir("openclaw-lid-a-", async (first) => {
await withTempDir("openclaw-lid-b-", (second) => {
const mappingPath = path.join(second, "lid-mapping-321_reverse.json");
fs.writeFileSync(mappingPath, JSON.stringify("123321"));
expect(jidToE164("321@lid", { lidMappingDirs: [first, second] })).toBe("+123321");
});
});
});
});
describe("resolveConfigDir", () => {
it("prefers ~/.openclaw when legacy dir is missing", async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-config-dir-"));
@@ -178,32 +104,6 @@ describe("shortenHomeInString", () => {
});
});
describe("resolveJidToE164", () => {
it("resolves @lid via lidLookup when mapping file is missing", async () => {
const lidLookup = {
getPNForLID: vi.fn().mockResolvedValue("777:0@s.whatsapp.net"),
};
await expect(resolveJidToE164("777@lid", { lidLookup })).resolves.toBe("+777");
expect(lidLookup.getPNForLID).toHaveBeenCalledWith("777@lid");
});
it("skips lidLookup for non-lid JIDs", async () => {
const lidLookup = {
getPNForLID: vi.fn().mockResolvedValue("888:0@s.whatsapp.net"),
};
await expect(resolveJidToE164("888@s.whatsapp.net", { lidLookup })).resolves.toBe("+888");
expect(lidLookup.getPNForLID).not.toHaveBeenCalled();
});
it("returns null when lidLookup throws", async () => {
const lidLookup = {
getPNForLID: vi.fn().mockRejectedValue(new Error("lookup failed")),
};
await expect(resolveJidToE164("777@lid", { lidLookup })).resolves.toBeNull();
expect(lidLookup.getPNForLID).toHaveBeenCalledWith("777@lid");
});
});
describe("resolveUserPath", () => {
it("expands ~ to home dir", () => {
expect(resolveUserPath("~", {}, () => "/Users/thoffman")).toBe(path.resolve("/Users/thoffman"));

View File

@@ -13,6 +13,10 @@ const allowedNonExtensionTests = new Set<string>([
"src/agents/pi-embedded-runner-extraparams.test.ts",
"src/channels/plugins/contracts/dm-policy.contract.test.ts",
"src/channels/plugins/contracts/group-policy.contract.test.ts",
"src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts",
"src/commands/onboard-channels.e2e.test.ts",
"src/gateway/hooks.test.ts",
"src/infra/outbound/deliver.test.ts",
"src/plugins/interactive.test.ts",
"src/plugins/contracts/discovery.contract.test.ts",
]);
@@ -156,7 +160,6 @@ describe("non-extension test boundaries", () => {
it("keeps bundled plugin public-surface imports on an explicit core allowlist", () => {
const allowed = new Set([
"src/auto-reply/reply.triggers.trigger-handling.test-harness.ts",
"src/channels/plugins/contracts/slack-outbound-harness.ts",
"src/commands/channel-test-registry.ts",
"src/plugin-sdk/testing.ts",
]);

View File

@@ -0,0 +1,5 @@
export const TEST_BUNDLED_RUNTIME_SIDECAR_PATHS = [
"dist/extensions/discord/runtime-api.js",
"dist/extensions/slack/helper-api.js",
"dist/extensions/telegram/thread-bindings-runtime.js",
] as const;

View File

@@ -0,0 +1,5 @@
export {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "../../../extensions/imessage/contract-api.js";

View File

@@ -0,0 +1,6 @@
export { buildTelegramModelsProviderChannelData } from "../../../extensions/telegram/contract-api.js";
export { whatsappCommandPolicy } from "../../../extensions/whatsapp/contract-api.js";
export {
isWhatsAppGroupJid,
normalizeWhatsAppTarget,
} from "../../../extensions/whatsapp/contract-api.js";

View File

@@ -0,0 +1 @@
export { createIMessageTestPlugin } from "../../../extensions/imessage/contract-api.js";

View File

@@ -0,0 +1,12 @@
export type {
DiscordInteractiveHandlerContext,
DiscordInteractiveHandlerRegistration,
} from "../../../extensions/discord/contract-api.js";
export type {
SlackInteractiveHandlerContext,
SlackInteractiveHandlerRegistration,
} from "../../../extensions/slack/contract-api.js";
export type {
TelegramInteractiveHandlerContext,
TelegramInteractiveHandlerRegistration,
} from "../../../extensions/telegram/contract-api.js";

View File

@@ -1 +1 @@
export { matrixSetupAdapter, matrixSetupWizard } from "../../../extensions/matrix/api.js";
export { matrixSetupAdapter, matrixSetupWizard } from "../../../extensions/matrix/contract-api.js";

View File

@@ -1,11 +1,11 @@
import { expect, it, type Mock, vi } from "vitest";
import { createSlackOutboundPayloadHarness } from "../../../extensions/slack/contract-api.js";
import {
chunkTextForOutbound as chunkZaloTextForOutbound,
sendPayloadWithChunkedTextAndMedia as sendZaloPayloadWithChunkedTextAndMedia,
} from "../../../extensions/zalo/runtime-api.js";
import { sendPayloadWithChunkedTextAndMedia as sendZalouserPayloadWithChunkedTextAndMedia } from "../../../extensions/zalouser/runtime-api.js";
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
import { createSlackOutboundPayloadHarness } from "../../../src/channels/plugins/contracts/slack-outbound-harness.js";
import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/test-helpers.js";
import { createDirectTextMediaOutbound } from "../../../src/channels/plugins/outbound/direct-text-media.js";
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";

View File

@@ -0,0 +1,9 @@
export { whatsappAccessControlTesting } from "../../../extensions/whatsapp/contract-api.js";
export {
evaluateZaloGroupAccess,
resolveZaloRuntimeGroupPolicy,
} from "../../../extensions/zalo/contract-api.js";
export {
isSignalSenderAllowed,
type SignalSender,
} from "../../../extensions/signal/contract-api.js";

View File

@@ -0,0 +1,5 @@
export { collectDiscordSecurityAuditFindings } from "../../../extensions/discord/contract-api.js";
export { collectSlackSecurityAuditFindings } from "../../../extensions/slack/contract-api.js";
export { collectSynologyChatSecurityAuditFindings } from "../../../extensions/synology-chat/contract-api.js";
export { collectTelegramSecurityAuditFindings } from "../../../extensions/telegram/contract-api.js";
export { collectZalouserSecurityAuditFindings } from "../../../extensions/zalouser/contract-api.js";

View File

@@ -1,8 +0,0 @@
export type { OpenClawConfig } from "../../../src/config/config.js";
export {
__testing,
registerSessionBindingAdapter,
} from "../../../src/infra/outbound/session-binding-service.js";
export { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
export { resolveAgentRoute } from "../../../src/routing/resolve-route.js";
export { createTestRegistry } from "../../../src/test-utils/channel-plugins.js";

View File

@@ -0,0 +1,8 @@
export {
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,
createAnthropicServiceTierWrapper,
resolveAnthropicBetas,
resolveAnthropicFastMode,
resolveAnthropicServiceTier,
} from "../../../extensions/anthropic/contract-api.js";

View File

@@ -21,6 +21,9 @@ describe("isUnitConfigTestFile", () => {
expect(isUnitConfigTestFile("src/infra/git-root.test.ts")).toBe(false);
expect(isUnitConfigTestFile("src/infra/home-dir.test.ts")).toBe(false);
expect(isUnitConfigTestFile("src/infra/openclaw-exec-env.test.ts")).toBe(false);
expect(
isUnitConfigTestFile(bundledPluginFile("matrix", "src/migration-snapshot.test.ts")),
).toBe(false);
expect(isUnitConfigTestFile("src/infra/openclaw-root.test.ts")).toBe(false);
expect(isUnitConfigTestFile("src/infra/package-json.test.ts")).toBe(false);
expect(isUnitConfigTestFile("src/infra/path-env.test.ts")).toBe(false);