重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,56 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { canonicalSparkleBuildFromVersion } from "../scripts/sparkle-build.ts";
const APPCAST_URL = new URL("../appcast.xml", import.meta.url);
type AppcastItem = {
raw: string;
shortVersion: string | null;
sparkleVersion: number | null;
};
function parseItems(appcast: string): AppcastItem[] {
return [...appcast.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((match) => {
const raw = match[1] ?? "";
const shortVersion =
raw.match(/<sparkle:shortVersionString>([^<]+)<\/sparkle:shortVersionString>/)?.[1] ?? null;
const sparkleVersionText = raw.match(/<sparkle:version>([^<]+)<\/sparkle:version>/)?.[1] ?? "";
const sparkleVersion = Number.parseInt(sparkleVersionText, 10);
return {
raw,
shortVersion,
sparkleVersion: Number.isFinite(sparkleVersion) ? sparkleVersion : null,
};
});
}
describe("appcast.xml", () => {
it("keeps every appcast entry on the canonical sparkle build for its version", () => {
const appcast = readFileSync(APPCAST_URL, "utf8");
const items = parseItems(appcast);
expect(items.length).toBeGreaterThan(0);
for (const item of items) {
expect(item.shortVersion, item.raw).not.toBeNull();
expect(item.sparkleVersion, item.raw).not.toBeNull();
expect(item.sparkleVersion).toBe(canonicalSparkleBuildFromVersion(item.shortVersion!));
}
});
it("keeps the first stable appcast entry aligned with the newest stable build", () => {
const appcast = readFileSync(APPCAST_URL, "utf8");
const stableItems = parseItems(appcast).filter(
(item) => item.sparkleVersion !== null && item.sparkleVersion % 100 === 90,
);
expect(stableItems.length).toBeGreaterThan(0);
const firstStable = stableItems[0];
const newestStable = [...stableItems].toSorted(
(left, right) => (right.sparkleVersion ?? 0) - (left.sparkleVersion ?? 0),
)[0];
expect(firstStable.sparkleVersion).toBe(newestStable.sparkleVersion);
expect(firstStable.shortVersion).toBe(newestStable.shortVersion);
});
});

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { collectArchitectureSmells, main } from "../scripts/check-architecture-smells.mjs";
import { createCapturedIo } from "./helpers/captured-io.js";
describe("architecture smell inventory", () => {
it("produces stable sorted output", async () => {
const first = await collectArchitectureSmells();
const second = await collectArchitectureSmells();
expect(second).toEqual(first);
expect(
[...first].toSorted(
(left, right) =>
left.category.localeCompare(right.category) ||
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.kind.localeCompare(right.kind) ||
left.specifier.localeCompare(right.specifier) ||
left.reason.localeCompare(right.reason),
),
).toEqual(first);
});
it("script json output matches the collector", async () => {
const captured = createCapturedIo();
const exitCode = await main(["--json"], captured.io);
expect(exitCode).toBe(0);
expect(captured.readStderr()).toBe("");
expect(JSON.parse(captured.readStdout())).toEqual(await collectArchitectureSmells());
});
});

View file

@ -0,0 +1,44 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "./helpers/temp-home.ts";
describe("cli json stdout contract", () => {
it("keeps `update status --json` stdout parseable even with legacy doctor preflight inputs", async () => {
await withTempHome(
async (tempHome) => {
const legacyDir = path.join(tempHome, ".clawdbot");
await fs.mkdir(legacyDir, { recursive: true });
await fs.writeFile(path.join(legacyDir, "clawdbot.json"), "{}", "utf8");
const env = {
...process.env,
HOME: tempHome,
USERPROFILE: tempHome,
OPENCLAW_TEST_FAST: "1",
};
delete env.OPENCLAW_HOME;
delete env.OPENCLAW_STATE_DIR;
delete env.OPENCLAW_CONFIG_PATH;
delete env.VITEST;
const entry = path.resolve(process.cwd(), "openclaw.mjs");
const result = spawnSync(
process.execPath,
[entry, "update", "status", "--json", "--timeout", "1"],
{ cwd: process.cwd(), env, encoding: "utf8" },
);
expect(result.status).toBe(0);
const stdout = result.stdout.trim();
expect(stdout.length).toBeGreaterThan(0);
expect(() => JSON.parse(stdout)).not.toThrow();
expect(stdout).not.toContain("Doctor warnings");
expect(stdout).not.toContain("Doctor changes");
expect(stdout).not.toContain("Config invalid");
},
{ prefix: "openclaw-json-e2e-" },
);
});
});

View file

@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import {
collectExtensionPluginSdkBoundaryInventory,
main as extensionPluginSdkMain,
} from "../scripts/check-extension-plugin-sdk-boundary.mjs";
import {
collectSdkPackageExtensionImportBoundaryInventory,
main as sdkPackageMain,
} from "../scripts/check-sdk-package-extension-import-boundary.mjs";
import {
collectSrcExtensionImportBoundaryInventory,
main as srcExtensionMain,
} from "../scripts/check-src-extension-import-boundary.mjs";
import { createCapturedIo } from "./helpers/captured-io.js";
const srcInventoryPromise = collectSrcExtensionImportBoundaryInventory();
const srcJsonOutputPromise = getJsonOutput(srcExtensionMain, ["--json"]);
const sdkPackageInventoryPromise = collectSdkPackageExtensionImportBoundaryInventory();
const sdkPackageJsonOutputPromise = getJsonOutput(sdkPackageMain, ["--json"]);
const srcOutsideInventoryPromise =
collectExtensionPluginSdkBoundaryInventory("src-outside-plugin-sdk");
const pluginSdkInternalInventoryPromise =
collectExtensionPluginSdkBoundaryInventory("plugin-sdk-internal");
const relativeOutsidePackageInventoryPromise = collectExtensionPluginSdkBoundaryInventory(
"relative-outside-package",
);
const srcOutsideJsonOutputPromise = getJsonOutput(extensionPluginSdkMain, [
"--mode=src-outside-plugin-sdk",
"--json",
]);
const pluginSdkInternalJsonOutputPromise = getJsonOutput(extensionPluginSdkMain, [
"--mode=plugin-sdk-internal",
"--json",
]);
const relativeOutsidePackageJsonOutputPromise = getJsonOutput(extensionPluginSdkMain, [
"--mode=relative-outside-package",
"--json",
]);
type CapturedIo = ReturnType<typeof createCapturedIo>["io"];
async function getJsonOutput(
main: (argv: string[], io: CapturedIo) => Promise<number>,
argv: string[],
) {
const captured = createCapturedIo();
const exitCode = await main(argv, captured.io);
return {
exitCode,
stderr: captured.readStderr(),
json: JSON.parse(captured.readStdout()),
};
}
describe("src extension import boundary inventory", () => {
it("stays empty", async () => {
expect(await srcInventoryPromise).toEqual([]);
});
it("produces stable sorted output", async () => {
const first = await srcInventoryPromise;
const second = await collectSrcExtensionImportBoundaryInventory();
expect(second).toEqual(first);
});
it("script json output stays empty", async () => {
const jsonOutput = await srcJsonOutputPromise;
expect(jsonOutput.exitCode).toBe(0);
expect(jsonOutput.stderr).toBe("");
expect(jsonOutput.json).toEqual([]);
});
});
describe("sdk/package extension import boundary inventory", () => {
it("stays empty", async () => {
expect(await sdkPackageInventoryPromise).toEqual([]);
});
it("produces stable sorted output", async () => {
const first = await sdkPackageInventoryPromise;
const second = await collectSdkPackageExtensionImportBoundaryInventory();
expect(second).toEqual(first);
});
it("script json output stays empty", async () => {
const jsonOutput = await sdkPackageJsonOutputPromise;
expect(jsonOutput.exitCode).toBe(0);
expect(jsonOutput.stderr).toBe("");
expect(jsonOutput.json).toEqual([]);
});
});
describe("extension src outside plugin-sdk boundary inventory", () => {
it("stays empty and sorted", async () => {
const inventory = await srcOutsideInventoryPromise;
const jsonResult = await srcOutsideJsonOutputPromise;
expect(inventory).toEqual([]);
expect(
[...inventory].toSorted(
(left, right) =>
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.kind.localeCompare(right.kind) ||
left.specifier.localeCompare(right.specifier) ||
left.resolvedPath.localeCompare(right.resolvedPath) ||
left.reason.localeCompare(right.reason),
),
).toEqual(inventory);
expect(jsonResult.exitCode).toBe(0);
expect(jsonResult.stderr).toBe("");
expect(jsonResult.json).toEqual([]);
});
});
describe("extension plugin-sdk-internal boundary inventory", () => {
it("stays empty", async () => {
const inventory = await pluginSdkInternalInventoryPromise;
const jsonResult = await pluginSdkInternalJsonOutputPromise;
expect(inventory).toEqual([]);
expect(jsonResult.exitCode).toBe(0);
expect(jsonResult.stderr).toBe("");
expect(jsonResult.json).toEqual([]);
});
});
describe("extension relative-outside-package boundary inventory", () => {
it("stays empty", async () => {
const inventory = await relativeOutsidePackageInventoryPromise;
const jsonResult = await relativeOutsidePackageJsonOutputPromise;
expect(inventory).toEqual([]);
expect(jsonResult.exitCode).toBe(0);
expect(jsonResult.stderr).toBe("");
expect(jsonResult.json).toEqual([]);
});
});

View file

@ -0,0 +1,38 @@
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const REPO_ROOT = resolve(import.meta.dirname, "..");
const CHECK_EXTENSION_PACKAGE_BOUNDARY_BIN = resolve(
REPO_ROOT,
"scripts/check-extension-package-tsc-boundary.mjs",
);
const SHOULD_RUN_BOUNDARY_SCRIPT_WRAPPER =
process.env.GITHUB_ACTIONS !== "true" ||
process.env.OPENCLAW_RUN_EXTENSION_PACKAGE_BOUNDARY_TEST === "1";
function runNode(args: string[], timeout: number) {
return spawnSync(process.execPath, args, {
cwd: REPO_ROOT,
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
timeout,
});
}
// The CI check-additional job runs this script directly. Avoid duplicating the cold
// 97-extension compile inside the full node test shard.
describe.skipIf(!SHOULD_RUN_BOUNDARY_SCRIPT_WRAPPER)(
"opt-in extension package TypeScript boundaries",
() => {
it("typechecks each opt-in extension cleanly through @openclaw/plugin-sdk", () => {
const result = runNode([CHECK_EXTENSION_PACKAGE_BOUNDARY_BIN, "--mode=compile"], 420_000);
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
}, 300_000);
it("fails when opt-in extensions import src/cli through a relative path", () => {
const result = runNode([CHECK_EXTENSION_PACKAGE_BOUNDARY_BIN, "--mode=canary"], 180_000);
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
});
},
);

View file

@ -0,0 +1,174 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { BUNDLED_PLUGIN_PATH_PREFIX } from "./helpers/bundled-plugin-paths.js";
import { GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES } from "./helpers/plugins/public-artifacts.js";
const repoRoot = path.resolve(import.meta.dirname, "..");
const ALLOWED_EXTENSION_PUBLIC_SURFACE_BASENAMES = new Set(
GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES,
);
function walk(dir: string, entries: string[] = []): string[] {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git") {
continue;
}
walk(fullPath, entries);
continue;
}
if (!entry.name.endsWith(".test.ts") && !entry.name.endsWith(".test.tsx")) {
continue;
}
entries.push(path.relative(repoRoot, fullPath).replaceAll(path.sep, "/"));
}
return entries;
}
function walkCode(dir: string, entries: string[] = []): string[] {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git") {
continue;
}
walkCode(fullPath, entries);
continue;
}
if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".tsx")) {
continue;
}
entries.push(path.relative(repoRoot, fullPath).replaceAll(path.sep, "/"));
}
return entries;
}
function findExtensionImports(source: string): string[] {
return [
...source.matchAll(/from\s+["']((?:\.\.\/)+extensions\/[^"']+)["']/g),
...source.matchAll(/import\(\s*["']((?:\.\.\/)+extensions\/[^"']+)["']\s*\)/g),
].map((match) => match[1]);
}
function isAllowedExtensionPublicImport(specifier: string): boolean {
return /(?:^|\/)extensions\/[^/]+\/(?:api|index|runtime-api|setup-entry|login-qr-api)\.js$/u.test(
specifier,
);
}
function findPluginSdkImports(source: string): string[] {
return [
...source.matchAll(/from\s+["']((?:\.\.\/)+plugin-sdk\/[^"']+)["']/g),
...source.matchAll(/import\(\s*["']((?:\.\.\/)+plugin-sdk\/[^"']+)["']\s*\)/g),
].map((match) => match[1]);
}
function findBundledPluginPublicSurfaceImports(source: string): string[] {
return [
...source.matchAll(/from\s+["'](?:\.\.\/)+test-utils\/bundled-plugin-public-surface\.js["']/g),
...source.matchAll(
/import\(\s*["'](?:\.\.\/)+test-utils\/bundled-plugin-public-surface\.js["']\s*\)/g,
),
].map((match) => match[0]);
}
function getImportBasename(importPath: string): string {
return importPath.split("/").at(-1) ?? importPath;
}
function isAllowedCoreContractSuite(file: string, imports: readonly string[]): boolean {
return (
file.startsWith("src/channels/plugins/contracts/") &&
file.endsWith(".contract.test.ts") &&
imports.every((entry) =>
ALLOWED_EXTENSION_PUBLIC_SURFACE_BASENAMES.has(getImportBasename(entry)),
)
);
}
describe("non-extension test boundaries", () => {
it("keeps plugin-owned behavior suites under the bundled plugin tree", () => {
const testFiles = [
...walk(path.join(repoRoot, "src")),
...walk(path.join(repoRoot, "test")),
...walk(path.join(repoRoot, "packages")),
].filter(
(file) =>
!file.startsWith(BUNDLED_PLUGIN_PATH_PREFIX) &&
!file.startsWith("test/helpers/") &&
!file.startsWith("ui/"),
);
const offenders = testFiles
.map((file) => {
const source = fs.readFileSync(path.join(repoRoot, file), "utf8");
const imports = findExtensionImports(source).filter(
(specifier) => !isAllowedExtensionPublicImport(specifier),
);
if (imports.length === 0) {
return null;
}
if (isAllowedCoreContractSuite(file, imports)) {
return null;
}
return {
file,
imports,
};
})
.filter((value): value is { file: string; imports: string[] } => value !== null);
expect(offenders).toEqual([]);
});
it("keeps extension-owned onboard helper coverage out of the core onboard auth suite", () => {
const bannedPluginSdkModules = new Set<string>([
"../plugin-sdk/litellm.js",
"../plugin-sdk/minimax.js",
"../plugin-sdk/mistral.js",
"../plugin-sdk/opencode-go.js",
"../plugin-sdk/opencode.js",
"../plugin-sdk/openrouter.js",
"../plugin-sdk/synthetic.js",
"../plugin-sdk/xai.js",
"../plugin-sdk/xiaomi.js",
]);
const file = "src/commands/onboard-auth.test.ts";
const source = fs.readFileSync(path.join(repoRoot, file), "utf8");
const imports = findPluginSdkImports(source).filter((entry) =>
bannedPluginSdkModules.has(entry),
);
expect(imports).toEqual([]);
});
it("keeps bundled plugin public-surface imports out of core source", () => {
const files = walkCode(path.join(repoRoot, "src"));
const offenders = files.filter((file) => {
const source = fs.readFileSync(path.join(repoRoot, file), "utf8");
return findBundledPluginPublicSurfaceImports(source).length > 0;
});
expect(offenders).toEqual([]);
});
it("keeps bundled plugin sync test-api loaders out of core tests", () => {
const files = [
...walkCode(path.join(repoRoot, "src")),
...walkCode(path.join(repoRoot, "test")),
]
.filter((file) => !file.startsWith(BUNDLED_PLUGIN_PATH_PREFIX))
.filter((file) => !file.startsWith("test/helpers/"))
.filter((file) => file !== "test/extension-test-boundary.test.ts");
const offenders = files.filter((file) => {
const source = fs.readFileSync(path.join(repoRoot, file), "utf8");
return source.includes("loadBundledPluginTestApiSync(");
});
expect(offenders).toEqual([]);
});
});

View file

@ -0,0 +1,11 @@
process.stdout.write("ready\n");
const keepAlive = setInterval(() => {}, 1000);
const shutdown = () => {
clearInterval(keepAlive);
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

View file

@ -0,0 +1,729 @@
{
"node": "v25.6.1",
"runs": 5,
"warmup": 1,
"timeoutMs": 30000,
"cpuProfDir": null,
"heapProfDir": null,
"primary": {
"entry": "openclaw.mjs",
"cases": [
{
"id": "version",
"name": "--version",
"args": ["--version"],
"samples": [
{
"ms": 96.717625,
"maxRssMb": 62.140625,
"exitCode": 0,
"signal": null
},
{
"ms": 95.282458,
"maxRssMb": 63.609375,
"exitCode": 0,
"signal": null
},
{
"ms": 98.361042,
"maxRssMb": 62.265625,
"exitCode": 0,
"signal": null
},
{
"ms": 100.737375,
"maxRssMb": 62.65625,
"exitCode": 0,
"signal": null
},
{
"ms": 98.220583,
"maxRssMb": 62.484375,
"exitCode": 0,
"signal": null
}
],
"summary": {
"sampleCount": 5,
"durationMs": {
"avg": 97.86381659999999,
"p50": 98.220583,
"p95": 100.737375,
"min": 95.282458,
"max": 100.737375
},
"maxRssMb": {
"avg": 62.63125,
"p50": 62.484375,
"p95": 63.609375,
"min": 62.140625,
"max": 63.609375
},
"exitSummary": "code:0x5"
}
},
{
"id": "help",
"name": "--help",
"args": ["--help"],
"samples": [
{
"ms": 51.178583,
"maxRssMb": 44.671875,
"exitCode": 0,
"signal": null
},
{
"ms": 51.062334,
"maxRssMb": 44.578125,
"exitCode": 0,
"signal": null
},
{
"ms": 51.028625,
"maxRssMb": 45.171875,
"exitCode": 0,
"signal": null
},
{
"ms": 54.476708,
"maxRssMb": 45.578125,
"exitCode": 0,
"signal": null
},
{
"ms": 57.570875,
"maxRssMb": 45.828125,
"exitCode": 0,
"signal": null
}
],
"summary": {
"sampleCount": 5,
"durationMs": {
"avg": 53.06342500000001,
"p50": 51.178583,
"p95": 57.570875,
"min": 51.028625,
"max": 57.570875
},
"maxRssMb": {
"avg": 45.165625,
"p50": 45.171875,
"p95": 45.828125,
"min": 44.578125,
"max": 45.828125
},
"exitSummary": "code:0x5"
}
},
{
"id": "health",
"name": "health",
"args": ["health"],
"samples": [
{
"ms": 9451.69,
"maxRssMb": 61.046875,
"exitCode": 0,
"signal": null
},
{
"ms": 9327.157,
"maxRssMb": 61.484375,
"exitCode": 0,
"signal": null
},
{
"ms": 9328.53775,
"maxRssMb": 61.5,
"exitCode": 0,
"signal": null
},
{
"ms": 9377.894208,
"maxRssMb": 61.28125,
"exitCode": 0,
"signal": null
},
{
"ms": 9362.753917,
"maxRssMb": 61.234375,
"exitCode": 0,
"signal": null
}
],
"summary": {
"sampleCount": 5,
"durationMs": {
"avg": 9369.606575000002,
"p50": 9362.753917,
"p95": 9451.69,
"min": 9327.157,
"max": 9451.69
},
"maxRssMb": {
"avg": 61.309375,
"p50": 61.28125,
"p95": 61.5,
"min": 61.046875,
"max": 61.5
},
"exitSummary": "code:0x5"
}
},
{
"id": "healthJson",
"name": "health --json",
"args": ["health", "--json"],
"samples": [
{
"ms": 893.690834,
"maxRssMb": 61.4375,
"exitCode": 0,
"signal": null
},
{
"ms": 888.014083,
"maxRssMb": 60.640625,
"exitCode": 0,
"signal": null
},
{
"ms": 876.364291,
"maxRssMb": 61.328125,
"exitCode": 0,
"signal": null
},
{
"ms": 895.80775,
"maxRssMb": 60.984375,
"exitCode": 0,
"signal": null
},
{
"ms": 892.019584,
"maxRssMb": 61.296875,
"exitCode": 0,
"signal": null
}
],
"summary": {
"sampleCount": 5,
"durationMs": {
"avg": 889.1793083999999,
"p50": 892.019584,
"p95": 895.80775,
"min": 876.364291,
"max": 895.80775
},
"maxRssMb": {
"avg": 61.1375,
"p50": 61.296875,
"p95": 61.4375,
"min": 60.640625,
"max": 61.4375
},
"exitSummary": "code:0x5"
}
},
{
"id": "statusJson",
"name": "status --json",
"args": ["status", "--json"],
"samples": [
{
"ms": 10240.30475,
"maxRssMb": 61.15625,
"exitCode": 0,
"signal": null
},
{
"ms": 9992.483416,
"maxRssMb": 61.71875,
"exitCode": 0,
"signal": null
},
{
"ms": 9871.820125,
"maxRssMb": 61.703125,
"exitCode": 0,
"signal": null
},
{
"ms": 9989.689625,
"maxRssMb": 61.265625,
"exitCode": 0,
"signal": null
},
{
"ms": 10314.48525,
"maxRssMb": 61.140625,
"exitCode": 0,
"signal": null
}
],
"summary": {
"sampleCount": 5,
"durationMs": {
"avg": 10081.756633199999,
"p50": 9992.483416,
"p95": 10314.48525,
"min": 9871.820125,
"max": 10314.48525
},
"maxRssMb": {
"avg": 61.396875,
"p50": 61.265625,
"p95": 61.71875,
"min": 61.140625,
"max": 61.71875
},
"exitSummary": "code:0x5"
}
},
{
"id": "status",
"name": "status",
"args": ["status"],
"samples": [
{
"ms": 13175.219042,
"maxRssMb": 61.546875,
"exitCode": 0,
"signal": null
},
{
"ms": 13352.02925,
"maxRssMb": 61.359375,
"exitCode": 0,
"signal": null
},
{
"ms": 13328.865583,
"maxRssMb": 61.34375,
"exitCode": 0,
"signal": null
},
{
"ms": 13178.18525,
"maxRssMb": 61.609375,
"exitCode": 0,
"signal": null
},
{
"ms": 13336.7915,
"maxRssMb": 61.765625,
"exitCode": 0,
"signal": null
}
],
"summary": {
"sampleCount": 5,
"durationMs": {
"avg": 13274.218125000003,
"p50": 13328.865583,
"p95": 13352.02925,
"min": 13175.219042,
"max": 13352.02925
},
"maxRssMb": {
"avg": 61.525,
"p50": 61.546875,
"p95": 61.765625,
"min": 61.34375,
"max": 61.765625
},
"exitSummary": "code:0x5"
}
},
{
"id": "sessions",
"name": "sessions",
"args": ["sessions"],
"samples": [
{
"ms": 7118.772542,
"maxRssMb": 61.109375,
"exitCode": 0,
"signal": null
},
{
"ms": 6319.220166,
"maxRssMb": 61.515625,
"exitCode": 0,
"signal": null
},
{
"ms": 8390.35675,
"maxRssMb": 62.046875,
"exitCode": 0,
"signal": null
},
{
"ms": 6280.020209,
"maxRssMb": 61.21875,
"exitCode": 0,
"signal": null
},
{
"ms": 5861.898708,
"maxRssMb": 61.578125,
"exitCode": 0,
"signal": null
}
],
"summary": {
"sampleCount": 5,
"durationMs": {
"avg": 6794.053675,
"p50": 6319.220166,
"p95": 8390.35675,
"min": 5861.898708,
"max": 8390.35675
},
"maxRssMb": {
"avg": 61.49375,
"p50": 61.515625,
"p95": 62.046875,
"min": 61.109375,
"max": 62.046875
},
"exitSummary": "code:0x5"
}
},
{
"id": "sessionsJson",
"name": "sessions --json",
"args": ["sessions", "--json"],
"samples": [
{
"ms": 7152.919625,
"maxRssMb": 62.625,
"exitCode": 0,
"signal": null
},
{
"ms": 6019.41,
"maxRssMb": 61.40625,
"exitCode": 0,
"signal": null
},
{
"ms": 6917.366666,
"maxRssMb": 61.890625,
"exitCode": 0,
"signal": null
},
{
"ms": 5592.944167,
"maxRssMb": 61.34375,
"exitCode": 0,
"signal": null
},
{
"ms": 5591.436042,
"maxRssMb": 61.65625,
"exitCode": 0,
"signal": null
}
],
"summary": {
"sampleCount": 5,
"durationMs": {
"avg": 6254.8153,
"p50": 6019.41,
"p95": 7152.919625,
"min": 5591.436042,
"max": 7152.919625
},
"maxRssMb": {
"avg": 61.784375,
"p50": 61.65625,
"p95": 62.625,
"min": 61.34375,
"max": 62.625
},
"exitSummary": "code:0x5"
}
},
{
"id": "agentsListJson",
"name": "agents list --json",
"args": ["agents", "list", "--json"],
"samples": [
{
"ms": 974.9935,
"maxRssMb": 61.203125,
"exitCode": 0,
"signal": null
},
{
"ms": 1011.803583,
"maxRssMb": 61.375,
"exitCode": 0,
"signal": null
},
{
"ms": 1008.81175,
"maxRssMb": 61.65625,
"exitCode": 0,
"signal": null
},
{
"ms": 968.630125,
"maxRssMb": 61.40625,
"exitCode": 0,
"signal": null
},
{
"ms": 1000.931541,
"maxRssMb": 62.03125,
"exitCode": 0,
"signal": null
}
],
"summary": {
"sampleCount": 5,
"durationMs": {
"avg": 993.0340997999999,
"p50": 1000.931541,
"p95": 1011.803583,
"min": 968.630125,
"max": 1011.803583
},
"maxRssMb": {
"avg": 61.534375,
"p50": 61.40625,
"p95": 62.03125,
"min": 61.203125,
"max": 62.03125
},
"exitSummary": "code:0x5"
}
},
{
"id": "gatewayStatus",
"name": "gateway status",
"args": ["gateway", "status"],
"samples": [
{
"ms": 1075.603667,
"maxRssMb": 61.1875,
"exitCode": 0,
"signal": null
},
{
"ms": 1108.246417,
"maxRssMb": 61.15625,
"exitCode": 0,
"signal": null
},
{
"ms": 1071.274458,
"maxRssMb": 61.0625,
"exitCode": 0,
"signal": null
},
{
"ms": 1084.775291,
"maxRssMb": 61.46875,
"exitCode": 0,
"signal": null
},
{
"ms": 1088.898833,
"maxRssMb": 61.53125,
"exitCode": 0,
"signal": null
}
],
"summary": {
"sampleCount": 5,
"durationMs": {
"avg": 1085.7597332,
"p50": 1084.775291,
"p95": 1108.246417,
"min": 1071.274458,
"max": 1108.246417
},
"maxRssMb": {
"avg": 61.28125,
"p50": 61.1875,
"p95": 61.53125,
"min": 61.0625,
"max": 61.53125
},
"exitSummary": "code:0x5"
}
},
{
"id": "gatewayStatusJson",
"name": "gateway status --json",
"args": ["gateway", "status", "--json"],
"samples": [
{
"ms": 1116.443292,
"maxRssMb": 61.5,
"exitCode": 0,
"signal": null
},
{
"ms": 1076.623208,
"maxRssMb": 61.28125,
"exitCode": 0,
"signal": null
},
{
"ms": 1059.811167,
"maxRssMb": 61.78125,
"exitCode": 0,
"signal": null
},
{
"ms": 1091.463375,
"maxRssMb": 61.875,
"exitCode": 0,
"signal": null
},
{
"ms": 1080.338709,
"maxRssMb": 61.0625,
"exitCode": 0,
"signal": null
}
],
"summary": {
"sampleCount": 5,
"durationMs": {
"avg": 1084.9359502,
"p50": 1080.338709,
"p95": 1116.443292,
"min": 1059.811167,
"max": 1116.443292
},
"maxRssMb": {
"avg": 61.5,
"p50": 61.5,
"p95": 61.875,
"min": 61.0625,
"max": 61.875
},
"exitSummary": "code:0x5"
}
},
{
"id": "gatewayHealthJson",
"name": "gateway health --json",
"args": ["gateway", "health", "--json"],
"samples": [
{
"ms": 5477.276208,
"maxRssMb": 62.65625,
"exitCode": 0,
"signal": null
},
{
"ms": 5507.074708,
"maxRssMb": 61.453125,
"exitCode": 0,
"signal": null
},
{
"ms": 5496.467084,
"maxRssMb": 61.375,
"exitCode": 0,
"signal": null
},
{
"ms": 5519.668917,
"maxRssMb": 61.703125,
"exitCode": 0,
"signal": null
},
{
"ms": 5498.737875,
"maxRssMb": 62.015625,
"exitCode": 0,
"signal": null
}
],
"summary": {
"sampleCount": 5,
"durationMs": {
"avg": 5499.844958399999,
"p50": 5498.737875,
"p95": 5519.668917,
"min": 5477.276208,
"max": 5519.668917
},
"maxRssMb": {
"avg": 61.840625,
"p50": 61.703125,
"p95": 62.65625,
"min": 61.375,
"max": 62.65625
},
"exitSummary": "code:0x5"
}
},
{
"id": "configGetGatewayPort",
"name": "config get gateway.port",
"args": ["config", "get", "gateway.port"],
"samples": [
{
"ms": 614.115833,
"maxRssMb": 61.375,
"exitCode": 0,
"signal": null
},
{
"ms": 581.650375,
"maxRssMb": 61.59375,
"exitCode": 0,
"signal": null
},
{
"ms": 590.865625,
"maxRssMb": 62.421875,
"exitCode": 0,
"signal": null
},
{
"ms": 612.827458,
"maxRssMb": 61.1875,
"exitCode": 0,
"signal": null
},
{
"ms": 605.788458,
"maxRssMb": 61.21875,
"exitCode": 0,
"signal": null
}
],
"summary": {
"sampleCount": 5,
"durationMs": {
"avg": 601.0495498,
"p50": 605.788458,
"p95": 614.115833,
"min": 581.650375,
"max": 614.115833
},
"maxRssMb": {
"avg": 61.559375,
"p50": 61.375,
"p95": 62.421875,
"min": 61.1875,
"max": 62.421875
},
"exitSummary": "code:0x5"
}
}
]
},
"secondary": null
}

View file

@ -0,0 +1,82 @@
{
"cases": [
{
"id": "simple-pipeline",
"command": "echo ok | jq .foo",
"ok": true,
"executables": ["echo", "jq"]
},
{
"id": "chained-commands",
"command": "ls && rm -rf /tmp/openclaw-allowlist",
"ok": true,
"executables": ["ls", "rm"]
},
{
"id": "quoted-chain-operators-remain-literal",
"command": "echo \"a && b\"",
"ok": true,
"executables": ["echo"]
},
{
"id": "reject-command-substitution-unquoted",
"command": "echo $(whoami)",
"ok": false,
"executables": []
},
{
"id": "reject-command-substitution-double-quoted",
"command": "echo \"output: $(whoami)\"",
"ok": false,
"executables": []
},
{
"id": "allow-command-substitution-literal-in-single-quotes",
"command": "echo 'output: $(whoami)'",
"ok": true,
"executables": ["echo"]
},
{
"id": "allow-escaped-command-substitution-double-quoted",
"command": "echo \"output: \\$(whoami)\"",
"ok": true,
"executables": ["echo"]
},
{
"id": "reject-backticks-unquoted",
"command": "echo `id`",
"ok": false,
"executables": []
},
{
"id": "reject-backticks-double-quoted",
"command": "echo \"output: `id`\"",
"ok": false,
"executables": []
},
{
"id": "reject-process-substitution-unquoted-input",
"command": "cat <(echo ok)",
"ok": false,
"executables": []
},
{
"id": "reject-process-substitution-unquoted-output",
"command": "echo >(cat)",
"ok": false,
"executables": []
},
{
"id": "allow-process-substitution-literal-double-quoted-input",
"command": "echo \"<(echo ok)\"",
"ok": true,
"executables": ["echo"]
},
{
"id": "allow-process-substitution-literal-double-quoted-output",
"command": "echo \">(cat)\"",
"ok": true,
"executables": ["echo"]
}
]
}

View file

@ -0,0 +1,39 @@
{
"cases": [
{
"id": "direct-absolute-executable",
"argv": ["/usr/bin/printf", "ok"],
"expectedRawExecutable": "/usr/bin/printf"
},
{
"id": "env-assignment-prefix",
"argv": ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
"expectedRawExecutable": "/usr/bin/env"
},
{
"id": "env-option-with-separate-value",
"argv": ["/usr/bin/env", "-u", "HOME", "/usr/bin/printf", "ok"],
"expectedRawExecutable": "/usr/bin/env"
},
{
"id": "env-option-with-inline-value",
"argv": ["/usr/bin/env", "-uHOME", "/usr/bin/printf", "ok"],
"expectedRawExecutable": "/usr/bin/env"
},
{
"id": "nested-env-wrappers",
"argv": ["/usr/bin/env", "/usr/bin/env", "FOO=bar", "printf", "ok"],
"expectedRawExecutable": "/usr/bin/env"
},
{
"id": "env-shell-wrapper-stops-at-shell",
"argv": ["/usr/bin/env", "bash", "-lc", "echo ok"],
"expectedRawExecutable": "bash"
},
{
"id": "env-missing-effective-command",
"argv": ["/usr/bin/env", "FOO=bar"],
"expectedRawExecutable": "/usr/bin/env"
}
]
}

View file

@ -0,0 +1,202 @@
[
{
"file": "extensions/bluebubbles/src/runtime-api.ts",
"line": 1,
"kind": "export",
"specifier": "../../../src/plugin-sdk/bluebubbles.js",
"resolvedPath": "src/plugin-sdk/bluebubbles.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/bluebubbles/src/targets.ts",
"line": 8,
"kind": "import",
"specifier": "../../imessage/api.js",
"resolvedPath": "extensions/imessage/api.js",
"reason": "imports another extension via relative path outside the extension package"
},
{
"file": "extensions/feishu/runtime-api.ts",
"line": 4,
"kind": "export",
"specifier": "../../src/plugin-sdk/feishu.js",
"resolvedPath": "src/plugin-sdk/feishu.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/googlechat/runtime-api.ts",
"line": 4,
"kind": "export",
"specifier": "../../src/plugin-sdk/googlechat.js",
"resolvedPath": "src/plugin-sdk/googlechat.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/imessage/runtime-api.ts",
"line": 16,
"kind": "export",
"specifier": "../../src/plugin-sdk/imessage.js",
"resolvedPath": "src/plugin-sdk/imessage.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/irc/src/runtime-api.ts",
"line": 4,
"kind": "export",
"specifier": "../../../src/plugin-sdk/irc.js",
"resolvedPath": "src/plugin-sdk/irc.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/line/runtime-api.ts",
"line": 4,
"kind": "export",
"specifier": "../../src/plugin-sdk/line.js",
"resolvedPath": "src/plugin-sdk/line.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/line/runtime-api.ts",
"line": 13,
"kind": "export",
"specifier": "../../src/plugin-sdk/line-core.js",
"resolvedPath": "src/plugin-sdk/line-core.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/matrix/runtime-api.ts",
"line": 19,
"kind": "export",
"specifier": "../../src/plugin-sdk/json-store.js",
"resolvedPath": "src/plugin-sdk/json-store.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/matrix/runtime-api.ts",
"line": 28,
"kind": "export",
"specifier": "../../src/plugin-sdk/matrix.js",
"resolvedPath": "src/plugin-sdk/matrix.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/matrix/runtime-api.ts",
"line": 29,
"kind": "export",
"specifier": "../../src/plugin-sdk/matrix.js",
"resolvedPath": "src/plugin-sdk/matrix.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/matrix/src/runtime-api.ts",
"line": 1,
"kind": "export",
"specifier": "../../../src/plugin-sdk/matrix.js",
"resolvedPath": "src/plugin-sdk/matrix.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/mattermost/runtime-api.ts",
"line": 4,
"kind": "export",
"specifier": "../../src/plugin-sdk/mattermost.js",
"resolvedPath": "src/plugin-sdk/mattermost.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/msteams/runtime-api.ts",
"line": 4,
"kind": "export",
"specifier": "../../src/plugin-sdk/msteams.js",
"resolvedPath": "src/plugin-sdk/msteams.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/nextcloud-talk/runtime-api.ts",
"line": 4,
"kind": "export",
"specifier": "../../src/plugin-sdk/nextcloud-talk.js",
"resolvedPath": "src/plugin-sdk/nextcloud-talk.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/nostr/runtime-api.ts",
"line": 4,
"kind": "export",
"specifier": "../../src/plugin-sdk/nostr.js",
"resolvedPath": "src/plugin-sdk/nostr.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/telegram/runtime-api.ts",
"line": 10,
"kind": "export",
"specifier": "../../src/plugin-sdk/telegram.js",
"resolvedPath": "src/plugin-sdk/telegram.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/telegram/runtime-api.ts",
"line": 40,
"kind": "export",
"specifier": "../../src/plugin-sdk/telegram.js",
"resolvedPath": "src/plugin-sdk/telegram.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/tlon/runtime-api.ts",
"line": 4,
"kind": "export",
"specifier": "../../src/plugin-sdk/tlon.js",
"resolvedPath": "src/plugin-sdk/tlon.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/twitch/runtime-api.ts",
"line": 4,
"kind": "export",
"specifier": "../../src/plugin-sdk/twitch.js",
"resolvedPath": "src/plugin-sdk/twitch.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/voice-call/runtime-api.ts",
"line": 4,
"kind": "export",
"specifier": "../../src/plugin-sdk/voice-call.js",
"resolvedPath": "src/plugin-sdk/voice-call.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/whatsapp/src/runtime-api.ts",
"line": 19,
"kind": "export",
"specifier": "../../../src/plugin-sdk/whatsapp-core.js",
"resolvedPath": "src/plugin-sdk/whatsapp-core.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/whatsapp/src/runtime-api.ts",
"line": 34,
"kind": "export",
"specifier": "../../../src/plugin-sdk/whatsapp-shared.js",
"resolvedPath": "src/plugin-sdk/whatsapp-shared.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/zalo/runtime-api.ts",
"line": 4,
"kind": "export",
"specifier": "../../src/plugin-sdk/zalo.js",
"resolvedPath": "src/plugin-sdk/zalo.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
},
{
"file": "extensions/zalouser/runtime-api.ts",
"line": 4,
"kind": "export",
"specifier": "../../src/plugin-sdk/zalouser.js",
"resolvedPath": "src/plugin-sdk/zalouser.js",
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
}
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
[]

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,139 @@
{
"cases": [
{
"name": "binding matches when env key order changes",
"request": {
"host": "node",
"command": "git diff",
"binding": {
"argv": ["git", "diff"],
"cwd": null,
"agentId": null,
"sessionKey": null,
"env": { "SAFE_A": "1", "SAFE_B": "2" }
}
},
"invoke": {
"argv": ["git", "diff"],
"binding": {
"cwd": null,
"agentId": null,
"sessionKey": null,
"env": { "SAFE_B": "2", "SAFE_A": "1" }
}
},
"expected": { "ok": true }
},
{
"name": "binding rejects env mismatch",
"request": {
"host": "node",
"command": "git diff",
"binding": {
"argv": ["git", "diff"],
"cwd": null,
"agentId": null,
"sessionKey": null,
"env": { "SAFE": "1" }
}
},
"invoke": {
"argv": ["git", "diff"],
"binding": {
"cwd": null,
"agentId": null,
"sessionKey": null,
"env": { "SAFE": "2" }
}
},
"expected": { "ok": false, "code": "APPROVAL_ENV_MISMATCH" }
},
{
"name": "binding rejects mismatched Windows-compatible env values",
"request": {
"host": "node",
"command": "cmd.exe /c echo ok",
"binding": {
"argv": ["cmd.exe", "/c", "echo", "ok"],
"cwd": null,
"agentId": null,
"sessionKey": null,
"env": { "ProgramFiles(x86)": "C:\\Program Files (x86)" }
}
},
"invoke": {
"argv": ["cmd.exe", "/c", "echo", "ok"],
"binding": {
"cwd": null,
"agentId": null,
"sessionKey": null,
"env": { "ProgramFiles(x86)": "D:\\malicious" }
}
},
"expected": { "ok": false, "code": "APPROVAL_ENV_MISMATCH" }
},
{
"name": "binding rejects unbound env overrides",
"request": {
"host": "node",
"command": "git diff",
"binding": {
"argv": ["git", "diff"],
"cwd": null,
"agentId": null,
"sessionKey": null
}
},
"invoke": {
"argv": ["git", "diff"],
"binding": {
"cwd": null,
"agentId": null,
"sessionKey": null,
"env": { "GIT_EXTERNAL_DIFF": "/tmp/pwn.sh" }
}
},
"expected": { "ok": false, "code": "APPROVAL_ENV_BINDING_MISSING" }
},
{
"name": "missing binding rejects requests even with matching argv",
"request": {
"host": "node",
"command": "echo SAFE",
"commandArgv": ["echo", "SAFE"]
},
"invoke": {
"argv": ["echo", "SAFE"],
"binding": {
"cwd": null,
"agentId": null,
"sessionKey": null
}
},
"expected": { "ok": false, "code": "APPROVAL_REQUEST_MISMATCH" }
},
{
"name": "binding stays authoritative when legacy command text diverges",
"request": {
"host": "node",
"command": "echo STALE",
"commandArgv": ["echo", "STALE"],
"binding": {
"argv": ["echo", "SAFE"],
"cwd": null,
"agentId": null,
"sessionKey": null
}
},
"invoke": {
"argv": ["echo", "SAFE"],
"binding": {
"cwd": null,
"agentId": null,
"sessionKey": null
}
},
"expected": { "ok": true }
}
]
}

View file

@ -0,0 +1,67 @@
{
"cases": [
{
"name": "request mismatch preserves base details",
"runId": "approval-req-1",
"match": {
"ok": false,
"code": "APPROVAL_REQUEST_MISMATCH",
"message": "approval id does not match request"
},
"expected": {
"ok": false,
"message": "approval id does not match request",
"details": {
"code": "APPROVAL_REQUEST_MISMATCH",
"runId": "approval-req-1"
}
}
},
{
"name": "missing env binding keeps env key details",
"runId": "approval-env-missing",
"match": {
"ok": false,
"code": "APPROVAL_ENV_BINDING_MISSING",
"message": "approval id missing env binding for requested env overrides",
"details": {
"envKeys": ["GIT_EXTERNAL_DIFF"]
}
},
"expected": {
"ok": false,
"message": "approval id missing env binding for requested env overrides",
"details": {
"code": "APPROVAL_ENV_BINDING_MISSING",
"runId": "approval-env-missing",
"envKeys": ["GIT_EXTERNAL_DIFF"]
}
}
},
{
"name": "env mismatch preserves hash diagnostics",
"runId": "approval-env-mismatch",
"match": {
"ok": false,
"code": "APPROVAL_ENV_MISMATCH",
"message": "approval id env binding mismatch",
"details": {
"envKeys": ["SAFE_A"],
"expectedEnvHash": "expected-hash",
"actualEnvHash": "actual-hash"
}
},
"expected": {
"ok": false,
"message": "approval id env binding mismatch",
"details": {
"code": "APPROVAL_ENV_MISMATCH",
"runId": "approval-env-mismatch",
"envKeys": ["SAFE_A"],
"expectedEnvHash": "expected-hash",
"actualEnvHash": "actual-hash"
}
}
}
]
}

View file

@ -0,0 +1,84 @@
{
"cases": [
{
"name": "direct argv infers display command",
"command": ["echo", "hi there"],
"expected": {
"valid": true,
"displayCommand": "echo \"hi there\""
}
},
{
"name": "direct argv rejects mismatched raw command",
"command": ["uname", "-a"],
"rawCommand": "echo hi",
"expected": {
"valid": false,
"errorContains": "rawCommand does not match command"
}
},
{
"name": "shell wrapper accepts shell payload raw command at ingress",
"command": ["/bin/sh", "-lc", "echo hi"],
"rawCommand": "echo hi",
"expected": {
"valid": true,
"displayCommand": "/bin/sh -lc \"echo hi\""
}
},
{
"name": "shell wrapper positional argv carrier requires full argv display binding",
"command": ["/bin/sh", "-lc", "$0 \"$1\"", "/usr/bin/touch", "/tmp/marker"],
"rawCommand": "$0 \"$1\"",
"expected": {
"valid": false,
"errorContains": "rawCommand does not match command"
}
},
{
"name": "shell wrapper positional argv carrier accepts canonical full argv raw command",
"command": ["/bin/sh", "-lc", "$0 \"$1\"", "/usr/bin/touch", "/tmp/marker"],
"rawCommand": "/bin/sh -lc \"$0 \\\"$1\\\"\" /usr/bin/touch /tmp/marker",
"expected": {
"valid": true,
"displayCommand": "/bin/sh -lc \"$0 \\\"$1\\\"\" /usr/bin/touch /tmp/marker"
}
},
{
"name": "env wrapper shell payload accepted at ingress when prelude has no env modifiers",
"command": ["/usr/bin/env", "bash", "-lc", "echo hi"],
"rawCommand": "echo hi",
"expected": {
"valid": true,
"displayCommand": "/usr/bin/env bash -lc \"echo hi\""
}
},
{
"name": "env wrapper accepts canonical full argv raw command",
"command": ["/usr/bin/env", "bash", "-lc", "echo hi"],
"rawCommand": "/usr/bin/env bash -lc \"echo hi\"",
"expected": {
"valid": true,
"displayCommand": "/usr/bin/env bash -lc \"echo hi\""
}
},
{
"name": "env assignment prelude requires full argv display binding",
"command": ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"],
"rawCommand": "echo hi",
"expected": {
"valid": false,
"errorContains": "rawCommand does not match command"
}
},
{
"name": "env assignment prelude accepts canonical full argv raw command",
"command": ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"],
"rawCommand": "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo hi\"",
"expected": {
"valid": true,
"displayCommand": "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo hi\""
}
}
]
}

View file

@ -0,0 +1,13 @@
import { aliasedThing as renamedThing, sharedThing, singleOwnerHelper } from "fixture-sdk";
import type { SharedType } from "fixture-sdk";
import * as extra from "fixture-sdk/extra";
export function alphaUse(input: SharedType) {
return [
sharedThing(),
singleOwnerHelper(),
renamedThing(),
extra.sharedThing(),
input.value,
].join(":");
}

View file

@ -0,0 +1,6 @@
import { sharedThing } from "fixture-sdk";
import type { SharedType } from "fixture-sdk";
export function betaUse(input: SharedType) {
return `${sharedThing()}:${input.value}`;
}

View file

@ -0,0 +1,5 @@
import { sharedThing } from "fixture-sdk";
export function internalUse() {
return sharedThing();
}

View file

@ -0,0 +1,23 @@
export function sharedThing() {
return "shared";
}
export function singleOwnerHelper() {
return "single-owner";
}
export function aliasedThing() {
return "aliased";
}
export function testOnlyThing() {
return "test-only";
}
export function unusedThing() {
return "unused";
}
export type SharedType = {
value: string;
};

View file

@ -0,0 +1 @@
export { sharedThing } from "../lib/shared.js";

View file

@ -0,0 +1,9 @@
export {
aliasedThing,
sharedThing,
singleOwnerHelper,
testOnlyThing,
unusedThing,
} from "../lib/shared.js";
export { sharedThing as aliasedSharedThing } from "../lib/shared.js";
export type { SharedType } from "../lib/shared.js";

View file

@ -0,0 +1,5 @@
import { testOnlyThing } from "fixture-sdk";
export function testUse() {
return testOnlyThing();
}

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"paths": {
"fixture-sdk": ["./src/public/index.ts"],
"fixture-sdk/*": ["./src/public/*"]
},
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "extensions/**/*.ts", "packages/**/*.ts", "tests/**/*.ts"]
}

View file

@ -0,0 +1,125 @@
import { randomUUID } from "node:crypto";
import { afterAll, describe, expect, it } from "vitest";
import { GatewayClient } from "../src/gateway/client.js";
import { connectGatewayClient } from "../src/gateway/test-helpers.e2e.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../src/utils/message-channel.js";
import {
type ChatEventPayload,
type GatewayInstance,
connectNode,
extractFirstTextBlock,
postJson,
spawnGatewayInstance,
stopGatewayInstance,
waitForChatFinalEvent,
waitForNodeStatus,
} from "./helpers/gateway-e2e-harness.js";
const E2E_TIMEOUT_MS = 120_000;
describe("gateway multi-instance e2e", () => {
const instances: GatewayInstance[] = [];
const nodeClients: GatewayClient[] = [];
const chatClients: GatewayClient[] = [];
afterAll(async () => {
for (const client of nodeClients) {
client.stop();
}
for (const client of chatClients) {
client.stop();
}
for (const inst of instances) {
await stopGatewayInstance(inst);
}
});
it(
"spins up two gateways and exercises WS + HTTP + node pairing",
{ timeout: E2E_TIMEOUT_MS },
async () => {
const [gwA, gwB] = await Promise.all([spawnGatewayInstance("a"), spawnGatewayInstance("b")]);
instances.push(gwA, gwB);
const [hookResA, hookResB] = await Promise.all([
postJson(
`http://127.0.0.1:${gwA.port}/hooks/wake`,
{
text: "wake a",
mode: "now",
},
{ "x-openclaw-token": gwA.hookToken },
),
postJson(
`http://127.0.0.1:${gwB.port}/hooks/wake`,
{
text: "wake b",
mode: "now",
},
{ "x-openclaw-token": gwB.hookToken },
),
]);
expect(hookResA.status).toBe(200);
expect((hookResA.json as { ok?: boolean } | undefined)?.ok).toBe(true);
expect(hookResB.status).toBe(200);
expect((hookResB.json as { ok?: boolean } | undefined)?.ok).toBe(true);
const [nodeA, nodeB] = await Promise.all([
connectNode(gwA, "node-a"),
connectNode(gwB, "node-b"),
]);
nodeClients.push(nodeA.client, nodeB.client);
await Promise.all([
waitForNodeStatus(gwA, nodeA.nodeId),
waitForNodeStatus(gwB, nodeB.nodeId),
]);
},
);
it(
"delivers final chat event for telegram-shaped session keys",
{ timeout: E2E_TIMEOUT_MS },
async () => {
const gw = await spawnGatewayInstance("chat-telegram-fixture");
instances.push(gw);
const chatEvents: ChatEventPayload[] = [];
const chatClient = await connectGatewayClient({
url: `ws://127.0.0.1:${gw.port}`,
token: gw.gatewayToken,
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: "chat-e2e-cli",
clientVersion: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.CLI,
onEvent: (evt) => {
if (evt.event === "chat" && evt.payload && typeof evt.payload === "object") {
chatEvents.push(evt.payload as ChatEventPayload);
}
},
});
chatClients.push(chatClient);
const sessionKey = "agent:main:telegram:direct:123456";
const idempotencyKey = `idem-${randomUUID()}`;
const sendRes = await chatClient.request("chat.send", {
sessionKey,
message: "/context list",
idempotencyKey,
});
expect(sendRes.status).toBe("started");
const runId = sendRes.runId;
expect(typeof runId).toBe("string");
const finalEvent = await waitForChatFinalEvent({
events: chatEvents,
runId: String(runId),
sessionKey,
});
const finalText = extractFirstTextBlock(finalEvent.message);
expect(typeof finalText).toBe("string");
expect(finalText?.length).toBeGreaterThan(0);
},
);
});

View file

@ -0,0 +1,111 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { cleanupTempDirs, makeTempRepoRoot } from "./helpers/temp-repo.js";
const baseGitEnv = {
GIT_CONFIG_NOSYSTEM: "1",
GIT_TERMINAL_PROMPT: "0",
};
const baseRunEnv: NodeJS.ProcessEnv = { ...process.env, ...baseGitEnv };
const tempDirs: string[] = [];
const run = (cwd: string, cmd: string, args: string[] = [], env?: NodeJS.ProcessEnv) => {
return execFileSync(cmd, args, {
cwd,
encoding: "utf8",
env: env ? { ...baseRunEnv, ...env } : baseRunEnv,
}).trim();
};
function writeExecutable(dir: string, name: string, contents: string): void {
writeFileSync(path.join(dir, name), contents, {
encoding: "utf8",
mode: 0o755,
});
}
function installPreCommitFixture(dir: string): string {
mkdirSync(path.join(dir, "git-hooks"), { recursive: true });
mkdirSync(path.join(dir, "scripts", "pre-commit"), { recursive: true });
symlinkSync(
path.join(process.cwd(), "git-hooks", "pre-commit"),
path.join(dir, "git-hooks", "pre-commit"),
);
writeFileSync(
path.join(dir, "scripts", "pre-commit", "run-node-tool.sh"),
"#!/usr/bin/env bash\nexit 0\n",
{
encoding: "utf8",
mode: 0o755,
},
);
writeFileSync(
path.join(dir, "scripts", "pre-commit", "filter-staged-files.mjs"),
"process.exit(0);\n",
"utf8",
);
const fakeBinDir = path.join(dir, "bin");
mkdirSync(fakeBinDir, { recursive: true });
writeExecutable(fakeBinDir, "node", "#!/usr/bin/env bash\nexit 0\n");
return fakeBinDir;
}
afterEach(() => {
cleanupTempDirs(tempDirs);
});
describe("git-hooks/pre-commit (integration)", () => {
it("does not treat staged filenames as git-add flags (e.g. --all)", () => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-");
run(dir, "git", ["init", "-q", "--initial-branch=main"]);
// Use the real hook script and lightweight helper stubs.
const fakeBinDir = installPreCommitFixture(dir);
// The hook ends with `pnpm check`, but this fixture is only exercising staged-file handling.
// Stub pnpm too so Windows CI does not invoke a real package-manager command in the temp repo.
writeExecutable(fakeBinDir, "pnpm", "#!/usr/bin/env bash\nexit 0\n");
// Create an untracked file that should NOT be staged by the hook.
writeFileSync(path.join(dir, "secret.txt"), "do-not-stage\n", "utf8");
// Stage a maliciously-named file. Older hooks using `xargs git add` could run `git add --all`.
writeFileSync(path.join(dir, "--all"), "flag\n", "utf8");
run(dir, "git", ["add", "--", "--all"]);
// Run the hook directly (same logic as when installed via core.hooksPath).
run(dir, "bash", ["git-hooks/pre-commit"], {
PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`,
});
const staged = run(dir, "git", ["diff", "--cached", "--name-only"]).split("\n").filter(Boolean);
expect(staged).toEqual(["--all"]);
});
it("skips pnpm check when FAST_COMMIT is enabled", () => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-yolo-");
run(dir, "git", ["init", "-q", "--initial-branch=main"]);
const fakeBinDir = installPreCommitFixture(dir);
writeFileSync(path.join(dir, "package.json"), '{"name":"tmp"}\n', "utf8");
writeFileSync(path.join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8");
writeExecutable(
fakeBinDir,
"pnpm",
"#!/usr/bin/env bash\necho 'pnpm should not run when FAST_COMMIT is enabled' >&2\nexit 99\n",
);
writeFileSync(path.join(dir, "tracked.txt"), "hello\n", "utf8");
run(dir, "git", ["add", "--", "tracked.txt"]);
const output = run(dir, "bash", ["git-hooks/pre-commit"], {
PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`,
FAST_COMMIT: "1",
});
expect(output).toContain("FAST_COMMIT enabled: skipping pnpm check in pre-commit hook.");
});
});

View file

@ -0,0 +1,6 @@
import { installTestEnv } from "./test-env";
export default async () => {
const { cleanup } = installTestEnv();
return () => cleanup();
};

View file

@ -0,0 +1,31 @@
# Shared Test Helper Boundary
This directory holds shared test helpers reused by core and bundled plugin
tests.
## Bundled Plugin Imports
- Shared helpers in this tree must not hardcode repo-relative imports into
`extensions/**`.
- When a helper needs a bundled plugin public surface, go through
`src/test-utils/bundled-plugin-public-surface.ts`.
- Prefer `loadBundledPluginApiSync(...)`,
`loadBundledPluginRuntimeApiSync(...)`,
`loadBundledPluginContractApiSync(...)`, and
`loadBundledPluginTestApiSync(...)` for eager access to exported surfaces.
- Prefer `resolveRelativeBundledPluginPublicModuleId(...)` or
`resolveBundledPluginPublicModulePath(...)` when a helper needs a module id
or filesystem path for dynamic import, mocking, or loading a plugin entrypoint
such as `index.js`.
- If `vi.hoisted(...)` is involved, do not call imported helper functions from
inside the hoisted callback. Resolve the module id outside the callback or
switch to `vi.doMock(...)`.
- Do not keep plugin-local deep mocks or private `src/**` knowledge in shared
helpers. Move those helpers into the owning bundled plugin package instead.
## Intent
- Keep shared helpers aligned with the same public/plugin boundary that
production code uses.
- Avoid shared helper debt that makes core test lanes depend on bundled plugin
private layout.

View file

@ -0,0 +1 @@
AGENTS.md

View file

@ -0,0 +1,19 @@
import { vi } from "vitest";
type PiAiMockModule = Record<string, unknown>;
export async function createPiAiStreamSimpleMock(
loadActual: () => Promise<PiAiMockModule>,
): Promise<PiAiMockModule> {
const original = await loadActual();
return {
...original,
streamSimple: vi.fn(() => ({
push: vi.fn(),
result: vi.fn(async () => undefined),
[Symbol.asyncIterator]: vi.fn(async function* () {
// Minimal async stream shape for wrappers that patch iteration/result.
}),
})),
};
}

View file

@ -0,0 +1,689 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
appendBootstrapPromptWarning,
analyzeBootstrapBudget,
buildBootstrapInjectionStats,
buildBootstrapPromptWarning,
} from "../../../src/agents/bootstrap-budget.js";
import { resolveBootstrapContextForRun } from "../../../src/agents/bootstrap-files.js";
import { buildEmbeddedSystemPrompt } from "../../../src/agents/pi-embedded-runner/system-prompt.js";
import { buildAgentSystemPrompt } from "../../../src/agents/system-prompt.js";
import { createStubTool } from "../../../src/agents/test-helpers/pi-tool-stubs.js";
import { buildGroupChatContext, buildGroupIntro } from "../../../src/auto-reply/reply/groups.js";
import {
buildInboundMetaSystemPrompt,
buildInboundUserContextPrefix,
} from "../../../src/auto-reply/reply/inbound-meta.js";
import type { TemplateContext } from "../../../src/auto-reply/templating.js";
import { SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { makeTempWorkspace, writeWorkspaceFile } from "../../../src/test-helpers/workspace.js";
export type PromptScenarioTurn = {
id: string;
label: string;
systemPrompt: string;
bodyPrompt: string;
notes: string[];
};
export type PromptScenario = {
scenario: string;
focus: string;
expectedStableSystemAfterTurnIds: string[];
turns: PromptScenarioTurn[];
};
function buildCommonSystemParams(workspaceDir: string) {
const toolNames = [
"bash",
"read",
"edit",
"grep",
"glob",
"message",
"memory_search",
"memory_get",
"web_search",
"x_search",
"web_fetch",
];
return {
runtimeInfo: {
agentId: "main",
host: "cache-lab",
repoRoot: workspaceDir,
os: "Darwin 24.0.0",
arch: "arm64",
node: process.version,
model: "anthropic/claude-sonnet-4-5",
defaultModel: "anthropic/claude-sonnet-4-5",
shell: "zsh",
},
userTimezone: "America/Los_Angeles",
userTime: "Monday, March 16th, 2026 - 9:00 PM",
userTimeFormat: "12" as const,
toolNames,
};
}
function buildSystemPrompt(params: {
workspaceDir: string;
extraSystemPrompt?: string;
skillsPrompt?: string;
reactionGuidance?: { level: "minimal" | "extensive"; channel: string };
contextFiles?: Array<{ path: string; content: string }>;
}) {
const { runtimeInfo, userTimezone, userTime, userTimeFormat, toolNames } =
buildCommonSystemParams(params.workspaceDir);
return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir,
extraSystemPrompt: params.extraSystemPrompt,
runtimeInfo,
userTimezone,
userTime,
userTimeFormat,
toolNames,
modelAliasLines: [],
promptMode: "full",
acpEnabled: true,
skillsPrompt: params.skillsPrompt,
reactionGuidance: params.reactionGuidance,
contextFiles: params.contextFiles,
});
}
function buildAutoReplyBody(params: { ctx: TemplateContext; body: string; eventLine?: string }) {
return [params.eventLine, buildInboundUserContextPrefix(params.ctx), params.body]
.filter(Boolean)
.join("\n\n");
}
async function readContextFiles(workspaceDir: string, fileNames: string[]) {
return Promise.all(
fileNames.map(async (fileName) => ({
path: fileName,
content: await fs.readFile(path.join(workspaceDir, fileName), "utf-8"),
})),
);
}
function buildAutoReplySystemPrompt(params: {
workspaceDir: string;
sessionCtx: TemplateContext;
includeGroupChatContext?: boolean;
includeGroupIntro?: boolean;
groupSystemPrompt?: string;
}) {
const extraSystemPromptParts = [
buildInboundMetaSystemPrompt(params.sessionCtx),
params.includeGroupChatContext ? buildGroupChatContext({ sessionCtx: params.sessionCtx }) : "",
params.includeGroupIntro
? buildGroupIntro({
cfg: {} as OpenClawConfig,
sessionCtx: params.sessionCtx,
defaultActivation: "mention",
silentToken: SILENT_REPLY_TOKEN,
})
: "",
params.groupSystemPrompt?.trim() ?? "",
].filter(Boolean);
return buildSystemPrompt({
workspaceDir: params.workspaceDir,
extraSystemPrompt: extraSystemPromptParts.join("\n\n") || undefined,
});
}
function buildToolRichSystemPrompt(params: {
workspaceDir: string;
skillsPrompt: string;
contextFiles: Array<{ path: string; content: string }>;
}) {
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildCommonSystemParams(
params.workspaceDir,
);
const tools = [
"bash",
"read",
"edit",
"grep",
"glob",
"message",
"memory_search",
"memory_get",
"web_search",
"x_search",
"web_fetch",
].map((name) => Object.assign({}, createStubTool(name), { description: `${name} tool` }));
return buildEmbeddedSystemPrompt({
workspaceDir: params.workspaceDir,
reasoningTagHint: false,
runtimeInfo,
tools,
modelAliasLines: [],
userTimezone,
userTime,
userTimeFormat,
acpEnabled: true,
skillsPrompt: params.skillsPrompt,
reactionGuidance: { level: "extensive", channel: "Telegram" },
contextFiles: params.contextFiles,
});
}
function createDirectScenario(workspaceDir: string): PromptScenario {
const baseCtx: TemplateContext = {
Provider: "slack",
Surface: "slack",
OriginatingChannel: "slack",
OriginatingTo: "D123",
AccountId: "A1",
ChatType: "direct",
SenderId: "U1",
SenderName: "Alice",
Body: "hi",
BodyStripped: "hi",
};
return {
scenario: "auto-reply-direct",
focus:
"Normal direct-chat turns with ids, reply context, think hint, and runtime event body injection",
expectedStableSystemAfterTurnIds: ["t2", "t3", "t4"],
turns: [
{
id: "t1",
label: "Direct turn with reply context",
systemPrompt: buildAutoReplySystemPrompt({
workspaceDir,
sessionCtx: {
...baseCtx,
MessageSid: "m1",
ReplyToId: "r1",
ReplyToBody: "prior message",
WasMentioned: true,
},
}),
bodyPrompt: buildAutoReplyBody({
ctx: {
...baseCtx,
MessageSid: "m1",
ReplyToId: "r1",
ReplyToBody: "prior message",
WasMentioned: true,
},
body: "Please summarize yesterday's decision.",
}),
notes: ["Direct chat baseline", "Per-message ids and reply context change in body only"],
},
{
id: "t2",
label: "Direct turn with new message id",
systemPrompt: buildAutoReplySystemPrompt({
workspaceDir,
sessionCtx: {
...baseCtx,
MessageSid: "m2",
ReplyToId: "r2",
},
}),
bodyPrompt: buildAutoReplyBody({
ctx: {
...baseCtx,
MessageSid: "m2",
ReplyToId: "r2",
},
body: "Now open the read tool and inspect AGENTS.md.",
}),
notes: ["Steady-state direct turn", "No runtime event"],
},
{
id: "t3",
label: "Direct turn with runtime event and think hint",
systemPrompt: buildAutoReplySystemPrompt({
workspaceDir,
sessionCtx: {
...baseCtx,
MessageSid: "m3",
ReplyToId: "r3",
},
}),
bodyPrompt: buildAutoReplyBody({
ctx: {
...baseCtx,
MessageSid: "m3",
ReplyToId: "r3",
},
eventLine: "System: [t] Model switched.",
body: "low use tools if needed and tell me which file controls startup behavior",
}),
notes: ["Touches runtime event body path", "Touches think-hint parsing path"],
},
{
id: "t4",
label: "Direct turn after runtime event",
systemPrompt: buildAutoReplySystemPrompt({
workspaceDir,
sessionCtx: {
...baseCtx,
MessageSid: "m4",
ReplyToId: "r4",
},
}),
bodyPrompt: buildAutoReplyBody({
ctx: {
...baseCtx,
MessageSid: "m4",
ReplyToId: "r4",
},
body: "Repeat the startup file path only.",
}),
notes: ["Checks steady-state after event turn"],
},
],
};
}
function createGroupScenario(workspaceDir: string): PromptScenario {
const baseCtx: TemplateContext = {
Provider: "slack",
Surface: "slack",
OriginatingChannel: "slack",
OriginatingTo: "C123",
AccountId: "A1",
ChatType: "group",
GroupSubject: "ops",
GroupChannel: "#ops",
GroupMembers: "Bob, Cara, Dan, Eve",
SenderId: "U2",
SenderName: "Bob",
Body: "hi",
BodyStripped: "hi",
};
return {
scenario: "auto-reply-group",
focus: "Group chat bootstrap, steady state, and runtime event turns",
expectedStableSystemAfterTurnIds: ["t3"],
turns: [
{
id: "t1",
label: "First group turn with one-time intro",
systemPrompt: buildAutoReplySystemPrompt({
workspaceDir,
sessionCtx: {
...baseCtx,
MessageSid: "g1",
WasMentioned: true,
InboundHistory: [{ sender: "Cara", timestamp: 1, body: "status?" }],
},
includeGroupChatContext: true,
includeGroupIntro: true,
}),
bodyPrompt: buildAutoReplyBody({
ctx: {
...baseCtx,
MessageSid: "g1",
WasMentioned: true,
InboundHistory: [{ sender: "Cara", timestamp: 1, body: "status?" }],
},
body: "Can you investigate this issue?",
}),
notes: ["Expected first-turn bootstrap churn", "Not steady-state"],
},
{
id: "t2",
label: "Steady-state group turn",
systemPrompt: buildAutoReplySystemPrompt({
workspaceDir,
sessionCtx: {
...baseCtx,
MessageSid: "g2",
WasMentioned: false,
InboundHistory: [
{ sender: "Cara", timestamp: 1, body: "status?" },
{ sender: "Dan", timestamp: 2, body: "please help" },
],
},
includeGroupChatContext: true,
}),
bodyPrompt: buildAutoReplyBody({
ctx: {
...baseCtx,
MessageSid: "g2",
WasMentioned: false,
InboundHistory: [
{ sender: "Cara", timestamp: 1, body: "status?" },
{ sender: "Dan", timestamp: 2, body: "please help" },
],
},
body: "Give a short update.",
}),
notes: ["One-time intro gone", "Should settle afterward"],
},
{
id: "t3",
label: "Group turn with runtime event",
systemPrompt: buildAutoReplySystemPrompt({
workspaceDir,
sessionCtx: {
...baseCtx,
MessageSid: "g2",
WasMentioned: false,
InboundHistory: [
{ sender: "Cara", timestamp: 1, body: "status?" },
{ sender: "Dan", timestamp: 2, body: "please help" },
],
},
includeGroupChatContext: true,
}),
bodyPrompt: buildAutoReplyBody({
ctx: {
...baseCtx,
MessageSid: "g3",
WasMentioned: true,
InboundHistory: [
{ sender: "Cara", timestamp: 1, body: "status?" },
{ sender: "Dan", timestamp: 2, body: "please help" },
{ sender: "Eve", timestamp: 3, body: "what changed?" },
],
},
eventLine: "System: [t] Node connected.",
body: "Tell the room whether tools are available.",
}),
notes: ["Runtime event lands in body", "System prompt should stay stable vs t2"],
},
],
};
}
async function createToolRichScenario(workspaceDir: string): Promise<PromptScenario> {
const skillsPrompt = [
"<available_skills>",
"<skill><name>checks</name><description>Run checks before landing changes.</description><location>/skills/checks/SKILL.md</location></skill>",
"<skill><name>release</name><description>Release OpenClaw safely.</description><location>/skills/release/SKILL.md</location></skill>",
"</available_skills>",
].join("\n");
const contextFiles = await readContextFiles(workspaceDir, ["AGENTS.md", "TOOLS.md", "SOUL.md"]);
const systemPrompt = buildToolRichSystemPrompt({
workspaceDir,
skillsPrompt,
contextFiles,
});
return {
scenario: "tool-rich-agent-run",
focus:
"Tool-enabled system prompt with skills, reactions, workspace bootstrap, and a follow-up after fictional tool calls",
expectedStableSystemAfterTurnIds: ["t2"],
turns: [
{
id: "t1",
label: "Tool-rich turn asking for search, read, and file edits",
systemPrompt,
bodyPrompt: [
"Conversation info (untrusted metadata):",
"```json",
JSON.stringify({ message_id: "tool-1", sender_id: "U9", was_mentioned: true }, null, 2),
"```",
"",
"high Search the workspace, read AGENTS.md, inspect the failing test, and propose a patch.",
].join("\n"),
notes: ["Touches tool list in system prompt", "Touches high-thinking hint in body"],
},
{
id: "t2",
label: "Follow-up after a fictional tool call",
systemPrompt,
bodyPrompt: [
"Conversation info (untrusted metadata):",
"```json",
JSON.stringify({ message_id: "tool-2", sender_id: "U9" }, null, 2),
"```",
"",
"Tool transcript summary (untrusted, for context):",
"```json",
JSON.stringify(
[
{ role: "assistant", action: "tool_use", name: "read", target: "AGENTS.md" },
{ role: "tool", name: "read", result: "Loaded AGENTS.md" },
{ role: "assistant", action: "tool_use", name: "grep", target: "failing test" },
{ role: "tool", name: "grep", result: "Matched src/foo.ts:42" },
],
null,
2,
),
"```",
"",
"Continue and explain the root cause.",
].join("\n"),
notes: ["Simulates tool-call-heavy conversation", "System prompt should stay stable"],
},
],
};
}
async function createBootstrapWarningScenario(workspaceDir: string): Promise<PromptScenario> {
const bootstrapConfig = {
agents: {
defaults: {
bootstrapMaxChars: 1_500,
bootstrapTotalMaxChars: 2_200,
},
},
} satisfies OpenClawConfig;
const largeAgents = "# AGENTS.md\n\n" + "Rules.\n".repeat(5_000);
const largeTools = "# TOOLS.md\n\n" + "Notes.\n".repeat(3_000);
await writeWorkspaceFile({ dir: workspaceDir, name: "AGENTS.md", content: largeAgents });
await writeWorkspaceFile({ dir: workspaceDir, name: "TOOLS.md", content: largeTools });
const { bootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({
workspaceDir,
config: bootstrapConfig,
});
const analysis = analyzeBootstrapBudget({
files: buildBootstrapInjectionStats({
bootstrapFiles,
injectedFiles: contextFiles,
}),
bootstrapMaxChars: bootstrapConfig.agents.defaults.bootstrapMaxChars,
bootstrapTotalMaxChars: bootstrapConfig.agents.defaults.bootstrapTotalMaxChars,
});
if (!analysis.hasTruncation) {
throw new Error("bootstrap-warning scenario expected truncated bootstrap context");
}
const warningFirst = buildBootstrapPromptWarning({
analysis,
mode: "once",
seenSignatures: [],
});
const warningSeen = buildBootstrapPromptWarning({
analysis,
mode: "once",
seenSignatures: warningFirst.warningSignaturesSeen,
previousSignature: warningFirst.signature,
});
const warningAlways = buildBootstrapPromptWarning({
analysis,
mode: "always",
seenSignatures: warningFirst.warningSignaturesSeen,
previousSignature: warningFirst.signature,
});
return {
scenario: "bootstrap-warning",
focus: "Workspace bootstrap truncation warnings inside # Project Context",
expectedStableSystemAfterTurnIds: ["t2", "t3"],
turns: [
{
id: "t1",
label: "First warning emission",
systemPrompt: buildSystemPrompt({
workspaceDir,
contextFiles,
}),
bodyPrompt: appendBootstrapPromptWarning("hello", warningFirst.lines),
notes: ["Warning is appended to the turn body", "System prompt should stay stable"],
},
{
id: "t2",
label: "Same truncation signature after once-mode dedupe",
systemPrompt: buildSystemPrompt({
workspaceDir,
contextFiles,
}),
bodyPrompt: appendBootstrapPromptWarning("hello again", warningSeen.lines),
notes: ["Once-mode removes warning lines", "Only the body tail changes now"],
},
{
id: "t3",
label: "Always-mode warning",
systemPrompt: buildSystemPrompt({
workspaceDir,
contextFiles,
}),
bodyPrompt: appendBootstrapPromptWarning("one more turn", warningAlways.lines),
notes: [
"Always-mode keeps warning in the body prompt tail",
"System prompt remains stable",
],
},
],
};
}
async function createMaintenanceScenario(workspaceDir: string): Promise<PromptScenario> {
await writeWorkspaceFile({
dir: workspaceDir,
name: "AGENTS.md",
content: [
"## Session Startup",
"Read AGENTS.md and MEMORY.md before responding.",
"",
"## Red Lines",
"Do not delete production data.",
"",
"## Safety",
"Never reveal secrets.",
].join("\n"),
});
const memoryFlushPrompt = [
"Pre-compaction memory flush.",
"Store durable memories only in memory/2026-03-15.md (create memory/ if needed).",
"Treat workspace bootstrap/reference files such as MEMORY.md, SOUL.md, TOOLS.md, and AGENTS.md as read-only during this flush; never overwrite, replace, or edit them.",
"If nothing to store, reply with NO_REPLY.",
"Current time: Sunday, March 15th, 2026 - 9:30 PM (America/Los_Angeles) / 2026-03-16 04:30 UTC",
].join("\n");
const memoryFlushSystemPrompt = buildSystemPrompt({
workspaceDir,
extraSystemPrompt: [
"Pre-compaction memory flush turn.",
"The session is near auto-compaction; capture durable memories to disk.",
"Store durable memories only in memory/YYYY-MM-DD.md (create memory/ if needed).",
"You may reply, but usually NO_REPLY is correct.",
].join(" "),
});
const postCompaction = [
"[Post-compaction context refresh]",
"",
"Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence.",
"",
"Critical rules from AGENTS.md:",
"",
"## Session Startup",
"Read AGENTS.md and MEMORY.md before responding.",
"",
"## Red Lines",
"Do not delete production data.",
"",
"Current time: Sunday, March 15th, 2026 - 9:30 PM (America/Los_Angeles) / 2026-03-16 04:30 UTC",
].join("\n");
const postCompactionSystemPrompt = buildSystemPrompt({
workspaceDir,
extraSystemPrompt: buildInboundMetaSystemPrompt({
Provider: "slack",
Surface: "slack",
OriginatingChannel: "slack",
OriginatingTo: "D123",
AccountId: "A1",
ChatType: "direct",
}),
});
return {
scenario: "maintenance-prompts",
focus: "Memory flush and post-compaction maintenance prompts",
expectedStableSystemAfterTurnIds: [],
turns: [
{
id: "t1",
label: "Pre-compaction memory flush run",
systemPrompt: memoryFlushSystemPrompt,
bodyPrompt: memoryFlushPrompt,
notes: [
"Writes to memory/2026-03-15.md",
"Separate maintenance run; expected to differ from normal user turns",
],
},
{
id: "t2",
label: "Post-compaction refresh context run",
systemPrompt: postCompactionSystemPrompt,
bodyPrompt: postCompaction,
notes: [
"Separate maintenance context payload",
"Expected to differ from normal user turns",
],
},
],
};
}
export async function createWorkspaceWithPromptCompositionFiles(): Promise<string> {
const workspaceDir = await makeTempWorkspace("openclaw-prompt-cache-");
await writeWorkspaceFile({
dir: workspaceDir,
name: "AGENTS.md",
content: [
"# AGENTS.md",
"",
"## Session Startup",
"Read AGENTS.md and TOOLS.md before making changes.",
"",
"## Red Lines",
"Do not rewrite user commits.",
].join("\n"),
});
await writeWorkspaceFile({
dir: workspaceDir,
name: "TOOLS.md",
content: "# TOOLS.md\n\nUse rg before grep.\n",
});
await writeWorkspaceFile({
dir: workspaceDir,
name: "SOUL.md",
content: "# SOUL.md\n\nBe concise but kind.\n",
});
return workspaceDir;
}
export async function createPromptCompositionScenarios(): Promise<{
workspaceDir: string;
warningWorkspaceDir: string;
scenarios: PromptScenario[];
cleanup: () => Promise<void>;
}> {
const workspaceDir = await createWorkspaceWithPromptCompositionFiles();
const warningWorkspaceDir = await makeTempWorkspace("openclaw-prompt-cache-warning-");
const scenarios = [
createDirectScenario(workspaceDir),
createGroupScenario(workspaceDir),
await createToolRichScenario(workspaceDir),
await createBootstrapWarningScenario(warningWorkspaceDir),
await createMaintenanceScenario(workspaceDir),
];
return {
workspaceDir,
warningWorkspaceDir,
scenarios,
cleanup: async () => {
await fs.rm(workspaceDir, { recursive: true, force: true });
await fs.rm(warningWorkspaceDir, { recursive: true, force: true });
},
};
}

View file

@ -0,0 +1,92 @@
import fs from "node:fs/promises";
import path from "node:path";
import { vi } from "vitest";
import type { RuntimeEnv } from "../../src/runtime.js";
import { makeTempWorkspace } from "../../src/test-helpers/workspace.js";
import { captureEnv } from "../../src/test-utils/env.js";
import type { WizardPrompter } from "../../src/wizard/prompts.js";
export const noopAsync = async () => {};
export const noop = () => {};
export function createExitThrowingRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
}
export function createWizardPrompter(
overrides: Partial<WizardPrompter>,
options?: { defaultSelect?: string },
): WizardPrompter {
return {
intro: vi.fn(noopAsync),
outro: vi.fn(noopAsync),
note: vi.fn(noopAsync),
select: vi.fn(async () => (options?.defaultSelect ?? "") as never),
multiselect: vi.fn(async () => []),
text: vi.fn(async () => "") as unknown as WizardPrompter["text"],
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: noop, stop: noop })),
...overrides,
};
}
export async function setupAuthTestEnv(
prefix = "openclaw-auth-",
options?: { agentSubdir?: string },
): Promise<{
stateDir: string;
agentDir: string;
}> {
const stateDir = await makeTempWorkspace(prefix);
const agentDir = path.join(stateDir, options?.agentSubdir ?? "agent");
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_AGENT_DIR = agentDir;
process.env.PI_CODING_AGENT_DIR = agentDir;
await fs.mkdir(agentDir, { recursive: true });
return { stateDir, agentDir };
}
export type AuthTestLifecycle = {
setStateDir: (stateDir: string) => void;
cleanup: () => Promise<void>;
};
export function createAuthTestLifecycle(envKeys: string[]): AuthTestLifecycle {
const envSnapshot = captureEnv(envKeys);
let stateDir: string | null = null;
return {
setStateDir(nextStateDir: string) {
stateDir = nextStateDir;
},
async cleanup() {
if (stateDir) {
await fs.rm(stateDir, { recursive: true, force: true });
stateDir = null;
}
envSnapshot.restore();
},
};
}
export function requireOpenClawAgentDir(): string {
const agentDir = process.env.OPENCLAW_AGENT_DIR;
if (!agentDir) {
throw new Error("OPENCLAW_AGENT_DIR not set");
}
return agentDir;
}
export function authProfilePathForAgent(agentDir: string): string {
return path.join(agentDir, "auth-profiles.json");
}
export async function readAuthProfilesForAgent<T>(agentDir: string): Promise<T> {
const raw = await fs.readFile(authProfilePathForAgent(agentDir), "utf8");
return JSON.parse(raw) as T;
}

