@rackbops/ac-agent 2.0.0-alpha.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,683 @@
1
+ # @rackbops/ac-agent
2
+
3
+ The per-machine host agent for [artifact-console](https://github.com/Rackbops/artifact-console): a
4
+ small HTTPS server, paired to a console over a token, that lets it reach one dev machine's
5
+ `claude`/`git`/`gh` and file system through an allowlisted verb API. Console-initiated pairing (a
6
+ one-time code exchanged for a per-machine token, #48), `repos` list/status/pull and `open` (#49,
7
+ 49a), `exec/claude` (with the job registry and its SSE log) plus `exec/gh` (#49, 49b), and the
8
+ `context-pressure` / `subagent-cost` session-log scans (#52, 52a), and `memory-footprint` /
9
+ `toolchain-inventory` (#52, 52b) are all live -- every `scan/:kind` kind #52 planned has landed.
10
+ Every verb also
11
+ requires a per-plugin capability grant, pushed by the console and enforced here (#50) -- see
12
+ **Verbs** and **Security model** below. `GET /v1/preflight` (#55) -- git/gh/node/claude executed,
13
+ chromium only resolved -- is control plane, not a verb; see **Preflight**.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ npx @rackbops/ac-agent install
19
+ ```
20
+
21
+ During the prerelease period, install the newest prerelease explicitly with the `next` dist-tag
22
+ (`npx @rackbops/ac-agent@next install`) -- `release.yml`'s publish step tags every `-alpha.N`/
23
+ `-beta.N`/`-rc.N` version `next` from here on (#269), since npm refuses a plain `npm publish` of a
24
+ prerelease version with no explicit `--tag`; a stable version still publishes under `latest`. The
25
+ very first publish (`2.0.0-alpha.8`, #268) was done by hand under `--tag alpha` before this existed
26
+ -- and because it was the package's first version ever, npm *also* pointed `latest` at
27
+ `2.0.0-alpha.8` (npm's own first-publish behaviour, not a deliberate choice here). So right now a
28
+ bare `npx @rackbops/ac-agent install` (no tag) resolves to `alpha.8`, with `alpha` and `latest` both
29
+ still pointing at it; `next` starts moving with the next prerelease tag, and `latest` only moves
30
+ again once a real stable version is published.
31
+
32
+ ### Upgrade with the Tooling script
33
+
34
+ For an existing install, an up-to-date Tooling checkout provides
35
+ [`Update-AcAgent.ps1`](https://github.com/Rackbops/Tooling/blob/main/PowerShell/Update-AcAgent.ps1)
36
+ and its sibling helpers, including `Resolve-GhExe.ps1`. These examples assume the PowerShell session
37
+ is at the root of that checkout:
38
+
39
+ ```powershell
40
+ # Local Windows agent, explicit release
41
+ & .\PowerShell\Update-AcAgent.ps1 -Local -Version 2.0.0-alpha.31
42
+
43
+ # Newest release carrying its matching agent tarball, including prereleases
44
+ & .\PowerShell\Update-AcAgent.ps1 -Local -Version latest
45
+
46
+ # Linux targets reached from this PowerShell session
47
+ & .\PowerShell\Update-AcAgent.ps1 -Linux 'user@host-a,user@host-b' -Version 2.0.0-alpha.31
48
+
49
+ # Preview the same Linux operation
50
+ & .\PowerShell\Update-AcAgent.ps1 -Linux 'user@host-a,user@host-b' -Version 2.0.0-alpha.31 -WhatIf
51
+ ```
52
+
53
+ The updater downloads the matching release tarball once into a fresh temporary directory. A selected
54
+ target already serving that version skips installation. Other targets are read before and after the
55
+ install, including the endpoint version and served-certificate fingerprint; Linux also checks that
56
+ the user service is active. Target failures are independent, and any failure makes the script exit
57
+ nonzero. Temporary-file cleanup is attempted at the end. If the before endpoint was stopped or
58
+ unreadable, there is no fingerprint to compare, so the run cannot verify that it stayed unchanged.
59
+
60
+ `-WhatIf` is a non-mutating preview, not a zero-I/O mode: it still queries releases, downloads the
61
+ tarball, and reads target health, but runs no install, `scp`, or mutating SSH command.
62
+
63
+ Run Windows `-Local` from a [normal PowerShell window](#run-install-from-a-normal-shell). Every
64
+ Windows machine runs `-Local` for itself; remote Windows installation is unsupported. `gh` must have
65
+ access to the release repository. Linux targets need working noninteractive SSH and `node`/`npx` on
66
+ their `PATH`. `-Port` changes only the health-check port (default `8788`); it does not reconfigure the
67
+ agent.
68
+
69
+ ### Linux: no special `npx` spelling needed (#278)
70
+
71
+ The plain `npx @rackbops/ac-agent[@next] <verb>` line above is the one spelling that works on Linux
72
+ regardless of npm version -- confirmed on Ubuntu 26.04 with both npm 11.19 and npm 12.0.2. `#278` was
73
+ originally filed as an npm-11-vs-12 `npx` regression (alpha.9 silently printing `npm notice run` and
74
+ exiting 0 with no verb output on a npm 12 box), but the real cause was narrower and applied to *every*
75
+ npm version on Linux: `dist/ac-agent.mjs`'s own entry-point guard compared `import.meta.url` (always
76
+ the symlink-resolved real path) against a **raw, unresolved** `process.argv[1]`. npm's
77
+ `node_modules/.bin/<name>` bin link is a real filesystem symlink on Linux (whether reached via `npx`
78
+ or `npm install -g`), so the two paths never matched, `main()` never ran, and the process exited `0`
79
+ having done nothing -- on npm 11 *and* npm 12 alike. Windows never showed this: npm links a bin there
80
+ with a `.cmd`/`.ps1` shim that invokes `node "<real path>" ...` directly, so `argv[1]` was already the
81
+ real path. Fixed by resolving `argv[1]` through `realpathSync` before comparing (`isEntryPoint` in
82
+ `src/cli.ts`) -- no `--yes`, `--package=`, or `--` flag is needed or helps; none of those spellings
83
+ touch the actual bug. Requires a published release built with this fix (past `2.0.0-alpha.26`); any
84
+ prerelease at or before that still silently no-ops on Linux the same way.
85
+
86
+ Registers a Windows Task Scheduler task (`\ClaudeLocal\ac-agent`, a logon trigger for the current
87
+ user) or, on Linux, a systemd `--user` unit (`ac-agent.service`, `enable --now`), copies the running
88
+ bundle into the state dir (never the `npx` cache — that can be garbage-collected out from under a
89
+ long-running service), writes `agent.toml` if one doesn't already exist, and generates a self-signed
90
+ cert if one doesn't already exist. On an existing install, an explicit `--repos-root <abs>` narrowly
91
+ updates only the top-level `repos_root` string in `agent.toml`; comments, ordering, unknown keys,
92
+ credentials, grants, and every other setting stay untouched. The old and new roots are compared as
93
+ exact strings, so an equal value does not rewrite the file or change its mtime. A malformed file or
94
+ a non-string `repos_root` is refused rather than rewritten. Prints the resolved config/state paths,
95
+ the cert's fingerprint, and the firewall rule's status.
96
+
97
+ Options: `--port <n>` (default `8788`), `--name <machine>` (default `os.hostname()`), `--repos-root
98
+ <abs>` (default `<homedir>/repos`; the fleet's roots differ per box — Windows and Linux checkouts
99
+ don't share a layout, so every real install passes this explicitly). A relative `--repos-root` is
100
+ rejected before installation side effects on both fresh and existing installs. On an existing
101
+ install, `--name` and `--port` remain intentionally ignored and print the configured value they
102
+ kept; platform registration and restart checks use that existing port. If
103
+ `AC_AGENT_REPOS_ROOT` is non-blank and differs from an explicitly saved `--repos-root`, install
104
+ warns that the environment still wins at runtime until it is unset.
105
+
106
+ Installing over an already-running agent restarts it (#277): once the task/unit is re-registered
107
+ against the new bundle, `install` stops the old process and starts it again through the same
108
+ `stop`/`start` machinery the `ac-agent stop`/`ac-agent start` commands use, then confirms the new
109
+ process is actually answering (a health check, retried for a few seconds — the OS command that
110
+ launches a process can report success before that process has actually started listening) before
111
+ printing `restarted: now serving <version>`. If the restart fails outright, or the health check
112
+ never comes back healthy, the install is still reported as a success — the printed line is
113
+ `restart failed: <reason>; run ac-agent stop && ac-agent start`, and that manual sequence is the
114
+ fallback. A fresh install, with nothing running yet, prints neither line.
115
+
116
+ ### The firewall step
117
+
118
+ `install` tries to add an inbound rule (`New-NetFirewallRule`, Windows only) so a LAN caller can
119
+ reach the port. Creating a firewall rule needs administrator rights; when the process isn't
120
+ elevated, `install` prints the exact one-liner to run in an elevated PowerShell and `status` reports
121
+ `firewall: missing` until that's done. This is expected on a fresh install run from an ordinary
122
+ shell — it is not a failure to work around.
123
+
124
+ ### Run install from a normal shell
125
+
126
+ The Claude desktop app is an MSIX package (`Claude_<hash>`, e.g. `Claude_pzs8sxrjxfjjc`). A Claude
127
+ Code session it starts — and every process that session spawns, including a PowerShell/Bash tool
128
+ shell running `npx @rackbops/ac-agent install` — inherits the package identity, and MSIX
129
+ **virtualizes AppData**: a write to a subfolder of `%LOCALAPPDATA%`/`%APPDATA%` is silently
130
+ redirected into the package's private `...\Packages\<pkg>\LocalCache\Local\...`/
131
+ `...\LocalCache\Roaming\...`, while the writing process itself believes it wrote the real path.
132
+ `install` writes the bundle copy, `launch.js`, the cert and `agent.toml` under the state/config
133
+ dirs, so run from such a session those files land somewhere the Windows logon task — launched later
134
+ with the user's real, unpackaged token — can never see, and the task exits `1` at once (#263).
135
+ `install` detects this (a nonce probe file, not just the presence of an old package copy, so a stale
136
+ VFS leftover from a prior virtualized install never blocks a later normal one) and refuses with:
137
+
138
+ ```
139
+ install ran inside a packaged app (<packageFamily>), which virtualizes AppData: the files landed
140
+ under <vfsPath> where the logon task cannot see them. Run ac-agent install from a normal PowerShell
141
+ window, not from a session started by the Claude desktop app.
142
+ ```
143
+
144
+ Run `install` from a normal PowerShell window opened from the Start menu, not from a Claude Code
145
+ session the desktop app started. `status`/`serve` don't refuse the same way (see **Status** and
146
+ **serve / stop / start** below) — a `serve` launched from a virtualized session still works, since
147
+ everything it reads and writes goes through the same virtualized view within that one session; it's
148
+ only a *task* launched later, outside that session, that can't see the files.
149
+
150
+ ### Linux
151
+
152
+ Run `install` as the **login user you'll actually use the agent as, never `root`**. As root there is
153
+ no user session bus for the `systemd --user` unit to register against —
154
+ `loginctl show-user`/`systemctl --user` calls fail with `Failed to connect to user scope bus via
155
+ local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined` — and a root install
156
+ would in any case put the config/state under `/root`, where the operator's own shell never looks.
157
+
158
+ A `systemd --user` unit only survives past that user's login session — logout, or a reboot — if
159
+ **lingering** is enabled for that user:
160
+
161
+ ```sh
162
+ sudo loginctl enable-linger "$USER"
163
+ ```
164
+
165
+ This can be run before or after `install`; order doesn't matter, because it governs the **user
166
+ manager's** lifetime (whether `systemd --user` keeps running with no session attached), not the
167
+ `ac-agent.service` unit itself. Without it, the user manager — and the agent with it — stops the
168
+ moment the last session for that user ends.
169
+
170
+ The unit's HTTPS listener isn't up the instant `install` returns — a `curl` fired immediately can
171
+ race the bind and report a false failure. Wait a moment, then check:
172
+
173
+ ```sh
174
+ curl -k https://127.0.0.1:8788/v1/health
175
+ ```
176
+
177
+ ### Windows: no console window
178
+
179
+ The task's action is `wscript.exe //B "<launch.js>"` against a small generated JScript launcher
180
+ (written into the state dir alongside the bundle copy), not a bare `node` invocation and not
181
+ `conhost.exe --headless` (an earlier design that turned out to run nothing at all — verified live,
182
+ `conhost.exe` isn't a generic headless-launch wrapper). `launch.js` runs
183
+ `WScript.CreateObject("WScript.Shell").Run("<node> <bundle> serve --config-dir <abs> --state-dir
184
+ <abs>", 0, true)` in a loop (the two dirs `install` itself resolved, baked in the same way
185
+ `<node>`/`<bundle>` already are — see **Config and state directories** below), retrying on a crash.
186
+ Window style `0` keeps node's console from ever appearing (Node has no `nodew.exe`, the
187
+ `pythonw.exe`-equivalent 1.x used), and waiting (`true`, not fire-and-forget) keeps `wscript.exe`
188
+ itself alive as the task's tracked process for the agent's entire lifetime, so the task reads
189
+ Running while serving and a crash propagates as a non-zero exit, visible afterward as
190
+ `LastTaskResult`. **`RestartCount 3` does not actually restart a crashed agent** — verified live (two
191
+ scratch tasks with these exact settings, one killed by pid and one exiting on its own, sampled every
192
+ 30 s for 150 s): Task Scheduler's restart-on-failure does not act on the action's exit code for this
193
+ action shape. The real crash-restart mechanism is `launch.js`'s own retry loop (#238): a non-zero
194
+ exit sleeps a minute and runs node again, up to 3 retries, since the exit code is already in
195
+ `wscript.exe`'s own hand; a zero exit (a deliberate shutdown) ends the loop instead. `stop`/`uninstall`
196
+ stop the task first — ending the loop — before killing the orphaned node by pid, the reverse of the
197
+ order that would otherwise let the loop see a bare kill as a crash and resurrect the agent a minute
198
+ after `stop`. Verified live: killing node by pid left the task Running with a fresh node pid
199
+ listening within about a minute; `stop` afterward left the task `Ready` with `LastTaskResult 267014`
200
+ (0x41306, terminated by user) and nothing listening 150 s later. The launcher is JScript rather than
201
+ VBScript specifically because
202
+ VBScript is a Windows feature-on-demand Microsoft is phasing out (Insider builds first); JScript and
203
+ Windows Script Host itself carry no such notice, so the same mechanism ships without a deprecated
204
+ component in the loop. (A PowerShell hidden launcher — `Start-Process -WindowStyle Hidden -PassThru
205
+ -Wait` — was also tried and verified working, but was dropped in favor of keeping Windows Script
206
+ Host as the only script host involved.) One thing this package cannot verify by itself — no CI
207
+ runner can register a real logon task — is named as unverified rather than assumed: whether any
208
+ window is visible at a real logon.
209
+
210
+ ## Status
211
+
212
+ ```sh
213
+ npx @rackbops/ac-agent status
214
+ ```
215
+
216
+ Prints, as JSON: the config path and whether it came from a file or defaults, the state dir, the
217
+ Task Scheduler/systemd service state, whether a pairing token is stored (`paired: true/false`), the
218
+ cert's fingerprint, the result of a live `GET /v1/health` call against itself (using its own pinned
219
+ cert, not a system CA — see **Security model** below), the currently-held grants
220
+ (`{revision, plugins: <count>} | null`, from `grants.json` — `null` when it is absent or malformed,
221
+ the same as a freshly-purged or never-pushed-to agent), the effective `reposRoot` and a live
222
+ `reposCount` computed from the same immediate-child, `.git`-entry, realpath-contained listing as
223
+ `GET /v1/repos` (a missing or unreadable root is zero), and `virtualized: true/false` (#263) — `true`
224
+ when this session's own AppData writes to the resolved config/state dirs are virtualized by a
225
+ packaged app (see **Run install from a normal shell** above), which also prints a `warning: this
226
+ session's AppData is virtualized (<packageFamily>); the logon task cannot see <stateDir> -- see
227
+ README, Install` line to stderr. `serve` logs the same warning line once at startup, in the same
228
+ circumstance, without refusing to start.
229
+
230
+ ## Pair
231
+
232
+ ```sh
233
+ npx @rackbops/ac-agent pair [--force]
234
+ ```
235
+
236
+ Generates a one-time pairing code and prints exactly three lines:
237
+
238
+ ```
239
+ code: <8-char code>
240
+ url: <this machine's URL>
241
+ fingerprint: <cert fingerprint>
242
+ ```
243
+
244
+ The code is 8 characters from an alphabet that excludes ambiguous glyphs (`0`/`O`, `1`/`I`/`L`, easy
245
+ to misread off a small screen or mishear read aloud) and expires in **10 minutes**; only its hash is
246
+ ever written to disk (`<stateDir>/pair.json`). Refuses with `already paired -- pass --force to
247
+ re-pair (mints a fresh code)` if a token is already stored — `--force` starts a fresh pairing
248
+ without discarding the old token until the *new* code is actually consumed, so a stale in-flight
249
+ attempt never wins a race against a still-valid one. Requires a certificate to already exist
250
+ (`install` always creates one); refuses with `no certificate found -- run install first` otherwise.
251
+
252
+ **On the console side**, open its Admin → Agents panel (the `agents` plugin, `plugins/agents/src/
253
+ ui.tsx`) and fill in the pairing form with the three printed values plus an optional machine name:
254
+ URL, Fingerprint, Code, Name. Submitting calls `POST /api/agents` — success adds the machine to the
255
+ paired list; the console then reaches it, per capability, once an operator grants that capability to
256
+ a plugin for it in the same panel's Grants grid (see **Security model** below — nothing runs until a
257
+ capability is both declared by the plugin and granted here).
258
+
259
+ ## Uninstall
260
+
261
+ ```sh
262
+ npx @rackbops/ac-agent uninstall [--purge]
263
+ ```
264
+
265
+ Stops and unregisters the task/unit, removes the copied bundle, cert, key, token hash, pending-pair
266
+ file, grants and lock file. Keeps `agent.toml` unless `--purge` is given, so a re-install remembers
267
+ the machine's chosen port/name.
268
+
269
+ ## serve / stop / start
270
+
271
+ `serve` is what the registered task/unit actually runs — it isn't meant to be invoked directly
272
+ except for debugging. `stop`/`start` control the registered service (`Stop-`/`Start-ScheduledTask`
273
+ or `systemctl --user stop/start`) without touching the registration itself.
274
+
275
+ ## Config and state directories
276
+
277
+ | | Windows | Linux |
278
+ |---|---|---|
279
+ | Config (`agent.toml`) | `%APPDATA%\ac-agent\` | `~/.config/ac-agent/` |
280
+ | State (cert, key, token hash, grants, log, pid, the installed bundle copy) | `%LOCALAPPDATA%\ac-agent\` | `~/.local/state/ac-agent/` |
281
+
282
+ Both are overridable with `AC_AGENT_CONFIG_DIR` / `AC_AGENT_STATE_DIR` — **absolute paths only**; a
283
+ relative override throws, naming the value. `install` resolves these once, in its own process, and
284
+ bakes the resolved absolute paths into the registered task/unit as `serve --config-dir <abs>
285
+ --state-dir <abs>`, the same way it already baked in the node/bundle paths — rather than relying on
286
+ `serve` to re-resolve them from its own environment when the task/unit actually runs. This matters
287
+ because a Task Scheduler process (and a systemd `--user` unit) is a separate process tree that does
288
+ not see the installing shell's variables — transient or persisted — so before this, an install under
289
+ the overrides silently served from the real platform defaults instead, with a different certificate
290
+ and the default port (E4 finding 13, fixed in #241). A manual `serve` with no flags still honours
291
+ `AC_AGENT_CONFIG_DIR`/`AC_AGENT_STATE_DIR` as before.
292
+
293
+ `agent.toml`:
294
+
295
+ ```toml
296
+ port = 8788
297
+ bind = "0.0.0.0"
298
+ machine = "melody"
299
+ repos_root = "S:/Repos"
300
+ claude_projects_dir = "C:/Users/melody/.claude/projects"
301
+ ```
302
+
303
+ Unknown keys are ignored, so a future field never breaks an older agent reading a newer file.
304
+ `repos_root` is overridable live with `AC_AGENT_REPOS_ROOT` the same way `port` is with
305
+ `AC_AGENT_PORT` — absolute paths only; a relative override throws, naming the value.
306
+ `claude_projects_dir` (#52, 52a; default `<homedir>/.claude/projects`) is handled identically, with
307
+ its own live override `AC_AGENT_CLAUDE_PROJECTS_DIR` — it names the root the `context-pressure` and
308
+ `subagent-cost` scans read, overridable so a fixture tree or a non-default `CLAUDE_CONFIG_DIR`
309
+ still resolves. Unlike `repos_root`, `install` takes no flag for it and `status` does not report
310
+ it.
311
+
312
+ ## Verbs
313
+
314
+ Everything under `/v1/*` except `/v1/health` and `/v1/pair` requires pairing (see **Security
315
+ model**). The verb registry fills `GET /v1/health`'s `capabilities` field with the sorted, unique
316
+ list of every registered verb's capability name — with every verb group registered, that's
317
+ `["agent.desktop.open", "agent.exec.claude", "agent.exec.gh", "agent.git.pull", "agent.git.read",
318
+ "agent.publish.mirror", "agent.scan.context-pressure", "agent.scan.memory-footprint",
319
+ "agent.scan.subagent-cost", "agent.scan.toolchain-inventory"]`.
320
+
321
+ **Every verb also requires two headers, checked in this order** (#50), after the bearer check
322
+ passes: `X-AC-Plugin` (the calling plugin's id) missing or shaped wrong is `400 {ok: false, error:
323
+ "missing X-AC-Plugin"}` / `"invalid X-AC-Plugin"`; `X-AC-Grants-Revision` missing is `400 {ok:
324
+ false, error: "missing X-AC-Grants-Revision"}`; a value that doesn't match this agent's own current
325
+ revision — including a never-pushed `null` — is `409 {ok: false, error: "grants out of date",
326
+ revision: <this agent's own, or null>}`, since a stale console must self-heal before its refusal
327
+ means anything; only once the agent's own copy is known-current does an ungranted capability get a
328
+ real refusal, `403 {ok: false, error: "<capability> is not granted to <plugin> on <machine>",
329
+ capability, plugin}`. See **Security model** for how a grant reaches the agent in the first place.
330
+
331
+ - **`GET /v1/repos`** (`agent.git.read`) → `{ok, root, repos: [{name, path, ownerName}]}`: every
332
+ immediate child of `repos_root` with a `.git` entry (a file for a worktree, a directory for a
333
+ clone), sorted by name. A missing or unreadable root answers
334
+ `{ok: true, repos: [], reason: "root missing"}`, never a 500. `ownerName` (#304) is the checkout's
335
+ `owner/name` resolved from its `origin` remote (`git remote get-url origin`, parsed the way 1.x's
336
+ `init_config.py:parse_remote` does), or `null` when there's no remote or it isn't a GitHub-shaped
337
+ URL — never an error. This makes the route spawn one `git remote get-url` per repo (parallelized),
338
+ so it is no longer git-free the way it was before #304.
339
+ - **`GET /v1/repos/:name/status`** (`agent.git.read`) → `{ok, name, path, ownerName, branch,
340
+ defaultBranch, upstream, ahead, behind, dirty, inProgress}` (`ownerName` as above), read-only (no
341
+ fetch — `pull` is what refreshes the refs `status` reads).
342
+ - **`POST /v1/repos/:name/pull`** (`agent.git.pull`), body `{dryRun?: boolean}` → `{ok: true, row}`
343
+ where `row.status` is `updated | would | current | skipped | error` and `row.reason` names which
344
+ of the six 1.x-derived guards applied (`dirty`, `branch`, `ahead`, `in-progress`, `no-upstream`,
345
+ `diverged`) when it skipped, or `not-a-repo` / `fetch-failed` / `dry-run`. Fast-forward only
346
+ (`git merge --ff-only`) — a diverged branch is reported, never resolved. Every git call runs with
347
+ `GIT_TERMINAL_PROMPT=0`, so a credential prompt refuses instead of hanging a request until its
348
+ timeout.
349
+ - **`:name` is an allowlist, not a path** — it must match a machine-generated repo name pattern, must
350
+ not be `.`/`..`, and must actually be one of the entries `GET /v1/repos` would list; anything else
351
+ is `404 {"error": "no such repo"}` before any git call runs.
352
+ - **`POST /v1/open`** (`agent.desktop.open`), body `{url}` → `{ok: true}`. Only a `https:` or
353
+ `claude:` URL is accepted (the console's own deep-link shape,
354
+ `claude://code/new?q=<prompt>&folder=<path>`); anything else is `400` before any process spawns.
355
+ The `200` attests only that the launcher (`rundll32.exe url.dll,FileProtocolHandler` on Windows,
356
+ `xdg-open` on Linux) started — never a shell string, always argv — not that a browser or Claude
357
+ Desktop actually opened; that's visible to the person at the desktop, not to the caller.
358
+ - **`POST /v1/scan/<kind>`** (`agent.scan.<kind>`, one capability per kind), body `{}` — a pure
359
+ dispatch shell (#49, 49c) that mounts one verb per kind registered into it; an unregistered
360
+ `<kind>` is `404 {"error": "no such verb"}`, the same as any other unregistered route — there is
361
+ no separate `GET /v1/scan` list, since the kinds a machine implements are already visible as
362
+ `agent.scan.<kind>` entries in `GET /v1/health`'s `capabilities`. One run per kind at a time: a
363
+ second `POST` while one is still in flight is `409 {ok: false, error: "scan already running"}`
364
+ (these scans read whole `~/.claude` trees and must not stack); a kind's own `run()` gets an
365
+ `AbortSignal` that fires at its `timeoutMs` — a run that doesn't honour it promptly answers the
366
+ client `504 {ok: false, error: "scan timed out after <n> ms"}` while the run keeps going in the
367
+ background (a later `POST` for that kind still gets `409`, now naming "timed out, still
368
+ finishing", until the run actually settles); one that never settles at all is abandoned five
369
+ timeouts later, freeing the kind up rather than locking it for the agent's whole lifetime. A
370
+ successful run answers `200 {ok: true, kind, generatedAt, durationMs, payload}`; a rejected one,
371
+ `502 {ok: false, error: "<message>"}`.
372
+ - **`context-pressure` and `subagent-cost`** (#52, 52a; `timeoutMs: 900_000` — a cold scan can
373
+ read on the order of a gigabyte of transcripts) both scan `claude_projects_dir` (default
374
+ `<homedir>/.claude/projects`) and are byte-compatible with the 1.x `artifacts-console`
375
+ scheduled tasks of the same name: same JSON shape, same key order, same incremental
376
+ `(mtime, size)` cache, same `os.linesep` cache-file line ending. `context-pressure` walks
377
+ `<root>/<encoded-project>/<session>.jsonl` (main session transcripts only — a subagent's own
378
+ transcript has its own separate window and is never folded in) and reports each session's peak
379
+ context-window occupancy (`input + cache_read + cache_creation` tokens, as a percentage of the
380
+ model's window) plus its compaction and unmarked-drop counts. `subagent-cost` walks
381
+ `<root>/<encoded-project>/<session>/subagents/**/agent-*.jsonl` (both the plain-Task layout and
382
+ the nested Workflow one) and reports each subagent's token totals and an API-equivalent dollar
383
+ figure at list rates (never money actually spent — this box runs Claude Code on a
384
+ subscription). Both read exactly six fields per transcript record (`type`, `subtype` /
385
+ `attributionAgent`, `timestamp`, `cwd`, `message.{id, model, usage}`); `message.content` --
386
+ prompts, tool results, file contents -- is never deserialised, by either scan. The cache lands
387
+ at `<stateDir>/scans/context-pressure.json` / `<stateDir>/scans/subagent-cost.json`, created on
388
+ first write and removed by `uninstall` regardless of `--purge`.
389
+ - **`memory-footprint`** (#52, 52b; no custom `timeoutMs` -- a cold run is kilobytes of I/O, not
390
+ the transcript-scale reads above) is a producer only, computed live on every request and never
391
+ cached: the fixed 8-layer set Claude Code assembles into a session's preamble (`enterprise`,
392
+ `user`, `user-rules` -- global, loaded into every session everywhere -- plus `project`,
393
+ `project-alt`, `project-rules`, `project-local`, `auto-memory` per repo, `auto-memory` truncated
394
+ to the first 200 lines of `MEMORY.md` under `claude_projects_dir`, same as `context-pressure`/
395
+ `subagent-cost`). The repo set is `GET /v1/repos`' own listing (every immediate child of
396
+ `repos_root` with a `.git` entry) -- unlike 1.x, which read the console's configured repo list,
397
+ so the payload's `repo` and `name` fields are both the directory name, never an `owner/name`
398
+ string. Tokens are a byte estimate (~4 chars/token; the payload says `estimated: true`); only
399
+ sizes and line counts ever leave this scan -- file content is never read into the payload.
400
+ - **`toolchain-inventory`** (#52, 52b; `timeoutMs: 600_000` -- about seventy version probes plus a
401
+ WSL probe) ships and runs 1.x's own `Get-ToolchainInventory.ps1` (vendored byte-for-byte,
402
+ `data/README.md`) through `pwsh -NoProfile -File <script> -OutFile <state>/scans/
403
+ toolchain-inventory-<hostname>.md -NoRender`, with the child's `cwd` set to `<state>/scans` --
404
+ **never a project directory**, since the script's own `pnpm --version` probe rewrites
405
+ `pnpm-lock.yaml` in whatever directory it runs in. **Windows-only**: on any other platform this
406
+ answers `{skipped: true, reason: "toolchain-inventory is Windows-only (1.x had no Linux
407
+ equivalent)"}` without spawning anything. Writes (and reads back) `<state>/scans/
408
+ toolchain-inventory-<hostname>.md` and its `.json` sidecar, and maintains its own copy of the
409
+ script at `<state>/scans/Get-ToolchainInventory.ps1` (rewritten only when the bundled text
410
+ changes, via a temp file + rename so a killed agent never leaves a half-written copy). A
411
+ Store-installed `pwsh` -- an App Execution Alias under `%LOCALAPPDATA%\Microsoft\WindowsApps`,
412
+ which a plain Node exists check cannot see -- is found: on Windows the agent's exists check falls
413
+ back to `lstat` (#439), the same check `which` and the preflight use. A missing `pwsh` on PATH is
414
+ `502 {"error": "pwsh not found on PATH; probed: <each PATH directory's pwsh.exe, in PATH order;
415
+ past 40 directories, the first 20 and the last 20 around ... (+N more) ...>"}` before any spawn
416
+ (the registry PATH lists the machine hive then the user hive, so the tail is the end of the user
417
+ hive, where WindowsApps is; entries in the middle of a very long PATH can still be left out, and
418
+ the marker counts them); the request timeout kills only the
419
+ `pwsh` child, never a hung probe or `wsl.exe` process it may have spawned (1.x had the same
420
+ scope). The hostname used for the file name and inside the script's own report is
421
+ `os.hostname()` -- never the operator's configured `machine`/`--name` override -- so the agent's
422
+ file name always matches 1.x's own for a byte-compare.
423
+
424
+ ### `POST /v1/exec/claude` and the job registry
425
+
426
+ - **`POST /v1/exec/claude`** (`agent.exec.claude`), body `{repo, prompt, permissionMode?, model?}` →
427
+ `202 {ok: true, jobId}`. `repo` resolves through the same allowlist as `GET /v1/repos/:name/status`
428
+ (a 404 before anything spawns); `prompt` is a non-empty string, at most 8 KiB (the Windows
429
+ `CreateProcess` command-line limit is 32,767 characters total, and quoting can double a prompt's
430
+ own backslashes and quotes — 8 KiB leaves headroom for the fixed flags and the repo path in the
431
+ worst case) with no control character other than `\n`/`\t`; `permissionMode` is one of `plan`
432
+ (the default) / `acceptEdits` / `bypassPermissions`; `model` is an optional
433
+ `^[A-Za-z0-9._-]{1,64}$` string. Any other shape is `400` before anything spawns. `claude` runs as
434
+ `claude -p <prompt> --permission-mode <mode> --add-dir <repo path> [--model <model>]`, `cwd` the
435
+ resolved repo. **At most 4 `claude` jobs run at once** — a 5th concurrent request is
436
+ `429 {ok: false, error: "too many running jobs"}`, checked before spawning, not after.
437
+ - **`GET /v1/jobs`** (`agent.exec.claude`) → `{ok: true, jobs: [view...]}`, newest first.
438
+ **`GET /v1/jobs/:id`** → `{ok: true, job: view}` or `404` for an unknown id. A job view is
439
+ `{id, kind: "claude", label, status: "running"|"done"|"failed", startedAt, endedAt?,
440
+ exitCode?: number|null, repo, permissionMode}`. **`POST /v1/jobs/clear-finished`** →
441
+ `{ok: true, cleared}`, dropping every done/failed job (running ones are kept).
442
+ - **`GET /v1/jobs/:id/events`** (`agent.exec.claude`) streams a job's log as Server-Sent Events: a
443
+ `404` for an unknown id up front, otherwise a finished job replays its whole buffer and closes,
444
+ and a running job replays its buffer then streams new lines live as they arrive, closing once the
445
+ job finishes. Two frame shapes only: `event: line` with `data` the JSON-encoded line text and `id`
446
+ the line's absolute position in the job's log (so a reconnecting client can tell whether it missed
447
+ lines to eviction); `event: done` with `data` `{"status": "done"|"failed", "exitCode":
448
+ number|null}`. These are the exact event names and shapes the host's own `/api/tasks/:id/log`
449
+ emits (`packages/host/src/tasks/route.ts`) with `exitCode` added, since the console's shell code
450
+ already knows how to consume them.
451
+
452
+ ### `POST /v1/exec/gh`
453
+
454
+ - **`POST /v1/exec/gh`** (`agent.exec.gh`), body `{args: string[]}` → `200 {ok: true, code, stdout,
455
+ stderr, truncated}` (stdout/stderr each capped at 1 MiB, `truncated: true` when either was cut). A
456
+ non-zero `gh` exit is still a `200` — the caller reads `code`. At most 32 arguments, each at most
457
+ 4 KiB with no control characters, except a `--body` value (`--body <text>` or `--body=<text>`),
458
+ which may be up to 64 KiB and contain `\n`/`\t` (a PR/issue comment body).
459
+ - **Every argument is checked against a fixed allowlist before anything spawns** — an unlisted
460
+ subcommand, flag, or malformed positional is `400 {ok: false, error: "<token and why>"}`, logged as
461
+ `gh refused: <error>`, additive-only (nothing here is ever loosened without a deliberate change to
462
+ the table):
463
+
464
+ | Subcommand | Positional | Flags |
465
+ |---|---|---|
466
+ | `issue view` | one `^[0-9]{1,9}$` | `--repo`, `--json`, `--jq` |
467
+ | `issue list` | none | `--repo`, `--json`, `--jq`, `--limit` (1-200), `--label`, `--author`, `--search`, `--state` (`open`\|`closed`\|`all`) |
468
+ | `issue comment` | one `^[0-9]{1,9}$` | `--repo`, `--body` |
469
+ | `pr view` | one `^[0-9]{1,9}$` | `--repo`, `--json`, `--jq` |
470
+ | `pr list` | none | `--repo`, `--json`, `--jq`, `--limit` (1-200), `--label`, `--author`, `--search`, `--state` (`open`\|`closed`\|`merged`\|`all`) |
471
+ | `pr checks` | one `^[0-9]{1,9}$` | `--repo`, `--json`, `--jq` |
472
+ | `pr comment` | one `^[0-9]{1,9}$` | `--repo`, `--body` |
473
+ | `release view` | one `^v?[0-9][A-Za-z0-9._-]{0,63}$` | `--repo` |
474
+ | `api` | one `^/?(repos\|orgs\|users\|rate_limit)(/(?!\.{1,2}(?:/\|$))[A-Za-z0-9._-]+)*$`, no query string | `--jq`, `--method` (`GET` only) |
475
+
476
+ `--repo` must be `^(?!\.{1,2}(?:/|$))[A-Za-z0-9._-]+/(?!\.{1,2}(?:/|$))[A-Za-z0-9._-]+$` (a bare
477
+ `.`/`..` half is refused the same way `resolveRepo` already refuses a bare `.`/`..` repo name — a
478
+ real owner or repo name may still contain a dot, e.g. `my.repo`); `--json` must be `^[A-Za-z,]+$`.
479
+ Both this and the `api` row's path pattern above build the same `.`/`..`-excluding segment rule
480
+ from one shared source (`SAFE_SEGMENT_SOURCE` in `gh-allowlist.ts`), not two independent copies.
481
+ `api` is **read-only by construction**: no request-body flags at all (`-f`, `-F`, `--input`, or `--method`
482
+ with anything but `GET` are refused), `search` is deliberately not in the path allowlist since a
483
+ real search needs a `q=` query string this table never permits, and a bare `.`/`..` path segment
484
+ is refused too — the segment class allows a literal `.` for a real repo/org name (`my.repo`), so
485
+ without this a `/repos/../search/issues` would otherwise satisfy the pattern and reach exactly
486
+ the excluded path space. `--flag=value` is split
487
+ before matching, and a bare `-`/`--` is always refused. A flag or subcommand not in this table —
488
+ `repo delete`, `issue delete`, `--body-file`, `--editor`, `--web`, `--hostname`, anything else — is
489
+ refused the same way, before `gh` is ever resolved or spawned.
490
+
491
+ ### The `.cmd`/`.bat` shim note (`claude` and `gh`)
492
+
493
+ Both `exec/claude` and `exec/gh` resolve their binary by walking `PATH` themselves rather than
494
+ letting a shell do it, because neither ever sets `shell: true` (request input reaching a shell is
495
+ exactly what this agent refuses to do). Node itself cannot spawn a `.cmd`/`.bat` shim without
496
+ `shell: true` (`EINVAL`, since the CVE-2024-27980 fix) — so a `claude` or `gh` install that only put
497
+ a shim on `PATH` (common for a global npm install on Windows) answers `502` with a message naming
498
+ the shim specifically (`"claude is a .cmd shim; install the native Claude Code binary"` /
499
+ `"gh is a .cmd shim; install the native GitHub CLI binary"`), distinct from a bare
500
+ `"<name> not found on PATH"` — the operator's fix differs (install the native binary vs. install it
501
+ at all). Windows PATH resolution stops at the **first** directory with any matching extension, so a
502
+ shim earlier on `PATH` is reported even if a native binary exists further down — that is what a real
503
+ invocation would actually run, not "helpful" behavior a real shell wouldn't show either.
504
+
505
+ ### `POST /v1/publish/mirror`
506
+
507
+ **`POST /v1/publish/mirror`** (`agent.publish.mirror`, #53) is the 2.0 home of 1.x's hourly
508
+ `publish_onedrive.py`: the console pushes changed report HTML to this verb rather than the agent
509
+ pulling from the console (the console sits behind Cloudflare Access, which an agent cannot pass).
510
+ Body `{files: [{href, base64}], wanted: string[], pdf: boolean, prune: boolean}` — `files` at most
511
+ 1000 entries, each `href` a relative POSIX path (no empty/`.`/`..` segment, no drive/UNC anchor)
512
+ ending `.html`, `base64` a valid base64 string, decoded total at most 48 MB (a request body over
513
+ ~66 MB is rejected by the route's own raised body-size cap before JSON parsing even runs, `413
514
+ {ok: false, error: "request body too large"}`); `wanted` an array of such hrefs (every html href the
515
+ caller currently wants mirrored, not just the changed ones in `files`); `pdf`/`prune` booleans. `409
516
+ {ok: false, error: "publish target not configured"}` when `publish_dir` is unset (most paired
517
+ machines — a Linux box has no OneDrive). One run at a time: `409 {ok: false, error: "publish already
518
+ running"}`.
519
+
520
+ - **HTML**: each file's bytes are compared against what's on disk (size, then sha256) — unchanged
521
+ bytes are never rewritten (OneDrive re-uploads every rewrite); a changed or new file is written to
522
+ `<publish_dir>/html/<href>.tmp-<pid>` then renamed into place. A write/rename failure (a file
523
+ OneDrive has locked mid-sync) counts as `failed` and the run continues with the next file.
524
+ - **PDF** (when `pdf: true`): one `pdf/<href>.pdf` per `wanted` report (never for an `index.html`
525
+ page), rendered with a local headless Chromium-family browser only when missing or older than its
526
+ html — resolved via the same search `GET /v1/preflight`'s `chromium` row uses (winPaths, then
527
+ PATH, then a version manager's bin dir), never executed otherwise. No resolvable browser is not an
528
+ error: html still mirrors, the response's `pdf.browser` is `null`.
529
+ - **Ownership, not "not in my docroot"** (1.x issue #230): the publish target is shared across every
530
+ paired machine that mirrors to it, so pruning (`prune: true`) is scoped to what THIS machine's
531
+ ledger — `<stateDir>/published-ledger.json`, machine-local, never inside the publish target itself
532
+ — claims it published; a file another machine owns is never even considered for deletion. With no
533
+ ledger yet, a machine adopts conservatively (only `wanted ∩ mirrored`). A prune is refused outright
534
+ (a veto, nothing deleted) when `wanted` is empty, or when it would delete ≥5 files AND over half
535
+ the mirror in one run — the response's `html.veto`/`pdf.veto` names the reason; re-run with the
536
+ same request (or a `--force`-equivalent isn't exposed here — see `publish-adopt` below for the
537
+ one-time claim that closes an adoption gap) once satisfied it's intended.
538
+ - Response `200 {ok: true, dest, html: {written, unchanged, failed, pruned, veto}, pdf: {rendered,
539
+ upToDate, failed, pruned, veto, browser}}`.
540
+
541
+ **`ac-agent publish-adopt`** (CLI, not a verb; one-time): refuses when `publish_dir` is unset,
542
+ otherwise claims every file currently in the mirror for this machine's ledger. Run exactly once, only
543
+ on the machine that published the existing (pre-agent) mirror, right at cut-over — without it those
544
+ pre-agent files are unowned and permanently unprunable, since conservative adoption only ever claims
545
+ files a run both mirrors and finds already present.
546
+
547
+ **Config**: `publish_dir` in `agent.toml` (env override `AC_AGENT_PUBLISH_DIR`, absolute-only, like
548
+ `repos_root`/`claude_projects_dir`) — default on Windows: `%OneDrive%\Claude-Artifacts` (or
549
+ `%OneDriveConsumer%\Claude-Artifacts` when only that's set); `null` (unconfigured) on every other
550
+ platform, and on a Windows box with neither OneDrive env var. `status` reports it under `publishDir`
551
+ (`null` when unconfigured).
552
+
553
+ ## Health: `claudeDesktop`
554
+
555
+ `GET /v1/health` carries `claudeDesktop: boolean` (#457) — whether this machine can open a
556
+ `claude://` link, so the console can refuse to hand a deep link to a machine that has no Claude
557
+ Desktop. **The field is omitted until the agent holds a conclusive answer**: a console reads a
558
+ missing field as "unknown", never as "no". Detection is read-only and best-effort: only `reg.exe
559
+ query` (Windows) or `xdg-mime query` (Linux) is ever run.
560
+
561
+ - **Windows: `true` when either signal is present.** The classic registration — a `URL Protocol`
562
+ value under `HKCU\Software\Classes\claude` or `HKLM\Software\Classes\claude` — or a packaged
563
+ (MSIX) one: a `Claude_*` package key under
564
+ `HKCU\Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages`
565
+ holding a `claude` value (named exactly, with data) under a key ending `\URLAssociations` (the package
566
+ manifest's URL-protocol registration; the same package's `FileAssociations` never counts). An
567
+ MSIX app's own `HKCU` writes can be private to the package, so a normal process cannot count on
568
+ seeing a classic key for a packaged install; both are checked, the classic pair first and the
569
+ packaged lookup (at most five package keys) only when both miss. `reg.exe`'s "End of search"
570
+ trailer is localized, so it is never parsed — only exit codes and key/value lines.
571
+ - **Linux:** `xdg-mime query default x-scheme-handler/claude` exits 0 with non-blank output.
572
+ - **Every other platform:** `false`, and nothing is run (a definite answer).
573
+ - **Detection is never request-driven.** `serve` runs a detection at start-up and then every five
574
+ minutes on its own timer (unref'd, so it never keeps the process alive; stopped on shutdown).
575
+ `GET /v1/health` only READS the answer the agent holds: it never starts, awaits or delays a
576
+ detection, so a persistently failing detection costs one attempt per five minutes however often
577
+ health is called.
578
+ - **Only a conclusive detection changes the held answer.** A detection is *inconclusive* if a
579
+ command timed out, could not be started (other than the binary being absent) or threw, or if the
580
+ whole detection ran past 15 seconds. An inconclusive detection never replaces the held answer,
581
+ and while none is held the field stays omitted. A `true` answer is always conclusive, and a
582
+ detection whose commands all gave an answer is definitive, including a `false` (reg.exe's "not
583
+ found" exit, `xdg-mime` with no handler, a binary that is not there). A child killed from outside
584
+ is reported as an ordinary exit code, so it counts as an answer. A detection abandoned at its
585
+ 15-second deadline is discarded whole: its late result changes nothing.
586
+ - **Two log lines, edge-triggered:** `claude desktop: yes` / `claude desktop: no` when the held
587
+ answer is first set or changes, and one line when detection becomes inconclusive (not one per
588
+ attempt).
589
+ - **Lag:** after Claude Desktop is installed or removed the agent knows within five minutes plus one
590
+ detection (at most 15 seconds), provided detection is conclusive; the console learns it on its next
591
+ probe of that machine (the scheduled agent-job tick) or at once on a Ping from the Agents panel. A
592
+ restarted agent holds no answer until its first conclusive detection, so a probe landing in that
593
+ window stores "unknown".
594
+
595
+ ## Preflight
596
+
597
+ **`GET /v1/preflight`** (#55): control plane, like `PUT /v1/grants` -- bearer-gated, no capability,
598
+ no `X-AC-Plugin`/`X-AC-Grants-Revision` headers, and refuses a caller carrying `X-AC-Plugin` with
599
+ `403 {ok: false, error: "preflight is the console's, not a plugin's"}`. The console's own
600
+ `POST /api/agents/:id/preflight` (`packages/host`) relays it into the Agents panel's Environment
601
+ section. Live and stateless: nothing is cached on either side, and a call answers `200 {ok: true,
602
+ machine, checkedAt, searchPath: "registry" | "process", rows}`, five rows always, in this order:
603
+
604
+ | `cmd` | required | executed | verdicts |
605
+ |---|---|---|---|
606
+ | `git` | yes | `git --version` | `ok` \| `missing` \| `broken` \| `off-path` |
607
+ | `gh` | yes | `gh --version`, then `gh auth status` | `ok` \| `missing` \| `broken` \| `off-path` \| `unauthenticated` |
608
+ | `node` | yes | `node --version` | `ok` \| `missing` \| `broken` \| `off-path` |
609
+ | `claude` | yes | `claude --version` | `ok` \| `missing` \| `broken` \| `off-path` |
610
+ | `chromium` | no | never | `ok` \| `missing` |
611
+
612
+ Each row is `{cmd, label, status, required, path, why, version, detail}` -- `version` the first
613
+ non-empty line of `--version`'s stdout (CRLF handled), `""` otherwise; `detail` status-dependent
614
+ free text (a `broken` row's last non-empty output line, `unauthenticated`'s
615
+ `run gh auth login [-- <stderr line>]`, `off-path`'s `found under a version manager, not on PATH`),
616
+ `""` when there is nothing to add.
617
+
618
+ - **Executed, not just resolved.** `git`/`gh`/`node`/`claude` are resolved on the refreshed PATH
619
+ (below) AND run (`<exe> --version`, a **15 s** timeout per spawn); `gh` additionally runs
620
+ `gh auth status` (needs the network -- offline it exits non-zero and the row reads
621
+ `unauthenticated` with the stderr line saying why, since a real, validated token is what bullet 2
622
+ wants, not merely a cached one `gh auth token` would report on regardless). `chromium` is
623
+ resolved only, never executed -- required: false, so amber (`missing`), never red, when absent.
624
+ - **A `.cmd`/`.bat` shim is `broken` without ever being run.** Node cannot spawn one without
625
+ `shell: true`, so a shim is reported the same way `exec/claude`/`exec/gh` already do -- distinct
626
+ from a bare "not found", naming the shim's own path and the exact fix (install the native
627
+ binary).
628
+ - **The PATH is the machine's, not this process's.** On Windows it is re-read from the registry
629
+ (`HKLM\...\Environment` then `HKCU\Environment` via `reg.exe`, spawned by its absolute
630
+ `System32` path) so a tool installed after the agent's logon task started is still seen; on
631
+ Linux the process PATH plus every version-manager bin directory (`fnm`/`nvm`/`volta`/`asdf`) is
632
+ searched as a fallback, so a tool invisible to a bare `which` reads `off-path`, never `missing`.
633
+ `searchPath` on the report names which: `"registry"` or `"process"`.
634
+ - **The whole check answers in a few seconds.** Five rows run concurrently; the worst case is
635
+ `gh`'s `--version` answering just under 15 s and then `auth status` timing out at 15 s too --
636
+ under the console's own 100 s edge-request budget behind Cloudflare Access.
637
+
638
+ ## Security model
639
+
640
+ - **HTTPS only**, with a self-signed certificate generated once at install (RSA 2048, SHA-256,
641
+ 10-year validity; SAN covers the machine's hostname, every non-internal IPv4 address, and
642
+ `127.0.0.1`).
643
+ - **`GET /v1/health` and `POST /v1/pair` are the only unauthenticated routes.** `/v1/health` answers
644
+ `{ok, version, apiVersion, machine, paired, capabilities, grantsRevision, claudeDesktop?}` — enough
645
+ for a caller to discover what it's talking to before pairing, nothing that requires trust.
646
+ `grantsRevision` is `null` before this agent has ever received a `PUT /v1/grants` (or if
647
+ `grants.json` went missing). `claudeDesktop` (present only once the agent holds a conclusive
648
+ answer; see **Health: `claudeDesktop`**) discloses one bit
649
+ — that a `claude://` handler is registered on the machine (for the agent's user, or machine-wide)
650
+ — as public as `capabilities`.
651
+ `/v1/pair` authenticates by a one-time code instead of a bearer token, since pairing is what mints
652
+ the token in the first place.
653
+ - **Every other route requires `Authorization: Bearer <token>`**, compared with a
654
+ constant-time equality check against a stored hash (never the token itself). Before pairing,
655
+ every such route answers `401 {"error": "not paired"}`; a present-but-wrong token answers
656
+ `401 {"error": "unauthorized"}`.
657
+ - **Grants: pushed by the console, enforced here, self-healing (#50).** The console's
658
+ `machine_grants` table is the source of truth; every change is pushed via `PUT /v1/grants` (bearer-
659
+ gated, refuses a caller carrying `X-AC-Plugin` — that header is how a real verb's own guard tells a
660
+ plugin's request apart from the console's own, so a plugin can never reach this route), persisted
661
+ to `grants.json`, and read back on every `serve` start. A push that fails at change time (agent
662
+ down, network) is not the end of the story: the very next verb request from that plugin fails its
663
+ revision check (`409`, see **Verbs**), the console pushes again and retries once, and the request
664
+ succeeds or is genuinely refused — so a grant change reaches the agent on its next request either
665
+ way, without restarting either side.
666
+ - **A caller must pin the certificate's fingerprint, not trust a system CA** — there is no CA here,
667
+ by design, and a pairing console holds only the fingerprint an operator typed at pairing time, never
668
+ the certificate's PEM. The correct client shape is therefore an explicit compare, not
669
+ `checkServerIdentity`: open the TLS socket with `rejectUnauthorized: false` (there is no chain to
670
+ validate against), then on connect compare `getPeerCertificate().fingerprint256` — the same
671
+ colon-separated uppercase hex SHA-256 this package's own `status`/install output prints — against
672
+ the pinned value yourself, and destroy the socket on any mismatch. This package's own
673
+ `server.integration.test.ts` exercises exactly that shape against a real TLS socket, both for a
674
+ correct pin and a wrong one.
675
+ **`rejectUnauthorized: false` is not a pin by itself, and pairing it with `checkServerIdentity`
676
+ doesn't make it one:** verified directly against this Node version that `checkServerIdentity` is
677
+ never invoked at all once `rejectUnauthorized` is false — the whole verification pathway is
678
+ skipped, so a "pin" layered on top of it is dead code, not an additional check. (Holding the
679
+ actual certificate PEM in hand — which this package's own CLI does, reading its own on-disk
680
+ `cert.pem` for its `status` self-check — is a different, legitimate case: `ca: <PEM>` with the
681
+ default `rejectUnauthorized: true` validates the chain and keeps `checkServerIdentity` live. A
682
+ pairing console never has that PEM, only the fingerprint, so it needs the explicit-compare shape
683
+ above.)