@rackbops/ac-agent 2.0.0-alpha.33
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/LICENSE +201 -0
- package/README.md +683 -0
- package/data/Get-ToolchainInventory.ps1 +1397 -0
- package/data/README.md +40 -0
- package/dist/ac-agent.mjs +66722 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
# @rackbops/ac-agent
|
|
2
|
+
|
|
3
|
+
The per-machine host agent for [artifact-console](https://github.com/Rackbops/artifact-console): a
|
|
4
|
+
small HTTPS server, paired to a console over a token, that lets it reach one dev machine's
|
|
5
|
+
`claude`/`git`/`gh` and file system through an allowlisted verb API. Console-initiated pairing (a
|
|
6
|
+
one-time code exchanged for a per-machine token, #48), `repos` list/status/pull and `open` (#49,
|
|
7
|
+
49a), `exec/claude` (with the job registry and its SSE log) plus `exec/gh` (#49, 49b), and the
|
|
8
|
+
`context-pressure` / `subagent-cost` session-log scans (#52, 52a), and `memory-footprint` /
|
|
9
|
+
`toolchain-inventory` (#52, 52b) are all live -- every `scan/:kind` kind #52 planned has landed.
|
|
10
|
+
Every verb also
|
|
11
|
+
requires a per-plugin capability grant, pushed by the console and enforced here (#50) -- see
|
|
12
|
+
**Verbs** and **Security model** below. `GET /v1/preflight` (#55) -- git/gh/node/claude executed,
|
|
13
|
+
chromium only resolved -- is control plane, not a verb; see **Preflight**.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npx @rackbops/ac-agent install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
During the prerelease period, install the newest prerelease explicitly with the `next` dist-tag
|
|
22
|
+
(`npx @rackbops/ac-agent@next install`) -- `release.yml`'s publish step tags every `-alpha.N`/
|
|
23
|
+
`-beta.N`/`-rc.N` version `next` from here on (#269), since npm refuses a plain `npm publish` of a
|
|
24
|
+
prerelease version with no explicit `--tag`; a stable version still publishes under `latest`. The
|
|
25
|
+
very first publish (`2.0.0-alpha.8`, #268) was done by hand under `--tag alpha` before this existed
|
|
26
|
+
-- and because it was the package's first version ever, npm *also* pointed `latest` at
|
|
27
|
+
`2.0.0-alpha.8` (npm's own first-publish behaviour, not a deliberate choice here). So right now a
|
|
28
|
+
bare `npx @rackbops/ac-agent install` (no tag) resolves to `alpha.8`, with `alpha` and `latest` both
|
|
29
|
+
still pointing at it; `next` starts moving with the next prerelease tag, and `latest` only moves
|
|
30
|
+
again once a real stable version is published.
|
|
31
|
+
|
|
32
|
+
### Upgrade with the Tooling script
|
|
33
|
+
|
|
34
|
+
For an existing install, an up-to-date Tooling checkout provides
|
|
35
|
+
[`Update-AcAgent.ps1`](https://github.com/Rackbops/Tooling/blob/main/PowerShell/Update-AcAgent.ps1)
|
|
36
|
+
and its sibling helpers, including `Resolve-GhExe.ps1`. These examples assume the PowerShell session
|
|
37
|
+
is at the root of that checkout:
|
|
38
|
+
|
|
39
|
+
```powershell
|
|
40
|
+
# Local Windows agent, explicit release
|
|
41
|
+
& .\PowerShell\Update-AcAgent.ps1 -Local -Version 2.0.0-alpha.31
|
|
42
|
+
|
|
43
|
+
# Newest release carrying its matching agent tarball, including prereleases
|
|
44
|
+
& .\PowerShell\Update-AcAgent.ps1 -Local -Version latest
|
|
45
|
+
|
|
46
|
+
# Linux targets reached from this PowerShell session
|
|
47
|
+
& .\PowerShell\Update-AcAgent.ps1 -Linux 'user@host-a,user@host-b' -Version 2.0.0-alpha.31
|
|
48
|
+
|
|
49
|
+
# Preview the same Linux operation
|
|
50
|
+
& .\PowerShell\Update-AcAgent.ps1 -Linux 'user@host-a,user@host-b' -Version 2.0.0-alpha.31 -WhatIf
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The updater downloads the matching release tarball once into a fresh temporary directory. A selected
|
|
54
|
+
target already serving that version skips installation. Other targets are read before and after the
|
|
55
|
+
install, including the endpoint version and served-certificate fingerprint; Linux also checks that
|
|
56
|
+
the user service is active. Target failures are independent, and any failure makes the script exit
|
|
57
|
+
nonzero. Temporary-file cleanup is attempted at the end. If the before endpoint was stopped or
|
|
58
|
+
unreadable, there is no fingerprint to compare, so the run cannot verify that it stayed unchanged.
|
|
59
|
+
|
|
60
|
+
`-WhatIf` is a non-mutating preview, not a zero-I/O mode: it still queries releases, downloads the
|
|
61
|
+
tarball, and reads target health, but runs no install, `scp`, or mutating SSH command.
|
|
62
|
+
|
|
63
|
+
Run Windows `-Local` from a [normal PowerShell window](#run-install-from-a-normal-shell). Every
|
|
64
|
+
Windows machine runs `-Local` for itself; remote Windows installation is unsupported. `gh` must have
|
|
65
|
+
access to the release repository. Linux targets need working noninteractive SSH and `node`/`npx` on
|
|
66
|
+
their `PATH`. `-Port` changes only the health-check port (default `8788`); it does not reconfigure the
|
|
67
|
+
agent.
|
|
68
|
+
|
|
69
|
+
### Linux: no special `npx` spelling needed (#278)
|
|
70
|
+
|
|
71
|
+
The plain `npx @rackbops/ac-agent[@next] <verb>` line above is the one spelling that works on Linux
|
|
72
|
+
regardless of npm version -- confirmed on Ubuntu 26.04 with both npm 11.19 and npm 12.0.2. `#278` was
|
|
73
|
+
originally filed as an npm-11-vs-12 `npx` regression (alpha.9 silently printing `npm notice run` and
|
|
74
|
+
exiting 0 with no verb output on a npm 12 box), but the real cause was narrower and applied to *every*
|
|
75
|
+
npm version on Linux: `dist/ac-agent.mjs`'s own entry-point guard compared `import.meta.url` (always
|
|
76
|
+
the symlink-resolved real path) against a **raw, unresolved** `process.argv[1]`. npm's
|
|
77
|
+
`node_modules/.bin/<name>` bin link is a real filesystem symlink on Linux (whether reached via `npx`
|
|
78
|
+
or `npm install -g`), so the two paths never matched, `main()` never ran, and the process exited `0`
|
|
79
|
+
having done nothing -- on npm 11 *and* npm 12 alike. Windows never showed this: npm links a bin there
|
|
80
|
+
with a `.cmd`/`.ps1` shim that invokes `node "<real path>" ...` directly, so `argv[1]` was already the
|
|
81
|
+
real path. Fixed by resolving `argv[1]` through `realpathSync` before comparing (`isEntryPoint` in
|
|
82
|
+
`src/cli.ts`) -- no `--yes`, `--package=`, or `--` flag is needed or helps; none of those spellings
|
|
83
|
+
touch the actual bug. Requires a published release built with this fix (past `2.0.0-alpha.26`); any
|
|
84
|
+
prerelease at or before that still silently no-ops on Linux the same way.
|
|
85
|
+
|
|
86
|
+
Registers a Windows Task Scheduler task (`\ClaudeLocal\ac-agent`, a logon trigger for the current
|
|
87
|
+
user) or, on Linux, a systemd `--user` unit (`ac-agent.service`, `enable --now`), copies the running
|
|
88
|
+
bundle into the state dir (never the `npx` cache — that can be garbage-collected out from under a
|
|
89
|
+
long-running service), writes `agent.toml` if one doesn't already exist, and generates a self-signed
|
|
90
|
+
cert if one doesn't already exist. On an existing install, an explicit `--repos-root <abs>` narrowly
|
|
91
|
+
updates only the top-level `repos_root` string in `agent.toml`; comments, ordering, unknown keys,
|
|
92
|
+
credentials, grants, and every other setting stay untouched. The old and new roots are compared as
|
|
93
|
+
exact strings, so an equal value does not rewrite the file or change its mtime. A malformed file or
|
|
94
|
+
a non-string `repos_root` is refused rather than rewritten. Prints the resolved config/state paths,
|
|
95
|
+
the cert's fingerprint, and the firewall rule's status.
|
|
96
|
+
|
|
97
|
+
Options: `--port <n>` (default `8788`), `--name <machine>` (default `os.hostname()`), `--repos-root
|
|
98
|
+
<abs>` (default `<homedir>/repos`; the fleet's roots differ per box — Windows and Linux checkouts
|
|
99
|
+
don't share a layout, so every real install passes this explicitly). A relative `--repos-root` is
|
|
100
|
+
rejected before installation side effects on both fresh and existing installs. On an existing
|
|
101
|
+
install, `--name` and `--port` remain intentionally ignored and print the configured value they
|
|
102
|
+
kept; platform registration and restart checks use that existing port. If
|
|
103
|
+
`AC_AGENT_REPOS_ROOT` is non-blank and differs from an explicitly saved `--repos-root`, install
|
|
104
|
+
warns that the environment still wins at runtime until it is unset.
|
|
105
|
+
|
|
106
|
+
Installing over an already-running agent restarts it (#277): once the task/unit is re-registered
|
|
107
|
+
against the new bundle, `install` stops the old process and starts it again through the same
|
|
108
|
+
`stop`/`start` machinery the `ac-agent stop`/`ac-agent start` commands use, then confirms the new
|
|
109
|
+
process is actually answering (a health check, retried for a few seconds — the OS command that
|
|
110
|
+
launches a process can report success before that process has actually started listening) before
|
|
111
|
+
printing `restarted: now serving <version>`. If the restart fails outright, or the health check
|
|
112
|
+
never comes back healthy, the install is still reported as a success — the printed line is
|
|
113
|
+
`restart failed: <reason>; run ac-agent stop && ac-agent start`, and that manual sequence is the
|
|
114
|
+
fallback. A fresh install, with nothing running yet, prints neither line.
|
|
115
|
+
|
|
116
|
+
### The firewall step
|
|
117
|
+
|
|
118
|
+
`install` tries to add an inbound rule (`New-NetFirewallRule`, Windows only) so a LAN caller can
|
|
119
|
+
reach the port. Creating a firewall rule needs administrator rights; when the process isn't
|
|
120
|
+
elevated, `install` prints the exact one-liner to run in an elevated PowerShell and `status` reports
|
|
121
|
+
`firewall: missing` until that's done. This is expected on a fresh install run from an ordinary
|
|
122
|
+
shell — it is not a failure to work around.
|
|
123
|
+
|
|
124
|
+
### Run install from a normal shell
|
|
125
|
+
|
|
126
|
+
The Claude desktop app is an MSIX package (`Claude_<hash>`, e.g. `Claude_pzs8sxrjxfjjc`). A Claude
|
|
127
|
+
Code session it starts — and every process that session spawns, including a PowerShell/Bash tool
|
|
128
|
+
shell running `npx @rackbops/ac-agent install` — inherits the package identity, and MSIX
|
|
129
|
+
**virtualizes AppData**: a write to a subfolder of `%LOCALAPPDATA%`/`%APPDATA%` is silently
|
|
130
|
+
redirected into the package's private `...\Packages\<pkg>\LocalCache\Local\...`/
|
|
131
|
+
`...\LocalCache\Roaming\...`, while the writing process itself believes it wrote the real path.
|
|
132
|
+
`install` writes the bundle copy, `launch.js`, the cert and `agent.toml` under the state/config
|
|
133
|
+
dirs, so run from such a session those files land somewhere the Windows logon task — launched later
|
|
134
|
+
with the user's real, unpackaged token — can never see, and the task exits `1` at once (#263).
|
|
135
|
+
`install` detects this (a nonce probe file, not just the presence of an old package copy, so a stale
|
|
136
|
+
VFS leftover from a prior virtualized install never blocks a later normal one) and refuses with:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
install ran inside a packaged app (<packageFamily>), which virtualizes AppData: the files landed
|
|
140
|
+
under <vfsPath> where the logon task cannot see them. Run ac-agent install from a normal PowerShell
|
|
141
|
+
window, not from a session started by the Claude desktop app.
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Run `install` from a normal PowerShell window opened from the Start menu, not from a Claude Code
|
|
145
|
+
session the desktop app started. `status`/`serve` don't refuse the same way (see **Status** and
|
|
146
|
+
**serve / stop / start** below) — a `serve` launched from a virtualized session still works, since
|
|
147
|
+
everything it reads and writes goes through the same virtualized view within that one session; it's
|
|
148
|
+
only a *task* launched later, outside that session, that can't see the files.
|
|
149
|
+
|
|
150
|
+
### Linux
|
|
151
|
+
|
|
152
|
+
Run `install` as the **login user you'll actually use the agent as, never `root`**. As root there is
|
|
153
|
+
no user session bus for the `systemd --user` unit to register against —
|
|
154
|
+
`loginctl show-user`/`systemctl --user` calls fail with `Failed to connect to user scope bus via
|
|
155
|
+
local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined` — and a root install
|
|
156
|
+
would in any case put the config/state under `/root`, where the operator's own shell never looks.
|
|
157
|
+
|
|
158
|
+
A `systemd --user` unit only survives past that user's login session — logout, or a reboot — if
|
|
159
|
+
**lingering** is enabled for that user:
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
sudo loginctl enable-linger "$USER"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
This can be run before or after `install`; order doesn't matter, because it governs the **user
|
|
166
|
+
manager's** lifetime (whether `systemd --user` keeps running with no session attached), not the
|
|
167
|
+
`ac-agent.service` unit itself. Without it, the user manager — and the agent with it — stops the
|
|
168
|
+
moment the last session for that user ends.
|
|
169
|
+
|
|
170
|
+
The unit's HTTPS listener isn't up the instant `install` returns — a `curl` fired immediately can
|
|
171
|
+
race the bind and report a false failure. Wait a moment, then check:
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
curl -k https://127.0.0.1:8788/v1/health
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Windows: no console window
|
|
178
|
+
|
|
179
|
+
The task's action is `wscript.exe //B "<launch.js>"` against a small generated JScript launcher
|
|
180
|
+
(written into the state dir alongside the bundle copy), not a bare `node` invocation and not
|
|
181
|
+
`conhost.exe --headless` (an earlier design that turned out to run nothing at all — verified live,
|
|
182
|
+
`conhost.exe` isn't a generic headless-launch wrapper). `launch.js` runs
|
|
183
|
+
`WScript.CreateObject("WScript.Shell").Run("<node> <bundle> serve --config-dir <abs> --state-dir
|
|
184
|
+
<abs>", 0, true)` in a loop (the two dirs `install` itself resolved, baked in the same way
|
|
185
|
+
`<node>`/`<bundle>` already are — see **Config and state directories** below), retrying on a crash.
|
|
186
|
+
Window style `0` keeps node's console from ever appearing (Node has no `nodew.exe`, the
|
|
187
|
+
`pythonw.exe`-equivalent 1.x used), and waiting (`true`, not fire-and-forget) keeps `wscript.exe`
|
|
188
|
+
itself alive as the task's tracked process for the agent's entire lifetime, so the task reads
|
|
189
|
+
Running while serving and a crash propagates as a non-zero exit, visible afterward as
|
|
190
|
+
`LastTaskResult`. **`RestartCount 3` does not actually restart a crashed agent** — verified live (two
|
|
191
|
+
scratch tasks with these exact settings, one killed by pid and one exiting on its own, sampled every
|
|
192
|
+
30 s for 150 s): Task Scheduler's restart-on-failure does not act on the action's exit code for this
|
|
193
|
+
action shape. The real crash-restart mechanism is `launch.js`'s own retry loop (#238): a non-zero
|
|
194
|
+
exit sleeps a minute and runs node again, up to 3 retries, since the exit code is already in
|
|
195
|
+
`wscript.exe`'s own hand; a zero exit (a deliberate shutdown) ends the loop instead. `stop`/`uninstall`
|
|
196
|
+
stop the task first — ending the loop — before killing the orphaned node by pid, the reverse of the
|
|
197
|
+
order that would otherwise let the loop see a bare kill as a crash and resurrect the agent a minute
|
|
198
|
+
after `stop`. Verified live: killing node by pid left the task Running with a fresh node pid
|
|
199
|
+
listening within about a minute; `stop` afterward left the task `Ready` with `LastTaskResult 267014`
|
|
200
|
+
(0x41306, terminated by user) and nothing listening 150 s later. The launcher is JScript rather than
|
|
201
|
+
VBScript specifically because
|
|
202
|
+
VBScript is a Windows feature-on-demand Microsoft is phasing out (Insider builds first); JScript and
|
|
203
|
+
Windows Script Host itself carry no such notice, so the same mechanism ships without a deprecated
|
|
204
|
+
component in the loop. (A PowerShell hidden launcher — `Start-Process -WindowStyle Hidden -PassThru
|
|
205
|
+
-Wait` — was also tried and verified working, but was dropped in favor of keeping Windows Script
|
|
206
|
+
Host as the only script host involved.) One thing this package cannot verify by itself — no CI
|
|
207
|
+
runner can register a real logon task — is named as unverified rather than assumed: whether any
|
|
208
|
+
window is visible at a real logon.
|
|
209
|
+
|
|
210
|
+
## Status
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
npx @rackbops/ac-agent status
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Prints, as JSON: the config path and whether it came from a file or defaults, the state dir, the
|
|
217
|
+
Task Scheduler/systemd service state, whether a pairing token is stored (`paired: true/false`), the
|
|
218
|
+
cert's fingerprint, the result of a live `GET /v1/health` call against itself (using its own pinned
|
|
219
|
+
cert, not a system CA — see **Security model** below), the currently-held grants
|
|
220
|
+
(`{revision, plugins: <count>} | null`, from `grants.json` — `null` when it is absent or malformed,
|
|
221
|
+
the same as a freshly-purged or never-pushed-to agent), the effective `reposRoot` and a live
|
|
222
|
+
`reposCount` computed from the same immediate-child, `.git`-entry, realpath-contained listing as
|
|
223
|
+
`GET /v1/repos` (a missing or unreadable root is zero), and `virtualized: true/false` (#263) — `true`
|
|
224
|
+
when this session's own AppData writes to the resolved config/state dirs are virtualized by a
|
|
225
|
+
packaged app (see **Run install from a normal shell** above), which also prints a `warning: this
|
|
226
|
+
session's AppData is virtualized (<packageFamily>); the logon task cannot see <stateDir> -- see
|
|
227
|
+
README, Install` line to stderr. `serve` logs the same warning line once at startup, in the same
|
|
228
|
+
circumstance, without refusing to start.
|
|
229
|
+
|
|
230
|
+
## Pair
|
|
231
|
+
|
|
232
|
+
```sh
|
|
233
|
+
npx @rackbops/ac-agent pair [--force]
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Generates a one-time pairing code and prints exactly three lines:
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
code: <8-char code>
|
|
240
|
+
url: <this machine's URL>
|
|
241
|
+
fingerprint: <cert fingerprint>
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
The code is 8 characters from an alphabet that excludes ambiguous glyphs (`0`/`O`, `1`/`I`/`L`, easy
|
|
245
|
+
to misread off a small screen or mishear read aloud) and expires in **10 minutes**; only its hash is
|
|
246
|
+
ever written to disk (`<stateDir>/pair.json`). Refuses with `already paired -- pass --force to
|
|
247
|
+
re-pair (mints a fresh code)` if a token is already stored — `--force` starts a fresh pairing
|
|
248
|
+
without discarding the old token until the *new* code is actually consumed, so a stale in-flight
|
|
249
|
+
attempt never wins a race against a still-valid one. Requires a certificate to already exist
|
|
250
|
+
(`install` always creates one); refuses with `no certificate found -- run install first` otherwise.
|
|
251
|
+
|
|
252
|
+
**On the console side**, open its Admin → Agents panel (the `agents` plugin, `plugins/agents/src/
|
|
253
|
+
ui.tsx`) and fill in the pairing form with the three printed values plus an optional machine name:
|
|
254
|
+
URL, Fingerprint, Code, Name. Submitting calls `POST /api/agents` — success adds the machine to the
|
|
255
|
+
paired list; the console then reaches it, per capability, once an operator grants that capability to
|
|
256
|
+
a plugin for it in the same panel's Grants grid (see **Security model** below — nothing runs until a
|
|
257
|
+
capability is both declared by the plugin and granted here).
|
|
258
|
+
|
|
259
|
+
## Uninstall
|
|
260
|
+
|
|
261
|
+
```sh
|
|
262
|
+
npx @rackbops/ac-agent uninstall [--purge]
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Stops and unregisters the task/unit, removes the copied bundle, cert, key, token hash, pending-pair
|
|
266
|
+
file, grants and lock file. Keeps `agent.toml` unless `--purge` is given, so a re-install remembers
|
|
267
|
+
the machine's chosen port/name.
|
|
268
|
+
|
|
269
|
+
## serve / stop / start
|
|
270
|
+
|
|
271
|
+
`serve` is what the registered task/unit actually runs — it isn't meant to be invoked directly
|
|
272
|
+
except for debugging. `stop`/`start` control the registered service (`Stop-`/`Start-ScheduledTask`
|
|
273
|
+
or `systemctl --user stop/start`) without touching the registration itself.
|
|
274
|
+
|
|
275
|
+
## Config and state directories
|
|
276
|
+
|
|
277
|
+
| | Windows | Linux |
|
|
278
|
+
|---|---|---|
|
|
279
|
+
| Config (`agent.toml`) | `%APPDATA%\ac-agent\` | `~/.config/ac-agent/` |
|
|
280
|
+
| State (cert, key, token hash, grants, log, pid, the installed bundle copy) | `%LOCALAPPDATA%\ac-agent\` | `~/.local/state/ac-agent/` |
|
|
281
|
+
|
|
282
|
+
Both are overridable with `AC_AGENT_CONFIG_DIR` / `AC_AGENT_STATE_DIR` — **absolute paths only**; a
|
|
283
|
+
relative override throws, naming the value. `install` resolves these once, in its own process, and
|
|
284
|
+
bakes the resolved absolute paths into the registered task/unit as `serve --config-dir <abs>
|
|
285
|
+
--state-dir <abs>`, the same way it already baked in the node/bundle paths — rather than relying on
|
|
286
|
+
`serve` to re-resolve them from its own environment when the task/unit actually runs. This matters
|
|
287
|
+
because a Task Scheduler process (and a systemd `--user` unit) is a separate process tree that does
|
|
288
|
+
not see the installing shell's variables — transient or persisted — so before this, an install under
|
|
289
|
+
the overrides silently served from the real platform defaults instead, with a different certificate
|
|
290
|
+
and the default port (E4 finding 13, fixed in #241). A manual `serve` with no flags still honours
|
|
291
|
+
`AC_AGENT_CONFIG_DIR`/`AC_AGENT_STATE_DIR` as before.
|
|
292
|
+
|
|
293
|
+
`agent.toml`:
|
|
294
|
+
|
|
295
|
+
```toml
|
|
296
|
+
port = 8788
|
|
297
|
+
bind = "0.0.0.0"
|
|
298
|
+
machine = "melody"
|
|
299
|
+
repos_root = "S:/Repos"
|
|
300
|
+
claude_projects_dir = "C:/Users/melody/.claude/projects"
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Unknown keys are ignored, so a future field never breaks an older agent reading a newer file.
|
|
304
|
+
`repos_root` is overridable live with `AC_AGENT_REPOS_ROOT` the same way `port` is with
|
|
305
|
+
`AC_AGENT_PORT` — absolute paths only; a relative override throws, naming the value.
|
|
306
|
+
`claude_projects_dir` (#52, 52a; default `<homedir>/.claude/projects`) is handled identically, with
|
|
307
|
+
its own live override `AC_AGENT_CLAUDE_PROJECTS_DIR` — it names the root the `context-pressure` and
|
|
308
|
+
`subagent-cost` scans read, overridable so a fixture tree or a non-default `CLAUDE_CONFIG_DIR`
|
|
309
|
+
still resolves. Unlike `repos_root`, `install` takes no flag for it and `status` does not report
|
|
310
|
+
it.
|
|
311
|
+
|
|
312
|
+
## Verbs
|
|
313
|
+
|
|
314
|
+
Everything under `/v1/*` except `/v1/health` and `/v1/pair` requires pairing (see **Security
|
|
315
|
+
model**). The verb registry fills `GET /v1/health`'s `capabilities` field with the sorted, unique
|
|
316
|
+
list of every registered verb's capability name — with every verb group registered, that's
|
|
317
|
+
`["agent.desktop.open", "agent.exec.claude", "agent.exec.gh", "agent.git.pull", "agent.git.read",
|
|
318
|
+
"agent.publish.mirror", "agent.scan.context-pressure", "agent.scan.memory-footprint",
|
|
319
|
+
"agent.scan.subagent-cost", "agent.scan.toolchain-inventory"]`.
|
|
320
|
+
|
|
321
|
+
**Every verb also requires two headers, checked in this order** (#50), after the bearer check
|
|
322
|
+
passes: `X-AC-Plugin` (the calling plugin's id) missing or shaped wrong is `400 {ok: false, error:
|
|
323
|
+
"missing X-AC-Plugin"}` / `"invalid X-AC-Plugin"`; `X-AC-Grants-Revision` missing is `400 {ok:
|
|
324
|
+
false, error: "missing X-AC-Grants-Revision"}`; a value that doesn't match this agent's own current
|
|
325
|
+
revision — including a never-pushed `null` — is `409 {ok: false, error: "grants out of date",
|
|
326
|
+
revision: <this agent's own, or null>}`, since a stale console must self-heal before its refusal
|
|
327
|
+
means anything; only once the agent's own copy is known-current does an ungranted capability get a
|
|
328
|
+
real refusal, `403 {ok: false, error: "<capability> is not granted to <plugin> on <machine>",
|
|
329
|
+
capability, plugin}`. See **Security model** for how a grant reaches the agent in the first place.
|
|
330
|
+
|
|
331
|
+
- **`GET /v1/repos`** (`agent.git.read`) → `{ok, root, repos: [{name, path, ownerName}]}`: every
|
|
332
|
+
immediate child of `repos_root` with a `.git` entry (a file for a worktree, a directory for a
|
|
333
|
+
clone), sorted by name. A missing or unreadable root answers
|
|
334
|
+
`{ok: true, repos: [], reason: "root missing"}`, never a 500. `ownerName` (#304) is the checkout's
|
|
335
|
+
`owner/name` resolved from its `origin` remote (`git remote get-url origin`, parsed the way 1.x's
|
|
336
|
+
`init_config.py:parse_remote` does), or `null` when there's no remote or it isn't a GitHub-shaped
|
|
337
|
+
URL — never an error. This makes the route spawn one `git remote get-url` per repo (parallelized),
|
|
338
|
+
so it is no longer git-free the way it was before #304.
|
|
339
|
+
- **`GET /v1/repos/:name/status`** (`agent.git.read`) → `{ok, name, path, ownerName, branch,
|
|
340
|
+
defaultBranch, upstream, ahead, behind, dirty, inProgress}` (`ownerName` as above), read-only (no
|
|
341
|
+
fetch — `pull` is what refreshes the refs `status` reads).
|
|
342
|
+
- **`POST /v1/repos/:name/pull`** (`agent.git.pull`), body `{dryRun?: boolean}` → `{ok: true, row}`
|
|
343
|
+
where `row.status` is `updated | would | current | skipped | error` and `row.reason` names which
|
|
344
|
+
of the six 1.x-derived guards applied (`dirty`, `branch`, `ahead`, `in-progress`, `no-upstream`,
|
|
345
|
+
`diverged`) when it skipped, or `not-a-repo` / `fetch-failed` / `dry-run`. Fast-forward only
|
|
346
|
+
(`git merge --ff-only`) — a diverged branch is reported, never resolved. Every git call runs with
|
|
347
|
+
`GIT_TERMINAL_PROMPT=0`, so a credential prompt refuses instead of hanging a request until its
|
|
348
|
+
timeout.
|
|
349
|
+
- **`:name` is an allowlist, not a path** — it must match a machine-generated repo name pattern, must
|
|
350
|
+
not be `.`/`..`, and must actually be one of the entries `GET /v1/repos` would list; anything else
|
|
351
|
+
is `404 {"error": "no such repo"}` before any git call runs.
|
|
352
|
+
- **`POST /v1/open`** (`agent.desktop.open`), body `{url}` → `{ok: true}`. Only a `https:` or
|
|
353
|
+
`claude:` URL is accepted (the console's own deep-link shape,
|
|
354
|
+
`claude://code/new?q=<prompt>&folder=<path>`); anything else is `400` before any process spawns.
|
|
355
|
+
The `200` attests only that the launcher (`rundll32.exe url.dll,FileProtocolHandler` on Windows,
|
|
356
|
+
`xdg-open` on Linux) started — never a shell string, always argv — not that a browser or Claude
|
|
357
|
+
Desktop actually opened; that's visible to the person at the desktop, not to the caller.
|
|
358
|
+
- **`POST /v1/scan/<kind>`** (`agent.scan.<kind>`, one capability per kind), body `{}` — a pure
|
|
359
|
+
dispatch shell (#49, 49c) that mounts one verb per kind registered into it; an unregistered
|
|
360
|
+
`<kind>` is `404 {"error": "no such verb"}`, the same as any other unregistered route — there is
|
|
361
|
+
no separate `GET /v1/scan` list, since the kinds a machine implements are already visible as
|
|
362
|
+
`agent.scan.<kind>` entries in `GET /v1/health`'s `capabilities`. One run per kind at a time: a
|
|
363
|
+
second `POST` while one is still in flight is `409 {ok: false, error: "scan already running"}`
|
|
364
|
+
(these scans read whole `~/.claude` trees and must not stack); a kind's own `run()` gets an
|
|
365
|
+
`AbortSignal` that fires at its `timeoutMs` — a run that doesn't honour it promptly answers the
|
|
366
|
+
client `504 {ok: false, error: "scan timed out after <n> ms"}` while the run keeps going in the
|
|
367
|
+
background (a later `POST` for that kind still gets `409`, now naming "timed out, still
|
|
368
|
+
finishing", until the run actually settles); one that never settles at all is abandoned five
|
|
369
|
+
timeouts later, freeing the kind up rather than locking it for the agent's whole lifetime. A
|
|
370
|
+
successful run answers `200 {ok: true, kind, generatedAt, durationMs, payload}`; a rejected one,
|
|
371
|
+
`502 {ok: false, error: "<message>"}`.
|
|
372
|
+
- **`context-pressure` and `subagent-cost`** (#52, 52a; `timeoutMs: 900_000` — a cold scan can
|
|
373
|
+
read on the order of a gigabyte of transcripts) both scan `claude_projects_dir` (default
|
|
374
|
+
`<homedir>/.claude/projects`) and are byte-compatible with the 1.x `artifacts-console`
|
|
375
|
+
scheduled tasks of the same name: same JSON shape, same key order, same incremental
|
|
376
|
+
`(mtime, size)` cache, same `os.linesep` cache-file line ending. `context-pressure` walks
|
|
377
|
+
`<root>/<encoded-project>/<session>.jsonl` (main session transcripts only — a subagent's own
|
|
378
|
+
transcript has its own separate window and is never folded in) and reports each session's peak
|
|
379
|
+
context-window occupancy (`input + cache_read + cache_creation` tokens, as a percentage of the
|
|
380
|
+
model's window) plus its compaction and unmarked-drop counts. `subagent-cost` walks
|
|
381
|
+
`<root>/<encoded-project>/<session>/subagents/**/agent-*.jsonl` (both the plain-Task layout and
|
|
382
|
+
the nested Workflow one) and reports each subagent's token totals and an API-equivalent dollar
|
|
383
|
+
figure at list rates (never money actually spent — this box runs Claude Code on a
|
|
384
|
+
subscription). Both read exactly six fields per transcript record (`type`, `subtype` /
|
|
385
|
+
`attributionAgent`, `timestamp`, `cwd`, `message.{id, model, usage}`); `message.content` --
|
|
386
|
+
prompts, tool results, file contents -- is never deserialised, by either scan. The cache lands
|
|
387
|
+
at `<stateDir>/scans/context-pressure.json` / `<stateDir>/scans/subagent-cost.json`, created on
|
|
388
|
+
first write and removed by `uninstall` regardless of `--purge`.
|
|
389
|
+
- **`memory-footprint`** (#52, 52b; no custom `timeoutMs` -- a cold run is kilobytes of I/O, not
|
|
390
|
+
the transcript-scale reads above) is a producer only, computed live on every request and never
|
|
391
|
+
cached: the fixed 8-layer set Claude Code assembles into a session's preamble (`enterprise`,
|
|
392
|
+
`user`, `user-rules` -- global, loaded into every session everywhere -- plus `project`,
|
|
393
|
+
`project-alt`, `project-rules`, `project-local`, `auto-memory` per repo, `auto-memory` truncated
|
|
394
|
+
to the first 200 lines of `MEMORY.md` under `claude_projects_dir`, same as `context-pressure`/
|
|
395
|
+
`subagent-cost`). The repo set is `GET /v1/repos`' own listing (every immediate child of
|
|
396
|
+
`repos_root` with a `.git` entry) -- unlike 1.x, which read the console's configured repo list,
|
|
397
|
+
so the payload's `repo` and `name` fields are both the directory name, never an `owner/name`
|
|
398
|
+
string. Tokens are a byte estimate (~4 chars/token; the payload says `estimated: true`); only
|
|
399
|
+
sizes and line counts ever leave this scan -- file content is never read into the payload.
|
|
400
|
+
- **`toolchain-inventory`** (#52, 52b; `timeoutMs: 600_000` -- about seventy version probes plus a
|
|
401
|
+
WSL probe) ships and runs 1.x's own `Get-ToolchainInventory.ps1` (vendored byte-for-byte,
|
|
402
|
+
`data/README.md`) through `pwsh -NoProfile -File <script> -OutFile <state>/scans/
|
|
403
|
+
toolchain-inventory-<hostname>.md -NoRender`, with the child's `cwd` set to `<state>/scans` --
|
|
404
|
+
**never a project directory**, since the script's own `pnpm --version` probe rewrites
|
|
405
|
+
`pnpm-lock.yaml` in whatever directory it runs in. **Windows-only**: on any other platform this
|
|
406
|
+
answers `{skipped: true, reason: "toolchain-inventory is Windows-only (1.x had no Linux
|
|
407
|
+
equivalent)"}` without spawning anything. Writes (and reads back) `<state>/scans/
|
|
408
|
+
toolchain-inventory-<hostname>.md` and its `.json` sidecar, and maintains its own copy of the
|
|
409
|
+
script at `<state>/scans/Get-ToolchainInventory.ps1` (rewritten only when the bundled text
|
|
410
|
+
changes, via a temp file + rename so a killed agent never leaves a half-written copy). A
|
|
411
|
+
Store-installed `pwsh` -- an App Execution Alias under `%LOCALAPPDATA%\Microsoft\WindowsApps`,
|
|
412
|
+
which a plain Node exists check cannot see -- is found: on Windows the agent's exists check falls
|
|
413
|
+
back to `lstat` (#439), the same check `which` and the preflight use. A missing `pwsh` on PATH is
|
|
414
|
+
`502 {"error": "pwsh not found on PATH; probed: <each PATH directory's pwsh.exe, in PATH order;
|
|
415
|
+
past 40 directories, the first 20 and the last 20 around ... (+N more) ...>"}` before any spawn
|
|
416
|
+
(the registry PATH lists the machine hive then the user hive, so the tail is the end of the user
|
|
417
|
+
hive, where WindowsApps is; entries in the middle of a very long PATH can still be left out, and
|
|
418
|
+
the marker counts them); the request timeout kills only the
|
|
419
|
+
`pwsh` child, never a hung probe or `wsl.exe` process it may have spawned (1.x had the same
|
|
420
|
+
scope). The hostname used for the file name and inside the script's own report is
|
|
421
|
+
`os.hostname()` -- never the operator's configured `machine`/`--name` override -- so the agent's
|
|
422
|
+
file name always matches 1.x's own for a byte-compare.
|
|
423
|
+
|
|
424
|
+
### `POST /v1/exec/claude` and the job registry
|
|
425
|
+
|
|
426
|
+
- **`POST /v1/exec/claude`** (`agent.exec.claude`), body `{repo, prompt, permissionMode?, model?}` →
|
|
427
|
+
`202 {ok: true, jobId}`. `repo` resolves through the same allowlist as `GET /v1/repos/:name/status`
|
|
428
|
+
(a 404 before anything spawns); `prompt` is a non-empty string, at most 8 KiB (the Windows
|
|
429
|
+
`CreateProcess` command-line limit is 32,767 characters total, and quoting can double a prompt's
|
|
430
|
+
own backslashes and quotes — 8 KiB leaves headroom for the fixed flags and the repo path in the
|
|
431
|
+
worst case) with no control character other than `\n`/`\t`; `permissionMode` is one of `plan`
|
|
432
|
+
(the default) / `acceptEdits` / `bypassPermissions`; `model` is an optional
|
|
433
|
+
`^[A-Za-z0-9._-]{1,64}$` string. Any other shape is `400` before anything spawns. `claude` runs as
|
|
434
|
+
`claude -p <prompt> --permission-mode <mode> --add-dir <repo path> [--model <model>]`, `cwd` the
|
|
435
|
+
resolved repo. **At most 4 `claude` jobs run at once** — a 5th concurrent request is
|
|
436
|
+
`429 {ok: false, error: "too many running jobs"}`, checked before spawning, not after.
|
|
437
|
+
- **`GET /v1/jobs`** (`agent.exec.claude`) → `{ok: true, jobs: [view...]}`, newest first.
|
|
438
|
+
**`GET /v1/jobs/:id`** → `{ok: true, job: view}` or `404` for an unknown id. A job view is
|
|
439
|
+
`{id, kind: "claude", label, status: "running"|"done"|"failed", startedAt, endedAt?,
|
|
440
|
+
exitCode?: number|null, repo, permissionMode}`. **`POST /v1/jobs/clear-finished`** →
|
|
441
|
+
`{ok: true, cleared}`, dropping every done/failed job (running ones are kept).
|
|
442
|
+
- **`GET /v1/jobs/:id/events`** (`agent.exec.claude`) streams a job's log as Server-Sent Events: a
|
|
443
|
+
`404` for an unknown id up front, otherwise a finished job replays its whole buffer and closes,
|
|
444
|
+
and a running job replays its buffer then streams new lines live as they arrive, closing once the
|
|
445
|
+
job finishes. Two frame shapes only: `event: line` with `data` the JSON-encoded line text and `id`
|
|
446
|
+
the line's absolute position in the job's log (so a reconnecting client can tell whether it missed
|
|
447
|
+
lines to eviction); `event: done` with `data` `{"status": "done"|"failed", "exitCode":
|
|
448
|
+
number|null}`. These are the exact event names and shapes the host's own `/api/tasks/:id/log`
|
|
449
|
+
emits (`packages/host/src/tasks/route.ts`) with `exitCode` added, since the console's shell code
|
|
450
|
+
already knows how to consume them.
|
|
451
|
+
|
|
452
|
+
### `POST /v1/exec/gh`
|
|
453
|
+
|
|
454
|
+
- **`POST /v1/exec/gh`** (`agent.exec.gh`), body `{args: string[]}` → `200 {ok: true, code, stdout,
|
|
455
|
+
stderr, truncated}` (stdout/stderr each capped at 1 MiB, `truncated: true` when either was cut). A
|
|
456
|
+
non-zero `gh` exit is still a `200` — the caller reads `code`. At most 32 arguments, each at most
|
|
457
|
+
4 KiB with no control characters, except a `--body` value (`--body <text>` or `--body=<text>`),
|
|
458
|
+
which may be up to 64 KiB and contain `\n`/`\t` (a PR/issue comment body).
|
|
459
|
+
- **Every argument is checked against a fixed allowlist before anything spawns** — an unlisted
|
|
460
|
+
subcommand, flag, or malformed positional is `400 {ok: false, error: "<token and why>"}`, logged as
|
|
461
|
+
`gh refused: <error>`, additive-only (nothing here is ever loosened without a deliberate change to
|
|
462
|
+
the table):
|
|
463
|
+
|
|
464
|
+
| Subcommand | Positional | Flags |
|
|
465
|
+
|---|---|---|
|
|
466
|
+
| `issue view` | one `^[0-9]{1,9}$` | `--repo`, `--json`, `--jq` |
|
|
467
|
+
| `issue list` | none | `--repo`, `--json`, `--jq`, `--limit` (1-200), `--label`, `--author`, `--search`, `--state` (`open`\|`closed`\|`all`) |
|
|
468
|
+
| `issue comment` | one `^[0-9]{1,9}$` | `--repo`, `--body` |
|
|
469
|
+
| `pr view` | one `^[0-9]{1,9}$` | `--repo`, `--json`, `--jq` |
|
|
470
|
+
| `pr list` | none | `--repo`, `--json`, `--jq`, `--limit` (1-200), `--label`, `--author`, `--search`, `--state` (`open`\|`closed`\|`merged`\|`all`) |
|
|
471
|
+
| `pr checks` | one `^[0-9]{1,9}$` | `--repo`, `--json`, `--jq` |
|
|
472
|
+
| `pr comment` | one `^[0-9]{1,9}$` | `--repo`, `--body` |
|
|
473
|
+
| `release view` | one `^v?[0-9][A-Za-z0-9._-]{0,63}$` | `--repo` |
|
|
474
|
+
| `api` | one `^/?(repos\|orgs\|users\|rate_limit)(/(?!\.{1,2}(?:/\|$))[A-Za-z0-9._-]+)*$`, no query string | `--jq`, `--method` (`GET` only) |
|
|
475
|
+
|
|
476
|
+
`--repo` must be `^(?!\.{1,2}(?:/|$))[A-Za-z0-9._-]+/(?!\.{1,2}(?:/|$))[A-Za-z0-9._-]+$` (a bare
|
|
477
|
+
`.`/`..` half is refused the same way `resolveRepo` already refuses a bare `.`/`..` repo name — a
|
|
478
|
+
real owner or repo name may still contain a dot, e.g. `my.repo`); `--json` must be `^[A-Za-z,]+$`.
|
|
479
|
+
Both this and the `api` row's path pattern above build the same `.`/`..`-excluding segment rule
|
|
480
|
+
from one shared source (`SAFE_SEGMENT_SOURCE` in `gh-allowlist.ts`), not two independent copies.
|
|
481
|
+
`api` is **read-only by construction**: no request-body flags at all (`-f`, `-F`, `--input`, or `--method`
|
|
482
|
+
with anything but `GET` are refused), `search` is deliberately not in the path allowlist since a
|
|
483
|
+
real search needs a `q=` query string this table never permits, and a bare `.`/`..` path segment
|
|
484
|
+
is refused too — the segment class allows a literal `.` for a real repo/org name (`my.repo`), so
|
|
485
|
+
without this a `/repos/../search/issues` would otherwise satisfy the pattern and reach exactly
|
|
486
|
+
the excluded path space. `--flag=value` is split
|
|
487
|
+
before matching, and a bare `-`/`--` is always refused. A flag or subcommand not in this table —
|
|
488
|
+
`repo delete`, `issue delete`, `--body-file`, `--editor`, `--web`, `--hostname`, anything else — is
|
|
489
|
+
refused the same way, before `gh` is ever resolved or spawned.
|
|
490
|
+
|
|
491
|
+
### The `.cmd`/`.bat` shim note (`claude` and `gh`)
|
|
492
|
+
|
|
493
|
+
Both `exec/claude` and `exec/gh` resolve their binary by walking `PATH` themselves rather than
|
|
494
|
+
letting a shell do it, because neither ever sets `shell: true` (request input reaching a shell is
|
|
495
|
+
exactly what this agent refuses to do). Node itself cannot spawn a `.cmd`/`.bat` shim without
|
|
496
|
+
`shell: true` (`EINVAL`, since the CVE-2024-27980 fix) — so a `claude` or `gh` install that only put
|
|
497
|
+
a shim on `PATH` (common for a global npm install on Windows) answers `502` with a message naming
|
|
498
|
+
the shim specifically (`"claude is a .cmd shim; install the native Claude Code binary"` /
|
|
499
|
+
`"gh is a .cmd shim; install the native GitHub CLI binary"`), distinct from a bare
|
|
500
|
+
`"<name> not found on PATH"` — the operator's fix differs (install the native binary vs. install it
|
|
501
|
+
at all). Windows PATH resolution stops at the **first** directory with any matching extension, so a
|
|
502
|
+
shim earlier on `PATH` is reported even if a native binary exists further down — that is what a real
|
|
503
|
+
invocation would actually run, not "helpful" behavior a real shell wouldn't show either.
|
|
504
|
+
|
|
505
|
+
### `POST /v1/publish/mirror`
|
|
506
|
+
|
|
507
|
+
**`POST /v1/publish/mirror`** (`agent.publish.mirror`, #53) is the 2.0 home of 1.x's hourly
|
|
508
|
+
`publish_onedrive.py`: the console pushes changed report HTML to this verb rather than the agent
|
|
509
|
+
pulling from the console (the console sits behind Cloudflare Access, which an agent cannot pass).
|
|
510
|
+
Body `{files: [{href, base64}], wanted: string[], pdf: boolean, prune: boolean}` — `files` at most
|
|
511
|
+
1000 entries, each `href` a relative POSIX path (no empty/`.`/`..` segment, no drive/UNC anchor)
|
|
512
|
+
ending `.html`, `base64` a valid base64 string, decoded total at most 48 MB (a request body over
|
|
513
|
+
~66 MB is rejected by the route's own raised body-size cap before JSON parsing even runs, `413
|
|
514
|
+
{ok: false, error: "request body too large"}`); `wanted` an array of such hrefs (every html href the
|
|
515
|
+
caller currently wants mirrored, not just the changed ones in `files`); `pdf`/`prune` booleans. `409
|
|
516
|
+
{ok: false, error: "publish target not configured"}` when `publish_dir` is unset (most paired
|
|
517
|
+
machines — a Linux box has no OneDrive). One run at a time: `409 {ok: false, error: "publish already
|
|
518
|
+
running"}`.
|
|
519
|
+
|
|
520
|
+
- **HTML**: each file's bytes are compared against what's on disk (size, then sha256) — unchanged
|
|
521
|
+
bytes are never rewritten (OneDrive re-uploads every rewrite); a changed or new file is written to
|
|
522
|
+
`<publish_dir>/html/<href>.tmp-<pid>` then renamed into place. A write/rename failure (a file
|
|
523
|
+
OneDrive has locked mid-sync) counts as `failed` and the run continues with the next file.
|
|
524
|
+
- **PDF** (when `pdf: true`): one `pdf/<href>.pdf` per `wanted` report (never for an `index.html`
|
|
525
|
+
page), rendered with a local headless Chromium-family browser only when missing or older than its
|
|
526
|
+
html — resolved via the same search `GET /v1/preflight`'s `chromium` row uses (winPaths, then
|
|
527
|
+
PATH, then a version manager's bin dir), never executed otherwise. No resolvable browser is not an
|
|
528
|
+
error: html still mirrors, the response's `pdf.browser` is `null`.
|
|
529
|
+
- **Ownership, not "not in my docroot"** (1.x issue #230): the publish target is shared across every
|
|
530
|
+
paired machine that mirrors to it, so pruning (`prune: true`) is scoped to what THIS machine's
|
|
531
|
+
ledger — `<stateDir>/published-ledger.json`, machine-local, never inside the publish target itself
|
|
532
|
+
— claims it published; a file another machine owns is never even considered for deletion. With no
|
|
533
|
+
ledger yet, a machine adopts conservatively (only `wanted ∩ mirrored`). A prune is refused outright
|
|
534
|
+
(a veto, nothing deleted) when `wanted` is empty, or when it would delete ≥5 files AND over half
|
|
535
|
+
the mirror in one run — the response's `html.veto`/`pdf.veto` names the reason; re-run with the
|
|
536
|
+
same request (or a `--force`-equivalent isn't exposed here — see `publish-adopt` below for the
|
|
537
|
+
one-time claim that closes an adoption gap) once satisfied it's intended.
|
|
538
|
+
- Response `200 {ok: true, dest, html: {written, unchanged, failed, pruned, veto}, pdf: {rendered,
|
|
539
|
+
upToDate, failed, pruned, veto, browser}}`.
|
|
540
|
+
|
|
541
|
+
**`ac-agent publish-adopt`** (CLI, not a verb; one-time): refuses when `publish_dir` is unset,
|
|
542
|
+
otherwise claims every file currently in the mirror for this machine's ledger. Run exactly once, only
|
|
543
|
+
on the machine that published the existing (pre-agent) mirror, right at cut-over — without it those
|
|
544
|
+
pre-agent files are unowned and permanently unprunable, since conservative adoption only ever claims
|
|
545
|
+
files a run both mirrors and finds already present.
|
|
546
|
+
|
|
547
|
+
**Config**: `publish_dir` in `agent.toml` (env override `AC_AGENT_PUBLISH_DIR`, absolute-only, like
|
|
548
|
+
`repos_root`/`claude_projects_dir`) — default on Windows: `%OneDrive%\Claude-Artifacts` (or
|
|
549
|
+
`%OneDriveConsumer%\Claude-Artifacts` when only that's set); `null` (unconfigured) on every other
|
|
550
|
+
platform, and on a Windows box with neither OneDrive env var. `status` reports it under `publishDir`
|
|
551
|
+
(`null` when unconfigured).
|
|
552
|
+
|
|
553
|
+
## Health: `claudeDesktop`
|
|
554
|
+
|
|
555
|
+
`GET /v1/health` carries `claudeDesktop: boolean` (#457) — whether this machine can open a
|
|
556
|
+
`claude://` link, so the console can refuse to hand a deep link to a machine that has no Claude
|
|
557
|
+
Desktop. **The field is omitted until the agent holds a conclusive answer**: a console reads a
|
|
558
|
+
missing field as "unknown", never as "no". Detection is read-only and best-effort: only `reg.exe
|
|
559
|
+
query` (Windows) or `xdg-mime query` (Linux) is ever run.
|
|
560
|
+
|
|
561
|
+
- **Windows: `true` when either signal is present.** The classic registration — a `URL Protocol`
|
|
562
|
+
value under `HKCU\Software\Classes\claude` or `HKLM\Software\Classes\claude` — or a packaged
|
|
563
|
+
(MSIX) one: a `Claude_*` package key under
|
|
564
|
+
`HKCU\Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages`
|
|
565
|
+
holding a `claude` value (named exactly, with data) under a key ending `\URLAssociations` (the package
|
|
566
|
+
manifest's URL-protocol registration; the same package's `FileAssociations` never counts). An
|
|
567
|
+
MSIX app's own `HKCU` writes can be private to the package, so a normal process cannot count on
|
|
568
|
+
seeing a classic key for a packaged install; both are checked, the classic pair first and the
|
|
569
|
+
packaged lookup (at most five package keys) only when both miss. `reg.exe`'s "End of search"
|
|
570
|
+
trailer is localized, so it is never parsed — only exit codes and key/value lines.
|
|
571
|
+
- **Linux:** `xdg-mime query default x-scheme-handler/claude` exits 0 with non-blank output.
|
|
572
|
+
- **Every other platform:** `false`, and nothing is run (a definite answer).
|
|
573
|
+
- **Detection is never request-driven.** `serve` runs a detection at start-up and then every five
|
|
574
|
+
minutes on its own timer (unref'd, so it never keeps the process alive; stopped on shutdown).
|
|
575
|
+
`GET /v1/health` only READS the answer the agent holds: it never starts, awaits or delays a
|
|
576
|
+
detection, so a persistently failing detection costs one attempt per five minutes however often
|
|
577
|
+
health is called.
|
|
578
|
+
- **Only a conclusive detection changes the held answer.** A detection is *inconclusive* if a
|
|
579
|
+
command timed out, could not be started (other than the binary being absent) or threw, or if the
|
|
580
|
+
whole detection ran past 15 seconds. An inconclusive detection never replaces the held answer,
|
|
581
|
+
and while none is held the field stays omitted. A `true` answer is always conclusive, and a
|
|
582
|
+
detection whose commands all gave an answer is definitive, including a `false` (reg.exe's "not
|
|
583
|
+
found" exit, `xdg-mime` with no handler, a binary that is not there). A child killed from outside
|
|
584
|
+
is reported as an ordinary exit code, so it counts as an answer. A detection abandoned at its
|
|
585
|
+
15-second deadline is discarded whole: its late result changes nothing.
|
|
586
|
+
- **Two log lines, edge-triggered:** `claude desktop: yes` / `claude desktop: no` when the held
|
|
587
|
+
answer is first set or changes, and one line when detection becomes inconclusive (not one per
|
|
588
|
+
attempt).
|
|
589
|
+
- **Lag:** after Claude Desktop is installed or removed the agent knows within five minutes plus one
|
|
590
|
+
detection (at most 15 seconds), provided detection is conclusive; the console learns it on its next
|
|
591
|
+
probe of that machine (the scheduled agent-job tick) or at once on a Ping from the Agents panel. A
|
|
592
|
+
restarted agent holds no answer until its first conclusive detection, so a probe landing in that
|
|
593
|
+
window stores "unknown".
|
|
594
|
+
|
|
595
|
+
## Preflight
|
|
596
|
+
|
|
597
|
+
**`GET /v1/preflight`** (#55): control plane, like `PUT /v1/grants` -- bearer-gated, no capability,
|
|
598
|
+
no `X-AC-Plugin`/`X-AC-Grants-Revision` headers, and refuses a caller carrying `X-AC-Plugin` with
|
|
599
|
+
`403 {ok: false, error: "preflight is the console's, not a plugin's"}`. The console's own
|
|
600
|
+
`POST /api/agents/:id/preflight` (`packages/host`) relays it into the Agents panel's Environment
|
|
601
|
+
section. Live and stateless: nothing is cached on either side, and a call answers `200 {ok: true,
|
|
602
|
+
machine, checkedAt, searchPath: "registry" | "process", rows}`, five rows always, in this order:
|
|
603
|
+
|
|
604
|
+
| `cmd` | required | executed | verdicts |
|
|
605
|
+
|---|---|---|---|
|
|
606
|
+
| `git` | yes | `git --version` | `ok` \| `missing` \| `broken` \| `off-path` |
|
|
607
|
+
| `gh` | yes | `gh --version`, then `gh auth status` | `ok` \| `missing` \| `broken` \| `off-path` \| `unauthenticated` |
|
|
608
|
+
| `node` | yes | `node --version` | `ok` \| `missing` \| `broken` \| `off-path` |
|
|
609
|
+
| `claude` | yes | `claude --version` | `ok` \| `missing` \| `broken` \| `off-path` |
|
|
610
|
+
| `chromium` | no | never | `ok` \| `missing` |
|
|
611
|
+
|
|
612
|
+
Each row is `{cmd, label, status, required, path, why, version, detail}` -- `version` the first
|
|
613
|
+
non-empty line of `--version`'s stdout (CRLF handled), `""` otherwise; `detail` status-dependent
|
|
614
|
+
free text (a `broken` row's last non-empty output line, `unauthenticated`'s
|
|
615
|
+
`run gh auth login [-- <stderr line>]`, `off-path`'s `found under a version manager, not on PATH`),
|
|
616
|
+
`""` when there is nothing to add.
|
|
617
|
+
|
|
618
|
+
- **Executed, not just resolved.** `git`/`gh`/`node`/`claude` are resolved on the refreshed PATH
|
|
619
|
+
(below) AND run (`<exe> --version`, a **15 s** timeout per spawn); `gh` additionally runs
|
|
620
|
+
`gh auth status` (needs the network -- offline it exits non-zero and the row reads
|
|
621
|
+
`unauthenticated` with the stderr line saying why, since a real, validated token is what bullet 2
|
|
622
|
+
wants, not merely a cached one `gh auth token` would report on regardless). `chromium` is
|
|
623
|
+
resolved only, never executed -- required: false, so amber (`missing`), never red, when absent.
|
|
624
|
+
- **A `.cmd`/`.bat` shim is `broken` without ever being run.** Node cannot spawn one without
|
|
625
|
+
`shell: true`, so a shim is reported the same way `exec/claude`/`exec/gh` already do -- distinct
|
|
626
|
+
from a bare "not found", naming the shim's own path and the exact fix (install the native
|
|
627
|
+
binary).
|
|
628
|
+
- **The PATH is the machine's, not this process's.** On Windows it is re-read from the registry
|
|
629
|
+
(`HKLM\...\Environment` then `HKCU\Environment` via `reg.exe`, spawned by its absolute
|
|
630
|
+
`System32` path) so a tool installed after the agent's logon task started is still seen; on
|
|
631
|
+
Linux the process PATH plus every version-manager bin directory (`fnm`/`nvm`/`volta`/`asdf`) is
|
|
632
|
+
searched as a fallback, so a tool invisible to a bare `which` reads `off-path`, never `missing`.
|
|
633
|
+
`searchPath` on the report names which: `"registry"` or `"process"`.
|
|
634
|
+
- **The whole check answers in a few seconds.** Five rows run concurrently; the worst case is
|
|
635
|
+
`gh`'s `--version` answering just under 15 s and then `auth status` timing out at 15 s too --
|
|
636
|
+
under the console's own 100 s edge-request budget behind Cloudflare Access.
|
|
637
|
+
|
|
638
|
+
## Security model
|
|
639
|
+
|
|
640
|
+
- **HTTPS only**, with a self-signed certificate generated once at install (RSA 2048, SHA-256,
|
|
641
|
+
10-year validity; SAN covers the machine's hostname, every non-internal IPv4 address, and
|
|
642
|
+
`127.0.0.1`).
|
|
643
|
+
- **`GET /v1/health` and `POST /v1/pair` are the only unauthenticated routes.** `/v1/health` answers
|
|
644
|
+
`{ok, version, apiVersion, machine, paired, capabilities, grantsRevision, claudeDesktop?}` — enough
|
|
645
|
+
for a caller to discover what it's talking to before pairing, nothing that requires trust.
|
|
646
|
+
`grantsRevision` is `null` before this agent has ever received a `PUT /v1/grants` (or if
|
|
647
|
+
`grants.json` went missing). `claudeDesktop` (present only once the agent holds a conclusive
|
|
648
|
+
answer; see **Health: `claudeDesktop`**) discloses one bit
|
|
649
|
+
— that a `claude://` handler is registered on the machine (for the agent's user, or machine-wide)
|
|
650
|
+
— as public as `capabilities`.
|
|
651
|
+
`/v1/pair` authenticates by a one-time code instead of a bearer token, since pairing is what mints
|
|
652
|
+
the token in the first place.
|
|
653
|
+
- **Every other route requires `Authorization: Bearer <token>`**, compared with a
|
|
654
|
+
constant-time equality check against a stored hash (never the token itself). Before pairing,
|
|
655
|
+
every such route answers `401 {"error": "not paired"}`; a present-but-wrong token answers
|
|
656
|
+
`401 {"error": "unauthorized"}`.
|
|
657
|
+
- **Grants: pushed by the console, enforced here, self-healing (#50).** The console's
|
|
658
|
+
`machine_grants` table is the source of truth; every change is pushed via `PUT /v1/grants` (bearer-
|
|
659
|
+
gated, refuses a caller carrying `X-AC-Plugin` — that header is how a real verb's own guard tells a
|
|
660
|
+
plugin's request apart from the console's own, so a plugin can never reach this route), persisted
|
|
661
|
+
to `grants.json`, and read back on every `serve` start. A push that fails at change time (agent
|
|
662
|
+
down, network) is not the end of the story: the very next verb request from that plugin fails its
|
|
663
|
+
revision check (`409`, see **Verbs**), the console pushes again and retries once, and the request
|
|
664
|
+
succeeds or is genuinely refused — so a grant change reaches the agent on its next request either
|
|
665
|
+
way, without restarting either side.
|
|
666
|
+
- **A caller must pin the certificate's fingerprint, not trust a system CA** — there is no CA here,
|
|
667
|
+
by design, and a pairing console holds only the fingerprint an operator typed at pairing time, never
|
|
668
|
+
the certificate's PEM. The correct client shape is therefore an explicit compare, not
|
|
669
|
+
`checkServerIdentity`: open the TLS socket with `rejectUnauthorized: false` (there is no chain to
|
|
670
|
+
validate against), then on connect compare `getPeerCertificate().fingerprint256` — the same
|
|
671
|
+
colon-separated uppercase hex SHA-256 this package's own `status`/install output prints — against
|
|
672
|
+
the pinned value yourself, and destroy the socket on any mismatch. This package's own
|
|
673
|
+
`server.integration.test.ts` exercises exactly that shape against a real TLS socket, both for a
|
|
674
|
+
correct pin and a wrong one.
|
|
675
|
+
**`rejectUnauthorized: false` is not a pin by itself, and pairing it with `checkServerIdentity`
|
|
676
|
+
doesn't make it one:** verified directly against this Node version that `checkServerIdentity` is
|
|
677
|
+
never invoked at all once `rejectUnauthorized` is false — the whole verification pathway is
|
|
678
|
+
skipped, so a "pin" layered on top of it is dead code, not an additional check. (Holding the
|
|
679
|
+
actual certificate PEM in hand — which this package's own CLI does, reading its own on-disk
|
|
680
|
+
`cert.pem` for its `status` self-check — is a different, legitimate case: `ca: <PEM>` with the
|
|
681
|
+
default `rejectUnauthorized: true` validates the chain and keeps `checkServerIdentity` live. A
|
|
682
|
+
pairing console never has that PEM, only the fingerprint, so it needs the explicit-compare shape
|
|
683
|
+
above.)
|