View file

@ -0,0 +1,483 @@
import { rmSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, beforeAll, expect, vi } from "vitest";
import { clearRuntimeAuthProfileStoreSnapshots } from "../../../src/agents/auth-profiles.js";
import { withFastReplyConfig } from "../../../src/auto-reply/reply/get-reply-fast-path.js";
import type { OpenClawConfig } from "../../../src/config/types.openclaw.js";
import { resetProviderRuntimeHookCacheForTest } from "../../../src/plugins/provider-runtime.js";
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
type AnyMock = any;
type AnyMocks = Record<string, any>;
function getSharedMocks<T>(key: string, create: () => T): T {
const symbol = Symbol.for(key);
const store = globalThis as Record<symbol, T | undefined>;
if (!store[symbol]) {
store[symbol] = create();
}
return store[symbol];
}
const piEmbeddedMocks = getSharedMocks("openclaw.trigger-handling.pi-embedded-mocks", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveActiveEmbeddedRunSessionId: vi.fn().mockReturnValue(undefined),
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
export function getAbortEmbeddedPiRunMock(): AnyMock {
return piEmbeddedMocks.abortEmbeddedPiRun;
}
export function getCompactEmbeddedPiSessionMock(): AnyMock {
return piEmbeddedMocks.compactEmbeddedPiSession;
}
export function getRunEmbeddedPiAgentMock(): AnyMock {
return piEmbeddedMocks.runEmbeddedPiAgent;
}
export function getQueueEmbeddedPiMessageMock(): AnyMock {
return piEmbeddedMocks.queueEmbeddedPiMessage;
}
const installPiEmbeddedMock = () =>
vi.doMock("../../../src/agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: (...args: unknown[]) => piEmbeddedMocks.abortEmbeddedPiRun(...args),
compactEmbeddedPiSession: (...args: unknown[]) =>
piEmbeddedMocks.compactEmbeddedPiSession(...args),
runEmbeddedPiAgent: (...args: unknown[]) => piEmbeddedMocks.runEmbeddedPiAgent(...args),
queueEmbeddedPiMessage: (...args: unknown[]) => piEmbeddedMocks.queueEmbeddedPiMessage(...args),
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
resolveActiveEmbeddedRunSessionId: (...args: unknown[]) =>
piEmbeddedMocks.resolveActiveEmbeddedRunSessionId(...args),
isEmbeddedPiRunActive: (...args: unknown[]) => piEmbeddedMocks.isEmbeddedPiRunActive(...args),
isEmbeddedPiRunStreaming: (...args: unknown[]) =>
piEmbeddedMocks.isEmbeddedPiRunStreaming(...args),
}));
installPiEmbeddedMock();
vi.doMock("../../../src/agents/pi-embedded-runner/runs.js", () => ({
abortEmbeddedPiRun: (...args: unknown[]) => piEmbeddedMocks.abortEmbeddedPiRun(...args),
}));
const providerUsageMocks = vi.hoisted(() => ({
loadProviderUsageSummary: vi.fn().mockResolvedValue({
updatedAt: 0,
providers: [],
}),
formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"),
formatUsageWindowSummary: vi.fn().mockReturnValue("Claude 80% left"),
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
}));
export function getProviderUsageMocks(): AnyMocks {
return providerUsageMocks;
}
vi.mock("../../../src/infra/provider-usage.js", () => providerUsageMocks);
const modelCatalogMocks = getSharedMocks("openclaw.trigger-handling.model-catalog-mocks", () => ({
loadModelCatalog: vi.fn().mockResolvedValue([
{
provider: "anthropic",
id: "claude-opus-4-6",
name: "Claude Opus 4.5",
contextWindow: 200000,
},
{
provider: "openrouter",
id: "anthropic/claude-opus-4-6",
name: "Claude Opus 4.5 (OpenRouter)",
contextWindow: 200000,
},
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" },
{ provider: "openai", id: "gpt-5.4", name: "GPT-5.2" },
{ provider: "openai-codex", id: "gpt-5.4", name: "GPT-5.2 (Codex)" },
{ provider: "minimax", id: "MiniMax-M2.7", name: "MiniMax M2.7" },
]),
resetModelCatalogCacheForTest: vi.fn(),
}));
export function getModelCatalogMocks(): AnyMocks {
return modelCatalogMocks;
}
const installModelCatalogMock = () =>
vi.doMock("../../../src/agents/model-catalog.js", () => modelCatalogMocks);
installModelCatalogMock();
vi.doMock("../../../src/agents/model-catalog.runtime.js", () => ({
loadModelCatalog: (...args: unknown[]) => modelCatalogMocks.loadModelCatalog(...args),
}));
vi.doMock("../../../src/plugins/provider-runtime.runtime.js", () => ({
augmentModelCatalogWithProviderPlugins: async (params: { catalog?: unknown[] }) =>
params.catalog ?? [],
buildProviderAuthDoctorHintWithPlugin: () => undefined,
buildProviderMissingAuthMessageWithPlugin: () => undefined,
formatProviderAuthProfileApiKeyWithPlugin: (params: { apiKey?: string }) => params.apiKey,
prepareProviderRuntimeAuth: async () => undefined,
refreshProviderOAuthCredentialWithPlugin: async () => undefined,
}));
const modelFallbackMocks = getSharedMocks("openclaw.trigger-handling.model-fallback-mocks", () => ({
runWithModelFallback: vi.fn(
async (params: {
provider: string;
model: string;
run: (provider: string, model: string, runOptions?: unknown) => Promise<unknown>;
}) => ({
result: await params.run(params.provider, params.model),
provider: params.provider,
model: params.model,
attempts: [],
}),
),
}));
export function getModelFallbackMocks(): AnyMocks {
return modelFallbackMocks;
}
const installModelFallbackMock = () =>
vi.doMock("../../../src/agents/model-fallback.js", () => modelFallbackMocks);
installModelFallbackMock();
vi.doMock("../../../src/infra/git-commit.js", () => ({
resolveCommitHash: vi.fn(() => "abcdef0"),
}));
const webSessionMocks = getSharedMocks("openclaw.trigger-handling.web-session-mocks", () => ({
webAuthExists: vi.fn().mockResolvedValue(true),
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
}));
const whatsappRuntimeApiModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "whatsapp",
artifactBasename: "runtime-api.js",
});
export function getWebSessionMocks(): AnyMocks {
return webSessionMocks;
}
const installWebSessionMock = () => vi.doMock(whatsappRuntimeApiModuleId, () => webSessionMocks);
installWebSessionMock();
export const MAIN_SESSION_KEY = "agent:main:main";
type TempHomeEnvSnapshot = {
home: string | undefined;
userProfile: string | undefined;
homeDrive: string | undefined;
homePath: string | undefined;
openclawHome: string | undefined;
stateDir: string | undefined;
};
let suiteTempHomeRoot = "";
let suiteTempHomeId = 0;
function snapshotTempHomeEnv(): TempHomeEnvSnapshot {
return {
home: process.env.HOME,
userProfile: process.env.USERPROFILE,
homeDrive: process.env.HOMEDRIVE,
homePath: process.env.HOMEPATH,
openclawHome: process.env.OPENCLAW_HOME,
stateDir: process.env.OPENCLAW_STATE_DIR,
};
}
function restoreTempHomeEnv(snapshot: TempHomeEnvSnapshot): void {
const restoreKey = (key: string, value: string | undefined) => {
if (value === undefined) {
delete process.env[key];
return;
}
process.env[key] = value;
};
restoreKey("HOME", snapshot.home);
restoreKey("USERPROFILE", snapshot.userProfile);
restoreKey("HOMEDRIVE", snapshot.homeDrive);
restoreKey("HOMEPATH", snapshot.homePath);
restoreKey("OPENCLAW_HOME", snapshot.openclawHome);
restoreKey("OPENCLAW_STATE_DIR", snapshot.stateDir);
}
function setTempHomeEnv(home: string): void {
process.env.HOME = home;
process.env.USERPROFILE = home;
delete process.env.OPENCLAW_HOME;
process.env.OPENCLAW_STATE_DIR = join(home, ".openclaw");
if (process.platform !== "win32") {
return;
}
const match = home.match(/^([A-Za-z]:)(.*)$/);
if (!match) {
return;
}
process.env.HOMEDRIVE = match[1];
process.env.HOMEPATH = match[2] || "\\";
}
beforeAll(async () => {
suiteTempHomeRoot = await fs.mkdtemp(join(os.tmpdir(), "openclaw-triggers-suite-"));
});
afterAll(async () => {
if (!suiteTempHomeRoot) {
return;
}
try {
rmSync(suiteTempHomeRoot, { recursive: true, force: true });
} catch {
// Best-effort temp cleanup only.
}
suiteTempHomeRoot = "";
suiteTempHomeId = 0;
});
export async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const home = join(suiteTempHomeRoot, `case-${++suiteTempHomeId}`);
const snapshot = snapshotTempHomeEnv();
await fs.mkdir(join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true });
setTempHomeEnv(home);
try {
// Hard reset shared mocks so non-isolated runs don't inherit prior behavior.
piEmbeddedMocks.runEmbeddedPiAgent.mockReset();
piEmbeddedMocks.abortEmbeddedPiRun.mockReset().mockReturnValue(false);
piEmbeddedMocks.compactEmbeddedPiSession.mockReset();
piEmbeddedMocks.queueEmbeddedPiMessage.mockReset().mockReturnValue(false);
piEmbeddedMocks.isEmbeddedPiRunActive.mockReset().mockReturnValue(false);
piEmbeddedMocks.isEmbeddedPiRunStreaming.mockReset().mockReturnValue(false);
modelFallbackMocks.runWithModelFallback.mockClear();
return await fn(home);
} finally {
restoreTempHomeEnv(snapshot);
}
}
export function makeCfg(home: string): OpenClawConfig {
return withFastReplyConfig({
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-6" },
workspace: join(home, "openclaw"),
// Test harness: avoid 1s coalescer idle sleeps that dominate trigger suites.
blockStreamingCoalesce: { idleMs: 1 },
// Trigger tests assert routing/authorization behavior, not delivery pacing.
humanDelay: { mode: "off" },
},
},
channels: {
whatsapp: {
allowFrom: ["*"],
},
},
messages: {
queue: {
debounceMs: 0,
},
},
session: { store: join(home, "sessions.json") },
} as OpenClawConfig);
}
export async function loadGetReplyFromConfig() {
return (await import("../../../src/auto-reply/reply.js")).getReplyFromConfig;
}
export function installTriggerHandlingReplyHarness(
setGetReplyFromConfig: (
getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig,
) => void,
): void {
beforeAll(async () => {
setGetReplyFromConfig(await loadGetReplyFromConfig());
});
installTriggerHandlingE2eTestHooks();
}
export function requireSessionStorePath(cfg: { session?: { store?: string } }): string {
const storePath = cfg.session?.store;
if (!storePath) {
throw new Error("expected session store path");
}
return storePath;
}
export async function readSessionStore(cfg: {
session?: { store?: string };
}): Promise<Record<string, { elevatedLevel?: string }>> {
const storeRaw = await fs.readFile(requireSessionStorePath(cfg), "utf-8");
return JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
}
export function makeWhatsAppElevatedCfg(
home: string,
opts?: { elevatedEnabled?: boolean; requireMentionInGroups?: boolean },
): OpenClawConfig {
const cfg = makeCfg(home);
cfg.channels ??= {};
cfg.channels.whatsapp = {
...cfg.channels.whatsapp,
allowFrom: ["+1000"],
};
if (opts?.requireMentionInGroups !== undefined) {
cfg.channels.whatsapp.groups = { "*": { requireMention: opts.requireMentionInGroups } };
}
cfg.tools = {
...cfg.tools,
elevated: {
allowFrom: { whatsapp: ["+1000"] },
...(opts?.elevatedEnabled === false ? { enabled: false } : {}),
},
};
return cfg;
}
export async function runDirectElevatedToggleAndLoadStore(params: {
cfg: OpenClawConfig;
getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig;
body?: string;
}): Promise<{
text: string | undefined;
store: Record<string, { elevatedLevel?: string }>;
}> {
const res = await params.getReplyFromConfig(
{
Body: params.body ?? "/elevated on",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
CommandAuthorized: true,
},
{},
params.cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const storePath = params.cfg.session?.store;
if (!storePath) {
throw new Error("session.store is required in test config");
}
const store = await readSessionStore(params.cfg);
return { text, store };
}
export async function expectInlineCommandHandledAndStripped(params: {
home: string;
getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig;
body: string;
stripToken: string;
blockReplyContains: string;
requestOverrides?: Record<string, unknown>;
}) {
const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk();
runEmbeddedPiAgentMock.mockClear();
const { blockReplies, handlers } = createBlockReplyCollector();
const res = await params.getReplyFromConfig(
{
Body: params.body,
From: "+1002",
To: "+2000",
CommandAuthorized: true,
...params.requestOverrides,
},
handlers,
makeCfg(params.home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(blockReplies.length).toBe(1);
expect(blockReplies[0]?.text).toContain(params.blockReplyContains);
expect(runEmbeddedPiAgentMock).toHaveBeenCalled();
const prompt = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0]?.prompt ?? "";
expect(prompt).not.toContain(params.stripToken);
expect(text).toBe("ok");
}
export async function runGreetingPromptForBareNewOrReset(params: {
home: string;
body: "/new" | "/reset";
getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig;
}) {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockClear();
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "hello" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await params.getReplyFromConfig(
{
Body: params.body,
From: "+1003",
To: "+2000",
CommandAuthorized: true,
},
{},
makeCfg(params.home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("hello");
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
const prompt = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0]?.prompt ?? "";
expect(prompt).toContain("A new session was started via /new or /reset");
expect(prompt).toContain("Execute your Session Startup sequence now");
expect(prompt).toContain("read the required files before responding to the user");
}
export function installTriggerHandlingE2eTestHooks() {
afterEach(() => {
clearRuntimeAuthProfileStoreSnapshots();
resetProviderRuntimeHookCacheForTest();
vi.clearAllMocks();
});
}
export function mockRunEmbeddedPiAgentOk(text = "ok"): AnyMock {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
return runEmbeddedPiAgentMock;
}
export function createBlockReplyCollector() {
const blockReplies: Array<{ text?: string }> = [];
return {
blockReplies,
handlers: {
onBlockReply: async (payload: { text?: string }) => {
blockReplies.push(payload);
},
},
};
}

View file

@ -0,0 +1,71 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const BROWSER_FIXTURE_MANIFEST = {
id: "browser",
enabledByDefault: true,
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
};
const BROWSER_FIXTURE_ENTRY = `module.exports = {
id: "browser",
name: "Browser",
description: "Bundled browser fixture plugin",
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
register(api) {
api.registerTool((ctx) => ({
name: "browser",
label: "browser",
description: "browser fixture tool",
parameters: {
type: "object",
properties: {},
},
async execute() {
return {
content: [{ type: "text", text: "ok" }],
details: {
workspaceOnly: ctx.fsPolicy?.workspaceOnly ?? null,
},
};
},
}));
api.registerCli(({ program }) => {
program.command("browser");
}, { commands: ["browser"] });
api.registerGatewayMethod("browser.request", async () => ({ ok: true }), {
scope: "operator.write",
});
api.registerService({
id: "browser-control",
start() {},
});
},
};`;
export function createBundledBrowserPluginFixture(): { rootDir: string; cleanup: () => void } {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-browser-bundled-"));
const pluginDir = path.join(rootDir, "browser");
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify(BROWSER_FIXTURE_MANIFEST, null, 2),
"utf8",
);
fs.writeFileSync(path.join(pluginDir, "index.js"), BROWSER_FIXTURE_ENTRY, "utf8");
return {
rootDir,
cleanup() {
fs.rmSync(rootDir, { recursive: true, force: true });
},
};
}

View file

@ -0,0 +1,38 @@
import { expect, it } from "vitest";
type BundledChannelEntry = {
id: string;
kind?: string;
name: string;
};
type BundledChannelSetupEntry = {
kind?: string;
loadSetupPlugin?: unknown;
};
export function assertBundledChannelEntries(params: {
entry: BundledChannelEntry;
expectedId: string;
expectedName: string;
setupEntry: BundledChannelSetupEntry;
channelMessage?: string;
setupMessage?: string;
}) {
it(
params.channelMessage ?? "declares the channel plugin without importing the broad api barrel",
() => {
expect(params.entry.kind).toBe("bundled-channel-entry");
expect(params.entry.id).toBe(params.expectedId);
expect(params.entry.name).toBe(params.expectedName);
},
);
it(
params.setupMessage ?? "declares the setup plugin without importing the broad api barrel",
() => {
expect(params.setupEntry.kind).toBe("bundled-channel-setup-entry");
expect(typeof params.setupEntry.loadSetupPlugin).toBe("function");
},
);
}

View file

@ -0,0 +1,59 @@
export const BUNDLED_PLUGIN_ROOT_DIR = "extensions";
export const BUNDLED_PLUGIN_PATH_PREFIX = `${BUNDLED_PLUGIN_ROOT_DIR}/`;
export const BUNDLED_PLUGIN_TEST_GLOB = `${BUNDLED_PLUGIN_ROOT_DIR}/**/*.test.ts`;
export function bundledPluginRoot(pluginId: string): string {
return `${BUNDLED_PLUGIN_PATH_PREFIX}${pluginId}`;
}
export function bundledPluginFile(pluginId: string, relativePath: string): string {
return `${bundledPluginRoot(pluginId)}/${relativePath}`;
}
function joinRoot(baseDir: string, relativePath: string): string {
return `${baseDir.replace(/\/$/, "")}/${relativePath}`;
}
export function bundledPluginDirPrefix(pluginId: string, relativeDir: string): string {
return `${bundledPluginRoot(pluginId)}/${relativeDir.replace(/\/$/, "")}/`;
}
export function bundledPluginRootAt(baseDir: string, pluginId: string): string {
return joinRoot(baseDir, bundledPluginRoot(pluginId));
}
export function bundledPluginFileAt(
baseDir: string,
pluginId: string,
relativePath: string,
): string {
return joinRoot(baseDir, bundledPluginFile(pluginId, relativePath));
}
export function bundledDistPluginRoot(pluginId: string): string {
return `dist/${bundledPluginRoot(pluginId)}`;
}
export function bundledDistPluginFile(pluginId: string, relativePath: string): string {
return `${bundledDistPluginRoot(pluginId)}/${relativePath}`;
}
export function bundledDistPluginRootAt(baseDir: string, pluginId: string): string {
return joinRoot(baseDir, bundledDistPluginRoot(pluginId));
}
export function bundledDistPluginFileAt(
baseDir: string,
pluginId: string,
relativePath: string,
): string {
return joinRoot(baseDir, bundledDistPluginFile(pluginId, relativePath));
}
export function installedPluginRoot(baseDir: string, pluginId: string): string {
return bundledPluginRootAt(baseDir, pluginId);
}
export function repoInstallSpec(pluginId: string): string {
return `./${bundledPluginRoot(pluginId)}`;
}

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,20 @@
export function createCapturedIo() {
let stdout = "";
let stderr = "";
return {
io: {
stdout: {
write(chunk: unknown) {
stdout += String(chunk);
},
},
stderr: {
write(chunk: unknown) {
stderr += String(chunk);
},
},
},
readStdout: () => stdout,
readStderr: () => stderr,
};
}

View file

@ -0,0 +1,34 @@
# Test Helper Boundary
This directory holds shared channel test helpers used by core and bundled plugin
tests.
This file adds channel-specific rules on top of `test/helpers/AGENTS.md`.
## Bundled Plugin Imports
- Core test helpers in this directory must not hardcode repo-relative imports
into `extensions/**`.
- When a helper needs a bundled plugin public/test surface, go through
`src/test-utils/bundled-plugin-public-surface.ts`.
- Prefer `loadBundledPluginTestApiSync(...)` for eager access to exported test
helpers.
- Prefer `resolveRelativeBundledPluginPublicModuleId(...)` when a test needs a
module id for dynamic import or mocking.
- If `vi.mock(...)` hoisting would evaluate the module id too early, use
`vi.doMock(...)` with the resolved module id instead of falling back to a
hardcoded path.
- For contract helpers, prefer minimal in-memory channel/plugin fixtures when
the contract only needs capabilities, session binding hooks, routing metadata,
or outbound payload helpers. Do not load broad `api.ts`, `runtime-api.ts`, or
`test-api.ts` barrels for incidental setup.
- If a bundled plugin parser is the contract under test, load the narrow module
that owns that parser or promote a small public artifact. Avoid pulling a full
extension barrel just to parse a target id.
## Intent
- Keep shared test helpers aligned with the same public/plugin boundary that
production code uses.
- Avoid drift where core test helpers start reaching into bundled plugin private
files by path because it is convenient in one test.

View file

@ -0,0 +1 @@
AGENTS.md

View file

@ -0,0 +1,213 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
getChannelPluginCatalogEntry,
listChannelPluginCatalogEntries,
} from "../../../src/channels/plugins/catalog.js";
type CatalogEntryMeta = {
id: string;
label: string;
selectionLabel: string;
docsPath: string;
blurb: string;
detailLabel?: string;
aliases?: string[];
};
export function describeChannelCatalogEntryContract(params: {
channelId: string;
npmSpec: string;
alias?: string;
}) {
describe(`${params.channelId} channel catalog contract`, () => {
it("keeps the shipped catalog entry aligned", () => {
const entry = getChannelPluginCatalogEntry(params.channelId);
expect(entry?.install.npmSpec).toBe(params.npmSpec);
if (params.alias) {
expect(entry?.meta.aliases).toContain(params.alias);
}
});
it("appears in the channel catalog listing", () => {
const ids = listChannelPluginCatalogEntries().map((entry) => entry.id);
expect(ids).toContain(params.channelId);
});
});
}
export function describeBundledMetadataOnlyChannelCatalogContract(params: {
pluginId: string;
packageName: string;
npmSpec: string;
meta: CatalogEntryMeta;
defaultChoice?: string;
}) {
describe(`${params.pluginId} bundled metadata-only channel catalog contract`, () => {
it("includes the bundled metadata-only channel entry when the runtime entrypoint is omitted", () => {
const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-catalog-"));
const bundledDir = path.join(packageRoot, "dist", "extensions", params.pluginId);
fs.mkdirSync(bundledDir, { recursive: true });
fs.writeFileSync(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw" }),
"utf8",
);
fs.writeFileSync(
path.join(bundledDir, "package.json"),
JSON.stringify({
name: params.packageName,
openclaw: {
extensions: ["./index.js"],
channel: params.meta,
install: {
npmSpec: params.npmSpec,
defaultChoice: params.defaultChoice,
},
},
}),
"utf8",
);
fs.writeFileSync(path.join(bundledDir, "index.js"), "export default {};\n", "utf8");
fs.writeFileSync(
path.join(bundledDir, "openclaw.plugin.json"),
JSON.stringify({ id: params.pluginId, channels: [params.meta.id], configSchema: {} }),
"utf8",
);
const entry = listChannelPluginCatalogEntries({
env: {
...process.env,
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(packageRoot, "dist", "extensions"),
},
}).find((item) => item.id === params.meta.id);
expect(entry?.install.npmSpec).toBe(params.npmSpec);
expect(entry?.pluginId).toBe(params.pluginId);
});
});
}
export function describeOfficialFallbackChannelCatalogContract(params: {
channelId: string;
npmSpec: string;
meta: CatalogEntryMeta;
packageName: string;
pluginId: string;
externalNpmSpec: string;
externalLabel: string;
}) {
describe(`${params.channelId} official fallback channel catalog contract`, () => {
it("includes shipped official channel catalog entries when bundled metadata is omitted", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-official-catalog-"));
const catalogPath = path.join(dir, "channel-catalog.json");
fs.writeFileSync(
catalogPath,
JSON.stringify({
entries: [
{
name: params.packageName,
openclaw: {
channel: params.meta,
install: {
npmSpec: params.npmSpec,
defaultChoice: "npm",
},
},
},
],
}),
);
const entry = listChannelPluginCatalogEntries({
env: {
...process.env,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
officialCatalogPaths: [catalogPath],
}).find((item) => item.id === params.channelId);
expect(entry?.install.npmSpec).toBe(params.npmSpec);
expect(entry?.pluginId).toBeUndefined();
});
it("lets external catalogs override shipped fallback channel metadata", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-fallback-catalog-"));
const bundledDir = path.join(dir, "dist", "extensions", params.pluginId);
const officialCatalogPath = path.join(dir, "channel-catalog.json");
const externalCatalogPath = path.join(dir, "catalog.json");
fs.mkdirSync(bundledDir, { recursive: true });
fs.writeFileSync(
path.join(bundledDir, "package.json"),
JSON.stringify({
name: params.packageName,
openclaw: {
channel: {
...params.meta,
label: `${params.meta.label} Bundled`,
selectionLabel: `${params.meta.label} Bundled`,
blurb: "bundled fallback",
},
install: { npmSpec: params.npmSpec },
},
}),
"utf8",
);
fs.writeFileSync(
officialCatalogPath,
JSON.stringify({
entries: [
{
name: params.packageName,
openclaw: {
channel: {
...params.meta,
label: `${params.meta.label} Official`,
selectionLabel: `${params.meta.label} Official`,
blurb: "official fallback",
},
install: { npmSpec: params.npmSpec },
},
},
],
}),
"utf8",
);
fs.writeFileSync(
externalCatalogPath,
JSON.stringify({
entries: [
{
name: params.externalNpmSpec,
openclaw: {
channel: {
...params.meta,
label: params.externalLabel,
selectionLabel: params.externalLabel,
blurb: "external override",
},
install: { npmSpec: params.externalNpmSpec },
},
},
],
}),
"utf8",
);
const entry = listChannelPluginCatalogEntries({
catalogPaths: [externalCatalogPath],
officialCatalogPaths: [officialCatalogPath],
env: {
...process.env,
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(dir, "dist", "extensions"),
},
}).find((item) => item.id === params.channelId);
expect(entry?.install.npmSpec).toBe(params.externalNpmSpec);
expect(entry?.meta.label).toBe(params.externalLabel);
expect(entry?.pluginId).toBeUndefined();
});
});
}

