@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.
- package/README.md +268 -52
- package/dist/ac-agent.mjs +57656 -52673
- 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.
|
|
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).
|
|
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:
|
|
156
|
-
|
|
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
|
|
188
|
-
|
|
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
|
|
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,
|
|
286
|
-
|
|
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
|
|
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 —
|
|
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`
|
|
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`
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
577
|
-
non-zero `gh` exit is still a `200` — the
|
|
578
|
-
|
|
579
|
-
|
|
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
|
|
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.
|
|
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
|
|
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}}`; `
|
|
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
|
|
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
|
|
942
|
-
file is kept, overwritten at the next rotation),
|
|
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
|
|
1048
|
-
|
|
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,
|
|
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
|
|
1161
|
-
preflight already runs: an explicit list, so
|
|
1162
|
-
|
|
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.
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
default `rejectUnauthorized: true`
|
|
1215
|
-
|
|
1216
|
-
|
|
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.
|