@cldmv/gh-broker 0.8.0 → 0.10.0

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 (106) hide show
  1. package/README.md +48 -13
  2. package/config.example.json +86 -3
  3. package/config.sample.json +1 -1
  4. package/deploy/README.md +58 -18
  5. package/deploy/cli.mjs +38 -0
  6. package/deploy/gh-broker.service +9 -2
  7. package/deploy/install.mjs +100 -41
  8. package/deploy/lib/agent-cli.mjs +166 -0
  9. package/deploy/lib/agents.mjs +128 -0
  10. package/deploy/lib/cli.mjs +72 -0
  11. package/deploy/lib/config-merge.mjs +29 -8
  12. package/deploy/lib/install-args.mjs +3 -2
  13. package/deploy/lib/mcp-config.mjs +211 -0
  14. package/deploy/lib/otp-cli.mjs +136 -0
  15. package/deploy/lib/otp.mjs +325 -0
  16. package/deploy/lib/secret-store.mjs +1 -0
  17. package/deploy/lib/system.mjs +24 -12
  18. package/deploy/real-deps.mjs +102 -0
  19. package/dist/index.mjs +5171 -1686
  20. package/dist/index.mjs.map +4 -4
  21. package/package.json +14 -3
  22. package/docs/TOOLS.md +0 -538
  23. package/docs/agent-user-model.md +0 -89
  24. package/docs/changelog/v0/v0.2.0.md +0 -26
  25. package/docs/changelog/v0/v0.2.1.md +0 -23
  26. package/docs/changelog/v0/v0.2.2.md +0 -23
  27. package/docs/changelog/v0/v0.2.3.md +0 -33
  28. package/docs/changelog/v0/v0.2.4.md +0 -25
  29. package/docs/changelog/v0/v0.2.5.md +0 -29
  30. package/docs/changelog/v0/v0.3.0.md +0 -84
  31. package/docs/changelog/v0/v0.3.1.md +0 -30
  32. package/docs/changelog/v0/v0.3.2.md +0 -24
  33. package/docs/changelog/v0/v0.3.3.md +0 -28
  34. package/docs/changelog/v0/v0.4.0.md +0 -58
  35. package/docs/changelog/v0/v0.4.1.md +0 -33
  36. package/docs/changelog/v0/v0.4.2.md +0 -45
  37. package/docs/changelog/v0/v0.5.0.md +0 -39
  38. package/docs/changelog/v0/v0.5.1.md +0 -28
  39. package/docs/changelog/v0/v0.6.0.md +0 -52
  40. package/docs/changelog/v0/v0.6.1.md +0 -48
  41. package/docs/changelog/v0/v0.7.0.md +0 -71
  42. package/docs/changelog/v0/v0.8.0.md +0 -115
  43. package/docs/deploy-architecture.md +0 -84
  44. package/src/appauth.ts +0 -56
  45. package/src/audit.ts +0 -34
  46. package/src/availability.ts +0 -42
  47. package/src/catalog.ts +0 -182
  48. package/src/config.ts +0 -157
  49. package/src/index.ts +0 -107
  50. package/src/paths.ts +0 -60
  51. package/src/router.ts +0 -299
  52. package/src/secrets.ts +0 -167
  53. package/src/server.ts +0 -91
  54. package/src/types.ts +0 -128
  55. package/src/upstream.ts +0 -85
  56. package/src/verbs/ghcr/archive.ts +0 -314
  57. package/src/verbs/ghcr/config.ts +0 -142
  58. package/src/verbs/ghcr/jobs.ts +0 -141
  59. package/src/verbs/ghcr/push.ts +0 -192
  60. package/src/verbs/ghcr/registry.ts +0 -275
  61. package/src/verbs/ghcr/tar.ts +0 -203
  62. package/src/verbs/ghcr_push.ts +0 -264
  63. package/src/verbs/issue_labels.ts +0 -329
  64. package/src/verbs/link_issue.ts +0 -84
  65. package/src/verbs/npm-registry.ts +0 -168
  66. package/src/verbs/npm-tarball.ts +0 -76
  67. package/src/verbs/npm_scaffold.ts +0 -325
  68. package/src/verbs/release-analysis.ts +0 -212
  69. package/src/verbs/repo-tier.ts +0 -90
  70. package/src/verbs/report_ci_status.ts +0 -358
  71. package/src/verbs/report_release_prs.ts +0 -238
  72. package/src/verbs/report_security_alerts.ts +0 -442
  73. package/src/verbs/report_unlabeled.ts +0 -194
  74. package/src/verbs/rest-client.ts +0 -181
  75. package/src/verbs/review_thread.ts +0 -138
  76. package/tests/appauth.test.vitest.mts +0 -76
  77. package/tests/audit.test.vitest.mts +0 -48
  78. package/tests/catalog.test.vitest.mts +0 -234
  79. package/tests/config-merge.test.vitest.mts +0 -325
  80. package/tests/config.test.vitest.mts +0 -388
  81. package/tests/credentials.test.vitest.mts +0 -127
  82. package/tests/ghcr-archive.test.vitest.mts +0 -439
  83. package/tests/ghcr-config.test.vitest.mts +0 -96
  84. package/tests/ghcr-push.test.vitest.mts +0 -526
  85. package/tests/ghcr-registry.test.vitest.mts +0 -206
  86. package/tests/helpers/ghcr-fixtures.mts +0 -373
  87. package/tests/install-args.test.vitest.mts +0 -152
  88. package/tests/issue-labels.test.vitest.mts +0 -362
  89. package/tests/npm-scaffold.test.vitest.mts +0 -479
  90. package/tests/npm-setup.test.vitest.mts +0 -196
  91. package/tests/paths.test.vitest.mts +0 -57
  92. package/tests/prompt.test.vitest.mts +0 -128
  93. package/tests/release-prs.test.vitest.mts +0 -247
  94. package/tests/rest-client.test.vitest.mts +0 -115
  95. package/tests/router.test.vitest.mts +0 -505
  96. package/tests/run-vitest.mjs +0 -51
  97. package/tests/secret-store.test.vitest.mts +0 -167
  98. package/tests/secrets.test.vitest.mts +0 -208
  99. package/tests/security-alerts.test.vitest.mts +0 -338
  100. package/tests/server.test.vitest.mts +0 -200
  101. package/tests/smoke.test.vitest.mts +0 -177
  102. package/tests/system.test.vitest.mts +0 -355
  103. package/tests/upstream.test.vitest.mts +0 -91
  104. package/tests/verb-helpers.test.vitest.mts +0 -377
  105. package/tests/verbs.test.vitest.mts +0 -624
  106. package/tsconfig.json +0 -21