View file

@ -0,0 +1,25 @@
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
type IMessageContractSurface = {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS: string[];
resolveIMessageAttachmentRoots: (params: unknown) => string[];
resolveIMessageRemoteAttachmentRoots: (params: unknown) => string[];
};
const {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} = (await import(
resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "imessage",
artifactBasename: "contract-api.js",
})
)) as IMessageContractSurface;
export {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
};

View file

@ -0,0 +1,277 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { listChannelPluginCatalogEntries } from "../../../src/channels/plugins/catalog.js";
function createCatalogEntry(params: {
packageName: string;
channelId: string;
label: string;
blurb: string;
order?: number;
}) {
return {
name: params.packageName,
openclaw: {
channel: {
id: params.channelId,
label: params.label,
selectionLabel: params.label,
docsPath: `/channels/${params.channelId}`,
blurb: params.blurb,
...(params.order === undefined ? {} : { order: params.order }),
},
install: {
npmSpec: params.packageName,
},
},
};
}
function writeCatalogFile(catalogPath: string, entry: Record<string, unknown>) {
fs.writeFileSync(
catalogPath,
JSON.stringify({
entries: [entry],
}),
);
}
function writeDiscoveredChannelPlugin(params: {
stateDir: string;
packageName: string;
channelLabel: string;
pluginId: string;
blurb: string;
}) {
const pluginDir = path.join(params.stateDir, "extensions", "demo-channel-plugin");
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: params.packageName,
openclaw: {
extensions: ["./index.js"],
channel: {
id: "demo-channel",
label: params.channelLabel,
selectionLabel: params.channelLabel,
docsPath: "/channels/demo-channel",
blurb: params.blurb,
},
install: {
npmSpec: params.packageName,
},
},
}),
"utf8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify({
id: params.pluginId,
configSchema: {},
}),
"utf8",
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {}", "utf8");
}
function expectCatalogIdsContain(params: {
expectedId: string;
catalogPaths?: string[];
env?: NodeJS.ProcessEnv;
}) {
const ids = listChannelPluginCatalogEntries({
...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}),
...(params.env ? { env: params.env } : {}),
}).map((entry) => entry.id);
expect(ids).toContain(params.expectedId);
}
function findCatalogEntry(params: {
channelId: string;
catalogPaths?: string[];
env?: NodeJS.ProcessEnv;
}) {
return listChannelPluginCatalogEntries({
...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}),
...(params.env ? { env: params.env } : {}),
}).find((entry) => entry.id === params.channelId);
}
function expectCatalogEntryMatch(params: {
channelId: string;
expected: Record<string, unknown>;
catalogPaths?: string[];
env?: NodeJS.ProcessEnv;
}) {
expect(
findCatalogEntry({
channelId: params.channelId,
...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}),
...(params.env ? { env: params.env } : {}),
}),
).toMatchObject(params.expected);
}
export function describeChannelPluginCatalogEntriesContract() {
describe("channel plugin catalog entries contract", () => {
it.each([
{
name: "includes external catalog entries",
setup: () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-"));
const catalogPath = path.join(dir, "catalog.json");
writeCatalogFile(
catalogPath,
createCatalogEntry({
packageName: "@openclaw/demo-channel",
channelId: "demo-channel",
label: "Demo Channel",
blurb: "Demo entry",
order: 999,
}),
);
return {
channelId: "demo-channel",
catalogPaths: [catalogPath],
expected: { id: "demo-channel" },
};
},
},
{
name: "preserves plugin ids when they differ from channel ids",
setup: () => {
const stateDir = fs.mkdtempSync(
path.join(os.tmpdir(), "openclaw-channel-catalog-state-"),
);
writeDiscoveredChannelPlugin({
stateDir,
packageName: "@vendor/demo-channel-plugin",
channelLabel: "Demo Channel",
pluginId: "@vendor/demo-runtime",
blurb: "Demo channel",
});
return {
channelId: "demo-channel",
env: {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
expected: { pluginId: "@vendor/demo-runtime" },
};
},
},
{
name: "keeps discovered plugins ahead of external catalog overrides",
setup: () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-"));
const catalogPath = path.join(stateDir, "catalog.json");
writeDiscoveredChannelPlugin({
stateDir,
packageName: "@vendor/demo-channel-plugin",
channelLabel: "Demo Channel Runtime",
pluginId: "@vendor/demo-channel-runtime",
blurb: "discovered plugin",
});
writeCatalogFile(
catalogPath,
createCatalogEntry({
packageName: "@vendor/demo-channel-catalog",
channelId: "demo-channel",
label: "Demo Channel Catalog",
blurb: "external catalog",
}),
);
return {
channelId: "demo-channel",
catalogPaths: [catalogPath],
env: {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
CLAWDBOT_STATE_DIR: undefined,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
expected: {
install: { npmSpec: "@vendor/demo-channel-plugin" },
meta: { label: "Demo Channel Runtime" },
pluginId: "@vendor/demo-channel-runtime",
},
};
},
},
] as const)("$name", ({ setup }) => {
const setupResult = setup();
const { channelId, expected } = setupResult;
expectCatalogEntryMatch({
channelId,
expected,
...("catalogPaths" in setupResult ? { catalogPaths: setupResult.catalogPaths } : {}),
...("env" in setupResult ? { env: setupResult.env } : {}),
});
});
});
}
export function describeChannelPluginCatalogPathResolutionContract() {
describe("channel plugin catalog path resolution contract", () => {
it.each([
{
name: "uses the provided env for external catalog path resolution",
setup: () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-home-"));
const catalogPath = path.join(home, "catalog.json");
writeCatalogFile(
catalogPath,
createCatalogEntry({
packageName: "@openclaw/env-demo-channel",
channelId: "env-demo-channel",
label: "Env Demo Channel",
blurb: "Env demo entry",
order: 1000,
}),
);
return {
env: {
...process.env,
OPENCLAW_PLUGIN_CATALOG_PATHS: "~/catalog.json",
OPENCLAW_HOME: home,
HOME: home,
},
expectedId: "env-demo-channel",
};
},
},
{
name: "uses the provided env for default catalog paths",
setup: () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-"));
const catalogPath = path.join(stateDir, "plugins", "catalog.json");
fs.mkdirSync(path.dirname(catalogPath), { recursive: true });
writeCatalogFile(
catalogPath,
createCatalogEntry({
packageName: "@openclaw/default-env-demo",
channelId: "default-env-demo",
label: "Default Env Demo",
blurb: "Default env demo entry",
}),
);
return {
env: {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
},
expectedId: "default-env-demo",
};
},
},
] as const)("$name", ({ setup }) => {
const { env, expectedId } = setup();
expectCatalogIdsContain({ env, expectedId });
});
});
}

