@cldmv/gh-broker 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,19 +19,18 @@ It runs as a dedicated `gh-broker` system user, holds a read-scoped and a write-
19
19
 
20
20
  ## ✨ What's New
21
21
 
22
- ### Latest: v0.9.0 (November 2026)
22
+ ### Latest: v0.10.1 (November 2026)
23
23
 
24
- - **Three report verbs for the release and fleet skills.** `report_alert_status` buckets each open Dependabot alert by where its fix stands (released but still open, fixed on `next`, open PR, closed PR, nothing). `report_release_changes` returns the PRs a release actually ships, grouped by changelog section, with the net `package.json` and dependency deltas. `report_dependents` finds every repo that declares an npm package, including dev-trunk repos on `dev`, and skips a `dev` that has fallen behind the default branch.
25
- - **`ciVerbosity` on `list_pull_requests`.** The same opt-in CI detail as `pull_request_read`, across a whole PR list with one shared call budget.
26
- - **Install from npm.** `sudo npx @cldmv/gh-broker <agent-user>` runs the installer from the prebuilt package. The tarball now ships only runtime files.
27
- - [View full v0.9.0 Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.9.0.md)
24
+ - **The OTP gate says why it refused a code.** A wrong code now reads "not accepted", with the bad-code count and the lockout length, and a reused code says it's spent, so the agent can tell you what happened instead of just asking again.
25
+ - **`report_dependents` lists dev branches on large orgs.** The GraphQL listing pages at 25 repos and backs off when GitHub times out, instead of failing with a raw 502.
26
+ - [View full v0.10.1 Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.10.1.md)
28
27
 
29
28
  ### Recent Releases
30
29
 
30
+ - **v0.10.0** (November 2026) — OTP gate for risky write tools, one socket per agent with the `gh-broker` command, eleven new write tools and a built-in deny list ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.10.0.md))
31
+ - **v0.9.0** (November 2026) — `report_alert_status`, `report_release_changes` and `report_dependents` for the release and fleet skills, `ciVerbosity` on `list_pull_requests`, and install from npm ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.9.0.md))
31
32
  - **v0.8.0** (November 2026) — `npm_scaffold` creates an npm package and its trusted publisher, `ghcr_push` pushes images without an agent-held token, and optional credentials hide their tools ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.8.0.md))
32
33
  - **v0.7.0** (October 2026) — `issue_labels` adds, removes or swaps single labels without replacing the set, and `issue_write` warns that its `labels` does ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.7.0.md))
33
- - **v0.6.1** (October 2026) — MCP SDK 1.32.0 (same-origin redirects only) and a refreshed vitest toolchain; no broker source changes ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.6.1.md))
34
- - **v0.6.0** (October 2026) — Compact per-repo roll-up, filters and flow tier for `report_security_alerts`, and release-readiness analysis for `report_release_prs` ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.6.0.md))
35
34
 
