test: dedupe plugin bundle and discovery suites

This commit is contained in:
Peter Steinberger 2026-03-28 02:33:23 +00:00
parent c18d315858
commit 0454612083
17 changed files with 1703 additions and 1760 deletions

View File

@ -23,6 +23,15 @@ describe("Claude bundle plugin inspect integration", () => {
return result.manifest;
}
function expectClaudeManifestField(params: {
field: "skills" | "hooks" | "settingsFiles" | "capabilities";
includes: readonly string[];
}) {
const manifest = expectLoadedClaudeManifest();
const values = manifest[params.field];
expect(values).toEqual(expect.arrayContaining([...params.includes]));
}
beforeAll(() => {
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-bundle-"));
@ -129,30 +138,26 @@ describe("Claude bundle plugin inspect integration", () => {
expect(m.bundleFormat).toBe("claude");
});
it("resolves skills from skills, commands, and agents paths", () => {
const manifest = expectLoadedClaudeManifest();
expect(manifest.skills).toContain("skill-packs");
expect(manifest.skills).toContain("extra-commands");
// Agent and output style dirs are merged into skills so their .md files are discoverable
expect(manifest.skills).toContain("agents");
expect(manifest.skills).toContain("output-styles");
});
it("resolves hooks from default and declared paths", () => {
const manifest = expectLoadedClaudeManifest();
// Default hooks/hooks.json path + declared custom-hooks
expect(manifest.hooks).toContain("hooks/hooks.json");
expect(manifest.hooks).toContain("custom-hooks");
});
it("detects settings files", () => {
expect(expectLoadedClaudeManifest().settingsFiles).toEqual(["settings.json"]);
});
it("detects all bundle capabilities", () => {
const caps = expectLoadedClaudeManifest().capabilities;
expect(caps).toEqual(
expect.arrayContaining([
it.each([
{
name: "resolves skills from skills, commands, and agents paths",
field: "skills" as const,
includes: ["skill-packs", "extra-commands", "agents", "output-styles"],
},
{
name: "resolves hooks from default and declared paths",
field: "hooks" as const,
includes: ["hooks/hooks.json", "custom-hooks"],
},
{
name: "detects settings files",
field: "settingsFiles" as const,
includes: ["settings.json"],
},
{
name: "detects all bundle capabilities",
field: "capabilities" as const,
includes: [
"skills",
"commands",
"agents",
@ -161,8 +166,10 @@ describe("Claude bundle plugin inspect integration", () => {
"lspServers",
"outputStyles",
"settings",
]),
);
],
},
] as const)("$name", ({ field, includes }) => {
expectClaudeManifestField({ field, includes });
});
it("inspects MCP runtime support with supported and unsupported servers", () => {

View File

@ -29,45 +29,60 @@ async function withBundleHomeEnv<T>(
}
}
async function writeClaudeBundleCommandFixture(params: {
homeDir: string;
pluginId: string;
commands: Array<{ relativePath: string; contents: string[] }>;
}) {
const pluginRoot = path.join(params.homeDir, ".openclaw", "extensions", params.pluginId);
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify({ name: params.pluginId }, null, 2)}\n`,
"utf-8",
);
for (const command of params.commands) {
await fs.mkdir(path.dirname(path.join(pluginRoot, command.relativePath)), { recursive: true });
await fs.writeFile(
path.join(pluginRoot, command.relativePath),
[...command.contents, ""].join("\n"),
"utf-8",
);
}
}
describe("loadEnabledClaudeBundleCommands", () => {
it("loads enabled Claude bundle markdown commands and skips disabled-model-invocation entries", async () => {
await withBundleHomeEnv("openclaw-bundle-commands", async ({ homeDir, workspaceDir }) => {
const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "compound-bundle");
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(pluginRoot, "commands", "workflows"), { recursive: true });
await fs.writeFile(
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify({ name: "compound-bundle" }, null, 2)}\n`,
"utf-8",
);
await fs.writeFile(
path.join(pluginRoot, "commands", "office-hours.md"),
[
"---",
"description: Help with scoping and architecture",
"---",
"Give direct engineering advice.",
"",
].join("\n"),
"utf-8",
);
await fs.writeFile(
path.join(pluginRoot, "commands", "workflows", "review.md"),
[
"---",
"name: workflows:review",
"description: Run a structured review",
"---",
"Review the code. $ARGUMENTS",
"",
].join("\n"),
"utf-8",
);
await fs.writeFile(
path.join(pluginRoot, "commands", "disabled.md"),
["---", "disable-model-invocation: true", "---", "Do not load me.", ""].join("\n"),
"utf-8",
);
await writeClaudeBundleCommandFixture({
homeDir,
pluginId: "compound-bundle",
commands: [
{
relativePath: "commands/office-hours.md",
contents: [
"---",
"description: Help with scoping and architecture",
"---",
"Give direct engineering advice.",
],
},
{
relativePath: "commands/workflows/review.md",
contents: [
"---",
"name: workflows:review",
"description: Run a structured review",
"---",
"Review the code. $ARGUMENTS",
],
},
{
relativePath: "commands/disabled.md",
contents: ["---", "disable-model-invocation: true", "---", "Do not load me."],
},
],
});
const commands = loadEnabledClaudeBundleCommands({
workspaceDir,

View File

@ -31,152 +31,175 @@ function expectLoadedManifest(rootDir: string, bundleFormat: "codex" | "claude"
return result.manifest;
}
function writeBundleManifest(
rootDir: string,
relativePath: string,
manifest: Record<string, unknown>,
) {
mkdirSafe(path.dirname(path.join(rootDir, relativePath)));
fs.writeFileSync(path.join(rootDir, relativePath), JSON.stringify(manifest), "utf-8");
}
afterEach(() => {
cleanupTrackedTempDirs(tempDirs);
});
describe("bundle manifest parsing", () => {
it("detects and loads Codex bundle manifests", () => {
const rootDir = makeTempDir();
mkdirSafe(path.join(rootDir, ".codex-plugin"));
mkdirSafe(path.join(rootDir, "skills"));
mkdirSafe(path.join(rootDir, "hooks"));
fs.writeFileSync(
path.join(rootDir, CODEX_BUNDLE_MANIFEST_RELATIVE_PATH),
JSON.stringify({
it.each([
{
name: "detects and loads Codex bundle manifests",
bundleFormat: "codex" as const,
setup: (rootDir: string) => {
mkdirSafe(path.join(rootDir, ".codex-plugin"));
mkdirSafe(path.join(rootDir, "skills"));
mkdirSafe(path.join(rootDir, "hooks"));
writeBundleManifest(rootDir, CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, {
name: "Sample Bundle",
description: "Codex fixture",
skills: "skills",
hooks: "hooks",
mcpServers: {
sample: {
command: "node",
args: ["server.js"],
},
},
apps: {
sample: {
title: "Sample App",
},
},
});
},
expected: {
id: "sample-bundle",
name: "Sample Bundle",
description: "Codex fixture",
skills: "skills",
hooks: "hooks",
mcpServers: {
sample: {
command: "node",
args: ["server.js"],
},
},
apps: {
sample: {
title: "Sample App",
},
},
}),
"utf-8",
);
expect(detectBundleManifestFormat(rootDir)).toBe("codex");
expect(expectLoadedManifest(rootDir, "codex")).toMatchObject({
id: "sample-bundle",
name: "Sample Bundle",
description: "Codex fixture",
bundleFormat: "codex",
skills: ["skills"],
hooks: ["hooks"],
capabilities: expect.arrayContaining(["hooks", "skills", "mcpServers", "apps"]),
});
});
it("detects and loads Claude bundle manifests from the component layout", () => {
const rootDir = makeTempDir();
mkdirSafe(path.join(rootDir, ".claude-plugin"));
mkdirSafe(path.join(rootDir, "skill-packs", "starter"));
mkdirSafe(path.join(rootDir, "commands-pack"));
mkdirSafe(path.join(rootDir, "agents-pack"));
mkdirSafe(path.join(rootDir, "hooks-pack"));
mkdirSafe(path.join(rootDir, "mcp"));
mkdirSafe(path.join(rootDir, "lsp"));
mkdirSafe(path.join(rootDir, "styles"));
mkdirSafe(path.join(rootDir, "hooks"));
fs.writeFileSync(path.join(rootDir, "hooks", "hooks.json"), '{"hooks":[]}', "utf-8");
fs.writeFileSync(path.join(rootDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8");
fs.writeFileSync(
path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH),
JSON.stringify({
bundleFormat: "codex",
skills: ["skills"],
hooks: ["hooks"],
capabilities: expect.arrayContaining(["hooks", "skills", "mcpServers", "apps"]),
},
},
{
name: "detects and loads Claude bundle manifests from the component layout",
bundleFormat: "claude" as const,
setup: (rootDir: string) => {
for (const relativeDir of [
".claude-plugin",
"skill-packs/starter",
"commands-pack",
"agents-pack",
"hooks-pack",
"mcp",
"lsp",
"styles",
"hooks",
]) {
mkdirSafe(path.join(rootDir, relativeDir));
}
fs.writeFileSync(path.join(rootDir, "hooks", "hooks.json"), '{"hooks":[]}', "utf-8");
fs.writeFileSync(
path.join(rootDir, "settings.json"),
'{"hideThinkingBlock":true}',
"utf-8",
);
writeBundleManifest(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, {
name: "Claude Sample",
description: "Claude fixture",
skills: ["skill-packs/starter"],
commands: "commands-pack",
agents: "agents-pack",
hooks: "hooks-pack",
mcpServers: "mcp",
lspServers: "lsp",
outputStyles: "styles",
});
},
expected: {
id: "claude-sample",
name: "Claude Sample",
description: "Claude fixture",
skills: ["skill-packs/starter"],
commands: "commands-pack",
agents: "agents-pack",
hooks: "hooks-pack",
mcpServers: "mcp",
lspServers: "lsp",
outputStyles: "styles",
}),
"utf-8",
);
expect(detectBundleManifestFormat(rootDir)).toBe("claude");
expect(expectLoadedManifest(rootDir, "claude")).toMatchObject({
id: "claude-sample",
name: "Claude Sample",
description: "Claude fixture",
bundleFormat: "claude",
skills: ["skill-packs/starter", "commands-pack", "agents-pack", "styles"],
settingsFiles: ["settings.json"],
hooks: ["hooks/hooks.json", "hooks-pack"],
capabilities: expect.arrayContaining([
"hooks",
"skills",
"commands",
"agents",
"mcpServers",
"lspServers",
"outputStyles",
"settings",
]),
});
});
it("detects and loads Cursor bundle manifests", () => {
const rootDir = makeTempDir();
mkdirSafe(path.join(rootDir, ".cursor-plugin"));
mkdirSafe(path.join(rootDir, "skills"));
mkdirSafe(path.join(rootDir, ".cursor", "commands"));
mkdirSafe(path.join(rootDir, ".cursor", "rules"));
mkdirSafe(path.join(rootDir, ".cursor", "agents"));
fs.writeFileSync(path.join(rootDir, ".cursor", "hooks.json"), '{"hooks":[]}', "utf-8");
fs.writeFileSync(
path.join(rootDir, CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH),
JSON.stringify({
bundleFormat: "claude",
skills: ["skill-packs/starter", "commands-pack", "agents-pack", "styles"],
settingsFiles: ["settings.json"],
hooks: ["hooks/hooks.json", "hooks-pack"],
capabilities: expect.arrayContaining([
"hooks",
"skills",
"commands",
"agents",
"mcpServers",
"lspServers",
"outputStyles",
"settings",
]),
},
},
{
name: "detects and loads Cursor bundle manifests",
bundleFormat: "cursor" as const,
setup: (rootDir: string) => {
for (const relativeDir of [
".cursor-plugin",
"skills",
".cursor/commands",
".cursor/rules",
".cursor/agents",
]) {
mkdirSafe(path.join(rootDir, relativeDir));
}
fs.writeFileSync(path.join(rootDir, ".cursor", "hooks.json"), '{"hooks":[]}', "utf-8");
writeBundleManifest(rootDir, CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH, {
name: "Cursor Sample",
description: "Cursor fixture",
mcpServers: "./.mcp.json",
});
fs.writeFileSync(path.join(rootDir, ".mcp.json"), '{"servers":{}}', "utf-8");
},
expected: {
id: "cursor-sample",
name: "Cursor Sample",
description: "Cursor fixture",
mcpServers: "./.mcp.json",
bundleFormat: "cursor",
skills: ["skills", ".cursor/commands"],
hooks: [],
capabilities: expect.arrayContaining([
"skills",
"commands",
"agents",
"rules",
"hooks",
"mcpServers",
]),
},
},
{
name: "detects manifestless Claude bundles from the default layout",
bundleFormat: "claude" as const,
setup: (rootDir: string) => {
mkdirSafe(path.join(rootDir, "commands"));
mkdirSafe(path.join(rootDir, "skills"));
fs.writeFileSync(
path.join(rootDir, "settings.json"),
'{"hideThinkingBlock":true}',
"utf-8",
);
},
expected: (rootDir: string) => ({
id: path.basename(rootDir).toLowerCase(),
skills: ["skills", "commands"],
settingsFiles: ["settings.json"],
capabilities: expect.arrayContaining(["skills", "commands", "settings"]),
}),
"utf-8",
);
fs.writeFileSync(path.join(rootDir, ".mcp.json"), '{"servers":{}}', "utf-8");
expect(detectBundleManifestFormat(rootDir)).toBe("cursor");
expect(expectLoadedManifest(rootDir, "cursor")).toMatchObject({
id: "cursor-sample",
name: "Cursor Sample",
description: "Cursor fixture",
bundleFormat: "cursor",
skills: ["skills", ".cursor/commands"],
hooks: [],
capabilities: expect.arrayContaining([
"skills",
"commands",
"agents",
"rules",
"hooks",
"mcpServers",
]),
});
});
it("detects manifestless Claude bundles from the default layout", () => {
},
] as const)("$name", ({ bundleFormat, setup, expected }) => {
const rootDir = makeTempDir();
mkdirSafe(path.join(rootDir, "commands"));
mkdirSafe(path.join(rootDir, "skills"));
fs.writeFileSync(path.join(rootDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8");
setup(rootDir);
expect(detectBundleManifestFormat(rootDir)).toBe("claude");
const manifest = expectLoadedManifest(rootDir, "claude");
expect(manifest.id).toBe(path.basename(rootDir).toLowerCase());
expect(manifest.skills).toEqual(["skills", "commands"]);
expect(manifest.settingsFiles).toEqual(["settings.json"]);
expect(manifest.capabilities).toEqual(
expect.arrayContaining(["skills", "commands", "settings"]),
expect(detectBundleManifestFormat(rootDir)).toBe(bundleFormat);
expect(expectLoadedManifest(rootDir, bundleFormat)).toMatchObject(
typeof expected === "function" ? expected(rootDir) : expected,
);
});

View File

@ -52,6 +52,29 @@ async function withBundleHomeEnv<T>(
}
}
function createEnabledBundleConfig(pluginIds: string[]): OpenClawConfig {
return {
plugins: {
entries: Object.fromEntries(pluginIds.map((pluginId) => [pluginId, { enabled: true }])),
},
};
}
async function writeInlineClaudeBundleManifest(params: {
homeDir: string;
pluginId: string;
manifest: Record<string, unknown>;
}) {
const pluginRoot = path.join(params.homeDir, ".openclaw", "extensions", params.pluginId);
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify(params.manifest, null, 2)}\n`,
"utf-8",
);
return pluginRoot;
}
describe("loadEnabledBundleMcpConfig", () => {
it("loads enabled Claude bundle MCP config and absolutizes relative args", async () => {
await withBundleHomeEnv("openclaw-bundle-mcp", async ({ homeDir, workspaceDir }) => {
@ -91,57 +114,43 @@ describe("loadEnabledBundleMcpConfig", () => {
it("merges inline bundle MCP servers and skips disabled bundles", async () => {
await withBundleHomeEnv("openclaw-bundle-inline", async ({ homeDir, workspaceDir }) => {
const enabledRoot = path.join(homeDir, ".openclaw", "extensions", "inline-enabled");
const disabledRoot = path.join(homeDir, ".openclaw", "extensions", "inline-disabled");
await fs.mkdir(path.join(enabledRoot, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(disabledRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(enabledRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify(
{
name: "inline-enabled",
mcpServers: {
enabledProbe: {
command: "node",
args: ["./enabled.mjs"],
},
await writeInlineClaudeBundleManifest({
homeDir,
pluginId: "inline-enabled",
manifest: {
name: "inline-enabled",
mcpServers: {
enabledProbe: {
command: "node",
args: ["./enabled.mjs"],
},
},
null,
2,
)}\n`,
"utf-8",
);
await fs.writeFile(
path.join(disabledRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify(
{
name: "inline-disabled",
mcpServers: {
disabledProbe: {
command: "node",
args: ["./disabled.mjs"],
},
},
},
null,
2,
)}\n`,
"utf-8",
);
const config: OpenClawConfig = {
plugins: {
entries: {
"inline-enabled": { enabled: true },
"inline-disabled": { enabled: false },
},
},
};
});
await writeInlineClaudeBundleManifest({
homeDir,
pluginId: "inline-disabled",
manifest: {
name: "inline-disabled",
mcpServers: {
disabledProbe: {
command: "node",
args: ["./disabled.mjs"],
},
},
},
});
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: config,
cfg: {
plugins: {
entries: {
...createEnabledBundleConfig(["inline-enabled"]).plugins?.entries,
"inline-disabled": { enabled: false },
},
},
},
});
expect(loaded.config.mcpServers.enabledProbe).toBeDefined();
@ -153,39 +162,27 @@ describe("loadEnabledBundleMcpConfig", () => {
await withBundleHomeEnv(
"openclaw-bundle-inline-placeholder",
async ({ homeDir, workspaceDir }) => {
const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "inline-claude");
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify(
{
name: "inline-claude",
mcpServers: {
inlineProbe: {
command: "${CLAUDE_PLUGIN_ROOT}/bin/server.sh",
args: ["${CLAUDE_PLUGIN_ROOT}/servers/probe.mjs", "./local-probe.mjs"],
cwd: "${CLAUDE_PLUGIN_ROOT}",
env: {
PLUGIN_ROOT: "${CLAUDE_PLUGIN_ROOT}",
},
const pluginRoot = await writeInlineClaudeBundleManifest({
homeDir,
pluginId: "inline-claude",
manifest: {
name: "inline-claude",
mcpServers: {
inlineProbe: {
command: "${CLAUDE_PLUGIN_ROOT}/bin/server.sh",
args: ["${CLAUDE_PLUGIN_ROOT}/servers/probe.mjs", "./local-probe.mjs"],
cwd: "${CLAUDE_PLUGIN_ROOT}",
env: {
PLUGIN_ROOT: "${CLAUDE_PLUGIN_ROOT}",
},
},
},
null,
2,
)}\n`,
"utf-8",
);
},
});
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: {
plugins: {
entries: {
"inline-claude": { enabled: true },
},
},
},
cfg: createEnabledBundleConfig(["inline-claude"]),
});
const loadedServer = loaded.config.mcpServers.inlineProbe;
const loadedArgs = getServerArgs(loadedServer);

View File

@ -15,6 +15,20 @@ import {
installGeneratedPluginTempRootCleanup();
function expectGeneratedAuthEnvVarModuleState(params: {
tempRoot: string;
expectedChanged: boolean;
expectedWrote: boolean;
}) {
const result = writeBundledProviderAuthEnvVarModule({
repoRoot: params.tempRoot,
outputPath: "src/plugins/bundled-provider-auth-env-vars.generated.ts",
check: true,
});
expect(result.changed).toBe(params.expectedChanged);
expect(result.wrote).toBe(params.expectedWrote);
}
describe("bundled provider auth env vars", () => {
it("matches the generated manifest snapshot", () => {
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toEqual(
@ -70,13 +84,11 @@ describe("bundled provider auth env vars", () => {
});
expect(initial.wrote).toBe(true);
const current = writeBundledProviderAuthEnvVarModule({
repoRoot: tempRoot,
outputPath: "src/plugins/bundled-provider-auth-env-vars.generated.ts",
check: true,
expectGeneratedAuthEnvVarModuleState({
tempRoot,
expectedChanged: false,
expectedWrote: false,
});
expect(current.changed).toBe(false);
expect(current.wrote).toBe(false);
fs.writeFileSync(
path.join(tempRoot, "src/plugins/bundled-provider-auth-env-vars.generated.ts"),
@ -84,12 +96,10 @@ describe("bundled provider auth env vars", () => {
"utf8",
);
const stale = writeBundledProviderAuthEnvVarModule({
repoRoot: tempRoot,
outputPath: "src/plugins/bundled-provider-auth-env-vars.generated.ts",
check: true,
expectGeneratedAuthEnvVarModuleState({
tempRoot,
expectedChanged: true,
expectedWrote: false,
});
expect(stale.changed).toBe(true);
expect(stale.wrote).toBe(false);
});
});

View File

@ -16,6 +16,24 @@ vi.mock("./manifest.js", () => ({
loadPluginManifest: (...args: unknown[]) => loadPluginManifestMock(...args),
}));
function createBundledCandidate(params: {
rootDir: string;
packageName: string;
npmSpec?: string;
origin?: "bundled" | "global";
}) {
return {
origin: params.origin ?? "bundled",
rootDir: params.rootDir,
packageName: params.packageName,
packageManifest: {
install: {
npmSpec: params.npmSpec ?? params.packageName,
},
},
};
}
function setBundledDiscoveryCandidates(candidates: unknown[]) {
discoverOpenClawPluginsMock.mockReturnValue({
candidates,
@ -23,6 +41,18 @@ function setBundledDiscoveryCandidates(candidates: unknown[]) {
});
}
function setBundledManifestIdsByRoot(manifestIds: Record<string, string>) {
loadPluginManifestMock.mockImplementation((rootDir: string) =>
rootDir in manifestIds
? { ok: true, manifest: { id: manifestIds[rootDir] } }
: {
ok: false,
error: "invalid manifest",
manifestPath: `${rootDir}/openclaw.plugin.json`,
},
);
}
function expectBundledSourceLookup(
lookup: Parameters<typeof findBundledPluginSource>[0]["lookup"],
expected:
@ -48,48 +78,28 @@ describe("bundled plugin sources", () => {
});
it("resolves bundled sources keyed by plugin id", () => {
discoverOpenClawPluginsMock.mockReturnValue({
candidates: [
{
origin: "global",
rootDir: "/global/feishu",
packageName: "@openclaw/feishu",
packageManifest: { install: { npmSpec: "@openclaw/feishu" } },
},
{
origin: "bundled",
rootDir: "/app/extensions/feishu",
packageName: "@openclaw/feishu",
packageManifest: { install: { npmSpec: "@openclaw/feishu" } },
},
{
origin: "bundled",
rootDir: "/app/extensions/feishu-dup",
packageName: "@openclaw/feishu",
packageManifest: { install: { npmSpec: "@openclaw/feishu" } },
},
{
origin: "bundled",
rootDir: "/app/extensions/msteams",
packageName: "@openclaw/msteams",
packageManifest: { install: { npmSpec: "@openclaw/msteams" } },
},
],
diagnostics: [],
});
loadPluginManifestMock.mockImplementation((rootDir: string) => {
if (rootDir === "/app/extensions/feishu") {
return { ok: true, manifest: { id: "feishu" } };
}
if (rootDir === "/app/extensions/msteams") {
return { ok: true, manifest: { id: "msteams" } };
}
return {
ok: false,
error: "invalid manifest",
manifestPath: `${rootDir}/openclaw.plugin.json`,
};
setBundledDiscoveryCandidates([
createBundledCandidate({
origin: "global",
rootDir: "/global/feishu",
packageName: "@openclaw/feishu",
}),
createBundledCandidate({
rootDir: "/app/extensions/feishu",
packageName: "@openclaw/feishu",
}),
createBundledCandidate({
rootDir: "/app/extensions/feishu-dup",
packageName: "@openclaw/feishu",
}),
createBundledCandidate({
rootDir: "/app/extensions/msteams",
packageName: "@openclaw/msteams",
}),
]);
setBundledManifestIdsByRoot({
"/app/extensions/feishu": "feishu",
"/app/extensions/msteams": "msteams",
});
const map = resolveBundledPluginSources({});
@ -125,26 +135,19 @@ describe("bundled plugin sources", () => {
],
] as const)("%s", (_name, lookup, expected) => {
setBundledDiscoveryCandidates([
{
origin: "bundled",
createBundledCandidate({
rootDir: "/app/extensions/feishu",
packageName: "@openclaw/feishu",
packageManifest: { install: { npmSpec: "@openclaw/feishu" } },
},
{
origin: "bundled",
}),
createBundledCandidate({
rootDir: "/app/extensions/diffs",
packageName: "@openclaw/diffs",
packageManifest: { install: { npmSpec: "@openclaw/diffs" } },
},
}),
]);
loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "feishu" } });
loadPluginManifestMock.mockImplementation((rootDir: string) => ({
ok: true,
manifest: {
id: rootDir === "/app/extensions/diffs" ? "diffs" : "feishu",
},
}));
setBundledManifestIdsByRoot({
"/app/extensions/feishu": "feishu",
"/app/extensions/diffs": "diffs",
});
expectBundledSourceLookup(lookup, expected);
});

View File

@ -134,67 +134,81 @@ describe("installPluginFromClawHub", () => {
expect(warn).not.toHaveBeenCalled();
});
it("rejects packages whose plugin API range exceeds the runtime version", async () => {
resolveCompatibilityHostVersionMock.mockReturnValueOnce("2026.3.21");
await expect(installPluginFromClawHub({ spec: "clawhub:demo" })).resolves.toMatchObject({
ok: false,
code: CLAWHUB_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API,
error:
'Plugin "demo" requires plugin API >=2026.3.22, but this OpenClaw runtime exposes 2026.3.21.',
});
});
it("rejects skill families and redirects to skills install", async () => {
fetchClawHubPackageDetailMock.mockResolvedValueOnce({
package: {
name: "calendar",
displayName: "Calendar",
family: "skill",
channel: "official",
isOfficial: true,
createdAt: 0,
updatedAt: 0,
it.each([
{
name: "rejects packages whose plugin API range exceeds the runtime version",
setup: () => {
resolveCompatibilityHostVersionMock.mockReturnValueOnce("2026.3.21");
},
});
await expect(installPluginFromClawHub({ spec: "clawhub:calendar" })).resolves.toMatchObject({
ok: false,
code: CLAWHUB_INSTALL_ERROR_CODE.SKILL_PACKAGE,
error: '"calendar" is a skill. Use "openclaw skills install calendar" instead.',
});
});
it("returns typed package-not-found failures", async () => {
fetchClawHubPackageDetailMock.mockRejectedValueOnce(
new ClawHubRequestError({
path: "/api/v1/packages/demo",
status: 404,
body: "Package not found",
}),
);
await expect(installPluginFromClawHub({ spec: "clawhub:demo" })).resolves.toMatchObject({
ok: false,
code: CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND,
error: "Package not found on ClawHub.",
});
});
it("returns typed version-not-found failures", async () => {
parseClawHubPluginSpecMock.mockReturnValueOnce({ name: "demo", version: "9.9.9" });
fetchClawHubPackageVersionMock.mockRejectedValueOnce(
new ClawHubRequestError({
path: "/api/v1/packages/demo/versions/9.9.9",
status: 404,
body: "Version not found",
}),
);
await expect(installPluginFromClawHub({ spec: "clawhub:demo@9.9.9" })).resolves.toMatchObject({
ok: false,
code: CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND,
error: "Version not found on ClawHub: demo@9.9.9.",
});
spec: "clawhub:demo",
expected: {
ok: false,
code: CLAWHUB_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API,
error:
'Plugin "demo" requires plugin API >=2026.3.22, but this OpenClaw runtime exposes 2026.3.21.',
},
},
{
name: "rejects skill families and redirects to skills install",
setup: () => {
fetchClawHubPackageDetailMock.mockResolvedValueOnce({
package: {
name: "calendar",
displayName: "Calendar",
family: "skill",
channel: "official",
isOfficial: true,
createdAt: 0,
updatedAt: 0,
},
});
},
spec: "clawhub:calendar",
expected: {
ok: false,
code: CLAWHUB_INSTALL_ERROR_CODE.SKILL_PACKAGE,
error: '"calendar" is a skill. Use "openclaw skills install calendar" instead.',
},
},
{
name: "returns typed package-not-found failures",
setup: () => {
fetchClawHubPackageDetailMock.mockRejectedValueOnce(
new ClawHubRequestError({
path: "/api/v1/packages/demo",
status: 404,
body: "Package not found",
}),
);
},
spec: "clawhub:demo",
expected: {
ok: false,
code: CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND,
error: "Package not found on ClawHub.",
},
},
{
name: "returns typed version-not-found failures",
setup: () => {
parseClawHubPluginSpecMock.mockReturnValueOnce({ name: "demo", version: "9.9.9" });
fetchClawHubPackageVersionMock.mockRejectedValueOnce(
new ClawHubRequestError({
path: "/api/v1/packages/demo/versions/9.9.9",
status: 404,
body: "Version not found",
}),
);
},
spec: "clawhub:demo@9.9.9",
expected: {
ok: false,
code: CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND,
error: "Version not found on ClawHub: demo@9.9.9.",
},
},
] as const)("$name", async ({ setup, spec, expected }) => {
setup();
await expect(installPluginFromClawHub({ spec })).resolves.toMatchObject(expected);
});
});

View File

@ -19,6 +19,21 @@ async function importCommandsModule(cacheBust: string): Promise<CommandsModule>
return (await import(`${commandsModuleUrl}?t=${cacheBust}`)) as CommandsModule;
}
function createVoiceCommand(overrides: Partial<Parameters<typeof registerPluginCommand>[1]> = {}) {
return {
name: "voice",
description: "Voice command",
handler: async () => ({ text: "ok" }),
...overrides,
};
}
function resolveBindingConversationFromCommand(
params: Parameters<typeof __testing.resolveBindingConversationFromCommand>[0],
) {
return __testing.resolveBindingConversationFromCommand(params);
}
beforeEach(() => {
setActivePluginRegistry(createTestRegistry([]));
});
@ -28,30 +43,34 @@ afterEach(() => {
});
describe("registerPluginCommand", () => {
it("rejects malformed runtime command shapes", () => {
const invalidName = registerPluginCommand(
"demo-plugin",
// Runtime plugin payloads are untyped; guard at boundary.
{
it.each([
{
name: "rejects invalid command names",
command: {
// Runtime plugin payloads are untyped; guard at boundary.
name: undefined as unknown as string,
description: "Demo",
handler: async () => ({ text: "ok" }),
},
);
expect(invalidName).toEqual({
ok: false,
error: "Command name must be a string",
});
const invalidDescription = registerPluginCommand("demo-plugin", {
name: "demo",
description: undefined as unknown as string,
handler: async () => ({ text: "ok" }),
});
expect(invalidDescription).toEqual({
ok: false,
error: "Command description must be a string",
});
expected: {
ok: false,
error: "Command name must be a string",
},
},
{
name: "rejects invalid command descriptions",
command: {
name: "demo",
description: undefined as unknown as string,
handler: async () => ({ text: "ok" }),
},
expected: {
ok: false,
error: "Command description must be a string",
},
},
] as const)("$name", ({ command, expected }) => {
expect(registerPluginCommand("demo-plugin", command)).toEqual(expected);
});
it("normalizes command metadata for downstream consumers", () => {
@ -78,15 +97,16 @@ describe("registerPluginCommand", () => {
});
it("supports provider-specific native command aliases", () => {
const result = registerPluginCommand("demo-plugin", {
name: "voice",
nativeNames: {
default: "talkvoice",
discord: "discordvoice",
},
description: "Demo command",
handler: async () => ({ text: "ok" }),
});
const result = registerPluginCommand(
"demo-plugin",
createVoiceCommand({
nativeNames: {
default: "talkvoice",
discord: "discordvoice",
},
description: "Demo command",
}),
);
expect(result).toEqual({ ok: true });
expect(getPluginCommandSpecs()).toEqual([
@ -120,14 +140,14 @@ describe("registerPluginCommand", () => {
first.clearPluginCommands();
expect(
first.registerPluginCommand("demo-plugin", {
name: "voice",
nativeNames: {
telegram: "voice",
},
description: "Voice command",
handler: async () => ({ text: "ok" }),
}),
first.registerPluginCommand(
"demo-plugin",
createVoiceCommand({
nativeNames: {
telegram: "voice",
},
}),
),
).toEqual({ ok: true });
expect(second.getPluginCommandSpecs("telegram")).toEqual([
@ -148,16 +168,17 @@ describe("registerPluginCommand", () => {
});
it("matches provider-specific native aliases back to the canonical command", () => {
const result = registerPluginCommand("demo-plugin", {
name: "voice",
nativeNames: {
default: "talkvoice",
discord: "discordvoice",
},
description: "Demo command",
acceptsArgs: true,
handler: async () => ({ text: "ok" }),
});
const result = registerPluginCommand(
"demo-plugin",
createVoiceCommand({
nativeNames: {
default: "talkvoice",
discord: "discordvoice",
},
description: "Demo command",
acceptsArgs: true,
}),
);
expect(result).toEqual({ ok: true });
expect(matchPluginCommand("/talkvoice now")).toMatchObject({
@ -170,155 +191,152 @@ describe("registerPluginCommand", () => {
});
});
it("rejects provider aliases that collide with another registered command", () => {
expect(
registerPluginCommand("demo-plugin", {
name: "voice",
nativeNames: {
telegram: "pair_device",
},
description: "Voice command",
handler: async () => ({ text: "ok" }),
}),
).toEqual({ ok: true });
expect(
registerPluginCommand("other-plugin", {
it.each([
{
name: "rejects provider aliases that collide with another registered command",
setup: () =>
registerPluginCommand(
"demo-plugin",
createVoiceCommand({
nativeNames: {
telegram: "pair_device",
},
}),
),
candidate: {
name: "pair",
nativeNames: {
telegram: "pair_device",
},
description: "Pair command",
handler: async () => ({ text: "ok" }),
}),
).toEqual({
ok: false,
error: 'Command "pair_device" already registered by plugin "demo-plugin"',
});
});
it("rejects reserved provider aliases", () => {
expect(
registerPluginCommand("demo-plugin", {
name: "voice",
},
expected: {
ok: false,
error: 'Command "pair_device" already registered by plugin "demo-plugin"',
},
},
{
name: "rejects reserved provider aliases",
candidate: createVoiceCommand({
nativeNames: {
telegram: "help",
},
description: "Voice command",
handler: async () => ({ text: "ok" }),
}),
).toEqual({
ok: false,
error:
'Native command alias "telegram" invalid: Command name "help" is reserved by a built-in command',
});
expected: {
ok: false,
error:
'Native command alias "telegram" invalid: Command name "help" is reserved by a built-in command',
},
},
] as const)("$name", ({ setup, candidate, expected }) => {
setup?.();
expect(registerPluginCommand("other-plugin", candidate)).toEqual(expected);
});
it("resolves Discord DM command bindings with the user target prefix intact", () => {
expect(
__testing.resolveBindingConversationFromCommand({
it.each([
{
name: "resolves Discord DM command bindings with the user target prefix intact",
params: {
channel: "discord",
from: "discord:1177378744822943744",
to: "slash:1177378744822943744",
accountId: "default",
}),
).toEqual({
channel: "discord",
accountId: "default",
conversationId: "user:1177378744822943744",
});
});
it("resolves Discord guild command bindings with the channel target prefix intact", () => {
expect(
__testing.resolveBindingConversationFromCommand({
},
expected: {
channel: "discord",
accountId: "default",
conversationId: "user:1177378744822943744",
},
},
{
name: "resolves Discord guild command bindings with the channel target prefix intact",
params: {
channel: "discord",
from: "discord:channel:1480554272859881494",
accountId: "default",
}),
).toEqual({
channel: "discord",
accountId: "default",
conversationId: "channel:1480554272859881494",
});
});
it("resolves Discord thread command bindings with parent channel context intact", () => {
expect(
__testing.resolveBindingConversationFromCommand({
},
expected: {
channel: "discord",
accountId: "default",
conversationId: "channel:1480554272859881494",
},
},
{
name: "resolves Discord thread command bindings with parent channel context intact",
params: {
channel: "discord",
from: "discord:channel:1480554272859881494",
accountId: "default",
messageThreadId: "thread-42",
threadParentId: "channel-parent-7",
}),
).toEqual({
channel: "discord",
accountId: "default",
conversationId: "channel:1480554272859881494",
parentConversationId: "channel-parent-7",
threadId: "thread-42",
});
});
it("resolves Telegram topic command bindings without a Telegram registry entry", () => {
expect(
__testing.resolveBindingConversationFromCommand({
},
expected: {
channel: "discord",
accountId: "default",
conversationId: "channel:1480554272859881494",
parentConversationId: "channel-parent-7",
threadId: "thread-42",
},
},
{
name: "resolves Telegram topic command bindings without a Telegram registry entry",
params: {
channel: "telegram",
from: "telegram:group:-100123",
to: "telegram:group:-100123:topic:77",
accountId: "default",
}),
).toEqual({
channel: "telegram",
accountId: "default",
conversationId: "-100123",
threadId: 77,
});
});
it("resolves Telegram native slash command bindings using the From peer", () => {
expect(
__testing.resolveBindingConversationFromCommand({
},
expected: {
channel: "telegram",
accountId: "default",
conversationId: "-100123",
threadId: 77,
},
},
{
name: "resolves Telegram native slash command bindings using the From peer",
params: {
channel: "telegram",
from: "telegram:group:-100123:topic:77",
to: "slash:12345",
accountId: "default",
messageThreadId: 77,
}),
).toEqual({
channel: "telegram",
accountId: "default",
conversationId: "-100123",
threadId: 77,
});
});
it("falls back to the parsed From threadId for Telegram slash commands when messageThreadId is missing", () => {
expect(
__testing.resolveBindingConversationFromCommand({
},
expected: {
channel: "telegram",
accountId: "default",
conversationId: "-100123",
threadId: 77,
},
},
{
name: "falls back to the parsed From threadId for Telegram slash commands when messageThreadId is missing",
params: {
channel: "telegram",
from: "telegram:group:-100123:topic:77",
to: "slash:12345",
accountId: "default",
}),
).toEqual({
channel: "telegram",
accountId: "default",
conversationId: "-100123",
threadId: 77,
});
});
it("does not resolve binding conversations for unsupported command channels", () => {
expect(
__testing.resolveBindingConversationFromCommand({
},
expected: {
channel: "telegram",
accountId: "default",
conversationId: "-100123",
threadId: 77,
},
},
{
name: "does not resolve binding conversations for unsupported command channels",
params: {
channel: "slack",
from: "slack:U123",
to: "C456",
accountId: "default",
}),
).toBeNull();
},
expected: null,
},
] as const)("$name", ({ params, expected }) => {
expect(resolveBindingConversationFromCommand(params)).toEqual(expected);
});
it("does not expose binding APIs to plugin commands on unsupported channels", async () => {

View File

@ -71,11 +71,19 @@ describe("buildPluginConfigSchema", () => {
describe("emptyPluginConfigSchema", () => {
it("accepts undefined and empty objects only", () => {
const schema = emptyPluginConfigSchema();
expect(schema.safeParse?.(undefined)).toEqual({ success: true, data: undefined });
expect(schema.safeParse?.({})).toEqual({ success: true, data: {} });
expect(schema.safeParse?.({ nope: true })).toEqual({
success: false,
error: { issues: [{ path: [], message: "config must be empty" }] },
expect(schema.safeParse).toBeDefined();
expect([
[undefined, { success: true, data: undefined }],
[{}, { success: true, data: {} }],
[
{ nope: true },
{ success: false, error: { issues: [{ path: [], message: "config must be empty" }] } },
],
] as const).toSatisfy((cases) => {
for (const [value, expected] of cases) {
expect(schema.safeParse?.(value)).toEqual(expected);
}
return true;
});
});
});

View File

@ -197,6 +197,36 @@ function createTelegramCodexBindRequest(
};
}
function createCodexBindRequest(params: {
channel: "discord" | "telegram";
accountId: string;
conversationId: string;
summary: string;
pluginRoot?: string;
pluginId?: string;
parentConversationId?: string;
threadId?: string;
detachHint?: string;
}) {
return {
pluginId: params.pluginId ?? "codex",
pluginName: "Codex App Server",
pluginRoot: params.pluginRoot ?? "/plugins/codex-a",
requestedBySenderId: "user-1",
conversation: {
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
...(params.parentConversationId ? { parentConversationId: params.parentConversationId } : {}),
...(params.threadId ? { threadId: params.threadId } : {}),
},
binding: {
summary: params.summary,
...(params.detachHint ? { detachHint: params.detachHint } : {}),
},
} satisfies PluginBindingRequestInput;
}
async function requestPendingBinding(
input: PluginBindingRequestInput,
requestBinding = requestPluginConversationBinding,
@ -256,6 +286,91 @@ function createDeferredVoid(): { promise: Promise<void>; resolve: () => void } {
return { promise, resolve };
}
function createResolvedHandlerRegistry(params: {
pluginRoot: string;
handler: (input: unknown) => Promise<void>;
}) {
const registry = createEmptyPluginRegistry();
registry.conversationBindingResolvedHandlers.push({
pluginId: "codex",
pluginRoot: params.pluginRoot,
handler: params.handler,
source: `${params.pluginRoot}/index.ts`,
rootDir: params.pluginRoot,
});
setActivePluginRegistry(registry);
return registry;
}
async function expectResolutionCallback(params: {
pluginRoot: string;
requestInput: PluginBindingRequestInput;
decision: PluginBindingDecision;
expectedStatus: "approved" | "denied";
expectedCallback: unknown;
}) {
const onResolved = vi.fn(async () => undefined);
createResolvedHandlerRegistry({
pluginRoot: params.pluginRoot,
handler: onResolved,
});
const request = await requestPluginConversationBinding(params.requestInput);
expect(request.status).toBe("pending");
if (request.status !== "pending") {
throw new Error("expected pending bind request");
}
const result = await resolvePluginConversationBindingApproval({
approvalId: request.approvalId,
decision: params.decision,
senderId: "user-1",
});
expect(result.status).toBe(params.expectedStatus);
await flushMicrotasks();
expect(onResolved).toHaveBeenCalledWith(params.expectedCallback);
}
async function expectResolutionDoesNotWait(params: {
pluginRoot: string;
requestInput: PluginBindingRequestInput;
decision: PluginBindingDecision;
expectedStatus: "approved" | "denied";
}) {
const callbackGate = createDeferredVoid();
const onResolved = vi.fn(async () => callbackGate.promise);
createResolvedHandlerRegistry({
pluginRoot: params.pluginRoot,
handler: onResolved,
});
const request = await requestPluginConversationBinding(params.requestInput);
expect(request.status).toBe("pending");
if (request.status !== "pending") {
throw new Error("expected pending bind request");
}
let settled = false;
const resolutionPromise = resolvePluginConversationBindingApproval({
approvalId: request.approvalId,
decision: params.decision,
senderId: "user-1",
}).then((result) => {
settled = true;
return result;
});
await flushMicrotasks();
expect(settled).toBe(true);
expect(onResolved).toHaveBeenCalledTimes(1);
callbackGate.resolve();
const result = await resolutionPromise;
expect(result.status).toBe(params.expectedStatus);
}
describe("plugin conversation binding approvals", () => {
beforeEach(async () => {
vi.resetModules();
@ -423,20 +538,16 @@ describe("plugin conversation binding approvals", () => {
});
it("does not share persistent approvals across plugin roots even with the same plugin id", async () => {
const request = await requestPluginConversationBinding({
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/codex-a",
requestedBySenderId: "user-1",
conversation: {
const request = await requestPluginConversationBinding(
createCodexBindRequest({
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
parentConversationId: "-10099",
threadId: "77",
},
binding: { summary: "Bind this conversation to Codex thread abc." },
});
summary: "Bind this conversation to Codex thread abc.",
}),
);
expect(request.status).toBe("pending");
if (request.status !== "pending") {
@ -449,40 +560,31 @@ describe("plugin conversation binding approvals", () => {
senderId: "user-1",
});
const samePluginNewPath = await requestPluginConversationBinding({
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/codex-b",
requestedBySenderId: "user-1",
conversation: {
const samePluginNewPath = await requestPluginConversationBinding(
createCodexBindRequest({
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:78",
parentConversationId: "-10099",
threadId: "78",
},
binding: { summary: "Bind this conversation to Codex thread def." },
});
summary: "Bind this conversation to Codex thread def.",
pluginRoot: "/plugins/codex-b",
}),
);
expect(samePluginNewPath.status).toBe("pending");
});
it("persists detachHint on approved plugin bindings", async () => {
const request = await requestPluginConversationBinding({
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/codex-a",
requestedBySenderId: "user-1",
conversation: {
const request = await requestPluginConversationBinding(
createCodexBindRequest({
channel: "discord",
accountId: "isolated",
conversationId: "channel:detach-hint",
},
binding: {
summary: "Bind this conversation to Codex thread 999.",
detachHint: "/codex_detach",
},
});
}),
);
expect(["pending", "bound"]).toContain(request.status);
@ -517,220 +619,120 @@ describe("plugin conversation binding approvals", () => {
expect(currentBinding?.detachHint).toBe("/codex_detach");
});
it("notifies the owning plugin when a bind approval is approved", async () => {
const registry = createEmptyPluginRegistry();
const onResolved = vi.fn(async () => undefined);
registry.conversationBindingResolvedHandlers.push({
pluginId: "codex",
it.each([
{
name: "notifies the owning plugin when a bind approval is approved",
pluginRoot: "/plugins/callback-test",
handler: onResolved,
source: "/plugins/callback-test/index.ts",
rootDir: "/plugins/callback-test",
});
setActivePluginRegistry(registry);
const request = await requestPluginConversationBinding({
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/callback-test",
requestedBySenderId: "user-1",
conversation: {
channel: "discord",
accountId: "isolated",
conversationId: "channel:callback-test",
},
binding: { summary: "Bind this conversation to Codex thread abc." },
});
expect(request.status).toBe("pending");
if (request.status !== "pending") {
throw new Error("expected pending bind request");
}
const approved = await resolvePluginConversationBindingApproval({
approvalId: request.approvalId,
decision: "allow-once",
senderId: "user-1",
});
expect(approved.status).toBe("approved");
await flushMicrotasks();
expect(onResolved).toHaveBeenCalledWith({
status: "approved",
binding: expect.objectContaining({
requestInput: {
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/callback-test",
conversationId: "channel:callback-test",
}),
decision: "allow-once",
request: {
summary: "Bind this conversation to Codex thread abc.",
detachHint: undefined,
requestedBySenderId: "user-1",
conversation: {
channel: "discord",
accountId: "isolated",
conversationId: "channel:callback-test",
},
binding: { summary: "Bind this conversation to Codex thread abc." },
},
});
});
it("notifies the owning plugin when a bind approval is denied", async () => {
const registry = createEmptyPluginRegistry();
const onResolved = vi.fn(async () => undefined);
registry.conversationBindingResolvedHandlers.push({
pluginId: "codex",
pluginRoot: "/plugins/callback-deny",
handler: onResolved,
source: "/plugins/callback-deny/index.ts",
rootDir: "/plugins/callback-deny",
});
setActivePluginRegistry(registry);
const request = await requestPluginConversationBinding({
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/callback-deny",
requestedBySenderId: "user-1",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "8460800771",
decision: "allow-once" as const,
expectedStatus: "approved" as const,
expectedCallback: {
status: "approved",
binding: expect.objectContaining({
pluginId: "codex",
pluginRoot: "/plugins/callback-test",
conversationId: "channel:callback-test",
}),
decision: "allow-once",
request: {
summary: "Bind this conversation to Codex thread abc.",
detachHint: undefined,
requestedBySenderId: "user-1",
conversation: {
channel: "discord",
accountId: "isolated",
conversationId: "channel:callback-test",
},
},
},
binding: { summary: "Bind this conversation to Codex thread deny." },
});
expect(request.status).toBe("pending");
if (request.status !== "pending") {
throw new Error("expected pending bind request");
}
const denied = await resolvePluginConversationBindingApproval({
approvalId: request.approvalId,
decision: "deny",
senderId: "user-1",
});
expect(denied.status).toBe("denied");
await flushMicrotasks();
expect(onResolved).toHaveBeenCalledWith({
status: "denied",
binding: undefined,
decision: "deny",
request: {
summary: "Bind this conversation to Codex thread deny.",
detachHint: undefined,
},
{
name: "notifies the owning plugin when a bind approval is denied",
pluginRoot: "/plugins/callback-deny",
requestInput: {
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/callback-deny",
requestedBySenderId: "user-1",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "8460800771",
},
binding: { summary: "Bind this conversation to Codex thread deny." },
},
});
decision: "deny" as const,
expectedStatus: "denied" as const,
expectedCallback: {
status: "denied",
binding: undefined,
decision: "deny",
request: {
summary: "Bind this conversation to Codex thread deny.",
detachHint: undefined,
requestedBySenderId: "user-1",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "8460800771",
},
},
},
},
] as const)("$name", async (testCase) => {
await expectResolutionCallback(testCase);
});
it("does not wait for an approved bind callback before returning", async () => {
const registry = createEmptyPluginRegistry();
const callbackGate = createDeferredVoid();
const onResolved = vi.fn(async () => callbackGate.promise);
registry.conversationBindingResolvedHandlers.push({
pluginId: "codex",
it.each([
{
name: "does not wait for an approved bind callback before returning",
pluginRoot: "/plugins/callback-slow-approve",
handler: onResolved,
source: "/plugins/callback-slow-approve/index.ts",
rootDir: "/plugins/callback-slow-approve",
});
setActivePluginRegistry(registry);
const request = await requestPluginConversationBinding({
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/callback-slow-approve",
requestedBySenderId: "user-1",
conversation: {
channel: "discord",
accountId: "isolated",
conversationId: "channel:slow-approve",
requestInput: {
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/callback-slow-approve",
requestedBySenderId: "user-1",
conversation: {
channel: "discord",
accountId: "isolated",
conversationId: "channel:slow-approve",
},
binding: { summary: "Bind this conversation to Codex thread slow-approve." },
},
binding: { summary: "Bind this conversation to Codex thread slow-approve." },
});
expect(request.status).toBe("pending");
if (request.status !== "pending") {
throw new Error("expected pending bind request");
}
let settled = false;
const resolutionPromise = resolvePluginConversationBindingApproval({
approvalId: request.approvalId,
decision: "allow-once",
senderId: "user-1",
}).then((result) => {
settled = true;
return result;
});
await flushMicrotasks();
expect(settled).toBe(true);
expect(onResolved).toHaveBeenCalledTimes(1);
callbackGate.resolve();
const approved = await resolutionPromise;
expect(approved.status).toBe("approved");
});
it("does not wait for a denied bind callback before returning", async () => {
const registry = createEmptyPluginRegistry();
const callbackGate = createDeferredVoid();
const onResolved = vi.fn(async () => callbackGate.promise);
registry.conversationBindingResolvedHandlers.push({
pluginId: "codex",
decision: "allow-once" as const,
expectedStatus: "approved" as const,
},
{
name: "does not wait for a denied bind callback before returning",
pluginRoot: "/plugins/callback-slow-deny",
handler: onResolved,
source: "/plugins/callback-slow-deny/index.ts",
rootDir: "/plugins/callback-slow-deny",
});
setActivePluginRegistry(registry);
const request = await requestPluginConversationBinding({
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/callback-slow-deny",
requestedBySenderId: "user-1",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "slow-deny",
requestInput: {
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/callback-slow-deny",
requestedBySenderId: "user-1",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "slow-deny",
},
binding: { summary: "Bind this conversation to Codex thread slow-deny." },
},
binding: { summary: "Bind this conversation to Codex thread slow-deny." },
});
expect(request.status).toBe("pending");
if (request.status !== "pending") {
throw new Error("expected pending bind request");
}
let settled = false;
const resolutionPromise = resolvePluginConversationBindingApproval({
approvalId: request.approvalId,
decision: "deny",
senderId: "user-1",
}).then((result) => {
settled = true;
return result;
});
await flushMicrotasks();
expect(settled).toBe(true);
expect(onResolved).toHaveBeenCalledTimes(1);
callbackGate.resolve();
const denied = await resolutionPromise;
expect(denied.status).toBe("denied");
decision: "deny" as const,
expectedStatus: "denied" as const,
},
] as const)("$name", async (testCase) => {
await expectResolutionDoesNotWait(testCase);
});
it("returns and detaches only bindings owned by the requesting plugin root", async () => {
@ -842,89 +844,75 @@ describe("plugin conversation binding approvals", () => {
});
});
it("migrates a legacy plugin binding record through the new approval flow even if the old plugin id differs", async () => {
sessionBindingState.setRecord({
bindingId: "binding-legacy",
targetSessionKey: "plugin-binding:old-codex-plugin:legacy123",
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
it.each([
{
name: "migrates a legacy plugin binding record through the new approval flow even if the old plugin id differs",
existingRecord: {
bindingId: "binding-legacy",
targetSessionKey: "plugin-binding:old-codex-plugin:legacy123",
targetKind: "session" as const,
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
},
status: "active" as const,
metadata: {
label: "legacy plugin bind",
},
},
status: "active",
boundAt: Date.now(),
metadata: {
label: "legacy plugin bind",
},
});
const request = await requestPluginConversationBinding({
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/codex-a",
requestedBySenderId: "user-1",
conversation: {
requestInput: createCodexBindRequest({
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
parentConversationId: "-10099",
threadId: "77",
},
binding: { summary: "Bind this conversation to Codex thread abc." },
});
const binding = await resolveRequestedBinding(request);
expect(binding).toEqual(
expect.objectContaining({
summary: "Bind this conversation to Codex thread abc.",
}),
expectedBinding: {
pluginId: "codex",
pluginRoot: "/plugins/codex-a",
conversationId: "-10099:topic:77",
}),
);
});
it("migrates a legacy codex thread binding session key through the new approval flow", async () => {
sessionBindingState.setRecord({
bindingId: "binding-legacy-codex-thread",
targetSessionKey: "openclaw-app-server:thread:019ce411-6322-7db2-a821-1a61c530e7d9",
targetKind: "session",
conversation: {
},
},
{
name: "migrates a legacy codex thread binding session key through the new approval flow",
existingRecord: {
bindingId: "binding-legacy-codex-thread",
targetSessionKey: "openclaw-app-server:thread:019ce411-6322-7db2-a821-1a61c530e7d9",
targetKind: "session" as const,
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "8460800771",
},
status: "active" as const,
metadata: {
label: "legacy codex thread bind",
},
},
requestInput: createCodexBindRequest({
channel: "telegram",
accountId: "default",
conversationId: "8460800771",
},
status: "active",
boundAt: Date.now(),
metadata: {
label: "legacy codex thread bind",
},
});
const request = await requestPluginConversationBinding({
pluginId: "openclaw-codex-app-server",
pluginName: "Codex App Server",
pluginRoot: "/plugins/codex-a",
requestedBySenderId: "user-1",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "8460800771",
},
binding: {
summary: "Bind this conversation to Codex thread 019ce411-6322-7db2-a821-1a61c530e7d9.",
},
});
const binding = await resolveRequestedBinding(request);
expect(binding).toEqual(
expect.objectContaining({
pluginId: "openclaw-codex-app-server",
}),
expectedBinding: {
pluginId: "openclaw-codex-app-server",
pluginRoot: "/plugins/codex-a",
conversationId: "8460800771",
}),
);
},
},
] as const)("$name", async ({ existingRecord, requestInput, expectedBinding }) => {
sessionBindingState.setRecord({
...existingRecord,
boundAt: Date.now(),
});
const request = await requestPluginConversationBinding(requestInput);
const binding = await resolveRequestedBinding(request);
expect(binding).toEqual(expect.objectContaining(expectedBinding));
});
});

View File

@ -22,6 +22,61 @@ function writeJson(filePath: string, value: unknown): void {
writeJsonFile(filePath, value);
}
function createPlugin(
repoRoot: string,
params: {
id: string;
packageName: string;
manifest?: Record<string, unknown>;
packageOpenClaw?: Record<string, unknown>;
},
) {
const pluginDir = path.join(repoRoot, "extensions", params.id);
fs.mkdirSync(pluginDir, { recursive: true });
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
id: params.id,
configSchema: { type: "object" },
...params.manifest,
});
writeJson(path.join(pluginDir, "package.json"), {
name: params.packageName,
...(params.packageOpenClaw ? { openclaw: params.packageOpenClaw } : {}),
});
return pluginDir;
}
function readBundledManifest(repoRoot: string, pluginId: string) {
return JSON.parse(
fs.readFileSync(
path.join(repoRoot, "dist", "extensions", pluginId, "openclaw.plugin.json"),
"utf8",
),
) as { skills?: string[] };
}
function readBundledPackageJson(repoRoot: string, pluginId: string) {
return JSON.parse(
fs.readFileSync(path.join(repoRoot, "dist", "extensions", pluginId, "package.json"), "utf8"),
) as { openclaw?: { extensions?: string[] } };
}
function bundledPluginDir(repoRoot: string, pluginId: string) {
return path.join(repoRoot, "dist", "extensions", pluginId);
}
function bundledSkillPath(repoRoot: string, pluginId: string, ...relativePath: string[]) {
return path.join(bundledPluginDir(repoRoot, pluginId), ...relativePath);
}
function createTlonSkillPlugin(repoRoot: string, skillPath = "node_modules/@tloncorp/tlon-skill") {
return createPlugin(repoRoot, {
id: "tlon",
packageName: "@openclaw/tlon",
manifest: { skills: [skillPath] },
packageOpenClaw: { extensions: ["./index.ts"] },
});
}
afterEach(() => {
cleanupTempDirs(tempDirs);
});
@ -38,22 +93,18 @@ describe("rewritePackageExtensions", () => {
describe("copyBundledPluginMetadata", () => {
it("copies plugin manifests, package metadata, and local skill directories", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-meta-");
const pluginDir = path.join(repoRoot, "extensions", "acpx");
const pluginDir = createPlugin(repoRoot, {
id: "acpx",
packageName: "@openclaw/acpx",
manifest: { skills: ["./skills"] },
packageOpenClaw: { extensions: ["./index.ts"] },
});
fs.mkdirSync(path.join(pluginDir, "skills", "acp-router"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "skills", "acp-router", "SKILL.md"),
"# ACP Router\n",
"utf8",
);
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
id: "acpx",
configSchema: { type: "object" },
skills: ["./skills"],
});
writeJson(path.join(pluginDir, "package.json"), {
name: "@openclaw/acpx",
openclaw: { extensions: ["./index.ts"] },
});
copyBundledPluginMetadata({ repoRoot });
@ -66,22 +117,15 @@ describe("copyBundledPluginMetadata", () => {
"utf8",
),
).toContain("ACP Router");
const bundledManifest = JSON.parse(
fs.readFileSync(
path.join(repoRoot, "dist", "extensions", "acpx", "openclaw.plugin.json"),
"utf8",
),
) as { skills?: string[] };
const bundledManifest = readBundledManifest(repoRoot, "acpx");
expect(bundledManifest.skills).toEqual(["./skills"]);
const packageJson = JSON.parse(
fs.readFileSync(path.join(repoRoot, "dist", "extensions", "acpx", "package.json"), "utf8"),
) as { openclaw?: { extensions?: string[] } };
const packageJson = readBundledPackageJson(repoRoot, "acpx");
expect(packageJson.openclaw?.extensions).toEqual(["./index.js"]);
});
it("relocates node_modules-backed skill paths into bundled-skills and rewrites the manifest", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-node-modules-");
const pluginDir = path.join(repoRoot, "extensions", "tlon");
const pluginDir = createTlonSkillPlugin(repoRoot);
const storeSkillDir = path.join(
repoRoot,
"node_modules",
@ -105,20 +149,8 @@ describe("copyBundledPluginMetadata", () => {
path.join(pluginDir, "node_modules", "@tloncorp", "tlon-skill"),
process.platform === "win32" ? "junction" : "dir",
);
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
id: "tlon",
configSchema: { type: "object" },
skills: ["node_modules/@tloncorp/tlon-skill"],
});
writeJson(path.join(pluginDir, "package.json"), {
name: "@openclaw/tlon",
openclaw: { extensions: ["./index.ts"] },
});
const staleNodeModulesSkillDir = path.join(
repoRoot,
"dist",
"extensions",
"tlon",
bundledPluginDir(repoRoot, "tlon"),
"node_modules",
"@tloncorp",
"tlon-skill",
@ -129,10 +161,7 @@ describe("copyBundledPluginMetadata", () => {
copyBundledPluginMetadata({ repoRoot });
const copiedSkillDir = path.join(
repoRoot,
"dist",
"extensions",
"tlon",
bundledPluginDir(repoRoot, "tlon"),
"bundled-skills",
"@tloncorp",
"tlon-skill",
@ -140,96 +169,50 @@ describe("copyBundledPluginMetadata", () => {
expect(fs.existsSync(path.join(copiedSkillDir, "SKILL.md"))).toBe(true);
expect(fs.lstatSync(copiedSkillDir).isSymbolicLink()).toBe(false);
expect(fs.existsSync(path.join(copiedSkillDir, "node_modules"))).toBe(false);
expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "tlon", "node_modules"))).toBe(
expect(fs.existsSync(path.join(bundledPluginDir(repoRoot, "tlon"), "node_modules"))).toBe(
false,
);
const bundledManifest = JSON.parse(
fs.readFileSync(
path.join(repoRoot, "dist", "extensions", "tlon", "openclaw.plugin.json"),
"utf8",
),
) as { skills?: string[] };
const bundledManifest = readBundledManifest(repoRoot, "tlon");
expect(bundledManifest.skills).toEqual(["./bundled-skills/@tloncorp/tlon-skill"]);
});
it("falls back to repo-root hoisted node_modules skill paths", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-hoisted-skill-");
const pluginDir = path.join(repoRoot, "extensions", "tlon");
const pluginDir = createTlonSkillPlugin(repoRoot);
const hoistedSkillDir = path.join(repoRoot, "node_modules", "@tloncorp", "tlon-skill");
fs.mkdirSync(hoistedSkillDir, { recursive: true });
fs.writeFileSync(path.join(hoistedSkillDir, "SKILL.md"), "# Hoisted Tlon Skill\n", "utf8");
fs.mkdirSync(pluginDir, { recursive: true });
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
id: "tlon",
configSchema: { type: "object" },
skills: ["node_modules/@tloncorp/tlon-skill"],
});
writeJson(path.join(pluginDir, "package.json"), {
name: "@openclaw/tlon",
openclaw: { extensions: ["./index.ts"] },
});
copyBundledPluginMetadata({ repoRoot });
expect(
fs.readFileSync(
path.join(
repoRoot,
"dist",
"extensions",
"tlon",
"bundled-skills",
"@tloncorp",
"tlon-skill",
"SKILL.md",
),
bundledSkillPath(repoRoot, "tlon", "bundled-skills", "@tloncorp", "tlon-skill", "SKILL.md"),
"utf8",
),
).toContain("Hoisted Tlon Skill");
const bundledManifest = JSON.parse(
fs.readFileSync(
path.join(repoRoot, "dist", "extensions", "tlon", "openclaw.plugin.json"),
"utf8",
),
) as { skills?: string[] };
const bundledManifest = readBundledManifest(repoRoot, "tlon");
expect(bundledManifest.skills).toEqual(["./bundled-skills/@tloncorp/tlon-skill"]);
});
it("omits missing declared skill paths and removes stale generated outputs", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-missing-skill-");
const pluginDir = path.join(repoRoot, "extensions", "tlon");
fs.mkdirSync(pluginDir, { recursive: true });
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
id: "tlon",
configSchema: { type: "object" },
skills: ["node_modules/@tloncorp/tlon-skill"],
});
writeJson(path.join(pluginDir, "package.json"), {
name: "@openclaw/tlon",
openclaw: { extensions: ["./index.ts"] },
});
createTlonSkillPlugin(repoRoot);
const staleBundledSkillDir = path.join(
repoRoot,
"dist",
"extensions",
"tlon",
bundledPluginDir(repoRoot, "tlon"),
"bundled-skills",
"@tloncorp",
"tlon-skill",
);
fs.mkdirSync(staleBundledSkillDir, { recursive: true });
fs.writeFileSync(path.join(staleBundledSkillDir, "SKILL.md"), "# stale\n", "utf8");
const staleNodeModulesDir = path.join(repoRoot, "dist", "extensions", "tlon", "node_modules");
const staleNodeModulesDir = path.join(bundledPluginDir(repoRoot, "tlon"), "node_modules");
fs.mkdirSync(staleNodeModulesDir, { recursive: true });
copyBundledPluginMetadata({ repoRoot });
const bundledManifest = JSON.parse(
fs.readFileSync(
path.join(repoRoot, "dist", "extensions", "tlon", "openclaw.plugin.json"),
"utf8",
),
) as { skills?: string[] };
const bundledManifest = readBundledManifest(repoRoot, "tlon");
expect(bundledManifest.skills).toEqual([]);
expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "tlon", "bundled-skills"))).toBe(
false,
@ -239,18 +222,14 @@ describe("copyBundledPluginMetadata", () => {
it("retries transient skill copy races from concurrent runtime postbuilds", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-retry-");
const pluginDir = path.join(repoRoot, "extensions", "diffs");
const pluginDir = createPlugin(repoRoot, {
id: "diffs",
packageName: "@openclaw/diffs",
manifest: { skills: ["./skills"] },
packageOpenClaw: { extensions: ["./index.ts"] },
});
fs.mkdirSync(path.join(pluginDir, "skills", "diffs"), { recursive: true });
fs.writeFileSync(path.join(pluginDir, "skills", "diffs", "SKILL.md"), "# Diffs\n", "utf8");
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
id: "diffs",
configSchema: { type: "object" },
skills: ["./skills"],
});
writeJson(path.join(pluginDir, "package.json"), {
name: "@openclaw/diffs",
openclaw: { extensions: ["./index.ts"] },
});
const realCpSync = fs.cpSync.bind(fs);
let attempts = 0;
@ -339,42 +318,36 @@ describe("copyBundledPluginMetadata", () => {
expect(fs.existsSync(staleDistDir)).toBe(false);
});
it("skips metadata for optional bundled clusters only when explicitly disabled", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-optional-skip-");
const pluginDir = path.join(repoRoot, "extensions", "acpx");
fs.mkdirSync(pluginDir, { recursive: true });
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
id: "acpx",
configSchema: { type: "object" },
});
writeJson(path.join(pluginDir, "package.json"), {
name: "@openclaw/acpx-plugin",
openclaw: { extensions: ["./index.ts"] },
});
copyBundledPluginMetadataWithEnv({ repoRoot, env: excludeOptionalEnv });
expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "acpx"))).toBe(false);
});
it("still bundles previously released optional plugins without the opt-in env", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-released-optional-");
const pluginDir = path.join(repoRoot, "extensions", "whatsapp");
fs.mkdirSync(pluginDir, { recursive: true });
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
id: "whatsapp",
configSchema: { type: "object" },
});
writeJson(path.join(pluginDir, "package.json"), {
name: "@openclaw/whatsapp",
openclaw: {
it.each([
{
name: "skips metadata for optional bundled clusters only when explicitly disabled",
pluginId: "acpx",
packageName: "@openclaw/acpx-plugin",
packageOpenClaw: { extensions: ["./index.ts"] },
env: excludeOptionalEnv,
expectedExists: false,
},
{
name: "still bundles previously released optional plugins without the opt-in env",
pluginId: "whatsapp",
packageName: "@openclaw/whatsapp",
packageOpenClaw: {
extensions: ["./index.ts"],
install: { npmSpec: "@openclaw/whatsapp" },
},
env: {},
expectedExists: true,
},
] as const)("$name", ({ pluginId, packageName, packageOpenClaw, env, expectedExists }) => {
const repoRoot = makeRepoRoot(`openclaw-bundled-plugin-${pluginId}-`);
createPlugin(repoRoot, {
id: pluginId,
packageName,
packageOpenClaw,
});
copyBundledPluginMetadataWithEnv({ repoRoot, env: {} });
copyBundledPluginMetadataWithEnv({ repoRoot, env });
expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "whatsapp"))).toBe(true);
expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", pluginId))).toBe(expectedExists);
});
});

View File

@ -41,6 +41,17 @@ function buildDiscoveryEnv(stateDir: string): NodeJS.ProcessEnv {
};
}
function buildCachedDiscoveryEnv(
stateDir: string,
overrides: Partial<NodeJS.ProcessEnv> = {},
): NodeJS.ProcessEnv {
return {
...buildDiscoveryEnv(stateDir),
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
...overrides,
};
}
async function discoverWithStateDir(
stateDir: string,
params: Parameters<typeof discoverOpenClawPlugins>[0],
@ -48,6 +59,10 @@ async function discoverWithStateDir(
return discoverOpenClawPlugins({ ...params, env: buildDiscoveryEnv(stateDir) });
}
function discoverWithCachedEnv(params: Parameters<typeof discoverOpenClawPlugins>[0]) {
return discoverOpenClawPlugins(params);
}
function writePluginPackageManifest(params: {
packageDir: string;
packageName: string;
@ -74,6 +89,66 @@ function writePluginManifest(params: { pluginDir: string; id: string }) {
);
}
function writePluginEntry(filePath: string) {
fs.writeFileSync(filePath, "export default function () {}", "utf-8");
}
function writeStandalonePlugin(filePath: string, source = "export default function () {}") {
mkdirSafe(path.dirname(filePath));
fs.writeFileSync(filePath, source, "utf-8");
}
function createPackagePlugin(params: {
packageDir: string;
packageName: string;
extensions: string[];
pluginId?: string;
}) {
mkdirSafe(params.packageDir);
writePluginPackageManifest({
packageDir: params.packageDir,
packageName: params.packageName,
extensions: params.extensions,
});
if (params.pluginId) {
writePluginManifest({ pluginDir: params.packageDir, id: params.pluginId });
}
}
function createBundleRoot(bundleDir: string, markerPath: string, manifest?: unknown) {
mkdirSafe(path.dirname(path.join(bundleDir, markerPath)));
if (manifest) {
fs.writeFileSync(path.join(bundleDir, markerPath), JSON.stringify(manifest), "utf-8");
return;
}
mkdirSafe(path.join(bundleDir, markerPath));
}
function expectCandidateIds(
candidates: Array<{ idHint: string }>,
params: { includes?: readonly string[]; excludes?: readonly string[] },
) {
const ids = candidates.map((candidate) => candidate.idHint);
for (const includedId of params.includes ?? []) {
expect(ids).toContain(includedId);
}
for (const excludedId of params.excludes ?? []) {
expect(ids).not.toContain(excludedId);
}
}
function findCandidateById<T extends { idHint?: string }>(candidates: T[], idHint: string) {
return candidates.find((candidate) => candidate.idHint === idHint);
}
function expectCandidateSource(
candidates: Array<{ idHint?: string; source?: string }>,
idHint: string,
source: string,
) {
expect(findCandidateById(candidates, idHint)?.source).toBe(source);
}
function expectEscapesPackageDiagnostic(diagnostics: Array<{ message: string }>) {
expect(diagnostics.some((entry) => entry.message.includes("escapes package directory"))).toBe(
true,
@ -166,22 +241,11 @@ describe("discoverOpenClawPlugins", () => {
packageName: "pack",
extensions: ["./src/one.ts", "./src/two.ts"],
});
fs.writeFileSync(
path.join(globalExt, "src", "one.ts"),
"export default function () {}",
"utf-8",
);
fs.writeFileSync(
path.join(globalExt, "src", "two.ts"),
"export default function () {}",
"utf-8",
);
writePluginEntry(path.join(globalExt, "src", "one.ts"));
writePluginEntry(path.join(globalExt, "src", "two.ts"));
const { candidates } = await discoverWithStateDir(stateDir, {});
const ids = candidates.map((c) => c.idHint);
expect(ids).toContain("pack/one");
expect(ids).toContain("pack/two");
expectCandidateIds(candidates, { includes: ["pack/one", "pack/two"] });
});
it("does not discover nested node_modules copies under installed plugins", async () => {
@ -227,210 +291,178 @@ describe("discoverOpenClawPlugins", () => {
expect(candidates.map((candidate) => candidate.idHint)).toEqual(["opik-openclaw"]);
});
it("derives unscoped ids for scoped packages", async () => {
it.each([
{
name: "derives unscoped ids for scoped packages",
setup: (stateDir: string) => {
const packageDir = path.join(stateDir, "extensions", "voice-call-pack");
mkdirSafe(path.join(packageDir, "src"));
createPackagePlugin({
packageDir,
packageName: "@openclaw/voice-call",
extensions: ["./src/index.ts"],
});
writePluginEntry(path.join(packageDir, "src", "index.ts"));
return {};
},
includes: ["voice-call"],
},
{
name: "strips provider suffixes from package-derived ids",
setup: (stateDir: string) => {
const packageDir = path.join(stateDir, "extensions", "ollama-provider-pack");
mkdirSafe(path.join(packageDir, "src"));
createPackagePlugin({
packageDir,
packageName: "@openclaw/ollama-provider",
extensions: ["./src/index.ts"],
pluginId: "ollama",
});
writePluginEntry(path.join(packageDir, "src", "index.ts"));
return {};
},
includes: ["ollama"],
excludes: ["ollama-provider"],
},
{
name: "normalizes bundled speech package ids to canonical plugin ids",
setup: (stateDir: string) => {
for (const [dirName, packageName, pluginId] of [
["elevenlabs-speech-pack", "@openclaw/elevenlabs-speech", "elevenlabs"],
["microsoft-speech-pack", "@openclaw/microsoft-speech", "microsoft"],
] as const) {
const packageDir = path.join(stateDir, "extensions", dirName);
mkdirSafe(path.join(packageDir, "src"));
createPackagePlugin({
packageDir,
packageName,
extensions: ["./src/index.ts"],
pluginId,
});
writePluginEntry(path.join(packageDir, "src", "index.ts"));
}
return {};
},
includes: ["elevenlabs", "microsoft"],
excludes: ["elevenlabs-speech", "microsoft-speech"],
},
{
name: "treats configured directory paths as plugin packages",
setup: (stateDir: string) => {
const packageDir = path.join(stateDir, "packs", "demo-plugin-dir");
createPackagePlugin({
packageDir,
packageName: "@openclaw/demo-plugin-dir",
extensions: ["./index.js"],
});
fs.writeFileSync(path.join(packageDir, "index.js"), "module.exports = {}", "utf-8");
return { extraPaths: [packageDir] };
},
includes: ["demo-plugin-dir"],
},
] as const)("$name", async ({ setup, includes, excludes }) => {
const stateDir = makeTempDir();
const globalExt = path.join(stateDir, "extensions", "voice-call-pack");
mkdirSafe(path.join(globalExt, "src"));
writePluginPackageManifest({
packageDir: globalExt,
packageName: "@openclaw/voice-call",
extensions: ["./src/index.ts"],
});
fs.writeFileSync(
path.join(globalExt, "src", "index.ts"),
"export default function () {}",
"utf-8",
);
const discoverParams = setup(stateDir);
const { candidates } = await discoverWithStateDir(stateDir, discoverParams);
expectCandidateIds(candidates, { includes, excludes });
});
it.each([
{
name: "auto-detects Codex bundles as bundle candidates",
idHint: "sample-bundle",
bundleFormat: "codex",
setup: (stateDir: string) => {
const bundleDir = path.join(stateDir, "extensions", "sample-bundle");
createBundleRoot(bundleDir, ".codex-plugin/plugin.json", {
name: "Sample Bundle",
skills: "skills",
});
mkdirSafe(path.join(bundleDir, "skills"));
return bundleDir;
},
expectRootDir: true,
},
{
name: "auto-detects manifestless Claude bundles from the default layout",
idHint: "claude-bundle",
bundleFormat: "claude",
setup: (stateDir: string) => {
const bundleDir = path.join(stateDir, "extensions", "claude-bundle");
mkdirSafe(path.join(bundleDir, "commands"));
fs.writeFileSync(
path.join(bundleDir, "settings.json"),
'{"hideThinkingBlock":true}',
"utf-8",
);
return bundleDir;
},
},
{
name: "auto-detects Cursor bundles as bundle candidates",
idHint: "cursor-bundle",
bundleFormat: "cursor",
setup: (stateDir: string) => {
const bundleDir = path.join(stateDir, "extensions", "cursor-bundle");
createBundleRoot(bundleDir, ".cursor-plugin/plugin.json", {
name: "Cursor Bundle",
});
mkdirSafe(path.join(bundleDir, ".cursor", "commands"));
return bundleDir;
},
},
] as const)("$name", async ({ idHint, bundleFormat, setup, expectRootDir }) => {
const stateDir = makeTempDir();
const bundleDir = setup(stateDir);
const { candidates } = await discoverWithStateDir(stateDir, {});
const bundle = findCandidateById(candidates, idHint);
const ids = candidates.map((c) => c.idHint);
expect(ids).toContain("voice-call");
});
it("strips provider suffixes from package-derived ids", async () => {
const stateDir = makeTempDir();
const globalExt = path.join(stateDir, "extensions", "ollama-provider-pack");
mkdirSafe(path.join(globalExt, "src"));
writePluginPackageManifest({
packageDir: globalExt,
packageName: "@openclaw/ollama-provider",
extensions: ["./src/index.ts"],
});
writePluginManifest({ pluginDir: globalExt, id: "ollama" });
fs.writeFileSync(
path.join(globalExt, "src", "index.ts"),
"export default function () {}",
"utf-8",
);
const { candidates } = await discoverWithStateDir(stateDir, {});
const ids = candidates.map((c) => c.idHint);
expect(ids).toContain("ollama");
expect(ids).not.toContain("ollama-provider");
});
it("normalizes bundled speech package ids to canonical plugin ids", async () => {
const stateDir = makeTempDir();
const extensionsDir = path.join(stateDir, "extensions");
const elevenlabsDir = path.join(extensionsDir, "elevenlabs-speech-pack");
const microsoftDir = path.join(extensionsDir, "microsoft-speech-pack");
mkdirSafe(path.join(elevenlabsDir, "src"));
mkdirSafe(path.join(microsoftDir, "src"));
writePluginPackageManifest({
packageDir: elevenlabsDir,
packageName: "@openclaw/elevenlabs-speech",
extensions: ["./src/index.ts"],
});
writePluginManifest({ pluginDir: elevenlabsDir, id: "elevenlabs" });
writePluginPackageManifest({
packageDir: microsoftDir,
packageName: "@openclaw/microsoft-speech",
extensions: ["./src/index.ts"],
});
writePluginManifest({ pluginDir: microsoftDir, id: "microsoft" });
fs.writeFileSync(
path.join(elevenlabsDir, "src", "index.ts"),
"export default function () {}",
"utf-8",
);
fs.writeFileSync(
path.join(microsoftDir, "src", "index.ts"),
"export default function () {}",
"utf-8",
);
const { candidates } = await discoverWithStateDir(stateDir, {});
const ids = candidates.map((c) => c.idHint);
expect(ids).toContain("elevenlabs");
expect(ids).toContain("microsoft");
expect(ids).not.toContain("elevenlabs-speech");
expect(ids).not.toContain("microsoft-speech");
});
it("treats configured directory paths as plugin packages", async () => {
const stateDir = makeTempDir();
const packDir = path.join(stateDir, "packs", "demo-plugin-dir");
mkdirSafe(packDir);
writePluginPackageManifest({
packageDir: packDir,
packageName: "@openclaw/demo-plugin-dir",
extensions: ["./index.js"],
});
fs.writeFileSync(path.join(packDir, "index.js"), "module.exports = {}", "utf-8");
const { candidates } = await discoverWithStateDir(stateDir, { extraPaths: [packDir] });
const ids = candidates.map((c) => c.idHint);
expect(ids).toContain("demo-plugin-dir");
});
it("auto-detects Codex bundles as bundle candidates", async () => {
const stateDir = makeTempDir();
const bundleDir = path.join(stateDir, "extensions", "sample-bundle");
mkdirSafe(path.join(bundleDir, ".codex-plugin"));
mkdirSafe(path.join(bundleDir, "skills"));
fs.writeFileSync(
path.join(bundleDir, ".codex-plugin", "plugin.json"),
JSON.stringify({
name: "Sample Bundle",
skills: "skills",
expect(bundle).toBeDefined();
expect(bundle).toEqual(
expect.objectContaining({
idHint,
format: "bundle",
bundleFormat,
source: bundleDir,
}),
"utf-8",
);
const { candidates } = await discoverWithStateDir(stateDir, {});
const bundle = candidates.find((candidate) => candidate.idHint === "sample-bundle");
expect(bundle).toBeDefined();
expect(bundle?.idHint).toBe("sample-bundle");
expect(bundle?.format).toBe("bundle");
expect(bundle?.bundleFormat).toBe("codex");
expect(bundle?.source).toBe(bundleDir);
expect(normalizePathForAssertion(bundle?.rootDir)).toBe(
normalizePathForAssertion(fs.realpathSync(bundleDir)),
);
if (expectRootDir) {
expect(normalizePathForAssertion(bundle?.rootDir)).toBe(
normalizePathForAssertion(fs.realpathSync(bundleDir)),
);
}
});
it("auto-detects manifestless Claude bundles from the default layout", async () => {
it.each([
{
name: "falls back to legacy index discovery when a scanned bundle sidecar is malformed",
bundleMarker: ".claude-plugin/plugin.json",
setup: (stateDir: string) => {
const pluginDir = path.join(stateDir, "extensions", "legacy-with-bad-bundle");
mkdirSafe(path.dirname(path.join(pluginDir, ".claude-plugin", "plugin.json")));
fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}", "utf-8");
fs.writeFileSync(path.join(pluginDir, ".claude-plugin", "plugin.json"), "{", "utf-8");
return {};
},
},
{
name: "falls back to legacy index discovery for configured paths with malformed bundle sidecars",
bundleMarker: ".codex-plugin/plugin.json",
setup: (stateDir: string) => {
const pluginDir = path.join(stateDir, "plugins", "legacy-with-bad-bundle");
mkdirSafe(path.dirname(path.join(pluginDir, ".codex-plugin", "plugin.json")));
fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}", "utf-8");
fs.writeFileSync(path.join(pluginDir, ".codex-plugin", "plugin.json"), "{", "utf-8");
return { extraPaths: [pluginDir] };
},
},
] as const)("$name", async ({ setup, bundleMarker }) => {
const stateDir = makeTempDir();
const bundleDir = path.join(stateDir, "extensions", "claude-bundle");
mkdirSafe(path.join(bundleDir, "commands"));
fs.writeFileSync(path.join(bundleDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8");
const result = await discoverWithStateDir(stateDir, setup(stateDir));
const legacy = findCandidateById(result.candidates, "legacy-with-bad-bundle");
const { candidates } = await discoverWithStateDir(stateDir, {});
const bundle = candidates.find((candidate) => candidate.idHint === "claude-bundle");
expect(bundle).toBeDefined();
expect(bundle?.format).toBe("bundle");
expect(bundle?.bundleFormat).toBe("claude");
expect(bundle?.source).toBe(bundleDir);
});
it("auto-detects Cursor bundles as bundle candidates", async () => {
const stateDir = makeTempDir();
const bundleDir = path.join(stateDir, "extensions", "cursor-bundle");
mkdirSafe(path.join(bundleDir, ".cursor-plugin"));
mkdirSafe(path.join(bundleDir, ".cursor", "commands"));
fs.writeFileSync(
path.join(bundleDir, ".cursor-plugin", "plugin.json"),
JSON.stringify({
name: "Cursor Bundle",
}),
"utf-8",
);
const { candidates } = await discoverWithStateDir(stateDir, {});
const bundle = candidates.find((candidate) => candidate.idHint === "cursor-bundle");
expect(bundle).toBeDefined();
expect(bundle?.format).toBe("bundle");
expect(bundle?.bundleFormat).toBe("cursor");
expect(bundle?.source).toBe(bundleDir);
});
it("falls back to legacy index discovery when a scanned bundle sidecar is malformed", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "legacy-with-bad-bundle");
mkdirSafe(path.join(pluginDir, ".claude-plugin"));
fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}", "utf-8");
fs.writeFileSync(path.join(pluginDir, ".claude-plugin", "plugin.json"), "{", "utf-8");
const result = await discoverWithStateDir(stateDir, {});
const legacy = result.candidates.find(
(candidate) => candidate.idHint === "legacy-with-bad-bundle",
);
expect(legacy).toBeDefined();
expect(legacy?.format).toBe("openclaw");
expect(hasDiagnosticSourceSuffix(result.diagnostics, ".claude-plugin/plugin.json")).toBe(true);
});
it("falls back to legacy index discovery for configured paths with malformed bundle sidecars", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "plugins", "legacy-with-bad-bundle");
mkdirSafe(path.join(pluginDir, ".codex-plugin"));
fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}", "utf-8");
fs.writeFileSync(path.join(pluginDir, ".codex-plugin", "plugin.json"), "{", "utf-8");
const result = await discoverWithStateDir(stateDir, {
extraPaths: [pluginDir],
});
const legacy = result.candidates.find(
(candidate) => candidate.idHint === "legacy-with-bad-bundle",
);
expect(legacy).toBeDefined();
expect(legacy?.format).toBe("openclaw");
expect(hasDiagnosticSourceSuffix(result.diagnostics, ".codex-plugin/plugin.json")).toBe(true);
expect(hasDiagnosticSourceSuffix(result.diagnostics, bundleMarker)).toBe(true);
});
it("blocks extension entries that escape package directory", async () => {
@ -635,57 +667,29 @@ describe("discoverOpenClawPlugins", () => {
const pluginPath = path.join(globalExt, "cached.ts");
fs.writeFileSync(pluginPath, "export default function () {}", "utf-8");
const first = discoverOpenClawPlugins({
env: {
...buildDiscoveryEnv(stateDir),
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
},
});
const cachedEnv = buildCachedDiscoveryEnv(stateDir);
const first = discoverWithCachedEnv({ env: cachedEnv });
expect(first.candidates.some((candidate) => candidate.idHint === "cached")).toBe(true);
fs.rmSync(pluginPath, { force: true });
const second = discoverOpenClawPlugins({
env: {
...buildDiscoveryEnv(stateDir),
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
},
});
const second = discoverWithCachedEnv({ env: cachedEnv });
expect(second.candidates.some((candidate) => candidate.idHint === "cached")).toBe(true);
clearPluginDiscoveryCache();
const third = discoverOpenClawPlugins({
env: {
...buildDiscoveryEnv(stateDir),
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
},
});
const third = discoverWithCachedEnv({ env: cachedEnv });
expect(third.candidates.some((candidate) => candidate.idHint === "cached")).toBe(false);
});
it("does not reuse discovery results across env root changes", () => {
const stateDirA = makeTempDir();
const stateDirB = makeTempDir();
const globalExtA = path.join(stateDirA, "extensions");
const globalExtB = path.join(stateDirB, "extensions");
mkdirSafe(globalExtA);
mkdirSafe(globalExtB);
fs.writeFileSync(path.join(globalExtA, "alpha.ts"), "export default function () {}", "utf-8");
fs.writeFileSync(path.join(globalExtB, "beta.ts"), "export default function () {}", "utf-8");
writeStandalonePlugin(path.join(stateDirA, "extensions", "alpha.ts"));
writeStandalonePlugin(path.join(stateDirB, "extensions", "beta.ts"));
const first = discoverOpenClawPlugins({
env: {
...buildDiscoveryEnv(stateDirA),
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
},
});
const second = discoverOpenClawPlugins({
env: {
...buildDiscoveryEnv(stateDirB),
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
},
});
const first = discoverWithCachedEnv({ env: buildCachedDiscoveryEnv(stateDirA) });
const second = discoverWithCachedEnv({ env: buildCachedDiscoveryEnv(stateDirB) });
expect(first.candidates.some((candidate) => candidate.idHint === "alpha")).toBe(true);
expect(first.candidates.some((candidate) => candidate.idHint === "beta")).toBe(false);
@ -699,52 +703,36 @@ describe("discoverOpenClawPlugins", () => {
const homeB = makeTempDir();
const pluginA = path.join(homeA, "plugins", "demo.ts");
const pluginB = path.join(homeB, "plugins", "demo.ts");
mkdirSafe(path.dirname(pluginA));
mkdirSafe(path.dirname(pluginB));
fs.writeFileSync(pluginA, "export default {}", "utf-8");
fs.writeFileSync(pluginB, "export default {}", "utf-8");
writeStandalonePlugin(pluginA, "export default {}");
writeStandalonePlugin(pluginB, "export default {}");
const first = discoverOpenClawPlugins({
const first = discoverWithCachedEnv({
extraPaths: ["~/plugins/demo.ts"],
env: {
...buildDiscoveryEnv(stateDir),
HOME: homeA,
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
},
env: buildCachedDiscoveryEnv(stateDir, { HOME: homeA }),
});
const second = discoverOpenClawPlugins({
const second = discoverWithCachedEnv({
extraPaths: ["~/plugins/demo.ts"],
env: {
...buildDiscoveryEnv(stateDir),
HOME: homeB,
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
},
env: buildCachedDiscoveryEnv(stateDir, { HOME: homeB }),
});
expect(first.candidates.find((candidate) => candidate.idHint === "demo")?.source).toBe(pluginA);
expect(second.candidates.find((candidate) => candidate.idHint === "demo")?.source).toBe(
pluginB,
);
expectCandidateSource(first.candidates, "demo", pluginA);
expectCandidateSource(second.candidates, "demo", pluginB);
});
it("treats configured load-path order as cache-significant", () => {
const stateDir = makeTempDir();
const pluginA = path.join(stateDir, "plugins", "alpha.ts");
const pluginB = path.join(stateDir, "plugins", "beta.ts");
mkdirSafe(path.dirname(pluginA));
fs.writeFileSync(pluginA, "export default {}", "utf-8");
fs.writeFileSync(pluginB, "export default {}", "utf-8");
writeStandalonePlugin(pluginA, "export default {}");
writeStandalonePlugin(pluginB, "export default {}");
const env = {
...buildDiscoveryEnv(stateDir),
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
};
const env = buildCachedDiscoveryEnv(stateDir);
const first = discoverOpenClawPlugins({
const first = discoverWithCachedEnv({
extraPaths: [pluginA, pluginB],
env,
});
const second = discoverOpenClawPlugins({
const second = discoverWithCachedEnv({
extraPaths: [pluginB, pluginA],
env,
});

View File

@ -43,6 +43,26 @@ function expectRouteRegistrationDenied(params: {
expect(registry.httpRoutes).toHaveLength(1);
}
function expectRegisteredRouteShape(
registry: ReturnType<typeof createEmptyPluginRegistry>,
params: {
path: string;
handler?: unknown;
auth: "plugin" | "gateway";
match?: "exact" | "prefix";
},
) {
expect(registry.httpRoutes).toHaveLength(1);
expect(registry.httpRoutes[0]).toEqual(
expect.objectContaining({
path: params.path,
auth: params.auth,
...(params.match ? { match: params.match } : {}),
...(params.handler ? { handler: params.handler } : {}),
}),
);
}
describe("registerPluginHttpRoute", () => {
afterEach(() => {
releasePinnedPluginHttpRouteRegistry();
@ -60,11 +80,12 @@ describe("registerPluginHttpRoute", () => {
registry,
});
expect(registry.httpRoutes).toHaveLength(1);
expect(registry.httpRoutes[0]?.path).toBe("/plugins/demo");
expect(registry.httpRoutes[0]?.handler).toBe(handler);
expect(registry.httpRoutes[0]?.auth).toBe("plugin");
expect(registry.httpRoutes[0]?.match).toBe("exact");
expectRegisteredRouteShape(registry, {
path: "/plugins/demo",
handler,
auth: "plugin",
match: "exact",
});
unregister();
expect(registry.httpRoutes).toHaveLength(0);
@ -129,17 +150,21 @@ describe("registerPluginHttpRoute", () => {
expect(registry.httpRoutes).toHaveLength(0);
});
it("rejects conflicting route registrations without replaceExisting", () => {
expectRouteRegistrationDenied({
it.each([
{
name: "rejects conflicting route registrations without replaceExisting",
replaceExisting: false,
expectedLogFragment: "route conflict",
});
});
it("rejects route replacement when a different plugin owns the route", () => {
expectRouteRegistrationDenied({
},
{
name: "rejects route replacement when a different plugin owns the route",
replaceExisting: true,
expectedLogFragment: "route replacement denied",
},
] as const)("$name", ({ replaceExisting, expectedLogFragment }) => {
expectRouteRegistrationDenied({
replaceExisting,
expectedLogFragment,
});
});
@ -190,8 +215,10 @@ describe("registerPluginHttpRoute", () => {
handler: vi.fn(),
});
expect(startupRegistry.httpRoutes).toHaveLength(1);
expect(startupRegistry.httpRoutes[0]?.path).toBe("/bluebubbles-webhook");
expectRegisteredRouteShape(startupRegistry, {
path: "/bluebubbles-webhook",
auth: "plugin",
});
expect(laterActiveRegistry.httpRoutes).toHaveLength(0);
unregister();

View File

@ -192,6 +192,113 @@ async function expectDedupedInteractiveDispatch(params: {
expect(params.handler).toHaveBeenCalledWith(expect.objectContaining(params.expectedCall));
}
async function dispatchInteractive(params: InteractiveDispatchParams) {
if (params.channel === "telegram") {
return await dispatchPluginInteractiveHandler(params);
}
if (params.channel === "discord") {
return await dispatchPluginInteractiveHandler(params);
}
return await dispatchPluginInteractiveHandler(params);
}
function expectRegisteredInteractiveHandler(params: {
channel: "telegram" | "discord" | "slack";
namespace: string;
handler: ReturnType<typeof vi.fn>;
}) {
expect(
registerPluginInteractiveHandler("codex-plugin", {
channel: params.channel,
namespace: params.namespace,
handler: params.handler as never,
}),
).toEqual({ ok: true });
}
type BindingHelperCase = {
name: string;
registerParams: { channel: "telegram" | "discord" | "slack"; namespace: string };
dispatchParams: InteractiveDispatchParams;
requestResult: {
status: "bound";
binding: {
bindingId: string;
pluginId: string;
pluginName: string;
pluginRoot: string;
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
threadId?: string | number;
boundAt: number;
};
};
requestSummary: string;
expectedConversation: {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
threadId?: string | number;
};
};
async function expectBindingHelperWiring(params: BindingHelperCase) {
const currentBinding = {
...params.requestResult.binding,
boundAt: params.requestResult.binding.boundAt + 1,
};
requestPluginConversationBindingMock.mockResolvedValueOnce(params.requestResult);
getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding);
const handler = vi.fn(async (ctx) => {
await expect(
ctx.requestConversationBinding({ summary: params.requestSummary }),
).resolves.toEqual(params.requestResult);
await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true });
await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding);
return { handled: true };
});
expect(
registerPluginInteractiveHandler(
"codex-plugin",
{
...params.registerParams,
handler: handler as never,
},
{ pluginName: "Codex", pluginRoot: "/plugins/codex" },
),
).toEqual({ ok: true });
await expect(dispatchInteractive(params.dispatchParams)).resolves.toEqual({
matched: true,
handled: true,
duplicate: false,
});
expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
requestedBySenderId: "user-1",
conversation: params.expectedConversation,
binding: {
summary: params.requestSummary,
},
});
expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({
pluginRoot: "/plugins/codex",
conversation: params.expectedConversation,
});
expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({
pluginRoot: "/plugins/codex",
conversation: params.expectedConversation,
});
}
describe("plugin interactive handlers", () => {
beforeEach(() => {
clearPluginInteractiveHandlers();
@ -235,24 +342,14 @@ describe("plugin interactive handlers", () => {
vi.restoreAllMocks();
});
it("routes Telegram callbacks by namespace and dedupes callback ids", async () => {
const handler = vi.fn(async () => ({ handled: true }));
expect(
registerPluginInteractiveHandler("codex-plugin", {
channel: "telegram",
namespace: "codex",
handler,
it.each([
{
name: "routes Telegram callbacks by namespace and dedupes callback ids",
channel: "telegram" as const,
baseParams: createTelegramDispatchParams({
data: "codex:resume:thread-1",
callbackId: "cb-1",
}),
).toEqual({ ok: true });
const baseParams = createTelegramDispatchParams({
data: "codex:resume:thread-1",
callbackId: "cb-1",
});
await expectDedupedInteractiveDispatch({
baseParams,
handler,
expectedCall: {
channel: "telegram",
conversationId: "-10099:topic:77",
@ -263,6 +360,58 @@ describe("plugin interactive handlers", () => {
messageId: 55,
}),
},
},
{
name: "routes Discord interactions by namespace and dedupes interaction ids",
channel: "discord" as const,
baseParams: createDiscordDispatchParams({
data: "codex:approve:thread-1",
interactionId: "ix-1",
interaction: { kind: "button", values: ["allow"] },
}),
expectedCall: {
channel: "discord",
conversationId: "channel-1",
interaction: expect.objectContaining({
namespace: "codex",
payload: "approve:thread-1",
messageId: "message-1",
values: ["allow"],
}),
},
},
{
name: "routes Slack interactions by namespace and dedupes interaction ids",
channel: "slack" as const,
baseParams: createSlackDispatchParams({
data: "codex:approve:thread-1",
interactionId: "slack-ix-1",
interaction: { kind: "button" },
}),
expectedCall: {
channel: "slack",
conversationId: "C123",
threadId: "1710000000.000100",
interaction: expect.objectContaining({
namespace: "codex",
payload: "approve:thread-1",
actionId: "codex",
messageTs: "1710000000.000200",
}),
},
},
] as const)("$name", async ({ channel, baseParams, expectedCall }) => {
const handler = vi.fn(async () => ({ handled: true }));
expectRegisteredInteractiveHandler({
channel,
namespace: "codex",
handler,
});
await expectDedupedInteractiveDispatch({
baseParams,
handler,
expectedCall,
});
});
@ -322,38 +471,6 @@ describe("plugin interactive handlers", () => {
});
});
it("routes Discord interactions by namespace and dedupes interaction ids", async () => {
const handler = vi.fn(async () => ({ handled: true }));
expect(
registerPluginInteractiveHandler("codex-plugin", {
channel: "discord",
namespace: "codex",
handler,
}),
).toEqual({ ok: true });
const baseParams = createDiscordDispatchParams({
data: "codex:approve:thread-1",
interactionId: "ix-1",
interaction: { kind: "button", values: ["allow"] },
});
await expectDedupedInteractiveDispatch({
baseParams,
handler,
expectedCall: {
channel: "discord",
conversationId: "channel-1",
interaction: expect.objectContaining({
namespace: "codex",
payload: "approve:thread-1",
messageId: "message-1",
values: ["allow"],
}),
},
});
});
it("acknowledges matched Discord interactions before awaiting plugin handlers", async () => {
const callOrder: string[] = [];
const handler = vi.fn(async () => {
@ -387,326 +504,107 @@ describe("plugin interactive handlers", () => {
});
});
it("routes Slack interactions by namespace and dedupes interaction ids", async () => {
const handler = vi.fn(async () => ({ handled: true }));
expect(
registerPluginInteractiveHandler("codex-plugin", {
channel: "slack",
namespace: "codex",
handler,
it.each([
{
name: "wires Telegram conversation binding helpers with topic context",
registerParams: { channel: "telegram", namespace: "codex" },
dispatchParams: createTelegramDispatchParams({
data: "codex:bind",
callbackId: "cb-bind",
}),
).toEqual({ ok: true });
const baseParams = createSlackDispatchParams({
data: "codex:approve:thread-1",
interactionId: "slack-ix-1",
interaction: { kind: "button" },
});
await expectDedupedInteractiveDispatch({
baseParams,
handler,
expectedCall: {
channel: "slack",
conversationId: "C123",
threadId: "1710000000.000100",
interaction: expect.objectContaining({
namespace: "codex",
payload: "approve:thread-1",
actionId: "codex",
messageTs: "1710000000.000200",
}),
},
});
});
it("wires Telegram conversation binding helpers with topic context", async () => {
const requestResult = {
status: "bound" as const,
binding: {
bindingId: "binding-telegram",
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
parentConversationId: "-10099",
threadId: 77,
boundAt: 1,
},
};
const currentBinding = {
...requestResult.binding,
boundAt: 2,
};
requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult);
getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding);
const handler = vi.fn(async (ctx) => {
await expect(
ctx.requestConversationBinding({
summary: "Bind this topic",
detachHint: "Use /new to detach",
}),
).resolves.toEqual(requestResult);
await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true });
await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding);
return { handled: true };
});
expect(
registerPluginInteractiveHandler(
"codex-plugin",
{
requestResult: {
status: "bound" as const,
binding: {
bindingId: "binding-telegram",
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
channel: "telegram",
namespace: "codex",
handler,
accountId: "default",
conversationId: "-10099:topic:77",
parentConversationId: "-10099",
threadId: 77,
boundAt: 1,
},
{ pluginName: "Codex", pluginRoot: "/plugins/codex" },
),
).toEqual({ ok: true });
await expect(
dispatchPluginInteractiveHandler(
createTelegramDispatchParams({
data: "codex:bind",
callbackId: "cb-bind",
}),
),
).resolves.toEqual({
matched: true,
handled: true,
duplicate: false,
});
expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
requestedBySenderId: "user-1",
conversation: {
},
requestSummary: "Bind this topic",
expectedConversation: {
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
parentConversationId: "-10099",
threadId: 77,
},
binding: {
summary: "Bind this topic",
detachHint: "Use /new to detach",
},
});
expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({
pluginRoot: "/plugins/codex",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
parentConversationId: "-10099",
threadId: 77,
},
});
expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({
pluginRoot: "/plugins/codex",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
parentConversationId: "-10099",
threadId: 77,
},
});
});
it("wires Discord conversation binding helpers with parent channel context", async () => {
const requestResult = {
status: "bound" as const,
binding: {
bindingId: "binding-discord",
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
channel: "discord",
accountId: "default",
conversationId: "channel-1",
parentConversationId: "parent-1",
boundAt: 1,
},
};
const currentBinding = {
...requestResult.binding,
boundAt: 2,
};
requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult);
getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding);
const handler = vi.fn(async (ctx) => {
await expect(ctx.requestConversationBinding({ summary: "Bind Discord" })).resolves.toEqual(
requestResult,
);
await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true });
await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding);
return { handled: true };
});
expect(
registerPluginInteractiveHandler(
"codex-plugin",
{
},
{
name: "wires Discord conversation binding helpers with parent channel context",
registerParams: { channel: "discord", namespace: "codex" },
dispatchParams: createDiscordDispatchParams({
data: "codex:bind",
interactionId: "ix-bind",
interaction: { kind: "button", values: ["allow"] },
}),
requestResult: {
status: "bound" as const,
binding: {
bindingId: "binding-discord",
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
channel: "discord",
namespace: "codex",
handler,
accountId: "default",
conversationId: "channel-1",
parentConversationId: "parent-1",
boundAt: 1,
},
{ pluginName: "Codex", pluginRoot: "/plugins/codex" },
),
).toEqual({ ok: true });
await expect(
dispatchPluginInteractiveHandler(
createDiscordDispatchParams({
data: "codex:bind",
interactionId: "ix-bind",
interaction: { kind: "button", values: ["allow"] },
}),
),
).resolves.toEqual({
matched: true,
handled: true,
duplicate: false,
});
expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
requestedBySenderId: "user-1",
conversation: {
},
requestSummary: "Bind Discord",
expectedConversation: {
channel: "discord",
accountId: "default",
conversationId: "channel-1",
parentConversationId: "parent-1",
},
binding: {
summary: "Bind Discord",
},
});
expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({
pluginRoot: "/plugins/codex",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel-1",
parentConversationId: "parent-1",
},
});
expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({
pluginRoot: "/plugins/codex",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel-1",
parentConversationId: "parent-1",
},
});
});
it("wires Slack conversation binding helpers with thread context", async () => {
const requestResult = {
status: "bound" as const,
binding: {
bindingId: "binding-slack",
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
channel: "slack",
accountId: "default",
conversationId: "C123",
parentConversationId: "C123",
threadId: "1710000000.000100",
boundAt: 1,
},
};
const currentBinding = {
...requestResult.binding,
boundAt: 2,
};
requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult);
getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding);
const handler = vi.fn(async (ctx) => {
await expect(ctx.requestConversationBinding({ summary: "Bind Slack" })).resolves.toEqual(
requestResult,
);
await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true });
await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding);
return { handled: true };
});
expect(
registerPluginInteractiveHandler(
"codex-plugin",
{
},
{
name: "wires Slack conversation binding helpers with thread context",
registerParams: { channel: "slack", namespace: "codex" },
dispatchParams: createSlackDispatchParams({
data: "codex:bind",
interactionId: "slack-bind",
interaction: {
kind: "button",
value: "bind",
selectedValues: ["bind"],
selectedLabels: ["Bind"],
},
}),
requestResult: {
status: "bound" as const,
binding: {
bindingId: "binding-slack",
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
channel: "slack",
namespace: "codex",
handler,
accountId: "default",
conversationId: "C123",
parentConversationId: "C123",
threadId: "1710000000.000100",
boundAt: 1,
},
{ pluginName: "Codex", pluginRoot: "/plugins/codex" },
),
).toEqual({ ok: true });
await expect(
dispatchPluginInteractiveHandler(
createSlackDispatchParams({
data: "codex:bind",
interactionId: "slack-bind",
interaction: {
kind: "button",
value: "bind",
selectedValues: ["bind"],
selectedLabels: ["Bind"],
},
}),
),
).resolves.toEqual({
matched: true,
handled: true,
duplicate: false,
});
expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
requestedBySenderId: "user-1",
conversation: {
},
requestSummary: "Bind Slack",
expectedConversation: {
channel: "slack",
accountId: "default",
conversationId: "C123",
parentConversationId: "C123",
threadId: "1710000000.000100",
},
binding: {
summary: "Bind Slack",
},
});
expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({
pluginRoot: "/plugins/codex",
conversation: {
channel: "slack",
accountId: "default",
conversationId: "C123",
parentConversationId: "C123",
threadId: "1710000000.000100",
},
});
expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({
pluginRoot: "/plugins/codex",
conversation: {
channel: "slack",
accountId: "default",
conversationId: "C123",
parentConversationId: "C123",
threadId: "1710000000.000100",
},
});
},
] as const)("$name", async (testCase) => {
await expectBindingHelperWiring(testCase);
});
it("does not consume dedupe keys when a handler throws", async () => {

View File

@ -3,20 +3,22 @@ import { createPluginLoaderLogger } from "./logger.js";
describe("plugins/logger", () => {
it("forwards logger methods", () => {
const info = vi.fn();
const warn = vi.fn();
const error = vi.fn();
const debug = vi.fn();
const logger = createPluginLoaderLogger({ info, warn, error, debug });
const methods = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
const logger = createPluginLoaderLogger(methods);
logger.info("i");
logger.warn("w");
logger.error("e");
logger.debug?.("d");
expect(info).toHaveBeenCalledWith("i");
expect(warn).toHaveBeenCalledWith("w");
expect(error).toHaveBeenCalledWith("e");
expect(debug).toHaveBeenCalledWith("d");
for (const [method, value] of [
["info", "i"],
["warn", "w"],
["error", "e"],
["debug", "d"],
] as const) {
logger[method]?.(value);
expect(methods[method]).toHaveBeenCalledWith(value);
}
});
});

View File

@ -24,6 +24,13 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
}
}
async function writeMarketplaceManifest(rootDir: string, manifest: unknown): Promise<string> {
const manifestPath = path.join(rootDir, ".claude-plugin", "marketplace.json");
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
await fs.writeFile(manifestPath, JSON.stringify(manifest));
return manifestPath;
}
function mockRemoteMarketplaceClone(manifest: unknown) {
runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => {
const repoDir = argv.at(-1);
@ -46,22 +53,18 @@ describe("marketplace plugins", () => {
it("lists plugins from a local marketplace root", async () => {
await withTempDir(async (rootDir) => {
await fs.mkdir(path.join(rootDir, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(rootDir, ".claude-plugin", "marketplace.json"),
JSON.stringify({
name: "Example Marketplace",
version: "1.0.0",
plugins: [
{
name: "frontend-design",
version: "0.1.0",
description: "Design system bundle",
source: "./plugins/frontend-design",
},
],
}),
);
await writeMarketplaceManifest(rootDir, {
name: "Example Marketplace",
version: "1.0.0",
plugins: [
{
name: "frontend-design",
version: "0.1.0",
description: "Design system bundle",
source: "./plugins/frontend-design",
},
],
});
const { listMarketplacePlugins } = await import("./marketplace.js");
const result = await listMarketplacePlugins({ marketplace: rootDir });
@ -88,19 +91,15 @@ describe("marketplace plugins", () => {
it("resolves relative plugin paths against the marketplace root", async () => {
await withTempDir(async (rootDir) => {
const pluginDir = path.join(rootDir, "plugins", "frontend-design");
await fs.mkdir(path.join(rootDir, ".claude-plugin"), { recursive: true });
await fs.mkdir(pluginDir, { recursive: true });
await fs.writeFile(
path.join(rootDir, ".claude-plugin", "marketplace.json"),
JSON.stringify({
plugins: [
{
name: "frontend-design",
source: "./plugins/frontend-design",
},
],
}),
);
const manifestPath = await writeMarketplaceManifest(rootDir, {
plugins: [
{
name: "frontend-design",
source: "./plugins/frontend-design",
},
],
});
installPluginFromPathMock.mockResolvedValue({
ok: true,
pluginId: "frontend-design",
@ -111,7 +110,7 @@ describe("marketplace plugins", () => {
const { installPluginFromMarketplace } = await import("./marketplace.js");
const result = await installPluginFromMarketplace({
marketplace: path.join(rootDir, ".claude-plugin", "marketplace.json"),
marketplace: manifestPath,
plugin: "frontend-design",
});
@ -221,22 +220,18 @@ describe("marketplace plugins", () => {
"fetch",
vi.fn(async () => new Response(null, { status: 200 })),
);
await fs.mkdir(path.join(rootDir, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(rootDir, ".claude-plugin", "marketplace.json"),
JSON.stringify({
plugins: [
{
name: "frontend-design",
source: "https://example.com/frontend-design.tgz",
},
],
}),
);
const manifestPath = await writeMarketplaceManifest(rootDir, {
plugins: [
{
name: "frontend-design",
source: "https://example.com/frontend-design.tgz",
},
],
});
const { installPluginFromMarketplace } = await import("./marketplace.js");
const result = await installPluginFromMarketplace({
marketplace: path.join(rootDir, ".claude-plugin", "marketplace.json"),
marketplace: manifestPath,
plugin: "frontend-design",
});
@ -248,77 +243,67 @@ describe("marketplace plugins", () => {
});
});
it("rejects remote marketplace git plugin sources before cloning nested remotes", async () => {
mockRemoteMarketplaceClone({
plugins: [
{
name: "frontend-design",
source: {
type: "git",
url: "https://evil.example/repo.git",
it.each([
{
name: "rejects remote marketplace git plugin sources before cloning nested remotes",
manifest: {
plugins: [
{
name: "frontend-design",
source: {
type: "git",
url: "https://evil.example/repo.git",
},
},
},
],
});
const { listMarketplacePlugins } = await import("./marketplace.js");
const result = await listMarketplacePlugins({ marketplace: "owner/repo" });
expect(result).toEqual({
ok: false,
error:
],
},
expectedError:
'invalid marketplace entry "frontend-design" in owner/repo: ' +
"remote marketplaces may not use git plugin sources",
});
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
});
it("rejects remote marketplace absolute plugin paths", async () => {
mockRemoteMarketplaceClone({
plugins: [
{
name: "frontend-design",
source: {
type: "path",
path: "/tmp/frontend-design",
},
{
name: "rejects remote marketplace absolute plugin paths",
manifest: {
plugins: [
{
name: "frontend-design",
source: {
type: "path",
path: "/tmp/frontend-design",
},
},
},
],
});
const { listMarketplacePlugins } = await import("./marketplace.js");
const result = await listMarketplacePlugins({ marketplace: "owner/repo" });
expect(result).toEqual({
ok: false,
error:
],
},
expectedError:
'invalid marketplace entry "frontend-design" in owner/repo: ' +
"remote marketplaces may only use relative plugin paths",
});
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
});
it("rejects remote marketplace HTTP plugin paths", async () => {
mockRemoteMarketplaceClone({
plugins: [
{
name: "frontend-design",
source: {
type: "path",
path: "https://evil.example/plugin.tgz",
},
{
name: "rejects remote marketplace HTTP plugin paths",
manifest: {
plugins: [
{
name: "frontend-design",
source: {
type: "path",
path: "https://evil.example/plugin.tgz",
},
},
},
],
});
],
},
expectedError:
'invalid marketplace entry "frontend-design" in owner/repo: ' +
"remote marketplaces may not use HTTP(S) plugin paths",
},
] as const)("$name", async ({ manifest, expectedError }) => {
mockRemoteMarketplaceClone(manifest);
const { listMarketplacePlugins } = await import("./marketplace.js");
const result = await listMarketplacePlugins({ marketplace: "owner/repo" });
expect(result).toEqual({
ok: false,
error:
'invalid marketplace entry "frontend-design" in owner/repo: ' +
"remote marketplaces may not use HTTP(S) plugin paths",
error: expectedError,
});
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
});

View File

@ -13,6 +13,28 @@ import {
restoreMemoryPluginState,
} from "./memory-state.js";
function createMemoryRuntime() {
return {
async getMemorySearchManager() {
return { manager: null, error: "missing" };
},
resolveMemoryBackendConfig() {
return { backend: "builtin" as const };
},
};
}
function createMemoryFlushPlan(relativePath: string) {
return {
softThresholdTokens: 1,
forceFlushTranscriptBytes: 2,
reserveTokensFloor: 3,
prompt: relativePath,
systemPrompt: relativePath,
relativePath,
};
}
describe("memory plugin state", () => {
afterEach(() => {
clearMemoryPluginState();
@ -66,14 +88,7 @@ describe("memory plugin state", () => {
});
it("stores the registered memory runtime", async () => {
const runtime = {
async getMemorySearchManager() {
return { manager: null, error: "missing" };
},
resolveMemoryBackendConfig() {
return { backend: "builtin" as const };
},
};
const runtime = createMemoryRuntime();
registerMemoryRuntime(runtime);
@ -88,22 +103,8 @@ describe("memory plugin state", () => {
it("restoreMemoryPluginState swaps both prompt and flush state", () => {
registerMemoryPromptSection(() => ["first"]);
registerMemoryFlushPlanResolver(() => ({
softThresholdTokens: 1,
forceFlushTranscriptBytes: 2,
reserveTokensFloor: 3,
prompt: "first",
systemPrompt: "first",
relativePath: "memory/first.md",
}));
const runtime = {
async getMemorySearchManager() {
return { manager: null, error: "missing" };
},
resolveMemoryBackendConfig() {
return { backend: "builtin" as const };
},
};
registerMemoryFlushPlanResolver(() => createMemoryFlushPlan("memory/first.md"));
const runtime = createMemoryRuntime();
registerMemoryRuntime(runtime);
const snapshot = {
promptBuilder: getMemoryPromptSectionBuilder(),
@ -124,22 +125,8 @@ describe("memory plugin state", () => {
it("clearMemoryPluginState resets both registries", () => {
registerMemoryPromptSection(() => ["stale section"]);
registerMemoryFlushPlanResolver(() => ({
softThresholdTokens: 1,
forceFlushTranscriptBytes: 2,
reserveTokensFloor: 3,
prompt: "prompt",
systemPrompt: "system",
relativePath: "memory/stale.md",
}));
registerMemoryRuntime({
async getMemorySearchManager() {
return { manager: null };
},
resolveMemoryBackendConfig() {
return { backend: "builtin" as const };
},
});
registerMemoryFlushPlanResolver(() => createMemoryFlushPlan("memory/stale.md"));
registerMemoryRuntime(createMemoryRuntime());
clearMemoryPluginState();