View file

@ -0,0 +1,60 @@
import {
loadBundledPluginApiSync,
loadBundledPluginContractApiSync,
} from "../../../src/test-utils/bundled-plugin-public-surface.js";
type TelegramContractSurface = {
buildTelegramModelsProviderChannelData: (...args: unknown[]) => unknown;
};
type WhatsAppApiSurface = {
isWhatsAppGroupJid: (...args: unknown[]) => boolean;
normalizeWhatsAppTarget: (...args: unknown[]) => string | null;
whatsappCommandPolicy: Record<string, unknown>;
};
let telegramContractSurface: TelegramContractSurface | undefined;
let whatsappApiSurface: WhatsAppApiSurface | undefined;
function createLazyObjectSurface<T extends object>(loadSurface: () => T): T {
return new Proxy({} as T, {
get(_target, property) {
const surface = loadSurface();
const value = Reflect.get(surface, property, surface);
return typeof value === "function" ? value.bind(surface) : value;
},
has(_target, property) {
return property in loadSurface();
},
ownKeys() {
return Reflect.ownKeys(loadSurface());
},
getOwnPropertyDescriptor(_target, property) {
return Reflect.getOwnPropertyDescriptor(loadSurface(), property);
},
});
}
function getTelegramContractSurface(): TelegramContractSurface {
telegramContractSurface ??= loadBundledPluginContractApiSync<TelegramContractSurface>("telegram");
return telegramContractSurface;
}
function getWhatsAppApiSurface(): WhatsAppApiSurface {
whatsappApiSurface ??= loadBundledPluginApiSync<WhatsAppApiSurface>("whatsapp");
return whatsappApiSurface;
}
export const buildTelegramModelsProviderChannelData = (
...args: Parameters<TelegramContractSurface["buildTelegramModelsProviderChannelData"]>
) => getTelegramContractSurface().buildTelegramModelsProviderChannelData(...args);
export const isWhatsAppGroupJid = (...args: Parameters<WhatsAppApiSurface["isWhatsAppGroupJid"]>) =>
getWhatsAppApiSurface().isWhatsAppGroupJid(...args);
export const normalizeWhatsAppTarget = (
...args: Parameters<WhatsAppApiSurface["normalizeWhatsAppTarget"]>
) => getWhatsAppApiSurface().normalizeWhatsAppTarget(...args);
export const whatsappCommandPolicy = createLazyObjectSurface(
() => getWhatsAppApiSurface().whatsappCommandPolicy,
);

View file

@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
import {
authorizeConfigWrite,
canBypassConfigWritePolicy,
formatConfigWriteDeniedMessage,
resolveExplicitConfigWriteTarget,
resolveConfigWriteTargetFromPath,
} from "../../../src/channels/plugins/config-writes.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../../src/utils/message-channel.js";
const demoOriginChannelId = "demo-origin";
const demoTargetChannelId = "demo-target";
function makeDemoConfigWritesCfg(accountIdKey: string) {
return {
channels: {
[demoOriginChannelId]: {
configWrites: true,
accounts: {
[accountIdKey]: { configWrites: false },
},
},
[demoTargetChannelId]: {
configWrites: true,
accounts: {
[accountIdKey]: { configWrites: false },
},
},
},
};
}
function expectConfigWriteBlocked(params: {
disabledAccountId: string;
reason: "target-disabled" | "origin-disabled";
blockedScope: "target" | "origin";
}) {
expect(
authorizeConfigWrite({
cfg: makeDemoConfigWritesCfg(params.disabledAccountId),
origin: { channelId: demoOriginChannelId, accountId: "default" },
target: resolveExplicitConfigWriteTarget({
channelId: params.blockedScope === "target" ? demoTargetChannelId : demoOriginChannelId,
accountId: "work",
}),
}),
).toEqual({
allowed: false,
reason: params.reason,
blockedScope: {
kind: params.blockedScope,
scope: {
channelId: params.blockedScope === "target" ? demoTargetChannelId : demoOriginChannelId,
accountId: params.blockedScope === "target" ? "work" : "default",
},
},
});
}
function expectAuthorizedConfigWriteCase(
input: Parameters<typeof authorizeConfigWrite>[0],
expected: ReturnType<typeof authorizeConfigWrite>,
) {
expect(authorizeConfigWrite(input)).toEqual(expected);
}
function expectResolvedConfigWriteTargetCase(pathSegments: readonly string[], expected: unknown) {
expect(resolveConfigWriteTargetFromPath([...pathSegments])).toEqual(expected);
}
function expectExplicitConfigWriteTargetCase(
input: Parameters<typeof resolveExplicitConfigWriteTarget>[0],
expected: ReturnType<typeof resolveExplicitConfigWriteTarget>,
) {
expect(resolveExplicitConfigWriteTarget(input)).toEqual(expected);
}
function expectFormattedDeniedMessage(
result: Exclude<ReturnType<typeof authorizeConfigWrite>, { allowed: true }>,
) {
expect(
formatConfigWriteDeniedMessage({
result,
}),
).toContain(`channels.${demoTargetChannelId}.accounts.work.configWrites=true`);
}
export function describeChannelConfigWritePolicyContract() {
describe("authorizeConfigWrite policy contract", () => {
it.each([
{
name: "blocks when a target account disables writes",
disabledAccountId: "work",
reason: "target-disabled",
blockedScope: "target",
},
{
name: "blocks when the origin account disables writes",
disabledAccountId: "default",
reason: "origin-disabled",
blockedScope: "origin",
},
] as const)("$name", (testCase) => {
expectConfigWriteBlocked(testCase);
});
it.each([
{
name: "allows bypass for internal operator.admin writes",
input: {
cfg: makeDemoConfigWritesCfg("work"),
origin: { channelId: demoOriginChannelId, accountId: "default" },
target: resolveExplicitConfigWriteTarget({
channelId: demoTargetChannelId,
accountId: "work",
}),
allowBypass: canBypassConfigWritePolicy({
channel: INTERNAL_MESSAGE_CHANNEL,
gatewayClientScopes: ["operator.admin"],
}),
},
expected: { allowed: true },
},
{
name: "treats non-channel config paths as global writes",
input: {
cfg: makeDemoConfigWritesCfg("work"),
origin: { channelId: demoOriginChannelId, accountId: "default" },
target: resolveConfigWriteTargetFromPath(["messages", "ackReaction"]),
},
expected: { allowed: true },
},
] as const)("$name", ({ input, expected }) => {
expectAuthorizedConfigWriteCase(input, expected);
});
});
}
export function describeChannelConfigWriteTargetContract() {
describe("authorizeConfigWrite target contract", () => {
it.each([
{
name: "rejects bare channel collection writes",
pathSegments: ["channels", "demo-channel"],
expected: { kind: "ambiguous", scopes: [{ channelId: "demo-channel" }] },
},
{
name: "rejects account collection writes",
pathSegments: ["channels", "demo-channel", "accounts"],
expected: { kind: "ambiguous", scopes: [{ channelId: "demo-channel" }] },
},
] as const)("$name", ({ pathSegments, expected }) => {
expectResolvedConfigWriteTargetCase(pathSegments, expected);
});
it.each([
{
name: "resolves explicit channel target",
input: { channelId: demoOriginChannelId },
expected: {
kind: "channel",
scope: { channelId: demoOriginChannelId },
},
},
{
name: "resolves explicit account target",
input: { channelId: demoTargetChannelId, accountId: "work" },
expected: {
kind: "account",
scope: { channelId: demoTargetChannelId, accountId: "work" },
},
},
] as const)("$name", ({ input, expected }) => {
expectExplicitConfigWriteTargetCase(input, expected);
});
it.each([
{
name: "formats denied messages consistently",
result: {
allowed: false,
reason: "target-disabled",
blockedScope: {
kind: "target",
scope: { channelId: demoTargetChannelId, accountId: "work" },
},
} as const,
},
] as const)("$name", ({ result }) => {
expectFormattedDeniedMessage(result);
});
});
}

View file

@ -0,0 +1,26 @@
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
export type SignalSender = {
kind: string;
raw: string;
e164?: string;
uuid?: string;
username?: string;
};
type SignalContractApiSurface = {
isSignalSenderAllowed: (...args: unknown[]) => boolean;
};
let signalContractSurface: Promise<SignalContractApiSurface> | undefined;
export function getSignalContractSurface(): Promise<SignalContractApiSurface> {
signalContractSurface ??= import(
resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "signal",
artifactBasename: "contract-api.js",
})
) as Promise<SignalContractApiSurface>;
return signalContractSurface;
}

View file

@ -0,0 +1,49 @@
import { expect, it } from "vitest";
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js";
type ResolvedGroupPolicy = ReturnType<typeof resolveOpenProviderRuntimeGroupPolicy>;
export type RuntimeGroupPolicyResolver = (
params: Parameters<typeof resolveOpenProviderRuntimeGroupPolicy>[0],
) => ReturnType<typeof resolveOpenProviderRuntimeGroupPolicy>;
export function installChannelRuntimeGroupPolicyFallbackSuite(params: {
configuredLabel: string;
defaultGroupPolicyUnderTest: "allowlist" | "disabled" | "open";
missingConfigLabel: string;
missingDefaultLabel: string;
resolve: RuntimeGroupPolicyResolver;
}) {
it(params.missingConfigLabel, () => {
const resolved = params.resolve({
providerConfigPresent: false,
});
expect(resolved.groupPolicy).toBe("allowlist");
expect(resolved.providerMissingFallbackApplied).toBe(true);
});
it(params.configuredLabel, () => {
const resolved = params.resolve({
providerConfigPresent: true,
});
expect(resolved.groupPolicy).toBe("open");
expect(resolved.providerMissingFallbackApplied).toBe(false);
});
it(params.missingDefaultLabel, () => {
const resolved = params.resolve({
providerConfigPresent: false,
defaultGroupPolicy: params.defaultGroupPolicyUnderTest,
});
expect(resolved.groupPolicy).toBe("allowlist");
expect(resolved.providerMissingFallbackApplied).toBe(true);
});
}
export function expectResolvedGroupPolicyCase(
resolved: Pick<ResolvedGroupPolicy, "groupPolicy" | "providerMissingFallbackApplied">,
expected: Pick<ResolvedGroupPolicy, "groupPolicy" | "providerMissingFallbackApplied">,
) {
expect(resolved.groupPolicy).toBe(expected.groupPolicy);
expect(resolved.providerMissingFallbackApplied).toBe(expected.providerMissingFallbackApplied);
}

View file

@ -0,0 +1,6 @@
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js";
const resolveWhatsAppRuntimeGroupPolicy = resolveOpenProviderRuntimeGroupPolicy;
const resolveZaloRuntimeGroupPolicy = resolveOpenProviderRuntimeGroupPolicy;
export { resolveWhatsAppRuntimeGroupPolicy, resolveZaloRuntimeGroupPolicy };

View file

@ -0,0 +1,19 @@
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-plugin-common";
import { loadBundledPluginTestApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
type CreateIMessageTestPlugin = (params?: { outbound?: ChannelOutboundAdapter }) => ChannelPlugin;
let createIMessageTestPluginCache: CreateIMessageTestPlugin | undefined;
function getCreateIMessageTestPlugin(): CreateIMessageTestPlugin {
if (!createIMessageTestPluginCache) {
({ createIMessageTestPlugin: createIMessageTestPluginCache } = loadBundledPluginTestApiSync<{
createIMessageTestPlugin: CreateIMessageTestPlugin;
}>("imessage"));
}
return createIMessageTestPluginCache;
}
export const createIMessageTestPlugin: CreateIMessageTestPlugin = (...args) =>
getCreateIMessageTestPlugin()(...args);

View file

@ -0,0 +1,28 @@
import { it } from "vitest";
import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/test-helpers.js";
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
type BuildFinalizedDiscordDirectInboundContext =
() => import("../../../src/auto-reply/templating.js").MsgContext;
const discordInboundContextHarnessModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "discord",
artifactBasename: "src/monitor/inbound-context.test-helpers.js",
});
async function getBuildFinalizedDiscordDirectInboundContext(): Promise<BuildFinalizedDiscordDirectInboundContext> {
const module = (await import(discordInboundContextHarnessModuleId)) as {
buildFinalizedDiscordDirectInboundContext: BuildFinalizedDiscordDirectInboundContext;
};
return module.buildFinalizedDiscordDirectInboundContext;
}
export function installDiscordInboundContractSuite() {
it("keeps inbound context finalized", async () => {
const buildContext = await getBuildFinalizedDiscordDirectInboundContext();
const ctx = buildContext();
expectChannelInboundContextContract(ctx);
});
}

View file

@ -0,0 +1,32 @@
import { it } from "vitest";
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/test-helpers.js";
export function installSignalInboundContractSuite() {
it("keeps inbound context finalized", () => {
const ctx = finalizeInboundContext({
Body: "Alice: hi",
BodyForAgent: "hi",
RawBody: "hi",
CommandBody: "hi",
BodyForCommands: "hi",
From: "group:g1",
To: "group:g1",
SessionKey: "agent:main:signal:group:g1",
AccountId: "default",
ChatType: "group",
ConversationLabel: "Alice",
GroupSubject: "Test Group",
SenderName: "Alice",
SenderId: "+15550001111",
Provider: "signal",
Surface: "signal",
MessageSid: "1700000000000",
OriginatingChannel: "signal",
OriginatingTo: "group:g1",
CommandAuthorized: true,
});
expectChannelInboundContextContract(ctx);
});
}

View file

@ -0,0 +1,108 @@
import { expect, it } from "vitest";
import type { MsgContext } from "../../../src/auto-reply/templating.js";
import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/test-helpers.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
import { withTempHome } from "../temp-home.js";
type ResolvedSlackAccount = {
accountId: string;
enabled: boolean;
botTokenSource: string;
appTokenSource: string;
userTokenSource: string;
config: {
replyToMode?: unknown;
replyToModeByChatType?: unknown;
dm?: unknown;
};
replyToMode?: unknown;
replyToModeByChatType?: unknown;
dm?: unknown;
};
type SlackMessageEvent = {
channel: string;
channel_type?: string;
user?: string;
text?: string;
ts: string;
};
type SlackPrepareResult = { ctxPayload: MsgContext } | null | undefined;
type SlackTestApi = {
createInboundSlackTestContext: (params: { cfg: OpenClawConfig }) => {
resolveUserName?: () => Promise<unknown>;
};
prepareSlackMessage: (params: {
ctx: {
resolveUserName?: () => Promise<unknown>;
};
account: ResolvedSlackAccount;
message: SlackMessageEvent;
opts: { source: string };
}) => Promise<SlackPrepareResult>;
};
const slackPrepareTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "slack",
artifactBasename: "inbound-contract-test-api.js",
});
let slackTestApiPromise: Promise<SlackTestApi> | undefined;
async function loadSlackTestApi(): Promise<SlackTestApi> {
slackTestApiPromise ??= import(slackPrepareTestApiModuleId) as Promise<SlackTestApi>;
return await slackTestApiPromise;
}
function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount {
return {
accountId: "default",
enabled: true,
botTokenSource: "config",
appTokenSource: "config",
userTokenSource: "none",
config,
replyToMode: config.replyToMode,
replyToModeByChatType: config.replyToModeByChatType,
dm: config.dm,
};
}
function createSlackMessage(overrides: Partial<SlackMessageEvent>): SlackMessageEvent {
return {
channel: "D123",
channel_type: "im",
user: "U1",
text: "hi",
ts: "1.000",
...overrides,
} as SlackMessageEvent;
}
export function installSlackInboundContractSuite() {
it("keeps inbound context finalized", async () => {
await withTempHome(async () => {
const { createInboundSlackTestContext, prepareSlackMessage } = await loadSlackTestApi();
const ctx = createInboundSlackTestContext({
cfg: {
channels: { slack: { enabled: true } },
} as OpenClawConfig,
});
ctx.resolveUserName = async () => ({ name: "Alice" }) as never;
const prepared = await prepareSlackMessage({
ctx,
account: createSlackAccount(),
message: createSlackMessage({}),
opts: { source: "message" },
});
expect(prepared).toBeTruthy();
expectChannelInboundContextContract(prepared!.ctxPayload);
});
});
}

View file

@ -0,0 +1,63 @@
import { it } from "vitest";
import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/test-helpers.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
const telegramHarnessModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "telegram",
artifactBasename: "src/bot-message-context.test-harness.js",
});
async function buildTelegramMessageContextForTest(params: {
cfg: OpenClawConfig;
message: Record<string, unknown>;
}) {
const telegramHarnessModule = (await import(telegramHarnessModuleId)) as {
buildTelegramMessageContextForTest: (params: {
cfg: OpenClawConfig;
message: Record<string, unknown>;
}) => Promise<
{ ctxPayload: import("../../../src/auto-reply/templating.js").MsgContext } | null | undefined
>;
};
return await telegramHarnessModule.buildTelegramMessageContextForTest(params);
}
export function installTelegramInboundContractSuite() {
it("keeps inbound context finalized", async () => {
const context = await buildTelegramMessageContextForTest({
cfg: {
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
} satisfies OpenClawConfig,
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "hello",
date: 1736380800,
message_id: 2,
from: {
id: 99,
first_name: "Ada",
last_name: "Lovelace",
username: "ada",
},
},
});
const payload = context?.ctxPayload;
if (!payload) {
throw new Error("expected telegram inbound payload");
}
expectChannelInboundContextContract(payload);
});
}

View file

@ -0,0 +1,33 @@
import { it } from "vitest";
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/test-helpers.js";
export function installWhatsAppInboundContractSuite() {
it("keeps inbound context finalized", () => {
const ctx = finalizeInboundContext({
Body: "Alice: hi",
BodyForAgent: "hi",
RawBody: "hi",
CommandBody: "hi",
BodyForCommands: "hi",
From: "123@g.us",
To: "+15550001111",
SessionKey: "agent:main:whatsapp:group:123",
AccountId: "default",
ChatType: "group",
ConversationLabel: "123@g.us",
GroupSubject: "Test Group",
SenderName: "Alice",
SenderId: "alice@s.whatsapp.net",
SenderE164: "+15550002222",
Provider: "whatsapp",
Surface: "whatsapp",
MessageSid: "msg1",
OriginatingChannel: "whatsapp",
OriginatingTo: "123@g.us",
CommandAuthorized: true,
});
expectChannelInboundContextContract(ctx);
});
}

View file

@ -0,0 +1,78 @@
type ConversationBindingHelpers = {
requestConversationBinding: (...args: unknown[]) => unknown;
detachConversationBinding: (...args: unknown[]) => unknown;
getCurrentConversationBinding: (...args: unknown[]) => unknown;
};
type InteractiveHandlerRegistration<
TChannel extends string,
TContext,
> = ConversationBindingHelpers & {
channel: TChannel;
namespace: string;
handler: (ctx: TContext) => unknown;
};
type BaseInteractiveContext<TChannel extends string> = ConversationBindingHelpers & {
channel: TChannel;
accountId: string;
conversationId: string;
parentConversationId?: string;
senderId: string;
senderUsername?: string;
auth?: unknown;
};
export type TelegramInteractiveHandlerContext = BaseInteractiveContext<"telegram"> & {
callbackId: string;
senderUsername?: string;
threadId?: number;
isGroup?: boolean;
isForum?: boolean;
callback: {
data: string;
namespace: string;
payload: string;
messageId: number;
chatId: string;
messageText?: string;
};
respond: Record<string, (...args: unknown[]) => unknown>;
};
export type DiscordInteractiveHandlerContext = BaseInteractiveContext<"discord"> & {
interactionId: string;
guildId?: string;
interaction: {
data: string;
namespace: string;
payload: string;
[key: string]: unknown;
};
respond: Record<string, (...args: unknown[]) => unknown>;
};
export type SlackInteractiveHandlerContext = BaseInteractiveContext<"slack"> & {
interactionId: string;
threadId?: string;
interaction: {
data: string;
namespace: string;
payload: string;
[key: string]: unknown;
};
respond: Record<string, (...args: unknown[]) => unknown>;
};
export type TelegramInteractiveHandlerRegistration = InteractiveHandlerRegistration<
"telegram",
TelegramInteractiveHandlerContext
>;
export type DiscordInteractiveHandlerRegistration = InteractiveHandlerRegistration<
"discord",
DiscordInteractiveHandlerContext
>;
export type SlackInteractiveHandlerRegistration = InteractiveHandlerRegistration<
"slack",
SlackInteractiveHandlerContext
>;

View file

@ -0,0 +1,23 @@
export const channelPluginSurfaceKeys = [
"actions",
"setup",
"status",
"outbound",
"messaging",
"threading",
"directory",
"gateway",
] as const;
export type ChannelPluginSurface = (typeof channelPluginSurfaceKeys)[number];
export const sessionBindingContractChannelIds = [
"bluebubbles",
"discord",
"feishu",
"imessage",
"matrix",
"telegram",
] as const;
export type SessionBindingContractChannelId = (typeof sessionBindingContractChannelIds)[number];

View file

