Files
AgentHub/server/__tests__/skill-plugins.test.js
T
nntrivi2001 697384145a test(cc-config): cover the env runClaudePlugin actually spawns with
`autoInstallSkillPlugins` takes `run` as a seam, so every existing test
stops short of the real `spawnSync` — the line that decides whether
CLAUDE_CONFIG_DIR is pinned was untested. Restoring the unconditional
pin here (the bug that sent `.claude.json` to `~/.claude/.claude.json`)
kept the whole suite green.

Export `runClaudePlugin` and drive it against a fake `claude` on PATH
that prints the variable back: one test for CLAUDE_HOME set, one for
unset. Mutation-checked — the unconditional pin fails the second.
2026-09-09 10:16:58 +07:00

290 lines
10 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(), "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-<unset>}"';
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=<unset>");
} 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;
}
});
});