@rackbops/ac-agent 2.0.0-alpha.53 → 2.0.0-alpha.55
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 +232 -7
- package/dist/ac-agent.mjs +961 -113
- package/package.json +1 -1
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.
|
|
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:
|
|
@@ -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
|
+
(the console's PR 2 for #675), so the grid has a row for it once the console runs that release.
|
|
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
|
|
830
|
-
|
|
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
|