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

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 +135 -45
  2. package/dist/ac-agent.mjs +30779 -29893
  3. package/package.json +1 -1
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
@@ -258,7 +281,8 @@ Running while serving and a crash propagates as a non-zero exit, visible afterwa
258
281
  scratch tasks with these exact settings, one killed by pid and one exiting on its own, sampled every
259
282
  30 s for 150 s): Task Scheduler's restart-on-failure does not act on the action's exit code for this
260
283
  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
284
+ exit sleeps a minute and runs node again, up to 3 retries in a row (a run that lasted longer than five
285
+ minutes starts the count over, so the budget is per crash loop, not per logon session), since the exit code is already in
262
286
  `wscript.exe`'s own hand; a zero exit (a deliberate shutdown) ends the loop instead. `stop`/`uninstall`
263
287
  stop the task first — ending the loop — before killing the orphaned node by pid, the reverse of the
264
288
  order that would otherwise let the loop see a bare kill as a crash and resurrect the agent a minute
@@ -280,14 +304,17 @@ window is visible at a real logon.
280
304
  npx @rackbops/ac-agent status
281
305
  ```
282
306
 
283
- Prints, as JSON: the config path and whether it came from a file or defaults, the state dir, the
307
+ 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
308
  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
309
+ cert's fingerprint, `certExpiry` (`{validTo, daysLeft, expiringSoon}`, `null` with no readable cert;
310
+ `expiringSoon` is true within 30 days of `notAfter` or after it, and then a `warning: the agent's
311
+ certificate ...` line goes to stderr), the result of a live `GET /v1/health` call against itself (pinned
312
+ to the cert's fingerprint and nothing else: not a system CA and not the validity dates, so an expired
313
+ cert still answers and `certExpiry` is where the expiry shows — see **Security model** below), the currently-held grants
287
314
  (`{revision, plugins: <count>} | null`, from `grants.json` — `null` when it is absent or malformed,
288
315
  the same as a freshly-purged or never-pushed-to agent), the effective `reposRoot` and a live
289
316
  `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`
317
+ `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
318
  when this session's own AppData writes to the resolved config/state dirs are virtualized by a
292
319
  packaged app (see **Run install from a normal shell** above), which also prints a `warning: this
293
320
  session's AppData is virtualized (<packageFamily>); the logon task cannot see <stateDir> -- see
@@ -314,7 +341,7 @@ url: <this machine's URL>
314
341
  fingerprint: <cert fingerprint>
315
342
  ```
316
343
 