36
35
  📚 For the complete version history, see the [docs/changelog/](https://github.com/CLDMV/gh-broker/tree/master/docs/changelog/) folder.
37
36
 
@@ -133,7 +132,7 @@ The broker presents its **own** MCP contract to the agent — a proxied tool's s
133
132
 
134
133
  ### 📦 **One-Command Install & Upgrade**
135
134
 
136
- `sudo node deploy/install.mjs` handles first install and every upgrade: builds under the checkout's own owner (no `chown`, ever), merges shipped config defaults over the deployed config, lets you choose how PATs are stored at rest, and runs a post-deploy smoke test.
135
+ `sudo gh-broker install` (or `sudo node deploy/install.mjs` from a checkout) handles first install and every upgrade: builds under the checkout's own owner (no `chown`, ever), merges shipped config defaults over the deployed config, lets you choose how PATs are stored at rest, and runs a post-deploy smoke test.
137
136
 
138
137
  📦 **Full prerequisites, PAT scopes, and flags: [deploy/README.md](deploy/README.md)**
139
138
 
@@ -152,10 +151,11 @@ The broker presents its **own** MCP contract to the agent — a proxied tool's s
152
151
 
153
152
  gh-broker is a system service, not a library. The installer handles both the first install and every upgrade, and you can run it from npm or from a checkout.
154
153
 
155
- From npm ([`@cldmv/gh-broker`](https://www.npmjs.com/package/@cldmv/gh-broker)), with the prebuilt bundle:
154
+ From npm ([`@cldmv/gh-broker`](https://www.npmjs.com/package/@cldmv/gh-broker)), with the prebuilt bundle, as a global install (upgrade with the same command, then `gh-broker install` again):
156
155
 
157
156
  ```sh
158
- sudo npx @cldmv/gh-broker <agent-user>
157
+ sudo npm install -g --prefix /usr/local --ignore-scripts @cldmv/gh-broker
158
+ sudo gh-broker install <agent-user>
159
159
  ```
160
160
 
161
161
  From a checkout:
@@ -166,13 +166,13 @@ cd gh-broker
166
166
  sudo node deploy/install.mjs <agent-user>
167
167
  ```
168
168
 
169
- Both take the same flags. The installer creates the service user and the socket group, builds as the checkout's owner when there's no bundle yet, merges the shipped defaults with the deployed config, stores the PATs in the backend you pick, installs the unit and runs a smoke test. Prerequisites, PAT scopes, unattended installs and every flag: [deploy/README.md](deploy/README.md).
169
+ Both take the same flags. The installer creates the service user, registers each agent user (its own group, socket and MCP config; see `gh-broker agent`), builds as the checkout's owner when there's no bundle yet, merges the shipped defaults with the deployed config, stores the PATs in the backend you pick, installs the unit and runs a smoke test. Prerequisites, PAT scopes, unattended installs and every flag: [deploy/README.md](deploy/README.md).
170
170
 
171
171
  ---
172
172
 
173
173
  ## 🚀 Quick Start
174
174
 
175
- Point the agent's MCP client at the socket ([deploy/README.md § 3](deploy/README.md#3-point-claude-at-the-broker)). From then on the client sees an ordinary `tools/call`, and the broker resolves the credential, routes it, and (for custom/augmented tools) does the extra work in between:
175
+ The installer writes each agent's MCP config for its own socket ([deploy/README.md § 3](deploy/README.md#3-agents-one-socket-and-one-mcp-config-each)). From then on the client sees an ordinary `tools/call`, and the broker resolves the credential, routes it, and (for custom/augmented tools) does the extra work in between:
176
176
 
177
177
  ```json
178
178
  // tools/call
@@ -201,6 +201,7 @@ src/
201
201
  catalog.ts build the advertised tool list (real upstream schemas + custom + augmented)
202
202
  server.ts Unix-socket MCP server toward the agent (StdioServerTransport)
203
203
  audit.ts structured audit log (never tokens)
204
+ denylist.ts tools refused at config load, gated or not (file writes, repo deletion, repo configuration)
204
205
  verbs/
205
206
  report_unlabeled.ts issues/PRs with no labels, one repo or org-wide
206
207
  report_security_alerts.ts open Dependabot/CodeQL/secret alerts, one repo or org-wide
@@ -216,6 +217,9 @@ src/
216
217
  npm-registry.ts the npm registry requests npm_scaffold makes
217
218
  npm-tarball.ts minimal npm tarball builder for the placeholder
218
219
  review_thread.ts resolve/reply PR review threads (write PAT)
220
+ minimize_comment.ts hide or show a comment (write PAT)
221
+ delete_branch.ts delete a branch, refusing flow branches and open-PR heads/bases (write PAT, gated)
222
+ dismiss_alert.ts dismiss or reopen a code scanning, Dependabot or secret scanning alert (write PAT, gated)
219
223
  ghcr_push.ts ghcr_push / ghcr_push_status / ghcr_tags (GHCR classic PAT)
220
224
  ghcr/ config policy, tar + image-archive reader, OCI registry client, push, job queue
221
225
  link_issue.ts addClosingIssueReference (App token) — DEFERRED, not wired in
@@ -238,7 +242,7 @@ config.sample.json a full config with placeholder values, for reference only
238
242
 
239
243
  ## 📚 Documentation
240
244
 
241
- - **[Tools Reference](docs/TOOLS.md)** — every custom and augmented tool: `report_unlabeled`, `report_security_alerts`, `report_alert_status`, `report_release_prs`, `report_release_changes`, `report_dependents`, `report_ci_status`, the `pull_request_read` and `list_pull_requests` `ciVerbosity` extensions, `issue_labels`, `npm_scaffold`, `resolve_review_thread`/`reply_review_thread`, `ghcr_push`/`ghcr_push_status`/`ghcr_tags`, and how to add a new one
245
+ - **[Tools Reference](docs/TOOLS.md)** — every custom and augmented tool: `report_unlabeled`, `report_security_alerts`, `report_alert_status`, `report_release_prs`, `report_release_changes`, `report_dependents`, `report_ci_status`, the `pull_request_read` and `list_pull_requests` `ciVerbosity` extensions, `issue_labels`, `npm_scaffold`, `resolve_review_thread`/`reply_review_thread`, `minimize_comment`, `delete_branch`, `dismiss_alert`, `ghcr_push`/`ghcr_push_status`/`ghcr_tags`, `otp_status`, the OTP gate and the deny list, and how to add a new one
242
246
  - **[Agent-as-Separate-User Model](docs/agent-user-model.md)** — the isolation model behind "use ≠ possession": users, groups, filesystem permissions, verified against a real host
243
247
  - **[Deploy Architecture](docs/deploy-architecture.md)** — config merge, secret store backends, node handling, the installer's full flow
244
248
  - **[Install Guide](deploy/README.md)** — prerequisites, PAT scopes, install/upgrade command, flags
@@ -1,5 +1,5 @@
1
1
  {
2
- "listen": "unix:/run/gh-broker/gh-broker.sock",
2
+ "listen": "agents:/run/gh-broker/agents",
3
3
  "upstream": "https://api.githubcopilot.com/mcp/x/all",
4
4
  "tools": [
5
5
  {
@@ -256,6 +256,65 @@
256
256
  "mode": "write",
257
257
  "custom": true
258
258
  },
259
+ {
260
+ "name": "update_pull_request_branch",
261
+ "mode": "write"
262
+ },
263
+ {
264
+ "name": "request_copilot_review",
265
+ "mode": "write"
266
+ },
267
+ {
268
+ "name": "minimize_comment",
269
+ "mode": "write",
270
+ "custom": true
271
+ },
272
+ {
273
+ "name": "create_repository",
274
+ "mode": "write"
275
+ },
276
+ {
277
+ "name": "fork_repository",
278
+ "mode": "write"
279
+ },
280
+ {
281
+ "name": "merge_pull_request",
282
+ "mode": "write"
283
+ },
284
+ {
285
+ "name": "pull_request_review_write",
286
+ "mode": "write"
287
+ },
288
+ {
289
+ "name": "actions_run_trigger",
290
+ "mode": "write"
291
+ },
292
+ {
293
+ "name": "label_write",
294
+ "mode": "write"
295
+ },
296
+ {
297
+ "name": "delete_branch",
298
+ "mode": "write",
299
+ "custom": true
300
+ },
301
+ {
302
+ "name": "dismiss_alert",
303
+ "mode": "write",
304
+ "custom": true
305
+ },
306
+ {
307
+ "name": "create_pull_request_with_copilot",
308
+ "mode": "write"
309
+ },
310
+ {
311
+ "name": "assign_copilot_to_issue",
312
+ "mode": "write"
313
+ },
314
+ {
315
+ "name": "assign_copilot_to_issue_with_intent",
316
+ "mode": "write"
317
+ },
259
318
  {
260
319
  "name": "report_unlabeled",
261
320
  "mode": "read",
@@ -305,6 +364,14 @@
305
364
  "name": "ghcr_tags",
306
365
  "mode": "read",
307
366
  "custom": true
367
+ },
368
+ {
369
+ "name": "otp_status",
370
+ "mode": "read",
371
+ "custom": true
308
372
  }
309
- ]
373
+ ],
374
+ "otp": {
375
+ "seed": "cred:otp-seed"
376
+ }
310
377
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "listen": "unix:/run/gh-broker/gh-broker.sock",
2
+ "listen": "agents:/run/gh-broker/agents",
3
3
  "upstream": "https://api.githubcopilot.com/mcp/x/all",
4
4
  "defaultOwner": "your-org",
5
5
  "owners": {
package/deploy/README.md CHANGED
@@ -4,7 +4,7 @@ One interactive installer does first-install and upgrade. Design and rationale:
4
4
 
5
5
  ## 0. Host prerequisites
6
6
 
7
- - **Node ≥ 20** — the only real prerequisite; the installer runs under it (`sudo node deploy/install.mjs`). If node isn't installed yet, the optional Linux bootstrap `sudo ./deploy/install.sh` sets up a system-wide nvm + node + the `/usr/local/bin/node` symlink, then hands off to the Node installer.
7
+ - **Node ≥ 20** — the only real prerequisite; the installer runs under it (`sudo gh-broker install`, or `sudo node deploy/install.mjs` from a checkout). If node isn't installed yet, the optional Linux bootstrap `sudo ./deploy/install.sh` sets up a system-wide nvm + node + the `/usr/local/bin/node` symlink, then hands off to the Node installer.
8
8
  - **`socat`** — the agent's stdio↔socket bridge. **`rsync`** — used to sync the build (falls back to a plain copy if absent). **`systemd`** with `systemctl`. **`sudo`** — used internally to build as the checkout's own owner (see step 5 below), not just to invoke the installer itself. Install what's missing (`apt install socat rsync sudo` etc.).
9
9
 
10
10
  The installer maintains `/usr/local/bin/node` as the single node indirection (CLI and service resolve the same link). Bumping node is re-pointing that symlink — which the installer does on every run.
@@ -14,9 +14,9 @@ The installer maintains `/usr/local/bin/node` as the single node indirection (CL
14
14
  For each GitHub owner (user or org) the broker serves, `<org>`: GitHub → Settings → Developer settings → **Fine-grained tokens** → Generate new token, **twice**. Resource owner: `<org>`. Repository access: **All repositories**.
15
15
 
16
16
  **`<org>-read`** — Issues: Read, Pull requests: Read, Contents: Read, Metadata: Read (auto).
17
- **`<org>-write`** — Issues: R/W, Pull requests: R/W, Contents: R/W, Metadata: Read (auto).
17
+ **`<org>-write`** — Issues: R/W, Pull requests: R/W, Contents: R/W, Metadata: Read (auto). For the tools added in #146, also: Administration: R/W (`create_repository`, `fork_repository`), Actions: R/W (`actions_run_trigger`), Workflows: R/W (`merge_pull_request` and `update_pull_request_branch` on a PR that changes `.github/workflows/`), Code scanning alerts: R/W, Dependabot alerts: R/W and Secret scanning alerts: R/W (`dismiss_alert`). The Copilot coding-agent tools ship disabled and need nothing extra beyond Issues and Pull requests: R/W, plus Copilot coding agent on the account. The installer ships every tool on each run, so a scope left off doesn't hide its tools: the calls are advertised and GitHub refuses them. Administration: R/W is broad: on its own it also lets the token delete repos and change their settings. The broker never offers those, but the token itself can do them, so treat the write PAT accordingly.
18
18
 
19
- No organization or admin permissions. If the org requires approval for fine-grained PATs, approve them in Org → Settings → Personal access tokens. The two-PAT split (read vs write) is a deliberate least-privilege control — see the design doc.
19
+ No organization permissions. If the org requires approval for fine-grained PATs, approve them in Org → Settings → Personal access tokens. The two-PAT split (read vs write) is a deliberate least-privilege control — see the design doc.
20
20
 
21
21
  ### GHCR pushes: one classic PAT (`<org>-packages`)
22
22
 
@@ -27,23 +27,25 @@ The installer also creates the drop directory from `ghcr.dropDir` (default `/srv
27
27
  ## 2. Install (first install AND upgrade — same command)
28
28
 
29
29
  ```sh
30
- sudo npx @cldmv/gh-broker [agent-user] # from npm
31
- sudo node deploy/install.mjs [agent-user] # from a checkout
30
+ sudo npm install -g --prefix /usr/local --ignore-scripts @cldmv/gh-broker # install, or upgrade with @latest
31
+ sudo gh-broker install [agent-user]
32
32
  ```
33
33
 
34
- Both run the same installer and take the same flags; the npm package ships a prebuilt `dist/`, so step 5 has nothing to build. `agent-user` defaults to `$SUDO_USER` (the account you ran `sudo` from). The installer walks the whole thing and is idempotent:
34
+ `--prefix /usr/local` puts the package in `/usr/local/lib/node_modules/@cldmv/gh-broker` and the command at `/usr/local/bin/gh-broker`, independent of the Node version and on `sudo`'s PATH. `--ignore-scripts` because a global install runs as root on the box that holds the tokens. The package ships a prebuilt `dist/`, so there is nothing to build. A copy installed under an nvm version folder breaks when Node is upgraded; `gh-broker install` warns if it finds one. One-offs still work: `sudo npx @cldmv/gh-broker install [agent-user]`, and from a checkout `sudo node deploy/install.mjs [agent-user]`.
35
+
36
+ `gh-broker install` takes the same flags either way. `agent-user` registers that user as an agent (see [Agents](#3-agents-one-socket-and-one-mcp-config-each)); on an upgrade with agents already registered it is optional and adds nobody. A first install with no agent asks for one (`--non-interactive` needs `--agent-user`); it never takes `$SUDO_USER`, the admin running sudo. The installer walks the whole thing and is idempotent:
35
37
 
36
38
  1. checks prerequisites,
37
- 2. **creates or confirms the agent user** (stops if it can't — no silent half-install),
38
- 3. creates the `gh-broker` service user + `gh-broker-clients` bridge group, adds the agent user to it (and, when the config has a `ghcr` block, creates the GHCR drop directory),
39
+ 2. **registers the agents**: members of the old shared `gh-broker-clients` group become registered agents (the group itself is left alone), the user named on the command line is added (and created if missing), and each one gets its own group `gh-broker-agent-<user>`,
40
+ 3. creates the `gh-broker` service user and, when the config has a `ghcr` block, the GHCR drop directory (owned by the agent named on the command line, else the first registered one),
39
41
  4. points `/usr/local/bin/node` at the running node,
40
42
  5. builds `dist/` if it isn't already built, or rebuilds it if a prior run left it (or `node_modules/`) owned by someone other than the checkout's owner — the build itself runs via `sudo -u '#<uid>'` as whoever owns `deploy/install.mjs`, not as root, so the installer never leaves root-owned files behind in your checkout,
41
- 6. regenerates the config by layering `config.example.json`, whatever is deployed, your `--config` file and your flags (your data is preserved; allowlist changes from the repo arrive; see [Configuring the install](#configuring-the-install)); a first install with no owner asks for one,
43
+ 6. regenerates the config (replacing an old `unix:` `listen` with `agents:/run/gh-broker/agents`) by layering `config.example.json`, whatever is deployed, your `--config` file and your flags (your data is preserved; allowlist changes from the repo arrive; see [Configuring the install](#configuring-the-install)); a first install with no owner asks for one,
42
44
  7. sets up the optional **npm integration** when asked (offered on a first install, or `--npm`): an npm web login it waits on, then the token it creates for `npm_scaffold` (see [npm integration](#npm-integration-npm_scaffold)),
43
- 8. **asks how to store the credentials** — `systemd-creds` (encrypted at rest, host-bound; recommended) or a plaintext `file` (`0640 root:gh-broker`; portable) — then stores the tokens in the chosen backend: it keeps existing creds, decrypts a legacy `systemd-creds` token when you switch to `file`, and **prompts only for what it can't find** (input hidden),
45
+ 8. **asks how to store the credentials** — `systemd-creds` (encrypted at rest, host-bound; recommended) or a plaintext `file` (`0640 root:gh-broker`; portable) — then stores the tokens in the chosen backend: it keeps existing creds, decrypts a legacy `systemd-creds` token when you switch to `file`, and **prompts only for what it can't find** (input hidden); when no OTP seed is stored it also generates one and shows it once (see [OTP gate](#otp-gate-gh-broker-otp)),
44
46
  9. validates the config with the real loader (`--check`),
45
- 10. installs the unit, deploys `dist/`, restarts, and
46
- 11. runs a **smoke test** (MCP handshake + a real read over the socket) → ✅/❌.
47
+ 10. installs the unit, deploys `dist/` to `/opt/gh-broker`, restarts, writes every agent's MCP config (`--no-mcp-config` prints the entry instead), and
48
+ 11. runs a **smoke test** over each agent's own socket (MCP handshake + a real read) → ✅/❌.
47
49
 
48
50
  ### Unattended (single non-interactive command)
49
51
 
@@ -59,6 +61,28 @@ Re-running on an existing install needs no `--owner`: the deployed owners are ke
59
61
 
60
62
  **Upgrade** is just re-running it: config merged (edits kept), your previously-chosen storage backend pre-selected, existing tokens kept. You can **switch backends** on any run — pick `file` and it decrypts the existing `systemd-creds` tokens into the file (no re-entry); pick `systemd-creds` and it encrypts the tokens into `.cred` files. `launch.sh` is no longer used — remove it after a successful run: `sudo rm -f /opt/gh-broker/launch.sh` (and `sudo rm -rf /etc/gh-broker/creds` only if you've moved fully to the `file` backend).
61
63
 
64
+ ## OTP gate (`gh-broker otp`)
65
+
66
+ Tools marked `"otp": true` in `/etc/gh-broker/config.json` (an upgrade keeps each tool's `otp` and `enabled` settings) only run when the call carries a current code from an authenticator app, so a code is something only you can supply. The seed behind those codes is generated by the installer and stored next to the PATs, where the agent can't read it.
67
+
68
+ **Setup.** On a first install, and on any later run that finds no stored `otp-seed`, the installer generates a seed, prints it, then stores it in the chosen backend (an encrypted `otp-seed.cred` delivered by the unit drop-in under `systemd-creds`, or the `otp-seed` key in `secrets.json` under `file`). It is printed once, before it is stored, so a run that fails later can't leave a stored seed nobody saw: a QR code of the `otpauth://` URI, the base32 key as text, and the URI itself. If a run fails and the next one prints a different key, use the newer one. Scan the QR code with an authenticator app, or enter the key by hand. `--non-interactive` does the same, so capture the output of an unattended run. An existing seed is never overwritten by the installer. To supply your own, pass `--secret otp-seed=<base32>`, `--secrets-file` or env `GH_BROKER_SECRET_OTP_SEED`; it is then used as is and not printed.
69
+
70
+ **The `gh-broker` command.** The global npm install puts `gh-broker` on root's PATH (`/usr/local/bin/gh-broker`). It needs root, because the state directory is `0700 gh-broker`:
71
+
72
+ ```sh
73
+ sudo gh-broker otp status [<user>] [--json] # per agent: bad codes so far, short lockouts in the last 24 h, hard lock, state file health
74
+ sudo gh-broker otp list [--json] # who is locked out now, and the command that clears each
75
+ sudo gh-broker otp unlock <user> # clear one agent's lockout and hard lock
76
+ sudo gh-broker otp unlock --all # clear everyone
77
+ sudo gh-broker otp rotate # new seed: stored, shown once, service restarted
78
+ ```
79
+
80
+ `unlock <user>` drops that user's entry from the state file and leaves the others alone, rewriting the file atomically as `0600 gh-broker:gh-broker`. Every write from the command also counts each code up to the next 30-second step as spent, so a write that races the daemon can't hand a code back that the daemon just accepted. After an unlock the current code and the one after it are refused (they don't count as bad codes); the command says how many seconds to wait for a fresh one, up to a minute. If the broker writes the state file while the command runs (a lockout, an accepted code), the command changes nothing and asks you to run it again, so it can't undo what the broker just recorded. Each unlock is logged to the journal as `gh-broker`, next to the daemon's audit log, with the sudo user who ran it. If the state file is corrupt the broker treats every user as hard-locked; `status` and `list` report that, `unlock <user>` refuses (clearing one user would not fix the file), and `unlock --all` rewrites it clean. `rotate` replaces the stored seed and restarts the service, which closes every open window; remove the old entry from your authenticator.
81
+
82
+ **State file.** The lock counters live in `/var/lib/gh-broker/otp-state.json` (`otp.statePath` to change it): `version`, a global `lastAcceptedStep`, and a `users` object with `failures`, `lockedUntil`, `lockouts` and `hardLock` per agent user. A user with no entry is all zero. The unit sets `StateDirectory=gh-broker` with `StateDirectoryMode=0700`, which makes the directory writable under `ProtectSystem=strict` and owned by the service user. It survives restarts, so a hard lock does too. The broker re-reads it whenever it changes, so `unlock` takes effect on the next call with no restart.
83
+
84
+ **Config.** `config.example.json` ships `"otp": { "seed": "cred:otp-seed" }`. An upgrade adds it to a deployed config that lacks it; an `otp` block you already have (`maxFailures`, `lockoutSeconds`, `maxLockouts`, `windowSeconds`, `statePath`) wins field by field, and a `--config` file may hold one too.
85
+
62
86
  ## Credential storage — your choice
63
87
 
64
88
  The installer asks which backend holds the tokens at rest:
@@ -101,23 +125,38 @@ Flags that build a single GHCR owner do so for one owner per run; use `--config`
101
125
 
102
126
  With no owner anywhere (a first install with no flags and no `--config`), an interactive run asks for the GitHub owner name and then for its two tokens; `--non-interactive` fails and names `--owner` / `--config`.
103
127
 
104
- ## 3. Point Claude at the broker
128
+ ## 3. Agents: one socket and one MCP config each
129
+
130
+ The broker knows which agent is calling from the socket the call arrives on: there is one Unix socket per agent user, `/run/gh-broker/agents/<user>.sock`, reachable only by that user. An agent is registered when the group `gh-broker-agent-<user>` exists with exactly `gh-broker` and `<user>` as members (user names are lowercase, up to 16 characters: `^[a-z_][a-z0-9_-]{0,15}$`). There is no shared socket, so a locked-out agent can't keep using the broker through another door.
105
131
 
106
- The installer prints this; add it to the agent user's `~/.claude.json` `mcpServers`:
132
+ ```sh
133
+ sudo gh-broker agent add <user> # group, MCP config, restart, smoke test over the user's socket
134
+ sudo gh-broker agent remove <user> # empty the group, restart (closes the socket), then the MCP entry (backup first) and the user's lock state
135
+ sudo gh-broker agent list # socket path, whether it exists, and whether the MCP config points at it (ok / missing / points elsewhere)
136
+ sudo gh-broker agent sync [<user>] # re-apply the MCP config for one agent or all
137
+ ```
138
+
139
+ `agent remove` empties the agent's group rather than deleting it. Processes the user already has running keep that group's id, so deleting it would let a later `agent add` reuse the id and give those processes the new agent's socket. An empty group isn't an agent; `agent add` for the same user fills it again.
140
+
141
+ **The MCP config.** The broker writes the entry into the top-level `mcpServers` of `<home>/.claude.json`, using `sg` so the new group is effective without a re-login:
107
142
 
108
143
  ```json
109
144
  "github": {
110
145
  "type": "stdio",
111
- "command": "socat",
112
- "args": ["STDIO", "UNIX-CONNECT:/run/gh-broker/gh-broker.sock"]
146
+ "command": "sg",
147
+ "args": ["gh-broker-agent-<user>", "-c", "exec socat STDIO UNIX-CONNECT:/run/gh-broker/agents/<user>.sock"]
113
148
  }
114
149
  ```
115
150
 
116
- Re-login as the agent user first, so its `gh-broker-clients` membership is active.
151
+ If an existing entry already mentions `/run/gh-broker/` (the old shared socket or the new one) that entry is updated in place and keeps its name; otherwise the entry is added as `github`, or `gh-broker` when that name is taken. Nothing else in the file changes. The file is parsed, edited, written to a temp file in the same directory and renamed, keeping its owner and mode, and `~/.claude.json.gh-broker.bak` keeps the previous copy (overwritten each time). A missing file is created `0600`; a file that doesn't parse is left alone and the entry is printed to add by hand. The home directory belongs to the agent user and the command runs as root, so it reads the file without following a symlink and only touches a plain file the user owns with a single link; anything else (a symlink, a hard link, someone else's file) is left alone with a warning, and the temp file is created with a random name that can't be pre-planted. Running Claude sessions for that user must be restarted to pick the change up, and `agent sync <user>` puts the entry back if a running session overwrote the file. `--no-mcp-config` (on `install`, `agent add` and `agent sync`) skips the write and prints the entry.
152
+
153
+ **Upgrading from the shared socket.** The first `gh-broker install` after the change registers every member of `gh-broker-clients` (other than `gh-broker`) as an agent, writes the new `listen` (`agents:/run/gh-broker/agents`), rewrites each agent's MCP config to its own socket and smoke-tests every socket. `gh-broker-clients` is left in place; nothing depends on it any more. Agents added later need a service restart, which `agent add` does.
154
+
155
+ **Versions.** The service runs from `/opt/gh-broker`, which `gh-broker install` deploys. `gh-broker otp status` and `gh-broker agent list` warn when the installed package and the deployed copy differ; run `sudo gh-broker install` again.
117
156
 
118
157
  ## Adding an org later
119
158
 
120
- Re-run the installer with `--owner <org>` (repeatable). The deployed owners stay, the new one is added with `cred:<org>-read` / `cred:<org>-write`, and the installer prompts for its two tokens. No code change.
159
+ Re-run `sudo gh-broker install` with `--owner <org>` (repeatable). The deployed owners stay, the new one is added with `cred:<org>-read` / `cred:<org>-write`, and the installer prompts for its two tokens. No code change.
121
160
 
122
161
  ## npm integration (`npm_scaffold`)
123
162
 
@@ -148,4 +187,4 @@ Without the `npm` block nothing changes: the installer says "not enabled", and t
148
187
  - The agent user cannot read `/etc/gh-broker/` — the dir is `0750 root:gh-broker`; the config (`0640`) and, whichever backend is used, the tokens (systemd-creds `.cred` files `0400 root`, or the `file` backend's `secrets.json` `0640 root:gh-broker`) are unreadable to a non-member.
149
188
  - The config file holds **no tokens** — only `cred:` references; the tokens live in the separate secret store.
150
189
  - The socket carries MCP frames only.
151
- - `journalctl -u gh-broker` shows the audit log (tool, owner, mode, repo, result) — never tokens.
190
+ - `journalctl -t gh-broker` shows the audit log (tool, user, owner, mode, repo, result, OTP outcome, lockouts and unlocks) — never tokens, codes or the seed.
package/deploy/cli.mjs ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ *
4
+ * @Project: @cldmv/gh-broker
5
+ * @Filename: /deploy/cli.mjs
6
+ * @Date: 2026-11-01T20:43:03-08:00 (1793594583)
7
+ * @Author: Nate Corcoran <CLDMV>
8
+ * @Email: <Shinrai@users.noreply.github.com>
9
+ * -----
10
+ * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
11
+ * @Last modified time: 2026-11-01T20:43:03-08:00 (1793594583)
12
+ * -----
13
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
14
+ *
15
+ */
16
+
17
+ /**
18
+ * The `gh-broker` command (the package `bin`): `gh-broker install`, `gh-broker otp ...` and
19
+ * `gh-broker agent ...`. `install` hands over to the installer (deploy/install.mjs); the rest is
20
+ * lib/cli.mjs with the real effects from real-deps.mjs.
21
+ */
22
+ import { realpathSync } from "node:fs";
23
+ import { fileURLToPath } from "node:url";
24
+ import { runCli } from "./lib/cli.mjs";
25
+ import { realDeps } from "./real-deps.mjs";
26
+
27
+ /** Run the CLI with the real effects and set the exit code. `install` is the installer. */
28
+ export async function main(argv = process.argv.slice(2)) {
29
+ if (argv[0] === "install") {
30
+ process.argv.splice(2, 1); // the installer reads its flags from argv[2..]
31
+ await import("./install.mjs");
32
+ return;
33
+ }
34
+ process.exitCode = await runCli(argv, realDeps());
35
+ }
36
+
37
+ // npm links the bin as a symlink, so compare real paths.
38
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
@@ -15,8 +15,9 @@ Group=gh-broker
15
15
  # See secrets.ts / docs/deploy-architecture.md.
16
16
 
17
17
  # Socket dir /run/gh-broker (traversable), owned by the service user; the daemon
18
- # creates the socket there and sets it 0660 group=gh-broker-clients so the agent
19
- # user (a member of that group) can connect, while the token stays server-side.
18
+ # creates one socket per agent under /run/gh-broker/agents, each 0660 with group
19
+ # gh-broker-agent-<user>, so only that agent can connect and the broker knows who
20
+ # is calling. The token stays server-side.
20
21
  RuntimeDirectory=gh-broker
21
22
  RuntimeDirectoryMode=0755
22
23
 
@@ -24,6 +25,8 @@ RuntimeDirectoryMode=0755
24
25
  # it at the chosen node and re-points it on a bump), so no node version is pinned
25
26
  # here and there is no nvm resolution at start. The symlink is the single source of
26
27
  # truth, so CLI node == service node by construction. See docs/deploy-architecture.md.
28
+ # The audit log (stdout) and `gh-broker otp unlock` both log as gh-broker: journalctl -t gh-broker
29
+ SyslogIdentifier=gh-broker
27
30
  ExecStart=/usr/local/bin/node /opt/gh-broker/dist/index.mjs --config /etc/gh-broker/config.json
28
31
  Restart=on-failure
29
32
  RestartSec=2
@@ -48,6 +51,10 @@ SystemCallArchitectures=native
48
51
  SystemCallFilter=@system-service
49
52
  SystemCallErrorNumber=EPERM
50
53
  ReadWritePaths=/run/gh-broker
54
+ # OTP gate state (lock counters, hard lock): /var/lib/gh-broker, created 0700 gh-broker:gh-broker.
55
+ # StateDirectory= makes it writable under ProtectSystem=strict; `sudo gh-broker otp unlock` edits it.
56
+ StateDirectory=gh-broker
57
+ StateDirectoryMode=0700
51
58
  UMask=0077
52
59
  # Optional egress lockdown once GitHub ranges are pinned:
53
60
  # IPAddressDeny=any