/** * @file Tests scripts/systemd-service.js, and specifically the split that * exists because of one incident: the plugin self-update killed the dashboard * and left it dead. * * The bootstrap that performs an update is spawned by the running server, so * it lives in that server's systemd cgroup. The old code SIGTERM-ed the server * from inside that cgroup, which (a) exits 0, and `Restart=on-failure` reads a * clean exit as "meant to stop", and (b) trips `KillMode=control-group`, so * systemd reaped the bootstrap before it could write state or repoint the unit * at the new plugin root. The dashboard then stayed down until some later * Claude Code session happened to bootstrap again. * * Hence: writing the unit must not start anything, restarting must not block, * and a clean exit must no longer be read as "leave it stopped". * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, beforeEach, after } = 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-systemd-")); const prevHome = process.env.HOME; process.env.HOME = TMP_HOME; const svc = require("../../scripts/systemd-service"); /** Every systemctl invocation this test saw, as flat argv strings. */ let calls = []; beforeEach(() => { calls = []; // Each test starts from "no unit on disk" — otherwise a previous test's // write turns the next one's `written` into `unchanged`. fs.rmSync(svc.unitPath("agenthub"), { force: true }); svc.__setExecImpl((args) => { // `--version` is the availability probe every entry point runs first, not // an action — recording it would bury the calls these tests are about. if (args[0] !== "--version") calls.push(args.join(" ")); return { status: 0 }; }); }); after(() => { svc.__reset(); if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; fs.rmSync(TMP_HOME, { recursive: true, force: true }); }); const UNIT = { name: "agenthub", description: "AgentHub dashboard server (Claude Code plugin)", workingDir: "/plugin/root/new", execStart: "/usr/bin/node /plugin/root/new/server/index.js", env: { NODE_ENV: "production" }, }; function unitFile() { return fs.readFileSync(svc.unitPath("agenthub"), "utf8"); } const linuxOnly = { skip: process.platform !== "linux" ? "systemd is Linux-only" : false }; describe("systemd-service — unit content", linuxOnly, () => { it("restarts always, because a graceful SIGTERM exits 0", () => { svc.writeUserService(UNIT); const body = unitFile(); // `on-failure` is what let one stray kill (or an out-of-band stop during // an update) leave the dashboard down indefinitely. assert.match(body, /^Restart=always$/m); assert.doesNotMatch(body, /^Restart=on-failure$/m); }); it("stops only the server process, so an update never takes the lane panes with it", () => { // tmux forks its server from whichever process first asks for a session — // the dashboard, after a boot with autostart on — and that server sits in // this unit's cgroup. The default KillMode=control-group would kill it, // and every running agent, on each update's restart. svc.writeUserService(UNIT); assert.match(unitFile(), /^KillMode=process$/m); }); it("carries the ownership marker, so a hand-written unit is never clobbered", () => { svc.writeUserService(UNIT); assert.match(unitFile(), /agenthub-managed-unit/); }); }); describe("systemd-service — writeUserService", linuxOnly, () => { it("writes and enables the unit but starts NOTHING", () => { // The point of the split: the update must be able to repoint the unit at // the new plugin root while the old server is still serving requests. const result = svc.writeUserService(UNIT); assert.equal(result, "written"); assert.ok( calls.some((c) => c.startsWith("daemon-reload")), "a rewritten unit must be reloaded" ); assert.ok( calls.some((c) => c === "enable agenthub.service"), "it must still be enabled for boot" ); assert.ok( !calls.some((c) => c.includes("--now") || c.startsWith("restart") || c.startsWith("start")), `nothing may be started here, got: ${JSON.stringify(calls)}` ); }); it("reports an identical unit as unchanged and touches systemd not at all", () => { svc.writeUserService(UNIT); calls = []; assert.equal(svc.writeUserService(UNIT), "unchanged"); assert.deepEqual(calls, [], "an unchanged unit needs no systemctl call"); }); it("refuses a unit it did not write", () => { fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true }); fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nExecStart=/opt/mine\n", "utf8"); assert.equal(svc.writeUserService(UNIT), "foreign"); assert.equal(svc.unitIsOurs("agenthub"), false); }); }); describe("systemd-service — restartUserService", linuxOnly, () => { it("never blocks, because the caller is inside the cgroup being restarted", () => { assert.equal(svc.restartUserService("agenthub"), "restarted"); assert.deepEqual(calls, ["restart --no-block agenthub.service"]); }); it("reports failure rather than throwing when systemctl refuses", () => { // Available, but the restart itself is rejected — distinct from "no // systemd here", which must not be reported as a failure. svc.__setExecImpl((args) => ({ status: args[0] === "--version" ? 0 : 1 })); assert.equal(svc.restartUserService("agenthub"), "failed"); }); }); describe("systemd-service — unitIsStale", linuxOnly, () => { it("is true when the unit names a plugin root that is no longer the current one", () => { // Invisible while the server runs, fatal at the next reboot: the unit // starts a path Claude Code garbage-collected after the update. svc.writeUserService({ ...UNIT, workingDir: "/plugin/root/old" }); assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), true); }); it("is false for a unit already naming the current root", () => { svc.writeUserService(UNIT); assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), false); }); it("is false when there is no unit, and for one we do not own", () => { assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), false); fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true }); fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nWorkingDirectory=/opt/mine\n", "utf8"); assert.equal( svc.unitIsStale("agenthub", "/plugin/root/new"), false, "a foreign unit is not ours to call stale, or to rewrite" ); }); it("spawns nothing — the bootstrap fast path runs it on every SessionStart", () => { svc.writeUserService(UNIT); calls = []; svc.unitIsStale("agenthub", "/plugin/root/new"); assert.deepEqual(calls, []); }); }); 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" : "" })); assert.equal(svc.unitIsActive("agenthub"), true); svc.__setExecImpl((args) => ({ status: 3, stdout: args[0] === "is-active" ? "inactive\n" : "", })); assert.equal(svc.unitIsActive("agenthub"), false); }); }); describe("systemd-service — unitIsOurs", linuxOnly, () => { it("is false when no unit exists at all", () => { fs.rmSync(svc.unitPath("agenthub"), { force: true }); assert.equal(svc.unitIsOurs("agenthub"), false); }); it("is true for a unit this code wrote", () => { svc.writeUserService(UNIT); assert.equal(svc.unitIsOurs("agenthub"), true); }); });