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/.
This commit is contained in:
@@ -488,3 +488,127 @@ describe("buildClient dependency install", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,6 +168,25 @@ describe("systemd-service — unitIsStale", linuxOnly, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("systemd-service — unitRoot", linuxOnly, () => {
|
||||
it("reports the directory the unit runs from", () => {
|
||||
svc.writeUserService({ ...UNIT, workingDir: "/plugin/root/old" });
|
||||
assert.equal(svc.unitRoot("agenthub"), "/plugin/root/old");
|
||||
});
|
||||
|
||||
it("reports a FOREIGN unit's directory too", () => {
|
||||
// Not ours to rewrite, but still a directory the cache pruner may not
|
||||
// delete out from under a running service.
|
||||
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
|
||||
fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nWorkingDirectory=/opt/mine\n", "utf8");
|
||||
assert.equal(svc.unitRoot("agenthub"), "/opt/mine");
|
||||
});
|
||||
|
||||
it("is null when there is no unit", () => {
|
||||
assert.equal(svc.unitRoot("agenthub"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("systemd-service — unitIsActive", linuxOnly, () => {
|
||||
it("is true only for `active`", () => {
|
||||
svc.__setExecImpl((args) => ({ status: 0, stdout: args[0] === "is-active" ? "active\n" : "" }));
|
||||
|
||||
Reference in New Issue
Block a user