From 943ba03b76b3987f3c7d86bd24acd9b32637b56c Mon Sep 17 00:00:00 2001
From: nntrivi2001
Date: Mon, 12 Oct 2026 14:04:08 +0700
Subject: [PATCH] =?UTF-8?q?fix:=20v2.7.3=20=E2=80=94=20Update=20now=20rest?=
=?UTF-8?q?arts=20through=20our=20own=20service=20unit,=20member=20panes?=
=?UTF-8?q?=20stay=20live=20on=20a=20dev=20server,=20a=20start=20refuses?=
=?UTF-8?q?=20a=20missing=20folder?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- self-restart: a server in our unit's cgroup (/agenthub.service, system
or user) restarts via `systemctl restart --no-block` (sudo -n for the
system unit), our launchd job by exiting into KeepAlive. The detached
restart-helper replacement held the port while Restart=always started
the unit every 3 s (v2.7.2 on the S3 box: 240+ restarts, MainPID 0).
- run-as.mayHaveTmux: only ENOENT means "no tmux". A dev server in server
mode without CAP_DAC_READ_SEARCH got EACCES on a member's 0700
.agenthub/tmux, dropped the member from the list and ended their runs
within seconds — the real cause of `401 not a live pane's token`.
- tmux: a member's list is re-asked every 5 s, not 1 s (one sudo + three
auth.log lines each).
- POST /api/lanes/:id/start: 409 ENOCWD for a folder that no longer
exists (the pane started in the account's home otherwise).
- Settings: Remote sources only for staff (a developer got a 403).
- scripts/test-server.js drops every inherited AGENTHUB_* (pane env).
Tests: self-restart.test.js, server-s3-panes (mayHaveTmux), lanes-api
(ENOCWD); touched suites 771 + client pages 191, tsc -b.
---
ARCHITECTURE.md | 2 +-
CLAUDE.md | 4 +-
client/src/pages/Settings.tsx | 12 +--
.../screens.snapshot.test.tsx.snap | 2 +-
docs/API.md | 2 +
docs/SERVER-MODE.md | 21 ++++-
openapi.yaml | 2 +-
package-lock.json | 4 +-
package.json | 2 +-
scripts/test-server.js | 12 ++-
server/__tests__/lanes-api.test.js | 10 +++
server/__tests__/self-restart.test.js | 88 +++++++++++++++++++
server/__tests__/server-s3-panes.test.js | 16 ++++
server/lib/run-as.js | 36 +++++---
server/lib/self-restart.js | 67 +++++++++++++-
server/lib/tmux.js | 9 +-
server/routes/lanes.js | 16 ++++
17 files changed, 271 insertions(+), 34 deletions(-)
create mode 100644 server/__tests__/self-restart.test.js
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 003ccd73..844d7194 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -2323,7 +2323,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
- **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button.
- **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved.
-What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js` + the detached `scripts/restart-helper.js` rather than an in-process guess at the supervisor.
+What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`. A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2).
---
diff --git a/CLAUDE.md b/CLAUDE.md
index beda257e..5899138d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,6 +1,6 @@
# Claude Code Working Guide
-_Last synced: v2.7.2 (2026-10-12) — an account's own agent instructions go before every workspace's (#67); Chat ⇄ terminal keeps the unsent prompt; a server without S3 never ends a member's run._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
+_Last synced: v2.7.3 (2026-10-12) — Update now restarts through our own service unit; a start refuses a missing folder (ENOCWD)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -9,7 +9,7 @@ _Last synced: v2.7.2 (2026-10-12) — an account's own agent instructions go bef
## Repo map
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions, no tokens)).
- `client/`: React + Vite UI.
-- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` + the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
+- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` — through our own unit when this process runs in one (`systemctl restart --no-block`, launchd's KeepAlive; a detached replacement beside a `Restart=always` unit crash-loops it), else the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
- `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:` (bare `/` works as an alias). Put a skill here only if it should reach plugin users. The plugin ships no subagents and no pipeline skill, and depends on no other plugin (Superpowers included) — do not add an auto-install of one back into the bootstrap.
- `mcp/`, `statusline/`, `monitoring/`: opt-in integrations that only READ the running dashboard over HTTP — the read-only MCP server (`agenthub mcp`, docs/MCP.md), the Claude Code statusline (`agenthub statusline install|uninstall`, docs/STATUSLINE.md) and Prometheus/Grafana files for `/api/metrics` (`docker-compose.monitoring.yml`, docs/MONITORING.md). Neither the MCP server nor the statusline is ever registered by the bootstrap; only the user's own command writes `statusLine`, never over a foreign one without `--force`, always with a backup. The MCP server and the statusline find the dashboard through `server/lib/dashboard-url.js`, the CLI's own discovery.
- `.claude-plugin/`: the marketplace plus the root `agenthub` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/agenthub/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agenthub/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx
index dfacc2b5..86f1b02c 100644
--- a/client/src/pages/Settings.tsx
+++ b/client/src/pages/Settings.tsx
@@ -196,7 +196,7 @@ export function Settings({
section?: "data" | "about" | "notifications";
} = {}) {
const { t } = useTranslation("settings");
- const { isAdmin } = usePermissions();
+ const { isAdmin, seesAll } = usePermissions();
// Only Data and About show server info; General and Notifications never read it.
const needsInfo = section === "data" || section === "about";
const [loading, setLoading] = useState(needsInfo);
@@ -954,10 +954,12 @@ export function Settings({
- {/* ─── REMOTE DATA SOURCES ─── */}
-
+ {/* ─── REMOTE DATA SOURCES (staff only: the server answers 403 to a developer) ─── */}
+ {seesAll && (
+
+ )}
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap
index 2033743c..8de4b2ea 100644
--- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap
+++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
- UI build v2.7.2 (rebuild client to match)
+ UI build v2.7.3 (rebuild client to match)
diff --git a/docs/API.md b/docs/API.md
index 6a3e3e17..52921e6c 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -357,6 +357,8 @@ Error `409 ESESSIONLIVE` is returned when `resumeSessionId` (or `resume`) names
Error `400 EBADPROMPT` is returned for a `prompt` (or `POST /api/run`'s `initialPrompt`) over 128 KB: no CLI can take one argument that long (Linux's `MAX_ARG_STRLEN`, issue #68) — attach the text as a file instead. A prompt over 4 KB never rides in tmux's argv (one tmux message holds 16 KB, `command too long`): outside Windows it is written to `/run-prompts/.txt` (0600) and the pane's own `sh` reads it back as the CLI's last argument and deletes the file. An `after-start` tool's paste still goes through `tmux set-buffer` and stays under that 16 KB.
+Error `409 ENOCWD` is returned when the workspace's folder no longer exists (or is not a directory) — the pane would otherwise start in the account's home under this workspace's name. A folder this server cannot read (`EACCES`) is not refused.
+
Error `409 ERUNLIVE` is returned if the workspace already has a live run; stop it first with the `stop` action. The same code and status come back when the workspace's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
#### Preflight a destructive workspace action
diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md
index 427c80a0..ffc0d7c7 100644
--- a/docs/SERVER-MODE.md
+++ b/docs/SERVER-MODE.md
@@ -801,7 +801,26 @@ agenthub users provision alice # the Linux account only (a user made before
v2.7.2 on it leaves any run and workspace under the users root (read from
`/etc/agenthub/server.conf`) alone; a build older than v2.7.2 marks their
runs interrupted or abandoned within seconds, and the proxy gateway then
- refuses those panes (`401 not a live pane's token`).
+ refuses those panes (`401 not a live pane's token`). A dev server that DOES
+ run in server mode (it read `AGENTHUB_MODE=server` from
+ `~/.config/agenthub/settings.env`) but lacks the unit's
+ `CAP_DAC_READ_SEARCH` cannot stat a member's 0700 `.agenthub/tmux`; from
+ v2.7.3 on that `EACCES` counts as "maybe running" and the member's list is
+ asked through sudo, never read as "no panes".
+- **Update now** under the system unit restarts through
+ `sudo -n systemctl restart --no-block agenthub.service` (from v2.7.3). An
+ older build started a detached replacement instead, which kept the port
+ while `Restart=always` started the unit every few seconds, each start exiting
+ "already running" — `systemctl status agenthub.service` shows
+ `activating (auto-restart)` with `MainPID` 0. Recover with
+ `kill -TERM `; the unit then starts on the
+ new code.
+- A member's tmux list is re-asked at most every 5 s (one `sudo` each, which
+ writes three `auth.log` lines); a pane the dashboard starts or stops shows at
+ once, one that exits on its own within those 5 s.
+- Claude Code does not carry a folder's trust down from the member's home
+ (which provisioning trusts): a member answers "Trust this folder" once per
+ workspace, in the terminal.
### Undo and uninstall
diff --git a/openapi.yaml b/openapi.yaml
index 83eaa3db..ec33b3b7 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
- version: 2.7.2
+ version: 2.7.3
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
diff --git a/package-lock.json b/package-lock.json
index 45f153ce..86ec732c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "agenthub",
- "version": "2.7.2",
+ "version": "2.7.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
- "version": "2.7.2",
+ "version": "2.7.3",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
diff --git a/package.json b/package.json
index 595808cb..71793bef 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "agenthub",
- "version": "2.7.2",
+ "version": "2.7.3",
"bin": {
"agenthub": "bin/agenthub.js"
},
diff --git a/scripts/test-server.js b/scripts/test-server.js
index ba9a8065..70e152f2 100644
--- a/scripts/test-server.js
+++ b/scripts/test-server.js
@@ -16,7 +16,9 @@
* setting never writes the developer's real `~/.config/agenthub/settings.env`,
* and `AGENTHUB_PRICING_SYNC=0` keeps every suite off the network. The
* `GIT_DIR`-family variables a git hook exports are dropped too, so a test's
- * own `git init`/`commit` can never land in the real repository.
+ * own `git init`/`commit` can never land in the real repository. Every
+ * inherited `AGENTHUB_*` variable is dropped as well: a dashboard pane carries
+ * `AGENTHUB_MODE=server` and friends, which turned the suites login-gated.
* @author Nguyễn Ngọc Trí Vĩ
*/
@@ -34,8 +36,14 @@ const files = fs
const args = process.argv.slice(2);
const tmuxDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-test-tmux-"));
+// A dashboard pane's own AGENTHUB_* (AGENTHUB_MODE=server, AGENTHUB_USERS_ROOT,
+// its pane token…) would turn the suites into a login-gated server: hundreds
+// of 401s and EFOLDERs that have nothing to do with the code under test.
+const inherited = Object.fromEntries(
+ Object.entries(process.env).filter(([k]) => !k.startsWith("AGENTHUB_"))
+);
const env = {
- ...process.env,
+ ...inherited,
TMUX_TMPDIR: tmuxDir,
// Never the developer's real ~/.config/agenthub/settings.env: it can pin
// DASHBOARD_DATA_DIR at their live database.
diff --git a/server/__tests__/lanes-api.test.js b/server/__tests__/lanes-api.test.js
index cb632526..040c38cc 100644
--- a/server/__tests__/lanes-api.test.js
+++ b/server/__tests__/lanes-api.test.js
@@ -427,6 +427,16 @@ describe("lanes changed by another server on the same DB", () => {
});
describe("lane start input", () => {
+ it("refuses to start in a workspace folder that no longer exists (409 ENOCWD)", async () => {
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-gone-"));
+ const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
+ fs.rmSync(cwd, { recursive: true, force: true });
+ // Without the check the pane started in the account's home instead.
+ const r = await request("POST", `/api/lanes/${id}/start`, {});
+ assert.equal(r.status, 409);
+ assert.equal(r.body.error.code, "ENOCWD");
+ });
+
it("answers 400 for a model that is not a plain model id", async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-bad-model-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
diff --git a/server/__tests__/self-restart.test.js b/server/__tests__/self-restart.test.js
new file mode 100644
index 00000000..dab2c4dd
--- /dev/null
+++ b/server/__tests__/self-restart.test.js
@@ -0,0 +1,88 @@
+/**
+ * @file self-restart.test.js
+ * @description After an update, a server our service unit runs is restarted
+ * by the unit, never by a detached replacement: v2.7.2 on the S3 system unit
+ * left the replacement holding the port while `Restart=always` started the
+ * unit every 3 s, each start exiting "already running".
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+const { describe, it } = require("node:test");
+const assert = require("node:assert/strict");
+const { scheduleRestart, owningUnit } = require("../lib/self-restart");
+
+function deps(unit, restartAnswer = "restarted") {
+ const calls = { restart: [], kill: 0, spawn: 0 };
+ return {
+ calls,
+ unit,
+ svc: () => ({
+ restartUserService: (name) => {
+ calls.restart.push(name);
+ return restartAnswer;
+ },
+ }),
+ kill: () => calls.kill++,
+ spawnHelper: () => {
+ calls.spawn++;
+ return { unref() {} };
+ },
+ };
+}
+
+describe("self-restart", () => {
+ it("tells which unit owns this process, from its cgroup or launchd label", () => {
+ const linux = (text) => owningUnit({ platform: "linux", cgroup: () => text });
+ assert.equal(linux("0::/system.slice/agenthub.service\n"), "systemd");
+ assert.equal(
+ linux("0::/user.slice/user-1001.slice/user@1001.service/app.slice/agenthub.service\n"),
+ "systemd"
+ );
+ assert.equal(linux("0::/user.slice/user-1001.slice/session-3.scope\n"), null);
+ assert.equal(linux("0::/system.slice/agenthub.service.d/x\n"), null);
+ assert.equal(
+ owningUnit({
+ platform: "linux",
+ cgroup: () => {
+ throw new Error("no /proc");
+ },
+ }),
+ null
+ );
+ assert.equal(
+ owningUnit({ platform: "darwin", env: { XPC_SERVICE_NAME: "io.smartgift.agenthub" } }),
+ "launchd"
+ );
+ assert.equal(owningUnit({ platform: "darwin", env: {} }), null);
+ });
+
+ it("asks systemd to restart its unit and starts no replacement", () => {
+ const d = deps("systemd");
+ assert.equal(scheduleRestart("/r", d), "service");
+ assert.deepEqual(d.calls.restart, ["agenthub"]);
+ assert.equal(d.calls.spawn, 0);
+ assert.equal(d.calls.kill, 0, "systemd's restart job stops this process");
+ });
+
+ it("exits for Restart=always when systemd cannot be asked — still no replacement", () => {
+ const d = deps("systemd", "failed");
+ assert.equal(scheduleRestart("/r", d), "exit");
+ assert.equal(d.calls.kill, 1);
+ assert.equal(d.calls.spawn, 0);
+ });
+
+ it("exits under launchd, whose KeepAlive starts the new code", () => {
+ const d = deps("launchd");
+ assert.equal(scheduleRestart("/r", d), "exit");
+ assert.equal(d.calls.kill, 1);
+ assert.equal(d.calls.spawn, 0);
+ assert.deepEqual(d.calls.restart, []);
+ });
+
+ it("hands over to the detached helper when no unit owns the server", () => {
+ const d = deps(null);
+ assert.equal(scheduleRestart("/r", d), "helper");
+ assert.equal(d.calls.spawn, 1);
+ assert.equal(d.calls.kill, 1);
+ });
+});
diff --git a/server/__tests__/server-s3-panes.test.js b/server/__tests__/server-s3-panes.test.js
index 2670e943..e2ad81c5 100644
--- a/server/__tests__/server-s3-panes.test.js
+++ b/server/__tests__/server-s3-panes.test.js
@@ -248,4 +248,20 @@ describe("run-as: a path under the users root belongs to that user", () => {
"/opt/agenthub/libexec/write-as"
);
});
+ it("counts a member's tmux it cannot stat as maybe there, a missing one as none", () => {
+ const home = path.join(TMP, "users", "zoe");
+ const acct = { name: "zoe", login: "ah-zoe", home, uid: 5002 };
+ assert.equal(runAs.mayHaveTmux(acct), false, "no .agenthub at all");
+ const dir = path.join(home, ".agenthub", "tmux");
+ fs.mkdirSync(dir, { recursive: true });
+ assert.equal(runAs.mayHaveTmux(acct), false, "no socket yet");
+ // The member's own 0700 dir, seen by a dashboard without CAP_DAC_READ_SEARCH.
+ fs.chmodSync(dir, 0o000);
+ try {
+ if (process.getuid() !== 0)
+ assert.equal(runAs.mayHaveTmux(acct), true, "EACCES proves nothing");
+ } finally {
+ fs.chmodSync(dir, 0o700);
+ }
+ });
});
diff --git a/server/lib/run-as.js b/server/lib/run-as.js
index 57e6fd76..033470d2 100644
--- a/server/lib/run-as.js
+++ b/server/lib/run-as.js
@@ -133,13 +133,22 @@ function memberHomes() {
function membersWithTmux() {
return memberHomes()
.map((home) => accountForPath(home))
- .filter((acct) => {
- try {
- return fs.statSync(tmuxSocket(acct)).isSocket();
- } catch {
- return false;
- }
- });
+ .filter(mayHaveTmux);
+}
+
+/**
+ * Whether the member may have a tmux server. Only a missing socket says no:
+ * a member's `.agenthub/tmux` is theirs (0700), and a dashboard without
+ * CAP_DAC_READ_SEARCH (a dev server on the same database) gets EACCES there
+ * — that proves nothing, and reading it as "no server" ended every run of
+ * theirs within seconds. Their list is asked through sudo, which can tell.
+ */
+function mayHaveTmux(acct) {
+ try {
+ return fs.statSync(tmuxSocket(acct)).isSocket();
+ } catch (err) {
+ return err.code !== "ENOENT" && err.code !== "ENOTDIR";
+ }
}
// uid by login, from /etc/passwd (world-readable; node has no getpwnam).
@@ -357,8 +366,7 @@ function openSafe(file) {
// at no cost): MEMBER_READ_MAX unless the caller passes its own `max`, and
// never past the size `fstat` gave — a file growing under the read is cut.
const MEMBER_READ_MAX = 64 * 1024 * 1024;
-const tooBig = (file) =>
- Object.assign(new Error(`${file}: too big to read`), { code: "ETOOBIG" });
+const tooBig = (file) => Object.assign(new Error(`${file}: too big to read`), { code: "ETOOBIG" });
/** `fs.readFileSync(file, enc)`, guarded (a member's file: ≤ `max` bytes). */
function readFileSafe(file, enc, { max = MEMBER_READ_MAX } = {}) {
@@ -369,7 +377,7 @@ function readFileSafe(file, enc, { max = MEMBER_READ_MAX } = {}) {
if (size > max) throw tooBig(file);
const buf = Buffer.alloc(size);
let off = 0;
- for (let n; off < size && (n = fs.readSync(fd, buf, off, size - off, off)) > 0; ) off += n;
+ for (let n; off < size && (n = fs.readSync(fd, buf, off, size - off, off)) > 0;) off += n;
return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off);
} finally {
fs.closeSync(fd);
@@ -398,7 +406,7 @@ async function readFileSafeAsync(file, enc, { max = MEMBER_READ_MAX } = {}) {
if (size > max) throw tooBig(file);
const buf = Buffer.alloc(size);
let off = 0;
- for (let n; off < size && (n = (await fh.read(buf, off, size - off, off)).bytesRead) > 0; )
+ for (let n; off < size && (n = (await fh.read(buf, off, size - off, off)).bytesRead) > 0;)
off += n;
return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off);
} finally {
@@ -438,7 +446,10 @@ async function* readLines(file, { maxLine = 64 * 1024 * 1024 } = {}) {
let start = 0;
for (let i = view.indexOf(0x0a); i !== -1; i = view.indexOf(0x0a, start)) {
if (skipping || partLen + (i - start) > maxLine) yield "";
- else yield text(partLen ? Buffer.concat([...parts, view.subarray(start, i)]) : view.subarray(start, i));
+ else
+ yield text(
+ partLen ? Buffer.concat([...parts, view.subarray(start, i)]) : view.subarray(start, i)
+ );
parts = [];
partLen = 0;
skipping = false;
@@ -502,6 +513,7 @@ module.exports = {
assertOwnerFits,
hasLinuxAccount,
membersWithTmux,
+ mayHaveTmux,
memberHomes,
ownerForRead,
tmuxSocket,
diff --git a/server/lib/self-restart.js b/server/lib/self-restart.js
index 234ccfa9..62411ef4 100644
--- a/server/lib/self-restart.js
+++ b/server/lib/self-restart.js
@@ -7,26 +7,85 @@
* it, and hands the "wait for the old PID to actually die, then spawn the
* replacement" job to a detached helper script — the dying process can't do
* that itself without racing its own replacement for the port.
+ *
+ * A server our service unit runs (systemd `agenthub.service`, system or user;
+ * the launchd job) is restarted BY that manager instead: a detached
+ * replacement would hold the port while `Restart=always` starts the unit
+ * again every few seconds, each start exiting "already running" — forever.
* @author Nguyễn Ngọc Trí Vĩ
*/
+const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");
+const UNIT = "agenthub";
+
+/**
+ * "systemd" when this process lives in our unit's cgroup, "launchd" when it
+ * is our LaunchAgent job, else null. Pure reads: no process is spawned.
+ */
+function owningUnit({
+ platform = process.platform,
+ cgroup = () => fs.readFileSync("/proc/self/cgroup", "utf8"),
+ env = process.env,
+} = {}) {
+ if (platform === "darwin") {
+ return env.XPC_SERVICE_NAME === `io.smartgift.${UNIT}` ? "launchd" : null;
+ }
+ try {
+ return cgroup()
+ .split("\n")
+ .some((l) => l.trim().endsWith(`/${UNIT}.service`))
+ ? "systemd"
+ : null;
+ } catch {
+ return null;
+ }
+}
+
/**
* @param {string} [root] repo root the helper should run from.
+ * @returns {"service"|"exit"|"helper"} how the restart was handed over.
*/
-function scheduleRestart(root = path.join(__dirname, "..", "..")) {
+function scheduleRestart(root = path.join(__dirname, "..", ".."), deps = {}) {
+ const {
+ unit = owningUnit(),
+ svc = () => require("../../scripts/service-manager"),
+ kill = () => process.kill(process.pid, "SIGTERM"),
+ spawnHelper = spawn,
+ } = deps;
+ // systemd: one non-blocking restart job (sudo -n for the system unit). If
+ // that cannot be asked, a plain exit still comes back — the unit is
+ // Restart=always — but never with a second server beside it.
+ if (unit === "systemd") {
+ let restarted = false;
+ try {
+ restarted = svc().restartUserService(UNIT) === "restarted";
+ } catch {
+ /* fall through to the exit */
+ }
+ if (restarted) return "service";
+ kill();
+ return "exit";
+ }
+ // launchd: KeepAlive restarts the job on exit; a `bootout` from inside the
+ // job would wait for this very process.
+ if (unit === "launchd") {
+ kill();
+ return "exit";
+ }
const helperPath = path.join(root, "scripts", "restart-helper.js");
const entry = process.argv[1] || path.join(root, "server", "index.js");
- const helper = spawn(process.execPath, [helperPath, String(process.pid), entry], {
+ const helper = spawnHelper(process.execPath, [helperPath, String(process.pid), entry], {
cwd: root,
detached: true,
stdio: "ignore",
env: process.env,
});
helper.unref();
- process.kill(process.pid, "SIGTERM");
+ kill();
+ return "helper";
}
-module.exports = { scheduleRestart };
+module.exports = { scheduleRestart, owningUnit };
diff --git a/server/lib/tmux.js b/server/lib/tmux.js
index 81cfd449..6a4e7c61 100644
--- a/server/lib/tmux.js
+++ b/server/lib/tmux.js
@@ -226,16 +226,21 @@ function listOutput(acct = null) {
}
// A member's list is never waited for: a read answers from `listed` and,
-// once that is LIST_TTL_MS old, starts one async re-list. Until a first
+// once that is MEMBER_LIST_TTL_MS old, starts one async re-list. Until a first
// answer — or once the last is MEMBER_LIST_MAX_AGE_MS old (nobody polled) —
// or while their server fails, it throws: their panes are UNKNOWN. A session
// this process creates or kills is written into the answer at once (`noteSession`).
const MEMBER_LIST_MAX_AGE_MS = 15_000; // > the 10 s heal beat
+// A member's list is a `sudo` (fork + PAM session + 3 auth.log lines) — at a
+// 1 s TTL every open dashboard re-asked each member ~30 times a minute. A pane
+// this process starts or ends is corrected at once (`noteSession`); only one
+// that exits on its own reads live for up to these 5 s.
+const MEMBER_LIST_TTL_MS = 5_000;
const refreshing = new Set(); // member logins with a list in flight
function memberListOutput(acct) {
const hit = listed.get(acct.login);
const age = hit ? Date.now() - hit.at : Infinity;
- if (age >= LIST_TTL_MS) refreshList(acct);
+ if (age >= MEMBER_LIST_TTL_MS) refreshList(acct);
if (age >= MEMBER_LIST_MAX_AGE_MS) {
throw Object.assign(new Error(`${acct.login}: tmux list pending`), { code: "ETMUXPENDING" });
}
diff --git a/server/routes/lanes.js b/server/routes/lanes.js
index eb3658f7..943b8f25 100644
--- a/server/routes/lanes.js
+++ b/server/routes/lanes.js
@@ -2060,6 +2060,22 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
try {
switch (action) {
case "start": {
+ // A folder that is gone (or never was) would start the pane in the
+ // account's home instead, under this workspace's name. EACCES is not
+ // "gone": a member's folder may be unreadable to this process.
+ try {
+ if (!fs.statSync(lane.cwd).isDirectory())
+ throw Object.assign(new Error(), { code: "ENOTDIR" });
+ } catch (err) {
+ if (err.code === "ENOENT" || err.code === "ENOTDIR") {
+ return res.status(409).json({
+ error: {
+ code: "ENOCWD",
+ message: `the workspace folder does not exist: ${lane.cwd}`,
+ },
+ });
+ }
+ }
// `"auto"`: the first free tab, picked under the lane lock below — a
// client's own pick comes from a list that may already be stale.
const auto = body.slot === "auto";