@ -0,0 +1,40 @@
import { loadBundledPluginContractApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
type MatrixContractSurface = {
matrixSetupAdapter: Record<string, unknown>;
matrixSetupWizard: Record<string, unknown>;
};
let matrixContractSurface: MatrixContractSurface | undefined;
function createLazyObjectSurface<T extends object>(loadSurface: () => T): T {
return new Proxy({} as T, {
get(_target, property) {
const surface = loadSurface();
const value = Reflect.get(surface, property, surface);
return typeof value === "function" ? value.bind(surface) : value;
},
has(_target, property) {
return property in loadSurface();
},
ownKeys() {
return Reflect.ownKeys(loadSurface());
},
getOwnPropertyDescriptor(_target, property) {
return Reflect.getOwnPropertyDescriptor(loadSurface(), property);
},
});
}
function getMatrixContractSurface(): MatrixContractSurface {
matrixContractSurface ??= loadBundledPluginContractApiSync<MatrixContractSurface>("matrix");
return matrixContractSurface;
}
export const matrixSetupAdapter = createLazyObjectSurface(
() => getMatrixContractSurface().matrixSetupAdapter,
);
export const matrixSetupWizard = createLazyObjectSurface(
() => getMatrixContractSurface().matrixSetupWizard,
);

View file

@ -0,0 +1,409 @@
import { beforeEach, expect, it, type Mock, vi } from "vitest";
import type { ReplyPayload } from "../../../src/auto-reply/types.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";
import { sendPayloadWithChunkedTextAndMedia } from "../../../src/plugin-sdk/reply-payload.js";
import { chunkTextForOutbound } from "../../../src/plugin-sdk/text-chunking.js";
import { resetGlobalHookRunner } from "../../../src/plugins/hook-runner-global.js";
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
type CreateSlackOutboundPayloadHarness = (params: PayloadHarnessParams) => {
run: () => Promise<Record<string, unknown>>;
sendMock: Mock;
to: string;
};
const discordOutboundAdapterModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "discord",
artifactBasename: "src/outbound-adapter.js",
});
const slackTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "slack",
artifactBasename: "outbound-payload-test-api.js",
});
const whatsappTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "whatsapp",
artifactBasename: "outbound-payload-test-api.js",
});
let discordOutboundCache: Promise<ChannelOutboundAdapter> | undefined;
let slackTestApiPromise:
| Promise<{
createSlackOutboundPayloadHarness: CreateSlackOutboundPayloadHarness;
}>
| undefined;
let whatsappTestApiPromise:
| Promise<{
whatsappOutbound: ChannelOutboundAdapter;
}>
| undefined;
async function getDiscordOutbound(): Promise<ChannelOutboundAdapter> {
discordOutboundCache ??= (async () => {
const module = (await import(discordOutboundAdapterModuleId)) as {
discordOutbound: ChannelOutboundAdapter;
};
return module.discordOutbound;
})();
return await discordOutboundCache;
}
async function getCreateSlackOutboundPayloadHarness(): Promise<CreateSlackOutboundPayloadHarness> {
slackTestApiPromise ??= import(slackTestApiModuleId) as Promise<{
createSlackOutboundPayloadHarness: CreateSlackOutboundPayloadHarness;
}>;
const { createSlackOutboundPayloadHarness } = await slackTestApiPromise;
return createSlackOutboundPayloadHarness;
}
async function getWhatsAppOutboundAsync(): Promise<ChannelOutboundAdapter> {
whatsappTestApiPromise ??= import(whatsappTestApiModuleId) as Promise<{
whatsappOutbound: ChannelOutboundAdapter;
}>;
const { whatsappOutbound } = await whatsappTestApiPromise;
return whatsappOutbound;
}
type PayloadHarnessParams = {
payload: ReplyPayload;
sendResults?: Array<{ messageId: string }>;
};
type PayloadLike = {
mediaUrl?: string;
mediaUrls?: string[];
text?: string;
};
type SendResultLike = {
messageId: string;
[key: string]: unknown;
};
type ChunkingMode =
| {
longTextLength: number;
maxChunkLength: number;
mode: "split";
}
| {
longTextLength: number;
mode: "passthrough";
};
function installChannelOutboundPayloadContractSuite(params: {
channel: string;
chunking: ChunkingMode;
createHarness: (params: { payload: PayloadLike; sendResults?: SendResultLike[] }) =>
| {
run: () => Promise<Record<string, unknown>>;
sendMock: Mock;
to: string;
}
| Promise<{
run: () => Promise<Record<string, unknown>>;
sendMock: Mock;
to: string;
}>;
}) {
beforeEach(() => {
resetGlobalHookRunner();
});
it("text-only delegates to sendText", async () => {
const { run, sendMock, to } = await params.createHarness({
payload: { text: "hello" },
});
const result = await run();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledWith(to, "hello", expect.any(Object));
expect(result).toMatchObject({ channel: params.channel });
});
it("single media delegates to sendMedia", async () => {
const { run, sendMock, to } = await params.createHarness({
payload: { text: "cap", mediaUrl: "https://example.com/a.jpg" },
});
const result = await run();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledWith(
to,
"cap",
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
);
expect(result).toMatchObject({ channel: params.channel });
});
it("multi-media iterates URLs with caption on first", async () => {
const { run, sendMock, to } = await params.createHarness({
payload: {
text: "caption",
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
},
sendResults: [{ messageId: "m-1" }, { messageId: "m-2" }],
});
const result = await run();
expect(sendMock).toHaveBeenCalledTimes(2);
expect(sendMock).toHaveBeenNthCalledWith(
1,
to,
"caption",
expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
);
expect(sendMock).toHaveBeenNthCalledWith(
2,
to,
"",
expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
);
expect(result).toMatchObject({ channel: params.channel, messageId: "m-2" });
});
it("empty payload returns no-op", async () => {
const { run, sendMock } = await params.createHarness({ payload: {} });
const result = await run();
expect(sendMock).not.toHaveBeenCalled();
expect(result).toEqual({ channel: params.channel, messageId: "" });
});
if (params.chunking.mode === "passthrough") {
it("text exceeding chunk limit is sent as-is when chunker is null", async () => {
const text = "a".repeat(params.chunking.longTextLength);
const { run, sendMock, to } = await params.createHarness({ payload: { text } });
const result = await run();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledWith(to, text, expect.any(Object));
expect(result).toMatchObject({ channel: params.channel });
});
return;
}
const chunking = params.chunking;
it("chunking splits long text", async () => {
const text = "a".repeat(chunking.longTextLength);
const { run, sendMock } = await params.createHarness({
payload: { text },
sendResults: [{ messageId: "c-1" }, { messageId: "c-2" }],
});
const result = await run();
expect(sendMock.mock.calls.length).toBeGreaterThanOrEqual(2);
for (const call of sendMock.mock.calls) {
expect((call[1] as string).length).toBeLessThanOrEqual(chunking.maxChunkLength);
}
expect(result).toMatchObject({ channel: params.channel });
});
}
function buildChannelSendResult(channel: string, result: Record<string, unknown>) {
return {
channel,
messageId: typeof result.messageId === "string" ? result.messageId : "",
};
}
function createDiscordHarness(params: PayloadHarnessParams) {
const sendDiscord = vi.fn();
primeChannelOutboundSendMock(
sendDiscord,
{ messageId: "dc-1", channelId: "123456" },
params.sendResults,
);
const ctx = {
cfg: {},
to: "channel:123456",
text: "",
payload: params.payload,
deps: {
sendDiscord,
},
};
return {
run: async () => await (await getDiscordOutbound()).sendPayload!(ctx),
sendMock: sendDiscord,
to: ctx.to,
};
}
function createWhatsAppHarness(params: PayloadHarnessParams) {
const sendWhatsApp = vi.fn();
primeChannelOutboundSendMock(sendWhatsApp, { messageId: "wa-1" }, params.sendResults);
const ctx = {
cfg: {},
to: "5511999999999@c.us",
text: "",
payload: params.payload,
deps: {
whatsapp: sendWhatsApp,
},
};
return {
run: async () => await (await getWhatsAppOutboundAsync()).sendPayload!(ctx),
sendMock: sendWhatsApp,
to: ctx.to,
};
}
function createDirectTextMediaHarness(params: PayloadHarnessParams) {
const sendFn = vi.fn();
primeChannelOutboundSendMock(sendFn, { messageId: "m1" }, params.sendResults);
const outbound = createDirectTextMediaOutbound({
channel: "imessage",
resolveSender: () => sendFn,
resolveMaxBytes: () => undefined,
buildTextOptions: (opts) => opts as never,
buildMediaOptions: (opts) => opts as never,
});
const ctx = {
cfg: {},
to: "user1",
text: "",
payload: params.payload,
};
return {
run: async () => await outbound.sendPayload!(ctx),
sendMock: sendFn,
to: ctx.to,
};
}
function createZaloHarness(params: PayloadHarnessParams) {
const sendZalo = vi.fn();
primeChannelOutboundSendMock(sendZalo, { ok: true, messageId: "zl-1" }, params.sendResults);
const ctx = {
cfg: {},
to: "123456789",
text: "",
payload: params.payload,
};
return {
run: async () =>
await sendPayloadWithChunkedTextAndMedia({
ctx,
textChunkLimit: 2000,
chunker: chunkTextForOutbound,
sendText: async (nextCtx) =>
buildChannelSendResult(
"zalo",
await sendZalo(nextCtx.to, nextCtx.text, {
accountId: undefined,
cfg: nextCtx.cfg,
}),
),
sendMedia: async (nextCtx) =>
buildChannelSendResult(
"zalo",
await sendZalo(nextCtx.to, nextCtx.text, {
accountId: undefined,
cfg: nextCtx.cfg,
mediaUrl: nextCtx.mediaUrl,
}),
),
emptyResult: { channel: "zalo", messageId: "" },
}),
sendMock: sendZalo,
to: ctx.to,
};
}
function createZalouserHarness(params: PayloadHarnessParams) {
const sendZalouser = vi.fn();
primeChannelOutboundSendMock(sendZalouser, { ok: true, messageId: "zlu-1" }, params.sendResults);
const ctx = {
cfg: {},
to: "987654321",
text: "",
payload: params.payload,
};
return {
run: async () =>
await sendPayloadWithChunkedTextAndMedia({
ctx,
sendText: async (nextCtx) => {
return buildChannelSendResult(
"zalouser",
await sendZalouser(nextCtx.to, nextCtx.text, {
profile: "default",
isGroup: false,
textMode: "markdown",
textChunkMode: "length",
textChunkLimit: 1200,
}),
);
},
sendMedia: async (nextCtx) => {
return buildChannelSendResult(
"zalouser",
await sendZalouser(nextCtx.to, nextCtx.text, {
profile: "default",
isGroup: false,
mediaUrl: nextCtx.mediaUrl,
textMode: "markdown",
textChunkMode: "length",
textChunkLimit: 1200,
}),
);
},
emptyResult: { channel: "zalouser", messageId: "" },
}),
sendMock: sendZalouser,
to: ctx.to,
};
}
export function installSlackOutboundPayloadContractSuite() {
installChannelOutboundPayloadContractSuite({
channel: "slack",
chunking: { mode: "passthrough", longTextLength: 5000 },
createHarness: async (params) => (await getCreateSlackOutboundPayloadHarness())(params),
});
}
export function installDiscordOutboundPayloadContractSuite() {
installChannelOutboundPayloadContractSuite({
channel: "discord",
chunking: { mode: "passthrough", longTextLength: 3000 },
createHarness: createDiscordHarness,
});
}
export function installWhatsAppOutboundPayloadContractSuite() {
installChannelOutboundPayloadContractSuite({
channel: "whatsapp",
chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 },
createHarness: createWhatsAppHarness,
});
}
export function installZaloOutboundPayloadContractSuite() {
installChannelOutboundPayloadContractSuite({
channel: "zalo",
chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
createHarness: createZaloHarness,
});
}
export function installZalouserOutboundPayloadContractSuite() {
installChannelOutboundPayloadContractSuite({
channel: "zalouser",
chunking: { mode: "passthrough", longTextLength: 3000 },
createHarness: createZalouserHarness,
});
}
export function installDirectTextMediaOutboundPayloadContractSuite() {
installChannelOutboundPayloadContractSuite({
channel: "imessage",
chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 },
createHarness: createDirectTextMediaHarness,
});
}

View file

@ -0,0 +1,452 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import type {
BaseProbeResult,
BaseTokenResolution,
ChannelDirectoryEntry,
} from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { LineProbeResult } from "../../../src/plugin-sdk/line.js";
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
import { withEnvAsync } from "../../../src/test-utils/env.js";
type DiscordDirectoryContractApiSurface = {
listDiscordDirectoryPeersFromConfig: DirectoryListFn;
listDiscordDirectoryGroupsFromConfig: DirectoryListFn;
};
type DiscordProbe = BaseProbeResult;
type DiscordTokenResolution = BaseTokenResolution;
type IMessageProbe = BaseProbeResult;
type SignalProbe = BaseProbeResult;
type SlackDirectoryContractApiSurface = {
listSlackDirectoryPeersFromConfig: DirectoryListFn;
listSlackDirectoryGroupsFromConfig: DirectoryListFn;
};
type SlackProbe = BaseProbeResult;
type TelegramDirectoryContractApiSurface = {
listTelegramDirectoryPeersFromConfig: DirectoryListFn;
listTelegramDirectoryGroupsFromConfig: DirectoryListFn;
};
type TelegramProbe = BaseProbeResult;
type TelegramTokenResolution = BaseTokenResolution;
type WhatsAppDirectoryContractApiSurface = {
listWhatsAppDirectoryPeersFromConfig: DirectoryListFn;
listWhatsAppDirectoryGroupsFromConfig: DirectoryListFn;
};
let discordDirectoryContractApi: Promise<DiscordDirectoryContractApiSurface> | undefined;
let slackDirectoryContractApi: Promise<SlackDirectoryContractApiSurface> | undefined;
let telegramDirectoryContractApi: Promise<TelegramDirectoryContractApiSurface> | undefined;
let whatsappDirectoryContractApi: Promise<WhatsAppDirectoryContractApiSurface> | undefined;
async function importDirectoryContractApi<T extends object>(pluginId: string): Promise<T> {
const moduleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId,
artifactBasename: "directory-contract-api.js",
});
return (await import(moduleId)) as T;
}
function getDiscordDirectoryContractApi(): Promise<DiscordDirectoryContractApiSurface> {
discordDirectoryContractApi ??=
importDirectoryContractApi<DiscordDirectoryContractApiSurface>("discord");
return discordDirectoryContractApi;
}
function getSlackDirectoryContractApi(): Promise<SlackDirectoryContractApiSurface> {
slackDirectoryContractApi ??=
importDirectoryContractApi<SlackDirectoryContractApiSurface>("slack");
return slackDirectoryContractApi;
}
function getTelegramDirectoryContractApi(): Promise<TelegramDirectoryContractApiSurface> {
telegramDirectoryContractApi ??=
importDirectoryContractApi<TelegramDirectoryContractApiSurface>("telegram");
return telegramDirectoryContractApi;
}
function getWhatsAppDirectoryContractApi(): Promise<WhatsAppDirectoryContractApiSurface> {
whatsappDirectoryContractApi ??=
importDirectoryContractApi<WhatsAppDirectoryContractApiSurface>("whatsapp");
return whatsappDirectoryContractApi;
}
type DirectoryListFn = (params: {
cfg: OpenClawConfig;
accountId?: string;
query?: string | null;
limit?: number | null;
}) => Promise<ChannelDirectoryEntry[]>;
async function listDirectoryEntriesWithDefaults(listFn: DirectoryListFn, cfg: OpenClawConfig) {
return await listFn({
cfg,
accountId: "default",
query: null,
limit: null,
});
}
async function expectDirectoryIds(
listFn: DirectoryListFn,
cfg: OpenClawConfig,
expected: string[],
options?: { sorted?: boolean },
) {
const entries = await listDirectoryEntriesWithDefaults(listFn, cfg);
const ids = entries.map((entry) => entry.id);
expect(options?.sorted ? ids.toSorted() : ids).toEqual(expected);
}
export function describeDiscordPluginsCoreExtensionContract() {
describe("discord plugins-core extension contract", () => {
it("DiscordProbe satisfies BaseProbeResult", () => {
expectTypeOf<DiscordProbe>().toMatchTypeOf<BaseProbeResult>();
});
it("Discord token resolution satisfies BaseTokenResolution", () => {
expectTypeOf<DiscordTokenResolution>().toMatchTypeOf<BaseTokenResolution>();
});
it("lists peers/groups from config (numeric ids only)", async () => {
const { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig } =
await getDiscordDirectoryContractApi();
const cfg = {
channels: {
discord: {
token: "discord-test",
dm: { allowFrom: ["<@111>", "<@!333>", "nope"] },
dms: { "222": {} },
guilds: {
"123": {
users: ["<@12345>", " discord:444 ", "not-an-id"],
channels: {
"555": {},
"<#777>": {},
"channel:666": {},
general: {},
},
},
},
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(
listDiscordDirectoryPeersFromConfig,
cfg,
["user:111", "user:12345", "user:222", "user:333", "user:444"],
{ sorted: true },
);
await expectDirectoryIds(
listDiscordDirectoryGroupsFromConfig,
cfg,
["channel:555", "channel:666", "channel:777"],
{
sorted: true,
},
);
});
it("keeps directories readable when tokens are unresolved SecretRefs", async () => {
const { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig } =
await getDiscordDirectoryContractApi();
const envSecret = {
source: "env",
provider: "default",
id: "MISSING_TEST_SECRET",
} as const;
const cfg = {
channels: {
discord: {
token: envSecret,
dm: { allowFrom: ["<@111>"] },
guilds: {
"123": {
channels: {
"555": {},
},
},
},
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(listDiscordDirectoryPeersFromConfig, cfg, ["user:111"]);
await expectDirectoryIds(listDiscordDirectoryGroupsFromConfig, cfg, ["channel:555"]);
});
it("applies query and limit filtering for config-backed directories", async () => {
const { listDiscordDirectoryGroupsFromConfig } = await getDiscordDirectoryContractApi();
const cfg = {
channels: {
discord: {
token: "discord-test",
guilds: {
"123": {
channels: {
"555": {},
"666": {},
"777": {},
},
},
},
},
},
} as unknown as OpenClawConfig;
const groups = await listDiscordDirectoryGroupsFromConfig({
cfg,
accountId: "default",
query: "666",
limit: 5,
});
expect(groups.map((entry) => entry.id)).toEqual(["channel:666"]);
});
});
}
export function describeSlackPluginsCoreExtensionContract() {
describe("slack plugins-core extension contract", () => {
it("SlackProbe satisfies BaseProbeResult", () => {
expectTypeOf<SlackProbe>().toMatchTypeOf<BaseProbeResult>();
});
it("lists peers/groups from config", async () => {
const { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig } =
await getSlackDirectoryContractApi();
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
dm: { allowFrom: ["U123", "user:U999"] },
dms: { U234: {} },
channels: { C111: { users: ["U777"] } },
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(
listSlackDirectoryPeersFromConfig,
cfg,
["user:u123", "user:u234", "user:u777", "user:u999"],
{ sorted: true },
);
await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]);
});
it("keeps directories readable when tokens are unresolved SecretRefs", async () => {
const { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig } =
await getSlackDirectoryContractApi();
const envSecret = {
source: "env",
provider: "default",
id: "MISSING_TEST_SECRET",
} as const;
const cfg = {
channels: {
slack: {
botToken: envSecret,
appToken: envSecret,
dm: { allowFrom: ["U123"] },
channels: { C111: {} },
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(listSlackDirectoryPeersFromConfig, cfg, ["user:u123"]);
await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]);
});
it("applies query and limit filtering for config-backed directories", async () => {
const { listSlackDirectoryPeersFromConfig } = await getSlackDirectoryContractApi();
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
dm: { allowFrom: ["U100", "U200"] },
dms: { U300: {} },
},
},
} as unknown as OpenClawConfig;
const peers = await listSlackDirectoryPeersFromConfig({
cfg,
accountId: "default",
query: "user:u",
limit: 2,
});
expect(peers).toHaveLength(2);
expect(peers.every((entry) => entry.id.startsWith("user:u"))).toBe(true);
});
});
}
export function describeTelegramPluginsCoreExtensionContract() {
describe("telegram plugins-core extension contract", () => {
it("TelegramProbe satisfies BaseProbeResult", () => {
expectTypeOf<TelegramProbe>().toMatchTypeOf<BaseProbeResult>();
});
it("Telegram token resolution satisfies BaseTokenResolution", () => {
expectTypeOf<TelegramTokenResolution>().toMatchTypeOf<BaseTokenResolution>();
});
it("lists peers/groups from config", async () => {
const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } =
await getTelegramDirectoryContractApi();
const cfg = {
channels: {
telegram: {
botToken: "telegram-test",
allowFrom: ["123", "alice", "tg:@bob"],
dms: { "456": {} },
groups: { "-1001": {}, "*": {} },
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(
listTelegramDirectoryPeersFromConfig,
cfg,
["123", "456", "@alice", "@bob"],
{
sorted: true,
},
);
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
});
it("keeps fallback semantics when accountId is omitted", async () => {
const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } =
await getTelegramDirectoryContractApi();
await withEnvAsync({ TELEGRAM_BOT_TOKEN: "tok-env" }, async () => {
const cfg = {
channels: {
telegram: {
allowFrom: ["alice"],
groups: { "-1001": {} },
accounts: {
work: {
botToken: "tok-work",
allowFrom: ["bob"],
groups: { "-2002": {} },
},
},
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]);
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
});
});
it("keeps directories readable when tokens are unresolved SecretRefs", async () => {
const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } =
await getTelegramDirectoryContractApi();
const envSecret = {
source: "env",
provider: "default",
id: "MISSING_TEST_SECRET",
} as const;
const cfg = {
channels: {
telegram: {
botToken: envSecret,
allowFrom: ["alice"],
groups: { "-1001": {} },
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]);
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
});
it("applies query and limit filtering for config-backed directories", async () => {
const { listTelegramDirectoryGroupsFromConfig } = await getTelegramDirectoryContractApi();
const cfg = {
channels: {
telegram: {
botToken: "telegram-test",
groups: { "-1001": {}, "-1002": {}, "-2001": {} },
},
},
} as unknown as OpenClawConfig;
const groups = await listTelegramDirectoryGroupsFromConfig({
cfg,
accountId: "default",
query: "-100",
limit: 1,
});
expect(groups.map((entry) => entry.id)).toEqual(["-1001"]);
});
});
}
export function describeWhatsAppPluginsCoreExtensionContract() {
describe("whatsapp plugins-core extension contract", () => {
it("lists peers/groups from config", async () => {
const { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig } =
await getWhatsAppDirectoryContractApi();
const cfg = {
channels: {
whatsapp: {
allowFrom: ["+15550000000", "*", "123@g.us"],
groups: { "999@g.us": { requireMention: true }, "*": {} },
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(listWhatsAppDirectoryPeersFromConfig, cfg, ["+15550000000"]);
await expectDirectoryIds(listWhatsAppDirectoryGroupsFromConfig, cfg, ["999@g.us"]);
});
it("applies query and limit filtering for config-backed directories", async () => {
const { listWhatsAppDirectoryGroupsFromConfig } = await getWhatsAppDirectoryContractApi();
const cfg = {
channels: {
whatsapp: {
groups: { "111@g.us": {}, "222@g.us": {}, "333@s.whatsapp.net": {} },
},
},
} as unknown as OpenClawConfig;
const groups = await listWhatsAppDirectoryGroupsFromConfig({
cfg,
accountId: "default",
query: "@g.us",
limit: 1,
});
expect(groups.map((entry) => entry.id)).toEqual(["111@g.us"]);
});
});
}
export function describeSignalPluginsCoreExtensionContract() {
describe("signal plugins-core extension contract", () => {
it("SignalProbe satisfies BaseProbeResult", () => {
expectTypeOf<SignalProbe>().toMatchTypeOf<BaseProbeResult>();
});
});
}
export function describeIMessagePluginsCoreExtensionContract() {
describe("imessage plugins-core extension contract", () => {
it("IMessageProbe satisfies BaseProbeResult", () => {
expectTypeOf<IMessageProbe>().toMatchTypeOf<BaseProbeResult>();
});
});
}
export function describeLinePluginsCoreExtensionContract() {
describe("line plugins-core extension contract", () => {
it("LineProbeResult satisfies BaseProbeResult", () => {
expectTypeOf<LineProbeResult>().toMatchTypeOf<BaseProbeResult>();
});
});
}

View file

@ -0,0 +1,213 @@
import { requireBundledChannelPlugin } from "../../../src/channels/plugins/bundled.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
type ActionsContractEntry = {
id: string;
plugin: Pick<ChannelPlugin, "id" | "actions">;
unsupportedAction?: string;
cases: Array<{
name: string;
cfg: OpenClawConfig;
expectedActions: string[];
expectedCapabilities?: string[];
beforeTest?: () => void;
}>;
};
let actionContractRegistryCache: ActionsContractEntry[] | undefined;
export function getActionContractRegistry(): ActionsContractEntry[] {
actionContractRegistryCache ??= [
{
id: "slack",
plugin: requireBundledChannelPlugin("slack"),
unsupportedAction: "poll",
cases: [
{
name: "configured account exposes default Slack actions",
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
} as OpenClawConfig,
expectedActions: [
"send",
"react",
"reactions",
"read",
"edit",
"delete",
"download-file",
"upload-file",
"pin",
"unpin",
"list-pins",
"member-info",
"emoji-list",
],
expectedCapabilities: ["blocks"],
},
{
name: "interactive replies add the shared interactive capability",
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
capabilities: {
interactiveReplies: true,
},
},
},
} as OpenClawConfig,
expectedActions: [
"send",
"react",
"reactions",
"read",
"edit",
"delete",
"download-file",
"upload-file",
"pin",
"unpin",
"list-pins",
"member-info",
"emoji-list",
],
expectedCapabilities: ["blocks", "interactive"],
},
{
name: "missing tokens disables the actions surface",
cfg: {
channels: {
slack: {
enabled: true,
},
},
} as OpenClawConfig,
expectedActions: [],
expectedCapabilities: [],
},
],
},
{
id: "mattermost",
plugin: requireBundledChannelPlugin("mattermost"),
unsupportedAction: "poll",
cases: [
{
name: "configured account exposes send and react",
cfg: {
channels: {
mattermost: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
},
},
} as OpenClawConfig,
expectedActions: ["send", "react"],
expectedCapabilities: ["buttons"],
},
{
name: "reactions can be disabled while send stays available",
cfg: {
channels: {
mattermost: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
actions: { reactions: false },
},
},
} as OpenClawConfig,
expectedActions: ["send"],
expectedCapabilities: ["buttons"],
},
{
name: "missing bot credentials disables the actions surface",
cfg: {
channels: {
mattermost: {
enabled: true,
},
},
} as OpenClawConfig,
expectedActions: [],
expectedCapabilities: [],
},
],
},
{
id: "telegram",
plugin: requireBundledChannelPlugin("telegram"),
cases: [
{
name: "exposes configured Telegram actions and capabilities",
cfg: {
channels: {
telegram: {
botToken: "123:telegram-test-token",
},
},
} as OpenClawConfig,
expectedActions: [
"send",
"poll",
"react",
"delete",
"edit",
"topic-create",
"topic-edit",
],
expectedCapabilities: ["interactive", "buttons"],
},
],
},
{
id: "discord",
plugin: requireBundledChannelPlugin("discord"),
cases: [
{
name: "describes configured Discord actions and capabilities",
cfg: {
channels: {
discord: {
token: "Bot token-main",
actions: {
polls: true,
reactions: true,
permissions: false,
messages: false,
pins: false,
threads: false,
search: false,
stickers: false,
memberInfo: false,
roleInfo: false,
emojiUploads: false,
stickerUploads: false,
channelInfo: false,
channels: false,
voiceStatus: false,
events: false,
roles: false,
moderation: false,
presence: false,
},
},
},
} as OpenClawConfig,
expectedActions: ["send", "poll", "react", "reactions", "emoji-list"],
expectedCapabilities: ["interactive", "components"],
},
],
},
];
return actionContractRegistryCache;
}

View file

@ -0,0 +1,238 @@
import { expect, it } from "vitest";
import type {
ChannelAccountSnapshot,
ChannelAccountState,
ChannelSetupInput,
} from "../../../src/channels/plugins/types.core.js";
import type {
ChannelMessageActionName,
ChannelMessageCapability,
ChannelPlugin,
} from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
function sortStrings(values: readonly string[]) {
return [...values].toSorted((left, right) => left.localeCompare(right));
}
function resolveContractMessageDiscovery(params: {
plugin: Pick<ChannelPlugin, "actions">;
cfg: OpenClawConfig;
}) {
const actions = params.plugin.actions;
if (!actions) {
return {
actions: [] as ChannelMessageActionName[],
capabilities: [] as readonly ChannelMessageCapability[],
};
}
const discovery = actions.describeMessageTool({ cfg: params.cfg }) ?? null;
return {
actions: Array.isArray(discovery?.actions) ? [...discovery.actions] : [],
capabilities: Array.isArray(discovery?.capabilities) ? discovery.capabilities : [],
};
}
export function installChannelPluginContractSuite(params: {
plugin: Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config">;
}) {
it("satisfies the base channel plugin contract", () => {
const { plugin } = params;
expect(typeof plugin.id).toBe("string");
expect(plugin.id.trim()).not.toBe("");
expect(plugin.meta.id).toBe(plugin.id);
expect(plugin.meta.label.trim()).not.toBe("");
expect(plugin.meta.selectionLabel.trim()).not.toBe("");
expect(plugin.meta.docsPath).toMatch(/^\/channels\//);
expect(plugin.meta.blurb.trim()).not.toBe("");
expect(plugin.capabilities.chatTypes.length).toBeGreaterThan(0);
expect(typeof plugin.config.listAccountIds).toBe("function");
expect(typeof plugin.config.resolveAccount).toBe("function");
});
}
type ChannelActionsContractCase = {
name: string;
cfg: OpenClawConfig;
expectedActions: readonly ChannelMessageActionName[];
expectedCapabilities?: readonly ChannelMessageCapability[];
beforeTest?: () => void;
};
export function installChannelActionsContractSuite(params: {
plugin: Pick<ChannelPlugin, "id" | "actions">;
cases: readonly ChannelActionsContractCase[];
unsupportedAction?: ChannelMessageActionName;
}) {
it("exposes the base message actions contract", () => {
expect(params.plugin.actions).toBeDefined();
expect(typeof params.plugin.actions?.describeMessageTool).toBe("function");
});
for (const testCase of params.cases) {
it(`actions contract: ${testCase.name}`, () => {
testCase.beforeTest?.();
const discovery = resolveContractMessageDiscovery({
plugin: params.plugin,
cfg: testCase.cfg,
});
const actions = discovery.actions;
const capabilities = discovery.capabilities;
expect(actions).toEqual([...new Set(actions)]);
expect(capabilities).toEqual([...new Set(capabilities)]);
expect(sortStrings(actions)).toEqual(sortStrings(testCase.expectedActions));
expect(sortStrings(capabilities)).toEqual(sortStrings(testCase.expectedCapabilities ?? []));
if (params.plugin.actions?.supportsAction) {
for (const action of testCase.expectedActions) {
expect(params.plugin.actions.supportsAction({ action })).toBe(true);
}
if (
params.unsupportedAction &&
!testCase.expectedActions.includes(params.unsupportedAction)
) {
expect(params.plugin.actions.supportsAction({ action: params.unsupportedAction })).toBe(
false,
);
}
}
});
}
}
type ChannelSetupContractCase<ResolvedAccount> = {
name: string;
cfg: OpenClawConfig;
accountId?: string;
input: ChannelSetupInput;
expectedAccountId?: string;
expectedValidation?: string | null;
beforeTest?: () => void;
assertPatchedConfig?: (cfg: OpenClawConfig) => void;
assertResolvedAccount?: (account: ResolvedAccount, cfg: OpenClawConfig) => void;
};
export function installChannelSetupContractSuite<ResolvedAccount>(params: {
plugin: Pick<ChannelPlugin<ResolvedAccount>, "id" | "config" | "setup">;
cases: readonly ChannelSetupContractCase<ResolvedAccount>[];
}) {
it("exposes the base setup contract", () => {
expect(params.plugin.setup).toBeDefined();
expect(typeof params.plugin.setup?.applyAccountConfig).toBe("function");
});
for (const testCase of params.cases) {
it(`setup contract: ${testCase.name}`, () => {
testCase.beforeTest?.();
const resolvedAccountId =
params.plugin.setup?.resolveAccountId?.({
cfg: testCase.cfg,
accountId: testCase.accountId,
input: testCase.input,
}) ??
testCase.accountId ??
"default";
expect(resolvedAccountId).toBe(testCase.expectedAccountId ?? resolvedAccountId);
const validation =
params.plugin.setup?.validateInput?.({
cfg: testCase.cfg,
accountId: resolvedAccountId,
input: testCase.input,
}) ?? null;
expect(validation).toBe(testCase.expectedValidation ?? null);
const nextCfg = params.plugin.setup?.applyAccountConfig({
cfg: testCase.cfg,
accountId: resolvedAccountId,
input: testCase.input,
});
expect(nextCfg).toBeDefined();
const account = params.plugin.config.resolveAccount(nextCfg!, resolvedAccountId);
testCase.assertPatchedConfig?.(nextCfg!);
testCase.assertResolvedAccount?.(account, nextCfg!);
});
}
}
type ChannelStatusContractCase<Probe> = {
name: string;
cfg: OpenClawConfig;
accountId?: string;
runtime?: ChannelAccountSnapshot;
probe?: Probe;
beforeTest?: () => void;
expectedState?: ChannelAccountState;
resolveStateInput?: {
configured: boolean;
enabled: boolean;
};
assertSnapshot?: (snapshot: ChannelAccountSnapshot) => void;
assertSummary?: (summary: Record<string, unknown>) => void;
};
export function installChannelStatusContractSuite<ResolvedAccount, Probe = unknown>(params: {
plugin: Pick<ChannelPlugin<ResolvedAccount, Probe>, "id" | "config" | "status">;
cases: readonly ChannelStatusContractCase<Probe>[];
}) {
it("exposes the base status contract", () => {
expect(params.plugin.status).toBeDefined();
expect(typeof params.plugin.status?.buildAccountSnapshot).toBe("function");
});
if (params.plugin.status?.defaultRuntime) {
it("status contract: default runtime is shaped like an account snapshot", () => {
expect(typeof params.plugin.status?.defaultRuntime?.accountId).toBe("string");
});
}
for (const testCase of params.cases) {
it(`status contract: ${testCase.name}`, async () => {
testCase.beforeTest?.();
const account = params.plugin.config.resolveAccount(testCase.cfg, testCase.accountId);
const snapshot = await params.plugin.status!.buildAccountSnapshot!({
account,
cfg: testCase.cfg,
runtime: testCase.runtime,
probe: testCase.probe,
});
expect(typeof snapshot.accountId).toBe("string");
expect(snapshot.accountId.trim()).not.toBe("");
testCase.assertSnapshot?.(snapshot);
if (params.plugin.status?.buildChannelSummary) {
const defaultAccountId =
params.plugin.config.defaultAccountId?.(testCase.cfg) ?? testCase.accountId ?? "default";
const summary = await params.plugin.status.buildChannelSummary({
account,
cfg: testCase.cfg,
defaultAccountId,
snapshot,
});
expect(summary).toEqual(expect.any(Object));
testCase.assertSummary?.(summary);
}
if (testCase.expectedState && params.plugin.status?.resolveAccountState) {
const state = params.plugin.status.resolveAccountState({
account,
cfg: testCase.cfg,
configured: testCase.resolveStateInput?.configured ?? true,
enabled: testCase.resolveStateInput?.enabled ?? true,
});
expect(state).toBe(testCase.expectedState);
}
});
}
}

View file

@ -0,0 +1,18 @@
import { listBundledChannelPlugins } from "../../../src/channels/plugins/bundled.js";
import { normalizeChannelMeta } from "../../../src/channels/plugins/meta-normalization.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
type PluginContractEntry = {
id: string;
plugin: Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config">;
};
export function getPluginContractRegistry(): PluginContractEntry[] {
return listBundledChannelPlugins().map((plugin) => ({
id: plugin.id,
plugin: {
...plugin,
meta: normalizeChannelMeta({ id: plugin.id, meta: plugin.meta }),
},
}));
}

View file

@ -0,0 +1,630 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { expect } from "vitest";
import { createChannelConversationBindingManager } from "../../../src/channels/plugins/conversation-bindings.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
getSessionBindingService,
type SessionBindingCapabilities,
type SessionBindingRecord,
} from "../../../src/infra/outbound/session-binding-service.js";
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
import { createTestRegistry } from "../../../src/test-utils/channel-plugins.js";
import {
sessionBindingContractChannelIds,
type SessionBindingContractChannelId,
} from "./manifest.js";
import { importBundledChannelContractArtifact } from "./runtime-artifacts.js";
import "../../../src/channels/plugins/registry.js";
type SessionBindingContractEntry = {
id: string;
expectedCapabilities: SessionBindingCapabilities;
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
bindAndResolve: () => Promise<SessionBindingRecord>;
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
cleanup: () => Promise<void> | void;
beforeEach?: () => Promise<void> | void;
};
const contractApiPromises = new Map<string, Promise<Record<string, unknown>>>();
const matrixSessionBindingStateDir = fs.mkdtempSync(
path.join(os.tmpdir(), "openclaw-matrix-session-binding-contract-"),
);
const matrixSessionBindingAuth = {
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
} as const;
async function getContractApi<T extends Record<string, unknown>>(pluginId: string): Promise<T> {
const existing = contractApiPromises.get(pluginId);
if (existing) {
return (await existing) as T;
}
const next = importBundledChannelContractArtifact<T>(pluginId, "contract-api");
contractApiPromises.set(pluginId, next);
return await next;
}
function expectResolvedSessionBinding(params: {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
targetSessionKey: string;
}) {
expect(
getSessionBindingService().resolveByConversation({
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
}),
)?.toMatchObject({
targetSessionKey: params.targetSessionKey,
});
}
async function unbindAndExpectClearedSessionBinding(binding: SessionBindingRecord) {
const service = getSessionBindingService();
const removed = await service.unbind({
bindingId: binding.bindingId,
reason: "contract-test",
});
expect(removed.map((entry) => entry.bindingId)).toContain(binding.bindingId);
expect(service.resolveByConversation(binding.conversation)).toBeNull();
}
function expectClearedSessionBinding(params: {
channel: string;
accountId: string;
conversationId: string;
}) {
expect(
getSessionBindingService().resolveByConversation({
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
}),
).toBeNull();
}
function resetMatrixSessionBindingStateDir() {
fs.rmSync(matrixSessionBindingStateDir, { recursive: true, force: true });
fs.mkdirSync(matrixSessionBindingStateDir, { recursive: true });
}
async function createContractMatrixThreadBindingManager() {
resetMatrixSessionBindingStateDir();
const { setMatrixRuntime, createMatrixThreadBindingManager } =
await getContractApi<MatrixContractApi>("matrix");
setMatrixRuntime({
state: {
resolveStateDir: () => matrixSessionBindingStateDir,
},
} as never);
return await createMatrixThreadBindingManager({
accountId: matrixSessionBindingAuth.accountId,
auth: matrixSessionBindingAuth,
client: {} as never,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
enableSweeper: false,
});
}
const baseSessionBindingCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
type ChannelConversationBindingManagerFactory = NonNullable<
NonNullable<ChannelPlugin["conversationBindings"]>["createManager"]
>;
type BlueBubblesContractApi = {
blueBubblesConversationBindingTesting: {
resetBlueBubblesConversationBindingsForTests: () => void;
};
createBlueBubblesConversationBindingManager: ChannelConversationBindingManagerFactory;
};
type DiscordContractApi = {
createThreadBindingManager: (params: {
accountId: string;
cfg?: OpenClawConfig;
persist: boolean;
enableSweeper: boolean;
}) => unknown;
discordThreadBindingTesting: {
resetThreadBindingsForTests: () => void;
};
};
type FeishuContractApi = {
createFeishuThreadBindingManager: (params: {
accountId?: string;
cfg: OpenClawConfig;
}) => unknown;
feishuThreadBindingTesting: {
resetFeishuThreadBindingsForTests: () => void;
};
};
type IMessageContractApi = {
createIMessageConversationBindingManager: ChannelConversationBindingManagerFactory;
imessageConversationBindingTesting: {
resetIMessageConversationBindingsForTests: () => void;
};
};
type MatrixContractApi = {
createMatrixThreadBindingManager: (params: {
accountId: string;
auth: typeof matrixSessionBindingAuth;
client: unknown;
idleTimeoutMs: number;
maxAgeMs: number;
enableSweeper: boolean;
}) => Promise<unknown>;
resetMatrixThreadBindingsForTests: () => void;
setMatrixRuntime: (runtime: unknown) => void;
};
type TelegramContractApi = {
createTelegramThreadBindingManager: (params: {
accountId: string;
persist: boolean;
enableSweeper: boolean;
}) => unknown;
resetTelegramThreadBindingsForTests: () => Promise<void>;
};
function setRegistryBackedConversationBindingPlugin(params: {
id: SessionBindingContractChannelId;
createManager: ChannelConversationBindingManagerFactory;
}) {
const plugin = {
id: params.id,
meta: {
id: params.id,
label: params.id,
selectionLabel: params.id,
blurb: "session binding contract fixture",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
},
conversationBindings: {
supportsCurrentConversationBinding: true,
createManager: params.createManager,
},
} as unknown as ChannelPlugin;
setActivePluginRegistry(
createTestRegistry([
{
pluginId: params.id,
plugin,
source: "test",
},
]),
);
}
async function prepareBlueBubblesSessionBindingContract() {
const api = await getContractApi<BlueBubblesContractApi>("bluebubbles");
api.blueBubblesConversationBindingTesting.resetBlueBubblesConversationBindingsForTests();
setRegistryBackedConversationBindingPlugin({
id: "bluebubbles",
createManager: api.createBlueBubblesConversationBindingManager,
});
}
async function prepareDiscordSessionBindingContract() {
const api = await getContractApi<DiscordContractApi>("discord");
api.discordThreadBindingTesting.resetThreadBindingsForTests();
}
async function prepareFeishuSessionBindingContract() {
const api = await getContractApi<FeishuContractApi>("feishu");
api.feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
}
async function prepareIMessageSessionBindingContract() {
const api = await getContractApi<IMessageContractApi>("imessage");
api.imessageConversationBindingTesting.resetIMessageConversationBindingsForTests();
setRegistryBackedConversationBindingPlugin({
id: "imessage",
createManager: api.createIMessageConversationBindingManager,
});
}
async function prepareMatrixSessionBindingContract() {
const api = await getContractApi<MatrixContractApi>("matrix");
api.resetMatrixThreadBindingsForTests();
}
async function prepareTelegramSessionBindingContract() {
const api = await getContractApi<TelegramContractApi>("telegram");
await api.resetTelegramThreadBindingsForTests();
}
const sessionBindingContractEntries: Record<
SessionBindingContractChannelId,
Omit<SessionBindingContractEntry, "id">
> = {
bluebubbles: {
beforeEach: prepareBlueBubblesSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
},
getCapabilities: () => {
void createChannelConversationBindingManager({
channelId: "bluebubbles",
cfg: baseSessionBindingCfg,
accountId: "default",
});
return getSessionBindingService().getCapabilities({
channel: "bluebubbles",
accountId: "default",
});
},
bindAndResolve: async () => {
await createChannelConversationBindingManager({
channelId: "bluebubbles",
cfg: baseSessionBindingCfg,
accountId: "default",
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:codex:acp:binding:bluebubbles:default:abc123",
targetKind: "session",
conversation: {
channel: "bluebubbles",
accountId: "default",
conversationId: "+15555550123",
},
placement: "current",
metadata: {
agentId: "codex",
label: "codex-main",
},
});
expectResolvedSessionBinding({
channel: "bluebubbles",
accountId: "default",
conversationId: "+15555550123",
targetSessionKey: "agent:codex:acp:binding:bluebubbles:default:abc123",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const manager = await createChannelConversationBindingManager({
channelId: "bluebubbles",
cfg: baseSessionBindingCfg,
accountId: "default",
});
await manager?.stop();
expectClearedSessionBinding({
channel: "bluebubbles",
accountId: "default",
conversationId: "+15555550123",
});
},
},
discord: {
beforeEach: prepareDiscordSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: async () => {
const { createThreadBindingManager } = await getContractApi<DiscordContractApi>("discord");
createThreadBindingManager({
accountId: "default",
cfg: baseSessionBindingCfg,
persist: false,
enableSweeper: false,
});
return getSessionBindingService().getCapabilities({
channel: "discord",
accountId: "default",
});
},
bindAndResolve: async () => {
const { createThreadBindingManager } = await getContractApi<DiscordContractApi>("discord");
createThreadBindingManager({
accountId: "default",
cfg: baseSessionBindingCfg,
persist: false,
enableSweeper: false,
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:discord:child:thread-1",
targetKind: "subagent",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel:123456789012345678",
},
placement: "current",
metadata: {
agentId: "discord",
label: "discord-child",
},
});
expectResolvedSessionBinding({
channel: "discord",
accountId: "default",
conversationId: "channel:123456789012345678",
targetSessionKey: "agent:discord:child:thread-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
expectClearedSessionBinding({
channel: "discord",
accountId: "default",
conversationId: "channel:123456789012345678",
});
},
},
feishu: {
beforeEach: prepareFeishuSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
},
getCapabilities: async () => {
const { createFeishuThreadBindingManager } =
await getContractApi<FeishuContractApi>("feishu");
createFeishuThreadBindingManager({
accountId: "default",
cfg: baseSessionBindingCfg,
});
return getSessionBindingService().getCapabilities({
channel: "feishu",
accountId: "default",
});
},
bindAndResolve: async () => {
const { createFeishuThreadBindingManager } =
await getContractApi<FeishuContractApi>("feishu");
createFeishuThreadBindingManager({
accountId: "default",
cfg: baseSessionBindingCfg,
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:feishu:child:thread-1",
targetKind: "subagent",
conversation: {
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
parentConversationId: "oc_group_chat",
},
placement: "current",
metadata: {
agentId: "feishu",
label: "feishu-child",
},
});
expectResolvedSessionBinding({
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
parentConversationId: "oc_group_chat",
targetSessionKey: "agent:feishu:child:thread-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
expectClearedSessionBinding({
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
});
},
},
imessage: {
beforeEach: prepareIMessageSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
},
getCapabilities: () => {
void createChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
return getSessionBindingService().getCapabilities({
channel: "imessage",
accountId: "default",
});
},
bindAndResolve: async () => {
await createChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:imessage:current",
targetKind: "session",
conversation: {
channel: "imessage",
accountId: "default",
conversationId: "+15555550124",
},
placement: "current",
metadata: {
agentId: "imessage",
label: "imessage-main",
},
});
expectResolvedSessionBinding({
channel: "imessage",
accountId: "default",
conversationId: "+15555550124",
targetSessionKey: "agent:imessage:current",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const manager = await createChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
await manager?.stop();
expectClearedSessionBinding({
channel: "imessage",
accountId: "default",
conversationId: "+15555550124",
});
},
},
matrix: {
beforeEach: prepareMatrixSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: async () => {
await createContractMatrixThreadBindingManager();
return getSessionBindingService().getCapabilities({
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
});
},
bindAndResolve: async () => {
await createContractMatrixThreadBindingManager();
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:matrix:thread",
targetKind: "subagent",
conversation: {
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
parentConversationId: "!room:example.org",
},
placement: "current",
metadata: {
agentId: "matrix",
label: "matrix-thread",
},
});
expectResolvedSessionBinding({
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
parentConversationId: "!room:example.org",
targetSessionKey: "agent:matrix:thread",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
expectClearedSessionBinding({
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
});
},
},
telegram: {
beforeEach: prepareTelegramSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: async () => {
const { createTelegramThreadBindingManager } =
await getContractApi<TelegramContractApi>("telegram");
createTelegramThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
return getSessionBindingService().getCapabilities({
channel: "telegram",
accountId: "default",
});
},
bindAndResolve: async () => {
const { createTelegramThreadBindingManager } =
await getContractApi<TelegramContractApi>("telegram");
createTelegramThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:telegram:child:thread-1",
targetKind: "subagent",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
},
placement: "current",
metadata: {
agentId: "telegram",
label: "telegram-topic",
},
});
expectResolvedSessionBinding({
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
targetSessionKey: "agent:telegram:child:thread-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
expectClearedSessionBinding({
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
});
},
},
};
let sessionBindingContractRegistryCache: SessionBindingContractEntry[] | undefined;
export function getSessionBindingContractRegistry(): SessionBindingContractEntry[] {
sessionBindingContractRegistryCache ??= sessionBindingContractChannelIds.map((id) =>
Object.assign({ id }, sessionBindingContractEntries[id]),
);
return sessionBindingContractRegistryCache;
}

View file

@ -0,0 +1,227 @@
import { expect } from "vitest";
import { requireBundledChannelPlugin } from "../../../src/channels/plugins/bundled.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
type SetupContractEntry = {
id: string;
plugin: Pick<ChannelPlugin, "id" | "config" | "setup">;
cases: Array<{
name: string;
cfg: OpenClawConfig;
accountId?: string;
input: Record<string, unknown>;
expectedAccountId?: string;
expectedValidation?: string | null;
beforeTest?: () => void;
assertPatchedConfig?: (cfg: OpenClawConfig) => void;
assertResolvedAccount?: (account: unknown, cfg: OpenClawConfig) => void;
}>;
};
type StatusContractEntry = {
id: string;
plugin: Pick<ChannelPlugin, "id" | "config" | "status">;
cases: Array<{
name: string;
cfg: OpenClawConfig;
accountId?: string;
runtime?: Record<string, unknown>;
probe?: unknown;
beforeTest?: () => void;
assertSnapshot?: (snapshot: Record<string, unknown>) => void;
assertSummary?: (summary: Record<string, unknown>) => void;
}>;
};
let setupContractRegistryCache: SetupContractEntry[] | undefined;
let statusContractRegistryCache: StatusContractEntry[] | undefined;
export function getSetupContractRegistry(): SetupContractEntry[] {
setupContractRegistryCache ??= [
{
id: "slack",
plugin: requireBundledChannelPlugin("slack"),
cases: [
{
name: "default account stores tokens and enables the channel",
cfg: {} as OpenClawConfig,
input: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
expectedAccountId: "default",
assertPatchedConfig: (cfg) => {
expect(cfg.channels?.slack?.enabled).toBe(true);
expect(cfg.channels?.slack?.botToken).toBe("xoxb-test");
expect(cfg.channels?.slack?.appToken).toBe("xapp-test");
},
},
{
name: "non-default env setup is rejected",
cfg: {} as OpenClawConfig,
accountId: "ops",
input: {
useEnv: true,
},
expectedAccountId: "ops",
expectedValidation: "Slack env tokens can only be used for the default account.",
},
],
},
{
id: "mattermost",
plugin: requireBundledChannelPlugin("mattermost"),
cases: [
{
name: "default account stores token and normalized base URL",
cfg: {} as OpenClawConfig,
input: {
botToken: "test-token",
httpUrl: "https://chat.example.com/",
},
expectedAccountId: "default",
assertPatchedConfig: (cfg) => {
expect(cfg.channels?.mattermost?.enabled).toBe(true);
expect(cfg.channels?.mattermost?.botToken).toBe("test-token");
expect(cfg.channels?.mattermost?.baseUrl).toBe("https://chat.example.com");
},
},
{
name: "missing credentials are rejected",
cfg: {} as OpenClawConfig,
input: {
httpUrl: "",
},
expectedAccountId: "default",
expectedValidation: "Mattermost requires --bot-token and --http-url (or --use-env).",
},
],
},
{
id: "line",
plugin: requireBundledChannelPlugin("line"),
cases: [
{
name: "default account stores token and secret",
cfg: {} as OpenClawConfig,
input: {
channelAccessToken: "line-token",
channelSecret: "line-secret",
},
expectedAccountId: "default",
assertPatchedConfig: (cfg) => {
expect(cfg.channels?.line?.enabled).toBe(true);
expect(cfg.channels?.line?.channelAccessToken).toBe("line-token");
expect(cfg.channels?.line?.channelSecret).toBe("line-secret");
},
},
{
name: "non-default env setup is rejected",
cfg: {} as OpenClawConfig,
accountId: "ops",
input: {
useEnv: true,
},
expectedAccountId: "ops",
expectedValidation: "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account.",
},
],
},
];
return setupContractRegistryCache;
}
export function getStatusContractRegistry(): StatusContractEntry[] {
statusContractRegistryCache ??= [
{
id: "slack",
plugin: requireBundledChannelPlugin("slack"),
cases: [
{
name: "configured account produces a configured status snapshot",
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
} as OpenClawConfig,
runtime: {
accountId: "default",
connected: true,
running: true,
},
probe: { ok: true },
assertSnapshot: (snapshot) => {
expect(snapshot.accountId).toBe("default");
expect(snapshot.enabled).toBe(true);
expect(snapshot.configured).toBe(true);
},
},
],
},
{
id: "mattermost",
plugin: requireBundledChannelPlugin("mattermost"),
cases: [
{
name: "configured account preserves connectivity details in the snapshot",
cfg: {
channels: {
mattermost: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
},
},
} as OpenClawConfig,
runtime: {
accountId: "default",
connected: true,
lastConnectedAt: 1234,
},
probe: { ok: true },
assertSnapshot: (snapshot) => {
expect(snapshot.accountId).toBe("default");
expect(snapshot.enabled).toBe(true);
expect(snapshot.configured).toBe(true);
expect(snapshot.connected).toBe(true);
expect(snapshot.baseUrl).toBe("https://chat.example.com");
},
},
],
},
{
id: "line",
plugin: requireBundledChannelPlugin("line"),
cases: [
{
name: "configured account produces a webhook status snapshot",
cfg: {
channels: {
line: {
enabled: true,
channelAccessToken: "line-token",
channelSecret: "line-secret",
},
},
} as OpenClawConfig,
runtime: {
accountId: "default",
running: true,
},
probe: { ok: true },
assertSnapshot: (snapshot) => {
expect(snapshot.accountId).toBe("default");
expect(snapshot.enabled).toBe(true);
expect(snapshot.configured).toBe(true);
expect(snapshot.mode).toBe("webhook");
},
},
],
},
];
return statusContractRegistryCache;
}