package/README.md CHANGED
@@ -19,19 +19,19 @@ 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.8.0 (October 2026)
22
+ ### Latest: v0.10.0 (November 2026)
23
23
 
24
- - **`npm_scaffold`: new npm packages without a manual first publish.** The new write verb publishes a broker-built `0.0.0-stage` placeholder and registers the repo's publish workflow as its trusted publisher, so CI publishes the first real release through OIDC. The npm token has no 2FA bypass: each write takes a one-time password from the user, so it's two calls, and the verb refuses any package that already exists.
25
- - **`ghcr_push`: container image pushes without a registry token.** Push a `docker save` archive from a drop directory to an allowlisted `ghcr.io/<owner>/<image>` as a background job, follow it with `ghcr_push_status` and check it with `ghcr_tags`. Release tags are never overwritten, and the classic PAT stays with the broker.
26
- - **Optional credentials.** The npm and GHCR tokens can be skipped at install; their tools stay hidden until the token is stored. The installer also takes tokens via `--secret <cred-name>=<token>`.
27
- - [View full v0.8.0 Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.8.0.md)
24
+ - **OTP gate for risky write tools.** Gated tools need a current code from an authenticator app, so the agent has to ask you. The first good code opens a five-minute window for that agent, and bad codes lock it out, then hard-lock it until `sudo gh-broker otp unlock <user>`.
25
+ - **One socket per agent and the `gh-broker` command.** Each agent user gets its own socket, and `gh-broker agent add|remove|list|sync` writes its MCP config. `gh-broker otp status|list|unlock|rotate` manages the gate, and `gh-broker install` runs the installer.
26
+ - **Eleven new write tools and a deny list.** These include merging, branch updates, repo create and fork, `delete_branch`, `dismiss_alert` and `minimize_comment`, with the risky ones gated by default and the Copilot coding-agent tools disabled. A built-in deny list refuses file writes, repo deletion and repo settings at config load. Breaking: the socket path, the `bin` and `npm_scaffold`'s `npm_otp`; see the upgrade notes.
27
+ - [View full v0.10.0 Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.10.0.md)
28
28
 