317
- The code is 8 characters from an alphabet that excludes ambiguous glyphs (`0`/`O`, `1`/`I`/`L`, easy
344
+ The code is 8 characters from an alphabet that excludes ambiguous glyphs (`0`/`O`, `1`/`I`, easy
318
345
  to misread off a small screen or mishear read aloud) and expires in **10 minutes**; only its hash is
319
346
  ever written to disk (`<stateDir>/pair.json`). Refuses with `already paired -- pass --force to
320
347
  re-pair (mints a fresh code)` if a token is already stored — `--force` starts a fresh pairing
@@ -345,6 +372,17 @@ the machine's chosen port/name.
345
372
  except for debugging. `stop`/`start` control the registered service (`Stop-`/`Start-ScheduledTask`
346
373
  or `systemctl --user stop/start`) without touching the registration itself.
347
374
 
375
+ **The lock.** `agent.pid` is taken with `O_EXCL`. A pid left by an unclean exit (a Windows logoff or
376
+ reboot runs no shutdown handler) is replaced when it is this process's own pid, is not a valid pid, or
377
+ is not running. It is also replaced when nothing answers `/v1/health` on the configured port with this
378
+ machine's own certificate, which is pid reuse. A start is refused only while that pinned answer names a
379
+ running agent. Both outcomes are written to `agent.log`, as `replaced stale lock (pid N, <why>)` or
380
+ `already running (pid N, port P)`. `GET /v1/health` carries the answering agent's `pid` for this.
381
+
382
+ `stop`, `uninstall` and an install's restart kill only the pid that pinned answer names. When nothing
383
+ answers they kill nothing and remove the stale `agent.pid`, and they print `... nothing was killed` if
384
+ that pid belongs to some other live process.
385
+
348
386
  ## Config and state directories
349
387
 
350
388
  | | Windows | Linux |
@@ -375,7 +413,8 @@ claude_projects_dir = "C:/Users/melody/.claude/projects"
375
413
 
376
414
  Unknown keys are ignored, so a future field never breaks an older agent reading a newer file.
377
415
  `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.
416
+ `AC_AGENT_PORT` (which, unlike the file's `port`, is not range-checked: it is read as an integer and a value
417
+ it cannot read is ignored) — absolute paths only; a relative override throws, naming the value.
379
418
  `claude_projects_dir` (#52, 52a; default `<homedir>/.claude/projects`) is handled identically, with
380
419
  its own live override `AC_AGENT_CLAUDE_PROJECTS_DIR` — it names the root the `context-pressure` and
381
420
  `subagent-cost` scans read, overridable so a fixture tree or a non-default `CLAUDE_CONFIG_DIR`
@@ -500,9 +539,14 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
500
539
  figure at list rates (never money actually spent — this box runs Claude Code on a
501
540
  subscription). Both read exactly six fields per transcript record (`type`, `subtype` /
502
541
  `attributionAgent`, `timestamp`, `cwd`, `message.{id, model, usage}`); `message.content` --
503
- prompts, tool results, file contents -- is never deserialised, by either scan. The cache lands
542
+ 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
543
  at `<stateDir>/scans/context-pressure.json` / `<stateDir>/scans/subagent-cost.json`, created on
505
- first write and removed by `uninstall` regardless of `--purge`.
544
+ first write and removed by `uninstall` regardless of `--purge`. Beside each cache sits a
545
+ `<kind>.tables` file holding a digest of the tables its rows were computed from (the context
546
+ windows; the prices and cache multipliers). A cache whose digest differs, or that has none, is
547
+ dropped and every transcript re-parsed, and a row naming an unmapped (context-pressure) or
548
+ unpriced (subagent-cost) model is re-parsed on every scan, so adding the model to the table
549
+ fixes its rows without a manual cache delete.
506
550
  - **`memory-footprint`** (#52, 52b; no custom `timeoutMs` -- a cold run is kilobytes of I/O, not
507
551
  the transcript-scale reads above) is a producer only, computed live on every request and never
508
552
  cached: the fixed 8-layer set Claude Code assembles into a session's preamble (`enterprise`,
@@ -513,7 +557,14 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
513
557
  `repos_root` with a `.git` entry) -- unlike 1.x, which read the console's configured repo list,
514
558
  so the payload's `repo` and `name` fields are both the directory name, never an `owner/name`
515
559
  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.
560
+ sizes and line counts ever leave this scan -- file content is never read into the payload. Every
561
+ layer file is `lstat`ed first: a layer file or the rules directory itself that is a symlink,
562
+ anything that is not a regular file, or a file over 1 MiB is skipped as if absent; inside the rules
563
+ directory, symlinked files and directories are not followed, and the walk goes at most 8
564
+ directories deep, so a hostile checkout cannot point a layer at `/dev/zero`, a FIFO or a huge
565
+ file. Accepted limitation: a skipped layer is silent and reads as absent (the payload has no field
566
+ for it), so a dotfiles-managed `~/.claude/CLAUDE.md` that is a symlink, or any layer file over
567
+ 1 MiB, is undercounted.
517
568
  - **`toolchain-inventory`** (#52, 52b; `timeoutMs: 600_000` -- about seventy version probes plus a
518
569
  WSL probe) ships and runs 1.x's own `Get-ToolchainInventory.ps1` (vendored byte for byte from 1.x
519
570
  `main` at `da0848f`, `data/README.md`) through `pwsh -NoProfile -File <script> -OutFile <state>/scans/
@@ -545,7 +596,8 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
545
596
  (a 404 before anything spawns); `prompt` is a non-empty string, at most 8 KiB (the Windows
546
597
  `CreateProcess` command-line limit is 32,767 characters total, and quoting can double a prompt's
547
598
  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`
599
+ worst case) with no control character other than `\n`/`\t`, and not starting with `-` (`400 prompt must
600
+ not start with '-'`: `claude` would read it as a flag); `permissionMode` is one of `plan`
549
601
  (the default) / `acceptEdits` / `bypassPermissions`; `model` is an optional
550
602
  `^[A-Za-z0-9._-]{1,64}$` string. Any other shape is `400` before anything spawns. `claude` runs as
551
603
  `claude -p <prompt> --permission-mode <mode> --add-dir <repo path> [--model <model>]`, `cwd` the
@@ -573,10 +625,19 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
573
625
  ### `POST /v1/exec/gh`
574
626
 
575
627
  - **`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).
628
+ stderr, truncated}` (stdout/stderr each capped at 1 MiB, `truncated: true` when either was cut or when
629
+ collection stopped at the cap, which also kills `gh`). A non-zero `gh` exit is still a `200` — the
630
+ caller reads `code`; a `gh` that cannot be launched answers `code: 127`. At most 32 arguments, each at
631
+ most 4 KiB with no control characters. Only a `--body` value (`--body <text>` or `--body=<text>`) may
632
+ be longer, up to 64 KiB (24 KiB on Windows, where the command line is limited) and contain `\n`/`\t`
633
+ (a PR/issue comment body); any other flag's value, `--search=<text>` included, is held to the 4 KiB and
634
+ no-control-character rule.
635
+ - **The `gh` child gets an allowlisted environment, not the agent's** (#714): `PATH`, the per-OS
636
+ profile/temp/locale keys, proxy and `SSL_CERT_*` keys, `GH_HOST` and `GH_CONFIG_DIR`, plus
637
+ `GH_PROMPT_DISABLED`, `GH_NO_UPDATE_NOTIFIER` and `NO_COLOR`. `GH_TOKEN`, `GITHUB_TOKEN`,
638
+ `GH_ENTERPRISE_TOKEN` and `GITHUB_ENTERPRISE_TOKEN` are deliberately left out, so `gh` takes its
639
+ credential from `gh auth login` on the machine. A `--jq` program that names the environment
640
+ (`env`, `$ENV`) is refused `400 invalid value for --jq: ...` before anything spawns.
580
641
  - **Every argument is checked against a fixed allowlist before anything spawns** — an unlisted
581
642
  subcommand, flag, or malformed positional is `400 {ok: false, error: "<token and why>"}`, logged as
582
643
  `gh refused: <error>`, additive-only (nothing here is ever loosened without a deliberate change to
@@ -631,12 +692,13 @@ pulling from the console (the console sits behind Cloudflare Access, which an ag
631
692
  Body `{files: [{href, base64}], wanted: string[], pdf: boolean, prune: boolean}` — `files` at most
632
693
  1000 entries, each `href` a relative POSIX path (no empty/`.`/`..` segment, no drive/UNC anchor)
633
694
  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
695
+ ~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
696
  {ok: false, error: "request body too large"}`); `wanted` an array of such hrefs (every html href the
636
697
  caller currently wants mirrored, not just the changed ones in `files`); `pdf`/`prune` booleans. `409
637
698
  {ok: false, error: "publish target not configured"}` when `publish_dir` is unset (most paired
638
699
  machines — a Linux box has no OneDrive). One run at a time: `409 {ok: false, error: "publish already
639
- running"}`.
700
+ running"}`, which also answers while an earlier request is still sending its body (the guard is taken
701
+ before the body is read, so a client that opens a request and never finishes it holds it for other callers).
640
702
 