View file

@ -0,0 +1,58 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { resolveBundledChannelWorkspacePath } from "../../../src/plugins/bundled-channel-runtime.js";
import {
resolvePluginRuntimeModulePath,
resolvePluginRuntimeRecord,
} from "../../../src/plugins/runtime/runtime-plugin-boundary.js";
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
function resolveBundledChannelWorkspaceArtifactPath(
pluginId: string,
entryBaseName: string,
): string | null {
const normalizedEntryBaseName = entryBaseName.replace(/\.(?:[cm]?js|ts)$/u, "");
const pluginRoot = resolveBundledChannelWorkspacePath({
rootDir: REPO_ROOT,
pluginId,
});
if (!pluginRoot) {
return null;
}
for (const extension of ["js", "ts"]) {
const candidate = path.join(pluginRoot, `${normalizedEntryBaseName}.${extension}`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
export function resolveBundledChannelContractArtifactUrl(
pluginId: string,
entryBaseName: string,
): string {
const normalizedEntryBaseName = entryBaseName.replace(/\.(?:[cm]?js|ts)$/u, "");
const record = resolvePluginRuntimeRecord(pluginId, () => {
throw new Error(`missing bundled channel plugin '${pluginId}'`);
});
if (!record) {
throw new Error(`missing bundled channel plugin '${pluginId}'`);
}
const modulePath =
resolvePluginRuntimeModulePath(record, normalizedEntryBaseName) ??
resolveBundledChannelWorkspaceArtifactPath(pluginId, entryBaseName);
if (!modulePath) {
throw new Error(`missing ${entryBaseName} for bundled channel plugin '${pluginId}'`);
}
return pathToFileURL(modulePath).href;
}
export async function importBundledChannelContractArtifact<T extends object>(
pluginId: string,
entryBaseName: string,
): Promise<T> {
return (await import(resolveBundledChannelContractArtifactUrl(pluginId, entryBaseName))) as T;
}

View file

@ -0,0 +1,146 @@
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { SecurityAuditFinding } from "../../../src/security/audit.types.js";
import {
loadBundledPluginPublicSurfaceSync,
resolveRelativeBundledPluginPublicModuleId,
} from "../../../src/test-utils/bundled-plugin-public-surface.js";
type SecurityAuditAccount = {
accountId: string;
enabled?: boolean;
token?: unknown;
tokenSource?: string;
config?: Record<string, unknown>;
[key: string]: unknown;
};
type FlexibleSecurityAuditParams = {
cfg?: OpenClawConfig;
sourceConfig?: OpenClawConfig;
account: SecurityAuditAccount;
accountId?: string | null;
orderedAccountIds?: string[];
hasExplicitAccountPath?: boolean;
};
type ConfigSecurityAuditParams = {
cfg: OpenClawConfig;
};
type AsyncChannelSecurityAuditCollector = (
params: FlexibleSecurityAuditParams,
) => Promise<SecurityAuditFinding[]>;
type SyncChannelSecurityAuditCollector = (
params: FlexibleSecurityAuditParams,
) => SecurityAuditFinding[];
type ConfigSecurityAuditCollector = (params: ConfigSecurityAuditParams) => SecurityAuditFinding[];
type DiscordSecurityAuditSurface = {
collectDiscordSecurityAuditFindings: AsyncChannelSecurityAuditCollector;
};
type FeishuSecuritySurface = {
collectFeishuSecurityAuditFindings: ConfigSecurityAuditCollector;
};
type SlackSecuritySurface = {
collectSlackSecurityAuditFindings: AsyncChannelSecurityAuditCollector;
};
type SynologyChatSecuritySurface = {
collectSynologyChatSecurityAuditFindings: SyncChannelSecurityAuditCollector;
};
type TelegramSecuritySurface = {
collectTelegramSecurityAuditFindings: AsyncChannelSecurityAuditCollector;
};
type ZalouserSecuritySurface = {
collectZalouserSecurityAuditFindings: SyncChannelSecurityAuditCollector;
};
const discordSecurityAuditModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "discord",
artifactBasename: "security-audit-contract-api.js",
});
const slackSecurityModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "slack",
artifactBasename: "security-contract-api.js",
});
const telegramSecurityModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "telegram",
artifactBasename: "security-audit-contract-api.js",
});
let discordSecurityAuditSurfacePromise: Promise<DiscordSecurityAuditSurface> | undefined;
let slackSecuritySurfacePromise: Promise<SlackSecuritySurface> | undefined;
let telegramSecuritySurfacePromise: Promise<TelegramSecuritySurface> | undefined;
function loadDiscordSecurityAuditSurface(): Promise<DiscordSecurityAuditSurface> {
discordSecurityAuditSurfacePromise ??= import(
discordSecurityAuditModuleId
) as Promise<DiscordSecurityAuditSurface>;
return discordSecurityAuditSurfacePromise;
}
function loadFeishuSecuritySurface(): FeishuSecuritySurface {
return loadBundledPluginPublicSurfaceSync<FeishuSecuritySurface>({
pluginId: "feishu",
artifactBasename: "security-contract-api.js",
});
}
function loadSlackSecuritySurface(): Promise<SlackSecuritySurface> {
slackSecuritySurfacePromise ??= import(slackSecurityModuleId) as Promise<SlackSecuritySurface>;
return slackSecuritySurfacePromise;
}
function loadSynologyChatSecuritySurface(): SynologyChatSecuritySurface {
return loadBundledPluginPublicSurfaceSync<SynologyChatSecuritySurface>({
pluginId: "synology-chat",
artifactBasename: "contract-api.js",
});
}
function loadTelegramSecuritySurface(): Promise<TelegramSecuritySurface> {
telegramSecuritySurfacePromise ??= import(
telegramSecurityModuleId
) as Promise<TelegramSecuritySurface>;
return telegramSecuritySurfacePromise;
}
function loadZalouserSecuritySurface(): ZalouserSecuritySurface {
return loadBundledPluginPublicSurfaceSync<ZalouserSecuritySurface>({
pluginId: "zalouser",
artifactBasename: "contract-api.js",
});
}
export const collectDiscordSecurityAuditFindings: DiscordSecurityAuditSurface["collectDiscordSecurityAuditFindings"] =
(async (...args) =>
(await loadDiscordSecurityAuditSurface()).collectDiscordSecurityAuditFindings(
...args,
)) as DiscordSecurityAuditSurface["collectDiscordSecurityAuditFindings"];
export const collectFeishuSecurityAuditFindings: FeishuSecuritySurface["collectFeishuSecurityAuditFindings"] =
((...args) =>
loadFeishuSecuritySurface().collectFeishuSecurityAuditFindings(
...args,
)) as FeishuSecuritySurface["collectFeishuSecurityAuditFindings"];
export const collectSlackSecurityAuditFindings: SlackSecuritySurface["collectSlackSecurityAuditFindings"] =
(async (...args) =>
(await loadSlackSecuritySurface()).collectSlackSecurityAuditFindings(
...args,
)) as SlackSecuritySurface["collectSlackSecurityAuditFindings"];
export const collectSynologyChatSecurityAuditFindings: SynologyChatSecuritySurface["collectSynologyChatSecurityAuditFindings"] =
((...args) =>
loadSynologyChatSecuritySurface().collectSynologyChatSecurityAuditFindings(
...args,
)) as SynologyChatSecuritySurface["collectSynologyChatSecurityAuditFindings"];
export const collectTelegramSecurityAuditFindings: TelegramSecuritySurface["collectTelegramSecurityAuditFindings"] =
(async (...args) =>
(await loadTelegramSecuritySurface()).collectTelegramSecurityAuditFindings(
...args,
)) as TelegramSecuritySurface["collectTelegramSecurityAuditFindings"];
export const collectZalouserSecurityAuditFindings: ZalouserSecuritySurface["collectZalouserSecurityAuditFindings"] =
((...args) =>
loadZalouserSecuritySurface().collectZalouserSecurityAuditFindings(
...args,
)) as ZalouserSecuritySurface["collectZalouserSecurityAuditFindings"];

View file

@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "../../../src/config/config.js";
import {
__testing as sessionBindingTesting,
type SessionBindingCapabilities,
type SessionBindingRecord,
} from "../../../src/infra/outbound/session-binding-service.js";
import { resetPluginRuntimeStateForTest } from "../../../src/plugins/runtime.js";
import { getSessionBindingContractRegistry } from "./registry-session-binding.js";
function resolveSessionBindingContractRuntimeConfig(id: string) {
if (id !== "discord" && id !== "matrix") {
return {};
}
return {
plugins: {
entries: {
[id]: {
enabled: true,
},
},
},
};
}
function installSessionBindingContractSuite(params: {
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
bindAndResolve: () => Promise<SessionBindingRecord>;
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
cleanup: () => Promise<void> | void;
expectedCapabilities: SessionBindingCapabilities;
}) {
it("registers, binds, unbinds, and cleans up session bindings", async () => {
expect(await Promise.resolve(params.getCapabilities())).toEqual(params.expectedCapabilities);
const binding = await params.bindAndResolve();
try {
expect(typeof binding.bindingId).toBe("string");
expect(binding.bindingId.trim()).not.toBe("");
expect(typeof binding.targetSessionKey).toBe("string");
expect(binding.targetSessionKey.trim()).not.toBe("");
expect(["session", "subagent"]).toContain(binding.targetKind);
expect(typeof binding.conversation.channel).toBe("string");
expect(typeof binding.conversation.accountId).toBe("string");
expect(typeof binding.conversation.conversationId).toBe("string");
expect(["active", "ending", "ended"]).toContain(binding.status);
expect(typeof binding.boundAt).toBe("number");
await params.unbindAndVerify(binding);
} finally {
await params.cleanup();
}
});
}
export function describeSessionBindingRegistryBackedContract(id: string) {
const entry = getSessionBindingContractRegistry().find((item) => item.id === id);
if (!entry) {
throw new Error(`missing session binding contract entry for ${id}`);
}
describe(`${entry.id} session binding contract`, () => {
beforeEach(async () => {
resetPluginRuntimeStateForTest();
clearRuntimeConfigSnapshot();
// Keep the suite hermetic; some contract helpers resolve runtime artifacts through config-aware
// plugin boundaries, so never fall back to the developer's real ~/.openclaw/openclaw.json here.
const runtimeConfig = resolveSessionBindingContractRuntimeConfig(entry.id);
// These registry-backed contract suites intentionally exercise bundled runtime facades.
// Opt the bundled-runtime cases in so the activation boundary behaves like real runtime usage.
setRuntimeConfigSnapshot(runtimeConfig);
sessionBindingTesting.resetSessionBindingAdaptersForTests();
await entry.beforeEach?.();
});
afterEach(() => {
clearRuntimeConfigSnapshot();
});
installSessionBindingContractSuite({
expectedCapabilities: entry.expectedCapabilities,
getCapabilities: entry.getCapabilities,
bindAndResolve: entry.bindAndResolve,
unbindAndVerify: entry.unbindAndVerify,
cleanup: entry.cleanup,
});
});
}

View file

@ -0,0 +1,110 @@
import { vi } from "vitest";
import {
listBundledChannelPlugins,
setBundledChannelRuntime,
} from "../../../src/channels/plugins/bundled.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount,
} from "../../../src/plugin-sdk/line.js";
import { channelPluginSurfaceKeys, type ChannelPluginSurface } from "./manifest.js";
function buildBundledPluginModuleId(pluginId: string, artifactBasename: string): string {
return ["..", "..", "..", "extensions", pluginId, artifactBasename].join("/");
}
type SurfaceContractEntry = {
id: string;
plugin: Pick<
ChannelPlugin,
| "id"
| "actions"
| "setup"
| "status"
| "outbound"
| "messaging"
| "threading"
| "directory"
| "gateway"
>;
surfaces: readonly ChannelPluginSurface[];
};
type ThreadingContractEntry = {
id: string;
plugin: Pick<ChannelPlugin, "id" | "threading">;
};
type DirectoryContractEntry = {
id: string;
plugin: Pick<ChannelPlugin, "id" | "directory">;
coverage: "lookups" | "presence";
cfg?: OpenClawConfig;
accountId?: string;
};
const sendMessageMatrixMock = vi.hoisted(() =>
vi.fn(async (to: string, _message: string, opts?: { threadId?: string }) => ({
messageId: opts?.threadId ? "$matrix-thread" : "$matrix-root",
roomId: to.replace(/^room:/, ""),
})),
);
setBundledChannelRuntime("line", {
channel: {
line: {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) =>
resolveLineAccount({ cfg, accountId }),
},
},
} as never);
vi.mock(buildBundledPluginModuleId("matrix", "runtime-api.js"), async () => {
const matrixRuntimeApiModuleId = buildBundledPluginModuleId("matrix", "runtime-api.js");
const actual = await vi.importActual(matrixRuntimeApiModuleId);
return {
...actual,
sendMessageMatrix: sendMessageMatrixMock,
};
});
let surfaceContractRegistryCache: SurfaceContractEntry[] | undefined;
let threadingContractRegistryCache: ThreadingContractEntry[] | undefined;
let directoryContractRegistryCache: DirectoryContractEntry[] | undefined;
export function getSurfaceContractRegistry(): SurfaceContractEntry[] {
surfaceContractRegistryCache ??= listBundledChannelPlugins().map((plugin) => ({
id: plugin.id,
plugin,
surfaces: channelPluginSurfaceKeys.filter((surface) => Boolean(plugin[surface])),
}));
return surfaceContractRegistryCache;
}
export function getThreadingContractRegistry(): ThreadingContractEntry[] {
threadingContractRegistryCache ??= getSurfaceContractRegistry()
.filter((entry) => entry.surfaces.includes("threading"))
.map((entry) => ({
id: entry.id,
plugin: entry.plugin,
}));
return threadingContractRegistryCache;
}
const directoryPresenceOnlyIds = new Set(["whatsapp", "zalouser"]);
export function getDirectoryContractRegistry(): DirectoryContractEntry[] {
directoryContractRegistryCache ??= getSurfaceContractRegistry()
.filter((entry) => entry.surfaces.includes("directory"))
.map((entry) => ({
id: entry.id,
plugin: entry.plugin,
coverage: directoryPresenceOnlyIds.has(entry.id) ? "presence" : "lookups",
}));
return directoryContractRegistryCache;
}

