Files
AgentHub/server/__tests__/plugin-bootstrap.test.js
T
nntrivi2001 29a5da3e4e fix(bootstrap): stop a retired plugin build from downgrading the dashboard
`claude plugin update` unpacks the new version into a new cache directory and
says "Restart to apply changes". A Claude Code process that was already running
does not: it keeps loading the OLD directory, hooks and all.

Every session such a process opened then ran the OLD plugin-bootstrap, which saw
a state.json naming a different root, concluded the server had to be replaced,
repointed agenthub.service at itself and restarted the dashboard onto the
version the user had just upgraded away from. The dashboard reported the old
version and offered the update again — once per session, forever, while
installed_plugins.json and the cache both said the new version was installed.
A /agenthub-update from that session "succeeded" and changed nothing.

- `supersededBy()` reads Claude Code's installed_plugins.json. When a SIBLING
  cache directory is the recorded install, the bootstrap logs one line and
  returns "superseded" — no deps, no unit, no server. `--force` does not
  override it: the self-update chain forces from the NEW directory, which is
  never superseded, so the only --force refused is a deliberate downgrade.
  It cannot hand over either — the process it would replace is the one loading
  it — so the log line says to restart Claude Code.
- Matched by sibling directory, not by the <plugin>@<marketplace> key: correct
  without parsing a cache path or trusting a name. A phantom entry (installPath
  that no longer exists) is ignored rather than wedging the bootstrap shut.

Also adds `pruneRetiredPluginRoots()`, since the same updates leave a full copy
of the repo — node_modules and all — per release; twelve of them measured 1.7 GB
on one machine. It runs after writeState, so a bad prune cannot cost the record
of a good bootstrap, and it protects four independent readings of "in use":
this build, every installPath Claude Code records, the directory
agenthub.service names (`unitRoot`, foreign unit included), and any directory
still named in the user's settings.json hooks — that last one is the un-restarted
process above, seen from the other side. Plus the 2 newest survivors as a
rollback cushion, and nothing at all outside ~/.claude/plugins/cache/.
2026-09-15 10:30:00 +07:00

615 lines
22 KiB
JavaScript

