/** * @file Tests server/lib/skill-plugins.js — the dependency the ship-feature * pipelines have on other plugins (`superpowers` today): deriving it from the * templates, deciding what is actually missing, and the unattended install the * SessionStart bootstrap performs. * * The security-relevant case is covered first: a pipeline template is a file * anyone may drop into DASHBOARD_PIPELINES_DIR, so a plugin name appearing * there must be REPORTED but never INSTALLED. Every case injects a fake `run` * — no test may reach a real `claude plugin install`. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, after, beforeEach } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-skill-plugins-home-")); const TMP_PIPELINES = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-skill-plugins-tpl-")); process.env.CLAUDE_HOME = TMP_HOME; const skillPlugins = require("../lib/skill-plugins"); const pipelines = require("../lib/pipelines"); after(() => { fs.rmSync(TMP_HOME, { recursive: true, force: true }); fs.rmSync(TMP_PIPELINES, { recursive: true, force: true }); delete process.env.CLAUDE_HOME; delete process.env.DASHBOARD_PIPELINES_DIR; pipelines.reload(); }); /** An empty Claude home: no plugins installed, no user skills. */ function emptyHome() { fs.rmSync(TMP_HOME, { recursive: true, force: true }); fs.mkdirSync(path.join(TMP_HOME, "plugins"), { recursive: true }); fs.mkdirSync(path.join(TMP_HOME, "skills"), { recursive: true }); fs.writeFileSync( path.join(TMP_HOME, "plugins", "installed_plugins.json"), JSON.stringify({ plugins: {} }) ); fs.writeFileSync(path.join(TMP_HOME, "settings.json"), JSON.stringify({})); } /** Point the loader at a throwaway template naming `command` on one node. */ function useTemplate(doc) { fs.writeFileSync(path.join(TMP_PIPELINES, "test.json"), JSON.stringify(doc)); process.env.DASHBOARD_PIPELINES_DIR = TMP_PIPELINES; pipelines.reload(); } function forgetTemplate() { fs.rmSync(path.join(TMP_PIPELINES, "test.json"), { force: true }); delete process.env.DASHBOARD_PIPELINES_DIR; pipelines.reload(); } /** Records the argv of every call and answers with a fixed result. */ function fakeRun(results) { const calls = []; const queue = [...results]; const run = (args) => { calls.push(args); return queue.length > 1 ? queue.shift() : queue[0]; }; run.calls = calls; return run; } const collect = () => { const lines = []; const log = (line) => lines.push(line); log.lines = lines; return log; }; beforeEach(() => { emptyHome(); forgetTemplate(); }); describe("requiredSkillPlugins", () => { it("collects the plugin behind every /plugin:skill a template names, node or pipeline level", () => { const required = skillPlugins.requiredSkillPlugins(); assert.ok(required.has("superpowers"), "the built-in templates name superpowers"); assert.ok(required.get("superpowers").has("writing-plans")); }); it("ignores agenthub's own commands and anything that is not a /plugin:skill", () => { useTemplate({ id: "tpl-shell", skill: "/agenthub:ship-feature", nodes: [ { id: "a", command: "npm test" }, { id: "b", command: "/reload-plugins" }, { id: "c", command: "" }, ], }); const required = skillPlugins.requiredSkillPlugins(); assert.equal(required.has("agenthub"), false, "agenthub is the plugin asking"); // Only the built-ins' superpowers requirement survives. assert.deepEqual([...required.keys()], ["superpowers"]); }); }); describe("missingSkillPlugins", () => { it("reports a required plugin that is absent, with the skills that need it", () => { const missing = skillPlugins.missingSkillPlugins(); const sp = missing.find((m) => m.name === "superpowers"); assert.ok(sp, "superpowers must be reported missing in an empty home"); assert.ok(sp.skills.includes("writing-plans")); assert.equal(sp.autoInstall.spec, "superpowers@claude-plugins-official"); }); it("counts the skills as satisfied when they sit in ~/.claude/skills instead", () => { for (const name of skillPlugins.requiredSkillPlugins().get("superpowers")) { fs.mkdirSync(path.join(TMP_HOME, "skills", name), { recursive: true }); fs.writeFileSync( path.join(TMP_HOME, "skills", name, "SKILL.md"), `---\nname: ${name}\n---\n\nbody\n` ); } assert.deepEqual(skillPlugins.missingSkillPlugins(), []); }); it("counts an installed but explicitly disabled plugin as missing", () => { const dir = path.join(TMP_HOME, "plugins", "sp"); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( path.join(TMP_HOME, "plugins", "installed_plugins.json"), JSON.stringify({ plugins: { "superpowers@x": [{ installPath: dir, scope: "user" }] } }) ); fs.writeFileSync( path.join(TMP_HOME, "settings.json"), JSON.stringify({ enabledPlugins: { "superpowers@x": false } }) ); assert.ok(skillPlugins.missingSkillPlugins().some((m) => m.name === "superpowers")); }); }); describe("autoInstallSkillPlugins", () => { it("installs an allowlisted plugin with an argv array, never a shell string", () => { const run = fakeRun([{ ok: true, output: "" }]); const log = collect(); const summary = skillPlugins.autoInstallSkillPlugins(log, { run }); assert.deepEqual(summary.installed, ["superpowers"]); assert.deepEqual(run.calls, [ ["plugin", "install", "superpowers@claude-plugins-official", "--scope", "user", "-y"], ]); assert.ok( log.lines.some((l) => l.includes("/reload-plugins")), "the user must be told the current session will not see it yet" ); }); it("registers the marketplace and retries exactly once when the install fails", () => { const run = fakeRun([ { ok: false, output: "unknown marketplace" }, { ok: true, output: "" }, { ok: true, output: "" }, ]); const summary = skillPlugins.autoInstallSkillPlugins(collect(), { run }); assert.deepEqual(summary.installed, ["superpowers"]); assert.equal(run.calls.length, 3); assert.deepEqual(run.calls[1], [ "plugin", "marketplace", "add", "anthropics/claude-plugins-official", "--scope", "user", ]); }); it("reports a failure with the manual command, and never throws", () => { const run = fakeRun([{ ok: false, output: "the `claude` CLI is not on PATH" }]); const log = collect(); const summary = skillPlugins.autoInstallSkillPlugins(log, { run }); assert.deepEqual(summary.failed, ["superpowers"]); assert.ok( log.lines.some((l) => l.includes("claude plugin install superpowers@claude-plugins-official") ), "a failed auto-install must leave the user a command to run" ); }); // The one that matters. A template is a file on disk; if the plugin name in // it drove the install, dropping a JSON file into DASHBOARD_PIPELINES_DIR // would run an arbitrary vendor's install command on the next session. it("REFUSES to install a plugin a template names but the allowlist does not", () => { useTemplate({ id: "tpl-evil", name: "Untrusted", nodes: [{ id: "a", command: "/attacker-plugin:payload" }], }); const run = fakeRun([{ ok: true, output: "" }]); const summary = skillPlugins.autoInstallSkillPlugins(collect(), { run }); assert.ok(summary.skipped.includes("attacker-plugin"), "it must be reported as skipped"); assert.equal( run.calls.some((args) => args.join(" ").includes("attacker-plugin")), false, "no claude invocation may ever mention a plugin outside AUTO_INSTALLABLE" ); // It is still visible to the reader, just never installed. assert.ok(skillPlugins.missingSkillPlugins().some((m) => m.name === "attacker-plugin")); }); it("does nothing at all when every requirement already resolves", () => { for (const name of skillPlugins.requiredSkillPlugins().get("superpowers")) { fs.mkdirSync(path.join(TMP_HOME, "skills", name), { recursive: true }); fs.writeFileSync( path.join(TMP_HOME, "skills", name, "SKILL.md"), `---\nname: ${name}\n---\n\nbody\n` ); } const run = fakeRun([{ ok: true, output: "" }]); const summary = skillPlugins.autoInstallSkillPlugins(collect(), { run }); assert.deepEqual(summary.attempted, []); assert.deepEqual(run.calls, []); }); }); describe("runClaudePlugin: the env it spawns with", () => { // Every other test in this file injects `run`, so nothing exercises the real // spawn. That gap is how a wrong CLAUDE_CONFIG_DIR pin lived here: the CLI // writes `.claude.json` INSIDE that dir, while cc-discovery reads it beside // ~/.claude by default, so pinning it unconditionally aimed writes at a file // nothing reads. cc-plugin-cli.js's runClaude has the mirror of this test. const PRINT_ENV = 'printf "%s\\n" "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR-}"'; function stubClaude(body) { const bin = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-skillplug-bin-")); const file = path.join(bin, "claude"); fs.writeFileSync(file, `#!/usr/bin/env bash\n${body}\n`); fs.chmodSync(file, 0o755); const originalPath = process.env.PATH; process.env.PATH = `${bin}${path.delimiter}${originalPath}`; return () => { process.env.PATH = originalPath; fs.rmSync(bin, { recursive: true, force: true }); }; } it("leaves CLAUDE_CONFIG_DIR unset when CLAUDE_HOME is not set", () => { const original = process.env.CLAUDE_HOME; delete process.env.CLAUDE_HOME; const restore = stubClaude(PRINT_ENV); try { const res = skillPlugins.runClaudePlugin(["plugin", "list"], 10_000); assert.equal(res.output.trim(), "CLAUDE_CONFIG_DIR="); } finally { restore(); if (original !== undefined) process.env.CLAUDE_HOME = original; } }); it("pins CLAUDE_CONFIG_DIR to CLAUDE_HOME when the operator set it", () => { const original = process.env.CLAUDE_HOME; process.env.CLAUDE_HOME = "/tmp/some-other-claude-home"; const restore = stubClaude(PRINT_ENV); try { const res = skillPlugins.runClaudePlugin(["plugin", "list"], 10_000); assert.equal(res.output.trim(), "CLAUDE_CONFIG_DIR=/tmp/some-other-claude-home"); } finally { restore(); if (original === undefined) delete process.env.CLAUDE_HOME; else process.env.CLAUDE_HOME = original; } }); });