@rackbops/ac-agent 2.0.0-alpha.58 → 2.0.0-alpha.60

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.
Files changed (3) hide show
  1. package/README.md +268 -52
  2. package/dist/ac-agent.mjs +57656 -52673
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -106,14 +106,24 @@ Registers a Windows Task Scheduler task (`\ClaudeLocal\ac-agent`, a logon trigge
106
106
  user) or, on Linux, a systemd `--user` unit (`ac-agent.service`, `enable --now`), copies the running
107
107
  bundle into the state dir (never the `npx` cache — that can be garbage-collected out from under a
108
108
  long-running service), writes `agent.toml` if one doesn't already exist, and generates a self-signed
109
- cert if one doesn't already exist. On an existing install, an explicit `--repos-root <abs>` narrowly
109
+ cert if one doesn't already exist (also when `cert.pem`/`key.pem` exist but are not a usable pair, an
110
+ empty or mismatched file, with a warning: the new fingerprint must be paired again; `install` prints
111
+ it, `serve` writes it to `agent.log`). The certificate, key, token hash, grants, pending pairing code,
112
+ `agent.toml` are written to a temp name (`<file>.tmp-<pid>`, fsynced) and renamed over; a hard kill
113
+ between the write and the rename can leave a stray `*.tmp-<pid>` beside the file. `update.json`
114
+ (`update.json.tmp`, not fsynced, a failed write is printed and swallowed) and the embedded
115
+ toolchain-inventory script are renamed over from a temp name too. Still written in place: the
116
+ Windows launch script, the systemd unit, the AppData-virtualization probe files, the scans cache (a
117
+ torn one just re-scans) and the self-update staging directory. A file that cannot be read for
118
+ any reason other than not existing is an error, not an empty answer (`status` instead lists it under
119
+ `unreadable`, by file name and error code, and treats it as absent). On an existing install, an explicit `--repos-root <abs>` narrowly
110
120
  updates only the top-level `repos_root` string in `agent.toml`; comments, ordering, unknown keys,
111
121
  credentials, grants, and every other setting stay untouched. The old and new roots are compared as
112
122
  exact strings, so an equal value does not rewrite the file or change its mtime. A malformed file or
113
123
  a non-string `repos_root` is refused rather than rewritten. Prints the resolved config/state paths,
114
124
  the cert's fingerprint, and the firewall rule's status.
115
125
 
116
- Options: `--port <n>` (default `8788`), `--name <machine>` (default `os.hostname()`), `--repos-root
126
+ Options: `--port <n>` (default `8788`; an integer from 1 to 65535, refused otherwise, and `agent.toml`'s `port` is held to the same rule), `--name <machine>` (default `os.hostname()`), `--repos-root
117
127
  <abs>` (default `<homedir>/repos`; the fleet's roots differ per box — Windows and Linux checkouts
118
128
  don't share a layout, so every real install passes this explicitly). A relative `--repos-root` is
119
129
  rejected before installation side effects on both fresh and existing installs. On an existing
@@ -125,13 +135,20 @@ warns that the environment still wins at runtime until it is unset.
125
135
  Installing over an already-running agent restarts it (#277): once the task/unit is re-registered
126
136
  against the new bundle, `install` stops the old process and starts it again through the same
127
137
  `stop`/`start` machinery the `ac-agent stop`/`ac-agent start` commands use, then confirms the new
128
- process is actually answering (a health check, retried for a few seconds — the OS command that
138
+ process is actually answering (on the configured `bind` when it names one address, else on `127.0.0.1`) (a health check, retried for a few seconds — the OS command that
129
139
  launches a process can report success before that process has actually started listening) before
130
140
  printing `restarted: now serving <version>`. Only a health answer reporting the **new** version
131
141
  counts (#659), so the old process still answering on the port is not mistaken for the new one.
132
142
 
133
143
  Before copying the new bundle in over a running agent, `install` keeps the one it replaces as
134
- `bin/ac-agent.prev.mjs` in the state dir (#659; one backup, overwritten by the next install). If the
144
+ `bin/ac-agent.prev.mjs` in the state dir (#659; one backup, overwritten by the next install). Both
145
+ the backup (`ac-agent.prev.mjs.tmp`) and the new bundle (`ac-agent.mjs.tmp`) are copied beside their
146
+ target and renamed into place, as is the restore, so an install killed mid-copy leaves the old bundle
147
+ whole. When the running agent reported its version and `node <bundle> version` is available, the live
148
+ bundle is backed up only if it reports that same version: after an aborted install it may be a newer
149
+ file than the process serving, and it must not overwrite a good backup (an existing backup is kept when
150
+ it is the running version; otherwise no backup is made and `install` prints a warning that it cannot
151
+ roll back). When the running version is not known the backup is made unchecked, as before. If the
135
152
  new bundle never answers health at its own version, `install` rolls back:
136
153
  1. It stops the task/unit again. On Windows this ends `launch.js`'s retry loop, so it can't rerun the
137
154
  broken bundle in the middle of the restore.
@@ -152,8 +169,11 @@ restart (registering the task/unit, the certificate) and a backup was made, the
152
169
  is copied back onto the disk; the error is reported as before.
153
170
 
154
171
  The install then **fails** (exit 1), because it did not take. If the rollback fails too, it prints
155
- `rollback failed: ...`, leaves both bundles in place, and also exits 1; `ac-agent start` is the
156
- fallback.
172
+ `rollback failed: ...` and also exits 1; `ac-agent start` is the fallback. Which file holds what depends
173
+ on how far the rollback got: `ac-agent.prev.mjs` always holds the previous bundle, and the live
174
+ `ac-agent.mjs` holds the previous bundle too once the restore copy has run (a failure while starting it or
175
+ confirming it), but still the new one when the failure came before that copy. The message names
176
+ `ac-agent.prev.mjs` and says to copy it over the live bundle if it is not already there.
157
177
 
158
178
  When there was no bundle to go back to, a restart that fails outright still reports the install as a
159
179
  success, with `restart failed: <reason>; run ac-agent stop && ac-agent start`, and that manual
@@ -184,8 +204,11 @@ it.
184
204
 
185
205
  `install` tries to add an inbound rule (`New-NetFirewallRule`, Windows only) so a LAN caller can
186
206
  reach the port. Creating a firewall rule needs administrator rights; when the process isn't
187
- elevated, `install` prints the exact one-liner to run in an elevated PowerShell and `status` reports
188
- `firewall: missing` until that's done. This is expected on a fresh install run from an ordinary
207
+ elevated, `install` prints the exact one-liner to run in an elevated PowerShell. An existing `ac-agent`
208
+ rule is kept only when its `LocalPort` is the install's port; a rule on another port (a reinstall with a
209
+ different port) is removed and recreated, and when that needs elevation the printed one-liner removes it
210
+ first. `uninstall` leaves the rule in place and prints the elevated `Remove-NetFirewallRule` command
211
+ that removes it. Only `install` reports the rule's state; `status` has no firewall field. This is expected on a fresh install run from an ordinary
189
212
  shell — it is not a failure to work around.
190
213
 
191
214
  ### Run install from a normal shell
@@ -241,6 +264,99 @@ race the bind and report a false failure. Wait a moment, then check:
241
264
  curl -k https://127.0.0.1:8788/v1/health
242
265
  ```
