Compare commits

..

4 Commits

Author SHA1 Message Date
nntrivi2001 5d8fc9bb98 docs+test: the young-run window in SERVER-MODE/LANES; run.test plants its link in the real drop dir
run.test.js hardcoded /tmp/agenthub-drops-<uid>, which drops left for
<data dir>/drops long ago — it passed only while an old build's folder
survived in /tmp (gone after a reboot). Full suites: server 2887/2887,
client 1250/1250.
2026-10-12 14:36:22 +07:00
nntrivi2001 c8ea18f594 fix(client): a developer's Sessions page no longer asks for remote sources (403)
/api/remote-sources is STAFF_ONLY; Sessions loaded its labels for every
viewer, so each developer page load logged a 403.
2026-10-12 14:19:42 +07:00
nntrivi2001 64173a5428 fix: a run started a moment ago is never judged dead — another dashboard's tmux list may predate it
Only the process that starts a pane writes it into its own cached tmux list
(noteSession); a second dashboard on the same database (a dev server) holds
a member list up to 15 s old, so its heal beat ended a Codex pane seconds
after it started and the proxy gateway answered 401. reconcileOrphans and
healRunId skip a run younger than 20 s (dashboard-runs.isYoungRun).
Tests: dashboard-runs (young vs old).
2026-10-12 14:15:22 +07:00
nntrivi2001 943ba03b76 fix: v2.7.3 — Update now restarts through our own service unit, member panes stay live on a dev server, a start refuses a missing folder
- 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.
2026-10-12 14:04:08 +07:00
23 changed files with 332 additions and 47 deletions
+1 -1
View File
@@ -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. - **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. - **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).
--- ---
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide # 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 ## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring. - 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 ## 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)). - `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. - `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:<name>` (bare `/<name>` 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. - `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:<name>` (bare `/<name>` 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. - `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`. - `.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`.
+5 -1
View File
@@ -88,6 +88,7 @@ import { SessionRowMenu, workspaceHref } from "../components/SessionRowMenu";
import { formatDateTime, formatDuration, truncate, fmtCost, isoOrUndefined } from "../lib/format"; import { formatDateTime, formatDuration, truncate, fmtCost, isoOrUndefined } from "../lib/format";
import { effectiveSessionStatus, sessionAwaitingReason } from "../lib/types"; import { effectiveSessionStatus, sessionAwaitingReason } from "../lib/types";
import type { Session, DashboardEvent } from "../lib/types"; import type { Session, DashboardEvent } from "../lib/types";
import { usePermissions } from "../components/AuthGate";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@@ -183,14 +184,17 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
// Load remote-source labels so remote-origin rows can show a friendly badge // Load remote-source labels so remote-origin rows can show a friendly badge
// instead of a raw `src_…` id. Refreshed when a source's status changes. // instead of a raw `src_…` id. Refreshed when a source's status changes.
// Staff only: a developer's /api/remote-sources is a 403 (STAFF_ONLY_PREFIXES).
const { seesAll } = usePermissions();
const loadSourceLabels = useCallback(() => { const loadSourceLabels = useCallback(() => {
if (!seesAll) return;
api.remoteSources api.remoteSources
.list() .list()
.then((res: { sources: RemoteSource[] }) => { .then((res: { sources: RemoteSource[] }) => {
setSourceLabels(new Map(res.sources.map((s) => [s.id, s.label]))); setSourceLabels(new Map(res.sources.map((s) => [s.id, s.label])));
}) })
.catch(() => undefined); .catch(() => undefined);
}, []); }, [seesAll]);
useEffect(() => { useEffect(() => {
loadSourceLabels(); loadSourceLabels();
+7 -5
View File
@@ -196,7 +196,7 @@ export function Settings({
section?: "data" | "about" | "notifications"; section?: "data" | "about" | "notifications";
} = {}) { } = {}) {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const { isAdmin } = usePermissions(); const { isAdmin, seesAll } = usePermissions();
// Only Data and About show server info; General and Notifications never read it. // Only Data and About show server info; General and Notifications never read it.
const needsInfo = section === "data" || section === "about"; const needsInfo = section === "data" || section === "about";
const [loading, setLoading] = useState(needsInfo); const [loading, setLoading] = useState(needsInfo);
@@ -954,10 +954,12 @@ export function Settings({
<ImportHistory /> <ImportHistory />
</section> </section>
{/* ─── REMOTE DATA SOURCES ─── */} {/* ─── REMOTE DATA SOURCES (staff only: the server answers 403 to a developer) ─── */}
<section id="remote-sources" className="scroll-mt-24"> {seesAll && (
<RemoteSources /> <section id="remote-sources" className="scroll-mt-24">
</section> <RemoteSources />
</section>
)}
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */} {/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
<PushIngestSettings /> <PushIngestSettings />
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span <span
class="text-status-warning/90" class="text-status-warning/90"
> >
UI build v2.7.2 (rebuild client to match) UI build v2.7.3 (rebuild client to match)
</span> </span>
</p> </p>
</div> </div>
+2
View File
@@ -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 `<data dir>/run-prompts/<run id>.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 `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 `<data dir>/run-prompts/<run id>.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. 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 #### Preflight a destructive workspace action
+1 -1
View File
File diff suppressed because one or more lines are too long
+23 -1
View File
@@ -801,7 +801,29 @@ 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 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 `/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 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". And a run younger than 20 s
is never ended by any server: only the server that started a pane writes it
into its own cached tmux list, so another one on the same database had not
listed it yet and ended a Codex pane seconds after its start.
- **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 <pid of the server on the port>`; 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 ### Undo and uninstall
+1 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Agent Dashboard for Claude Code API 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. description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact: contact:
name: Nguyễn Ngọc Trí Vĩ name: Nguyễn Ngọc Trí Vĩ
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.7.2", "version": "2.7.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.7.2", "version": "2.7.3",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.7.2", "version": "2.7.3",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
+10 -2
View File
@@ -16,7 +16,9 @@
* setting never writes the developer's real `~/.config/agenthub/settings.env`, * setting never writes the developer's real `~/.config/agenthub/settings.env`,
* and `AGENTHUB_PRICING_SYNC=0` keeps every suite off the network. The * 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 * `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ĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -34,8 +36,14 @@ const files = fs
const args = process.argv.slice(2); const args = process.argv.slice(2);
const tmuxDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-test-tmux-")); 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 = { const env = {
...process.env, ...inherited,
TMUX_TMPDIR: tmuxDir, TMUX_TMPDIR: tmuxDir,
// Never the developer's real ~/.config/agenthub/settings.env: it can pin // Never the developer's real ~/.config/agenthub/settings.env: it can pin
// DASHBOARD_DATA_DIR at their live database. // DASHBOARD_DATA_DIR at their live database.
+18 -1
View File
@@ -22,8 +22,17 @@ before(() => {
}); });
after(() => fs.rmSync(ROOT, { recursive: true, force: true })); after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
// Long enough ago that no reconcile treats the run as just started.
const start = (id, extra = {}) => const start = (id, extra = {}) =>
runs.recordRun({ id, mode: "interactive", cwd: "/r", status: "running", laneId: 4, ...extra }); runs.recordRun({
id,
mode: "interactive",
cwd: "/r",
status: "running",
laneId: 4,
startedAt: Date.parse("2026-01-01T00:00:00Z"),
...extra,
});
describe("dashboard-runs", () => { describe("dashboard-runs", () => {
it("keeps the previous run of a pane as history when the pane starts again", () => { it("keeps the previous run of a pane as history when the pane starts again", () => {
@@ -77,6 +86,14 @@ describe("dashboard-runs", () => {
assert.ok(runs.getRun("agenthub-lane-11"), "another tool's row stays"); assert.ok(runs.getRun("agenthub-lane-11"), "another tool's row stays");
}); });
it("never ends a run started a moment ago: another process's tmux list may predate it", () => {
start("agenthub-lane-30", { laneId: 30, startedAt: Date.now() - 2000 });
start("agenthub-lane-31", { laneId: 31, startedAt: Date.now() - 60_000 });
runs.reconcileOrphans(() => false);
assert.equal(runs.getRun("agenthub-lane-30").status, "running");
assert.equal(runs.getRun("agenthub-lane-31").status, "abandoned");
});
it("flags as abandoned only the running rows whose pane is gone", () => { it("flags as abandoned only the running rows whose pane is gone", () => {
start("agenthub-lane-5", { laneId: 5 }); start("agenthub-lane-5", { laneId: 5 });
start("agenthub-lane-6", { laneId: 6 }); start("agenthub-lane-6", { laneId: 6 });
+10
View File
@@ -427,6 +427,16 @@ describe("lanes changed by another server on the same DB", () => {
}); });
describe("lane start input", () => { 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 () => { 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 cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-bad-model-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id; const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
+5 -7
View File
@@ -335,17 +335,15 @@ describe("GET /api/run/drop/:name", () => {
}); });
it("serves no file that is not a dropped image or text file", async () => { it("serves no file that is not a dropped image or text file", async () => {
const link = path.join(
os.tmpdir(),
`agenthub-drops-${os.userInfo().uid}`,
"fedcba9876543210.png"
);
fs.rmSync(link, { force: true });
fs.symlinkSync("/etc/passwd", link);
const pdf = await fetchJson("/api/run/drop-file?name=a.pdf", { const pdf = await fetchJson("/api/run/drop-file?name=a.pdf", {
method: "POST", method: "POST",
raw: { type: "application/pdf", buf: Buffer.from("%PDF") }, raw: { type: "application/pdf", buf: Buffer.from("%PDF") },
}); });
// Planted in the real drop dir (the one this drop just made), not a
// guessed path: `<data dir>/drops` replaced `/tmp/agenthub-drops-<uid>`.
const link = path.join(path.dirname(pdf.body.path), "fedcba9876543210.png");
fs.rmSync(link, { force: true });
fs.symlinkSync("/etc/passwd", link);
for (const name of [ for (const name of [
path.basename(pdf.body.path), // a drop, but no image path.basename(pdf.body.path), // a drop, but no image
"0123456789abcdef.png", // the right shape, never dropped "0123456789abcdef.png", // the right shape, never dropped
@@ -31,7 +31,15 @@ const { healAllRunIds } = require("../routes/lanes");
after(() => fs.rmSync(ROOT, { recursive: true, force: true })); after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const start = (id, laneId, cwd) => const start = (id, laneId, cwd) =>
runs.recordRun({ id, mode: "interactive", cwd, status: "running", laneId, sessionId: `s-${id}` }); runs.recordRun({
id,
mode: "interactive",
cwd,
status: "running",
laneId,
sessionId: `s-${id}`,
startedAt: Date.parse("2026-01-01T00:00:00Z"),
});
describe("a server without S3 on an S3 machine's database", () => { describe("a server without S3 on an S3 machine's database", () => {
it("never ends a member's run, and still ends its own", () => { it("never ends a member's run, and still ends its own", () => {
+88
View File
@@ -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ĩ <vinnt@smartgift.vn>
*/
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);
});
});
+16
View File
@@ -248,4 +248,20 @@ describe("run-as: a path under the users root belongs to that user", () => {
"/opt/agenthub/libexec/write-as" "/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);
}
});
}); });
+16 -2
View File
@@ -258,8 +258,21 @@ function claimToolSessions(sessionId = null) {
} }
const runningStmt = db.prepare( const runningStmt = db.prepare(
"SELECT id, tool, session_id, cwd FROM dashboard_runs WHERE status IN ('running', 'spawning')" "SELECT id, tool, session_id, cwd, started_at FROM dashboard_runs WHERE status IN ('running', 'spawning')"
); );
/**
* How long a new run is never judged dead. A tmux list is a cached answer —
* a member's may be up to 15 s old (tmux.js MEMBER_LIST_MAX_AGE_MS) — and only
* the process that started a pane writes it into its own cache, so another
* dashboard on the same database (a dev server) ended a pane it had simply
* not listed yet: the proxy gateway then answered 401 to a live Codex.
*/
const YOUNG_RUN_MS = 20_000;
function isYoungRun(startedAt, now = Date.now()) {
const t = Date.parse(startedAt || "");
return Number.isFinite(t) && now - t < YOUNG_RUN_MS;
}
const endStmt = db.prepare(` const endStmt = db.prepare(`
UPDATE dashboard_runs UPDATE dashboard_runs
SET status = @status, SET status = @status,
@@ -300,7 +313,7 @@ function reconcileOrphans(isAlive = () => false) {
let changes = 0; let changes = 0;
const { unseenMemberFolder } = require("./linux-accounts"); const { unseenMemberFolder } = require("./linux-accounts");
for (const row of runningStmt.all()) { for (const row of runningStmt.all()) {
if (isAlive(row.id) || unseenMemberFolder(row.cwd)) continue; if (isAlive(row.id) || unseenMemberFolder(row.cwd) || isYoungRun(row.started_at)) continue;
const interrupted = const interrupted =
row.tool !== "shell" && row.tool !== "shell" &&
!!row.session_id && !!row.session_id &&
@@ -368,6 +381,7 @@ module.exports = {
getRun, getRun,
forgetShellRun, forgetShellRun,
reconcileOrphans, reconcileOrphans,
isYoungRun,
listInterrupted, listInterrupted,
dismissInterrupted, dismissInterrupted,
proxyForSession, proxyForSession,
+24 -12
View File
@@ -133,13 +133,22 @@ function memberHomes() {
function membersWithTmux() { function membersWithTmux() {
return memberHomes() return memberHomes()
.map((home) => accountForPath(home)) .map((home) => accountForPath(home))
.filter((acct) => { .filter(mayHaveTmux);
try { }
return fs.statSync(tmuxSocket(acct)).isSocket();
} catch { /**
return false; * 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). // 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 // 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. // never past the size `fstat` gave — a file growing under the read is cut.
const MEMBER_READ_MAX = 64 * 1024 * 1024; const MEMBER_READ_MAX = 64 * 1024 * 1024;
const tooBig = (file) => const tooBig = (file) => Object.assign(new Error(`${file}: too big to read`), { code: "ETOOBIG" });
Object.assign(new Error(`${file}: too big to read`), { code: "ETOOBIG" });
/** `fs.readFileSync(file, enc)`, guarded (a member's file: ≤ `max` bytes). */ /** `fs.readFileSync(file, enc)`, guarded (a member's file: ≤ `max` bytes). */
function readFileSafe(file, enc, { max = MEMBER_READ_MAX } = {}) { 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); if (size > max) throw tooBig(file);
const buf = Buffer.alloc(size); const buf = Buffer.alloc(size);
let off = 0; 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); return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off);
} finally { } finally {
fs.closeSync(fd); fs.closeSync(fd);
@@ -398,7 +406,7 @@ async function readFileSafeAsync(file, enc, { max = MEMBER_READ_MAX } = {}) {
if (size > max) throw tooBig(file); if (size > max) throw tooBig(file);
const buf = Buffer.alloc(size); const buf = Buffer.alloc(size);
let off = 0; 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; off += n;
return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off); return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off);
} finally { } finally {
@@ -438,7 +446,10 @@ async function* readLines(file, { maxLine = 64 * 1024 * 1024 } = {}) {
let start = 0; let start = 0;
for (let i = view.indexOf(0x0a); i !== -1; i = view.indexOf(0x0a, start)) { for (let i = view.indexOf(0x0a); i !== -1; i = view.indexOf(0x0a, start)) {
if (skipping || partLen + (i - start) > maxLine) yield ""; 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 = []; parts = [];
partLen = 0; partLen = 0;
skipping = false; skipping = false;
@@ -502,6 +513,7 @@ module.exports = {
assertOwnerFits, assertOwnerFits,
hasLinuxAccount, hasLinuxAccount,
membersWithTmux, membersWithTmux,
mayHaveTmux,
memberHomes, memberHomes,
ownerForRead, ownerForRead,
tmuxSocket, tmuxSocket,
+63 -4
View File
@@ -7,26 +7,85 @@
* it, and hands the "wait for the old PID to actually die, then spawn the * 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 * replacement" job to a detached helper script — the dying process can't do
* that itself without racing its own replacement for the port. * 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ĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
const fs = require("fs");
const path = require("path"); const path = require("path");
const { spawn } = require("child_process"); 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. * @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 helperPath = path.join(root, "scripts", "restart-helper.js");
const entry = process.argv[1] || path.join(root, "server", "index.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, cwd: root,
detached: true, detached: true,
stdio: "ignore", stdio: "ignore",
env: process.env, env: process.env,
}); });
helper.unref(); helper.unref();
process.kill(process.pid, "SIGTERM"); kill();
return "helper";
} }
module.exports = { scheduleRestart }; module.exports = { scheduleRestart, owningUnit };
+7 -2
View File
@@ -226,16 +226,21 @@ function listOutput(acct = null) {
} }
// A member's list is never waited for: a read answers from `listed` and, // 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) — // 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 // 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`). // 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 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 const refreshing = new Set(); // member logins with a list in flight
function memberListOutput(acct) { function memberListOutput(acct) {
const hit = listed.get(acct.login); const hit = listed.get(acct.login);
const age = hit ? Date.now() - hit.at : Infinity; 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) { if (age >= MEMBER_LIST_MAX_AGE_MS) {
throw Object.assign(new Error(`${acct.login}: tmux list pending`), { code: "ETMUXPENDING" }); throw Object.assign(new Error(`${acct.login}: tmux list pending`), { code: "ETMUXPENDING" });
} }
+20
View File
@@ -128,6 +128,10 @@ function healRunId(lane, live = null) {
if (require("../lib/linux-accounts").unseenMemberFolder(lane.cwd)) return lane; if (require("../lib/linux-accounts").unseenMemberFolder(lane.cwd)) return lane;
if (live) { if (live) {
if (live.has(lane.run_id)) return lane; if (live.has(lane.run_id)) return lane;
// Started a moment ago: a cached list may not show it yet (dashboard-runs.js).
const dashboardRuns = require("../lib/dashboard-runs");
const row = dashboardRuns.getRun(lane.run_id);
if (row && row.status === "running" && dashboardRuns.isYoungRun(row.started_at)) return lane;
} else { } else {
const run = runs.getRun(lane.run_id); const run = runs.getRun(lane.run_id);
if (run && run.status === "running") return lane; if (run && run.status === "running") return lane;
@@ -2060,6 +2064,22 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
try { try {
switch (action) { switch (action) {
case "start": { 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 // `"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. // client's own pick comes from a list that may already be stale.
const auto = body.slot === "auto"; const auto = body.slot === "auto";