641
703
  - **HTML**: each file's bytes are compared against what's on disk (size, then sha256) — unchanged
642
704
  bytes are never rewritten (OneDrive re-uploads every rewrite); a changed or new file is written to
@@ -809,7 +871,7 @@ anything starts; a request that fails is `400 {ok: false, error: "<field>: <why>
809
871
  | Field | Meaning | Must be |
810
872
  |---|---|---|
811
873
  | `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` |
874
+ | `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
875
  | `manager` | which package manager | `winget` (Windows only), `apt` (Linux only) or `native` |
814
876
  | `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
877
 
@@ -836,7 +898,7 @@ here). It still installs the new dependencies the upgrade needs (`apt-get(8)`).
836
898
  The worst a hostile console can do with a valid request is upgrade an installed package the operator
837
899
  did not mean, with the new dependencies that upgrade needs (never removing a package). Apart from the
838
900
  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
901
+ one of the six tools above to run `--version` on (never a program of its choosing); it cannot install
840
902
  or run anything else from the request's text. The verb does not tie `cmd` to `packageId`: `cmd` only says
841
903
  whose version is read, so a request naming `git` with another package's id reports the status of git's
842
904
  version. The audit lines name `cmd` for that reason. For `apt` the package must also **be an installed
@@ -854,6 +916,26 @@ counts an upgrade that has passed its check but not yet started its job. A tool
854
916
  (`winget`, `sudo`, `dpkg-query`, `cmd`) is `502 {ok: false, error: "<name> not found on PATH"}`. A running
855
917
  claude job does not count against it, and it does not count against the claude cap.
856
918
 
919
+ **Deferred upgrades (#978).** When `cmd` is `docker` or `cloudflared` (`DEFERRED_CMDS`), on every
920
+ platform, the answer is `202 {ok: true, jobId, scheduledFor}` with `scheduledFor` the ISO time of the next
921
+ **05:00 America/New_York** strictly after the request (a request at 05:00:30 runs at 05:00 the next day).
922
+ The window is wall-clock time in that zone, so it follows the daylight-saving change; the zone changes its
923
+ clocks at 02:00, so 05:00 always exists and is never ambiguous. Why: the upgrade restarts the Docker
924
+ daemon and its containers (the console's included; they come back through `restart: unless-stopped`,
925
+ `deploy/compose.yaml.example`), or the cloudflared tunnels the console is reached through. The agent runs
926
+ outside Docker, so it survives both, and it owns the schedule rather than the console. The request-time
927
+ refusals are the usual ones (`400`, `502`, the cap) and come before any job exists; the version is read
928
+ at fire time. The job exists at once, in status `running`, with a `scheduled: runs at ...` line, and it
929
+ holds the one upgrade slot: a second request is `429 {ok: false, error: "an upgrade is already scheduled
930
+ on this machine for <ISO time>"}`, and `POST /v1/update` is refused (`409 jobs running`) like for any
931
+ running job. At 05:00 the agent repeats the PATH refresh, both lookups, the version read and, for `apt`,
932
+ the installed-package check (the machine may have changed overnight); if one now fails, the job finishes
933
+ `failed` with that reason as its outcome. A machine that slept through the window and wakes more than 90
934
+ minutes late does not run it then: the job moves to the next 05:00 (job-log line `missed its window;
935
+ rescheduled`, audit line `toolchain upgrade rescheduled: ...`). The schedule lives in the agent's memory: **an agent restart
936
+ before 05:00 drops it**, and the `toolchain upgrade scheduled:` audit line is the record. There is no "run
937
+ now" override and no per-machine time yet (#978, later chunks).
938
+
857
939
  **The job.** The answer is `202 {ok: true, jobId}` and the upgrade runs as a job (`kind:
858
940
  "toolchain-upgrade"`, `repo` the manager, `permissionMode` empty, `label` `<tool> via <manager>
859
941
  <packageId>`) because `apt` and `winget` outlast the host's 30 s body budget. It is killed after **15
@@ -893,7 +975,8 @@ answered within 25 seconds is `missing`, and its output is bounded at 64 KiB.
893
975
  kind's jobs, and one holding only `agent.exec.claude` never reads these):
894
976
 
895
977
  - **`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
978
+ after}, scheduled?: {runAt, waiting}}`; `scheduled` is present for a deferred job (`waiting: true` until
979
+ 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
980
  job whose launch threw has none: its request was a `500` that returned no job id). `404 no such job` for
898
981
  an unknown id or another kind's.
899
982
  - **`GET /v1/toolchain/upgrade/:jobId/events`** → the same Server-Sent Events as `/v1/jobs/:id/events`
@@ -920,7 +1003,9 @@ desktop (the agent is a logon task in that session) that the agent cannot answer
920
1003
  until it is answered or the 15-minute limit fires (`timed-out`), and a limited `taskkill` cannot end an
921
1004
  elevated installer. Anything else an installer fails with is `failed`, with its last output line that is not closing advice.
922
1005
 
923
- **Audit.** Two lines in the agent log per run: `toolchain upgrade started: "<tool>" via <manager>
1006
+ **Audit.** Two lines in the agent log per run (a deferred one first logs `toolchain
1007
+ upgrade scheduled: "<tool>" via <manager> <packageId> at <ISO time> (version of <cmd>)`, #978):
1008
+ `toolchain upgrade started: "<tool>" via <manager>
924
1009
  <packageId> (version of <cmd>)` when the job starts (so a run the agent does not survive is on record),
925
1010
  and, when it finishes, `toolchain upgrade: "<tool>" via <manager> <packageId>: <before> -> <after> (<status>,
926
1011
  exit <code>, version of <cmd>)`. The tool key is console-supplied, so it is printed in quotes (its
@@ -938,9 +1023,9 @@ ones on a terminal) is written as `\xNN`, and so are U+2028 and U+2029 (which Py
938
1023
  most editors break lines on) and the explicit bidirectional embeddings, overrides and isolates
939
1024
  U+202A-202E and U+2066-2069 (which reorder text), as `\uNNNN`. So a request path cannot forge or split an
940
1025
  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.
1026
+ 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
1027
+ file is kept, overwritten at the next rotation), a logged request path is cut at 200 characters, and a failed write
1028
+ 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
1029
 
945
1030
  ### Operator setup
946
1031
 
@@ -1044,8 +1129,8 @@ The `docker` and `cloudflared` rows (#828) are two of the fleet standard's tools
1044
1129
  the server stacks run in Docker, and cloudflared runs the tunnels that put them behind Cloudflare
1045
1130
  Access. Both are optional: a missing one is amber, never a failed preflight. The standard applies Docker
1046
1131
  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`.
1132
+ panel grades them there and offers an Upgrade: `POST /v1/toolchain/upgrade` accepts both as a `cmd` and
1133
+ defers the run to the next 05:00 America/New_York (#978).
1049
1134
 
1050
1135
  Each row is `{cmd, label, status, required, path, why, version, detail}` -- `version` the first
1051
1136
  non-empty line of `--version`'s stdout (CRLF handled), `""` otherwise; `detail` status-dependent
@@ -1099,6 +1184,11 @@ software is never something a grant could hand to a plugin.
1099
1184
  3. **Downloads the tarball** (60 s timeout) and refuses it over 20 MB or unless its sha512 matches.
1100
1185
  A declared `content-length` over the cap is refused before reading. Without one, the body is read
1101
1186
  in full and then checked, so only the timeout bounds that read.
1187
+ **Trust model:** the sha512 compared is the `dist.integrity` the same registry answered in step 2, so
1188
+ it catches a corrupted or swapped tarball but not a malicious publish. No npm registry signature or
1189
+ provenance is checked, so a hostile `@rackbops/ac-agent` release (a compromised npm account or token)
1190
+ would pass these steps and, once an operator clicks Update, run as the privileged per-machine daemon.
1191
+ That is the same trust as the first `npx @rackbops/ac-agent install`.
1102
1192
  4. **Reads only `package/dist/ac-agent.mjs` and `package/package.json` out of it,** in memory,
1103
1193
  refusing an archive that unpacks past 100 MB. No path from the archive is ever written.
1104
1194
  `package.json` must name the same package and version.
@@ -1142,8 +1232,8 @@ Every refusal, the verified hand-off, and a failed hand-off are logged as
1142
1232
  ## Security model
1143
1233
 
1144
1234
  - **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`).
1235
+ 10-year validity; SAN covers the machine's hostname, the machine's LAN-preferred IPv4 addresses
1236
+ (virtual and tunnel interfaces skipped unless nothing else exists), and `127.0.0.1`).
1147
1237
  - **`GET /v1/health` and `POST /v1/pair` are the only unauthenticated routes.** `/v1/health` answers
1148
1238
  `{ok, version, apiVersion, machine, paired, capabilities, grantsRevision, claudeDesktop?}` — enough
1149
1239
  for a caller to discover what it's talking to before pairing, nothing that requires trust.
@@ -1157,9 +1247,9 @@ Every refusal, the verified hand-off, and a failed hand-off are logged as
1157
1247
  - **`POST /v1/toolchain/upgrade` is the one verb that installs software** (#675), so it is behind two
1158
1248
  independent gates (the console's grant, and the machine owner's own `toolchain_upgrade` key in
1159
1249
  `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
1250
+ id (the one other program a request can name, `cmd`, is limited to six tools whose `--version` the
1251
+ preflight already runs: an explicit list, so a new preflight row is not on it; `docker` and
1252
+ `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
1253
  elevating itself, and logs every run. See **`POST /v1/toolchain/upgrade`**, including what the Linux
1164
1254
  sudoers rule it needs actually grants.
1165
1255
  - **The agent does not run an executable it finds in a request's working directory** (#699), in two
@@ -1208,9 +1298,9 @@ Every refusal, the verified hand-off, and a failed hand-off are logged as
1208
1298
  **`rejectUnauthorized: false` is not a pin by itself, and pairing it with `checkServerIdentity`
1209
1299
  doesn't make it one:** verified directly against this Node version that `checkServerIdentity` is
1210
1300
  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.)
1301
+ skipped, so a "pin" layered on top of it is dead code, not an additional check. This package's own
1302
+ CLI uses the same explicit-compare shape (`src/pin.ts`, with a 10 s handshake and response budget) for
1303
+ its `status` and install self-checks, although it holds its own `cert.pem`: `ca: <PEM>` with the
1304
+ default `rejectUnauthorized: true` is not used, because a self-signed cert past its `notAfter` fails
1305
+ chain validation (CERT_HAS_EXPIRED) and would report a healthy agent as down. The expiry is
1306
+ `status`'s `certExpiry` to report.