@rackbops/ac-agent 2.0.0-alpha.55 → 2.0.0-alpha.57

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
@@ -10,8 +10,8 @@ one-time code exchanged for a per-machine token, #48), `repos` list/status/pull
10
10
  as are the OneDrive mirror (`publish/mirror`, #53) and the tunnel-config lint (`tunnel/lint`, #416).
11
11
  Every verb also
12
12
  requires a per-plugin capability grant, pushed by the console and enforced here (#50) -- see
13
- **Verbs** and **Security model** below. `GET /v1/preflight` (#55) -- git/gh/node/claude executed,
14
- chromium only resolved -- is control plane, not a verb; see **Preflight**.
13
+ **Verbs** and **Security model** below. `GET /v1/preflight` (#55) -- git/gh/node/claude and (#828)
14
+ docker/cloudflared executed, chromium only resolved -- is control plane, not a verb; see **Preflight**.
15
15
 
16
16
  ## Install
17
17
 
@@ -27,6 +27,26 @@ resolves to the newest release. Two older tags are frozen: `alpha` (the very fir
27
27
  `2.0.0-alpha.40`. Nothing moves either any more, and the maintainer removes or moves them by hand.
28
28
  `latest` is expected to go back to stable-only once the first stable `2.0.0` ships.
29
29
 
30
+ **Node 26 or newer** (`engines.node: ">=26"`, #827), the fleet's one Node line and the console image's
31
+ major. `npx` treats `engines` only as a warning, so the agent checks for itself before any verb: on an
32
+ older Node every verb (`install`, `status`, `stop` and `uninstall` included) does nothing and exits 2,
33
+ its stderr ending with the line `ac-agent requires Node >=26, found <version>`. On Ubuntu, the
34
+ distribution's own `nodejs` is older; Node 26 comes from NodeSource's `node_26.x` apt repository
35
+ (`https://deb.nodesource.com/node_26.x`).
36
+
37
+ Move a machine's Node to 26 **before** its agent updates to a release that carries this floor. The
38
+ console's update (`POST /v1/update`, below) checks the staged bundle by running its `version --json`
39
+ with the agent's own Node, so on an older Node the update is refused (422, naming that line) and the
40
+ running agent stays as it was. A plain `npx @rackbops/ac-agent install` on an older Node is refused the
41
+ same way, before anything is copied or registered.
42
+
43
+ - **The installed task or unit keeps the Node `install` ran under**, as an absolute path
44
+ (`process.execPath`). An apt or MSI upgrade in place moves that Node with it. A version manager
45
+ (fnm, nvm, volta) does not: after switching it to 26, run `install` again under 26, or the agent
46
+ keeps running the old Node and its updates are refused with that line.
47
+ - **To stop or uninstall from a machine still below 26**, run a release from before the floor, e.g.
48
+ `npx @rackbops/ac-agent@2.0.0-alpha.55 stop`, or move the machine's Node first.
49
+
30
50
  ### Upgrade with the Tooling script
31
51
 
32
52
  For an existing install, an up-to-date Tooling checkout provides
@@ -61,8 +81,9 @@ tarball, and reads target health, but runs no install, `scp`, or mutating SSH co
61
81
  Run Windows `-Local` from a [normal PowerShell window](#run-install-from-a-normal-shell). Every
62
82
  Windows machine runs `-Local` for itself; remote Windows installation is unsupported. `gh` must have
63
83
  access to the release repository. Linux targets need working noninteractive SSH and `node`/`npx` on
64
- their `PATH`. `-Port` changes only the health-check port (default `8788`); it does not reconfigure the
65
- agent.
84
+ their `PATH`; the target's own Node runs the new bundle, so a release that carries the Node 26 floor
85
+ needs Node 26 there too (see above). `-Port` changes only the health-check port (default `8788`); it
86
+ does not reconfigure the agent.
66
87
 
67
88
  ### Linux: no special `npx` spelling needed (#278)
68
89
 
@@ -416,10 +437,24 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
416
437
  `owner/name` resolved from its `origin` remote (`git remote get-url origin`, parsed the way 1.x's
417
438
  `init_config.py:parse_remote` does), or `null` when there's no remote or it isn't a GitHub-shaped
418
439
  URL — never an error. This makes the route spawn one `git remote get-url` per repo (parallelized),
419
- so it is no longer git-free the way it was before #304.
440
+ so it is no longer git-free the way it was before #304. **All three repo routes need git**: they
441
+ look for it first (see below), and a machine without it answers
442
+ `502 {ok: false, error: "git not found on PATH"}` (or, when PATH holds only a `.cmd`/`.bat` shim,
443
+ `git is a .cmd shim; install the native binary`) before any repo work, a bad `:name` included.
420
444
  - **`GET /v1/repos/:name/status`** (`agent.git.read`) → `{ok, name, path, ownerName, branch,
421
445
  defaultBranch, upstream, ahead, behind, dirty, inProgress}` (`ownerName` as above), read-only (no
422
446
  fetch — `pull` is what refreshes the refs `status` reads).
447
+ - **git is run by absolute path** (#699). Each of the three requests resolves git once, on the
448
+ refreshed PATH (the registry's on Windows, the process PATH elsewhere: the search the preflight and
449
+ the toolchain upgrade use), and every git spawn of that request names the path it found, made
450
+ absolute if PATH gave a relative entry (`exec/claude` does the same for `claude`, the other spawn
451
+ whose working directory a request chooses). This matters on Windows, where a bare `git` is looked for in the
452
+ spawn's working directory first (`git.com`, then `git.exe`), and every call here has a repo checkout
453
+ as its working directory: a `git.exe` that a clone, a branch or a fast-forward put in the repo's root
454
+ would otherwise run as the agent's user, unattended (the daily `repo.pull` job reaches it). A bare
455
+ name is looked up on PATH only elsewhere, so Linux was not exposed to that; a relative PATH entry is
456
+ the one way a working directory could matter there, and the absolute path closes it too. See
457
+ **Security model** for the second, general layer.
423
458
  - **`POST /v1/repos/:name/pull`** (`agent.git.pull`), body `{dryRun?: boolean}` → `{ok: true, row}`
424
459
  where `row.status` is `updated | would | current | skipped | error` and `row.reason` names which
425
460
  of the six 1.x-derived guards applied (`dirty`, `branch`, `ahead`, `in-progress`, `no-upstream`,
@@ -429,7 +464,8 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
429
464
  timeout.
430
465
  - **`:name` is an allowlist, not a path** — it must match a machine-generated repo name pattern, must
431
466
  not be `.`/`..`, and must actually be one of the entries `GET /v1/repos` would list; anything else
432
- is `404 {"error": "no such repo"}` before any git call runs.
467
+ is `404 {"error": "no such repo"}` before any git call runs (on a machine with git: without it the
468
+ `502` above comes first).
433
469
  - **`POST /v1/open`** (`agent.desktop.open`), body `{url}` → `{ok: true}`. Only a `https:` or
434
470
  `claude:` URL is accepted (the console's own deep-link shape,
435
471
  `claude://code/new?q=<prompt>&folder=<path>`); anything else is `400` before any process spawns.
@@ -479,8 +515,8 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
479
515
  string. Tokens are a byte estimate (~4 chars/token; the payload says `estimated: true`); only
480
516
  sizes and line counts ever leave this scan -- file content is never read into the payload.
481
517
  - **`toolchain-inventory`** (#52, 52b; `timeoutMs: 600_000` -- about seventy version probes plus a
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/
518
+ WSL probe) ships and runs 1.x's own `Get-ToolchainInventory.ps1` (vendored byte for byte from 1.x
519
+ `main` at `5de373e`, `data/README.md`) through `pwsh -NoProfile -File <script> -OutFile <state>/scans/
484
520
  toolchain-inventory-<hostname>.md -NoRender`, with the child's `cwd` set to `<state>/scans` --
485
521
  **never a project directory**, since the script's own `pnpm --version` probe rewrites
486
522
  `pnpm-lock.yaml` in whatever directory it runs in. **Windows-only**: on any other platform this
@@ -773,7 +809,7 @@ anything starts; a request that fails is `400 {ok: false, error: "<field>: <why>
773
809
  | Field | Meaning | Must be |
774
810
  |---|---|---|
775
811
  | `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` |
812
+ | `cmd` | the tool whose `--version` is read before and after; found with `which`, run for nothing else | one of **`git`, `gh`, `node`, `claude`** (`VERSION_READABLE_CMDS`, an explicit list): tools the agent's own preflight already runs `--version` on, which are the `cmd`s of the preflight rows the Environment panel sends. The preflight also executes `docker` and `cloudflared` (#828), but those rows are graded only: upgrading either can restart the host's containers or tunnels, so the verb refuses them with the error below (its wording predates #828), and adding a preflight row never widens this list. Any other name is `400 cmd: not a tool this agent reads the version of` |
777
813
  | `manager` | which package manager | `winget` (Windows only), `apt` (Linux only) or `native` |
778
814
  | `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
815
 
@@ -871,7 +907,7 @@ kind's jobs, and one holding only `agent.exec.claude` never reads these):
871
907
  | `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
908
  | `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
909
  | `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 |
910
+ | `failed` | any other non-zero exit (the last output line that is not closing advice such as `Or consider using native installation with: claude install`, 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
911
  | `timed-out` | the 15-minute limit | check the package before retrying: it may be half-installed |
876
912
 
877
913
  The winget return codes are pinned by number from winget's own generated table
@@ -882,7 +918,7 @@ package, or an installer that elevates itself, when its caller is not elevated h
882
918
  here**: it may relaunch the installer through UAC, which puts a consent prompt on the logon user's
883
919
  desktop (the agent is a logon task in that session) that the agent cannot answer; the job then waits
884
920
  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.
921
+ elevated installer. Anything else an installer fails with is `failed`, with its last output line that is not closing advice.
886
922
 
887
923
  **Audit.** Two lines in the agent log per run: `toolchain upgrade started: "<tool>" via <manager>
888
924
  <packageId> (version of <cmd>)` when the job starts (so a run the agent does not survive is on record),
@@ -992,7 +1028,7 @@ no `X-AC-Plugin`/`X-AC-Grants-Revision` headers, and refuses a caller carrying `
992
1028
  `403 {ok: false, error: "preflight is the console's, not a plugin's"}`. The console's own
993
1029
  `POST /api/agents/:id/preflight` (`packages/host`) relays it into the Agents panel's Environment
994
1030
  section. Live and stateless: nothing is cached on either side, and a call answers `200 {ok: true,
995
- machine, checkedAt, searchPath: "registry" | "process", rows}`, five rows always, in this order:
1031
+ machine, checkedAt, searchPath: "registry" | "process", rows}`, seven rows always, in this order:
996
1032
 
997
1033
  | `cmd` | required | executed | verdicts |
998
1034
  |---|---|---|---|
@@ -1000,16 +1036,25 @@ machine, checkedAt, searchPath: "registry" | "process", rows}`, five rows always
1000
1036
  | `gh` | yes | `gh --version`, then `gh auth status` | `ok` \| `missing` \| `broken` \| `off-path` \| `unauthenticated` |
1001
1037
  | `node` | yes | `node --version` | `ok` \| `missing` \| `broken` \| `off-path` |
1002
1038
  | `claude` | yes | `claude --version` | `ok` \| `missing` \| `broken` \| `off-path` |
1039
+ | `docker` | no | `docker --version` | `ok` \| `missing` \| `broken` \| `off-path` |
1040
+ | `cloudflared` | no | `cloudflared --version` | `ok` \| `missing` \| `broken` \| `off-path` |
1003
1041
  | `chromium` | no | never | `ok` \| `missing` |
1004
1042
 
1043
+ The `docker` and `cloudflared` rows (#828) are two of the fleet standard's tools: the console host and
1044
+ the server stacks run in Docker, and cloudflared runs the tunnels that put them behind Cloudflare
1045
+ Access. Both are optional: a missing one is amber, never a failed preflight. The standard applies Docker
1046
+ to `linux-server` and `windows-dev` hosts and cloudflared to `linux-server` only, and the Environment
1047
+ panel grades them there, but never offers to upgrade them: `POST /v1/toolchain/upgrade` refuses both as
1048
+ a `cmd`.
1049
+
1005
1050
  Each row is `{cmd, label, status, required, path, why, version, detail}` -- `version` the first
1006
1051
  non-empty line of `--version`'s stdout (CRLF handled), `""` otherwise; `detail` status-dependent
1007
1052
  free text (a `broken` row's last non-empty output line, `unauthenticated`'s
1008
1053
  `run gh auth login [-- <stderr line>]`, `off-path`'s `found under a version manager, not on PATH`),
1009
1054
  `""` when there is nothing to add.
1010
1055
 
1011
- - **Executed, not just resolved.** `git`/`gh`/`node`/`claude` are resolved on the refreshed PATH
1012
- (below) AND run (`<exe> --version`, a **15 s** timeout per spawn); `gh` additionally runs
1056
+ - **Executed, not just resolved.** `git`/`gh`/`node`/`claude`/`docker`/`cloudflared` are resolved on
1057
+ the refreshed PATH (below) AND run (`<exe> --version`, a **15 s** timeout per spawn); `gh` additionally runs
1013
1058
  `gh auth status` (needs the network -- offline it exits non-zero and the row reads
1014
1059
  `unauthenticated` with the stderr line saying why, since a real, validated token is what bullet 2
1015
1060
  wants, not merely a cached one `gh auth token` would report on regardless). `chromium` is
@@ -1024,7 +1069,7 @@ free text (a `broken` row's last non-empty output line, `unauthenticated`'s
1024
1069
  Linux the process PATH plus every version-manager bin directory (`fnm`/`nvm`/`volta`/`asdf`) is
1025
1070
  searched as a fallback, so a tool invisible to a bare `which` reads `off-path`, never `missing`.
1026
1071
  `searchPath` on the report names which: `"registry"` or `"process"`.
1027
- - **The whole check answers in a few seconds.** Five rows run concurrently; the worst case is
1072
+ - **The whole check answers in a few seconds.** The seven rows run concurrently; the worst case is
1028
1073
  `gh`'s `--version` answering just under 15 s and then `auth status` timing out at 15 s too --
1029
1074
  under the console's own 100 s edge-request budget behind Cloudflare Access.
1030
1075
 
@@ -1113,9 +1158,31 @@ Every refusal, the verified hand-off, and a failed hand-off are logged as
1113
1158
  independent gates (the console's grant, and the machine owner's own `toolchain_upgrade` key in
1114
1159
  `agent.toml`, off by default), upgrades with only three fixed command shapes and a validated package
1115
1160
  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
1161
+ preflight already runs: an explicit list, so the preflight's `docker` and `cloudflared` rows are not
1162
+ among them), takes its elevation from the machine's own configuration rather than ever
1117
1163
  elevating itself, and logs every run. See **`POST /v1/toolchain/upgrade`**, including what the Linux
1118
1164
  sudoers rule it needs actually grants.
1165
+ - **The agent does not run an executable it finds in a request's working directory** (#699), in two
1166
+ layers, neither of which replaces the other. (1) git (the repo verbs), `gh`, `claude`, the browser and
1167
+ `winget`/`sudo`/`dpkg-query` are found first and spawned by their absolute path (git and `claude`,
1168
+ the two whose working directory a request chooses, have a relative PATH entry made absolute first:
1169
+ layer (2) does not cover a relative name that carries a directory, only a bare one), and `reg.exe` and
1170
+ `taskkill.exe` are built from `%SystemRoot%\System32`; the system launchers that never run in a
1171
+ request's directory are still bare names: `rundll32.exe` and `powershell.exe` on Windows, which layer (2)
1172
+ covers, and `xdg-open`, `xdg-mime`, `systemctl`, `loginctl` and `systemd-run` on Linux, which has no
1173
+ default working-directory search for a bare name. (The tunnel lint runs its interpreter in the
1174
+ directory of the operator-configured preflight script, which is the script it runs anyway.)
1175
+ (2) On Windows the agent process defines
1176
+ `NoDefaultCurrentDirectoryInExePath=1` in its own environment as the first thing `ac-agent` does (every
1177
+ command: `install`, `serve`, `status`, ...), unless that variable is already set. Windows' (and
1178
+ libuv's) search for a bare executable name then no longer looks in the spawn's working directory
1179
+ first, for any bare-name spawn anywhere in the process, and every child inherits the variable. The
1180
+ variable is the agent's own addition to its environment, so a child (git and its credential
1181
+ helpers, `ssh`) sees it too; it only turns the current-directory executable search off, which is
1182
+ also what a Windows shell that has the variable set does: a script a child runs (an npm script, say)
1183
+ that relies on a bare `build.bat` in its working directory, without `.\`, stops finding it. No agent
1184
+ code depends on its value, and it does nothing off Windows. A machine that already defines it with a
1185
+ value is left as it is (an empty value counts as unset and is replaced).
1119
1186
  - **Every other route requires `Authorization: Bearer <token>`**, compared with a
1120
1187
  constant-time equality check against a stored hash (never the token itself). Before pairing,
1121
1188
  every such route answers `401 {"error": "not paired"}`; a present-but-wrong token answers
package/data/README.md CHANGED
@@ -1,17 +1,15 @@
1
1
  # `packages/agent/data/`
2
2
 
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`).
3
+ Vendored copies of source files owned by other repos, unmodified, embedded into the agent's bundle as
4
+ text (see `../text-plugin.mjs`).
5
5
 
6
6
  ## `Get-ToolchainInventory.ps1`
7
7
 
8
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` **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`.
10
+ to commit `5de373e` on that repo's `main`, byte-identical, with no local addition. From a checkout of
11
+ that repo, `git -C <artifacts-console checkout> show 5de373e:artifact-console/data/Get-ToolchainInventory.ps1 | diff - <this repo>/packages/agent/data/Get-ToolchainInventory.ps1`
12
+ prints nothing.
15
13
 
16
14
  - 1,832 lines, 102,154 bytes
17
15
  - LF line endings, no BOM, ASCII-only
@@ -21,20 +19,18 @@ The hash is platform-stable because both repos' `.gitattributes` normalize this
21
19
  `text=auto eol=lf`, and `toolchain-inventory.test.ts` asserts the embedded text hashes to the value
22
20
  above -- a re-vendored script whose bytes moved fails that test rather than silently drifting.
23
21
 
24
- **Re-copy, never edit -- with one standing exception.** The script is shipped and run as-is
22
+ **Re-copy, never edit.** The script is shipped and run as-is
25
23
  (`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.
24
+ against 1.x's own output, which is the entire point of vendoring rather than rewriting it. The
25
+ `Claude Code CLI` catalog row (after `Docker`) and its `$refLinks` entry, which this agent's
26
+ inventory reports for Rackbops/artifact-console#673, come from 1.x `main` itself since
27
+ artifacts-console#755. 1.x's `v1-maintenance` branch carries the same row since
28
+ artifacts-console#750, but that branch's script is an older revision without the `## Containers`
29
+ section, so it is not a source to re-copy from. To pick up a newer 1.x revision: copy the file from
30
+ the new `main` commit, update the commit hash and the three numbers above, and re-run the
31
+ embedding/drift-guard tests (`toolchain-inventory.test.ts`) -- they pin the script's catalog names
32
+ (the `Claude Code CLI` row included) and schema-version literal, so a re-vendor whose shape moved is
33
+ caught there, not discovered on a card.
38
34
 
39
35
  **Provenance note (Tooling#721 F3, landed 2026-09-29):** re-pinned from `7280ae6` to `8e33836`
40
36
  (`git diff --stat 7280ae6 8e33836 -- artifact-console/data/Get-ToolchainInventory.ps1`: 456
@@ -53,3 +49,8 @@ re-vendor changes for a machine with no such WSL/docker setup (the realistic cas
53
49
  today) is nothing. Confirmed by `toolchain-inventory.test.ts`'s existing suite passing unchanged (no
54
50
  test needed updating for this sync) -- the script's catalog and schema-version literal, which that
55
51
  suite pins directly, are both identical to before.
52
+
53
+ **Provenance note (2026-10-09):** re-pinned from `8e33836` plus the six local `Claude Code CLI`
54
+ lines (Rackbops/artifact-console#673) to `5de373e`, byte-identical, via artifacts-console#755 (the
55
+ 1.x `main` twin of artifacts-console#750, for artifacts-console#754). `git hash-object` gives
56
+ `50ee5b9ad2cf7ae3722d438bb956daedc322df71` for both files, so the three numbers above are unchanged.
package/dist/ac-agent.mjs CHANGED
@@ -47,7 +47,7 @@ function _mergeNamespaces(n, m) {
47
47
  }
48
48
 
49
49
  var name = "@rackbops/ac-agent";
50
- var version = "2.0.0-alpha.55";
50
+ var version = "2.0.0-alpha.57";
51
51
  var type = "module";
52
52
  var description = "The per-machine host agent (installed with npx @rackbops/ac-agent): an HTTPS health server plus install/status/uninstall for a Windows logon task or a systemd user unit.";
53
53
  var bin = {
@@ -59,7 +59,7 @@ var files = [
59
59
  "data/"
60
60
  ];
61
61
  var engines = {
62
- node: ">=24"
62
+ node: ">=26"
63
63
  };
64
64
  var publishConfig = {
65
65
  access: "public"
@@ -119,7 +119,9 @@ function expandVars(value, env) {
119
119
  }
120
120
  /** Where `reg.exe` lives on this machine (`%SystemRoot%\System32`, `C:\Windows` when the process
121
121
  * environment carries no `SystemRoot`). Shared with `claude-desktop.ts` (#457), so the two registry
122
- * readers never disagree about which binary they launch. */
122
+ * readers never disagree about which binary they launch. Absolute, not `reg.exe`, because Windows looks
123
+ * for a bare name in the working directory first; `applySpawnGuard` (cli.ts, #699) now stops the whole
124
+ * process doing that, and this stays absolute so neither layer is the only protection. */
123
125
  function regExePath(env) {
124
126
  const systemRoot = env.SystemRoot ?? String.raw `C:\Windows`;
125
127
  return path.win32.join(systemRoot, "System32", "reg.exe");
@@ -5208,7 +5210,12 @@ function createGrantState(initial, persist) {
5208
5210
  let current = initial;
5209
5211
  return {
5210
5212
  revision: () => current?.revision ?? null,
5211
- allows: (pluginId, capability) => (current?.grants[pluginId] ?? []).includes(capability),
5213
+ // Own keys only (#722): `grants` is parsed from JSON, a plain object, and `constructor` passes
5214
+ // PLUGIN_ID, so a bare `grants[pluginId]` read the inherited `Object` (no `.includes`) and the
5215
+ // guard answered 500 instead of 403.
5216
+ allows: (pluginId, capability) => current !== null && Object.hasOwn(current.grants, pluginId)
5217
+ ? (current.grants[pluginId] ?? []).includes(capability)
5218
+ : false,
5212
5219
  replace: (set) => {
5213
5220
  persist(set);
5214
5221
  current = set;
@@ -21540,8 +21547,8 @@ const GIT_TIMEOUT_MS = 120_000;
21540
21547
  // should cost that request single-digit seconds, not the full network-call budget.
21541
21548
  const ORIGIN_TIMEOUT_MS = 10_000;
21542
21549
  const GIT_ENV = { GIT_TERMINAL_PROMPT: "0" };
21543
- async function git(run, cwd, args, timeoutMs = GIT_TIMEOUT_MS) {
21544
- return run("git", args, { cwd, timeoutMs, env: GIT_ENV });
21550
+ async function git(exec, cwd, args, timeoutMs = GIT_TIMEOUT_MS) {
21551
+ return exec.run(exec.cmd, args, { cwd, timeoutMs, env: GIT_ENV });
21545
21552
  }
21546
21553
  /** 1.x `provenance.py:53-55`'s lesson: a git *worktree*'s marker files sit directly in its own
21547
21554
  * git-dir the same way a plain clone's do, so an existence check (not an is-directory check)
@@ -21608,26 +21615,26 @@ function parseRemoteOwnerName(url) {
21608
21615
  }
21609
21616
  /** The checkout's `origin` remote URL, trimmed, or null when there is none or the call fails --
21610
21617
  * never thrown. */
21611
- async function originUrl(repoPath, run) {
21612
- const result = await git(run, repoPath, ["remote", "get-url", "origin"], ORIGIN_TIMEOUT_MS);
21618
+ async function originUrl(repoPath, exec) {
21619
+ const result = await git(exec, repoPath, ["remote", "get-url", "origin"], ORIGIN_TIMEOUT_MS);
21613
21620
  if (result.code !== 0)
21614
21621
  return null;
21615
21622
  return result.stdout.trim();
21616
21623
  }
21617
- async function repoStatus(repoPath, run, exists) {
21618
- const gitDirResult = await git(run, repoPath, ["rev-parse", "--absolute-git-dir"]);
21624
+ async function repoStatus(repoPath, exec, exists) {
21625
+ const gitDirResult = await git(exec, repoPath, ["rev-parse", "--absolute-git-dir"]);
21619
21626
  if (gitDirResult.code !== 0)
21620
21627
  return { ok: false, error: `git: ${gitDirResult.stderr}` };
21621
- const headResult = await git(run, repoPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
21628
+ const headResult = await git(exec, repoPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
21622
21629
  if (headResult.code !== 0)
21623
21630
  return { ok: false, error: `git: ${headResult.stderr}` };
21624
- const originHeadResult = await git(run, repoPath, ["rev-parse", "--abbrev-ref", "origin/HEAD"]);
21625
- const upstreamResult = await git(run, repoPath, ["rev-parse", "--abbrev-ref", "@{upstream}"]);
21631
+ const originHeadResult = await git(exec, repoPath, ["rev-parse", "--abbrev-ref", "origin/HEAD"]);
21632
+ const upstreamResult = await git(exec, repoPath, ["rev-parse", "--abbrev-ref", "@{upstream}"]);
21626
21633
  const hasUpstream = upstreamResult.code === 0;
21627
21634
  const countsResult = hasUpstream
21628
- ? await git(run, repoPath, ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])
21635
+ ? await git(exec, repoPath, ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])
21629
21636
  : { code: 1, stdout: ""};
21630
- const porcelainResult = await git(run, repoPath, ["status", "--porcelain"]);
21637
+ const porcelainResult = await git(exec, repoPath, ["status", "--porcelain"]);
21631
21638
  if (porcelainResult.code !== 0)
21632
21639
  return { ok: false, error: `git: ${porcelainResult.stderr}` };
21633
21640
  const { behind, ahead } = counts(countsResult.stdout.trim(), countsResult.code);
@@ -21642,7 +21649,7 @@ async function repoStatus(repoPath, run, exists) {
21642
21649
  inProgress: inProgress(gitDirResult.stdout.trim(), exists),
21643
21650
  };
21644
21651
  }
21645
- async function pullRepo(name, repoPath, run, options, exists) {
21652
+ async function pullRepo(name, repoPath, exec, options, exists) {
21646
21653
  const skip = (reason, detail, behind = 0, ahead = 0) => ({
21647
21654
  name,
21648
21655
  path: repoPath,
@@ -21652,10 +21659,10 @@ async function pullRepo(name, repoPath, run, options, exists) {
21652
21659
  behind,
21653
21660
  ahead,
21654
21661
  });
21655
- const gitDirResult = await git(run, repoPath, ["rev-parse", "--absolute-git-dir"]);
21662
+ const gitDirResult = await git(exec, repoPath, ["rev-parse", "--absolute-git-dir"]);
21656
21663
  if (gitDirResult.code !== 0)
21657
21664
  return skip("not-a-repo", "no git checkout here");
21658
- const fetchResult = await git(run, repoPath, ["fetch", "--prune"]);
21665
+ const fetchResult = await git(exec, repoPath, ["fetch", "--prune"]);
21659
21666
  if (fetchResult.code !== 0) {
21660
21667
  return {
21661
21668
  name,
@@ -21670,20 +21677,20 @@ async function pullRepo(name, repoPath, run, options, exists) {
21670
21677
  ahead: 0,
21671
21678
  };
21672
21679
  }
21673
- const upstreamResult = await git(run, repoPath, ["rev-parse", "--abbrev-ref", "@{upstream}"]);
21680
+ const upstreamResult = await git(exec, repoPath, ["rev-parse", "--abbrev-ref", "@{upstream}"]);
21674
21681
  const hasUpstream = upstreamResult.code === 0;
21675
21682
  const countsResult = hasUpstream
21676
- ? await git(run, repoPath, ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])
21683
+ ? await git(exec, repoPath, ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])
21677
21684
  : { code: 1, stdout: ""};
21678
21685
  const { behind, ahead } = counts(countsResult.stdout.trim(), countsResult.code);
21679
- const headResult = await git(run, repoPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
21686
+ const headResult = await git(exec, repoPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
21680
21687
  const branch = headResult.stdout.trim();
21681
- const originHeadResult = await git(run, repoPath, ["rev-parse", "--abbrev-ref", "origin/HEAD"]);
21688
+ const originHeadResult = await git(exec, repoPath, ["rev-parse", "--abbrev-ref", "origin/HEAD"]);
21682
21689
  const defaultBr = defaultBranch(originHeadResult.stdout.trim(), originHeadResult.code);
21683
21690
  if (inProgress(gitDirResult.stdout.trim(), exists)) {
21684
21691
  return skip("in-progress", "a rebase/merge is underway", behind, ahead);
21685
21692
  }
21686
- const porcelainResult = await git(run, repoPath, ["status", "--porcelain"]);
21693
+ const porcelainResult = await git(exec, repoPath, ["status", "--porcelain"]);
21687
21694
  if (porcelainResult.stdout.trim() !== "") {
21688
21695
  return skip("dirty", "uncommitted changes", behind, ahead);
21689
21696
  }
@@ -21720,7 +21727,7 @@ async function pullRepo(name, repoPath, run, options, exists) {
21720
21727
  ahead: 0,
21721
21728
  };
21722
21729
  }
21723
- const mergeResult = await git(run, repoPath, ["merge", "--ff-only", "@{upstream}"]);
21730
+ const mergeResult = await git(exec, repoPath, ["merge", "--ff-only", "@{upstream}"]);
21724
21731
  if (mergeResult.code !== 0) {
21725
21732
  return skip("diverged",
21726
21733
  // 1.x's check_repo (repo_pull.py:216) slices the same combined, stripped stdout+stderr text.
@@ -21737,6 +21744,82 @@ async function pullRepo(name, repoPath, run, options, exists) {
21737
21744
  };
21738
21745
  }
21739
21746
 
21747
+ /** The absolute path of the executable `findExecutable` found, for a verb that spawns it with a working
21748
+ * directory that is not the agent's own (a repo checkout, #699). `findExecutable` joins a PATH entry to
21749
+ * the tool name as it is, so a PATH with a relative entry (`.`, `bin`) yields `git.exe` or `bin\git.exe`:
21750
+ * existence was checked against the AGENT's working directory, but a spawn resolves a relative name
21751
+ * against its own `cwd`, where a checkout's planted file would be the one that runs. `path.resolve`
21752
+ * names the very file that was checked, and returns an absolute path unchanged. */
21753
+ function absoluteExecutablePath(found) {
21754
+ return path.resolve(found);
21755
+ }
21756
+ /** The text of the `502` a verb answers when the executable it needs cannot be run (#699): the tool is
21757
+ * `name` and the result of looking for it is `found`. A `.cmd`/`.bat` shim on PATH is named as such,
21758
+ * with what to install instead (`nativeBinary` completes "install ..."), and anything else is "not found
21759
+ * on PATH". `gh.ts`, the repo verbs and the toolchain upgrade all build their message with this, so the
21760
+ * wording a client matches on is one function's for those three (`claude.ts` and the preflight still
21761
+ * build their own inline copies of the same kind of text). */
21762
+ function notFoundError(name, found, nativeBinary = "the native binary") {
21763
+ if (!found.found && found.reason === "shim") {
21764
+ return `${name} is a .cmd shim; install ${nativeBinary}`;
21765
+ }
21766
+ return `${name} not found on PATH`;
21767
+ }
21768
+ /** Strips one pair of surrounding double quotes from a PATH entry ("C:\Program Files\Git\cmd" is
21769
+ * legal in a Windows PATH, and `CreateProcess` strips it before use) -- an entry left quoted
21770
+ * would otherwise never match a real file and read as `missing`. Harmless on POSIX, where a real
21771
+ * directory name wrapped in literal quotes is not a thing that happens. */
21772
+ function unquote(entry) {
21773
+ return entry.length >= 2 && entry.startsWith('"') && entry.endsWith('"')
21774
+ ? entry.slice(1, -1)
21775
+ : entry;
21776
+ }
21777
+ function findExecutable(name, env, platform, fs) {
21778
+ const pathVar = env.PATH ?? env.Path ?? "";
21779
+ const sep = platform === "win32" ? ";" : ":";
21780
+ // A PATH entry that doesn't exist on disk is skipped by the exists() checks below, never thrown
21781
+ // -- there's nothing here to throw on a plain string split, and exists() is a pure boolean.
21782
+ const dirs = pathVar
21783
+ .split(sep)
21784
+ .filter((d) => d !== "")
21785
+ .map(unquote);
21786
+ // path.win32/path.posix explicitly, never the bare path.join/path.dirname -- those pick their
21787
+ // behavior from the OS this code is actually running on, not from the `platform` parameter, so
21788
+ // testing "linux" logic from a Windows dev box (or vice versa) would silently join with the
21789
+ // wrong separator otherwise.
21790
+ const join = platform === "win32" ? path.win32.join : path.posix.join;
21791
+ const probed = [];
21792
+ if (platform === "win32") {
21793
+ for (const dir of dirs) {
21794
+ const exePath = join(dir, `${name}.exe`);
21795
+ probed.push(exePath);
21796
+ if (fs.exists(exePath))
21797
+ return { found: true, path: exePath };
21798
+ const cmdPath = join(dir, `${name}.cmd`);
21799
+ if (fs.exists(cmdPath))
21800
+ return { found: false, reason: "shim", path: cmdPath };
21801
+ const batPath = join(dir, `${name}.bat`);
21802
+ if (fs.exists(batPath))
21803
+ return { found: false, reason: "shim", path: batPath };
21804
+ }
21805
+ return { found: false, reason: "not-found", probed };
21806
+ }
21807
+ for (const dir of dirs) {
21808
+ const candidate = join(dir, name);
21809
+ probed.push(candidate);
21810
+ if (fs.exists(candidate) && fs.isExecutable(candidate))
21811
+ return { found: true, path: candidate };
21812
+ }
21813
+ return { found: false, reason: "not-found", probed };
21814
+ }
21815
+
21816
+ var which = /*#__PURE__*/Object.freeze({
21817
+ __proto__: null,
21818
+ absoluteExecutablePath: absoluteExecutablePath,
21819
+ findExecutable: findExecutable,
21820
+ notFoundError: notFoundError
21821
+ });
21822
+
21740
21823
  /** Whether `root/name`'s REAL (symlink/junction-resolved) parent is still the real `root` --
21741
21824
  * the check a nominal path match can't make. `false` on any realpath failure: a path that can't
21742
21825
  * be resolved is never treated as contained. */
@@ -21810,18 +21893,34 @@ function resolveRepo(root, name, fs) {
21810
21893
  /** Annotates one already-listed entry with its resolved `ownerName` -- a real `git remote
21811
21894
  * get-url` spawn per call, so this is only ever used from the HTTP handlers below, never from
21812
21895
  * `listRepos` itself (memory-footprint's scan calls that directly and must stay git-free). */
21813
- async function withOwnerName(entry, run) {
21814
- const url = await originUrl(entry.path, run);
21896
+ async function withOwnerName(entry, exec) {
21897
+ const url = await originUrl(entry.path, exec);
21815
21898
  return { ...entry, ownerName: url === null ? null : parseRemoteOwnerName(url) };
21816
21899
  }
21900
+ /** Where git is, resolved ONCE at the start of a request (#699), before any repo work and before any
21901
+ * working directory is used: every git spawn of the request then names this absolute path, never the
21902
+ * bare `git` that Windows would look for in the repo itself first. Not found is the verb's `502`, in
21903
+ * the wording `gh` uses.
21904
+ *
21905
+ * The path is made absolute (`absoluteExecutablePath`): a PATH with a relative entry would otherwise
21906
+ * hand the spawn `git.exe` or `bin\git.exe`, to be resolved against the repo. */
21907
+ async function resolveGit(deps) {
21908
+ const found = deps.which("git", { PATH: await deps.searchPath() });
21909
+ if (!found.found)
21910
+ return { ok: false, error: notFoundError("git", found) };
21911
+ return { ok: true, git: { run: deps.run, cmd: absoluteExecutablePath(found.path) } };
21912
+ }
21817
21913
  function registerRepoVerbs(registry, deps) {
21818
21914
  registry.register({
21819
21915
  capability: "agent.git.read",
21820
21916
  method: "GET",
21821
21917
  path: "/repos",
21822
21918
  handler: async (c) => {
21919
+ const git = await resolveGit(deps);
21920
+ if (!git.ok)
21921
+ return c.json({ ok: false, error: git.error }, 502);
21823
21922
  const listed = listRepos(deps.root(), deps.fs);
21824
- const repos = await Promise.all(listed.repos.map((entry) => withOwnerName(entry, deps.run)));
21923
+ const repos = await Promise.all(listed.repos.map((entry) => withOwnerName(entry, git.git)));
21825
21924
  return c.json({ ...listed, repos });
21826
21925
  },
21827
21926
  });
@@ -21830,16 +21929,19 @@ function registerRepoVerbs(registry, deps) {
21830
21929
  method: "GET",
21831
21930
  path: "/repos/:name/status",
21832
21931
  handler: async (c) => {
21932
+ const git = await resolveGit(deps);
21933
+ if (!git.ok)
21934
+ return c.json({ ok: false, error: git.error }, 502);
21833
21935
  // Hono's param() is typed string | undefined for a dynamically-built path (it can't see
21834
21936
  // the literal ":name" above at the type level); an actually-missing segment can't reach
21835
21937
  // this handler at all, and the fallback empty string just fails resolveRepo's pattern.
21836
21938
  const resolved = resolveRepo(deps.root(), c.req.param("name") ?? "", deps.fs);
21837
21939
  if (resolved === null)
21838
21940
  return c.json({ ok: false, error: "no such repo" }, 404);
21839
- const status = await repoStatus(resolved.path, deps.run, deps.fs.exists);
21941
+ const status = await repoStatus(resolved.path, git.git, deps.fs.exists);
21840
21942
  if (!status.ok)
21841
21943
  return c.json(status, 502);
21842
- const { ownerName } = await withOwnerName(resolved, deps.run);
21944
+ const { ownerName } = await withOwnerName(resolved, git.git);
21843
21945
  return c.json({ name: resolved.name, path: resolved.path, ownerName, ...status });
21844
21946
  },
21845
21947
  });
@@ -21848,6 +21950,9 @@ function registerRepoVerbs(registry, deps) {
21848
21950
  method: "POST",
21849
21951
  path: "/repos/:name/pull",
21850
21952
  handler: async (c) => {
21953
+ const git = await resolveGit(deps);
21954
+ if (!git.ok)
21955
+ return c.json({ ok: false, error: git.error }, 502);
21851
21956
  const resolved = resolveRepo(deps.root(), c.req.param("name") ?? "", deps.fs);
21852
21957
  if (resolved === null)
21853
21958
  return c.json({ ok: false, error: "no such repo" }, 404);
@@ -21861,7 +21966,7 @@ function registerRepoVerbs(registry, deps) {
21861
21966
  catch {
21862
21967
  // No body, or a non-JSON one -- dryRun keeps its documented default of false.
21863
21968
  }
21864
- const row = await pullRepo(resolved.name, resolved.path, deps.run, { dryRun }, deps.fs.exists);
21969
+ const row = await pullRepo(resolved.name, resolved.path, git.git, { dryRun }, deps.fs.exists);
21865
21970
  deps.logEvent(`pull ${row.name}: ${row.status} ${row.reason}`);
21866
21971
  return c.json({ ok: true, row });
21867
21972
  },
@@ -22939,15 +23044,26 @@ async function dispatch(argv, io) {
22939
23044
  // apart from the fs-dep builders (`realExists`/`buildRepoFs`/`buildWhichFs`), which cli.test.ts builds
22940
23045
  // against a stubbed fs (#439). ---
22941
23046
  const TASK_NAME = "ac-agent";
22942
- function checkEnvironment() {
22943
- const major = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
22944
- if (major < 24) {
22945
- process.stderr.write(`ac-agent requires Node >=24, found ${process.versions.node}\n`);
22946
- return 2;
23047
+ /**
23048
+ * The oldest Node major the agent runs on (#827): the fleet's one Node line, the console image's
23049
+ * major. Both `package.json` files say `engines.node: ">=<NODE_FLOOR>"`, and node-floor.test.ts pins
23050
+ * all three to the Dockerfile's `FROM node:<major>...`. `npx` treats `engines` only as a warning, so
23051
+ * `checkEnvironment` is what actually refuses an older Node.
23052
+ */
23053
+ const NODE_FLOOR = 26;
23054
+ /**
23055
+ * The runtime check `main` runs before any verb (so a machine left on an older Node fails loudly
23056
+ * instead of running untested, and `install` never half-installs): a Node major below `NODE_FLOOR`,
23057
+ * or a platform other than Windows or Linux, is refused with one stderr line and exit 2. Pure, so the
23058
+ * caller writes the line and exits; `null` means the agent may run.
23059
+ */
23060
+ function checkEnvironment(nodeVersion = process.versions.node, platform = process.platform) {
23061
+ const major = Number.parseInt(nodeVersion.split(".")[0] ?? "0", 10);
23062
+ if (major < NODE_FLOOR) {
23063
+ return { code: 2, message: `ac-agent requires Node >=${NODE_FLOOR}, found ${nodeVersion}` };
22947
23064
  }
22948
- if (process.platform !== "win32" && process.platform !== "linux") {
22949
- process.stderr.write(`ac-agent supports Windows and Linux, found ${process.platform}\n`);
22950
- return 2;
23065
+ if (platform !== "win32" && platform !== "linux") {
23066
+ return { code: 2, message: `ac-agent supports Windows and Linux, found ${platform}` };
22951
23067
  }
22952
23068
  return null;
22953
23069
  }
@@ -23365,18 +23481,25 @@ async function realServe(overrides = {}) {
23365
23481
  const registry$3 = createVerbRegistry();
23366
23482
  const run = createRun();
23367
23483
  const repoFs = buildRepoFs(fs);
23484
+ const whichFs = buildWhichFs(fs);
23485
+ // The optional second (env) parameter is #55's own addition: claude.ts/gh.ts's own deps type
23486
+ // `which` as one-argument (a required second parameter would not be assignable to that, tsc
23487
+ // 5.9 TS2322), while runPreflight's deps call it with the refreshed PATH on every probe.
23488
+ const which$1 = (name, env = process.env) => findExecutable(name, env, process.platform, whichFs);
23489
+ // The PATH to look for an executable on: the registry's on Windows (a tool installed after this agent
23490
+ // started is still found), the process PATH elsewhere. One closure for the repo verbs (git, #699) and
23491
+ // the toolchain upgrade, so "where is X" has a single answer ("reuse that resolution, never a second
23492
+ // list").
23493
+ const refreshedPath = async () => (await searchPath({ platform: process.platform, env: process.env, run })).path;
23368
23494
  registerRepoVerbs(registry$3, {
23369
23495
  root: () => loaded.config.reposRoot,
23370
23496
  run,
23371
23497
  fs: repoFs,
23372
23498
  logEvent: (message) => log$1.write(message),
23499
+ which: which$1,
23500
+ searchPath: refreshedPath,
23373
23501
  });
23374
23502
  registerOpenVerb(registry$3, { spawn: createSpawn(), platform: process.platform });
23375
- const whichFs = buildWhichFs(fs);
23376
- // The optional second (env) parameter is #55's own addition: claude.ts/gh.ts's own deps type
23377
- // `which` as one-argument (a required second parameter would not be assignable to that, tsc
23378
- // 5.9 TS2322), while runPreflight's deps call it with the refreshed PATH on every probe.
23379
- const which$1 = (name, env = process.env) => findExecutable(name, env, process.platform, whichFs);
23380
23503
  // Named so both /v1/preflight (below, via createApp) and the publish verb's browser resolution
23381
23504
  // (#53, immediately below) share the exact same search -- "reuse that resolution, never a second
23382
23505
  // list" (E6-work/53-publish-mirror.md).
@@ -23493,7 +23616,7 @@ async function realServe(overrides = {}) {
23493
23616
  loaded,
23494
23617
  platform: process.platform,
23495
23618
  which: which$1,
23496
- searchPath: async () => (await searchPath({ platform: process.platform, env: process.env, run })).path,
23619
+ searchPath: refreshedPath,
23497
23620
  run,
23498
23621
  jobs,
23499
23622
  userInfo: () => os.userInfo(),
@@ -23609,10 +23732,40 @@ async function realServe(overrides = {}) {
23609
23732
  process.on("SIGTERM", shutdown);
23610
23733
  process.on("SIGINT", shutdown);
23611
23734
  }
23735
+ /** The environment variable that stops Windows from searching the current directory for an executable. */
23736
+ const NO_CWD_EXE_SEARCH_VAR = "NoDefaultCurrentDirectoryInExePath";
23737
+ /**
23738
+ * Make this process refuse the current-directory executable search (#699). On Windows, a spawn of a bare
23739
+ * name (`git`, `taskkill`, ...) is resolved by libuv's `search_path` (`src/win/process.c`), which looks in
23740
+ * the spawn's working directory first -- `<name>.com`, then `<name>.exe` -- whenever
23741
+ * `NeedCurrentDirectoryForExePathW("")` is true, i.e. unless the CALLING PROCESS's environment defines
23742
+ * `NoDefaultCurrentDirectoryInExePath` (the child's `env` option is not consulted). So a spawn whose cwd
23743
+ * is a repo checkout runs whatever the checkout holds. Setting the variable here covers every bare-name
23744
+ * spawn in this process, and the children inherit it (so a child's own `cmd.exe` stops running a bare
23745
+ * `build.bat` out of its working directory too). This is the general guard for that class (`taskkill.exe`
23746
+ * and `reg.exe` were already spawned by absolute path for it; `git` was the one that was missed); the
23747
+ * repo verbs still run git by absolute path, so neither layer is the only protection. It covers a BARE
23748
+ * name only: a relative name that carries a directory (`bin\git.exe`) is resolved against the spawn's
23749
+ * working directory either way, which is why `absoluteExecutablePath` exists. A no-op off Windows, and it
23750
+ * never changes a value that is set.
23751
+ * The empty string counts as unset: whether an empty value switches the search off is not something
23752
+ * this code relies on.
23753
+ */
23754
+ function applySpawnGuard(env, platform) {
23755
+ if (platform !== "win32")
23756
+ return;
23757
+ const current = env[NO_CWD_EXE_SEARCH_VAR];
23758
+ if (current === undefined || current === "")
23759
+ env[NO_CWD_EXE_SEARCH_VAR] = "1";
23760
+ }
23612
23761
  async function main(argv) {
23762
+ // Before anything can spawn: the very first statement, ahead of the environment check and of every
23763
+ // dynamic import below.
23764
+ applySpawnGuard(process.env, process.platform);
23613
23765
  const envError = checkEnvironment();
23614
23766
  if (envError !== null) {
23615
- process.exit(envError);
23767
+ process.stderr.write(`${envError.message}\n`);
23768
+ process.exit(envError.code);
23616
23769
  }
23617
23770
  const io = {
23618
23771
  install: async (args) => {
@@ -65144,7 +65297,8 @@ function killChild(child, spawnFn) {
65144
65297
  if (process.platform === "win32" && child.pid !== undefined) {
65145
65298
  try {
65146
65299
  // By absolute path (as `regExePath` does for reg.exe): a bare name is searched for in the working
65147
- // directory before PATH.
65300
+ // directory before PATH. `applySpawnGuard` (cli.ts, #699) now also stops the process doing that
65301
+ // for any bare name; this stays absolute so neither layer is the only protection.
65148
65302
  const taskkill = path.win32.join(process.env.SystemRoot ?? process.env.windir ?? String.raw `C:\Windows`, "System32", "taskkill.exe");
65149
65303
  const killer = spawnFn(taskkill, ["/pid", String(child.pid), "/t", "/f"], {
65150
65304
  shell: false,
@@ -65252,7 +65406,8 @@ function registerClaudeVerbs(registry, deps) {
65252
65406
  return c.json({ ok: false, error: "too many running jobs" }, 429);
65253
65407
  }
65254
65408
  const argv = [
65255
- which.path,
65409
+ // absolute: this spawn's cwd is the repo checkout (#699), see absoluteExecutablePath
65410
+ absoluteExecutablePath(which.path),
65256
65411
  "-p",
65257
65412
  validated.value.prompt,
65258
65413
  "--permission-mode",
@@ -65560,10 +65715,7 @@ function registerGhVerb(registry, deps) {
65560
65715
  }
65561
65716
  const which = deps.which("gh");
65562
65717
  if (!which.found) {
65563
- const error = which.reason === "shim"
65564
- ? "gh is a .cmd shim; install the native GitHub CLI binary"
65565
- : "gh not found on PATH";
65566
- return c.json({ ok: false, error }, 502);
65718
+ return c.json({ ok: false, error: notFoundError("gh", which, "the native GitHub CLI binary") }, 502);
65567
65719
  }
65568
65720
  const result = await deps.run(which.path, args, { timeoutMs: GH_TIMEOUT_MS, env: GH_ENV });
65569
65721
  const stdout = truncateOutput(result.stdout);
@@ -65586,59 +65738,6 @@ var gh = /*#__PURE__*/Object.freeze({
65586
65738
  registerGhVerb: registerGhVerb
65587
65739
  });
65588
65740
 
65589
- /** Strips one pair of surrounding double quotes from a PATH entry ("C:\Program Files\Git\cmd" is
65590
- * legal in a Windows PATH, and `CreateProcess` strips it before use) -- an entry left quoted
65591
- * would otherwise never match a real file and read as `missing`. Harmless on POSIX, where a real
65592
- * directory name wrapped in literal quotes is not a thing that happens. */
65593
- function unquote(entry) {
65594
- return entry.length >= 2 && entry.startsWith('"') && entry.endsWith('"')
65595
- ? entry.slice(1, -1)
65596
- : entry;
65597
- }
65598
- function findExecutable(name, env, platform, fs) {
65599
- const pathVar = env.PATH ?? env.Path ?? "";
65600
- const sep = platform === "win32" ? ";" : ":";
65601
- // A PATH entry that doesn't exist on disk is skipped by the exists() checks below, never thrown
65602
- // -- there's nothing here to throw on a plain string split, and exists() is a pure boolean.
65603
- const dirs = pathVar
65604
- .split(sep)
65605
- .filter((d) => d !== "")
65606
- .map(unquote);
65607
- // path.win32/path.posix explicitly, never the bare path.join/path.dirname -- those pick their
65608
- // behavior from the OS this code is actually running on, not from the `platform` parameter, so
65609
- // testing "linux" logic from a Windows dev box (or vice versa) would silently join with the
65610
- // wrong separator otherwise.
65611
- const join = platform === "win32" ? path.win32.join : path.posix.join;
65612
- const probed = [];
65613
- if (platform === "win32") {
65614
- for (const dir of dirs) {
65615
- const exePath = join(dir, `${name}.exe`);
65616
- probed.push(exePath);
65617
- if (fs.exists(exePath))
65618
- return { found: true, path: exePath };
65619
- const cmdPath = join(dir, `${name}.cmd`);
65620
- if (fs.exists(cmdPath))
65621
- return { found: false, reason: "shim", path: cmdPath };
65622
- const batPath = join(dir, `${name}.bat`);
65623
- if (fs.exists(batPath))
65624
- return { found: false, reason: "shim", path: batPath };
65625
- }
65626
- return { found: false, reason: "not-found", probed };
65627
- }
65628
- for (const dir of dirs) {
65629
- const candidate = join(dir, name);
65630
- probed.push(candidate);
65631
- if (fs.exists(candidate) && fs.isExecutable(candidate))
65632
- return { found: true, path: candidate };
65633
- }
65634
- return { found: false, reason: "not-found", probed };
65635
- }
65636
-
65637
- var which = /*#__PURE__*/Object.freeze({
65638
- __proto__: null,
65639
- findExecutable: findExecutable
65640
- });
65641
-
65642
65741
  /**
65643
65742
  * The agent's in-memory job registry (#49, 49b): a port of the host's own
65644
65743
  * `packages/host/src/tasks/registry.ts` (#42), extended with the fields a `claude -p` run needs
@@ -66234,7 +66333,8 @@ const WINDOWS = {
66234
66333
  };
66235
66334
  const TIERS = [200_000, 1_000_000];
66236
66335
  function windowFor(model, occupancy) {
66237
- const win = WINDOWS[model];
66336
+ // Own keys only (#722): `model` is text from a transcript, as in 1.x's dict lookup.
66337
+ const win = Object.hasOwn(WINDOWS, model) ? WINDOWS[model] : undefined;
66238
66338
  if (win !== undefined)
66239
66339
  return { window: win, mapped: true };
66240
66340
  for (const tier of TIERS) {
@@ -66644,7 +66744,8 @@ function isPlainObject(v) {
66644
66744
  /** USD-equivalent for one assistant turn, at list API rates. `0` for an unpriced model -- the
66645
66745
  * caller counts its tokens and surfaces the id instead of guessing. */
66646
66746
  function recordCost(model, usage) {
66647
- const rate = PRICES[model];
66747
+ // Own keys only (#722): `model` is text from a transcript, as in 1.x's dict lookup.
66748
+ const rate = Object.hasOwn(PRICES, model) ? PRICES[model] : undefined;
66648
66749
  if (!rate)
66649
66750
  return 0;
66650
66751
  const creation = isPlainObject(usage.cache_creation) ? usage.cache_creation : {};
@@ -66733,7 +66834,7 @@ function scanFile(fileBuffer, mtime, size, stem, parentName, fullPathComponents)
66733
66834
  turns++;
66734
66835
  if (model) {
66735
66836
  models.add(model);
66736
- if (!(model in PRICES))
66837
+ if (!Object.hasOwn(PRICES, model))
66737
66838
  unpriced.add(model);
66738
66839
  }
66739
66840
  cost += recordCost(model, usage);
@@ -66851,7 +66952,7 @@ var scriptText = "<#\n.SYNOPSIS\n Inventory the language toolchains installed
66851
66952
  * Runs 1.x's own `Get-ToolchainInventory.ps1` through `pwsh` (#52, 52b; 1.x had no Linux
66852
66953
  * equivalent, `toolchain_sidecar.py`, `sched_spec.py:264-273`), kind `toolchain-inventory`,
66853
66954
  * capability `agent.scan.toolchain-inventory`. Shipped and run, not rewritten (`data/README.md`):
66854
- * the script is 1,832 lines (1.x `main` at `8e33836` plus the six lines #673 added) whose output is
66955
+ * the script is 1,832 lines (1.x `main` at `5de373e`, vendored byte for byte) whose output is
66855
66956
  * the byte-compare target, so a TypeScript rewrite would have to re-derive every probe and
66856
66957
  * formatting rule and still match it byte for byte.
66857
66958
  *
@@ -67176,7 +67277,8 @@ var scan = /*#__PURE__*/Object.freeze({
67176
67277
  *
67177
67278
  * `why` strings are 1.x's own (`:116-157`), verbatim -- they are the *consequence* of a tool's
67178
67279
  * absence, not a restatement of its name, and this plan reuses them rather than re-deriving new
67179
- * wording that could quietly drift from what the 1.x Environment card has always said.
67280
+ * wording that could quietly drift from what the 1.x Environment card has always said. The
67281
+ * `docker` and `cloudflared` rows (#828) have no 1.x counterpart; theirs follow the same rule.
67180
67282
  */
67181
67283
  const PREREQ_TABLE = [
67182
67284
  {
@@ -67203,6 +67305,19 @@ const PREREQ_TABLE = [
67203
67305
  required: true,
67204
67306
  why: "the spawned `claude -p` coding tasks, and the context-pressure / subagent-cost scans -- every console action that runs a headless Claude fails without it",
67205
67307
  },
67308
+ // #828: two of the fleet standard's tools; optional, so a machine without them is not a failed preflight.
67309
+ {
67310
+ cmd: "docker",
67311
+ label: "Docker",
67312
+ required: false,
67313
+ why: "the console host and every server stack run in it; without it a server role has nothing to run, and a dev box simply has no container tooling",
67314
+ },
67315
+ {
67316
+ cmd: "cloudflared",
67317
+ label: "cloudflared",
67318
+ required: false,
67319
+ why: "the tunnels that put the console and the other stacks behind Cloudflare Access; without it a server host is unreachable from outside the LAN",
67320
+ },
67206
67321
  {
67207
67322
  cmd: "chromium",
67208
67323
  label: "PDF renderer (Chromium-family)",
@@ -67339,7 +67454,7 @@ async function checkRowUnsafe(entry, deps, refreshedEnv) {
67339
67454
  return { ...base, status: "missing", path: "", version: "", detail: "" };
67340
67455
  }
67341
67456
  /** Wraps one row's whole resolution so a throwing `which`/`run`/`exists` becomes that row's own
67342
- * `broken`, never a crash that takes the other four rows down with it (1.x
67457
+ * `broken`, never a crash that takes the other rows down with it (1.x
67343
67458
  * `tests/test_readiness.py:263`'s "a probe that raises becomes a row, not a crash"). */
67344
67459
  async function checkRow(entry, deps, refreshedEnv) {
67345
67460
  try {
@@ -67372,7 +67487,7 @@ const CHROMIUM_ENTRY = PREREQ_TABLE.find((entry) => entry.cmd === "chromium");
67372
67487
  * with `runPreflight`'s own `chromium` row) rather than a second candidate list: winPaths first, then
67373
67488
  * `cmd`/`altCmds` on the refreshed PATH, then the same names under a version manager's bin dir. Never
67374
67489
  * executes the browser (E4 §5 decision 19, unchanged here). Used by the publish-mirror verb (#53) so
67375
- * finding a PDF renderer doesn't also run the rest of preflight (git/gh/node/claude) on every publish.
67490
+ * finding a PDF renderer doesn't also run the rest of preflight (every executed row) on every publish.
67376
67491
  */
67377
67492
  async function resolveChromiumPath(deps) {
67378
67493
  if (!CHROMIUM_ENTRY)
@@ -75557,9 +75672,9 @@ var tunnel = /*#__PURE__*/Object.freeze({
75557
75672
  * three literal shapes and one validated id. Every field is checked against a grammar before anything
75558
75673
  * spawns; a request that does not match is a `400` naming the field. A valid id can still name a package
75559
75674
  * the operator did not mean (the console decides which one), which is the accepted worst case. The one
75560
- * other program the request can name is `cmd`, and only to read its version: it must be one of the tools
75561
- * the agent's own preflight already runs `--version` on (`VERSION_READABLE_CMDS`), never an executable of
75562
- * the request's choosing.
75675
+ * other program the request can name is `cmd`, and only to read its version: it must be one of the four
75676
+ * tools in `VERSION_READABLE_CMDS` (each one the preflight already runs `--version` on), never an
75677
+ * executable of the request's choosing.
75563
75678
  * - NO ELEVATION in the agent: it never tries to elevate itself. Linux needs a sudoers rule for the agent's
75564
75679
  * user (`needs-sudoers`). On Windows the agent is a limited logon task: winget answers `0x8A150019` when
75565
75680
  * the command itself needs administrator rights (`needs-elevation`, with the command to paste into an
@@ -75594,11 +75709,15 @@ const MANAGERS = ["winget", "apt", "native"];
75594
75709
  // punctuation: no quote, ":", "(", ")", ">" or ",", the characters that would let one run's line imitate
75595
75710
  // another's outcome. Spaces stay (the standard's keys have them: "GitHub CLI").
75596
75711
  const TOOL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 ._+-]{0,63}$/;
75597
- /** The commands whose `--version` this verb may run: the agent's own preflight table, minus the one entry
75598
- * the preflight itself never executes (a Chromium-family `--version` can open a window, E4 §5 decision
75599
- * 19). The Environment panel sends the `cmd` of a preflight row, so these are the only values it ever
75600
- * sends; anything else is a request to run a program of the console's choosing. */
75601
- const VERSION_READABLE_CMDS = PREREQ_TABLE.map((entry) => entry.cmd).filter((cmd) => cmd !== "chromium");
75712
+ /** The commands whose `--version` this verb may run: four of the tools the agent's own preflight executes
75713
+ * (never `chromium`, which the preflight only resolves: a Chromium-family `--version` can open a window,
75714
+ * E4 §5 decision 19). The Environment panel sends the `cmd` of a preflight row, so a `cmd` outside this
75715
+ * list is either a row the panel only grades (`docker`, `cloudflared`, #828: upgrading either can restart
75716
+ * the containers or tunnels on the host, a host operation, not a panel button's) or a request to run a
75717
+ * program of the console's choosing. */
75718
+ // An explicit list, not derived from PREREQ_TABLE: adding a preflight row must never widen the `cmd`s this
75719
+ // verb accepts for an upgrade (#828). (`cmd` is not tied to `packageId`; see the README.)
75720
+ const VERSION_READABLE_CMDS = ["git", "gh", "node", "claude"];
75602
75721
  // winget: a `Publisher.Name` id (`Git.Git`, `Microsoft.PowerShell`); must start alphanumeric, so it can
75603
75722
  // never be read as an option, and must contain a ".".
75604
75723
  const WINGET_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,127}$/;
@@ -75628,8 +75747,8 @@ function validateUpgradeRequest(body, platform) {
75628
75747
  const cmd = field("cmd");
75629
75748
  if (typeof cmd !== "string" || cmd === "")
75630
75749
  return refuse$1("cmd: must be a non-empty string");
75631
- // An allowlist, not a grammar: only the preflight's own executed tools pass, so no name, path or
75632
- // separator that is not one of them can reach `which`.
75750
+ // An allowlist, not a grammar: only the four upgradable tools pass, so no name, path or separator that
75751
+ // is not one of them can reach `which`.
75633
75752
  if (!VERSION_READABLE_CMDS.includes(cmd)) {
75634
75753
  return refuse$1(`cmd: not a tool this agent reads the version of (${VERSION_READABLE_CMDS.join(", ")})`);
75635
75754
  }
@@ -75738,12 +75857,18 @@ function sudoersRule(username) {
75738
75857
  function printable(text, max) {
75739
75858
  return text.replace(/[^\x20-\x7e]/g, "?").slice(0, max);
75740
75859
  }
75860
+ /** A closing line that advises rather than reports, e.g. the Claude CLI's "Or consider using native
75861
+ * installation with: claude install" printed after the error that actually failed the run. */
75862
+ const ADVICE_LINE = /^(?:or\s+)?(?:consider|try|hint|tip|see|for more)(?=\s|:|$)/i;
75863
+ /** The command's last line that reports something: trailing advice lines are skipped, so the reason
75864
+ * is the failure and not the hint printed after it. All-advice output falls back to its last line. */
75741
75865
  function lastNonEmptyLine(text) {
75742
75866
  const lines = text
75743
75867
  .split(/\r?\n/)
75744
75868
  .map((line) => line.trim())
75745
75869
  .filter((line) => line !== "");
75746
- return printable(lines.at(-1) ?? "", MAX_REASON_CHARS);
75870
+ const reported = lines.findLast((line) => !ADVICE_LINE.test(line));
75871
+ return printable(reported ?? lines.at(-1) ?? "", MAX_REASON_CHARS);
75747
75872
  }
75748
75873
  function classifyOutcome(input) {
75749
75874
  if (input.limit === "timeout") {
@@ -75812,12 +75937,6 @@ const SPAWN_DIAGNOSTIC = /^\[(?:killed, but its output never closed|the command
75812
75937
  // dpkg-query's own format syntax (a status column, a tab, the package name): not a template literal.
75813
75938
  // biome-ignore lint/suspicious/noTemplateCurlyInString: ${...} is dpkg-query's format syntax
75814
75939
  const DPKG_SHOWFORMAT = "--showformat=${db:Status-Abbrev}\t${Package}\n";
75815
- function notFoundError(name, found) {
75816
- if (!found.found && found.reason === "shim") {
75817
- return `${name} is a .cmd shim; install the native binary`;
75818
- }
75819
- return `${name} not found on PATH`;
75820
- }
75821
75940
  /** The most `<exe> --version` output a version read collects (`RunOptions.maxOutputLength`). */
75822
75941
  const VERSION_MAX_OUTPUT = 65_536;
75823
75942
  const VERSION_DEADLINE_MS = VERSION_TIMEOUT_MS + 5_000;
@@ -76536,4 +76655,4 @@ var updater = /*#__PURE__*/Object.freeze({
76536
76655
  selfUpdateSupported: selfUpdateSupported
76537
76656
  });
76538
76657
 
76539
- export { AGENT_API_VERSION, buildRepoFs, buildWhichFs, dispatch, healthResultFrom, isEntryPoint, main, realExists, runningJobCount, toolchainUpgradeDeps };
76658
+ export { AGENT_API_VERSION, NODE_FLOOR, NO_CWD_EXE_SEARCH_VAR, applySpawnGuard, buildRepoFs, buildWhichFs, checkEnvironment, dispatch, healthResultFrom, isEntryPoint, main, realExists, runningJobCount, toolchainUpgradeDeps };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rackbops/ac-agent",
3
- "version": "2.0.0-alpha.55",
3
+ "version": "2.0.0-alpha.57",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The per-machine host agent (installed with npx @rackbops/ac-agent): an HTTPS health server plus install/status/uninstall for a Windows logon task or a systemd user unit.",
@@ -13,7 +13,7 @@
13
13
  "data/"
14
14
  ],
15
15
  "engines": {
16
- "node": ">=24"
16
+ "node": ">=26"
17
17
  },
18
18
  "publishConfig": {
19
19
  "access": "public"