29
29
  ### Recent Releases
30
30
 
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))
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))
31
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))
32
34
  - **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))
33
- - **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))
34
- - **v0.5.1** (October 2026) — The installer keeps the existing secrets backend instead of prompting on every upgrade ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.5.1.md))
35
35
 
36
36
  📚 For the complete version history, see the [docs/changelog/](https://github.com/CLDMV/gh-broker/tree/master/docs/changelog/) folder.
37
37
 
@@ -51,7 +51,7 @@ A tool not listed in `config.tools` is neither advertised nor executable. Adding
51
51
 
52
52
  ### 📊 **CI Visibility Without Per-Call Log Fetches**
53
53
 
54
- `report_ci_status` answers "does this PR have failing checks" at whatever detail level you ask for — counts only by default, escalating to which job failed, which step failed, and a tail of the failing job's actual log — paying for that detail only on runs that are genuinely failing. The same status can ride inline on a normal `pull_request_read` via an opt-in `ciVerbosity` parameter.
54
+ `report_ci_status` answers "does this PR have failing checks" at whatever detail level you ask for — counts only by default, escalating to which job failed, which step failed, and a tail of the failing job's actual log — paying for that detail only on runs that are genuinely failing. The same status can ride inline on a normal `pull_request_read` or `list_pull_requests` via an opt-in `ciVerbosity` parameter.
55
55
 
56
56
  ```json
57
57
  { "owner": "cldmv", "repo": "droidsock", "pull": 35, "verbosity": 2 }
@@ -91,12 +91,30 @@ npm only accepts a trusted publisher on a package that already exists, so every
91
91
 
92
92
  🛡️ **Full parameter reference: [docs/TOOLS.md](docs/TOOLS.md#report_security_alerts)**
93
93
 
94
+ ### 🩹 **Where Each Alert's Fix Stands**
95
+
96
+ `report_alert_status` puts every open Dependabot alert in one bucket — `released-still-open`, `fixed-on-next`, `open-pr`, `closed-unmerged` or `no-pr` — by reading the lockfile nearest the alert's manifest on the default branch, `next` and `hotfixes`, and the repo's PR history. An alert that reads open because its fix is waiting on `next` stops looking like work to do.
97
+
98
+ 🩹 **Full parameter reference: [docs/TOOLS.md](docs/TOOLS.md#report_alert_status)**
99
+
94
100
  ### 🚢 **Release PR Discovery**
95
101
 
96
102
  `report_release_prs` finds every open release PR across an owner's repos and says which ones still lack their per-version changelog file — the list an agent needs during a mass update.
97
103
 
98
104
  🚢 **Full parameter reference: [docs/TOOLS.md](docs/TOOLS.md#report_release_prs)**
99
105
 
106
+ ### 📝 **Changelog Source Material**
107
+
108
+ `report_release_changes` returns the PRs one release actually ships — already-released and reverted PRs dropped — grouped by changelog section at the detail level you ask for, with the release's `package.json` and dependency deltas and an optional skeleton in the per-version template.
109
+
110
+ 📝 **Full parameter reference: [docs/TOOLS.md](docs/TOOLS.md#report_release_changes)**
111
+
112
+ ### 📦 **Package Dependents**
113
+
114
+ `report_dependents` finds every repo an owner has that declares an npm package, with the declared range, the lockfile-resolved version and the repo's flow tier — where a fleet-wide version bump starts. It reads `dev` branches too, so dev-trunk repos are included, and skips a `dev` whose version has fallen behind the default branch.
115
+
116
+ 📦 **Full parameter reference: [docs/TOOLS.md](docs/TOOLS.md#report_dependents)**
117
+
100
118
  ### 🐳 **Container Image Pushes Without a Registry Token**
101
119
 
102
120
  `ghcr_push` pushes a `docker save` archive to `ghcr.io/<owner>/<image>:<tag>` with a classic `write:packages` PAT the agent never sees. The image must be on an allowlist, the archive must sit in a dedicated drop directory (symlinks, hard links and paths outside it are refused), and an existing release tag is never overwritten. The push runs as a background job, uploads only the blobs the registry doesn't already have, and is tracked with `ghcr_push_status`; `ghcr_tags` checks the result.
@@ -115,7 +133,7 @@ The broker presents its **own** MCP contract to the agent — a proxied tool's s
115
133
 
116
134
  ### 📦 **One-Command Install & Upgrade**
117
135
 
118
- `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.
136
+ `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.
119
137
 
120
138
  📦 **Full prerequisites, PAT scopes, and flags: [deploy/README.md](deploy/README.md)**
121
139
 
@@ -132,7 +150,16 @@ The broker presents its **own** MCP contract to the agent — a proxied tool's s
132
150
 
133
151
  ### Install
134
152
 
135
- gh-broker is published on npm as [`@cldmv/gh-broker`](https://www.npmjs.com/package/@cldmv/gh-broker), but it is a system service, not a library or a global CLI. Install it from a checkout with the installer, which handles both the first install and every upgrade:
153
+ 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
+
155
+ 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
+
157
+ ```sh
158
+ sudo npm install -g --prefix /usr/local --ignore-scripts @cldmv/gh-broker
159
+ sudo gh-broker install <agent-user>
160
+ ```
161
+
162
+ From a checkout:
136
163
 
137
164
  ```sh
138
165
  git clone https://github.com/CLDMV/gh-broker.git
@@ -140,13 +167,13 @@ cd gh-broker
140
167
  sudo node deploy/install.mjs <agent-user>
141
168
  ```
142
169
 
143
- The installer creates the service user and the socket group, builds as the checkout's owner, 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
+ 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).
144
171
 
145
172
  ---
146
173
 
147
174
  ## 🚀 Quick Start
148
175
 
149
- 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:
176
+ 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:
150
177
 
151
178
  ```json
152
179
  // tools/call
@@ -175,10 +202,15 @@ src/
175
202
  catalog.ts build the advertised tool list (real upstream schemas + custom + augmented)
176
203
  server.ts Unix-socket MCP server toward the agent (StdioServerTransport)
177
204
  audit.ts structured audit log (never tokens)
205
+ denylist.ts tools refused at config load, gated or not (file writes, repo deletion, repo configuration)
178
206
  verbs/
179
207
  report_unlabeled.ts issues/PRs with no labels, one repo or org-wide
180
208
  report_security_alerts.ts open Dependabot/CodeQL/secret alerts, one repo or org-wide
181
209
  report_release_prs.ts open release PRs + whether each has its changelog file
210
+ report_dependents.ts repos that declare an npm package, with range, resolved version and flow tier
211
+ report_alert_status.ts where each open Dependabot alert's fix stands (lockfiles + PRs)
212
+ report_release_changes.ts the PRs a release ships, grouped, with net package/dependency deltas
213
+ lockfile.ts npm / pnpm / yarn lockfile readers shared by the report verbs
182
214
  report_ci_status.ts tiered CI status for a PR or every open PR in a repo
183
215
  rest-client.ts shared rate-limit-floor + call-budget REST helpers
184
216
  issue_labels.ts add/remove/swap/set labels on an issue or PR (write PAT)
@@ -186,6 +218,9 @@ src/
186
218
  npm-registry.ts the npm registry requests npm_scaffold makes
187
219
  npm-tarball.ts minimal npm tarball builder for the placeholder
188
220
  review_thread.ts resolve/reply PR review threads (write PAT)
221
+ minimize_comment.ts hide or show a comment (write PAT)
222
+ delete_branch.ts delete a branch, refusing flow branches and open-PR heads/bases (write PAT, gated)
223
+ dismiss_alert.ts dismiss or reopen a code scanning, Dependabot or secret scanning alert (write PAT, gated)
189
224
  ghcr_push.ts ghcr_push / ghcr_push_status / ghcr_tags (GHCR classic PAT)
190
225
  ghcr/ config policy, tar + image-archive reader, OCI registry client, push, job queue
191
226
  link_issue.ts addClosingIssueReference (App token) — DEFERRED, not wired in
@@ -208,7 +243,7 @@ config.sample.json a full config with placeholder values, for reference only
208
243
 
209
244
  ## 📚 Documentation
210
245
 
211
- - **[Tools Reference](docs/TOOLS.md)** — every custom and augmented tool: `report_unlabeled`, `report_security_alerts`, `report_release_prs`, `report_ci_status`, the `pull_request_read` `ciVerbosity` extension, `issue_labels`, `npm_scaffold`, `resolve_review_thread`/`reply_review_thread`, `ghcr_push`/`ghcr_push_status`/`ghcr_tags`, and how to add a new one
246
+ - **[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
212
247
  - **[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
213
248
  - **[Deploy Architecture](docs/deploy-architecture.md)** — config merge, secret store backends, node handling, the installer's full flow
214
249
  - **[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
  {
@@ -28,7 +28,8 @@
28
28
  },
29
29
  {
30
30
  "name": "list_pull_requests",
31
- "mode": "read"
31
+ "mode": "read",
32
+ "augment": true
32
33
  },
33
34
  {
34
35
  "name": "pull_request_read",
@@ -255,6 +256,65 @@
255
256
  "mode": "write",
256
257
  "custom": true
257
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
+ },
258
318
  {
259
319
  "name": "report_unlabeled",
260
320
  "mode": "read",
@@ -265,11 +325,26 @@
265
325
  "mode": "read",
266
326
  "custom": true
267
327
  },
328
+ {
329
+ "name": "report_dependents",
330
+ "mode": "read",
331
+ "custom": true
332
+ },
268
333
  {
269
334
  "name": "report_release_prs",
270
335
  "mode": "read",
271
336
  "custom": true
272
337
  },
338
+ {
339
+ "name": "report_release_changes",
340
+ "mode": "read",
341
+ "custom": true
342
+ },
343
+ {
344
+ "name": "report_alert_status",
345
+ "mode": "read",
346
+ "custom": true
347
+ },
273
348
  {
274
349
  "name": "report_ci_status",
275
350
  "mode": "read",
@@ -289,6 +364,14 @@
289
364
  "name": "ghcr_tags",
290
365
  "mode": "read",
291
366
  "custom": true
367
+ },
368
+ {
369
+ "name": "otp_status",
370
+ "mode": "read",
371
+ "custom": true
292
372
  }
293
- ]
373
+ ],
374
+ "otp": {
375
+ "seed": "cred:otp-seed"
376
+ }
294
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,22 +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 node deploy/install.mjs [agent-user]
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]
31
32
  ```
32
33
 
33
- `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:
34
37
 
35
38
  1. checks prerequisites,
36
- 2. **creates or confirms the agent user** (stops if it can't — no silent half-install),
37
- 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),
38
41
  4. points `/usr/local/bin/node` at the running node,
39
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,
40
- 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,
41
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)),
42
- 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)),
43
46
  9. validates the config with the real loader (`--check`),