243
266
 
267
+ #### A server host that also runs the console (#826)
268
+
269
+ The machine that runs the console in Docker can pair an agent too, so its own toolchain is graded in
270
+ the Agents panel's Environment section like any other machine's; nucbox is set up this way (#826; its pairing was still pending on
271
+ 2026-10-20). It installs
272
+ the Linux way above, with these differences.
273
+
274
+ **Node 26 from NodeSource's apt repository.** The agent needs Node 26 or newer (`engines.node`, #827;
275
+ see **Install** above), the fleet's one line, and Ubuntu's own `nodejs` is older (22 on Ubuntu 26.04).
276
+ Write NodeSource's key and source by hand rather than piping its setup script to a shell. The key:
277
+
278
+ ```sh
279
+ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
280
+ | gpg --dearmor | sudo tee /usr/share/keyrings/nodesource.gpg >/dev/null
281
+ sudo chmod 0644 /usr/share/keyrings/nodesource.gpg
282
+ ```
283
+
284
+ Its fingerprint is `6F71 F525 2828 41EE DAF8 51B4 2F59 B5F9 9B1B E0B4`. The source, in deb822 form,
285
+ is `/etc/apt/sources.list.d/nodesource.sources`:
286
+
287
+ ```
288
+ Types: deb
289
+ URIs: https://deb.nodesource.com/node_26.x
290
+ Suites: nodistro
291
+ Components: main
292
+ Architectures: amd64
293
+ Signed-By: /usr/share/keyrings/nodesource.gpg
294
+ ```
295
+
296
+ Two pins make NodeSource's packages win over Ubuntu's: `/etc/apt/preferences.d/nodejs` (below) and
297
+ `/etc/apt/preferences.d/nsolid` (the same, with `Package: nsolid`):
298
+
299
+ ```
300
+ Package: nodejs
301
+ Pin: origin deb.nodesource.com
302
+ Pin-Priority: 600
303
+ ```
304
+
305
+ Then `sudo apt-get update && sudo apt-get install -y nodejs`, which brings `npm` with it. On nucbox
306
+ (2026-10-09) that installed `26.11.1-1nodesource1` with npm 11.20.0 and removed Ubuntu's nodejs 22,
307
+ its `node-*` libraries and `nodejs-doc`, so check first that nothing else on the host runs on the
308
+ system Node. On nucbox nothing else did (its services run in Docker; the plan's read of the box,
309
+ 2026-10-08), so the agent is the system Node's only user.
310
+
311
+ **An empty repos root.** After `sudo loginctl enable-linger "$USER"`:
312
+
313
+ ```sh
314
+ mkdir -p "$HOME/ac-agent-repos"
315
+ npx -y @rackbops/ac-agent install --repos-root "$HOME/ac-agent-repos"
316
+ ```
317
+
318
+ The console's host holds no checkout the console should list or pull, so the repos root is an empty
319
+ directory. `curl -k https://127.0.0.1:8788/v1/health` then answers `{"ok":true, ...,
320
+ "paired":false}`.
321
+
322
+ **The console reaches it at the host's LAN address.** Pair it as in **Pair** below, giving the
323
+ console's form the URL `pair` prints. That is the host's LAN address, not `127.0.0.1`: the console's
324
+ container runs on the same machine, but it calls this agent the way it calls any other, and inside the
325
+ container `127.0.0.1` is the container itself. `pair` and the certificate leave Docker's bridge
326
+ interfaces out (`docker0`, `br-*`, `veth*`; #330) unless they are the machine's only addresses, so the
327
+ address printed is the real NIC's. A host firewall has to admit the agent's port from the console's
328
+ Docker network as well as from the LAN, since the container's traffic to the host arrives from its
329
+ bridge address (not verified on nucbox; pairing is the test). Give the pairing form the host's name in
330
+ the fleet standard (`nucbox`): the Upgrade action finds the standard host by the name the console holds
331
+ for the machine, and the Environment grading by the name the agent reports (its OS hostname, which an
332
+ inventory sidecar's `hostname` maps to the host; see the agents plugin's README, "Which standard host a
333
+ machine is").
334
+
335
+ **The minimal grant set.** Opt the machine in to upgrades (`toolchain_upgrade = true` and the sudoers
336
+ rule, **Operator setup** under `POST /v1/toolchain/upgrade`; read what that rule grants before adding
337
+ it to a production host), restart it (`stop`, then `start`), and in the Grants grid give the `agents`
338
+ plugin `agent.toolchain.upgrade` and nothing else: no `agent.exec.claude`, no `agent.git.*`, no
339
+ `agent.scan.*`. The grading needs no grant at all (`GET /v1/preflight` is control plane, and so is
340
+ `POST /v1/update`, which lets the console update the agent itself). The Upgrade button needs none
341
+ either: it shows on any `behind` row whose tool the standard gives a package for on a Linux server
342
+ (among `git`, `gh`, `node` and `claude`; a `docker` or `cloudflared` row gets one too, and the agent
343
+ runs that upgrade at the next 05:00 America/New_York, #978). The grant is what lets
344
+ a click succeed; without it the agent refuses with `agent.toolchain.upgrade is not granted to agents on
345
+ <machine>`. That one granted verb installs a package the standard names: as root, through `sudo apt-get
346
+ install --only-upgrade` and the sudoers rule, or for Claude Code with its own `claude update` as the
347
+ agent's user. No other verb is granted: no `agent.exec.*`, `agent.git.*` or `agent.scan.*`.
348
+
349
+ The console no longer asks this machine for what it has not been granted (#843):
350
+ - The host's job runner skips a machine that has not granted every capability a job declares: no
351
+ `job_runs` row, no task and no `job failed` notification. The four `scans` jobs declare
352
+ `agent.scan.<kind>`, so each reads `not granted` on the machine's row instead of a failed run.
353
+ - The `repos` plugin's repo-currency check and `provider-artifacts`' hourly `publish.mirror` raise no
354
+ Needs-attention row for a machine that never had the grant; the card reads `not granted`. A grant
355
+ revoked after a good result raises one row (`Repo currency is no longer granted on <machine>`, and
356
+ its mirror-publish counterpart) for 7 days, then nothing.
357
+ - The Admin Security card's tunnel lint reads the console's own grants and does not call this agent;
358
+ it shows `agent.tunnel.lint is not granted to admin on <machine>` as a neutral row, not a warning.
359
+
244
360
  ### Windows: no console window
245
361
 
246
362
  The task's action is `wscript.exe //B "<launch.js>"` against a small generated JScript launcher
@@ -258,7 +374,8 @@ Running while serving and a crash propagates as a non-zero exit, visible afterwa
258
374
  scratch tasks with these exact settings, one killed by pid and one exiting on its own, sampled every
259
375
  30 s for 150 s): Task Scheduler's restart-on-failure does not act on the action's exit code for this
260
376
  action shape. The real crash-restart mechanism is `launch.js`'s own retry loop (#238): a non-zero
261
- exit sleeps a minute and runs node again, up to 3 retries, since the exit code is already in
377
+ exit sleeps a minute and runs node again, up to 3 retries in a row (a run that lasted longer than five
378
+ minutes starts the count over, so the budget is per crash loop, not per logon session), since the exit code is already in
262
379
  `wscript.exe`'s own hand; a zero exit (a deliberate shutdown) ends the loop instead. `stop`/`uninstall`
263
380
  stop the task first — ending the loop — before killing the orphaned node by pid, the reverse of the
264
381
  order that would otherwise let the loop see a bare kill as a crash and resurrect the agent a minute
@@ -280,14 +397,17 @@ window is visible at a real logon.
280
397
  npx @rackbops/ac-agent status
281
398
  ```
282
399
 
283
- Prints, as JSON: the config path and whether it came from a file or defaults, the state dir, the
400
+ Prints two plain lines, `lan address: <ip|(none)>` and `repos: <n>`, and then, as JSON: the config path and whether it came from a file or defaults, the state dir, the
284
401
  Task Scheduler/systemd service state, whether a pairing token is stored (`paired: true/false`), the
285
- cert's fingerprint, the result of a live `GET /v1/health` call against itself (using its own pinned
286
- cert, not a system CA — see **Security model** below), the currently-held grants
402
+ cert's fingerprint, `certExpiry` (`{validTo, daysLeft, expiringSoon}`, `null` with no readable cert;
403
+ `expiringSoon` is true within 30 days of `notAfter` or after it, and then a `warning: the agent's
404
+ certificate ...` line goes to stderr), the result of a live `GET /v1/health` call against itself (pinned
405
+ to the cert's fingerprint and nothing else: not a system CA and not the validity dates, so an expired
406
+ cert still answers and `certExpiry` is where the expiry shows — see **Security model** below), the currently-held grants
287
407
  (`{revision, plugins: <count>} | null`, from `grants.json` — `null` when it is absent or malformed,
288
408
  the same as a freshly-purged or never-pushed-to agent), the effective `reposRoot` and a live
289
409
  `reposCount` computed from the same immediate-child, `.git`-entry, realpath-contained listing as
290
- `GET /v1/repos` (a missing or unreadable root is zero), and `virtualized: true/false` (#263) — `true`
410
+ `GET /v1/repos` (a missing or unreadable root is zero), `unreadable` (`{file: error code}` for any file status could not read, `{}` normally), and `virtualized: true/false` (#263) — `true`
291
411
  when this session's own AppData writes to the resolved config/state dirs are virtualized by a
292
412
  packaged app (see **Run install from a normal shell** above), which also prints a `warning: this
293
413
  session's AppData is virtualized (<packageFamily>); the logon task cannot see <stateDir> -- see
@@ -314,7 +434,7 @@ url: <this machine's URL>
314
434
  fingerprint: <cert fingerprint>
315
435
  ```
316
436
 
317
- The code is 8 characters from an alphabet that excludes ambiguous glyphs (`0`/`O`, `1`/`I`/`L`, easy
437
+ The code is 8 characters from an alphabet that excludes ambiguous glyphs (`0`/`O`, `1`/`I`, easy
318
438
  to misread off a small screen or mishear read aloud) and expires in **10 minutes**; only its hash is
319
439
  ever written to disk (`<stateDir>/pair.json`). Refuses with `already paired -- pass --force to
320
440
  re-pair (mints a fresh code)` if a token is already stored — `--force` starts a fresh pairing
@@ -326,8 +446,17 @@ attempt never wins a race against a still-valid one. Requires a certificate to a
326
446
  ui.tsx`) and fill in the pairing form with the three printed values plus an optional machine name:
327
447
  URL, Fingerprint, Code, Name. Submitting calls `POST /api/agents` — success adds the machine to the
328
448
  paired list; the console then reaches it, per capability, once an operator grants that capability to
329
- a plugin for it in the same panel's Grants grid (see **Security model** below — nothing runs until a
330
- capability is both declared by the plugin and granted here).
449
+ a plugin for it in the same panel's Grants grid (see **Security model** below — no verb runs until a
450
+ capability is both declared by the plugin and granted here; the control-plane routes, `PUT /v1/grants`,
451
+ `GET /v1/preflight`, and `POST` and `GET /v1/update`, need no grant).
452
+ Every other POST verb answers `413 {ok:false,error:"request body too large"}` above 64 KiB; publish,
453
+ toolchain-upgrade and `exec/gh` (512 KiB, for one 64 KiB `--body`) keep their own larger caps.
454
+
455
+ **Unpairing** (the Agents panel's Unpair, `DELETE /api/agents/:id`) is local to the console: it forgets the
456
+ machine and its grants and makes no call to the agent, which keeps its stored token hash and keeps accepting
457
+ the token. To cut that credential off, run `pair --force` on the machine and pair it again from the console
458
+ (the old token stops working once the new code is consumed), or `uninstall`, which removes the token hash. A
459
+ copy of the console's `/state/ac.db` taken before the unpair still holds the old token in clear.
331
460
 
332
461
  ## Uninstall
333
462
 
@@ -345,6 +474,17 @@ the machine's chosen port/name.
345
474
  except for debugging. `stop`/`start` control the registered service (`Stop-`/`Start-ScheduledTask`
346
475
  or `systemctl --user stop/start`) without touching the registration itself.
347
476
 
477
+ **The lock.** `agent.pid` is taken with `O_EXCL`. A pid left by an unclean exit (a Windows logoff or
478
+ reboot runs no shutdown handler) is replaced when it is this process's own pid, is not a valid pid, or
479
+ is not running. It is also replaced when nothing answers `/v1/health` on the configured port with this
480
+ machine's own certificate, which is pid reuse. A start is refused only while that pinned answer names a
481
+ running agent. Both outcomes are written to `agent.log`, as `replaced stale lock (pid N, <why>)` or
482
+ `already running (pid N, port P)`. `GET /v1/health` carries the answering agent's `pid` for this.
483
+
484
+ `stop`, `uninstall` and an install's restart kill only the pid that pinned answer names. When nothing
485
+ answers they kill nothing and remove the stale `agent.pid`, and they print `... nothing was killed` if
486
+ that pid belongs to some other live process.
487
+
348
488
  ## Config and state directories
349
489
 
350
490
  | | Windows | Linux |
@@ -375,7 +515,8 @@ claude_projects_dir = "C:/Users/melody/.claude/projects"
375
515
 
376
516
  Unknown keys are ignored, so a future field never breaks an older agent reading a newer file.
377
517
  `repos_root` is overridable live with `AC_AGENT_REPOS_ROOT` the same way `port` is with
378
- `AC_AGENT_PORT` — absolute paths only; a relative override throws, naming the value.
518
+ `AC_AGENT_PORT` (which, unlike the file's `port`, is not range-checked: it is read as an integer and a value
519
+ it cannot read is ignored) — absolute paths only; a relative override throws, naming the value.
379
520
  `claude_projects_dir` (#52, 52a; default `<homedir>/.claude/projects`) is handled identically, with
380
521
  its own live override `AC_AGENT_CLAUDE_PROJECTS_DIR` — it names the root the `context-pressure` and
381
522
  `subagent-cost` scans read, overridable so a fixture tree or a non-default `CLAUDE_CONFIG_DIR`
@@ -447,8 +588,8 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
447
588
  - **git is run by absolute path** (#699). Each of the three requests resolves git once, on the
448
589
  refreshed PATH (the registry's on Windows, the process PATH elsewhere: the search the preflight and
449
590
  the toolchain upgrade use), and every git spawn of that request names the path it found, made
450
- absolute if PATH gave a relative entry (`exec/claude` does the same for `claude`, the other spawn
451
- whose working directory a request chooses). This matters on Windows, where a bare `git` is looked for in the
591
+ absolute if PATH gave a relative entry (`exec/claude` and `exec/gh` resolve their tool on the same refreshed PATH, #716, and `exec/claude`
592
+ also makes its path absolute, the other spawn whose working directory a request chooses). This matters on Windows, where a bare `git` is looked for in the
452
593
  spawn's working directory first (`git.com`, then `git.exe`), and every call here has a repo checkout
453
594
  as its working directory: a `git.exe` that a clone, a branch or a fast-forward put in the repo's root
454
595
  would otherwise run as the agent's user, unattended (the daily `repo.pull` job reaches it). A bare
@@ -500,9 +641,14 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
500
641
  figure at list rates (never money actually spent — this box runs Claude Code on a
501
642
  subscription). Both read exactly six fields per transcript record (`type`, `subtype` /
502
643
  `attributionAgent`, `timestamp`, `cwd`, `message.{id, model, usage}`); `message.content` --
503
- prompts, tool results, file contents -- is never deserialised, by either scan. The cache lands
644
+ prompts, tool results, file contents -- is parsed in memory with the rest of the line but never read or kept, and never leaves the process, in either scan. The cache lands
504
645
  at `<stateDir>/scans/context-pressure.json` / `<stateDir>/scans/subagent-cost.json`, created on
505
- first write and removed by `uninstall` regardless of `--purge`.
646
+ first write and removed by `uninstall` regardless of `--purge`. Beside each cache sits a
647
+ `<kind>.tables` file holding a digest of the tables its rows were computed from (the context
648
+ windows; the prices and cache multipliers). A cache whose digest differs, or that has none, is
649
+ dropped and every transcript re-parsed, and a row naming an unmapped (context-pressure) or
650
+ unpriced (subagent-cost) model is re-parsed on every scan, so adding the model to the table
651
+ fixes its rows without a manual cache delete.
506
652
  - **`memory-footprint`** (#52, 52b; no custom `timeoutMs` -- a cold run is kilobytes of I/O, not
507
653
  the transcript-scale reads above) is a producer only, computed live on every request and never
508
654
  cached: the fixed 8-layer set Claude Code assembles into a session's preamble (`enterprise`,
@@ -513,7 +659,14 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
513
659
  `repos_root` with a `.git` entry) -- unlike 1.x, which read the console's configured repo list,
514
660
  so the payload's `repo` and `name` fields are both the directory name, never an `owner/name`
515
661
  string. Tokens are a byte estimate (~4 chars/token; the payload says `estimated: true`); only
516
- sizes and line counts ever leave this scan -- file content is never read into the payload.
662
+ sizes and line counts ever leave this scan -- file content is never read into the payload. Every
663
+ layer file is `lstat`ed first: a layer file or the rules directory itself that is a symlink,
664
+ anything that is not a regular file, or a file over 1 MiB is skipped as if absent; inside the rules
665
+ directory, symlinked files and directories are not followed, and the walk goes at most 8
666
+ directories deep, so a hostile checkout cannot point a layer at `/dev/zero`, a FIFO or a huge
667
+ file. Accepted limitation: a skipped layer is silent and reads as absent (the payload has no field
668
+ for it), so a dotfiles-managed `~/.claude/CLAUDE.md` that is a symlink, or any layer file over
669
+ 1 MiB, is undercounted.
517
670
  - **`toolchain-inventory`** (#52, 52b; `timeoutMs: 600_000` -- about seventy version probes plus a
518
671
  WSL probe) ships and runs 1.x's own `Get-ToolchainInventory.ps1` (vendored byte for byte from 1.x
519
672
  `main` at `da0848f`, `data/README.md`) through `pwsh -NoProfile -File <script> -OutFile <state>/scans/
@@ -545,11 +698,14 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
545
698
  (a 404 before anything spawns); `prompt` is a non-empty string, at most 8 KiB (the Windows
546
699
  `CreateProcess` command-line limit is 32,767 characters total, and quoting can double a prompt's
547
700
  own backslashes and quotes — 8 KiB leaves headroom for the fixed flags and the repo path in the
548
- worst case) with no control character other than `\n`/`\t`; `permissionMode` is one of `plan`
701
+ worst case) with no control character other than `\n`/`\t`, and not starting with `-` (`400 prompt must
702
+ not start with '-'`: `claude` would read it as a flag); `permissionMode` is one of `plan`
549
703
  (the default) / `acceptEdits` / `bypassPermissions`; `model` is an optional
550
704
  `^[A-Za-z0-9._-]{1,64}$` string. Any other shape is `400` before anything spawns. `claude` runs as
551
705
  `claude -p <prompt> --permission-mode <mode> --add-dir <repo path> [--model <model>]`, `cwd` the
552
- resolved repo. **At most 4 `claude` jobs run at once** — a 5th concurrent request is
706
+ resolved repo. `claude` is looked up on the refreshed PATH (the registry's on Windows, the process PATH
707
+ elsewhere), and the job runs with that PATH, so a `claude` installed after the agent started is found
708
+ without a restart (#716). **At most 4 `claude` jobs run at once** — a 5th concurrent request is
553
709
  `429 {ok: false, error: "too many running jobs"}`, checked before spawning, not after. Only `claude`
554
710
  jobs count: a running toolchain upgrade (#675) neither uses up a slot nor is blocked by a claude job.
555
711
  - **`GET /v1/jobs`** (`agent.exec.claude`) → `{ok: true, jobs: [view...]}`, newest first.
@@ -573,10 +729,19 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
573
729
  ### `POST /v1/exec/gh`
574
730
 
575
731
  - **`POST /v1/exec/gh`** (`agent.exec.gh`), body `{args: string[]}` → `200 {ok: true, code, stdout,
576
- stderr, truncated}` (stdout/stderr each capped at 1 MiB, `truncated: true` when either was cut). A
577
- non-zero `gh` exit is still a `200` — the caller reads `code`. At most 32 arguments, each at most
578
- 4 KiB with no control characters, except a `--body` value (`--body <text>` or `--body=<text>`),
579
- which may be up to 64 KiB and contain `\n`/`\t` (a PR/issue comment body).
732
+ stderr, truncated}` (stdout/stderr each capped at 1 MiB, `truncated: true` when either was cut or when
733
+ collection stopped at the cap, which also kills `gh`). A non-zero `gh` exit is still a `200` — the
734
+ caller reads `code`; a `gh` that cannot be launched answers `code: 127`. At most 32 arguments, each at
735
+ most 4 KiB with no control characters. Only a `--body` value (`--body <text>` or `--body=<text>`) may
736
+ be longer, up to 64 KiB (24 KiB on Windows, where the command line is limited) and contain `\n`/`\t`
737
+ (a PR/issue comment body); any other flag's value, `--search=<text>` included, is held to the 4 KiB and
738
+ no-control-character rule.
739
+ - **The `gh` child gets an allowlisted environment, not the agent's** (#714): `PATH` (the refreshed PATH `gh` was
740
+ looked up on, #716), the per-OS profile/temp/locale keys, proxy and `SSL_CERT_*` keys, `GH_HOST` and `GH_CONFIG_DIR`, plus
741
+ `GH_PROMPT_DISABLED`, `GH_NO_UPDATE_NOTIFIER` and `NO_COLOR`. `GH_TOKEN`, `GITHUB_TOKEN`,
742
+ `GH_ENTERPRISE_TOKEN` and `GITHUB_ENTERPRISE_TOKEN` are deliberately left out, so `gh` takes its
743
+ credential from `gh auth login` on the machine. A `--jq` program that names the environment
744
+ (`env`, `$ENV`) is refused `400 invalid value for --jq: ...` before anything spawns.
580
745
  - **Every argument is checked against a fixed allowlist before anything spawns** — an unlisted
581
746
  subcommand, flag, or malformed positional is `400 {ok: false, error: "<token and why>"}`, logged as
582
747
  `gh refused: <error>`, additive-only (nothing here is ever loosened without a deliberate change to
@@ -631,12 +796,13 @@ pulling from the console (the console sits behind Cloudflare Access, which an ag
631
796
  Body `{files: [{href, base64}], wanted: string[], pdf: boolean, prune: boolean}` — `files` at most
632
797
  1000 entries, each `href` a relative POSIX path (no empty/`.`/`..` segment, no drive/UNC anchor)
633
798
  ending `.html`, `base64` a valid base64 string, decoded total at most 48 MB (a request body over
634
- ~66 MB is rejected by the route's own raised body-size cap before JSON parsing even runs, `413
799
+ ~66 MB is rejected by the route's own body-size cap (larger than the 16 KiB cap on the pair, grants and update routes) before JSON parsing even runs, `413
635
800
  {ok: false, error: "request body too large"}`); `wanted` an array of such hrefs (every html href the
636
801
  caller currently wants mirrored, not just the changed ones in `files`); `pdf`/`prune` booleans. `409
637
802
  {ok: false, error: "publish target not configured"}` when `publish_dir` is unset (most paired
638
803
  machines — a Linux box has no OneDrive). One run at a time: `409 {ok: false, error: "publish already
639
- running"}`.
804
+ running"}`, which also answers while an earlier request is still sending its body (the guard is taken
805
+ before the body is read, so a client that opens a request and never finishes it holds it for other callers).
640
806
 
641
807
  - **HTML**: each file's bytes are compared against what's on disk (size, then sha256) — unchanged
642
808
  bytes are never rewritten (OneDrive re-uploads every rewrite); a changed or new file is written to
@@ -809,7 +975,7 @@ anything starts; a request that fails is `400 {ok: false, error: "<field>: <why>
809
975
  | Field | Meaning | Must be |
810
976
  |---|---|---|
811
977
  | `tool` | the standard's tool key; the job's label and the audit lines only, never part of a command | `^[A-Za-z0-9][A-Za-z0-9 ._+-]{0,63}$` (words, spaces allowed as in `GitHub CLI`: no `"`, `:`, `(`, `)`, `>` or `,`, and the audit lines print it in quotes, so one run's line cannot imitate another's outcome) |
812
- | `cmd` | the tool whose `--version` is read before and after; found with `which`, run for nothing else | one of **`git`, `gh`, `node`, `claude`** (`VERSION_READABLE_CMDS`, an explicit list): tools the agent's own preflight already runs `--version` on, which are the `cmd`s of the preflight rows the Environment panel sends. The preflight also executes `docker` and `cloudflared` (#828), but those rows are graded only: upgrading either can restart the host's containers or tunnels, so the verb refuses them with the error below (its wording predates #828), and adding a preflight row never widens this list. Any other name is `400 cmd: not a tool this agent reads the version of` |
978
+ | `cmd` | the tool whose `--version` is read before and after; found with `which`, run for nothing else | one of **`git`, `gh`, `node`, `claude`, `docker`, `cloudflared`** (`VERSION_READABLE_CMDS`, an explicit list): tools the agent's own preflight already runs `--version` on, which are the `cmd`s of the preflight rows the Environment panel sends. `docker` and `cloudflared` (#828) are accepted but **deferred** to the next 05:00 America/New_York, because upgrading either can restart the host's containers or tunnels (#978; see **Deferred upgrades** below). Adding a preflight row never widens this list. Any other name is `400 cmd: not a tool this agent reads the version of` |
813
979
  | `manager` | which package manager | `winget` (Windows only), `apt` (Linux only) or `native` |
814
980
  | `packageId` | the package | `winget`: `^[A-Za-z0-9][A-Za-z0-9.+_-]{0,127}$` with a `.` (a `Publisher.Name` id); `apt`: the Debian package-name grammar `^[a-z0-9][a-z0-9+.-]{1,127}$`, not ending in `-`; `native`: exactly `claude` (and `cmd` exactly `claude`) |
815
981
 
@@ -836,7 +1002,7 @@ here). It still installs the new dependencies the upgrade needs (`apt-get(8)`).
836
1002
  The worst a hostile console can do with a valid request is upgrade an installed package the operator
837
1003
  did not mean, with the new dependencies that upgrade needs (never removing a package). Apart from the
838
1004
  package id, the one thing the request names is `cmd`, and it can only pick
839
- one of the four tools above to run `--version` on (never a program of its choosing); it cannot install
1005
+ one of the six tools above to run `--version` on (never a program of its choosing); it cannot install
840
1006
  or run anything else from the request's text. The verb does not tie `cmd` to `packageId`: `cmd` only says
841
1007
  whose version is read, so a request naming `git` with another package's id reports the status of git's
842
1008
  version. The audit lines name `cmd` for that reason. For `apt` the package must also **be an installed
@@ -854,6 +1020,26 @@ counts an upgrade that has passed its check but not yet started its job. A tool
854
1020
  (`winget`, `sudo`, `dpkg-query`, `cmd`) is `502 {ok: false, error: "<name> not found on PATH"}`. A running
855
1021
  claude job does not count against it, and it does not count against the claude cap.
856
1022
 
1023
+ **Deferred upgrades (#978).** When `cmd` is `docker` or `cloudflared` (`DEFERRED_CMDS`), on every
1024
+ platform, the answer is `202 {ok: true, jobId, scheduledFor}` with `scheduledFor` the ISO time of the next
1025
+ **05:00 America/New_York** strictly after the request (a request at 05:00:30 runs at 05:00 the next day).
1026
+ The window is wall-clock time in that zone, so it follows the daylight-saving change; the zone changes its
1027
+ clocks at 02:00, so 05:00 always exists and is never ambiguous. Why: the upgrade restarts the Docker
1028
+ daemon and its containers (the console's included; they come back through `restart: unless-stopped`,
1029
+ `deploy/compose.yaml.example`), or the cloudflared tunnels the console is reached through. The agent runs
1030
+ outside Docker, so it survives both, and it owns the schedule rather than the console. The request-time
1031
+ refusals are the usual ones (`400`, `502`, the cap) and come before any job exists; the version is read
1032
+ at fire time. The job exists at once, in status `running`, with a `scheduled: runs at ...` line, and it
1033
+ holds the one upgrade slot: a second request is `429 {ok: false, error: "an upgrade is already scheduled
1034
+ on this machine for <ISO time>"}`, and `POST /v1/update` is refused (`409 jobs running`) like for any
1035
+ running job. At 05:00 the agent repeats the PATH refresh, both lookups, the version read and, for `apt`,
1036
+ the installed-package check (the machine may have changed overnight); if one now fails, the job finishes
1037
+ `failed` with that reason as its outcome. A machine that slept through the window and wakes more than 90
1038
+ minutes late does not run it then: the job moves to the next 05:00 (job-log line `missed its window;
1039
+ rescheduled`, audit line `toolchain upgrade rescheduled: ...`). The schedule lives in the agent's memory: **an agent restart
1040
+ before 05:00 drops it**, and the `toolchain upgrade scheduled:` audit line is the record. There is no "run
1041
+ now" override and no per-machine time yet (#978, later chunks).
1042
+
857
1043
  **The job.** The answer is `202 {ok: true, jobId}` and the upgrade runs as a job (`kind:
858
1044
  "toolchain-upgrade"`, `repo` the manager, `permissionMode` empty, `label` `<tool> via <manager>
859
1045
  <packageId>`) because `apt` and `winget` outlast the host's 30 s body budget. It is killed after **15
@@ -893,7 +1079,8 @@ answered within 25 seconds is `missing`, and its output is bounded at 64 KiB.
893
1079
  kind's jobs, and one holding only `agent.exec.claude` never reads these):
894
1080
 
895
1081
  - **`GET /v1/toolchain/upgrade/:jobId`** → `{ok: true, job: <view>, outcome?: {status, reason?, before,
896
- after}}`; `outcome` is present once the agent has worked the outcome out, when the command is over (a
1082
+ after}, scheduled?: {runAt, waiting}}`; `scheduled` is present for a deferred job (`waiting: true` until
1083
+ the agent has spawned it, `false` after; #978); `outcome` is present once the agent has worked the outcome out, when the command is over (a
897
1084
  job whose launch threw has none: its request was a `500` that returned no job id). `404 no such job` for
898
1085
  an unknown id or another kind's.
899
1086
  - **`GET /v1/toolchain/upgrade/:jobId/events`** → the same Server-Sent Events as `/v1/jobs/:id/events`
@@ -920,7 +1107,9 @@ desktop (the agent is a logon task in that session) that the agent cannot answer
920
1107
  until it is answered or the 15-minute limit fires (`timed-out`), and a limited `taskkill` cannot end an
921
1108
  elevated installer. Anything else an installer fails with is `failed`, with its last output line that is not closing advice.
922
1109
 
923
- **Audit.** Two lines in the agent log per run: `toolchain upgrade started: "<tool>" via <manager>
1110
+ **Audit.** Two lines in the agent log per run (a deferred one first logs `toolchain
1111
+ upgrade scheduled: "<tool>" via <manager> <packageId> at <ISO time> (version of <cmd>)`, #978):
1112
+ `toolchain upgrade started: "<tool>" via <manager>
924
1113
  <packageId> (version of <cmd>)` when the job starts (so a run the agent does not survive is on record),
925
1114
  and, when it finishes, `toolchain upgrade: "<tool>" via <manager> <packageId>: <before> -> <after> (<status>,
926
1115
  exit <code>, version of <cmd>)`. The tool key is console-supplied, so it is printed in quotes (its
@@ -938,9 +1127,9 @@ ones on a terminal) is written as `\xNN`, and so are U+2028 and U+2029 (which Py
938
1127
  most editors break lines on) and the explicit bidirectional embeddings, overrides and isolates
939
1128
  U+202A-202E and U+2066-2069 (which reorder text), as `\uNNNN`. So a request path cannot forge or split an
940
1129
  audit line, or reorder one with an explicit bidi control; the implicit marks U+200E, U+200F and U+061C
941
- are passed through and can only reorder neutral characters within the line. The log is rotated only when `serve` starts (one previous
942
- file is kept, overwritten at the next rotation), and a failed write is dropped, so a run is on record
943
- for as long as that file is, not for ever.
1130
+ are passed through and can only reorder neutral characters within the line. The log is rotated to `agent.log.1` once it passes 1 MiB, checked when it is opened and on every write (one previous
1131
+ file is kept, overwritten at the next rotation), a logged request path is cut at 200 characters, and a failed write
1132
+ or rotation is dropped rather than failing the request, so a run is on record for as long as that file is, not for ever.
944
1133
 
945
1134
  ### Operator setup
946
1135
 
@@ -1021,6 +1210,26 @@ query` (Windows) or `xdg-mime query` (Linux) is ever run.
1021
1210
  restarted agent holds no answer until its first conclusive detection, so a probe landing in that
1022
1211
  window stores "unknown".
1023
1212
 
1213
+ ## Machine identity (#914)
1214
+
1215
+ The console is moving to treat a machine as its **OS machine id** (#914): a changed id is a different
1216
+ machine. This agent reports the id so the console can recognise a re-pair from the same machine; what the
1217
+ console does with it is a separate change (AM5, #914), not part of this one. The agent reads the id once,
1218
+ at `serve` start:
1219
+
1220
+ - **Linux:** the first non-empty line of `/etc/machine-id`, else of `/var/lib/dbus/machine-id`, if it is 32
1221
+ hex characters (a first-boot `uninitialized` placeholder is not an id).
1222
+ - **Windows:** `MachineGuid` from `reg.exe query HKLM\SOFTWARE\Microsoft\Cryptography /v MachineGuid`
1223
+ (readable without elevation; 5 s limit), if it is a GUID.
1224
+ - **Anything else, or any failure:** no id; the agent reports none and the console decides by certificate
1225
+ as before.
1226
+
1227
+ **Only a hash leaves the machine**: HMAC-SHA256 of the lower-cased id under the key
1228
+ `ac-agent/os-machine-id/v1`, as 64 lowercase hex characters, in the field `osMachineId`. It is answered by
1229
+ `POST /v1/pair` and, to a caller with a valid bearer token only, by `GET /v1/health`; an unauthenticated
1230
+ health caller never sees it. The field is omitted when there is no id. `apiVersion` is unchanged (additive
1231
+ field; an older console ignores it).
1232
+
1024
1233
  ## Preflight
1025
1234
 
1026
1235
  **`GET /v1/preflight`** (#55): control plane, like `PUT /v1/grants` -- bearer-gated, no capability,
@@ -1044,8 +1253,8 @@ The `docker` and `cloudflared` rows (#828) are two of the fleet standard's tools
1044
1253
  the server stacks run in Docker, and cloudflared runs the tunnels that put them behind Cloudflare
1045
1254
  Access. Both are optional: a missing one is amber, never a failed preflight. The standard applies Docker
1046
1255
  to `linux-server` and `windows-dev` hosts and cloudflared to `linux-server` only, and the Environment
1047
- panel grades them there, but never offers to upgrade them: `POST /v1/toolchain/upgrade` refuses both as
1048
- a `cmd`.
1256
+ panel grades them there and offers an Upgrade: `POST /v1/toolchain/upgrade` accepts both as a `cmd` and
1257
+ defers the run to the next 05:00 America/New_York (#978).
1049
1258
 
1050
1259
  Each row is `{cmd, label, status, required, path, why, version, detail}` -- `version` the first
1051
1260
  non-empty line of `--version`'s stdout (CRLF handled), `""` otherwise; `detail` status-dependent
@@ -1068,7 +1277,9 @@ free text (a `broken` row's last non-empty output line, `unauthenticated`'s
1068
1277
  `System32` path) so a tool installed after the agent's logon task started is still seen; on
1069
1278
  Linux the process PATH plus every version-manager bin directory (`fnm`/`nvm`/`volta`/`asdf`) is
1070
1279
  searched as a fallback, so a tool invisible to a bare `which` reads `off-path`, never `missing`.
1071
- `searchPath` on the report names which: `"registry"` or `"process"`.
1280
+ `searchPath` on the report names which: `"registry"` or `"process"`. `exec/claude`, `exec/gh`, the repo
1281
+ verbs and the toolchain upgrade search the same PATH (#716, #699, #675), so a row the panel reads as
1282
+ `ok` is a tool those verbs find.
1072
1283
  - **The whole check answers in a few seconds.** The seven rows run concurrently; the worst case is
1073
1284
  `gh`'s `--version` answering just under 15 s and then `auth status` timing out at 15 s too --
1074
1285
  under the console's own 100 s edge-request budget behind Cloudflare Access.
@@ -1099,6 +1310,11 @@ software is never something a grant could hand to a plugin.
1099
1310
  3. **Downloads the tarball** (60 s timeout) and refuses it over 20 MB or unless its sha512 matches.
1100
1311
  A declared `content-length` over the cap is refused before reading. Without one, the body is read
1101
1312
  in full and then checked, so only the timeout bounds that read.
1313
+ **Trust model:** the sha512 compared is the `dist.integrity` the same registry answered in step 2, so
1314
+ it catches a corrupted or swapped tarball but not a malicious publish. No npm registry signature or
1315
+ provenance is checked, so a hostile `@rackbops/ac-agent` release (a compromised npm account or token)
1316
+ would pass these steps and, once an operator clicks Update, run as the privileged per-machine daemon.
1317
+ That is the same trust as the first `npx @rackbops/ac-agent install`.
1102
1318
  4. **Reads only `package/dist/ac-agent.mjs` and `package/package.json` out of it,** in memory,
1103
1319
  refusing an archive that unpacks past 100 MB. No path from the archive is ever written.
1104
1320
  `package.json` must name the same package and version.
@@ -1142,10 +1358,10 @@ Every refusal, the verified hand-off, and a failed hand-off are logged as
1142
1358
  ## Security model
1143
1359
 
1144
1360
  - **HTTPS only**, with a self-signed certificate generated once at install (RSA 2048, SHA-256,
1145
- 10-year validity; SAN covers the machine's hostname, every non-internal IPv4 address, and
1146
- `127.0.0.1`).
1361
+ 10-year validity; SAN covers the machine's hostname, the machine's LAN-preferred IPv4 addresses
1362
+ (virtual and tunnel interfaces skipped unless nothing else exists), and `127.0.0.1`).
1147
1363
  - **`GET /v1/health` and `POST /v1/pair` are the only unauthenticated routes.** `/v1/health` answers
1148
- `{ok, version, apiVersion, machine, paired, capabilities, grantsRevision, claudeDesktop?}` — enough
1364
+ `{ok, version, apiVersion, machine, paired, capabilities, grantsRevision, claudeDesktop?}` (plus `osMachineId` for a valid bearer only, #914; see **Machine identity**) — enough
1149
1365
  for a caller to discover what it's talking to before pairing, nothing that requires trust.
1150
1366
  `grantsRevision` is `null` before this agent has ever received a `PUT /v1/grants` (or if
1151
1367
  `grants.json` went missing). `claudeDesktop` (present only once the agent holds a conclusive
@@ -1157,9 +1373,9 @@ Every refusal, the verified hand-off, and a failed hand-off are logged as
1157
1373
  - **`POST /v1/toolchain/upgrade` is the one verb that installs software** (#675), so it is behind two
1158
1374
  independent gates (the console's grant, and the machine owner's own `toolchain_upgrade` key in
1159
1375
  `agent.toml`, off by default), upgrades with only three fixed command shapes and a validated package
1160
- id (the one other program a request can name, `cmd`, is limited to four tools whose `--version` the
1161
- preflight already runs: an explicit list, so the preflight's `docker` and `cloudflared` rows are not
1162
- among them), takes its elevation from the machine's own configuration rather than ever
1376
+ id (the one other program a request can name, `cmd`, is limited to six tools whose `--version` the
1377
+ preflight already runs: an explicit list, so a new preflight row is not on it; `docker` and
1378
+ `cloudflared` are, and their upgrade is deferred to 05:00 America/New_York, #978), takes its elevation from the machine's own configuration rather than ever
1163
1379
  elevating itself, and logs every run. See **`POST /v1/toolchain/upgrade`**, including what the Linux
1164
1380
  sudoers rule it needs actually grants.
1165
1381
  - **The agent does not run an executable it finds in a request's working directory** (#699), in two
@@ -1208,9 +1424,9 @@ Every refusal, the verified hand-off, and a failed hand-off are logged as
1208
1424
  **`rejectUnauthorized: false` is not a pin by itself, and pairing it with `checkServerIdentity`
1209
1425
  doesn't make it one:** verified directly against this Node version that `checkServerIdentity` is
1210
1426
  never invoked at all once `rejectUnauthorized` is false — the whole verification pathway is
1211
- skipped, so a "pin" layered on top of it is dead code, not an additional check. (Holding the
1212
- actual certificate PEM in hand — which this package's own CLI does, reading its own on-disk
1213
- `cert.pem` for its `status` self-check — is a different, legitimate case: `ca: <PEM>` with the
1214
- default `rejectUnauthorized: true` validates the chain and keeps `checkServerIdentity` live. A
1215
- pairing console never has that PEM, only the fingerprint, so it needs the explicit-compare shape
1216
- above.)
1427
+ skipped, so a "pin" layered on top of it is dead code, not an additional check. This package's own
1428
+ CLI uses the same explicit-compare shape (`src/pin.ts`, with a 10 s handshake and response budget) for
1429
+ its `status` and install self-checks, although it holds its own `cert.pem`: `ca: <PEM>` with the
1430
+ default `rejectUnauthorized: true` is not used, because a self-signed cert past its `notAfter` fails
1431
+ chain validation (CERT_HAS_EXPIRED) and would report a healthy agent as down. The expiry is
1432
+ `status`'s `certExpiry` to report.