View file

@ -0,0 +1,138 @@
import { expect, it } from "vitest";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
export function installChannelSurfaceContractSuite(params: {
plugin: Pick<
ChannelPlugin,
| "id"
| "actions"
| "setup"
| "status"
| "outbound"
| "messaging"
| "threading"
| "directory"
| "gateway"
>;
surface:
| "actions"
| "setup"
| "status"
| "outbound"
| "messaging"
| "threading"
| "directory"
| "gateway";
}) {
const { plugin, surface } = params;
it(`exposes the ${surface} surface contract`, () => {
if (surface === "actions") {
expect(plugin.actions).toBeDefined();
expect(typeof plugin.actions?.describeMessageTool).toBe("function");
return;
}
if (surface === "setup") {
expect(plugin.setup).toBeDefined();
expect(typeof plugin.setup?.applyAccountConfig).toBe("function");
return;
}
if (surface === "status") {
expect(plugin.status).toBeDefined();
expect(typeof plugin.status?.buildAccountSnapshot).toBe("function");
return;
}
if (surface === "outbound") {
const outbound = plugin.outbound;
expect(outbound).toBeDefined();
expect(["direct", "gateway", "hybrid"]).toContain(outbound?.deliveryMode);
expect(
[
outbound?.sendPayload,
outbound?.sendFormattedText,
outbound?.sendFormattedMedia,
outbound?.sendText,
outbound?.sendMedia,
outbound?.sendPoll,
].some((value) => typeof value === "function"),
).toBe(true);
return;
}
if (surface === "messaging") {
const messaging = plugin.messaging;
expect(messaging).toBeDefined();
expect(
[
messaging?.normalizeTarget,
messaging?.parseExplicitTarget,
messaging?.inferTargetChatType,
messaging?.buildCrossContextComponents,
messaging?.enableInteractiveReplies,
messaging?.hasStructuredReplyPayload,
messaging?.formatTargetDisplay,
messaging?.resolveOutboundSessionRoute,
].some((value) => typeof value === "function"),
).toBe(true);
if (messaging?.targetResolver) {
if (messaging.targetResolver.looksLikeId) {
expect(typeof messaging.targetResolver.looksLikeId).toBe("function");
}
if (messaging.targetResolver.hint !== undefined) {
expect(typeof messaging.targetResolver.hint).toBe("string");
expect(messaging.targetResolver.hint.trim()).not.toBe("");
}
if (messaging.targetResolver.resolveTarget) {
expect(typeof messaging.targetResolver.resolveTarget).toBe("function");
}
}
return;
}
if (surface === "threading") {
const threading = plugin.threading;
expect(threading).toBeDefined();
expect(
[
threading?.resolveReplyToMode,
threading?.buildToolContext,
threading?.resolveAutoThreadId,
threading?.resolveReplyTransport,
threading?.resolveFocusedBinding,
].some((value) => typeof value === "function"),
).toBe(true);
return;
}
if (surface === "directory") {
const directory = plugin.directory;
expect(directory).toBeDefined();
expect(
[
directory?.self,
directory?.listPeers,
directory?.listPeersLive,
directory?.listGroups,
directory?.listGroupsLive,
directory?.listGroupMembers,
].some((value) => typeof value === "function"),
).toBe(true);
return;
}
const gateway = plugin.gateway;
expect(gateway).toBeDefined();
expect(
[
gateway?.startAccount,
gateway?.stopAccount,
gateway?.loginWithQrStart,
gateway?.loginWithQrWait,
gateway?.logoutAccount,
].some((value) => typeof value === "function"),
).toBe(true);
});
}

View file

@ -0,0 +1,229 @@
import { expect, it } from "vitest";
import type {
ChannelDirectoryEntry,
ChannelFocusedBindingContext,
ChannelReplyTransport,
ChannelThreadingToolContext,
} from "../../../src/channels/plugins/types.core.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { createNonExitingRuntime } from "../../../src/runtime.js";
const contractRuntime = createNonExitingRuntime();
function expectDirectoryEntryShape(entry: ChannelDirectoryEntry) {
expect(["user", "group", "channel"]).toContain(entry.kind);
expect(typeof entry.id).toBe("string");
expect(entry.id.trim()).not.toBe("");
if (entry.name !== undefined) {
expect(typeof entry.name).toBe("string");
}
if (entry.handle !== undefined) {
expect(typeof entry.handle).toBe("string");
}
if (entry.avatarUrl !== undefined) {
expect(typeof entry.avatarUrl).toBe("string");
}
if (entry.rank !== undefined) {
expect(typeof entry.rank).toBe("number");
}
}
function expectThreadingToolContextShape(context: ChannelThreadingToolContext) {
if (context.currentChannelId !== undefined) {
expect(typeof context.currentChannelId).toBe("string");
}
if (context.currentChannelProvider !== undefined) {
expect(typeof context.currentChannelProvider).toBe("string");
}
if (context.currentThreadTs !== undefined) {
expect(typeof context.currentThreadTs).toBe("string");
}
if (context.currentMessageId !== undefined) {
expect(["string", "number"]).toContain(typeof context.currentMessageId);
}
if (context.replyToMode !== undefined) {
expect(["off", "first", "all"]).toContain(context.replyToMode);
}
if (context.hasRepliedRef !== undefined) {
expect(typeof context.hasRepliedRef).toBe("object");
}
if (context.skipCrossContextDecoration !== undefined) {
expect(typeof context.skipCrossContextDecoration).toBe("boolean");
}
}
function expectReplyTransportShape(transport: ChannelReplyTransport) {
if (transport.replyToId !== undefined && transport.replyToId !== null) {
expect(typeof transport.replyToId).toBe("string");
}
if (transport.threadId !== undefined && transport.threadId !== null) {
expect(["string", "number"]).toContain(typeof transport.threadId);
}
}
function expectFocusedBindingShape(binding: ChannelFocusedBindingContext) {
expect(typeof binding.conversationId).toBe("string");
expect(binding.conversationId.trim()).not.toBe("");
if (binding.parentConversationId !== undefined) {
expect(typeof binding.parentConversationId).toBe("string");
}
expect(["current", "child"]).toContain(binding.placement);
expect(typeof binding.labelNoun).toBe("string");
expect(binding.labelNoun.trim()).not.toBe("");
}
export function installChannelThreadingContractSuite(params: {
plugin: Pick<ChannelPlugin, "id" | "threading">;
}) {
it("exposes the base threading contract", () => {
expect(params.plugin.threading).toBeDefined();
});
it("keeps threading return values normalized", () => {
const threading = params.plugin.threading;
expect(threading).toBeDefined();
if (threading?.resolveReplyToMode) {
expect(
["off", "first", "all"].includes(
threading.resolveReplyToMode({
cfg: {} as OpenClawConfig,
accountId: "default",
chatType: "group",
}),
),
).toBe(true);
}
const repliedRef = { value: false };
const toolContext = threading?.buildToolContext?.({
cfg: {} as OpenClawConfig,
accountId: "default",
context: {
Channel: "group:test",
From: "user:test",
To: "group:test",
ChatType: "group",
CurrentMessageId: "msg-1",
ReplyToId: "msg-0",
ReplyToIdFull: "thread-0",
MessageThreadId: "thread-0",
NativeChannelId: "native:test",
},
hasRepliedRef: repliedRef,
});
if (toolContext) {
expectThreadingToolContextShape(toolContext);
if (toolContext.hasRepliedRef) {
expect(toolContext.hasRepliedRef).toBe(repliedRef);
}
}
const autoThreadId = threading?.resolveAutoThreadId?.({
cfg: {} as OpenClawConfig,
accountId: "default",
to: "group:test",
toolContext,
replyToId: null,
});
if (autoThreadId !== undefined) {
expect(typeof autoThreadId).toBe("string");
expect(autoThreadId.trim()).not.toBe("");
}
const replyTransport = threading?.resolveReplyTransport?.({
cfg: {} as OpenClawConfig,
accountId: "default",
threadId: "thread-0",
replyToId: "msg-0",
});
if (replyTransport) {
expectReplyTransportShape(replyTransport);
}
const focusedBinding = threading?.resolveFocusedBinding?.({
cfg: {} as OpenClawConfig,
accountId: "default",
context: {
Channel: "group:test",
From: "user:test",
To: "group:test",
ChatType: "group",
CurrentMessageId: "msg-1",
ReplyToId: "msg-0",
ReplyToIdFull: "thread-0",
MessageThreadId: "thread-0",
NativeChannelId: "native:test",
},
});
if (focusedBinding) {
expectFocusedBindingShape(focusedBinding);
}
});
}
export function installChannelDirectoryContractSuite(params: {
plugin: Pick<ChannelPlugin, "id" | "directory">;
coverage?: "lookups" | "presence";
cfg?: OpenClawConfig;
accountId?: string;
}) {
it("exposes the base directory contract", async () => {
const directory = params.plugin.directory;
expect(directory).toBeDefined();
if (params.coverage === "presence") {
return;
}
const self = await directory?.self?.({
cfg: params.cfg ?? ({} as OpenClawConfig),
accountId: params.accountId ?? "default",
runtime: contractRuntime,
});
if (self) {
expectDirectoryEntryShape(self);
}
const peers =
(await directory?.listPeers?.({
cfg: params.cfg ?? ({} as OpenClawConfig),
accountId: params.accountId ?? "default",
query: "",
limit: 5,
runtime: contractRuntime,
})) ?? [];
expect(Array.isArray(peers)).toBe(true);
for (const peer of peers) {
expectDirectoryEntryShape(peer);
}
const groups =
(await directory?.listGroups?.({
cfg: params.cfg ?? ({} as OpenClawConfig),
accountId: params.accountId ?? "default",
query: "",
limit: 5,
runtime: contractRuntime,
})) ?? [];
expect(Array.isArray(groups)).toBe(true);
for (const group of groups) {
expectDirectoryEntryShape(group);
}
if (directory?.listGroupMembers && groups[0]?.id) {
const members = await directory.listGroupMembers({
cfg: params.cfg ?? ({} as OpenClawConfig),
accountId: params.accountId ?? "default",
groupId: groups[0].id,
limit: 5,
runtime: contractRuntime,
});
expect(Array.isArray(members)).toBe(true);
for (const member of members) {
expectDirectoryEntryShape(member);
}
}
});
}

View file

@ -0,0 +1,98 @@
import * as bundledChannelModule from "../../../src/channels/plugins/bundled.js";
import type {
ChannelConfigRuntimeSchema,
ChannelConfigSchema,
} from "../../../src/channels/plugins/types.plugin.js";
import { listBundledPluginMetadata } from "../../../src/plugins/bundled-plugin-metadata.js";
type BundledChannelRuntimeMap = ReadonlyMap<string, ChannelConfigRuntimeSchema>;
type BundledChannelConfigSchemaMap = ReadonlyMap<string, ChannelConfigSchema>;
type BundledChannelPluginShape = {
id: string;
configSchema?: ChannelConfigSchema;
};
type BundledChannelMaps = {
runtimeMap: Map<string, ChannelConfigRuntimeSchema>;
configSchemaMap: Map<string, ChannelConfigSchema>;
};
let cachedBundledChannelMaps: BundledChannelMaps | undefined;
function buildBundledChannelMaps(
plugins: readonly BundledChannelPluginShape[],
): BundledChannelMaps {
const runtimeMap = new Map<string, ChannelConfigRuntimeSchema>();
const configSchemaMap = new Map<string, ChannelConfigSchema>();
for (const plugin of plugins) {
const channelSchema = plugin.configSchema;
if (!channelSchema) {
continue;
}
configSchemaMap.set(plugin.id, channelSchema);
if (channelSchema.runtime) {
runtimeMap.set(plugin.id, channelSchema.runtime);
}
}
for (const entry of listBundledPluginMetadata({ includeChannelConfigs: true })) {
const channelConfigs = entry.manifest.channelConfigs;
if (!channelConfigs) {
continue;
}
for (const [channelId, channelConfig] of Object.entries(channelConfigs)) {
const channelSchema = channelConfig?.schema as Record<string, unknown> | undefined;
if (!channelSchema) {
continue;
}
if (!configSchemaMap.has(channelId)) {
configSchemaMap.set(channelId, {
schema: channelSchema,
...(channelConfig.runtime ? { runtime: channelConfig.runtime } : {}),
...(channelConfig.uiHints ? { uiHints: channelConfig.uiHints } : {}),
});
}
if (channelConfig.runtime && !runtimeMap.has(channelId)) {
runtimeMap.set(channelId, channelConfig.runtime);
}
}
}
return { runtimeMap, configSchemaMap };
}
function readBundledChannelPlugins(): readonly BundledChannelPluginShape[] | undefined {
try {
if (typeof bundledChannelModule.listBundledChannelPlugins !== "function") {
return undefined;
}
const plugins = bundledChannelModule.listBundledChannelPlugins();
return Array.isArray(plugins) ? (plugins as readonly BundledChannelPluginShape[]) : undefined;
} catch (error) {
if (error instanceof ReferenceError) {
return undefined;
}
throw error;
}
}
function getBundledChannelMaps(): BundledChannelMaps {
const plugins = readBundledChannelPlugins();
if (plugins && cachedBundledChannelMaps) {
return cachedBundledChannelMaps;
}
const maps = buildBundledChannelMaps(plugins ?? []);
if (plugins) {
cachedBundledChannelMaps = maps;
}
return maps;
}
export function getBundledChannelRuntimeMap(): BundledChannelRuntimeMap {
return getBundledChannelMaps().runtimeMap;
}
export function getBundledChannelConfigSchemaMap(): BundledChannelConfigSchemaMap {
return getBundledChannelMaps().configSchemaMap;
}

View file

@ -0,0 +1,139 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { GENERATED_BASE_CONFIG_SCHEMA } from "../../../src/config/schema.base.generated.js";
export type ConfigHonorInventoryRow = {
key: string;
schemaPaths: string[];
typePaths: string[];
mergePaths: string[];
consumerPaths: string[];
reloadPaths: string[];
testPaths: string[];
notes?: string[];
};
type ConfigHonorProofKey =
| "schemaPaths"
| "typePaths"
| "mergePaths"
| "consumerPaths"
| "reloadPaths"
| "testPaths";
export type ConfigHonorAuditResult = {
schemaKeys: string[];
missingKeys: string[];
extraKeys: string[];
missingSchemaPaths: string[];
missingFiles: string[];
missingProofs: Array<{
key: string;
missing: ConfigHonorProofKey[];
}>;
};
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
function hasSchemaPath(schemaPath: string): boolean {
const segments = schemaPath.split(".");
let current: unknown = GENERATED_BASE_CONFIG_SCHEMA.schema;
for (const segment of segments) {
if (!current || typeof current !== "object") {
return false;
}
if (segment === "*") {
const items = (current as { items?: unknown }).items;
if (!items || typeof items !== "object") {
return false;
}
current = items;
continue;
}
const properties = (current as { properties?: Record<string, unknown> }).properties;
if (!properties || !Object.hasOwn(properties, segment)) {
return false;
}
current = properties[segment];
}
return true;
}
export function listSchemaLeafKeysForPrefixes(prefixes: string[]): string[] {
const keys = new Set<string>();
for (const prefix of prefixes) {
const segments = prefix.split(".");
let current: unknown = GENERATED_BASE_CONFIG_SCHEMA.schema;
for (const segment of segments) {
if (!current || typeof current !== "object") {
current = null;
break;
}
if (segment === "*") {
current = (current as { items?: unknown }).items ?? null;
continue;
}
current = (current as { properties?: Record<string, unknown> }).properties?.[segment] ?? null;
}
const properties = (current as { properties?: Record<string, unknown> } | null)?.properties;
if (!properties) {
continue;
}
for (const key of Object.keys(properties)) {
keys.add(key);
}
}
return [...keys].toSorted();
}
export function auditConfigHonorInventory(params: {
prefixes: string[];
rows: ConfigHonorInventoryRow[];
expectedKeys?: string[];
repoRoot?: string;
}): ConfigHonorAuditResult {
const repoRoot = params.repoRoot ?? REPO_ROOT;
const schemaKeys = listSchemaLeafKeysForPrefixes(params.prefixes);
const expectedKeys = new Set(params.expectedKeys ?? schemaKeys);
const rowKeys = new Set(params.rows.map((row) => row.key));
const missingKeys = [...expectedKeys].filter((key) => !rowKeys.has(key)).toSorted();
const extraKeys = params.rows
.map((row) => row.key)
.filter((key) => !expectedKeys.has(key))
.toSorted();
const missingSchemaPaths = params.rows.flatMap((row) =>
row.schemaPaths.filter((schemaPath) => !hasSchemaPath(schemaPath)),
);
const missingFiles = params.rows.flatMap((row) => {
const files = [...row.typePaths, ...row.mergePaths, ...row.consumerPaths, ...row.testPaths];
return files
.filter((relativePath) => !fs.existsSync(path.join(repoRoot, relativePath)))
.map((relativePath) => `${row.key}:${relativePath}`);
});
const missingProofs = params.rows
.map((row) => {
const missing: ConfigHonorProofKey[] = [
row.schemaPaths.length === 0 ? "schemaPaths" : null,
row.typePaths.length === 0 ? "typePaths" : null,
row.mergePaths.length === 0 ? "mergePaths" : null,
row.consumerPaths.length === 0 ? "consumerPaths" : null,
row.reloadPaths.length === 0 ? "reloadPaths" : null,
row.testPaths.length === 0 ? "testPaths" : null,
].filter((value): value is ConfigHonorProofKey => value !== null);
return missing.length > 0 ? { key: row.key, missing } : null;
})
.filter((row): row is NonNullable<typeof row> => row !== null);
return {
schemaKeys,
missingKeys,
extraKeys,
missingSchemaPaths,
missingFiles,
missingProofs,
};
}

View file

@ -0,0 +1,172 @@
import type { ConfigHonorInventoryRow } from "./config-honor-audit.js";
export const HEARTBEAT_CONFIG_PREFIXES = [
"agents.defaults.heartbeat",
"agents.list.*.heartbeat",
] as const;
export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [
{
key: "every",
schemaPaths: ["agents.defaults.heartbeat.every", "agents.list.*.heartbeat.every"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts", "src/agents/acp-spawn.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts", "src/agents/acp-spawn.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: [
"src/infra/heartbeat-runner.returns-default-unset.test.ts",
"src/gateway/config-reload.test.ts",
],
},
{
key: "model",
schemaPaths: ["agents.defaults.heartbeat.model", "agents.list.*.heartbeat.model"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: [
"src/infra/heartbeat-runner.model-override.test.ts",
"src/gateway/config-reload.test.ts",
],
},
{
key: "prompt",
schemaPaths: ["agents.defaults.heartbeat.prompt", "agents.list.*.heartbeat.prompt"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"],
},
{
key: "includeSystemPromptSection",
schemaPaths: [
"agents.defaults.heartbeat.includeSystemPromptSection",
"agents.list.*.heartbeat.includeSystemPromptSection",
],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/agents/heartbeat-system-prompt.ts"],
consumerPaths: [
"src/agents/heartbeat-system-prompt.ts",
"src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts",
],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/agents/heartbeat-system-prompt.test.ts"],
},
{
key: "ackMaxChars",
schemaPaths: ["agents.defaults.heartbeat.ackMaxChars", "agents.list.*.heartbeat.ackMaxChars"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts"],
},
{
key: "suppressToolErrorWarnings",
schemaPaths: [
"agents.defaults.heartbeat.suppressToolErrorWarnings",
"agents.list.*.heartbeat.suppressToolErrorWarnings",
],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/infra/heartbeat-runner.model-override.test.ts"],
},
{
key: "timeoutSeconds",
schemaPaths: [
"agents.defaults.heartbeat.timeoutSeconds",
"agents.list.*.heartbeat.timeoutSeconds",
],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts", "src/auto-reply/reply/get-reply.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: [
"src/config/zod-schema.agent-defaults.test.ts",
"src/infra/heartbeat-runner.model-override.test.ts",
],
},
{
key: "lightContext",
schemaPaths: ["agents.defaults.heartbeat.lightContext", "agents.list.*.heartbeat.lightContext"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts", "src/agents/bootstrap-files.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: [
"src/infra/heartbeat-runner.model-override.test.ts",
"src/agents/bootstrap-files.test.ts",
"src/gateway/config-reload.test.ts",
],
},
{
key: "isolatedSession",
schemaPaths: [
"agents.defaults.heartbeat.isolatedSession",
"agents.list.*.heartbeat.isolatedSession",
],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/infra/heartbeat-runner.model-override.test.ts"],
},
{
key: "target",
schemaPaths: ["agents.defaults.heartbeat.target", "agents.list.*.heartbeat.target"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"],
consumerPaths: ["src/infra/outbound/targets.ts", "src/infra/heartbeat-runner.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: [
"src/infra/heartbeat-runner.returns-default-unset.test.ts",
"src/cron/service.main-job-passes-heartbeat-target-last.test.ts",
],
},
{
key: "to",
schemaPaths: ["agents.defaults.heartbeat.to", "agents.list.*.heartbeat.to"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"],
consumerPaths: ["src/infra/outbound/targets.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"],
},
{
key: "accountId",
schemaPaths: ["agents.defaults.heartbeat.accountId", "agents.list.*.heartbeat.accountId"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"],
consumerPaths: ["src/infra/outbound/targets.ts", "src/infra/heartbeat-runner.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: [
"src/infra/heartbeat-runner.returns-default-unset.test.ts",
"src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts",
],
},
{
key: "directPolicy",
schemaPaths: ["agents.defaults.heartbeat.directPolicy", "agents.list.*.heartbeat.directPolicy"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"],
consumerPaths: ["src/infra/outbound/targets.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"],
},
{
key: "includeReasoning",
schemaPaths: [
"agents.defaults.heartbeat.includeReasoning",
"agents.list.*.heartbeat.includeReasoning",
],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"],
},
];

View file

@ -0,0 +1,17 @@
import type { ConfigUiHints } from "../../../src/config/schema.js";
// Keep this fixture minimal so redaction tests exercise the hint-matching
// behavior they care about without paying to build the full config schema graph.
export const redactSnapshotTestHints: ConfigUiHints = {
"agents.defaults.memorySearch.remote.apiKey": { sensitive: true },
"agents.list[].memorySearch.remote.apiKey": { sensitive: true },
"broadcast.apiToken[]": { sensitive: true },
"env.GROQ_API_KEY": { sensitive: true },
"gateway.auth.password": { sensitive: true },
"models.providers.*.apiKey": { sensitive: true },
"models.providers.*.baseUrl": { sensitive: true },
"models.providers.*.request.headers.*": { sensitive: true },
"models.providers.*.request.auth.token": { sensitive: true },
"models.providers.*.request.proxy.url": { sensitive: true },
"skills.entries.*.env.GEMINI_API_KEY": { sensitive: true },
};

View file

@ -0,0 +1,188 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { clearAllBootstrapSnapshots } from "../../../src/agents/bootstrap-cache.js";
import { clearSessionStoreCacheForTest } from "../../../src/config/sessions/store.js";
import { createCronServiceState, type CronServiceDeps } from "../../../src/cron/service/state.js";
import type { CronJob, CronJobState } from "../../../src/cron/types.js";
import { resetAgentRunContextForTest } from "../../../src/infra/agent-events.js";
import {
resetCommandQueueStateForTest,
waitForActiveTasks,
} from "../../../src/process/command-queue.js";
import { useFrozenTime, useRealTime } from "../../../src/test-utils/frozen-time.js";
const TOP_OF_HOUR_STAGGER_MS = 5 * 60 * 1_000;
export const noopLogger = {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
trace: () => {},
};
export function setupCronRegressionFixtures(options?: { prefix?: string; baseTimeIso?: string }) {
let fixtureRoot = "";
let fixtureCount = 0;
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), options?.prefix ?? "cron-issues-"));
});
beforeEach(() => {
resetCommandQueueStateForTest();
useFrozenTime(options?.baseTimeIso ?? "2026-02-06T10:05:00.000Z");
});
afterEach(async () => {
vi.clearAllTimers();
vi.restoreAllMocks();
useRealTime();
await waitForActiveTasks(250);
resetCommandQueueStateForTest();
clearSessionStoreCacheForTest();
resetAgentRunContextForTest();
clearAllBootstrapSnapshots();
});
afterAll(async () => {
useRealTime();
await waitForActiveTasks(250);
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
return {
makeStorePath() {
return {
storePath: path.join(fixtureRoot, `case-${fixtureCount++}.jobs.json`),
};
},
};
}
export function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
export function createRunningCronServiceState(params: {
storePath: string;
log: CronServiceDeps["log"];
nowMs: () => number;
jobs: CronJob[];
}) {
const state = createCronServiceState({
cronEnabled: true,
storePath: params.storePath,
log: params.log,
nowMs: params.nowMs,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "ok" }),
});
state.running = true;
state.store = {
version: 1,
jobs: params.jobs,
};
return state;
}
export function topOfHourOffsetMs(jobId: string) {
const digest = crypto.createHash("sha256").update(jobId).digest();
return digest.readUInt32BE(0) % TOP_OF_HOUR_STAGGER_MS;
}
export function createDueIsolatedJob(params: {
id: string;
nowMs: number;
nextRunAtMs: number;
deleteAfterRun?: boolean;
}): CronJob {
return {
id: params.id,
name: params.id,
enabled: true,
deleteAfterRun: params.deleteAfterRun ?? false,
createdAtMs: params.nowMs,
updatedAtMs: params.nowMs,
schedule: { kind: "at", at: new Date(params.nextRunAtMs).toISOString() },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: params.id },
delivery: { mode: "none" },
state: { nextRunAtMs: params.nextRunAtMs },
};
}
export function createDefaultIsolatedRunner(): CronServiceDeps["runIsolatedAgentJob"] {
return vi.fn().mockResolvedValue({
status: "ok",
summary: "ok",
}) as CronServiceDeps["runIsolatedAgentJob"];
}
export function createAbortAwareIsolatedRunner(summary = "late") {
let observedAbortSignal: AbortSignal | undefined;
const started = createDeferred<void>();
const runIsolatedAgentJob = vi.fn(async ({ abortSignal }) => {
observedAbortSignal = abortSignal;
started.resolve();
await new Promise<void>((resolve) => {
if (!abortSignal) {
return;
}
if (abortSignal.aborted) {
resolve();
return;
}
abortSignal.addEventListener("abort", () => resolve(), { once: true });
});
return { status: "ok" as const, summary };
}) as CronServiceDeps["runIsolatedAgentJob"];
return {
runIsolatedAgentJob,
getObservedAbortSignal: () => observedAbortSignal,
waitForStart: () => started.promise,
};
}
export function createIsolatedRegressionJob(params: {
id: string;
name: string;
scheduledAt: number;
schedule: CronJob["schedule"];
payload: CronJob["payload"];
state?: CronJobState;
}): CronJob {
return {
id: params.id,
name: params.name,
enabled: true,
createdAtMs: params.scheduledAt - 86_400_000,
updatedAtMs: params.scheduledAt - 86_400_000,
schedule: params.schedule,
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: params.payload,
delivery: { mode: "announce" },
state: params.state ?? {},
};
}
export async function writeCronJobs(storePath: string, jobs: CronJob[]) {
await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs }), "utf-8");
}
export async function writeCronStoreSnapshot(storePath: string, jobs: unknown[]) {
await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs }), "utf-8");
}

View file

@ -0,0 +1,43 @@
import {
formatUtcTimestamp,
formatZonedTimestamp,
} from "../../src/infra/format-time/format-datetime.js";
export { escapeRegExp } from "../../src/utils.js";
type EnvelopeTimestampZone = string;
export function formatEnvelopeTimestamp(date: Date, zone: EnvelopeTimestampZone = "utc"): string {
const trimmedZone = zone.trim();
const normalized = trimmedZone.toLowerCase();
const weekday = (() => {
try {
if (normalized === "utc" || normalized === "gmt") {
return new Intl.DateTimeFormat("en-US", { timeZone: "UTC", weekday: "short" }).format(date);
}
if (normalized === "local" || normalized === "host") {
return new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(date);
}
return new Intl.DateTimeFormat("en-US", { timeZone: trimmedZone, weekday: "short" }).format(
date,
);
} catch {
return undefined;
}
})();
if (normalized === "utc" || normalized === "gmt") {
const ts = formatUtcTimestamp(date);
return weekday ? `${weekday} ${ts}` : ts;
}
if (normalized === "local" || normalized === "host") {
const ts = formatZonedTimestamp(date) ?? formatUtcTimestamp(date);
return weekday ? `${weekday} ${ts}` : ts;
}
const ts = formatZonedTimestamp(date, { timeZone: trimmedZone }) ?? formatUtcTimestamp(date);
return weekday ? `${weekday} ${ts}` : ts;
}
export function formatLocalEnvelopeTimestamp(date: Date): string {
return formatEnvelopeTimestamp(date, "local");
}

View file

@ -0,0 +1,17 @@
import { setTimeout as nativeSetTimeout } from "node:timers";
import { vi } from "vitest";
export function useFastShortTimeouts(maxDelayMs = 2000): () => void {
const spy = vi.spyOn(global, "setTimeout").mockImplementation(((
handler: TimerHandler,
timeout?: number,
...args: unknown[]
) => {
const delay = typeof timeout === "number" ? timeout : 0;
if (delay > 0 && delay <= maxDelayMs) {
return nativeSetTimeout(handler, 0, ...args);
}
return nativeSetTimeout(handler, delay, ...args);
}) as typeof setTimeout);
return () => spy.mockRestore();
}

View file

