@rackbops/ac-agent 2.0.0-alpha.57 → 2.0.0-alpha.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -46
- package/data/Get-ToolchainInventory.ps1 +16 -2
- package/data/README.md +23 -11
- package/dist/ac-agent.mjs +30781 -29895
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -106,14 +106,24 @@ Registers a Windows Task Scheduler task (`\ClaudeLocal\ac-agent`, a logon trigge
|
|
|
106
106
|
user) or, on Linux, a systemd `--user` unit (`ac-agent.service`, `enable --now`), copies the running
|
|
107
107
|
bundle into the state dir (never the `npx` cache — that can be garbage-collected out from under a
|
|
108
108
|
long-running service), writes `agent.toml` if one doesn't already exist, and generates a self-signed
|
|
109
|
-
cert if one doesn't already exist.
|
|
109
|
+
cert if one doesn't already exist (also when `cert.pem`/`key.pem` exist but are not a usable pair, an
|
|
110
|
+
empty or mismatched file, with a warning: the new fingerprint must be paired again; `install` prints
|
|
111
|
+
it, `serve` writes it to `agent.log`). The certificate, key, token hash, grants, pending pairing code,
|
|
112
|
+
`agent.toml` are written to a temp name (`<file>.tmp-<pid>`, fsynced) and renamed over; a hard kill
|
|
113
|
+
between the write and the rename can leave a stray `*.tmp-<pid>` beside the file. `update.json`
|
|
114
|
+
(`update.json.tmp`, not fsynced, a failed write is printed and swallowed) and the embedded
|
|
115
|
+
toolchain-inventory script are renamed over from a temp name too. Still written in place: the
|
|
116
|
+
Windows launch script, the systemd unit, the AppData-virtualization probe files, the scans cache (a
|
|
117
|
+
torn one just re-scans) and the self-update staging directory. A file that cannot be read for
|
|
118
|
+
any reason other than not existing is an error, not an empty answer (`status` instead lists it under
|
|
119
|
+
`unreadable`, by file name and error code, and treats it as absent). On an existing install, an explicit `--repos-root <abs>` narrowly
|
|
110
120
|
updates only the top-level `repos_root` string in `agent.toml`; comments, ordering, unknown keys,
|
|
111
121
|
credentials, grants, and every other setting stay untouched. The old and new roots are compared as
|
|
112
122
|
exact strings, so an equal value does not rewrite the file or change its mtime. A malformed file or
|
|
113
123
|
a non-string `repos_root` is refused rather than rewritten. Prints the resolved config/state paths,
|
|
114
124
|
the cert's fingerprint, and the firewall rule's status.
|
|
115
125
|
|
|
116
|
-
Options: `--port <n>` (default `8788`), `--name <machine>` (default `os.hostname()`), `--repos-root
|
|
126
|
+
Options: `--port <n>` (default `8788`; an integer from 1 to 65535, refused otherwise, and `agent.toml`'s `port` is held to the same rule), `--name <machine>` (default `os.hostname()`), `--repos-root
|
|
117
127
|
<abs>` (default `<homedir>/repos`; the fleet's roots differ per box — Windows and Linux checkouts
|
|
118
128
|
don't share a layout, so every real install passes this explicitly). A relative `--repos-root` is
|
|
119
129
|
rejected before installation side effects on both fresh and existing installs. On an existing
|
|
@@ -125,13 +135,20 @@ warns that the environment still wins at runtime until it is unset.
|
|
|
125
135
|
Installing over an already-running agent restarts it (#277): once the task/unit is re-registered
|
|
126
136
|
against the new bundle, `install` stops the old process and starts it again through the same
|
|
127
137
|
`stop`/`start` machinery the `ac-agent stop`/`ac-agent start` commands use, then confirms the new
|
|
128
|
-
process is actually answering (a health check, retried for a few seconds — the OS command that
|
|
138
|
+
process is actually answering (on the configured `bind` when it names one address, else on `127.0.0.1`) (a health check, retried for a few seconds — the OS command that
|
|
129
139
|
launches a process can report success before that process has actually started listening) before
|
|
130
140
|
printing `restarted: now serving <version>`. Only a health answer reporting the **new** version
|
|
131
141
|
counts (#659), so the old process still answering on the port is not mistaken for the new one.
|
|
132
142
|
|
|
133
143
|
Before copying the new bundle in over a running agent, `install` keeps the one it replaces as
|
|
134
|
-
`bin/ac-agent.prev.mjs` in the state dir (#659; one backup, overwritten by the next install).
|
|
144
|
+
`bin/ac-agent.prev.mjs` in the state dir (#659; one backup, overwritten by the next install). Both
|
|
145
|
+
the backup (`ac-agent.prev.mjs.tmp`) and the new bundle (`ac-agent.mjs.tmp`) are copied beside their
|
|
146
|
+
target and renamed into place, as is the restore, so an install killed mid-copy leaves the old bundle
|
|
147
|
+
whole. When the running agent reported its version and `node <bundle> version` is available, the live
|
|
148
|
+
bundle is backed up only if it reports that same version: after an aborted install it may be a newer
|
|
149
|
+
file than the process serving, and it must not overwrite a good backup (an existing backup is kept when
|
|
150
|
+
it is the running version; otherwise no backup is made and `install` prints a warning that it cannot
|
|
151
|
+
roll back). When the running version is not known the backup is made unchecked, as before. If the
|
|
135
152
|
new bundle never answers health at its own version, `install` rolls back:
|
|
136
153
|
1. It stops the task/unit again. On Windows this ends `launch.js`'s retry loop, so it can't rerun the
|
|
137
154
|
broken bundle in the middle of the restore.
|
|
@@ -152,8 +169,11 @@ restart (registering the task/unit, the certificate) and a backup was made, the
|
|
|
152
169
|
is copied back onto the disk; the error is reported as before.
|
|
153
170
|
|
|
154
171
|
The install then **fails** (exit 1), because it did not take. If the rollback fails too, it prints
|
|
155
|
-
`rollback failed:
|
|
156
|
-
|
|
172
|
+
`rollback failed: ...` and also exits 1; `ac-agent start` is the fallback. Which file holds what depends
|
|
173
|
+
on how far the rollback got: `ac-agent.prev.mjs` always holds the previous bundle, and the live
|
|
174
|
+
`ac-agent.mjs` holds the previous bundle too once the restore copy has run (a failure while starting it or
|
|
175
|
+
confirming it), but still the new one when the failure came before that copy. The message names
|
|
176
|
+
`ac-agent.prev.mjs` and says to copy it over the live bundle if it is not already there.
|
|
157
177
|
|
|
158
178
|
When there was no bundle to go back to, a restart that fails outright still reports the install as a
|
|
159
179
|
success, with `restart failed: <reason>; run ac-agent stop && ac-agent start`, and that manual
|
|
@@ -184,8 +204,11 @@ it.
|
|
|
184
204
|
|
|
185
205
|
`install` tries to add an inbound rule (`New-NetFirewallRule`, Windows only) so a LAN caller can
|
|
186
206
|
reach the port. Creating a firewall rule needs administrator rights; when the process isn't
|
|
187
|
-
elevated, `install` prints the exact one-liner to run in an elevated PowerShell
|
|
188
|
-
|
|
207
|
+
elevated, `install` prints the exact one-liner to run in an elevated PowerShell. An existing `ac-agent`
|
|
208
|
+
rule is kept only when its `LocalPort` is the install's port; a rule on another port (a reinstall with a
|
|
209
|
+
different port) is removed and recreated, and when that needs elevation the printed one-liner removes it
|
|
210
|
+
first. `uninstall` leaves the rule in place and prints the elevated `Remove-NetFirewallRule` command
|
|
211
|
+
that removes it. Only `install` reports the rule's state; `status` has no firewall field. This is expected on a fresh install run from an ordinary
|
|
189
212
|
shell — it is not a failure to work around.
|
|
190
213
|
|
|
191
214
|
### Run install from a normal shell
|
|
@@ -258,7 +281,8 @@ Running while serving and a crash propagates as a non-zero exit, visible afterwa
|
|
|
258
281
|
scratch tasks with these exact settings, one killed by pid and one exiting on its own, sampled every
|
|
259
282
|
30 s for 150 s): Task Scheduler's restart-on-failure does not act on the action's exit code for this
|
|
260
283
|
action shape. The real crash-restart mechanism is `launch.js`'s own retry loop (#238): a non-zero
|
|
261
|
-
exit sleeps a minute and runs node again, up to 3 retries
|
|
284
|
+
exit sleeps a minute and runs node again, up to 3 retries in a row (a run that lasted longer than five
|
|
285
|
+
minutes starts the count over, so the budget is per crash loop, not per logon session), since the exit code is already in
|
|
262
286
|
`wscript.exe`'s own hand; a zero exit (a deliberate shutdown) ends the loop instead. `stop`/`uninstall`
|
|
263
287
|
stop the task first — ending the loop — before killing the orphaned node by pid, the reverse of the
|
|
264
288
|
order that would otherwise let the loop see a bare kill as a crash and resurrect the agent a minute
|
|
@@ -280,14 +304,17 @@ window is visible at a real logon.
|
|
|
280
304
|
npx @rackbops/ac-agent status
|
|
281
305
|
```
|
|
282
306
|
|
|
283
|
-
Prints, as JSON: the config path and whether it came from a file or defaults, the state dir, the
|
|
307
|
+
Prints two plain lines, `lan address: <ip|(none)>` and `repos: <n>`, and then, as JSON: the config path and whether it came from a file or defaults, the state dir, the
|
|
284
308
|
Task Scheduler/systemd service state, whether a pairing token is stored (`paired: true/false`), the
|
|
285
|
-
cert's fingerprint,
|
|
286
|
-
|
|
309
|
+
cert's fingerprint, `certExpiry` (`{validTo, daysLeft, expiringSoon}`, `null` with no readable cert;
|
|
310
|
+
`expiringSoon` is true within 30 days of `notAfter` or after it, and then a `warning: the agent's
|
|
311
|
+
certificate ...` line goes to stderr), the result of a live `GET /v1/health` call against itself (pinned
|
|
312
|
+
to the cert's fingerprint and nothing else: not a system CA and not the validity dates, so an expired
|
|
313
|
+
cert still answers and `certExpiry` is where the expiry shows — see **Security model** below), the currently-held grants
|
|
287
314
|
(`{revision, plugins: <count>} | null`, from `grants.json` — `null` when it is absent or malformed,
|
|
288
315
|
the same as a freshly-purged or never-pushed-to agent), the effective `reposRoot` and a live
|
|
289
316
|
`reposCount` computed from the same immediate-child, `.git`-entry, realpath-contained listing as
|
|
290
|
-
`GET /v1/repos` (a missing or unreadable root is zero), and `virtualized: true/false` (#263) — `true`
|
|
317
|
+
`GET /v1/repos` (a missing or unreadable root is zero), `unreadable` (`{file: error code}` for any file status could not read, `{}` normally), and `virtualized: true/false` (#263) — `true`
|
|
291
318
|
when this session's own AppData writes to the resolved config/state dirs are virtualized by a
|
|
292
319
|
packaged app (see **Run install from a normal shell** above), which also prints a `warning: this
|
|
293
320
|
session's AppData is virtualized (<packageFamily>); the logon task cannot see <stateDir> -- see
|
|
@@ -314,7 +341,7 @@ url: <this machine's URL>
|
|
|
314
341
|
fingerprint: <cert fingerprint>
|
|
315
342
|
```
|
|
316
343
|
|
|
317
|
-
The code is 8 characters from an alphabet that excludes ambiguous glyphs (`0`/`O`, `1`/`I
|
|
344
|
+
The code is 8 characters from an alphabet that excludes ambiguous glyphs (`0`/`O`, `1`/`I`, easy
|
|
318
345
|
to misread off a small screen or mishear read aloud) and expires in **10 minutes**; only its hash is
|
|
319
346
|
ever written to disk (`<stateDir>/pair.json`). Refuses with `already paired -- pass --force to
|
|
320
347
|
re-pair (mints a fresh code)` if a token is already stored — `--force` starts a fresh pairing
|
|
@@ -345,6 +372,17 @@ the machine's chosen port/name.
|
|
|
345
372
|
except for debugging. `stop`/`start` control the registered service (`Stop-`/`Start-ScheduledTask`
|
|
346
373
|
or `systemctl --user stop/start`) without touching the registration itself.
|
|
347
374
|
|
|
375
|
+
**The lock.** `agent.pid` is taken with `O_EXCL`. A pid left by an unclean exit (a Windows logoff or
|
|
376
|
+
reboot runs no shutdown handler) is replaced when it is this process's own pid, is not a valid pid, or
|
|
377
|
+
is not running. It is also replaced when nothing answers `/v1/health` on the configured port with this
|
|
378
|
+
machine's own certificate, which is pid reuse. A start is refused only while that pinned answer names a
|
|
379
|
+
running agent. Both outcomes are written to `agent.log`, as `replaced stale lock (pid N, <why>)` or
|
|
380
|
+
`already running (pid N, port P)`. `GET /v1/health` carries the answering agent's `pid` for this.
|
|
381
|
+
|
|
382
|
+
`stop`, `uninstall` and an install's restart kill only the pid that pinned answer names. When nothing
|
|
383
|
+
answers they kill nothing and remove the stale `agent.pid`, and they print `... nothing was killed` if
|
|
384
|
+
that pid belongs to some other live process.
|
|
385
|
+
|
|
348
386
|
## Config and state directories
|
|
349
387
|
|
|
350
388
|
| | Windows | Linux |
|
|
@@ -375,7 +413,8 @@ claude_projects_dir = "C:/Users/melody/.claude/projects"
|
|
|
375
413
|
|
|
376
414
|
Unknown keys are ignored, so a future field never breaks an older agent reading a newer file.
|
|
377
415
|
`repos_root` is overridable live with `AC_AGENT_REPOS_ROOT` the same way `port` is with
|
|
378
|
-
`AC_AGENT_PORT`
|
|
416
|
+
`AC_AGENT_PORT` (which, unlike the file's `port`, is not range-checked: it is read as an integer and a value
|
|
417
|
+
it cannot read is ignored) — absolute paths only; a relative override throws, naming the value.
|
|
379
418
|
`claude_projects_dir` (#52, 52a; default `<homedir>/.claude/projects`) is handled identically, with
|
|
380
419
|
its own live override `AC_AGENT_CLAUDE_PROJECTS_DIR` — it names the root the `context-pressure` and
|
|
381
420
|
`subagent-cost` scans read, overridable so a fixture tree or a non-default `CLAUDE_CONFIG_DIR`
|
|
@@ -500,9 +539,14 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
|
|
|
500
539
|
figure at list rates (never money actually spent — this box runs Claude Code on a
|
|
501
540
|
subscription). Both read exactly six fields per transcript record (`type`, `subtype` /
|
|
502
541
|
`attributionAgent`, `timestamp`, `cwd`, `message.{id, model, usage}`); `message.content` --
|
|
503
|
-
prompts, tool results, file contents -- is never
|
|
542
|
+
prompts, tool results, file contents -- is parsed in memory with the rest of the line but never read or kept, and never leaves the process, in either scan. The cache lands
|
|
504
543
|
at `<stateDir>/scans/context-pressure.json` / `<stateDir>/scans/subagent-cost.json`, created on
|
|
505
|
-
first write and removed by `uninstall` regardless of `--purge`.
|
|
544
|
+
first write and removed by `uninstall` regardless of `--purge`. Beside each cache sits a
|
|
545
|
+
`<kind>.tables` file holding a digest of the tables its rows were computed from (the context
|
|
546
|
+
windows; the prices and cache multipliers). A cache whose digest differs, or that has none, is
|
|
547
|
+
dropped and every transcript re-parsed, and a row naming an unmapped (context-pressure) or
|
|
548
|
+
unpriced (subagent-cost) model is re-parsed on every scan, so adding the model to the table
|
|
549
|
+
fixes its rows without a manual cache delete.
|
|
506
550
|
- **`memory-footprint`** (#52, 52b; no custom `timeoutMs` -- a cold run is kilobytes of I/O, not
|
|
507
551
|
the transcript-scale reads above) is a producer only, computed live on every request and never
|
|
508
552
|
cached: the fixed 8-layer set Claude Code assembles into a session's preamble (`enterprise`,
|
|
@@ -513,10 +557,17 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
|
|
|
513
557
|
`repos_root` with a `.git` entry) -- unlike 1.x, which read the console's configured repo list,
|
|
514
558
|
so the payload's `repo` and `name` fields are both the directory name, never an `owner/name`
|
|
515
559
|
string. Tokens are a byte estimate (~4 chars/token; the payload says `estimated: true`); only
|
|
516
|
-
sizes and line counts ever leave this scan -- file content is never read into the payload.
|
|
560
|
+
sizes and line counts ever leave this scan -- file content is never read into the payload. Every
|
|
561
|
+
layer file is `lstat`ed first: a layer file or the rules directory itself that is a symlink,
|
|
562
|
+
anything that is not a regular file, or a file over 1 MiB is skipped as if absent; inside the rules
|
|
563
|
+
directory, symlinked files and directories are not followed, and the walk goes at most 8
|
|
564
|
+
directories deep, so a hostile checkout cannot point a layer at `/dev/zero`, a FIFO or a huge
|
|
565
|
+
file. Accepted limitation: a skipped layer is silent and reads as absent (the payload has no field
|
|
566
|
+
for it), so a dotfiles-managed `~/.claude/CLAUDE.md` that is a symlink, or any layer file over
|
|
567
|
+
1 MiB, is undercounted.
|
|
517
568
|
- **`toolchain-inventory`** (#52, 52b; `timeoutMs: 600_000` -- about seventy version probes plus a
|
|
518
569
|
WSL probe) ships and runs 1.x's own `Get-ToolchainInventory.ps1` (vendored byte for byte from 1.x
|
|
519
|
-
`main` at `
|
|
570
|
+
`main` at `da0848f`, `data/README.md`) through `pwsh -NoProfile -File <script> -OutFile <state>/scans/
|
|
520
571
|
toolchain-inventory-<hostname>.md -NoRender`, with the child's `cwd` set to `<state>/scans` --
|
|
521
572
|
**never a project directory**, since the script's own `pnpm --version` probe rewrites
|
|
522
573
|
`pnpm-lock.yaml` in whatever directory it runs in. **Windows-only**: on any other platform this
|
|
@@ -545,7 +596,8 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
|
|
|
545
596
|
(a 404 before anything spawns); `prompt` is a non-empty string, at most 8 KiB (the Windows
|
|
546
597
|
`CreateProcess` command-line limit is 32,767 characters total, and quoting can double a prompt's
|
|
547
598
|
own backslashes and quotes — 8 KiB leaves headroom for the fixed flags and the repo path in the
|
|
548
|
-
worst case) with no control character other than `\n`/`\t
|
|
599
|
+
worst case) with no control character other than `\n`/`\t`, and not starting with `-` (`400 prompt must
|
|
600
|
+
not start with '-'`: `claude` would read it as a flag); `permissionMode` is one of `plan`
|
|
549
601
|
(the default) / `acceptEdits` / `bypassPermissions`; `model` is an optional
|
|
550
602
|
`^[A-Za-z0-9._-]{1,64}$` string. Any other shape is `400` before anything spawns. `claude` runs as
|
|
551
603
|
`claude -p <prompt> --permission-mode <mode> --add-dir <repo path> [--model <model>]`, `cwd` the
|
|
@@ -573,10 +625,19 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
|
|
|
573
625
|
### `POST /v1/exec/gh`
|
|
574
626
|
|
|
575
627
|
- **`POST /v1/exec/gh`** (`agent.exec.gh`), body `{args: string[]}` → `200 {ok: true, code, stdout,
|
|
576
|
-
stderr, truncated}` (stdout/stderr each capped at 1 MiB, `truncated: true` when either was cut
|
|
577
|
-
non-zero `gh` exit is still a `200` — the
|
|
578
|
-
|
|
579
|
-
|
|
628
|
+
stderr, truncated}` (stdout/stderr each capped at 1 MiB, `truncated: true` when either was cut or when
|
|
629
|
+
collection stopped at the cap, which also kills `gh`). A non-zero `gh` exit is still a `200` — the
|
|
630
|
+
caller reads `code`; a `gh` that cannot be launched answers `code: 127`. At most 32 arguments, each at
|
|
631
|
+
most 4 KiB with no control characters. Only a `--body` value (`--body <text>` or `--body=<text>`) may
|
|
632
|
+
be longer, up to 64 KiB (24 KiB on Windows, where the command line is limited) and contain `\n`/`\t`
|
|
633
|
+
(a PR/issue comment body); any other flag's value, `--search=<text>` included, is held to the 4 KiB and
|
|
634
|
+
no-control-character rule.
|
|
635
|
+
- **The `gh` child gets an allowlisted environment, not the agent's** (#714): `PATH`, the per-OS
|
|
636
|
+
profile/temp/locale keys, proxy and `SSL_CERT_*` keys, `GH_HOST` and `GH_CONFIG_DIR`, plus
|
|
637
|
+
`GH_PROMPT_DISABLED`, `GH_NO_UPDATE_NOTIFIER` and `NO_COLOR`. `GH_TOKEN`, `GITHUB_TOKEN`,
|
|
638
|
+
`GH_ENTERPRISE_TOKEN` and `GITHUB_ENTERPRISE_TOKEN` are deliberately left out, so `gh` takes its
|
|
639
|
+
credential from `gh auth login` on the machine. A `--jq` program that names the environment
|
|
640
|
+
(`env`, `$ENV`) is refused `400 invalid value for --jq: ...` before anything spawns.
|
|
580
641
|
- **Every argument is checked against a fixed allowlist before anything spawns** — an unlisted
|
|
581
642
|
subcommand, flag, or malformed positional is `400 {ok: false, error: "<token and why>"}`, logged as
|
|
582
643
|
`gh refused: <error>`, additive-only (nothing here is ever loosened without a deliberate change to
|
|
@@ -631,12 +692,13 @@ pulling from the console (the console sits behind Cloudflare Access, which an ag
|
|
|
631
692
|
Body `{files: [{href, base64}], wanted: string[], pdf: boolean, prune: boolean}` — `files` at most
|
|
632
693
|
1000 entries, each `href` a relative POSIX path (no empty/`.`/`..` segment, no drive/UNC anchor)
|
|
633
694
|
ending `.html`, `base64` a valid base64 string, decoded total at most 48 MB (a request body over
|
|
634
|
-
~66 MB is rejected by the route's own
|
|
695
|
+
~66 MB is rejected by the route's own body-size cap (larger than the 16 KiB cap on the pair, grants and update routes) before JSON parsing even runs, `413
|
|
635
696
|
{ok: false, error: "request body too large"}`); `wanted` an array of such hrefs (every html href the
|
|
636
697
|
caller currently wants mirrored, not just the changed ones in `files`); `pdf`/`prune` booleans. `409
|
|
637
698
|
{ok: false, error: "publish target not configured"}` when `publish_dir` is unset (most paired
|
|
638
699
|
machines — a Linux box has no OneDrive). One run at a time: `409 {ok: false, error: "publish already
|
|
639
|
-
running"}
|
|
700
|
+
running"}`, which also answers while an earlier request is still sending its body (the guard is taken
|
|
701
|
+
before the body is read, so a client that opens a request and never finishes it holds it for other callers).
|
|
640
702
|
|
|
641
703
|
- **HTML**: each file's bytes are compared against what's on disk (size, then sha256) — unchanged
|
|
642
704
|
bytes are never rewritten (OneDrive re-uploads every rewrite); a changed or new file is written to
|
|
@@ -809,7 +871,7 @@ anything starts; a request that fails is `400 {ok: false, error: "<field>: <why>
|
|
|
809
871
|
| Field | Meaning | Must be |
|
|
810
872
|
|---|---|---|
|
|
811
873
|
| `tool` | the standard's tool key; the job's label and the audit lines only, never part of a command | `^[A-Za-z0-9][A-Za-z0-9 ._+-]{0,63}$` (words, spaces allowed as in `GitHub CLI`: no `"`, `:`, `(`, `)`, `>` or `,`, and the audit lines print it in quotes, so one run's line cannot imitate another's outcome) |
|
|
812
|
-
| `cmd` | the tool whose `--version` is read before and after; found with `which`, run for nothing else | one of **`git`, `gh`, `node`, `claude`** (`VERSION_READABLE_CMDS`, an explicit list): tools the agent's own preflight already runs `--version` on, which are the `cmd`s of the preflight rows the Environment panel sends.
|
|
874
|
+
| `cmd` | the tool whose `--version` is read before and after; found with `which`, run for nothing else | one of **`git`, `gh`, `node`, `claude`, `docker`, `cloudflared`** (`VERSION_READABLE_CMDS`, an explicit list): tools the agent's own preflight already runs `--version` on, which are the `cmd`s of the preflight rows the Environment panel sends. `docker` and `cloudflared` (#828) are accepted but **deferred** to the next 05:00 America/New_York, because upgrading either can restart the host's containers or tunnels (#978; see **Deferred upgrades** below). Adding a preflight row never widens this list. Any other name is `400 cmd: not a tool this agent reads the version of` |
|
|
813
875
|
| `manager` | which package manager | `winget` (Windows only), `apt` (Linux only) or `native` |
|
|
814
876
|
| `packageId` | the package | `winget`: `^[A-Za-z0-9][A-Za-z0-9.+_-]{0,127}$` with a `.` (a `Publisher.Name` id); `apt`: the Debian package-name grammar `^[a-z0-9][a-z0-9+.-]{1,127}$`, not ending in `-`; `native`: exactly `claude` (and `cmd` exactly `claude`) |
|
|
815
877
|
|
|
@@ -836,7 +898,7 @@ here). It still installs the new dependencies the upgrade needs (`apt-get(8)`).
|
|
|
836
898
|
The worst a hostile console can do with a valid request is upgrade an installed package the operator
|
|
837
899
|
did not mean, with the new dependencies that upgrade needs (never removing a package). Apart from the
|
|
838
900
|
package id, the one thing the request names is `cmd`, and it can only pick
|
|
839
|
-
one of the
|
|
901
|
+
one of the six tools above to run `--version` on (never a program of its choosing); it cannot install
|
|
840
902
|
or run anything else from the request's text. The verb does not tie `cmd` to `packageId`: `cmd` only says
|
|
841
903
|
whose version is read, so a request naming `git` with another package's id reports the status of git's
|
|
842
904
|
version. The audit lines name `cmd` for that reason. For `apt` the package must also **be an installed
|
|
@@ -854,6 +916,26 @@ counts an upgrade that has passed its check but not yet started its job. A tool
|
|
|
854
916
|
(`winget`, `sudo`, `dpkg-query`, `cmd`) is `502 {ok: false, error: "<name> not found on PATH"}`. A running
|
|
855
917
|
claude job does not count against it, and it does not count against the claude cap.
|
|
856
918
|
|
|
919
|
+
**Deferred upgrades (#978).** When `cmd` is `docker` or `cloudflared` (`DEFERRED_CMDS`), on every
|
|
920
|
+
platform, the answer is `202 {ok: true, jobId, scheduledFor}` with `scheduledFor` the ISO time of the next
|
|
921
|
+
**05:00 America/New_York** strictly after the request (a request at 05:00:30 runs at 05:00 the next day).
|
|
922
|
+
The window is wall-clock time in that zone, so it follows the daylight-saving change; the zone changes its
|
|
923
|
+
clocks at 02:00, so 05:00 always exists and is never ambiguous. Why: the upgrade restarts the Docker
|
|
924
|
+
daemon and its containers (the console's included; they come back through `restart: unless-stopped`,
|
|
925
|
+
`deploy/compose.yaml.example`), or the cloudflared tunnels the console is reached through. The agent runs
|
|
926
|
+
outside Docker, so it survives both, and it owns the schedule rather than the console. The request-time
|
|
927
|
+
refusals are the usual ones (`400`, `502`, the cap) and come before any job exists; the version is read
|
|
928
|
+
at fire time. The job exists at once, in status `running`, with a `scheduled: runs at ...` line, and it
|
|
929
|
+
holds the one upgrade slot: a second request is `429 {ok: false, error: "an upgrade is already scheduled
|
|
930
|
+
on this machine for <ISO time>"}`, and `POST /v1/update` is refused (`409 jobs running`) like for any
|
|
931
|
+
running job. At 05:00 the agent repeats the PATH refresh, both lookups, the version read and, for `apt`,
|
|
932
|
+
the installed-package check (the machine may have changed overnight); if one now fails, the job finishes
|
|
933
|
+
`failed` with that reason as its outcome. A machine that slept through the window and wakes more than 90
|
|
934
|
+
minutes late does not run it then: the job moves to the next 05:00 (job-log line `missed its window;
|
|
935
|
+
rescheduled`, audit line `toolchain upgrade rescheduled: ...`). The schedule lives in the agent's memory: **an agent restart
|
|
936
|
+
before 05:00 drops it**, and the `toolchain upgrade scheduled:` audit line is the record. There is no "run
|
|
937
|
+
now" override and no per-machine time yet (#978, later chunks).
|
|
938
|
+
|
|
857
939
|
**The job.** The answer is `202 {ok: true, jobId}` and the upgrade runs as a job (`kind:
|
|
858
940
|
"toolchain-upgrade"`, `repo` the manager, `permissionMode` empty, `label` `<tool> via <manager>
|
|
859
941
|
<packageId>`) because `apt` and `winget` outlast the host's 30 s body budget. It is killed after **15
|
|
@@ -893,7 +975,8 @@ answered within 25 seconds is `missing`, and its output is bounded at 64 KiB.
|
|
|
893
975
|
kind's jobs, and one holding only `agent.exec.claude` never reads these):
|
|
894
976
|
|
|
895
977
|
- **`GET /v1/toolchain/upgrade/:jobId`** → `{ok: true, job: <view>, outcome?: {status, reason?, before,
|
|
896
|
-
after}}`; `
|
|
978
|
+
after}, scheduled?: {runAt, waiting}}`; `scheduled` is present for a deferred job (`waiting: true` until
|
|
979
|
+
the agent has spawned it, `false` after; #978); `outcome` is present once the agent has worked the outcome out, when the command is over (a
|
|
897
980
|
job whose launch threw has none: its request was a `500` that returned no job id). `404 no such job` for
|
|
898
981
|
an unknown id or another kind's.
|
|
899
982
|
- **`GET /v1/toolchain/upgrade/:jobId/events`** → the same Server-Sent Events as `/v1/jobs/:id/events`
|
|
@@ -920,7 +1003,9 @@ desktop (the agent is a logon task in that session) that the agent cannot answer
|
|
|
920
1003
|
until it is answered or the 15-minute limit fires (`timed-out`), and a limited `taskkill` cannot end an
|
|
921
1004
|
elevated installer. Anything else an installer fails with is `failed`, with its last output line that is not closing advice.
|
|
922
1005
|
|
|
923
|
-
**Audit.** Two lines in the agent log per run
|
|
1006
|
+
**Audit.** Two lines in the agent log per run (a deferred one first logs `toolchain
|
|
1007
|
+
upgrade scheduled: "<tool>" via <manager> <packageId> at <ISO time> (version of <cmd>)`, #978):
|
|
1008
|
+
`toolchain upgrade started: "<tool>" via <manager>
|
|
924
1009
|
<packageId> (version of <cmd>)` when the job starts (so a run the agent does not survive is on record),
|
|
925
1010
|
and, when it finishes, `toolchain upgrade: "<tool>" via <manager> <packageId>: <before> -> <after> (<status>,
|
|
926
1011
|
exit <code>, version of <cmd>)`. The tool key is console-supplied, so it is printed in quotes (its
|
|
@@ -938,9 +1023,9 @@ ones on a terminal) is written as `\xNN`, and so are U+2028 and U+2029 (which Py
|
|
|
938
1023
|
most editors break lines on) and the explicit bidirectional embeddings, overrides and isolates
|
|
939
1024
|
U+202A-202E and U+2066-2069 (which reorder text), as `\uNNNN`. So a request path cannot forge or split an
|
|
940
1025
|
audit line, or reorder one with an explicit bidi control; the implicit marks U+200E, U+200F and U+061C
|
|
941
|
-
are passed through and can only reorder neutral characters within the line. The log is rotated
|
|
942
|
-
file is kept, overwritten at the next rotation),
|
|
943
|
-
for as long as that file is, not for ever.
|
|
1026
|
+
are passed through and can only reorder neutral characters within the line. The log is rotated to `agent.log.1` once it passes 1 MiB, checked when it is opened and on every write (one previous
|
|
1027
|
+
file is kept, overwritten at the next rotation), a logged request path is cut at 200 characters, and a failed write
|
|
1028
|
+
or rotation is dropped rather than failing the request, so a run is on record for as long as that file is, not for ever.
|
|
944
1029
|
|
|
945
1030
|
### Operator setup
|
|
946
1031
|
|
|
@@ -1044,8 +1129,8 @@ The `docker` and `cloudflared` rows (#828) are two of the fleet standard's tools
|
|
|
1044
1129
|
the server stacks run in Docker, and cloudflared runs the tunnels that put them behind Cloudflare
|
|
1045
1130
|
Access. Both are optional: a missing one is amber, never a failed preflight. The standard applies Docker
|
|
1046
1131
|
to `linux-server` and `windows-dev` hosts and cloudflared to `linux-server` only, and the Environment
|
|
1047
|
-
panel grades them there
|
|
1048
|
-
|
|
1132
|
+
panel grades them there and offers an Upgrade: `POST /v1/toolchain/upgrade` accepts both as a `cmd` and
|
|
1133
|
+
defers the run to the next 05:00 America/New_York (#978).
|
|
1049
1134
|
|
|
1050
1135
|
Each row is `{cmd, label, status, required, path, why, version, detail}` -- `version` the first
|
|
1051
1136
|
non-empty line of `--version`'s stdout (CRLF handled), `""` otherwise; `detail` status-dependent
|
|
@@ -1099,6 +1184,11 @@ software is never something a grant could hand to a plugin.
|
|
|
1099
1184
|
3. **Downloads the tarball** (60 s timeout) and refuses it over 20 MB or unless its sha512 matches.
|
|
1100
1185
|
A declared `content-length` over the cap is refused before reading. Without one, the body is read
|
|
1101
1186
|
in full and then checked, so only the timeout bounds that read.
|
|
1187
|
+
**Trust model:** the sha512 compared is the `dist.integrity` the same registry answered in step 2, so
|
|
1188
|
+
it catches a corrupted or swapped tarball but not a malicious publish. No npm registry signature or
|
|
1189
|
+
provenance is checked, so a hostile `@rackbops/ac-agent` release (a compromised npm account or token)
|
|
1190
|
+
would pass these steps and, once an operator clicks Update, run as the privileged per-machine daemon.
|
|
1191
|
+
That is the same trust as the first `npx @rackbops/ac-agent install`.
|
|
1102
1192
|
4. **Reads only `package/dist/ac-agent.mjs` and `package/package.json` out of it,** in memory,
|
|
1103
1193
|
refusing an archive that unpacks past 100 MB. No path from the archive is ever written.
|
|
1104
1194
|
`package.json` must name the same package and version.
|
|
@@ -1142,8 +1232,8 @@ Every refusal, the verified hand-off, and a failed hand-off are logged as
|
|
|
1142
1232
|
## Security model
|
|
1143
1233
|
|
|
1144
1234
|
- **HTTPS only**, with a self-signed certificate generated once at install (RSA 2048, SHA-256,
|
|
1145
|
-
10-year validity; SAN covers the machine's hostname,
|
|
1146
|
-
`127.0.0.1`).
|
|
1235
|
+
10-year validity; SAN covers the machine's hostname, the machine's LAN-preferred IPv4 addresses
|
|
1236
|
+
(virtual and tunnel interfaces skipped unless nothing else exists), and `127.0.0.1`).
|
|
1147
1237
|
- **`GET /v1/health` and `POST /v1/pair` are the only unauthenticated routes.** `/v1/health` answers
|
|
1148
1238
|
`{ok, version, apiVersion, machine, paired, capabilities, grantsRevision, claudeDesktop?}` — enough
|
|
1149
1239
|
for a caller to discover what it's talking to before pairing, nothing that requires trust.
|
|
@@ -1157,9 +1247,9 @@ Every refusal, the verified hand-off, and a failed hand-off are logged as
|
|
|
1157
1247
|
- **`POST /v1/toolchain/upgrade` is the one verb that installs software** (#675), so it is behind two
|
|
1158
1248
|
independent gates (the console's grant, and the machine owner's own `toolchain_upgrade` key in
|
|
1159
1249
|
`agent.toml`, off by default), upgrades with only three fixed command shapes and a validated package
|
|
1160
|
-
id (the one other program a request can name, `cmd`, is limited to
|
|
1161
|
-
preflight already runs: an explicit list, so
|
|
1162
|
-
|
|
1250
|
+
id (the one other program a request can name, `cmd`, is limited to six tools whose `--version` the
|
|
1251
|
+
preflight already runs: an explicit list, so a new preflight row is not on it; `docker` and
|
|
1252
|
+
`cloudflared` are, and their upgrade is deferred to 05:00 America/New_York, #978), takes its elevation from the machine's own configuration rather than ever
|
|
1163
1253
|
elevating itself, and logs every run. See **`POST /v1/toolchain/upgrade`**, including what the Linux
|
|
1164
1254
|
sudoers rule it needs actually grants.
|
|
1165
1255
|
- **The agent does not run an executable it finds in a request's working directory** (#699), in two
|
|
@@ -1208,9 +1298,9 @@ Every refusal, the verified hand-off, and a failed hand-off are logged as
|
|
|
1208
1298
|
**`rejectUnauthorized: false` is not a pin by itself, and pairing it with `checkServerIdentity`
|
|
1209
1299
|
doesn't make it one:** verified directly against this Node version that `checkServerIdentity` is
|
|
1210
1300
|
never invoked at all once `rejectUnauthorized` is false — the whole verification pathway is
|
|
1211
|
-
skipped, so a "pin" layered on top of it is dead code, not an additional check.
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
default `rejectUnauthorized: true`
|
|
1215
|
-
|
|
1216
|
-
|
|
1301
|
+
skipped, so a "pin" layered on top of it is dead code, not an additional check. This package's own
|
|
1302
|
+
CLI uses the same explicit-compare shape (`src/pin.ts`, with a 10 s handshake and response budget) for
|
|
1303
|
+
its `status` and install self-checks, although it holds its own `cert.pem`: `ca: <PEM>` with the
|
|
1304
|
+
default `rejectUnauthorized: true` is not used, because a self-signed cert past its `notAfter` fails
|
|
1305
|
+
chain validation (CERT_HAS_EXPIRED) and would report a healthy agent as down. The expiry is
|
|
1306
|
+
`status`'s `certExpiry` to report.
|
|
@@ -276,6 +276,17 @@ $catalog = @(
|
|
|
276
276
|
# (Rackbops/Tooling#877 adds this one), and the 2.0 Agents panel's Environment row is labelled
|
|
277
277
|
# the same. `claude --version` prints one line, `<semver> (Claude Code)`.
|
|
278
278
|
@{ Cat='VCS & infra'; Name='Claude Code CLI'; Exe='claude'; Ver=@('--version') }
|
|
279
|
+
# Cloud CLIs -- added for Rackbops/Tooling#1307 (fleet toolchain inventory and standard). The
|
|
280
|
+
# standard keys tools by their exact Name here (Rackbops/Tooling#1308 adds these three). The
|
|
281
|
+
# version is the first number on the first non-empty line of the captured `--version` text, and
|
|
282
|
+
# stderr is merged into that text, so a notice a CLI writes there first would win. The CLI's own
|
|
283
|
+
# line is `azure-cli` padded to 25 columns and then the version (` *` ends it when an update
|
|
284
|
+
# exists; core/telemetry/extension lines follow), `Google Cloud SDK <ver>` (component lines
|
|
285
|
+
# follow; the shape third-party guides show, Google documents the flag but no output), and
|
|
286
|
+
# `aws-cli/<ver> Python/<ver> <os>/<release> ...` (one line).
|
|
287
|
+
@{ Cat='VCS & infra'; Name='Azure CLI'; Exe='az'; Ver=@('--version') }
|
|
288
|
+
@{ Cat='VCS & infra'; Name='Google Cloud CLI'; Exe='gcloud'; Ver=@('--version') }
|
|
289
|
+
@{ Cat='VCS & infra'; Name='AWS CLI'; Exe='aws'; Ver=@('--version') }
|
|
279
290
|
@{ Cat='VCS & infra'; Name='Docker Compose'; Exe='docker-compose'; Ver=@('--version') }
|
|
280
291
|
@{ Cat='VCS & infra'; Name='Podman'; Exe='podman'; Ver=@('--version') }
|
|
281
292
|
@{ Cat='VCS & infra'; Name='kubectl'; Exe='kubectl'; Ver=@('version','--client') }
|
|
@@ -377,6 +388,9 @@ $refLinks = @{
|
|
|
377
388
|
'GitHub CLI' = @{ Home='https://cli.github.com'; Repo='cli/cli' }
|
|
378
389
|
'Docker' = @{ Home='https://docker.com'; Repo='docker/cli' }
|
|
379
390
|
'Claude Code CLI' = @{ Home='https://code.claude.com/docs'; Repo='anthropics/claude-code' }
|
|
391
|
+
'Azure CLI' = @{ Home='https://learn.microsoft.com/cli/azure'; Repo='Azure/azure-cli' }
|
|
392
|
+
'Google Cloud CLI' = @{ Home='https://cloud.google.com/sdk'; Repo='' }
|
|
393
|
+
'AWS CLI' = @{ Home='https://aws.amazon.com/cli'; Repo='aws/aws-cli' }
|
|
380
394
|
'Docker Compose' = @{ Home='https://docs.docker.com/compose'; Repo='docker/compose' }
|
|
381
395
|
'Podman' = @{ Home='https://podman.io'; Repo='containers/podman' }
|
|
382
396
|
'kubectl' = @{ Home='https://kubernetes.io'; Repo='kubernetes/kubernetes' }
|
|
@@ -503,7 +517,7 @@ foreach ($n in @(
|
|
|
503
517
|
'rust-gdb','rust-gdbgui','rust-lldb','rust-analyzer',
|
|
504
518
|
'gofmt','godoc','npx','pnpx','corepack','yarnpkg',
|
|
505
519
|
'uvx','uvw','rls',
|
|
506
|
-
'pydoc','2to3','idle','wheel','activate',
|
|
520
|
+
'pydoc','2to3','idle','wheel','activate','aws_completer',
|
|
507
521
|
'php-cgi','phpdbg','erl','epmd','escript','iex','mix','rebar3',
|
|
508
522
|
'bundler','rdoc','ri'
|
|
509
523
|
)) { $companionExe[$n] = $true }
|
|
@@ -624,7 +638,7 @@ run cargo "cargo install" cargo install --list
|
|
|
624
638
|
run gem "gem (user)" gem list --local
|
|
625
639
|
if command -v go >/dev/null 2>&1; then b="$(go env GOBIN)"; [ -z "$b" ] && b="$(go env GOPATH)/bin"; if [ -d "$b" ]; then echo "### go install"; ls -1 "$b" 2>/dev/null; echo; fi; fi
|
|
626
640
|
echo "===UNKNOWN==="
|
|
627
|
-
for c in rustfmt rustdoc clippy-driver cargo-clippy cargo-fmt cargo-miri rust-gdb rust-gdbgui rust-lldb rust-analyzer rls gofmt godoc npx corepack uvx uvw pydoc 2to3 idle bundler rdoc ri erl epmd escript iex mix rebar3; do known[$c]=1; done
|
|
641
|
+
for c in rustfmt rustdoc clippy-driver cargo-clippy cargo-fmt cargo-miri rust-gdb rust-gdbgui rust-lldb rust-analyzer rls gofmt godoc npx corepack uvx uvw pydoc 2to3 idle bundler rdoc ri erl epmd escript iex mix rebar3 aws_completer; do known[$c]=1; done
|
|
628
642
|
gb=""; if command -v go >/dev/null 2>&1; then gb="$(go env GOBIN 2>/dev/null)"; [ -z "$gb" ] && gb="$(go env GOPATH 2>/dev/null)/bin"; fi
|
|
629
643
|
for d in "$HOME/.cargo/bin" "$gb" "$HOME/.local/bin" "$HOME/.dotnet/tools" /usr/local/bin; do
|
|
630
644
|
[ -d "$d" ] || continue
|
package/data/README.md
CHANGED
|
@@ -7,13 +7,13 @@ text (see `../text-plugin.mjs`).
|
|
|
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 `
|
|
11
|
-
that repo, `git -C <artifacts-console checkout> show
|
|
10
|
+
to commit `da0848f` on that repo's `main`, byte-identical, with no local addition. From a checkout of
|
|
11
|
+
that repo, `git -C <artifacts-console checkout> show da0848fa166511cc4aa572dca1391e3c6a851133:artifact-console/data/Get-ToolchainInventory.ps1 | diff - <this repo>/packages/agent/data/Get-ToolchainInventory.ps1`
|
|
12
12
|
prints nothing.
|
|
13
13
|
|
|
14
|
-
- 1,
|
|
14
|
+
- 1,846 lines, 103,473 bytes
|
|
15
15
|
- LF line endings, no BOM, ASCII-only
|
|
16
|
-
- sha256 `
|
|
16
|
+
- sha256 `8d9a8bf57f7870338498b6fe877393009732b25591db62c7ac866075d45a674f`
|
|
17
17
|
|
|
18
18
|
The hash is platform-stable because both repos' `.gitattributes` normalize this file to
|
|
19
19
|
`text=auto eol=lf`, and `toolchain-inventory.test.ts` asserts the embedded text hashes to the value
|
|
@@ -24,13 +24,15 @@ above -- a re-vendored script whose bytes moved fails that test rather than sile
|
|
|
24
24
|
against 1.x's own output, which is the entire point of vendoring rather than rewriting it. The
|
|
25
25
|
`Claude Code CLI` catalog row (after `Docker`) and its `$refLinks` entry, which this agent's
|
|
26
26
|
inventory reports for Rackbops/artifact-console#673, come from 1.x `main` itself since
|
|
27
|
-
artifacts-console#755
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
artifacts-console#755, and so do the `Azure CLI`, `Google Cloud CLI` and `AWS CLI` rows (after
|
|
28
|
+
`Claude Code CLI`) with their `$refLinks` entries, which it reports for Rackbops/Tooling#1307, since
|
|
29
|
+
artifacts-console#761. 1.x's `v1-maintenance` branch carries the same rows since
|
|
30
|
+
artifacts-console#750 and #760, but that branch's script is an older revision without the
|
|
31
|
+
`## Containers` section, so it is not a source to re-copy from. To pick up a newer 1.x revision:
|
|
32
|
+
copy the file from the new `main` commit, update the commit hash and the three numbers above, and
|
|
33
|
+
re-run the embedding/drift-guard tests (`toolchain-inventory.test.ts`) -- they pin the script's
|
|
34
|
+
catalog names (the `Claude Code CLI` and cloud CLI rows included) and schema-version literal, so a
|
|
35
|
+
re-vendor whose shape moved is caught there, not discovered on a card.
|
|
34
36
|
|
|
35
37
|
**Provenance note (Tooling#721 F3, landed 2026-09-29):** re-pinned from `7280ae6` to `8e33836`
|
|
36
38
|
(`git diff --stat 7280ae6 8e33836 -- artifact-console/data/Get-ToolchainInventory.ps1`: 456
|
|
@@ -54,3 +56,13 @@ suite pins directly, are both identical to before.
|
|
|
54
56
|
lines (Rackbops/artifact-console#673) to `5de373e`, byte-identical, via artifacts-console#755 (the
|
|
55
57
|
1.x `main` twin of artifacts-console#750, for artifacts-console#754). `git hash-object` gives
|
|
56
58
|
`50ee5b9ad2cf7ae3722d438bb956daedc322df71` for both files, so the three numbers above are unchanged.
|
|
59
|
+
|
|
60
|
+
**Provenance note (2026-10-17):** re-pinned from `5de373e` to `da0848f`, byte-identical, via
|
|
61
|
+
artifacts-console#761 (the 1.x `main` twin of #760, for Rackbops/Tooling#1307 via
|
|
62
|
+
artifacts-console#758; the 1.x release that knows the three names is `ac-pipeline` 0.4.2). From
|
|
63
|
+
`5de373e` the file gains 14 lines and changes 2: the three catalog rows after `Claude Code CLI`
|
|
64
|
+
under an eight-line comment, their three `$refLinks` entries, and `aws_completer` -- the companion
|
|
65
|
+
binary AWS's installer links beside `aws` -- added to both companion-known lists (the PowerShell
|
|
66
|
+
`$companionExe` and the bash probe's `known[...]` loop), so it is not reported as an uncatalogued
|
|
67
|
+
tool. `git hash-object` gives `5c16db59dae014027172a0c906e17f40aa0c14d6` for both files; the three
|
|
68
|
+
numbers above were re-measured (1,846 lines, 103,473 bytes).
|