mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 03:11:10 +00:00
test: dedupe plugin bundle and discovery suites
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user