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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user