feat: start-at-login is a Settings switch, off by default (v2.5.0)

- Settings → About → "Start AgentHub automatically" (AutostartSettings.tsx,
  GET/PUT /api/settings/autostart, server/lib/autostart.js). The switch is
  the systemd --user unit's / LaunchAgent's enabled state: on writes a unit
  for the running server when none exists and enables it, off disables it;
  neither starts or stops the server. A foreign unit is never touched
  (409 EFOREIGN)
- The plugin bootstrap no longer installs a unit on every install: it
  installs or starts one only while the user has it enabled, and a rewrite
  keeps the enabled state (writeUserService({ enable })). An install that
  already had the unit enabled stays on. A stale-but-enabled unit beside a
  live detached server is repointed, no longer started onto the bound port
- unitIsEnabled / setUnitEnabled in systemd-service.js and launchd-service.js

Also:
- fix(workspace): status bar no longer covers the chat box below md (#61)
This commit is contained in:
2026-10-06 18:53:10 +07:00
parent 4aae61297f
commit 03ecc9e29a
25 changed files with 620 additions and 36 deletions
+103
View File
@@ -0,0 +1,103 @@
/**
* @file Tests server/lib/autostart.js (Settings → About's "Start AgentHub
* automatically"): off by default, on writes + enables a unit for this server
* without starting it, off disables without stopping, and a foreign unit is
* never touched. Also the bootstrap's half: `writeUserService({enable:false})`
* leaves the enabled state alone. systemctl is faked; HOME is a temp dir.
* @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("node:fs");
const os = require("node:os");
const path = require("node:path");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-autostart-"));
const prevHome = process.env.HOME;
process.env.HOME = TMP_HOME;
const svc = require("../../scripts/systemd-service");
const autostart = require("../lib/autostart");
svc.enableLinger = () => true; // the real one runs loginctl, outside the seam
let calls = [];
let enabled = false;
beforeEach(() => {
calls = [];
enabled = false;
fs.rmSync(svc.unitPath("agenthub"), { force: true });
svc.__setExecImpl((args) => {
if (args[0] === "--version") return { status: 0 };
calls.push(args.join(" "));
if (args[0] === "is-enabled")
return { status: 0, stdout: enabled ? "enabled\n" : "disabled\n" };
if (args[0] === "enable") enabled = true;
if (args[0] === "disable") enabled = false;
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 linuxOnly = { skip: process.platform !== "linux" ? "systemd is Linux-only" : false };
const startsOrStops = (c) => /^(start|stop|restart)\b|--now/.test(c);
describe("autostart", linuxOnly, () => {
it("is off with no unit, and turning it off writes nothing", () => {
assert.equal(autostart.status().enabled, false);
autostart.setEnabled(false);
assert.equal(fs.existsSync(svc.unitPath("agenthub")), false);
});
it("on writes a unit for this server and enables it, starting nothing", () => {
const res = autostart.setEnabled(true);
assert.equal(res.enabled, true);
const body = fs.readFileSync(svc.unitPath("agenthub"), "utf8");
assert.match(
body,
new RegExp(`^WorkingDirectory=${path.resolve(__dirname, "..", "..")}$`, "m")
);
assert.ok(calls.includes("enable agenthub.service"));
assert.ok(!calls.some(startsOrStops), JSON.stringify(calls));
});
it("off disables our unit and keeps it, stopping nothing", () => {
autostart.setEnabled(true);
calls = [];
const res = autostart.setEnabled(false);
assert.equal(res.enabled, false);
assert.ok(calls.includes("disable agenthub.service"));
assert.ok(!calls.some(startsOrStops), JSON.stringify(calls));
assert.ok(fs.existsSync(svc.unitPath("agenthub")));
});
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(autostart.status().foreign, true);
assert.throws(() => autostart.setEnabled(true), { code: "EFOREIGN" });
assert.equal(
fs.readFileSync(svc.unitPath("agenthub"), "utf8"),
"[Service]\nExecStart=/opt/mine\n"
);
});
it("writeUserService({enable:false}) repoints without enabling — the bootstrap's off path", () => {
svc.writeUserService({
name: "agenthub",
description: "x",
workingDir: "/plugin/root",
execStart: "/usr/bin/node /plugin/root/server/index.js",
enable: false,
});
assert.ok(!calls.some((c) => c.startsWith("enable")), JSON.stringify(calls));
assert.equal(svc.unitIsEnabled("agenthub"), false);
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* @file autostart.js
* @description Settings → About's "Start automatically" switch: whether the
* dashboard starts at boot/login through the per-user service
* (`scripts/service-manager.js` — a systemd `--user` unit, or a LaunchAgent on
* macOS). The switch IS the unit's enabled state, nothing stored beside it.
* Turning it on writes a unit for THIS server when none exists, and enables
* it; turning it off disables it. Neither ever starts or stops anything — the
* server answering the click is the one that would die. Off by default: the
* plugin bootstrap installs no unit until the user opts in.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const svc = require("../../scripts/service-manager");
const NAME = "agenthub";
const ROOT = path.resolve(__dirname, "..", "..");
/** A unit by our name that AgentHub did not write — never ours to touch. */
function isForeign() {
return fs.existsSync(svc.unitPath(NAME)) && !svc.unitIsOurs(NAME);
}
function status() {
const available = svc.systemctlAvailable();
return {
available,
kind: svc.kind,
enabled: available && svc.unitIsEnabled(NAME),
foreign: isForeign(),
};
}
/** The unit for the running server — same shape the bootstrap writes. */
function unitForThisServer() {
const entry = path.join(ROOT, "server", "index.js");
const env = { NODE_ENV: "production", PATH: process.env.PATH || "" };
for (const key of ["NODE_PATH", "DASHBOARD_CLIENT_DIST", "DASHBOARD_PORT"]) {
if (process.env[key]) env[key] = process.env[key];
}
return {
name: NAME,
description: "AgentHub dashboard server",
workingDir: ROOT,
execStart: `${process.execPath} ${entry}`,
argv: [process.execPath, entry],
env,
};
}
function fail(code, message) {
return Object.assign(new Error(message), { code });
}
function setEnabled(on) {
if (!svc.systemctlAvailable()) {
throw fail("EUNAVAILABLE", `no ${svc.label} manager is available on this machine`);
}
let result;
if (svc.unitIsOurs(NAME)) result = svc.setUnitEnabled(NAME, on);
else if (isForeign()) result = "foreign";
else if (!on)
result = "ok"; // nothing installed: already off
else {
result = svc.writeUserService(unitForThisServer()); // writes + enables, starts nothing
if (result === "written") svc.enableLinger();
}
if (result === "foreign") {
throw fail("EFOREIGN", `${svc.unitPath(NAME)} was not written by AgentHub — not touching it`);
}
if (result === "failed" || result === "unavailable") {
throw fail("ESERVICE", `could not change the ${svc.label}`);
}
return status();
}
module.exports = { status, setEnabled };
+66
View File
@@ -4,6 +4,8 @@
* - GET /api/sessions/facets (Sessions)
* - GET /api/settings/claude-home (Settings)
* - PUT /api/settings/claude-home (Settings)
* - GET /api/settings/autostart (Settings)
* - PUT /api/settings/autostart (Settings)
* - GET /api/workflows/runs (Workflows)
* - GET /api/workflows/runs/{runId} (Workflows)
* - GET /api/remote-sources (Remote Sources)
@@ -1009,6 +1011,70 @@ const paths = {
},
},
"/api/settings/autostart": (() => {
const status = {
description: "Start-at-login state",
content: {
"application/json": {
schema: {
type: "object",
required: ["available", "kind", "enabled", "foreign"],
properties: {
available: {
type: "boolean",
description: "False with no systemd --user (Linux) or GUI login (macOS)",
},
kind: { type: "string", enum: ["systemd", "launchd"] },
enabled: { type: "boolean" },
foreign: {
type: "boolean",
description: "A unit named agenthub exists that AgentHub did not write",
},
},
},
},
},
};
return {
get: {
tags: ["Settings"],
summary: "Whether the dashboard starts at boot/login",
description:
"The per-user service's enabled state (a systemd `--user` unit, or a LaunchAgent on macOS). Off by default.",
operationId: "getAutostart",
responses: { 200: status },
},
put: {
tags: ["Settings"],
summary: "Turn start-at-login on or off",
description:
"Enables or disables the unit, writing one for the running server first when none exists. Never starts or stops the server. Same-origin only.",
operationId: "setAutostart",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["enabled"],
properties: { enabled: { type: "boolean" } },
},
},
},
},
responses: {
200: { ...status, description: "The new state" },
400: {
description:
"EBADVALUE: `enabled` is not a boolean; EUNAVAILABLE: no systemd --user / launchd GUI session",
},
403: { description: "EBADORIGIN: another site asked" },
409: { description: "EFOREIGN: a unit named agenthub that AgentHub did not write" },
},
},
};
})(),
"/api/settings/codex-hooks": (() => {
const status = {
description: "Where AgentHub's Codex hooks stand",
+19
View File
@@ -252,6 +252,25 @@ router.get("/antigravity-hooks", codexHooksRoute(antigravityHooks.status));
router.post("/antigravity-hooks", sameOriginGuard, codexHooksRoute(antigravityHooks.install));
router.delete("/antigravity-hooks", sameOriginGuard, codexHooksRoute(antigravityHooks.remove));
// GET/PUT /api/settings/autostart — start the dashboard at boot/login through
// the per-user service (server/lib/autostart.js). Off by default; a PUT only
// enables or disables the unit, it never starts or stops the server.
const autostart = require("../lib/autostart");
router.get("/autostart", (_req, res) => res.json(autostart.status()));
router.put("/autostart", sameOriginGuard, (req, res) => {
if (typeof req.body?.enabled !== "boolean") {
return res
.status(400)
.json({ error: { code: "EBADVALUE", message: "`enabled` must be a boolean" } });
}
try {
res.json(autostart.setEnabled(req.body.enabled));
} catch (err) {
const status = { EUNAVAILABLE: 400, EFOREIGN: 409 }[err.code] || 500;
res.status(status).json({ error: { code: err.code || "ESERVICE", message: err.message } });
}
});
// POST /api/settings/reset-pricing — reset pricing to defaults. The seed is
// Claude's; a model priced by the automatic sync (an OpenAI one, from Codex
// sessions) is fetched back at once, waiting at most RESET_SYNC_MS for it.