@ -0,0 +1,378 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import { request as httpRequest } from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { GatewayClient } from "../../src/gateway/client.js";
import { connectGatewayClient } from "../../src/gateway/test-helpers.e2e.js";
import { loadOrCreateDeviceIdentity } from "../../src/infra/device-identity.js";
import { extractFirstTextBlock } from "../../src/shared/chat-message-content.js";
import { sleep } from "../../src/utils.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../src/utils/message-channel.js";
export { extractFirstTextBlock };
export type ChatEventPayload = {
runId?: string;
sessionKey?: string;
state?: string;
message?: unknown;
};
export type GatewayInstance = {
name: string;
port: number;
hookToken: string;
gatewayToken: string;
homeDir: string;
stateDir: string;
configPath: string;
child: ChildProcessWithoutNullStreams;
stdout: string[];
stderr: string[];
};
const GATEWAY_START_TIMEOUT_MS = 60_000;
const GATEWAY_STOP_TIMEOUT_MS = 1_500;
const GATEWAY_CONNECT_STATUS_TIMEOUT_MS = 2_000;
const GATEWAY_NODE_STATUS_TIMEOUT_MS = 4_000;
const GATEWAY_NODE_STATUS_POLL_MS = 20;
const getFreePort = async () => {
const srv = net.createServer();
await new Promise<void>((resolve) => srv.listen(0, "127.0.0.1", resolve));
const addr = srv.address();
if (!addr || typeof addr === "string") {
srv.close();
throw new Error("failed to bind ephemeral port");
}
await new Promise<void>((resolve) => srv.close(() => resolve()));
return addr.port;
};
async function waitForPortOpen(
proc: ChildProcessWithoutNullStreams,
chunksOut: string[],
chunksErr: string[],
port: number,
timeoutMs: number,
) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (proc.exitCode !== null) {
const stdout = chunksOut.join("");
const stderr = chunksErr.join("");
throw new Error(
`gateway exited before listening (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` +
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
);
}
try {
await new Promise<void>((resolve, reject) => {
const socket = net.connect({ host: "127.0.0.1", port });
socket.once("connect", () => {
socket.destroy();
resolve();
});
socket.once("error", (err) => {
socket.destroy();
reject(err);
});
});
return;
} catch {
// keep polling
}
await sleep(10);
}
const stdout = chunksOut.join("");
const stderr = chunksErr.join("");
throw new Error(
`timeout waiting for gateway to listen on port ${port}\n` +
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
);
}
export async function spawnGatewayInstance(name: string): Promise<GatewayInstance> {
const port = await getFreePort();
const hookToken = `token-${name}-${randomUUID()}`;
const gatewayToken = `gateway-${name}-${randomUUID()}`;
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-e2e-${name}-`));
const configDir = path.join(homeDir, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, "openclaw.json");
const stateDir = path.join(configDir, "state");
const config = {
gateway: {
port,
auth: { mode: "token", token: gatewayToken },
controlUi: { enabled: false },
},
hooks: { enabled: true, token: hookToken, path: "/hooks" },
};
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
const stdout: string[] = [];
const stderr: string[] = [];
let child: ChildProcessWithoutNullStreams | null = null;
try {
child = spawn(
"node",
[
"dist/index.js",
"gateway",
"--port",
String(port),
"--bind",
"loopback",
"--allow-unconfigured",
],
{
cwd: process.cwd(),
env: {
...process.env,
HOME: homeDir,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_GATEWAY_TOKEN: "",
OPENCLAW_GATEWAY_PASSWORD: "",
OPENCLAW_SKIP_CHANNELS: "1",
OPENCLAW_SKIP_PROVIDERS: "1",
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
OPENCLAW_SKIP_CRON: "1",
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
OPENCLAW_SKIP_CANVAS_HOST: "1",
OPENCLAW_TEST_MINIMAL_GATEWAY: "1",
VITEST: "1",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (d) => stdout.push(String(d)));
child.stderr?.on("data", (d) => stderr.push(String(d)));
await waitForPortOpen(child, stdout, stderr, port, GATEWAY_START_TIMEOUT_MS);
return {
name,
port,
hookToken,
gatewayToken,
homeDir,
stateDir,
configPath,
child,
stdout,
stderr,
};
} catch (err) {
if (child && child.exitCode === null && !child.killed) {
try {
child.kill("SIGKILL");
} catch {
// ignore
}
}
await fs.rm(homeDir, { recursive: true, force: true });
throw err;
}
}
export async function stopGatewayInstance(inst: GatewayInstance) {
if (inst.child.exitCode === null && !inst.child.killed) {
try {
inst.child.kill("SIGTERM");
} catch {
// ignore
}
}
const exited = await Promise.race([
new Promise<boolean>((resolve) => {
if (inst.child.exitCode !== null) {
return resolve(true);
}
inst.child.once("exit", () => resolve(true));
}),
sleep(GATEWAY_STOP_TIMEOUT_MS).then(() => false),
]);
if (!exited && inst.child.exitCode === null && !inst.child.killed) {
try {
inst.child.kill("SIGKILL");
} catch {
// ignore
}
}
await fs.rm(inst.homeDir, { recursive: true, force: true });
}
export async function postJson(
url: string,
body: unknown,
headers?: Record<string, string>,
): Promise<{ status: number; json: unknown }> {
const payload = JSON.stringify(body);
const parsed = new URL(url);
return await new Promise<{ status: number; json: unknown }>((resolve, reject) => {
const req = httpRequest(
{
method: "POST",
hostname: parsed.hostname,
port: Number(parsed.port),
path: `${parsed.pathname}${parsed.search}`,
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
...headers,
},
},
(res) => {
let data = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
let json: unknown = null;
if (data.trim()) {
try {
json = JSON.parse(data);
} catch {
json = data;
}
}
resolve({ status: res.statusCode ?? 0, json });
});
},
);
req.on("error", reject);
req.write(payload);
req.end();
});
}
export async function connectNode(
inst: GatewayInstance,
label: string,
): Promise<{ client: GatewayClient; nodeId: string }> {
const identityPath = path.join(inst.homeDir, `${label}-device.json`);
const deviceIdentity = loadOrCreateDeviceIdentity(identityPath);
const nodeId = deviceIdentity.deviceId;
const client = await connectGatewayClient({
url: `ws://127.0.0.1:${inst.port}`,
token: inst.gatewayToken,
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: label,
clientVersion: "1.0.0",
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,
role: "node",
scopes: [],
caps: ["system"],
commands: ["system.run"],
deviceIdentity,
timeoutMessage: `timeout waiting for ${label} to connect`,
});
return { client, nodeId };
}
async function connectStatusClient(
inst: GatewayInstance,
timeoutMs = GATEWAY_CONNECT_STATUS_TIMEOUT_MS,
): Promise<GatewayClient> {
let settled = false;
let timer: NodeJS.Timeout | null = null;
return await new Promise<GatewayClient>((resolve, reject) => {
const finish = (err?: Error) => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
if (err) {
reject(err);
return;
}
resolve(client);
};
const client = new GatewayClient({
url: `ws://127.0.0.1:${inst.port}`,
connectChallengeTimeoutMs: 0,
token: inst.gatewayToken,
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: `status-${inst.name}`,
clientVersion: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.CLI,
onHelloOk: () => {
finish();
},
onConnectError: (err) => finish(err),
onClose: (code, reason) => {
finish(new Error(`gateway closed (${code}): ${reason}`));
},
});
timer = setTimeout(() => {
finish(new Error("timeout waiting for node.list"));
}, timeoutMs);
client.start();
});
}
export async function waitForNodeStatus(
inst: GatewayInstance,
nodeId: string,
timeoutMs = GATEWAY_NODE_STATUS_TIMEOUT_MS,
) {
const deadline = Date.now() + timeoutMs;
const client = await connectStatusClient(
inst,
Math.min(GATEWAY_CONNECT_STATUS_TIMEOUT_MS, timeoutMs),
);
try {
while (Date.now() < deadline) {
const list = await client.request("node.list", {});
const match = list.nodes?.find((n) => n.nodeId === nodeId);
if (match?.connected && match?.paired) {
return;
}
await sleep(GATEWAY_NODE_STATUS_POLL_MS);
}
} finally {
client.stop();
}
throw new Error(`timeout waiting for node status for ${nodeId}`);
}
export async function waitForChatFinalEvent(params: {
events: ChatEventPayload[];
runId: string;
sessionKey: string;
timeoutMs?: number;
}): Promise<ChatEventPayload> {
const deadline = Date.now() + (params.timeoutMs ?? 15_000);
while (Date.now() < deadline) {
const match = params.events.find(
(evt) =>
evt.runId === params.runId && evt.sessionKey === params.sessionKey && evt.state === "final",
);
if (match) {
return match;
}
await sleep(20);
}
throw new Error(`timeout waiting for final chat event (runId=${params.runId})`);
}

View file

@ -0,0 +1,35 @@
import type { OpenClawConfig } from "../../../src/config/config.js";
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
}
export function unwrapRemoteConfigSnapshot(raw: unknown): OpenClawConfig {
const rawObj = asRecord(raw);
const resolved = asRecord(rawObj.resolved);
if (Object.keys(resolved).length > 0) {
return resolved as OpenClawConfig;
}
const wrapped = asRecord(rawObj.config);
if (Object.keys(wrapped).length > 0) {
return wrapped as OpenClawConfig;
}
const legacyPayload = asRecord(rawObj.payload);
const legacyResolved = asRecord(legacyPayload.resolved);
if (Object.keys(legacyResolved).length > 0) {
return legacyResolved as OpenClawConfig;
}
const legacyConfig = asRecord(legacyPayload.config);
if (Object.keys(legacyConfig).length > 0) {
return legacyConfig as OpenClawConfig;
}
if (Object.keys(rawObj).length > 0 && !Object.prototype.hasOwnProperty.call(rawObj, "payload")) {
return rawObj as OpenClawConfig;
}
throw new Error("remote gateway config.get returned empty config payload");
}

View file

@ -0,0 +1,5 @@
import type { GatewayConnectionDetails } from "../../../src/gateway/call.js";
export function shouldFetchRemotePolicyConfig(details: GatewayConnectionDetails): boolean {
return details.urlSource !== "local loopback";
}

View file

@ -0,0 +1,18 @@
import { createServer, type RequestListener } from "node:http";
import type { AddressInfo } from "node:net";
export async function withServer(handler: RequestListener, fn: (baseUrl: string) => Promise<void>) {
const server = createServer(handler);
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address() as AddressInfo | null;
if (!address) {
throw new Error("missing server address");
}
try {
await fn(`http://127.0.0.1:${address.port}`);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}

View file

@ -0,0 +1,8 @@
export async function importFreshModule<TModule>(
from: string,
specifier: string,
): Promise<TModule> {
// Vitest keys module instances by the full URL string, including the query
// suffix. These tests rely on that behavior to emulate code-split chunks.
return (await import(/* @vite-ignore */ new URL(specifier, from).href)) as TModule;
}

View file

@ -0,0 +1,269 @@
import { chunkMarkdownTextWithMode, chunkText } from "../../../src/auto-reply/chunk.js";
import { resolveChannelMediaMaxBytes } from "../../../src/channels/plugins/media-limits.js";
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
resolveOutboundSendDep,
type OutboundSendDeps,
} from "../../../src/infra/outbound/send-deps.js";
import { sanitizeForPlainText } from "../../../src/plugin-sdk/outbound-runtime.js";
type SignalSendFn = (
to: string,
text: string,
options?: Record<string, unknown>,
) => Promise<{ messageId: string } & Record<string, unknown>>;
const MB = 1024 * 1024;
function resolveSignalMaxBytes(cfg: OpenClawConfig, accountId?: string): number | undefined {
const signalCfg = cfg.channels?.signal as
| {
mediaMaxMb?: number;
accounts?: Record<string, { mediaMaxMb?: number }>;
}
| undefined;
const accountMb = accountId ? signalCfg?.accounts?.[accountId]?.mediaMaxMb : undefined;
const mediaMaxMb = accountMb ?? signalCfg?.mediaMaxMb;
return typeof mediaMaxMb === "number" ? mediaMaxMb * MB : undefined;
}
function resolveSignalSender(deps: OutboundSendDeps | undefined): SignalSendFn {
const sender = resolveOutboundSendDep<SignalSendFn>(deps, "signal");
if (!sender) {
throw new Error("missing sendSignal dep");
}
return sender;
}
function resolveSignalTextChunkLimit(cfg: OpenClawConfig, accountId?: string | null): number {
const signalCfg = cfg.channels?.signal as
| {
textChunkLimit?: number;
accounts?: Record<string, { textChunkLimit?: number }>;
}
| undefined;
const accountLimit = accountId ? signalCfg?.accounts?.[accountId]?.textChunkLimit : undefined;
if (typeof accountLimit === "number") {
return accountLimit;
}
return typeof signalCfg?.textChunkLimit === "number" ? signalCfg.textChunkLimit : 4000;
}
function withSignalChannel(result: Awaited<ReturnType<SignalSendFn>>) {
return {
channel: "signal" as const,
...result,
};
}
export const signalOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
textChunkLimit: 4000,
sanitizeText: ({ text }) => sanitizeForPlainText(text),
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined);
const limit = resolveSignalTextChunkLimit(cfg, accountId);
const chunks = chunkMarkdownTextWithMode(text, limit, "length");
const outputChunks = chunks.length === 0 && text ? [text] : chunks;
const results = [];
for (const chunk of outputChunks) {
abortSignal?.throwIfAborted();
results.push(
withSignalChannel(
await send(to, chunk, {
cfg,
maxBytes,
accountId: accountId ?? undefined,
textMode: "plain",
textStyles: [],
}),
),
);
}
return results;
},
sendFormattedMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
abortSignal,
}) => {
abortSignal?.throwIfAborted();
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined);
return withSignalChannel(
await send(to, text, {
cfg,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
textMode: "plain",
textStyles: [],
mediaLocalRoots,
mediaReadFile,
}),
);
},
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined);
return withSignalChannel(
await send(to, text, {
cfg,
maxBytes,
accountId: accountId ?? undefined,
}),
);
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
}) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined);
return withSignalChannel(
await send(to, text, {
cfg,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
mediaLocalRoots,
mediaReadFile,
}),
);
},
};
type WhatsAppSendFn = (
to: string,
text: string,
options?: Record<string, unknown>,
) => Promise<{ messageId: string } & Record<string, unknown>>;
function resolveWhatsAppSender(deps: OutboundSendDeps | undefined): WhatsAppSendFn {
const sender = resolveOutboundSendDep<WhatsAppSendFn>(deps, "whatsapp");
if (!sender) {
throw new Error("missing whatsapp dep");
}
return sender;
}
function withWhatsAppChannel(result: Awaited<ReturnType<WhatsAppSendFn>>) {
return {
channel: "whatsapp" as const,
...result,
};
}
export const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "gateway",
chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4000,
sanitizeText: ({ text }) => sanitizeForPlainText(text),
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
const send = resolveWhatsAppSender(deps);
return withWhatsAppChannel(
await send(to, text, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
gifPlayback,
}),
);
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
gifPlayback,
}) => {
const send = resolveWhatsAppSender(deps);
return withWhatsAppChannel(
await send(to, text, {
verbose: false,
cfg,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId: accountId ?? undefined,
gifPlayback,
}),
);
},
};
function resolveIMessageSender(deps: OutboundSendDeps | undefined) {
const sender = resolveOutboundSendDep<
(
to: string,
text: string,
options?: Record<string, unknown>,
) => Promise<{ messageId: string; chatId?: string }>
>(deps, "imessage");
if (!sender) {
throw new Error("missing sendIMessage dep");
}
return sender;
}
function withIMessageChannel(
result: Awaited<ReturnType<ReturnType<typeof resolveIMessageSender>>>,
) {
return {
channel: "imessage" as const,
...result,
};
}
function resolveIMessageMaxBytes(
cfg: OpenClawConfig,
accountId?: string | null,
): number | undefined {
return resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
accountId,
});
}
export const imessageOutboundForTest: ChannelOutboundAdapter = {
deliveryMode: "direct",
sanitizeText: ({ text }) => text,
sendText: async ({ to, text, accountId, deps }) =>
withIMessageChannel(
await resolveIMessageSender(deps)(to, text, {
accountId: accountId ?? undefined,
}),
),
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, mediaReadFile, accountId, deps }) =>
withIMessageChannel(
await resolveIMessageSender(deps)(to, text, {
mediaUrl,
mediaLocalRoots,
mediaReadFile,
maxBytes: resolveIMessageMaxBytes(cfg, accountId),
accountId: accountId ?? undefined,
}),
),
};

View file

@ -0,0 +1,99 @@
import type {
ChannelId,
ChannelOutboundAdapter,
ChannelPlugin,
} from "../../../src/channels/plugins/types.js";
import {
resolveOutboundSendDep,
type OutboundSendDeps,
} from "../../../src/infra/outbound/send-deps.js";
import { createOutboundTestPlugin } from "../../../src/test-utils/channel-plugins.js";
type HeartbeatSendChannelId = "slack" | "telegram" | "whatsapp";
type HeartbeatSendFn = (
to: string,
text: string,
opts?: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
function createHeartbeatOutboundAdapter(channelId: HeartbeatSendChannelId): ChannelOutboundAdapter {
return {
deliveryMode: "direct",
sendText: async ({ to, text, deps, cfg, accountId, replyToId, threadId, ...opts }) => {
const send = resolveOutboundSendDep<HeartbeatSendFn>(deps as OutboundSendDeps, channelId);
if (!send) {
throw new Error(`Missing ${channelId} outbound send dependency`);
}
const baseOptions = {
verbose: false,
cfg,
accountId,
};
const sendOptions =
channelId === "telegram"
? {
...baseOptions,
...(typeof threadId === "number" ? { messageThreadId: threadId } : {}),
...(typeof replyToId === "string" ? { replyToMessageId: Number(replyToId) } : {}),
}
: {
...baseOptions,
...opts,
...(replyToId ? { replyToId } : {}),
...(threadId !== undefined ? { threadId } : {}),
};
return (await send(to, text, sendOptions)) as never;
},
};
}
function createHeartbeatChannelPlugin(params: {
id: HeartbeatSendChannelId;
label: string;
docsPath: string;
heartbeat?: ChannelPlugin["heartbeat"];
}): ChannelPlugin {
return {
...createOutboundTestPlugin({
id: params.id as ChannelId,
label: params.label,
docsPath: params.docsPath,
outbound: createHeartbeatOutboundAdapter(params.id),
}),
...(params.heartbeat ? { heartbeat: params.heartbeat } : {}),
};
}
export const heartbeatRunnerSlackPlugin = createHeartbeatChannelPlugin({
id: "slack",
label: "Slack",
docsPath: "/channels/slack",
});
export const heartbeatRunnerTelegramPlugin = createHeartbeatChannelPlugin({
id: "telegram",
label: "Telegram",
docsPath: "/channels/telegram",
});
export const heartbeatRunnerWhatsAppPlugin = createHeartbeatChannelPlugin({
id: "whatsapp",
label: "WhatsApp",
docsPath: "/channels/whatsapp",
heartbeat: {
checkReady: async ({ cfg, deps }) => {
if (cfg.web?.enabled === false) {
return { ok: false, reason: "whatsapp-disabled" };
}
const authExists = await (deps?.webAuthExists ?? (async () => true))();
if (!authExists) {
return { ok: false, reason: "whatsapp-not-linked" };
}
const listenerActive = deps?.hasActiveWebListener ? deps.hasActiveWebListener() : true;
if (!listenerActive) {
return { ok: false, reason: "whatsapp-not-running" };
}
return { ok: true, reason: "ok" };
},
},
});

View file

@ -0,0 +1,15 @@
import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
import { loadBundledPluginPublicSurfaceSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
type BundledPluginEntryModule = {
default: {
register(api: OpenClawPluginApi): void;
};
};
export function loadBundledProviderPlugin(pluginId: string): BundledPluginEntryModule["default"] {
return loadBundledPluginPublicSurfaceSync<BundledPluginEntryModule>({
pluginId,
artifactBasename: "index.js",
}).default;
}

View file

@ -0,0 +1,118 @@
import type { VideoGenerationResult } from "openclaw/plugin-sdk/video-generation";
import { expect, vi } from "vitest";
type ClearableMock = {
mockClear(): unknown;
};
type ResettableMock = {
mockReset(): unknown;
};
type ResolvableMock = {
mockResolvedValue(value: unknown): unknown;
};
type ChainableResolvedValueMock = ResettableMock & {
mockResolvedValueOnce(value: unknown): ChainableResolvedValueMock;
};
export type DashscopeVideoProviderMocks = {
resolveApiKeyForProviderMock: ClearableMock;
postJsonRequestMock: ResettableMock & ResolvableMock;
fetchWithTimeoutMock: ChainableResolvedValueMock;
assertOkOrThrowHttpErrorMock: ClearableMock;
resolveProviderHttpRequestConfigMock: ClearableMock;
};
export function resetDashscopeVideoProviderMocks(mocks: DashscopeVideoProviderMocks): void {
mocks.resolveApiKeyForProviderMock.mockClear();
mocks.postJsonRequestMock.mockReset();
mocks.fetchWithTimeoutMock.mockReset();
mocks.assertOkOrThrowHttpErrorMock.mockClear();
mocks.resolveProviderHttpRequestConfigMock.mockClear();
}
export function mockSuccessfulDashscopeVideoTask(
mocks: Pick<DashscopeVideoProviderMocks, "postJsonRequestMock" | "fetchWithTimeoutMock">,
params: {
requestId?: string;
taskId?: string;
taskStatus?: string;
videoUrl?: string;
} = {},
): void {
const {
requestId = "req-1",
taskId = "task-1",
taskStatus = "SUCCEEDED",
videoUrl = "https://example.com/out.mp4",
} = params;
mocks.postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
request_id: requestId,
output: {
task_id: taskId,
},
}),
},
release: vi.fn(async () => {}),
});
mocks.fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
output: {
task_status: taskStatus,
results: [{ video_url: videoUrl }],
},
}),
headers: new Headers(),
})
.mockResolvedValueOnce({
arrayBuffer: async () => Buffer.from("mp4-bytes"),
headers: new Headers({ "content-type": "video/mp4" }),
});
}
export function expectDashscopeVideoTaskPoll(
fetchWithTimeoutMock: ChainableResolvedValueMock,
params: {
baseUrl?: string;
taskId?: string;
timeoutMs?: number;
} = {},
): void {
const {
baseUrl = "https://dashscope-intl.aliyuncs.com",
taskId = "task-1",
timeoutMs = 120_000,
} = params;
expect(fetchWithTimeoutMock).toHaveBeenNthCalledWith(
1,
`${baseUrl}/api/v1/tasks/${taskId}`,
expect.objectContaining({ method: "GET" }),
timeoutMs,
fetch,
);
}
export function expectSuccessfulDashscopeVideoResult(
result: VideoGenerationResult,
params: {
requestId?: string;
taskId?: string;
taskStatus?: string;
} = {},
): void {
const { requestId = "req-1", taskId = "task-1", taskStatus = "SUCCEEDED" } = params;
expect(result.videos).toHaveLength(1);
expect(result.videos[0]?.mimeType).toBe("video/mp4");
expect(result.metadata).toEqual(
expect.objectContaining({
requestId,
taskId,
taskStatus,
}),
);
}

View file

@ -0,0 +1,68 @@
import { expect } from "vitest";
import { listSupportedMusicGenerationModes } from "../../../src/music-generation/capabilities.js";
import type {
MusicGenerationProviderPlugin,
VideoGenerationProviderPlugin,
} from "../../../src/plugins/types.js";
import { listSupportedVideoGenerationModes } from "../../../src/video-generation/capabilities.js";
export function expectExplicitVideoGenerationCapabilities(
provider: VideoGenerationProviderPlugin,
): void {
expect(
provider.capabilities.generate,
`${provider.id} missing generate capabilities`,
).toBeDefined();
expect(
provider.capabilities.imageToVideo,
`${provider.id} missing imageToVideo capabilities`,
).toBeDefined();
expect(
provider.capabilities.videoToVideo,
`${provider.id} missing videoToVideo capabilities`,
).toBeDefined();
const supportedModes = listSupportedVideoGenerationModes(provider);
const imageToVideo = provider.capabilities.imageToVideo;
const videoToVideo = provider.capabilities.videoToVideo;
if (imageToVideo?.enabled) {
expect(
imageToVideo.maxInputImages ?? 0,
`${provider.id} imageToVideo.enabled requires maxInputImages`,
).toBeGreaterThan(0);
expect(supportedModes).toContain("imageToVideo");
}
if (videoToVideo?.enabled) {
expect(
videoToVideo.maxInputVideos ?? 0,
`${provider.id} videoToVideo.enabled requires maxInputVideos`,
).toBeGreaterThan(0);
expect(supportedModes).toContain("videoToVideo");
}
}
export function expectExplicitMusicGenerationCapabilities(
provider: MusicGenerationProviderPlugin,
): void {
expect(
provider.capabilities.generate,
`${provider.id} missing generate capabilities`,
).toBeDefined();
expect(provider.capabilities.edit, `${provider.id} missing edit capabilities`).toBeDefined();
const edit = provider.capabilities.edit;
if (!edit) {
return;
}
if (edit.enabled) {
expect(
edit.maxInputImages ?? 0,
`${provider.id} edit.enabled requires maxInputImages`,
).toBeGreaterThan(0);
expect(listSupportedMusicGenerationModes(provider)).toContain("edit");
} else {
expect(listSupportedMusicGenerationModes(provider)).toEqual(["generate"]);
}
}

View file

@ -0,0 +1,57 @@
import type { resolveProviderHttpRequestConfig } from "openclaw/plugin-sdk/provider-http";
import { afterEach, vi } from "vitest";
type ResolveProviderHttpRequestConfigParams = Parameters<
typeof resolveProviderHttpRequestConfig
>[0];
const providerHttpMocks = vi.hoisted(() => ({
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "provider-key" })),
postJsonRequestMock: vi.fn(),
fetchWithTimeoutMock: vi.fn(),
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
resolveProviderHttpRequestConfigMock: vi.fn((params: ResolveProviderHttpRequestConfigParams) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders),
dispatcherPolicy: undefined,
})),
}));
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: providerHttpMocks.resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: providerHttpMocks.assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: ({
label,
timeoutMs,
}: {
label: string;
timeoutMs?: number;
}) => ({
label,
timeoutMs,
}),
fetchWithTimeout: providerHttpMocks.fetchWithTimeoutMock,
postJsonRequest: providerHttpMocks.postJsonRequestMock,
resolveProviderOperationTimeoutMs: ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
defaultTimeoutMs,
resolveProviderHttpRequestConfig: providerHttpMocks.resolveProviderHttpRequestConfigMock,
waitProviderOperationPollInterval: async () => {},
}));
export function getProviderHttpMocks() {
return providerHttpMocks;
}
export function installProviderHttpMockCleanup(): void {
afterEach(() => {
providerHttpMocks.resolveApiKeyForProviderMock.mockClear();
providerHttpMocks.postJsonRequestMock.mockReset();
providerHttpMocks.fetchWithTimeoutMock.mockReset();
providerHttpMocks.assertOkOrThrowHttpErrorMock.mockClear();
providerHttpMocks.resolveProviderHttpRequestConfigMock.mockClear();
});
}

View file

@ -0,0 +1,156 @@
import { vi } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { ImageGenerationProvider } from "../../../src/image-generation/types.js";
import type { MusicGenerationProvider } from "../../../src/music-generation/types.js";
import type { VideoGenerationProvider } from "../../../src/video-generation/types.js";
import { resetGenerationRuntimeMocks } from "./runtime-test-mocks.js";
type ModelRef = { provider: string; model: string };
const mediaRuntimeMocks = vi.hoisted(() => {
const debug = vi.fn();
const warn = vi.fn();
const parseGenerationModelRef = (raw?: string): ModelRef | undefined => {
const trimmed = raw?.trim();
if (!trimmed) {
return undefined;
}
const slash = trimmed.indexOf("/");
if (slash <= 0 || slash === trimmed.length - 1) {
return undefined;
}
return {
provider: trimmed.slice(0, slash),
model: trimmed.slice(slash + 1),
};
};
return {
createSubsystemLogger: vi.fn(() => ({ debug, warn: vi.fn() })),
describeFailoverError: vi.fn(),
getImageGenerationProvider: vi.fn<
(providerId: string, config?: OpenClawConfig) => ImageGenerationProvider | undefined
>(() => undefined),
getMusicGenerationProvider: vi.fn<
(providerId: string, config?: OpenClawConfig) => MusicGenerationProvider | undefined
>(() => undefined),
getProviderEnvVars: vi.fn<(providerId: string) => string[]>(() => []),
getVideoGenerationProvider: vi.fn<
(providerId: string, config?: OpenClawConfig) => VideoGenerationProvider | undefined
>(() => undefined),
isFailoverError: vi.fn<(err: unknown) => boolean>(() => false),
listImageGenerationProviders: vi.fn<(config?: OpenClawConfig) => ImageGenerationProvider[]>(
() => [],
),
listMusicGenerationProviders: vi.fn<(config?: OpenClawConfig) => MusicGenerationProvider[]>(
() => [],
),
listVideoGenerationProviders: vi.fn<(config?: OpenClawConfig) => VideoGenerationProvider[]>(
() => [],
),
parseImageGenerationModelRef:
vi.fn<(raw?: string) => ModelRef | undefined>(parseGenerationModelRef),
parseMusicGenerationModelRef:
vi.fn<(raw?: string) => ModelRef | undefined>(parseGenerationModelRef),
parseVideoGenerationModelRef:
vi.fn<(raw?: string) => ModelRef | undefined>(parseGenerationModelRef),
ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })),
listProfilesForProvider: vi.fn(() => []),
resolveEnvApiKey: vi.fn(() => undefined),
resolveAgentModelFallbackValues: vi.fn<(value: unknown) => string[]>(() => []),
resolveAgentModelPrimaryValue: vi.fn<(value: unknown) => string | undefined>(() => undefined),
resolveProviderAuthEnvVarCandidates: vi.fn(() => ({})),
debug,
warn,
};
});
vi.mock("../../../src/agents/auth-profiles.js", () => ({
ensureAuthProfileStore: mediaRuntimeMocks.ensureAuthProfileStore,
listProfilesForProvider: mediaRuntimeMocks.listProfilesForProvider,
}));
vi.mock("../../../src/agents/defaults.js", () => ({
DEFAULT_PROVIDER: "openai",
}));
vi.mock("../../../src/agents/failover-error.js", () => ({
describeFailoverError: mediaRuntimeMocks.describeFailoverError,
isFailoverError: mediaRuntimeMocks.isFailoverError,
}));
vi.mock("../../../src/agents/model-auth-env.js", () => ({
resolveEnvApiKey: mediaRuntimeMocks.resolveEnvApiKey,
}));
vi.mock("../../../src/config/model-input.js", () => ({
resolveAgentModelFallbackValues: mediaRuntimeMocks.resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue: mediaRuntimeMocks.resolveAgentModelPrimaryValue,
}));
vi.mock("../../../src/logging/subsystem.js", () => ({
createSubsystemLogger: mediaRuntimeMocks.createSubsystemLogger,
}));
vi.mock("../../../src/secrets/provider-env-vars.js", () => ({
getProviderEnvVars: mediaRuntimeMocks.getProviderEnvVars,
resolveProviderAuthEnvVarCandidates: mediaRuntimeMocks.resolveProviderAuthEnvVarCandidates,
}));
vi.mock("../../../src/image-generation/model-ref.js", () => ({
parseImageGenerationModelRef: mediaRuntimeMocks.parseImageGenerationModelRef,
}));
vi.mock("../../../src/image-generation/provider-registry.js", () => ({
getImageGenerationProvider: mediaRuntimeMocks.getImageGenerationProvider,
listImageGenerationProviders: mediaRuntimeMocks.listImageGenerationProviders,
}));
vi.mock("../../../src/music-generation/model-ref.js", () => ({
parseMusicGenerationModelRef: mediaRuntimeMocks.parseMusicGenerationModelRef,
}));
vi.mock("../../../src/music-generation/provider-registry.js", () => ({
getMusicGenerationProvider: mediaRuntimeMocks.getMusicGenerationProvider,
listMusicGenerationProviders: mediaRuntimeMocks.listMusicGenerationProviders,
}));
vi.mock("../../../src/video-generation/model-ref.js", () => ({
parseVideoGenerationModelRef: mediaRuntimeMocks.parseVideoGenerationModelRef,
}));
vi.mock("../../../src/video-generation/provider-registry.js", () => ({
getVideoGenerationProvider: mediaRuntimeMocks.getVideoGenerationProvider,
listVideoGenerationProviders: mediaRuntimeMocks.listVideoGenerationProviders,
}));
export function getMediaGenerationRuntimeMocks() {
return mediaRuntimeMocks;
}
export function resetImageGenerationRuntimeMocks(): void {
resetSharedRuntimeImportMocks();
resetGenerationRuntimeMocks({
...mediaRuntimeMocks,
getProvider: mediaRuntimeMocks.getImageGenerationProvider,
listProviders: mediaRuntimeMocks.listImageGenerationProviders,
parseModelRef: mediaRuntimeMocks.parseImageGenerationModelRef,
});
}
export function resetMusicGenerationRuntimeMocks(): void {
resetSharedRuntimeImportMocks();
resetGenerationRuntimeMocks({
...mediaRuntimeMocks,
getProvider: mediaRuntimeMocks.getMusicGenerationProvider,
listProviders: mediaRuntimeMocks.listMusicGenerationProviders,
parseModelRef: mediaRuntimeMocks.parseMusicGenerationModelRef,
});
}
export function resetVideoGenerationRuntimeMocks(): void {
resetSharedRuntimeImportMocks();
resetGenerationRuntimeMocks({
...mediaRuntimeMocks,
getProvider: mediaRuntimeMocks.getVideoGenerationProvider,
listProviders: mediaRuntimeMocks.listVideoGenerationProviders,
parseModelRef: mediaRuntimeMocks.parseVideoGenerationModelRef,
});
}
function resetSharedRuntimeImportMocks(): void {
mediaRuntimeMocks.ensureAuthProfileStore.mockReset();
mediaRuntimeMocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} });
mediaRuntimeMocks.listProfilesForProvider.mockReset();
mediaRuntimeMocks.listProfilesForProvider.mockReturnValue([]);
mediaRuntimeMocks.resolveEnvApiKey.mockReset();
mediaRuntimeMocks.resolveEnvApiKey.mockReturnValue(undefined);
}

View file

@ -0,0 +1,45 @@
type ClearableMock = {
mockClear(): unknown;
};
type ResettableMock = {
mockReset(): unknown;
};
type ResettableReturnMock = ResettableMock & {
mockReturnValue(value: unknown): unknown;
};
export type GenerationRuntimeMocks = {
createSubsystemLogger: ClearableMock;
describeFailoverError: ResettableMock;
getProvider: ResettableReturnMock;
getProviderEnvVars: ResettableReturnMock;
resolveProviderAuthEnvVarCandidates: ResettableReturnMock;
isFailoverError: ResettableReturnMock;
listProviders: ResettableReturnMock;
parseModelRef: ClearableMock;
resolveAgentModelFallbackValues: ResettableReturnMock;
resolveAgentModelPrimaryValue: ResettableReturnMock;
debug: ResettableMock;
};
export function resetGenerationRuntimeMocks(mocks: GenerationRuntimeMocks): void {
mocks.createSubsystemLogger.mockClear();
mocks.describeFailoverError.mockReset();
mocks.getProvider.mockReset();
mocks.getProviderEnvVars.mockReset();
mocks.getProviderEnvVars.mockReturnValue([]);
mocks.resolveProviderAuthEnvVarCandidates.mockReset();
mocks.resolveProviderAuthEnvVarCandidates.mockReturnValue({});
mocks.isFailoverError.mockReset();
mocks.isFailoverError.mockReturnValue(false);
mocks.listProviders.mockReset();
mocks.listProviders.mockReturnValue([]);
mocks.parseModelRef.mockClear();
mocks.resolveAgentModelFallbackValues.mockReset();
mocks.resolveAgentModelFallbackValues.mockReturnValue([]);
mocks.resolveAgentModelPrimaryValue.mockReset();
mocks.resolveAgentModelPrimaryValue.mockReturnValue(undefined);
mocks.debug.mockReset();
}

View file

@ -0,0 +1,27 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage } from "node:http";
export function createMockIncomingRequest(chunks: string[]): IncomingMessage {
const req = new EventEmitter() as IncomingMessage & {
destroyed?: boolean;
destroy: (error?: Error) => IncomingMessage;
};
req.destroyed = false;
req.headers = {};
req.destroy = () => {
req.destroyed = true;
return req;
};
void Promise.resolve().then(() => {
for (const chunk of chunks) {
req.emit("data", Buffer.from(chunk, "utf-8"));
if (req.destroyed) {
return;
}
}
req.emit("end");
});
return req;
}

Some files were not shown because too many files have changed in this diff Show more