@rackbops/ac-agent 2.0.0-alpha.52 → 2.0.0-alpha.54

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 CHANGED
@@ -272,7 +272,12 @@ packaged app (see **Run install from a normal shell** above), which also prints
272
272
  session's AppData is virtualized (<packageFamily>); the logon task cannot see <stateDir> -- see
273
273
  README, Install` line to stderr. `serve` logs the same warning line once at startup, in the same
274
274
  circumstance, without refusing to start. `update` (#659) is the last `install --update`'s record from
275
- `update.json`, or `null` when there is none or it is unreadable.
275
+ `update.json`, or `null` when there is none or it is unreadable. `toolchainUpgrade: true/false` (#675) is
276
+ the machine owner's own opt-in to `POST /v1/toolchain/upgrade` (`toolchain_upgrade` in `agent.toml`, or
277
+ `AC_AGENT_TOOLCHAIN_UPGRADE`), `false` by default. Like the other fields it is what `status` loads from
278
+ `agent.toml` and the environment of the shell that ran `status`: what a start from that file and shell
279
+ would load, not what the running agent holds (it read its config once, at start, and an installed
280
+ task/unit does not see that shell's variables).
276
281
 
277
282
  ## Pair
278
283
 
@@ -368,6 +373,22 @@ written (as `publish_dir`'s is, so a typo in an optional key can never stop `ser
368
373
  path` (or a `preflight-unusable` warning naming `tunnel_preflight`). A blank or non-string value in the
369
374
  file is treated as unset. `install` takes no flag for either and `status` does not report them.
370
375
 
376
+ `toolchain_upgrade` (#675; `false` unless set) is **the machine owner's own opt-in** to `POST
377
+ /v1/toolchain/upgrade`, the verb that installs software here (see **`POST /v1/toolchain/upgrade`**).
378
+ It is a TOML boolean, written to `agent.toml` only when `true`; only a boolean `true` counts (a quoted
379
+ string, a number or a misspelled key is `false`; an unquoted word that is not a TOML boolean, such as
380
+ `True` or `yes`, is invalid TOML and fails the whole config load). Live override
381
+ `AC_AGENT_TOOLCHAIN_UPGRADE`: `1`/`true` or `0`/`false` (case-insensitive); blank is unset; anything
382
+ else throws at load, naming the value. **The override only reaches the process that reads it**: a
383
+ foreground `serve` started from that shell, or a unit/task whose own environment sets it (the
384
+ installed task and unit do not see the shell's variables, as the paragraph above explains). A fresh `install` run with it
385
+ set writes `toolchain_upgrade = true` into the new `agent.toml`, where it then stays. Like every key it
386
+ is read once when the agent starts, so a change needs `ac-agent stop` and `start` (or a restart of the
387
+ task/unit). `status` prints `toolchainUpgrade: true|false`: what a start from `agent.toml` and the
388
+ shell's own environment would load, not what the running agent holds, so after a change restart first
389
+ and treat the agent's `403 not-opted-in` as the truth. The console's grant alone never turns it on: the
390
+ verb answers `403 not-opted-in` until this key is set.
391
+
371
392
  ## Verbs
372
393
 
373
394
  Everything under `/v1/*` except `/v1/health` and `/v1/pair` requires pairing (see **Security
@@ -375,7 +396,8 @@ model**). The verb registry fills `GET /v1/health`'s `capabilities` field with t
375
396
  list of every registered verb's capability name — with every verb group registered, that's
376
397
  `["agent.desktop.open", "agent.exec.claude", "agent.exec.gh", "agent.git.pull", "agent.git.read",
377
398
  "agent.publish.mirror", "agent.scan.context-pressure", "agent.scan.memory-footprint",
378
- "agent.scan.subagent-cost", "agent.scan.toolchain-inventory", "agent.tunnel.lint"]`.
399
+ "agent.scan.subagent-cost", "agent.scan.toolchain-inventory", "agent.toolchain.upgrade",
400
+ "agent.tunnel.lint"]`.
379
401
 
380
402
  **Every verb also requires two headers, checked in this order** (#50), after the bearer check
381
403
  passes: `X-AC-Plugin` (the calling plugin's id) missing or shaped wrong is `400 {ok: false, error:
@@ -457,8 +479,8 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
457
479
  string. Tokens are a byte estimate (~4 chars/token; the payload says `estimated: true`); only
458
480
  sizes and line counts ever leave this scan -- file content is never read into the payload.
459
481
  - **`toolchain-inventory`** (#52, 52b; `timeoutMs: 600_000` -- about seventy version probes plus a
460
- WSL probe) ships and runs 1.x's own `Get-ToolchainInventory.ps1` (vendored byte-for-byte,
461
- `data/README.md`) through `pwsh -NoProfile -File <script> -OutFile <state>/scans/
482
+ WSL probe) ships and runs 1.x's own `Get-ToolchainInventory.ps1` (vendored from 1.x `main` at `8e33836`
483
+ plus the six lines #673 added, `data/README.md`) through `pwsh -NoProfile -File <script> -OutFile <state>/scans/
462
484
  toolchain-inventory-<hostname>.md -NoRender`, with the child's `cwd` set to `<state>/scans` --
463
485
  **never a project directory**, since the script's own `pnpm --version` probe rewrites
464
486
  `pnpm-lock.yaml` in whatever directory it runs in. **Windows-only**: on any other platform this
@@ -492,14 +514,18 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
492
514
  `^[A-Za-z0-9._-]{1,64}$` string. Any other shape is `400` before anything spawns. `claude` runs as
493
515
  `claude -p <prompt> --permission-mode <mode> --add-dir <repo path> [--model <model>]`, `cwd` the
494
516
  resolved repo. **At most 4 `claude` jobs run at once** — a 5th concurrent request is
495
- `429 {ok: false, error: "too many running jobs"}`, checked before spawning, not after.
517
+ `429 {ok: false, error: "too many running jobs"}`, checked before spawning, not after. Only `claude`
518
+ jobs count: a running toolchain upgrade (#675) neither uses up a slot nor is blocked by a claude job.
496
519
  - **`GET /v1/jobs`** (`agent.exec.claude`) → `{ok: true, jobs: [view...]}`, newest first.
497
520
  **`GET /v1/jobs/:id`** → `{ok: true, job: view}` or `404` for an unknown id. A job view is
498
521
  `{id, kind: "claude", label, status: "running"|"done"|"failed", startedAt, endedAt?,
499
522
  exitCode?: number|null, repo, permissionMode}`. **`POST /v1/jobs/clear-finished`** →
500
- `{ok: true, cleared}`, dropping every done/failed job (running ones are kept).
523
+ `{ok: true, cleared}`, dropping every done/failed job (running ones are kept). **These routes serve
524
+ `claude` jobs only (#675):** the registry also holds `toolchain-upgrade` jobs, which a plugin holding
525
+ only `agent.exec.claude` must not list, read, stream or clear, so another kind's job id is `404 no such
526
+ job` here, exactly like an unknown one, and `clear-finished` leaves them alone.
501
527
  - **`GET /v1/jobs/:id/events`** (`agent.exec.claude`) streams a job's log as Server-Sent Events: a
502
- `404` for an unknown id up front, otherwise a finished job replays its whole buffer and closes,
528
+ `404` for an unknown id (or another kind's) up front, otherwise a finished job replays its whole buffer and closes,
503
529
  and a running job replays its buffer then streams new lines live as they arrive, closing once the
504
530
  job finishes. Two frame shapes only: `event: line` with `data` the JSON-encoded line text and `id`
505
531
  the line's absolute position in the job's log (so a reconnecting client can tell whether it missed
@@ -726,6 +752,197 @@ the preflight's own finding messages are passed through as the script wrote them
726
752
  <engine> ok=<bool> errors=<n> warns=<n> hostnames=<n>` or `tunnel lint: unavailable (<reason>)`, with
727
753
  any line break in a path flattened to a space.
728
754
 
755
+ ## `POST /v1/toolchain/upgrade` (#675)
756
+
757
+ **`POST /v1/toolchain/upgrade`** (`agent.toolchain.upgrade`) upgrades one tool on this machine with
758
+ the package manager's own command, so the Environment panel can bring a machine that is behind the
759
+ fleet standard back to it. **It is the one verb that installs software here**, so it is built so that
760
+ the console can ask for exactly one kind of thing and nothing else.
761
+
762
+ **Two gates, both required.** The console's grant for `agent.toolchain.upgrade` (the ordinary guard,
763
+ see **Verbs**), then this machine's own opt-in, `toolchain_upgrade = true` in `agent.toml` (see
764
+ **Config and state directories**): without it the answer is `403 {ok: false, error: "toolchain upgrades
765
+ are not enabled on this machine (agent.toml toolchain_upgrade)", reason: "not-opted-in"}`, and it is
766
+ given before the body is even read, so a machine that has not opted in does no work for a request. A body
767
+ over **4096 bytes** is `413 {ok: false, error: "request body too large"}` (a valid one is a few hundred),
768
+ by header and as it streams, before it is parsed.
769
+
770
+ **The request** is `{tool, cmd, manager, packageId}`. Every field is checked against a grammar before
771
+ anything starts; a request that fails is `400 {ok: false, error: "<field>: <why>"}`.
772
+
773
+ | Field | Meaning | Must be |
774
+ |---|---|---|
775
+ | `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) |
776
+ | `cmd` | the tool whose `--version` is read before and after; found with `which`, run for nothing else | one of **`git`, `gh`, `node`, `claude`**: the tools the agent's own preflight already runs `--version` on (`PREREQ_TABLE` minus `chromium`, which it never executes), which are the `cmd`s of the preflight rows the Environment panel sends. Any other name is `400 cmd: not a tool this agent reads the version of` |
777
+ | `manager` | which package manager | `winget` (Windows only), `apt` (Linux only) or `native` |
778
+ | `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`) |
779
+
780
+ A field is read only as the body's own property. An `apt` name ending in `-` is refused because
781
+ `apt-get` reads a trailing `-` on a package argument as "remove it".
782
+
783
+ **The command is never built from free text.** There are three fixed shapes, and the package id is the only
784
+ variable in them:
785
+
786
+ - `winget upgrade --id <id> --exact --silent --accept-package-agreements --accept-source-agreements
787
+ --disable-interactivity`
788
+ - `sudo -n apt-get install --only-upgrade -y --no-remove <package>`
789
+ - `claude update` (the native Claude Code updater; a `claude` that is only an npm `.cmd` shim is
790
+ `502 claude is a .cmd shim; install the native binary`)
791
+
792
+ `--no-remove` is a tightening of the shape on top of `-y` (`apt-get(8)`: "If any packages are to be
793
+ removed apt-get immediately aborts without prompting"). For `-y`, `apt-get(8)` says: "If an undesirable
794
+ situation, such as changing a held package, trying to install an unauthenticated package or removing an
795
+ essential package occurs then apt-get will abort." Removing a package that is not essential is not on
796
+ that list, so an upgrade whose new version Conflicts with or Breaks another installed package could,
797
+ as far as the manual says, remove that package without asking (an inference from the manual; not run
798
+ here). It still installs the new dependencies the upgrade needs (`apt-get(8)`).
799
+
800
+ The worst a hostile console can do with a valid request is upgrade an installed package the operator
801
+ did not mean, with the new dependencies that upgrade needs (never removing a package). Apart from the
802
+ package id, the one thing the request names is `cmd`, and it can only pick
803
+ one of the four tools above to run `--version` on (never a program of its choosing); it cannot install
804
+ or run anything else from the request's text. The verb does not tie `cmd` to `packageId`: `cmd` only says
805
+ whose version is read, so a request naming `git` with another package's id reports the status of git's
806
+ version. The audit lines name `cmd` for that reason. For `apt` the package must also **be an installed
807
+ package, exactly**: just before the upgrade starts (after the `before` version read, so a package removed
808
+ in between is caught), the agent runs `dpkg-query --show` on
809
+ the literal name and refuses (`400 packageId: <name> is not an installed package`) unless it reports that
810
+ name as installed. This matters because `apt-get` falls back to treating a package argument that matches
811
+ nothing as a POSIX regular expression (`apt-get(8)`: "applied to all package names in the database"),
812
+ which with `--only-upgrade` would upgrade every installed package it matches.
813
+
814
+ **One at a time.** A second request while one runs is `429 {ok: false, error: "an upgrade is already
815
+ running on this machine"}`; the cap check and the reservation of the slot are one synchronous step, so
816
+ two simultaneous requests cannot both pass, and the self-update refusal (`/v1/update`, `409 jobs running`)
817
+ counts an upgrade that has passed its check but not yet started its job. A tool that cannot be found
818
+ (`winget`, `sudo`, `dpkg-query`, `cmd`) is `502 {ok: false, error: "<name> not found on PATH"}`. A running
819
+ claude job does not count against it, and it does not count against the claude cap.
820
+
821
+ **The job.** The answer is `202 {ok: true, jobId}` and the upgrade runs as a job (`kind:
822
+ "toolchain-upgrade"`, `repo` the manager, `permissionMode` empty, `label` `<tool> via <manager>
823
+ <packageId>`) because `apt` and `winget` outlast the host's 30 s body budget. It is killed after **15
824
+ minutes** (on Windows with its whole process tree, since winget's work runs in child installers) or once
825
+ it has printed **1 MiB** (counting a line that never ends); the output cap and the timeout both end it as
826
+ `failed` or `timed-out`, with a log line (`[timed out after N ms; killed]`, `[output capped at N
827
+ characters; killed]`). A child's `close` event waits for every holder of its output pipes, so a
828
+ process it started (an `apt-get` under `sudo`, an installer's helper) that keeps them open would hold the
829
+ job `running` after the command is over, and the one upgrade slot with it. Two bounds stop that. **A
830
+ command that has exited** keeps its real exit code (the code a kill left, `null` for a signal): its output
831
+ is given 5 seconds to close, then the job finishes as the exit code says, with the line `[the command
832
+ exited, but its output never closed; processes it started may still be running]`. The timeout and the
833
+ output cap are never applied to a command that has already exited (its process id may by then be another
834
+ process's, and `taskkill /t /f` on it would take that one and everything it started): a cap crossed by a
835
+ descendant's output after the exit is recorded (`failed`; the log line `[output capped at N characters;
836
+ killed]` is printed as always although there was nothing left to kill) and the rest dropped. **A command
837
+ that was killed (a limit) and never even exits** is finished 15 seconds after the kill with a null exit
838
+ code and the line `[killed, but its output never closed; processes it started may still be running]`; one
839
+ that does exit meanwhile is finished at the earlier of 5 seconds after its exit and 15 seconds after the
840
+ kill, as above. In both cases the agent's end of the pipes is **drained, not closed**: closing the read
841
+ end makes the next write of whoever still holds the pipe raise SIGPIPE, which by default ends it, and
842
+ these are processes the agent must not disturb (a root-owned `apt-get` in the middle of a dpkg
843
+ transaction, a service a maintainer script started). What they write from then on is discarded, up to
844
+ 64 MiB: past that the pipes are closed after all, because draining a flood costs the agent most of a
845
+ core for as long as the writer runs, and closing ends only that writer. The agent cannot signal a
846
+ process it does not own either way. The job then finishes after the outcome is computed (a PATH refresh, up to about 10
847
+ seconds on Windows, and the `after` version read, up to about 25), and that is when the slot is free. The
848
+ log is: the exact command as its first line, then `before: <version|missing>`,
849
+ the command's own output, then `after: <version|missing>` and `result: <status>` (with ` (<reason>)` when
850
+ there is one). The trailer is the agent's; a child can print its own `result:` line earlier in the log, so
851
+ read the outcome from the status route, never by scanning the log. `before` and `after` are the first line of
852
+ `<cmd> --version`, read with the PATH refreshed from the registry on Windows (the preflight's own search)
853
+ so a tool the upgrade just installed is seen; a read that exits non-zero, prints nothing, or has not
854
+ answered within 25 seconds is `missing`, and its output is bounded at 64 KiB.
855
+
856
+ **Routes, all under `agent.toolchain.upgrade`** (a plugin holding only this capability reads only this
857
+ kind's jobs, and one holding only `agent.exec.claude` never reads these):
858
+
859
+ - **`GET /v1/toolchain/upgrade/:jobId`** → `{ok: true, job: <view>, outcome?: {status, reason?, before,
860
+ after}}`; `outcome` is present once the agent has worked the outcome out, when the command is over (a
861
+ job whose launch threw has none: its request was a `500` that returned no job id). `404 no such job` for
862
+ an unknown id or another kind's.
863
+ - **`GET /v1/toolchain/upgrade/:jobId/events`** → the same Server-Sent Events as `/v1/jobs/:id/events`
864
+ (`event: line`, then `event: done` with the exit code), `404` for another kind's job.
865
+
866
+ **Outcomes** (the job's `status` is `done` for the first two and `failed` for the rest):
867
+
868
+ | `status` | When | Its `reason` |
869
+ |---|---|---|
870
+ | `upgraded` | exit 0 and the version changed (apt: `N upgraded` with `N` of 1 or more also counts) | none |
871
+ | `already-current` | exit 0 and the version is unchanged; winget's `0x8A15002B` (no applicable update) also maps here, and means "no newer version this machine can install", which is not always the same as "current" | for the winget code |
872
+ | `needs-sudoers` | `apt`: `sudo -n` refused, in sudo 1.9's wording (`a password is required`, `is not in the sudoers file`, `is not allowed to execute`, `is not allowed to run sudo on`, `user <u> may not run sudo on <host>`) or sudo-rs's, the default `sudo` of recent Ubuntu (`interactive authentication is required`, `I'm afraid I can't do that`, `user <u> may not run <command> on <host>`) | the sudoers rule to add, with what it grants |
873
+ | `needs-elevation` | `winget`: its `0x8A150019` return code (command requires administrator privileges) | the command to paste into an elevated terminal |
874
+ | `failed` | any other non-zero exit (the last output line, printable and cut at 300 characters), winget's `0x8A150014` (no installed package with that id), the output cap, a version that cannot be read after an exit 0, a command that could not start | what failed |
875
+ | `timed-out` | the 15-minute limit | check the package before retrying: it may be half-installed |
876
+
877
+ The winget return codes are pinned by number from winget's own generated table
878
+ (`microsoft/winget-cli`, `doc/windows/package-manager/winget/returnCodes.md`); Node reports a Windows exit
879
+ code unsigned, so the job's `exitCode` for `0x8A15002B` is `2316632107`. Only `0x8A150019` is documented as
880
+ a "needs administrator" code, and it is a command-level one. **What winget does for a machine-scope
881
+ package, or an installer that elevates itself, when its caller is not elevated has not been verified
882
+ here**: it may relaunch the installer through UAC, which puts a consent prompt on the logon user's
883
+ desktop (the agent is a logon task in that session) that the agent cannot answer; the job then waits
884
+ until it is answered or the 15-minute limit fires (`timed-out`), and a limited `taskkill` cannot end an
885
+ elevated installer. Anything else an installer fails with is `failed`, with its last output line.
886
+
887
+ **Audit.** Two lines in the agent log per run: `toolchain upgrade started: "<tool>" via <manager>
888
+ <packageId> (version of <cmd>)` when the job starts (so a run the agent does not survive is on record),
889
+ and, when it finishes, `toolchain upgrade: "<tool>" via <manager> <packageId>: <before> -> <after> (<status>,
890
+ exit <code>, version of <cmd>)`. The tool key is console-supplied, so it is printed in quotes (its
891
+ grammar has none); `cmd` is named because the versions are `cmd`'s, which the verb does not tie to the
892
+ package id. `exit none` means the command left no exit code: it could not be started, or it was killed
893
+ (by the timeout, the output cap or a signal, which on Linux closes with no code). A request that throws
894
+ while starting (before or after its job exists) logs `toolchain upgrade: request failed: <message>` and
895
+ answers `500 {ok: false, error: "the upgrade could not be started"}` (the job, if one was created, is
896
+ finished `failed`); an unparseable body is `400 {ok: false, error: "invalid JSON body"}`. The console
897
+ records the click on its side. These lines are only as trustworthy as the log they are in, so **every
898
+ `agent.log` entry is one line**: any control character in an entry (the request line carries the
899
+ request's own path, percent-decoded and chosen by whoever calls, with or without the bearer token, so
900
+ `%0A` would otherwise start a line of the caller's own wording, and an escape sequence would erase real
901
+ ones on a terminal) is written as `\xNN`, and so are U+2028 and U+2029 (which Python's `splitlines` and
902
+ most editors break lines on) and the explicit bidirectional embeddings, overrides and isolates
903
+ U+202A-202E and U+2066-2069 (which reorder text), as `\uNNNN`. So a request path cannot forge or split an
904
+ audit line, or reorder one with an explicit bidi control; the implicit marks U+200E, U+200F and U+061C
905
+ are passed through and can only reorder neutral characters within the line. The log is rotated only when `serve` starts (one previous
906
+ file is kept, overwritten at the next rotation), and a failed write is dropped, so a run is on record
907
+ for as long as that file is, not for ever.
908
+
909
+ ### Operator setup
910
+
911
+ - **Opt in**: `toolchain_upgrade = true` in `agent.toml`, then restart the agent (the key is read once
912
+ at start-up). `AC_AGENT_TOOLCHAIN_UPGRADE=1` works only for the process whose environment has it (a
913
+ foreground `serve`, or a task/unit whose own environment sets it). `status` shows what a start would
914
+ load; the running agent's `403 not-opted-in` or its first `202` is the proof. Then grant
915
+ `agent.toolchain.upgrade` to the agents plugin from the Grants grid; the plugin declares that capability
916
+ in the console's PR 2 for #675, so the grid has no row for it until that has been released.
917
+ - **Linux**: `sudo -n` cannot ask for a password, so the agent's user needs a sudoers rule scoped to this
918
+ one form. In `/etc/sudoers.d/ac-agent-upgrade`, mode `0440`, checked with `visudo -c -f`:
919
+
920
+ ```
921
+ <user> ALL=(root) NOPASSWD: /usr/bin/apt-get install --only-upgrade -y *
922
+ ```
923
+
924
+ The trailing `*` takes the rest of the argv the agent builds (`--no-remove <package>`), and the rule
925
+ parses under both sudo 1.9 and sudo-rs (`visudo -c -f` and `visudo-rs -c -f`, checked with sudo-rs 0.2.13 on Ubuntu 26.04).
926
+ If the verb's flags or their order ever change, an installed rule stops matching and every upgrade
927
+ turns into `needs-sudoers`.
928
+
929
+ Until it exists the verb answers `needs-sudoers` and prints this rule with the user filled in.
930
+ **Understand what the rule grants.** A sudoers `*` matches any arguments, including `-o` options, and
931
+ `apt-get -o DPkg::Pre-Invoke::=<command>` runs a command as root, so this rule lets any process running
932
+ as that user become root without a password, not only this agent. That is fit for a machine whose user
933
+ already has unrestricted `sudo` (the usual dev box), and not otherwise. A narrower arrangement (a
934
+ root-owned wrapper that validates one package name and runs `apt-get`, with the sudoers rule naming only
935
+ the wrapper) is a hardening step this verb does not require.
936
+ - **Windows**: the agent runs as a limited logon task and never tries to elevate itself, so it can
937
+ upgrade **user-scope** packages. When winget itself reports that the command needs administrator rights
938
+ (`0x8A150019`) the outcome is `needs-elevation` with the exact command to paste into an elevated
939
+ terminal. **Not verified**: how winget behaves for a machine-scope package or a self-elevating
940
+ installer under a limited task (see the outcomes above: a UAC prompt may appear on the desktop). Treat a
941
+ `needs-elevation` as certain and the absence of one as no promise that nothing will ask for consent.
942
+ - **Environment**: the child gets `NO_COLOR=1`; for `apt`, also `DEBIAN_FRONTEND=noninteractive` and
943
+ `LC_ALL=C`, which `sudo`'s default `env_reset` may strip (the agent's stdin is closed and `-y` is set,
944
+ so a configuration-file prompt fails the upgrade instead of waiting).
945
+
729
946
  ## Health: `claudeDesktop`
730
947
 
731
948
  `GET /v1/health` carries `claudeDesktop: boolean` (#457) — whether this machine can open a
@@ -826,8 +1043,9 @@ software is never something a grant could hand to a plugin.
826
1043
  - 409 `a downgrade is not supported` (semver precedence).
827
1044
  - 409 `update in progress` while another request is still verifying in this process, or while
828
1045
  `update.json` reads `installing` and is under 10 minutes old.
829
- - 409 `jobs running` while any `exec/claude` job runs. This is checked again after verification,
830
- just before the hand-off.
1046
+ - 409 `jobs running` while any agent job runs (a claude run, or a toolchain upgrade, #675, including
1047
+ one that has passed its cap check but not yet started its job). This is checked again after
1048
+ verification, just before the hand-off.
831
1049
  2. **Resolves exactly that version on the public npm registry** (`https://registry.npmjs.org`, a
832
1050
  constant, never configurable):
833
1051
  - The metadata must be a JSON object naming `@rackbops/ac-agent` and that version.
@@ -891,6 +1109,13 @@ Every refusal, the verified hand-off, and a failed hand-off are logged as
891
1109
  — as public as `capabilities`.
892
1110
  `/v1/pair` authenticates by a one-time code instead of a bearer token, since pairing is what mints
893
1111
  the token in the first place.
1112
+ - **`POST /v1/toolchain/upgrade` is the one verb that installs software** (#675), so it is behind two
1113
+ independent gates (the console's grant, and the machine owner's own `toolchain_upgrade` key in
1114
+ `agent.toml`, off by default), upgrades with only three fixed command shapes and a validated package
1115
+ id (the one other program a request can name, `cmd`, is limited to four tools whose `--version` the
1116
+ preflight already runs), takes its elevation from the machine's own configuration rather than ever
1117
+ elevating itself, and logs every run. See **`POST /v1/toolchain/upgrade`**, including what the Linux
1118
+ sudoers rule it needs actually grants.
894
1119
  - **Every other route requires `Authorization: Bearer <token>`**, compared with a
895
1120
  constant-time equality check against a stored hash (never the token itself). Before pairing,
896
1121
  every such route answers `401 {"error": "not paired"}`; a present-but-wrong token answers
@@ -271,6 +271,11 @@ $catalog = @(
271
271
  @{ Cat='VCS & infra'; Name='Git'; Exe='git'; Ver=@('--version') }
272
272
  @{ Cat='VCS & infra'; Name='GitHub CLI'; Exe='gh'; Ver=@('--version') }
273
273
  @{ Cat='VCS & infra'; Name='Docker'; Exe='docker'; Ver=@('--version') }
274
+ # Claude Code CLI -- added for Rackbops/artifact-console#673 (fleet environment consistency,
275
+ # epic Rackbops/artifact-console#676): the fleet standard keys tools by their exact Name here
276
+ # (Rackbops/Tooling#877 adds this one), and the 2.0 Agents panel's Environment row is labelled
277
+ # the same. `claude --version` prints one line, `<semver> (Claude Code)`.
278
+ @{ Cat='VCS & infra'; Name='Claude Code CLI'; Exe='claude'; Ver=@('--version') }
274
279
  @{ Cat='VCS & infra'; Name='Docker Compose'; Exe='docker-compose'; Ver=@('--version') }
275
280
  @{ Cat='VCS & infra'; Name='Podman'; Exe='podman'; Ver=@('--version') }
276
281
  @{ Cat='VCS & infra'; Name='kubectl'; Exe='kubectl'; Ver=@('version','--client') }
@@ -371,6 +376,7 @@ $refLinks = @{
371
376
  'Git' = @{ Home='https://git-scm.com'; Repo='git/git' }
372
377
  'GitHub CLI' = @{ Home='https://cli.github.com'; Repo='cli/cli' }
373
378
  'Docker' = @{ Home='https://docker.com'; Repo='docker/cli' }
379
+ 'Claude Code CLI' = @{ Home='https://code.claude.com/docs'; Repo='anthropics/claude-code' }
374
380
  'Docker Compose' = @{ Home='https://docs.docker.com/compose'; Repo='docker/compose' }
375
381
  'Podman' = @{ Home='https://podman.io'; Repo='containers/podman' }
376
382
  'kubectl' = @{ Home='https://kubernetes.io'; Repo='kubernetes/kubernetes' }
package/data/README.md CHANGED
@@ -1,29 +1,40 @@
1
1
  # `packages/agent/data/`
2
2
 
3
- Vendored, unmodified copies of source files owned by other repos, embedded into the agent's bundle
4
- as text (see `../text-plugin.mjs`).
3
+ Vendored copies of source files owned by other repos (unmodified, except where an entry below
4
+ records a local addition), embedded into the agent's bundle as text (see `../text-plugin.mjs`).
5
5
 
6
6
  ## `Get-ToolchainInventory.ps1`
7
7
 
8
- A byte-identical copy of `artifact-console/data/Get-ToolchainInventory.ps1` from
8
+ A copy of `artifact-console/data/Get-ToolchainInventory.ps1` from
9
9
  [`Rackbops/artifacts-console`](https://github.com/Rackbops/artifacts-console) (the 1.x app), pinned
10
- to commit `8e33836`:
10
+ to commit `8e33836` **on that repo's `main`, plus one local addition (Rackbops/artifact-console#673):
11
+ six added lines, the `Claude Code CLI` catalog row (after `Docker`, with its four-line comment) and its
12
+ `$refLinks` entry**. It is byte-identical to `8e33836` apart from those six lines. From a checkout of
13
+ that repo, `git -C <artifacts-console checkout> show 8e33836:artifact-console/data/Get-ToolchainInventory.ps1 | diff - <this repo>/packages/agent/data/Get-ToolchainInventory.ps1`
14
+ prints exactly them, and `8e33836`'s own sha256 is `76b6a712c27f34ea9dc4cec230fc7107fd3c88720e8f24c0c2c933f2a685fea0`.
11
15
 
12
- - 1,826 lines, 101,592 bytes
16
+ - 1,832 lines, 102,154 bytes
13
17
  - LF line endings, no BOM, ASCII-only
14
- - sha256 `76b6a712c27f34ea9dc4cec230fc7107fd3c88720e8f24c0c2c933f2a685fea0`
18
+ - sha256 `a63f69f1e513405fc444e8686df396f6f4454cdecadda5a1b535c6ddfe1cc636`
15
19
 
16
20
  The hash is platform-stable because both repos' `.gitattributes` normalize this file to
17
21
  `text=auto eol=lf`, and `toolchain-inventory.test.ts` asserts the embedded text hashes to the value
18
22
  above -- a re-vendored script whose bytes moved fails that test rather than silently drifting.
19
23
 
20
- **Re-copy, never edit.** The script is shipped and run as-is (`packages/agent/src/scans/
21
- toolchain-inventory.ts`); a local edit here would break the byte-compare against 1.x's own output,
22
- which is the entire point of vendoring rather than rewriting it. To pick up a newer 1.x revision:
23
- copy the file from the new commit, update the commit hash and the three numbers above, and re-run
24
- the embedding/drift-guard tests (`toolchain-inventory.test.ts`) -- they also pin the script's
25
- catalog names and schema-version literal, so a re-vendor whose shape moved is caught there, not
26
- discovered on a card.
24
+ **Re-copy, never edit -- with one standing exception.** The script is shipped and run as-is
25
+ (`packages/agent/src/scans/toolchain-inventory.ts`); a local edit here would break the byte-compare
26
+ against 1.x's own output, which is the entire point of vendoring rather than rewriting it. The six
27
+ lines above are the only local edit, made so that this agent's inventory reports the Claude Code CLI's
28
+ version (the 1.x pipeline's sidecar validator had already learned the name, in `ac-pipeline` 0.4.1).
29
+ 1.x's `v1-maintenance` branch carries the same row and `$refLinks` text since artifacts-console#750
30
+ (`ac-pipeline` 0.4.1), but that branch's script is an older revision without the `## Containers`
31
+ section, so it is not a source to re-copy from. **Until the same six lines land on 1.x `main`, a
32
+ re-vendor from `main` drops the row**; `toolchain-inventory.test.ts` then fails (the catalog-names
33
+ test and the Claude Code CLI row tests name it), so it cannot be dropped silently. To pick up a newer
34
+ 1.x revision: copy the file from the new commit (re-applying the six lines if `main` does not carry
35
+ them yet), update the commit hash and the three numbers above, and re-run the embedding/drift-guard
36
+ tests (`toolchain-inventory.test.ts`) -- they also pin the script's catalog names and schema-version
37
+ literal, so a re-vendor whose shape moved is caught there, not discovered on a card.
27
38
 
28
39
  **Provenance note (Tooling#721 F3, landed 2026-09-29):** re-pinned from `7280ae6` to `8e33836`
29
40
  (`git diff --stat 7280ae6 8e33836 -- artifact-console/data/Get-ToolchainInventory.ps1`: 456