Files
AgentHub/server/__tests__/skill-plugins.test.js
T
nntrivi2001 6cb7f4f843 feat(plugin): install the pipelines' skill plugins on SessionStart
The ship-feature pipelines drive `/superpowers:*` commands as their per-node
drivers, so a fresh install had a dependency it was only told about — the
`/ccam-doctor` WARN row and a line in the README.

`server/lib/skill-plugins.js` now carries the whole thing, shared by the
doctor (reports) and the bootstrap (installs) so the two cannot disagree
about what is missing. The bootstrap installs at the very end of its DETACHED
worker — the install clones from the network and nothing in the 10 s
SessionStart hook may wait on it — retrying once behind a `marketplace add`,
logging a failure with the manual command, and never throwing: the dashboard,
hooks, CLI and MCP all work without these plugins. The outcome is recorded as
`state.skillPluginsSettled`, which `fastPathOk()` now also requires, so the
check runs exactly once per install and `/ccam-update` is the retry.
`CCAM_AUTO_INSTALL_PLUGINS=0|false|off` opts out.

Reporting is derived, installing is allowlisted. Requirements come from the
loaded templates, which is right for a report; installing from that same
source would not be, because a template is a file anyone can drop into
`DASHBOARD_PIPELINES_DIR` — it would turn "edit a JSON file" into "run this
vendor's install command on the next session". Auto-install consults
`AUTO_INSTALLABLE`, a constant in code (`superpowers` today), and reports
every other requirement as skipped.

Tests inject a `run` seam so no case reaches a real `claude plugin install`,
and cover the refusal explicitly.
2026-09-04 14:03:12 +07:00

241 lines
8.5 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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(), "ccam-skill-plugins-home-"));
const TMP_PIPELINES = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-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 ccam's own commands and anything that is not a /plugin:skill", () => {
useTemplate({
id: "tpl-shell",
skill: "/ccam:ship-feature",
nodes: [
{ id: "a", command: "npm test" },
{ id: "b", command: "/reload-plugins" },
{ id: "c", command: "" },
],
});
const required = skillPlugins.requiredSkillPlugins();
assert.equal(required.has("ccam"), false, "ccam 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, []);
});
});