Files
AgentHub/server/__tests__/plugin-update.test.js
T
nntrivi2001 cc85e35439 feat(updates): detect the newest release and self-apply it, including plugin installs
Update detection was a commit count against the default branch, which says nothing about whether a *version* shipped and is pure noise on the plugin cache (a depth-1 clone, where every distance is a graft artefact). It now reads the forge's own releases API and compares the published tag to this install's package.json version, falling back to the commit count only when no release can be read — with the reason in release_check, so "up to date" never silently means "we could not look".

Two states a commit count cannot express: dev_build (ahead of the newest release, missing nothing from it) now reads "Dev build - N ahead" instead of a false "Up to date"; and on a shallow clone every commits_*_release field is null and dev_build is never asserted, because the numbers would be meaningless.

POST /api/updates/apply gains a second mode, chosen by install kind and never by the request body. A checkout still fast-forwards onto the release tag, rebuilds and self-restarts. A ~/.claude/plugins/cache/ install is refused by applyUpdate (that directory is Claude Code's to replace) and handed instead to a detached chain: claude plugin marketplace update -> claude plugin update -y -> plugin-bootstrap.js --force from the NEW cache dir, which retires this server and starts its replacement. Both write the HTTP response before anything stops the process.

The chain records every outcome to plugin-update.json in the runtime dir, outside the cache the update replaces, so the next server reports it as last_plugin_update: a failed chain is otherwise indistinguishable from a slow one, and the tab would poll a server that never returns. /reload-plugins is deliberately not in the chain - it is REPL-scoped to the session that runs it, so no background process can perform it, and plugin-bootstrap derives PLUGIN_ROOT from its own __dirname, which is what makes it unnecessary for the dashboard.

Also rebrands the sidebar subtitle, splash and page title to SmartGift Solution, and corrects three ARCHITECTURE.md statements that claimed /apply did not exist.

Verified: server 1683/1683, client 486/486, build, prettier, file-header audit, openapi.yaml regenerated.
2026-09-10 16:03:07 +07:00

172 lines
6.9 KiB
JavaScript

/**
* @file Tests for the plugin-cache update chain: the verdict file the detached
* helper leaves behind (the only channel through which a failed chain becomes
* visible, since the server that would report it is the one being replaced),
* and the helper's own name guard.
*
* The guard test runs the REAL helper, deliberately, with a name that must be
* rejected — that is the one execution path safe to exercise, because it must
* bail out before invoking the `claude` CLI. No test here ever runs the chain
* with a valid name: that would pull a plugin version onto the machine running
* the suite and stop the developer's own dashboard.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { execFileSync } = require("child_process");
const { readLastPluginUpdate, schedulePluginUpdate, runtimeDir } = require("../lib/plugin-update");
const HELPER = path.join(__dirname, "..", "..", "scripts", "plugin-update-helper.js");
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pluginupd-"));
});
after(() => {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// ignore
}
});
/** Wait for `file` to exist, polling — the helper is a separate process. */
function waitForFile(file, timeoutMs = 20_000) {
const deadline = Date.now() + timeoutMs;
const sleeper = new Int32Array(new SharedArrayBuffer(4));
while (!fs.existsSync(file) && Date.now() < deadline) {
Atomics.wait(sleeper, 0, 0, 100);
}
return fs.existsSync(file);
}
describe("readLastPluginUpdate", () => {
it("returns null when the verdict file has never been written", () => {
const rt = path.join(tmpDir, "empty");
fs.mkdirSync(rt, { recursive: true });
assert.equal(readLastPluginUpdate(rt), null);
});
it("returns null for malformed JSON rather than throwing on a status poll", () => {
// This is read on EVERY status request for a plugin install, so a corrupt
// file must degrade to "nothing to report", never break the endpoint.
const rt = path.join(tmpDir, "malformed");
fs.mkdirSync(rt, { recursive: true });
fs.writeFileSync(path.join(rt, "plugin-update.json"), "{not json");
assert.equal(readLastPluginUpdate(rt), null);
});
it("returns null for valid JSON that isn't an object", () => {
const rt = path.join(tmpDir, "notobj");
fs.mkdirSync(rt, { recursive: true });
fs.writeFileSync(path.join(rt, "plugin-update.json"), "42");
assert.equal(readLastPluginUpdate(rt), null);
});
it("reads back a recorded failure, including the step it died at", () => {
const rt = path.join(tmpDir, "failed");
fs.mkdirSync(rt, { recursive: true });
fs.writeFileSync(
path.join(rt, "plugin-update.json"),
JSON.stringify({ ok: false, step: "bootstrap", error: "npm ci exploded" })
);
const v = readLastPluginUpdate(rt);
assert.equal(v.ok, false);
assert.equal(v.step, "bootstrap");
assert.match(v.error, /npm ci exploded/);
});
});
describe("schedulePluginUpdate", () => {
it("clears a stale verdict before spawning, so an old success can't be read as this run's result", () => {
// Redirect the runtime dir into the temp tree — this must not touch the
// real ~/.claude/agenthub/runtime of whoever runs the suite.
const dataDir = path.join(tmpDir, "datadir");
const prev = process.env.DASHBOARD_DATA_DIR;
process.env.DASHBOARD_DATA_DIR = dataDir;
// Point it at a root with no helper script: the spawn fails harmlessly
// (detached, stdio ignored) and nothing can rewrite the verdict, which is
// exactly what makes the clearing observable.
const fakeRoot = path.join(tmpDir, "noscripts");
fs.mkdirSync(fakeRoot, { recursive: true });
try {
const rt = runtimeDir();
fs.mkdirSync(rt, { recursive: true });
const verdict = path.join(rt, "plugin-update.json");
fs.writeFileSync(verdict, JSON.stringify({ ok: true, step: "done" }));
const res = schedulePluginUpdate({ marketplace: "sg", plugin: "agenthub", root: fakeRoot });
assert.equal(res.spawned, true);
assert.equal(res.runtime_dir, rt);
assert.equal(fs.existsSync(verdict), false, "stale verdict must be removed");
assert.equal(readLastPluginUpdate(rt), null);
} finally {
if (prev === undefined) delete process.env.DASHBOARD_DATA_DIR;
else process.env.DASHBOARD_DATA_DIR = prev;
}
});
});
describe("plugin-update-helper name guard", () => {
it("refuses a path-traversal marketplace name without invoking the claude CLI", () => {
const rt = path.join(tmpDir, "guard");
fs.mkdirSync(rt, { recursive: true });
// Run synchronously and let it record its own verdict. A name like this can
// only arrive from a bug or tampering; either way the helper must stop
// before it builds an argv for `claude`.
execFileSync(process.execPath, [HELPER, "../../../evil", "agenthub", "/nonexistent/root", rt], {
stdio: "ignore",
timeout: 30_000,
});
assert.ok(waitForFile(path.join(rt, "plugin-update.json")), "helper must record a verdict");
const v = JSON.parse(fs.readFileSync(path.join(rt, "plugin-update.json"), "utf8"));
assert.equal(v.ok, false);
assert.equal(v.step, "validate");
// And it must not have gotten as far as running a command.
const logPath = path.join(rt, "plugin-update.log");
const log = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : "";
assert.doesNotMatch(log, /claude plugin/, "no claude invocation may be logged");
});
it("refuses an empty plugin name the same way", () => {
const rt = path.join(tmpDir, "guard2");
fs.mkdirSync(rt, { recursive: true });
execFileSync(process.execPath, [HELPER, "sg", "", "/nonexistent/root", rt], {
stdio: "ignore",
timeout: 30_000,
});
const v = JSON.parse(fs.readFileSync(path.join(rt, "plugin-update.json"), "utf8"));
assert.equal(v.ok, false);
assert.equal(v.step, "validate");
});
it("stamps every verdict with a finish time", () => {
// The failure mode this file exists to prevent is a helper that dies
// silently: the tab would poll a server that never returns and say nothing.
// Only the rejected-name path is exercised — running the helper with a
// valid name would really update this machine's plugin and stop its server.
const rt = path.join(tmpDir, "guard3");
fs.mkdirSync(rt, { recursive: true });
execFileSync(process.execPath, [HELPER, "sg", "agent hub", "/nonexistent/root", rt], {
stdio: "ignore",
timeout: 30_000,
});
const v = JSON.parse(fs.readFileSync(path.join(rt, "plugin-update.json"), "utf8"));
assert.equal(v.step, "validate");
assert.ok(v.finished_at, "every verdict is timestamped");
assert.ok(!Number.isNaN(Date.parse(v.finished_at)));
});
});