/**
* @file Tests scripts/plugin-bootstrap.js — the SessionStart bootstrap that
* makes a plugin install self-sufficient. Covers the fast path, the Node
* version gate, stale-lock reclaim, legacy hook removal (the duplicate-hook
* double-counting bug) and the CLI launcher's refusal to clobber a foreign
* `agenthub`. Every path is exercised against injected temp directories — nothing
* here touches the real $HOME.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-bootstrap-"));
process.env.CLAUDE_HOME = TMP_HOME;
delete process.env.DASHBOARD_DATA_DIR;
const boot = require("../../scripts/plugin-bootstrap");
const RT = path.join(TMP_HOME, "agenthub", "runtime");
function resetRuntime() {
fs.rmSync(RT, { recursive: true, force: true });
fs.mkdirSync(RT, { recursive: true });
}
after(() => {
fs.rmSync(TMP_HOME, { recursive: true, force: true });
});
describe("serverIsLive", () => {
const infoPath = path.join(TMP_HOME, ".agenthub.json");
beforeEach(() => fs.rmSync(infoPath, { force: true }));
after(() => fs.rmSync(infoPath, { force: true }));
it("is false with no discovery file at all", () => {
// Regression: server-info's resolveAllDashboardPorts() falls back to
// [DEFAULT_PORT] when nothing is live (a reasonable guess for the CLI),
// which made the bootstrap wrongly believe a server was already running
// and skip starting its own.
assert.equal(boot.serverIsLive(), false);
});
it("is false when every recorded pid is dead", () => {
fs.writeFileSync(
infoPath,
JSON.stringify({ servers: [{ port: 4820, pid: 999999, startedAt: "x" }] })
);
assert.equal(boot.serverIsLive(), false);
});
it("is true when a recorded pid is actually alive", () => {
fs.writeFileSync(
infoPath,
JSON.stringify({ servers: [{ port: 4820, pid: process.pid, startedAt: "x" }] })
);
assert.equal(boot.serverIsLive(), true);
assert.deepEqual(boot.liveServers(), [{ port: 4820, pid: process.pid, startedAt: "x" }]);
});
});
describe("runtime location", () => {
it("lives under the shared data dir, not the plugin cache", () => {
assert.equal(boot.runtimeDir(), RT);
});
});
describe("node version gate", () => {
it("refuses anything below 22.5 (node:sqlite is the only driver available)", () => {
assert.equal(boot.nodeVersionOk("20.11.0"), false);
assert.equal(boot.nodeVersionOk("22.4.1"), false);
});
it("accepts 22.5 and newer", () => {
assert.equal(boot.nodeVersionOk("22.5.0"), true);
assert.equal(boot.nodeVersionOk("24.0.0"), true);
});
});
describe("fast path", () => {
beforeEach(resetRuntime);
it("is false with no recorded state", () => {
assert.equal(boot.fastPathOk(RT), false);
});
it("is false when the recorded plugin version is stale", () => {
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
boot.writeState({ pluginVersion: "0.0.0-old", depsHash: boot.depsHash() }, RT);
assert.equal(boot.fastPathOk(RT), false);
});
it("is false after a plugin update moved the install to a new cache dir", () => {
const pkg = require("../../package.json");
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
boot.writeState(
{
pluginVersion: pkg.version,
depsHash: boot.depsHash(),
pluginRoot: "/old/plugin/cache/dir",
},
RT
);
assert.equal(boot.fastPathOk(RT), false);
});
it("is false when node_modules is missing even if state matches", () => {
const pkg = require("../../package.json");
boot.writeState({ pluginVersion: pkg.version, depsHash: boot.depsHash() }, RT);
assert.equal(boot.fastPathOk(RT), false);
});
it("is false while the skill-plugin check has never settled", () => {
// A machine can be fully installed and still lack the plugins the
// pipelines drive; the worker has to run once to find that out.
const pkg = require("../../package.json");
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
boot.writeState(
{
pluginVersion: pkg.version,
depsHash: boot.depsHash(),
pluginRoot: path.resolve(__dirname, "..", ".."),
},
RT
);
assert.equal(boot.fastPathOk(RT), false);
});
it("is false when the systemd unit still names a retired plugin root", () => {
// The one failure a live server hides completely: everything is healthy
// until the next reboot, and then the dashboard never comes back because
// its unit points into a cache directory Claude Code has deleted. The fast
// path has to reject it, or no SessionStart ever repairs it.
//
// Everything else here is deliberately HEALTHY — including a live server,
// without which `fastPathOk` is false anyway and this test would pass
// against the unfixed code.
const ROOT = path.resolve(__dirname, "..", "..");
const pkg = require("../../package.json");
const infoPath = path.join(TMP_HOME, ".agenthub.json");
const prevHome = process.env.HOME;
// HOME first: `unitPath()` resolves against it, and computing the path
// before this line points every write in this test at the REAL
// ~/.config/systemd/user/agenthub.service — which the cleanup below then
// deletes. (Asked how I know.)
process.env.HOME = TMP_HOME;
const unit = require("../../scripts/systemd-service").unitPath("agenthub");
assert.ok(
unit.startsWith(TMP_HOME + path.sep),
`refusing to touch a unit outside the temp home: ${unit}`
);
const writeUnit = (workingDir) => {
fs.mkdirSync(path.dirname(unit), { recursive: true });
fs.writeFileSync(
unit,
"[Unit]\n# agenthub-managed-unit — regenerated on install, do not edit by hand\n" +
`[Service]\nWorkingDirectory=${workingDir}\n`,
"utf8"
);
};
try {
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
fs.writeFileSync(
infoPath,
JSON.stringify({ servers: [{ port: 4820, pid: process.pid, startedAt: "x" }] })
);
boot.writeState(
{
pluginVersion: pkg.version,
depsHash: boot.depsHash(),
pluginRoot: ROOT,
skillPluginsSettled: "nothing-missing",
},
RT
);
// Control: a unit naming THIS root is the steady state — fast path taken.
writeUnit(ROOT);
assert.equal(boot.fastPathOk(RT), true, "a current unit must not block the fast path");
// The regression: same healthy install, unit left on a retired root.
writeUnit("/plugins/cache/agenthub/deadbeef");
assert.equal(boot.fastPathOk(RT), false);
} finally {
fs.rmSync(infoPath, { force: true });
fs.rmSync(unit, { force: true });
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
}
});
});
describe("skill-plugin auto-install gate", () => {
// Both cases here must return WITHOUT reaching server/lib/skill-plugins.js —
// that module shells out to `claude plugin install`, and no test may.
beforeEach(resetRuntime);
after(() => delete process.env.AGENTHUB_AUTO_INSTALL_PLUGINS);
for (const flag of ["0", "false", "off"]) {
it(`is off when AGENTHUB_AUTO_INSTALL_PLUGINS=${flag}`, () => {
process.env.AGENTHUB_AUTO_INSTALL_PLUGINS = flag;
assert.equal(boot.autoInstallSkillPlugins(RT), "off");
});
}
it("runs once: a settled state is returned verbatim, not re-attempted", () => {
delete process.env.AGENTHUB_AUTO_INSTALL_PLUGINS;
boot.writeState({ skillPluginsSettled: "installed" }, RT);
// Verbatim matters: writeState records whatever comes back, so a marker
// here would erase the only record of what actually happened.
assert.equal(boot.autoInstallSkillPlugins(RT), "installed");
});
it("is off under the flag even when forced, so /agenthub-update honours the opt-out", () => {
process.env.AGENTHUB_AUTO_INSTALL_PLUGINS = "0";
boot.writeState({ skillPluginsSettled: "failed" }, RT);
assert.equal(boot.autoInstallSkillPlugins(RT, { force: true }), "off");
});
});
describe("bootstrap lock", () => {
beforeEach(resetRuntime);
it("is exclusive while held", () => {
assert.equal(boot.acquireLock(RT), true);
assert.equal(boot.acquireLock(RT), false, "a live lock must not be reclaimed");
boot.releaseLock(RT);
assert.equal(fs.existsSync(boot.lockPath(RT)), false);
});
it("reclaims a lock whose owner is dead", () => {
fs.mkdirSync(boot.lockPath(RT), { recursive: true });
// PID 1 is alive but not ours; use an unused high pid instead.
fs.writeFileSync(path.join(boot.lockPath(RT), "pid"), "999999", "utf8");
assert.equal(boot.lockIsStale(boot.lockPath(RT)), true);
assert.equal(boot.acquireLock(RT), true);
boot.releaseLock(RT);
});
it("reclaims a lock older than the stale timeout even if its pid is alive", () => {
fs.mkdirSync(boot.lockPath(RT), { recursive: true });
fs.writeFileSync(path.join(boot.lockPath(RT), "pid"), String(process.pid), "utf8");
const old = Date.now() - boot.LOCK_STALE_MS - 1000;
fs.utimesSync(boot.lockPath(RT), old / 1000, old / 1000);
assert.equal(boot.lockIsStale(boot.lockPath(RT)), true);
assert.equal(boot.acquireLock(RT), true);
boot.releaseLock(RT);
});
it("treats a lock directory with no pid file as debris", () => {
fs.mkdirSync(boot.lockPath(RT), { recursive: true });
assert.equal(boot.lockIsStale(boot.lockPath(RT)), true);
});
});
describe("legacy hook cleanup", () => {
const settings = path.join(TMP_HOME, "settings-legacy.json");
beforeEach(() => {
fs.rmSync(settings, { force: true });
fs.rmSync(`${settings}.agenthub-bak`, { force: true });
});
it("removes checkout-installed hook entries and backs the file up first", () => {
fs.writeFileSync(
settings,
JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: "*",
hooks: [
{ type: "command", command: "node /repo/scripts/hook-handler.js PreToolUse" },
],
},
{ matcher: "*", hooks: [{ type: "command", command: "node /other/tool.js" }] },
],
SessionStart: [
{
hooks: [
{ type: "command", command: "node /repo/scripts/hook-handler.js SessionStart" },
],
},
],
},
})
);
const removed = boot.stripLegacyHooks(settings);
assert.equal(removed, 2);
const after = JSON.parse(fs.readFileSync(settings, "utf8"));
assert.equal(after.hooks.PreToolUse.length, 1, "unrelated hooks must survive");
assert.match(JSON.stringify(after.hooks.PreToolUse), /other\/tool\.js/);
assert.equal(after.hooks.SessionStart, undefined, "an emptied list is dropped");
assert.ok(fs.existsSync(`${settings}.agenthub-bak`), "must back up before writing");
});
it("never removes the plugin's own cache-resolved hook entries", () => {
// Regression: `claude plugin install` materializes the plugin's inline
// hooks into settings.json itself, with ${CLAUDE_PLUGIN_ROOT} resolved to
// the cache path — confirmed against a real install. Those entries also
// contain "hook-handler.js", so a naive substring check would strip them
// right along with genuine checkout leftovers, killing the plugin's own
// hooks on every session start.
const cachePath = path.join(
TMP_HOME,
"plugins",
"cache",
"agenthub",
"agenthub",
"abc123",
"scripts",
"hook-handler.js"
);
fs.writeFileSync(
settings,
JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: "*",
hooks: [{ type: "command", command: `node "${cachePath}" PreToolUse` }],
},
{
matcher: "*",
hooks: [
{ type: "command", command: "node /repo/scripts/hook-handler.js PreToolUse" },
],
},
],
},
})
);
const removed = boot.stripLegacyHooks(settings);
assert.equal(removed, 1);
const after = JSON.parse(fs.readFileSync(settings, "utf8"));
assert.equal(after.hooks.PreToolUse.length, 1);
assert.match(JSON.stringify(after.hooks.PreToolUse), /plugins.*cache/);
});
it("leaves a file with no AgentHub hooks untouched and writes no backup", () => {
fs.writeFileSync(settings, JSON.stringify({ hooks: { Stop: [{ hooks: [] }] } }));
assert.equal(boot.stripLegacyHooks(settings), 0);
assert.equal(fs.existsSync(`${settings}.agenthub-bak`), false);
});
it("is a no-op when no settings file exists", () => {
assert.equal(boot.stripLegacyHooks(path.join(TMP_HOME, "nope.json")), 0);
});
});
describe("agenthub CLI launcher", () => {
const bin = path.join(TMP_HOME, "bin");
const target = path.join(bin, process.platform === "win32" ? "agenthub.cmd" : "agenthub");
beforeEach(() => {
fs.rmSync(bin, { recursive: true, force: true });
});
it("writes a launcher that points at the plugin's bin/agenthub.js", () => {
assert.equal(boot.linkCli(bin), "written");
assert.match(fs.readFileSync(target, "utf8"), /bin[/\\]agenthub\.js/);
});
it("is idempotent", () => {
boot.linkCli(bin);
assert.equal(boot.linkCli(bin), "exists");
});
it("never clobbers a agenthub it did not write (a linked checkout keeps winning)", () => {
fs.mkdirSync(bin, { recursive: true });
fs.writeFileSync(target, '#!/bin/sh\nexec node /my/checkout/bin/agenthub.js "$@"\n');
assert.equal(boot.linkCli(bin), "foreign");
assert.match(fs.readFileSync(target, "utf8"), /my\/checkout/);
});
});
describe("plugin-open / plugin-bootstrap require cycle", () => {
// bootstrap() lazily requires plugin-open for buildClient, and plugin-open
// requires bootstrap at load. When plugin-open is the entry point (/agenthub-open)
// that lazy require resolves to whatever sits on its module.exports at that
// instant — so the assignment must come before the entry block that calls
// bootstrap(), or /agenthub-open dies with "buildClient is not a function".
const src = fs.readFileSync(
path.join(__dirname, "..", "..", "scripts", "plugin-open.js"),
"utf8"
);
it("assigns module.exports before running the entry block", () => {
const exportsAt = src.indexOf("module.exports =");
const entryAt = src.indexOf("require.main === module");
assert.ok(exportsAt !== -1 && entryAt !== -1);
assert.ok(exportsAt < entryAt, "module.exports must be assigned before the entry block");
});
it("exposes buildClient to a bootstrap that requires it back", () => {
assert.equal(typeof require("../../scripts/plugin-open").buildClient, "function");
});
});
describe("buildClient dependency install", () => {
const { buildClient } = require("../../scripts/plugin-open");
// A fake plugin root holding just enough of a client/ to copy.
function fakeRoot() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-root-"));
fs.mkdirSync(path.join(root, "client"));
fs.writeFileSync(path.join(root, "client", "package.json"), "{}");
return root;
}
// Records every npm invocation; `fails` names the subcommands that exit non-zero.
// A successful "run build" writes the dist/ the real Vite build would produce.
function fakeExec(calls, fails = []) {
return (_cmd, args, cwd) => {
calls.push(args.join(" "));
if (fails.includes(args[0])) return false;
if (args[0] === "run") fs.mkdirSync(path.join(cwd, "dist"), { recursive: true });
return true;
};
}
it("installs with `npm ci`, which cannot reuse a half-populated node_modules", () => {
// Regression: `npm install` reported success against a truncated cache
// entry, and the build then died with TS2318 — typescript's lib/*.d.ts
// missing, so every global type (Array, Boolean, …) was undefined.
resetRuntime();
const calls = [];
const result = buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) });
assert.equal(result, "built");
assert.equal(calls[0].startsWith("ci"), true, `expected npm ci first, got ${calls[0]}`);
assert.equal(
calls.some((c) => c.startsWith("install")),
false,
"npm install must not run once ci succeeded"
);
});
it("falls back to `npm install` when ci refuses the lockfile", () => {
resetRuntime();
const calls = [];
const result = buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls, ["ci"]) });
assert.equal(result, "built");
assert.deepEqual(
calls.map((c) => c.split(" ")[0]),
["ci", "install", "run"]
);
});
it("installs devDependencies explicitly, because NODE_ENV=production omits them", () => {
// The whole client build toolchain (vite, typescript, @types/*) is a
// devDependency, and npm turns NODE_ENV=production into --omit=dev. Every
// bootstrap descended from the running server inherits that env, so the
// install dropped vite, `npm run build` failed, and the dashboard kept
// serving the PREVIOUS version's bundle after a self-update.
resetRuntime();
const calls = [];
buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) });
for (const call of calls.filter((c) => c.startsWith("ci") || c.startsWith("install"))) {
assert.match(call, /--include=dev/, `dependency install must include dev deps: ${call}`);
}
});
it("fails without building when neither install path works", () => {
resetRuntime();
const calls = [];
const result = buildClient({
rt: RT,
root: fakeRoot(),
exec: fakeExec(calls, ["ci", "install"]),
});
assert.equal(result, "failed");
assert.equal(
calls.some((c) => c.startsWith("run")),
false,
"the build must not run on a broken dependency tree"
);
});
});
describe("supersededBy — an old cache directory must not act", () => {
const pluginsDir = path.join(TMP_HOME, "plugins");
const cacheDir = path.join(pluginsDir, "cache", "sg", "agenthub");
const oldRoot = path.join(cacheDir, "aaaaaaaaaaaa");
const newRoot = path.join(cacheDir, "bbbbbbbbbbbb");
function writeInstalled(installPath) {
fs.mkdirSync(pluginsDir, { recursive: true });
fs.writeFileSync(
path.join(pluginsDir, "installed_plugins.json"),
JSON.stringify({
version: 2,
plugins: { "agenthub@sg": [{ scope: "user", installPath }] },
})
);
}
beforeEach(() => {
fs.rmSync(pluginsDir, { recursive: true, force: true });
fs.mkdirSync(oldRoot, { recursive: true });
fs.mkdirSync(newRoot, { recursive: true });
});
it("names the installed build when this one is not it", () => {
writeInstalled(newRoot);
assert.equal(boot.supersededBy(oldRoot), newRoot);
});
it("clears the build that IS installed", () => {
writeInstalled(newRoot);
assert.equal(boot.supersededBy(newRoot), null);
});
it("ignores an installed path that no longer exists", () => {
// A phantom entry must never wedge a bootstrap shut.
writeInstalled(path.join(cacheDir, "cccccccccccc"));
assert.equal(boot.supersededBy(oldRoot), null);
});
it("ignores a plugin that is not a sibling of this one", () => {
fs.mkdirSync(pluginsDir, { recursive: true });
const other = path.join(pluginsDir, "cache", "sg", "sg-all", "0.1.0");
fs.mkdirSync(other, { recursive: true });
fs.writeFileSync(
path.join(pluginsDir, "installed_plugins.json"),
JSON.stringify({ plugins: { "sg-all@sg": [{ installPath: other }] } })
);
assert.equal(boot.supersededBy(oldRoot), null);
});
it("says nothing when there is no record to read (a git checkout)", () => {
assert.equal(boot.supersededBy(oldRoot), null);
});
});
describe("pruneRetiredPluginRoots", () => {
const pluginsDir = path.join(TMP_HOME, "plugins");
const cacheDir = path.join(pluginsDir, "cache", "sg", "agenthub");
const settingsPath = path.join(TMP_HOME, "settings.json");
/** Six builds, oldest first, so mtime order is deterministic. */
function seed(names) {
fs.rmSync(pluginsDir, { recursive: true, force: true });
let t = Date.now() - names.length * 60_000;
for (const n of names) {
const dir = path.join(cacheDir, n);
fs.mkdirSync(dir, { recursive: true });
fs.utimesSync(dir, new Date(t), new Date(t));
t += 60_000;
}
}
const NAMES = ["v1", "v2", "v3", "v4", "v5", "current"];
const dir = (n) => path.join(cacheDir, n);
beforeEach(() => {
resetRuntime();
seed(NAMES);
fs.rmSync(settingsPath, { force: true });
fs.writeFileSync(path.join(pluginsDir, "installed_plugins.json"), "{}");
});
it("keeps the current build and the two newest retired ones", () => {
const removed = boot.pruneRetiredPluginRoots(RT, dir("current"));
assert.equal(removed, 3); // v1 v2 v3 go; v4 v5 are the cushion
assert.ok(fs.existsSync(dir("current")));
assert.ok(fs.existsSync(dir("v5")));
assert.ok(fs.existsSync(dir("v4")));
assert.ok(!fs.existsSync(dir("v1")));
});
it("never deletes a build Claude Code records as installed", () => {
fs.writeFileSync(
path.join(pluginsDir, "installed_plugins.json"),
JSON.stringify({ plugins: { "agenthub@sg": [{ installPath: dir("v1") }] } })
);
boot.pruneRetiredPluginRoots(RT, dir("current"));
assert.ok(fs.existsSync(dir("v1")));
});
it("never deletes a build the user's hooks still point at", () => {
// A Claude Code process that has not been restarted is still executing
// from there — deleting it breaks that session's hooks mid-flight.
fs.writeFileSync(
settingsPath,
JSON.stringify({
hooks: { SessionStart: [{ hooks: [{ command: `node "${dir("v2")}/scripts/x.js"` }] }] },
})
);
boot.pruneRetiredPluginRoots(RT, dir("current"));
assert.ok(fs.existsSync(dir("v2")));
assert.ok(!fs.existsSync(dir("v1")));
});
it("refuses to touch anything outside the plugin cache", () => {
const checkout = path.join(TMP_HOME, "checkout", "agenthub");
const sibling = path.join(TMP_HOME, "checkout", "something-precious");
fs.mkdirSync(checkout, { recursive: true });
fs.mkdirSync(sibling, { recursive: true });
assert.equal(boot.pruneRetiredPluginRoots(RT, checkout), 0);
assert.ok(fs.existsSync(sibling));
});
});