44
- 10. installs the unit, deploys `dist/`, restarts, and
45
- 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) → ✅/❌.
46
49
 
47
50
  ### Unattended (single non-interactive command)
48
51
 
@@ -58,6 +61,28 @@ Re-running on an existing install needs no `--owner`: the deployed owners are ke
58
61
 
59
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).
60
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
+
61
86
  ## Credential storage — your choice
62
87
 
63
88
  The installer asks which backend holds the tokens at rest:
@@ -100,23 +125,38 @@ Flags that build a single GHCR owner do so for one owner per run; use `--config`
100
125
 
101
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`.
102
127
 
103
- ## 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.
104
131
 
105
- 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:
106
142
 
107
143
  ```json
108
144
  "github": {
109
145
  "type": "stdio",
110
- "command": "socat",
111
- "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"]
112
148
  }
113
149
  ```
114
150
 
115
- 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.
116
156
 
117
157
  ## Adding an org later
118
158
 
119
- 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.
120
160
 
121
161
  ## npm integration (`npm_scaffold`)
122
162
 
@@ -147,4 +187,4 @@ Without the `npm` block nothing changes: the installer says "not enabled", and t
147
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.
148
188
  - The config file holds **no tokens** — only `cred:` references; the tokens live in the separate secret store.
149
189
  - The socket carries MCP frames only.
150
- - `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