@rackbops/ac-agent 2.0.0-alpha.8

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.
Files changed (4) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +307 -0
  3. package/dist/ac-agent.mjs +74208 -0
  4. package/package.json +41 -0
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 roshne
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,307 @@
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), and `exec/claude` (with the job registry and its SSE log) plus `exec/gh` (#49, 49b) are all
8
+ live; `scan/:kind` (49c) lands in a later issue. Every verb also requires a per-plugin capability
9
+ grant, pushed by the console and enforced here (#50) -- see **Verbs** and **Security model** below.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npx @rackbops/ac-agent install
15
+ ```
16
+
17
+ Registers a Windows Task Scheduler task (`\ClaudeLocal\ac-agent`, a logon trigger for the current
18
+ user) or, on Linux, a systemd `--user` unit (`ac-agent.service`, `enable --now`), copies the running
19
+ bundle into the state dir (never the `npx` cache — that can be garbage-collected out from under a
20
+ long-running service), writes `agent.toml` if one doesn't already exist, and generates a self-signed
21
+ cert if one doesn't already exist. Prints the resolved config/state paths, the cert's fingerprint,
22
+ and the firewall rule's status.
23
+
24
+ Options: `--port <n>` (default `8788`), `--name <machine>` (default `os.hostname()`), `--repos-root
25
+ <abs>` (default `<homedir>/repos`; the fleet's roots differ per box — Windows and Linux checkouts
26
+ don't share a layout, so every real install passes this explicitly).
27
+
28
+ ### The firewall step
29
+
30
+ `install` tries to add an inbound rule (`New-NetFirewallRule`, Windows only) so a LAN caller can
31
+ reach the port. Creating a firewall rule needs administrator rights; when the process isn't
32
+ elevated, `install` prints the exact one-liner to run in an elevated PowerShell and `status` reports
33
+ `firewall: missing` until that's done. This is expected on a fresh install run from an ordinary
34
+ shell — it is not a failure to work around.
35
+
36
+ ### Windows: no console window
37
+
38
+ The task's action is `wscript.exe //B "<launch.js>"` against a small generated JScript launcher
39
+ (written into the state dir alongside the bundle copy), not a bare `node` invocation and not
40
+ `conhost.exe --headless` (an earlier design that turned out to run nothing at all — verified live,
41
+ `conhost.exe` isn't a generic headless-launch wrapper). `launch.js` runs
42
+ `WScript.CreateObject("WScript.Shell").Run("<node> <bundle> serve --config-dir <abs> --state-dir
43
+ <abs>", 0, true)` in a loop (the two dirs `install` itself resolved, baked in the same way
44
+ `<node>`/`<bundle>` already are — see **Config and state directories** below), retrying on a crash.
45
+ Window style `0` keeps node's console from ever appearing (Node has no `nodew.exe`, the
46
+ `pythonw.exe`-equivalent 1.x used), and waiting (`true`, not fire-and-forget) keeps `wscript.exe`
47
+ itself alive as the task's tracked process for the agent's entire lifetime, so the task reads
48
+ Running while serving and a crash propagates as a non-zero exit, visible afterward as
49
+ `LastTaskResult`. **`RestartCount 3` does not actually restart a crashed agent** — verified live (two
50
+ scratch tasks with these exact settings, one killed by pid and one exiting on its own, sampled every
51
+ 30 s for 150 s): Task Scheduler's restart-on-failure does not act on the action's exit code for this
52
+ action shape. The real crash-restart mechanism is `launch.js`'s own retry loop (#238): a non-zero
53
+ exit sleeps a minute and runs node again, up to 3 retries, since the exit code is already in
54
+ `wscript.exe`'s own hand; a zero exit (a deliberate shutdown) ends the loop instead. `stop`/`uninstall`
55
+ stop the task first — ending the loop — before killing the orphaned node by pid, the reverse of the
56
+ order that would otherwise let the loop see a bare kill as a crash and resurrect the agent a minute
57
+ after `stop`. Verified live: killing node by pid left the task Running with a fresh node pid
58
+ listening within about a minute; `stop` afterward left the task `Ready` with `LastTaskResult 267014`
59
+ (0x41306, terminated by user) and nothing listening 150 s later. The launcher is JScript rather than
60
+ VBScript specifically because
61
+ VBScript is a Windows feature-on-demand Microsoft is phasing out (Insider builds first); JScript and
62
+ Windows Script Host itself carry no such notice, so the same mechanism ships without a deprecated
63
+ component in the loop. (A PowerShell hidden launcher — `Start-Process -WindowStyle Hidden -PassThru
64
+ -Wait` — was also tried and verified working, but was dropped in favor of keeping Windows Script
65
+ Host as the only script host involved.) One thing this package cannot verify by itself — no CI
66
+ runner can register a real logon task — is named as unverified rather than assumed: whether any
67
+ window is visible at a real logon.
68
+
69
+ ## Status
70
+
71
+ ```sh
72
+ npx @rackbops/ac-agent status
73
+ ```
74
+
75
+ Prints, as JSON: the config path and whether it came from a file or defaults, the state dir, the
76
+ Task Scheduler/systemd service state, whether a pairing token is stored (`paired: true/false`), the
77
+ cert's fingerprint, the result of a live `GET /v1/health` call against itself (using its own pinned
78
+ cert, not a system CA — see **Security model** below), and the currently-held grants
79
+ (`{revision, plugins: <count>} | null`, from `grants.json` — `null` when it is absent or malformed,
80
+ the same as a freshly-purged or never-pushed-to agent).
81
+
82
+ ## Uninstall
83
+
84
+ ```sh
85
+ npx @rackbops/ac-agent uninstall [--purge]
86
+ ```
87
+
88
+ Stops and unregisters the task/unit, removes the copied bundle, cert, key, token hash, pending-pair
89
+ file, grants and lock file. Keeps `agent.toml` unless `--purge` is given, so a re-install remembers
90
+ the machine's chosen port/name.
91
+
92
+ ## serve / stop / start
93
+
94
+ `serve` is what the registered task/unit actually runs — it isn't meant to be invoked directly
95
+ except for debugging. `stop`/`start` control the registered service (`Stop-`/`Start-ScheduledTask`
96
+ or `systemctl --user stop/start`) without touching the registration itself.
97
+
98
+ ## Config and state directories
99
+
100
+ | | Windows | Linux |
101
+ |---|---|---|
102
+ | Config (`agent.toml`) | `%APPDATA%\ac-agent\` | `~/.config/ac-agent/` |
103
+ | State (cert, key, token hash, grants, log, pid, the installed bundle copy) | `%LOCALAPPDATA%\ac-agent\` | `~/.local/state/ac-agent/` |
104
+
105
+ Both are overridable with `AC_AGENT_CONFIG_DIR` / `AC_AGENT_STATE_DIR` — **absolute paths only**; a
106
+ relative override throws, naming the value. `install` resolves these once, in its own process, and
107
+ bakes the resolved absolute paths into the registered task/unit as `serve --config-dir <abs>
108
+ --state-dir <abs>`, the same way it already baked in the node/bundle paths — rather than relying on
109
+ `serve` to re-resolve them from its own environment when the task/unit actually runs. This matters
110
+ because a Task Scheduler process (and a systemd `--user` unit) is a separate process tree that does
111
+ not see the installing shell's variables — transient or persisted — so before this, an install under
112
+ the overrides silently served from the real platform defaults instead, with a different certificate
113
+ and the default port (E4 finding 13, fixed in #241). A manual `serve` with no flags still honours
114
+ `AC_AGENT_CONFIG_DIR`/`AC_AGENT_STATE_DIR` as before.
115
+
116
+ `agent.toml`:
117
+
118
+ ```toml
119
+ port = 8788
120
+ bind = "0.0.0.0"
121
+ machine = "melody"
122
+ repos_root = "S:/Repos"
123
+ ```
124
+
125
+ Unknown keys are ignored, so a future field never breaks an older agent reading a newer file.
126
+ `repos_root` is overridable live with `AC_AGENT_REPOS_ROOT` the same way `port` is with
127
+ `AC_AGENT_PORT` — absolute paths only; a relative override throws, naming the value.
128
+
129
+ ## Verbs
130
+
131
+ Everything under `/v1/*` except `/v1/health` and `/v1/pair` requires pairing (see **Security
132
+ model**). The verb registry fills `GET /v1/health`'s `capabilities` field with the sorted, unique
133
+ list of every registered verb's capability name — with every verb group registered, that's
134
+ `["agent.desktop.open", "agent.exec.claude", "agent.exec.gh", "agent.git.pull", "agent.git.read"]`.
135
+
136
+ **Every verb also requires two headers, checked in this order** (#50), after the bearer check
137
+ passes: `X-AC-Plugin` (the calling plugin's id) missing or shaped wrong is `400 {ok: false, error:
138
+ "missing X-AC-Plugin"}` / `"invalid X-AC-Plugin"`; `X-AC-Grants-Revision` missing is `400 {ok:
139
+ false, error: "missing X-AC-Grants-Revision"}`; a value that doesn't match this agent's own current
140
+ revision — including a never-pushed `null` — is `409 {ok: false, error: "grants out of date",
141
+ revision: <this agent's own, or null>}`, since a stale console must self-heal before its refusal
142
+ means anything; only once the agent's own copy is known-current does an ungranted capability get a
143
+ real refusal, `403 {ok: false, error: "<capability> is not granted to <plugin> on <machine>",
144
+ capability, plugin}`. See **Security model** for how a grant reaches the agent in the first place.
145
+
146
+ - **`GET /v1/repos`** (`agent.git.read`) → `{ok, root, repos: [{name, path}]}`: every immediate
147
+ child of `repos_root` with a `.git` entry (a file for a worktree, a directory for a clone), sorted
148
+ by name. A missing or unreadable root answers `{ok: true, repos: [], reason: "root missing"}`,
149
+ never a 500.
150
+ - **`GET /v1/repos/:name/status`** (`agent.git.read`) → `{ok, name, path, branch, defaultBranch,
151
+ upstream, ahead, behind, dirty, inProgress}`, read-only (no fetch — `pull` is what refreshes the
152
+ refs `status` reads).
153
+ - **`POST /v1/repos/:name/pull`** (`agent.git.pull`), body `{dryRun?: boolean}` → `{ok: true, row}`
154
+ where `row.status` is `updated | would | current | skipped | error` and `row.reason` names which
155
+ of the six 1.x-derived guards applied (`dirty`, `branch`, `ahead`, `in-progress`, `no-upstream`,
156
+ `diverged`) when it skipped, or `not-a-repo` / `fetch-failed` / `dry-run`. Fast-forward only
157
+ (`git merge --ff-only`) — a diverged branch is reported, never resolved. Every git call runs with
158
+ `GIT_TERMINAL_PROMPT=0`, so a credential prompt refuses instead of hanging a request until its
159
+ timeout.
160
+ - **`:name` is an allowlist, not a path** — it must match a machine-generated repo name pattern, must
161
+ not be `.`/`..`, and must actually be one of the entries `GET /v1/repos` would list; anything else
162
+ is `404 {"error": "no such repo"}` before any git call runs.
163
+ - **`POST /v1/open`** (`agent.desktop.open`), body `{url}` → `{ok: true}`. Only a `https:` or
164
+ `claude:` URL is accepted (the console's own deep-link shape,
165
+ `claude://code/new?q=<prompt>&folder=<path>`); anything else is `400` before any process spawns.
166
+ The `200` attests only that the launcher (`rundll32.exe url.dll,FileProtocolHandler` on Windows,
167
+ `xdg-open` on Linux) started — never a shell string, always argv — not that a browser or Claude
168
+ Desktop actually opened; that's visible to the person at the desktop, not to the caller.
169
+ - **`POST /v1/scan/<kind>`** (`agent.scan.<kind>`, one capability per kind), body `{}` — a pure
170
+ dispatch shell (#49, 49c): this package ships no kinds itself, the four #52 scans
171
+ (`context-pressure`, `subagent-cost`, `memory-footprint`, `toolchain-inventory`) register into it,
172
+ so until then every `<kind>` is `404 {"error": "no such verb"}`, the same as any other unregistered
173
+ route — there is no separate `GET /v1/scan` list, since the kinds a machine implements are already
174
+ visible as `agent.scan.<kind>` entries in `GET /v1/health`'s `capabilities`. One run per kind at a
175
+ time: a second `POST` while one is still in flight is `409 {ok: false, error: "scan already
176
+ running"}` (the #52 scans read whole `~/.claude` trees and must not stack); a kind's own `run()`
177
+ gets an `AbortSignal` that fires at its `timeoutMs` (120s default) — a run that doesn't honour it
178
+ promptly answers the client `504 {ok: false, error: "scan timed out after <n> ms"}` while the run
179
+ keeps going in the background (a later `POST` for that kind still gets `409`, now naming "timed
180
+ out, still finishing", until the run actually settles); one that never settles at all is abandoned
181
+ five timeouts later, freeing the kind up rather than locking it for the agent's whole lifetime. A
182
+ successful run answers `200 {ok: true, kind, generatedAt, durationMs, payload}`; a rejected one,
183
+ `502 {ok: false, error: "<message>"}`.
184
+
185
+ ### `POST /v1/exec/claude` and the job registry
186
+
187
+ - **`POST /v1/exec/claude`** (`agent.exec.claude`), body `{repo, prompt, permissionMode?, model?}` →
188
+ `202 {ok: true, jobId}`. `repo` resolves through the same allowlist as `GET /v1/repos/:name/status`
189
+ (a 404 before anything spawns); `prompt` is a non-empty string, at most 8 KiB (the Windows
190
+ `CreateProcess` command-line limit is 32,767 characters total, and quoting can double a prompt's
191
+ own backslashes and quotes — 8 KiB leaves headroom for the fixed flags and the repo path in the
192
+ worst case) with no control character other than `\n`/`\t`; `permissionMode` is one of `plan`
193
+ (the default) / `acceptEdits` / `bypassPermissions`; `model` is an optional
194
+ `^[A-Za-z0-9._-]{1,64}$` string. Any other shape is `400` before anything spawns. `claude` runs as
195
+ `claude -p <prompt> --permission-mode <mode> --add-dir <repo path> [--model <model>]`, `cwd` the
196
+ resolved repo. **At most 4 `claude` jobs run at once** — a 5th concurrent request is
197
+ `429 {ok: false, error: "too many running jobs"}`, checked before spawning, not after.
198
+ - **`GET /v1/jobs`** (`agent.exec.claude`) → `{ok: true, jobs: [view...]}`, newest first.
199
+ **`GET /v1/jobs/:id`** → `{ok: true, job: view}` or `404` for an unknown id. A job view is
200
+ `{id, kind: "claude", label, status: "running"|"done"|"failed", startedAt, endedAt?,
201
+ exitCode?: number|null, repo, permissionMode}`. **`POST /v1/jobs/clear-finished`** →
202
+ `{ok: true, cleared}`, dropping every done/failed job (running ones are kept).
203
+ - **`GET /v1/jobs/:id/events`** (`agent.exec.claude`) streams a job's log as Server-Sent Events: a
204
+ `404` for an unknown id up front, otherwise a finished job replays its whole buffer and closes,
205
+ and a running job replays its buffer then streams new lines live as they arrive, closing once the
206
+ job finishes. Two frame shapes only: `event: line` with `data` the JSON-encoded line text and `id`
207
+ the line's absolute position in the job's log (so a reconnecting client can tell whether it missed
208
+ lines to eviction); `event: done` with `data` `{"status": "done"|"failed", "exitCode":
209
+ number|null}`. These are the exact event names and shapes the host's own `/api/tasks/:id/log`
210
+ emits (`packages/host/src/tasks/route.ts`) with `exitCode` added, since the console's shell code
211
+ already knows how to consume them.
212
+
213
+ ### `POST /v1/exec/gh`
214
+
215
+ - **`POST /v1/exec/gh`** (`agent.exec.gh`), body `{args: string[]}` → `200 {ok: true, code, stdout,
216
+ stderr, truncated}` (stdout/stderr each capped at 1 MiB, `truncated: true` when either was cut). A
217
+ non-zero `gh` exit is still a `200` — the caller reads `code`. At most 32 arguments, each at most
218
+ 4 KiB with no control characters, except a `--body` value (`--body <text>` or `--body=<text>`),
219
+ which may be up to 64 KiB and contain `\n`/`\t` (a PR/issue comment body).
220
+ - **Every argument is checked against a fixed allowlist before anything spawns** — an unlisted
221
+ subcommand, flag, or malformed positional is `400 {ok: false, error: "<token and why>"}`, logged as
222
+ `gh refused: <error>`, additive-only (nothing here is ever loosened without a deliberate change to
223
+ the table):
224
+
225
+ | Subcommand | Positional | Flags |
226
+ |---|---|---|
227
+ | `issue view` | one `^[0-9]{1,9}$` | `--repo`, `--json`, `--jq` |
228
+ | `issue list` | none | `--repo`, `--json`, `--jq`, `--limit` (1-200), `--label`, `--author`, `--search`, `--state` (`open`\|`closed`\|`all`) |
229
+ | `issue comment` | one `^[0-9]{1,9}$` | `--repo`, `--body` |
230
+ | `pr view` | one `^[0-9]{1,9}$` | `--repo`, `--json`, `--jq` |
231
+ | `pr list` | none | `--repo`, `--json`, `--jq`, `--limit` (1-200), `--label`, `--author`, `--search`, `--state` (`open`\|`closed`\|`merged`\|`all`) |
232
+ | `pr checks` | one `^[0-9]{1,9}$` | `--repo`, `--json`, `--jq` |
233
+ | `pr comment` | one `^[0-9]{1,9}$` | `--repo`, `--body` |
234
+ | `release view` | one `^v?[0-9][A-Za-z0-9._-]{0,63}$` | `--repo` |
235
+ | `api` | one `^/?(repos\|orgs\|users\|rate_limit)(/(?!\.{1,2}(?:/\|$))[A-Za-z0-9._-]+)*$`, no query string | `--jq`, `--method` (`GET` only) |
236
+
237
+ `--repo` must be `^(?!\.{1,2}(?:/|$))[A-Za-z0-9._-]+/(?!\.{1,2}(?:/|$))[A-Za-z0-9._-]+$` (a bare
238
+ `.`/`..` half is refused the same way `resolveRepo` already refuses a bare `.`/`..` repo name — a
239
+ real owner or repo name may still contain a dot, e.g. `my.repo`); `--json` must be `^[A-Za-z,]+$`.
240
+ Both this and the `api` row's path pattern above build the same `.`/`..`-excluding segment rule
241
+ from one shared source (`SAFE_SEGMENT_SOURCE` in `gh-allowlist.ts`), not two independent copies.
242
+ `api` is **read-only by construction**: no request-body flags at all (`-f`, `-F`, `--input`, or `--method`
243
+ with anything but `GET` are refused), `search` is deliberately not in the path allowlist since a
244
+ real search needs a `q=` query string this table never permits, and a bare `.`/`..` path segment
245
+ is refused too — the segment class allows a literal `.` for a real repo/org name (`my.repo`), so
246
+ without this a `/repos/../search/issues` would otherwise satisfy the pattern and reach exactly
247
+ the excluded path space. `--flag=value` is split
248
+ before matching, and a bare `-`/`--` is always refused. A flag or subcommand not in this table —
249
+ `repo delete`, `issue delete`, `--body-file`, `--editor`, `--web`, `--hostname`, anything else — is
250
+ refused the same way, before `gh` is ever resolved or spawned.
251
+
252
+ ### The `.cmd`/`.bat` shim note (`claude` and `gh`)
253
+
254
+ Both `exec/claude` and `exec/gh` resolve their binary by walking `PATH` themselves rather than
255
+ letting a shell do it, because neither ever sets `shell: true` (request input reaching a shell is
256
+ exactly what this agent refuses to do). Node itself cannot spawn a `.cmd`/`.bat` shim without
257
+ `shell: true` (`EINVAL`, since the CVE-2024-27980 fix) — so a `claude` or `gh` install that only put
258
+ a shim on `PATH` (common for a global npm install on Windows) answers `502` with a message naming
259
+ the shim specifically (`"claude is a .cmd shim; install the native Claude Code binary"` /
260
+ `"gh is a .cmd shim; install the native GitHub CLI binary"`), distinct from a bare
261
+ `"<name> not found on PATH"` — the operator's fix differs (install the native binary vs. install it
262
+ at all). Windows PATH resolution stops at the **first** directory with any matching extension, so a
263
+ shim earlier on `PATH` is reported even if a native binary exists further down — that is what a real
264
+ invocation would actually run, not "helpful" behavior a real shell wouldn't show either.
265
+
266
+ ## Security model
267
+
268
+ - **HTTPS only**, with a self-signed certificate generated once at install (RSA 2048, SHA-256,
269
+ 10-year validity; SAN covers the machine's hostname, every non-internal IPv4 address, and
270
+ `127.0.0.1`).
271
+ - **`GET /v1/health` and `POST /v1/pair` are the only unauthenticated routes.** `/v1/health` answers
272
+ `{ok, version, apiVersion, machine, paired, capabilities, grantsRevision}` — enough for a caller to
273
+ discover what it's talking to before pairing, nothing that requires trust. `grantsRevision` is
274
+ `null` before this agent has ever received a `PUT /v1/grants` (or if `grants.json` went missing).
275
+ `/v1/pair` authenticates by a one-time code instead of a bearer token, since pairing is what mints
276
+ the token in the first place.
277
+ - **Every other route requires `Authorization: Bearer <token>`**, compared with a
278
+ constant-time equality check against a stored hash (never the token itself). Before pairing,
279
+ every such route answers `401 {"error": "not paired"}`; a present-but-wrong token answers
280
+ `401 {"error": "unauthorized"}`.
281
+ - **Grants: pushed by the console, enforced here, self-healing (#50).** The console's
282
+ `machine_grants` table is the source of truth; every change is pushed via `PUT /v1/grants` (bearer-
283
+ gated, refuses a caller carrying `X-AC-Plugin` — that header is how a real verb's own guard tells a
284
+ plugin's request apart from the console's own, so a plugin can never reach this route), persisted
285
+ to `grants.json`, and read back on every `serve` start. A push that fails at change time (agent
286
+ down, network) is not the end of the story: the very next verb request from that plugin fails its
287
+ revision check (`409`, see **Verbs**), the console pushes again and retries once, and the request
288
+ succeeds or is genuinely refused — so a grant change reaches the agent on its next request either
289
+ way, without restarting either side.
290
+ - **A caller must pin the certificate's fingerprint, not trust a system CA** — there is no CA here,
291
+ by design, and a pairing console holds only the fingerprint an operator typed at pairing time, never
292
+ the certificate's PEM. The correct client shape is therefore an explicit compare, not
293
+ `checkServerIdentity`: open the TLS socket with `rejectUnauthorized: false` (there is no chain to
294
+ validate against), then on connect compare `getPeerCertificate().fingerprint256` — the same
295
+ colon-separated uppercase hex SHA-256 this package's own `status`/install output prints — against
296
+ the pinned value yourself, and destroy the socket on any mismatch. This package's own
297
+ `server.integration.test.ts` exercises exactly that shape against a real TLS socket, both for a
298
+ correct pin and a wrong one.
299
+ **`rejectUnauthorized: false` is not a pin by itself, and pairing it with `checkServerIdentity`
300
+ doesn't make it one:** verified directly against this Node version that `checkServerIdentity` is
301
+ never invoked at all once `rejectUnauthorized` is false — the whole verification pathway is
302
+ skipped, so a "pin" layered on top of it is dead code, not an additional check. (Holding the
303
+ actual certificate PEM in hand — which this package's own CLI does, reading its own on-disk
304
+ `cert.pem` for its `status` self-check — is a different, legitimate case: `ca: <PEM>` with the
305
+ default `rejectUnauthorized: true` validates the chain and keeps `checkServerIdentity` live. A
306
+ pairing console never has that PEM, only the fingerprint, so it needs the explicit-compare shape
307
+ above.)