@cldmv/gh-broker 0.9.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.
- package/README.md +17 -12
- package/config.example.json +69 -2
- package/config.sample.json +1 -1
- package/deploy/README.md +58 -19
- package/deploy/cli.mjs +38 -0
- package/deploy/gh-broker.service +9 -2
- package/deploy/install.mjs +99 -42
- package/deploy/lib/agent-cli.mjs +166 -0
- package/deploy/lib/agents.mjs +128 -0
- package/deploy/lib/cli.mjs +72 -0
- package/deploy/lib/config-merge.mjs +29 -8
- package/deploy/lib/install-args.mjs +3 -2
- package/deploy/lib/mcp-config.mjs +211 -0
- package/deploy/lib/otp-cli.mjs +136 -0
- package/deploy/lib/otp.mjs +325 -0
- package/deploy/lib/secret-store.mjs +1 -0
- package/deploy/lib/system.mjs +24 -12
- package/deploy/real-deps.mjs +102 -0
- package/dist/index.mjs +932 -66
- package/dist/index.mjs.map +4 -4
- package/package.json +4 -3
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.
|
|
22
|
+
### Latest: v0.10.0 (November 2026)
|
|
23
23
|
|
|
24
|
-
- **
|
|
25
|
-
-
|
|
26
|
-
- **
|
|
27
|
-
- [View full v0.
|
|
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))
|
|
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
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))
|
|
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
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
|
|
|
@@ -133,7 +133,7 @@ The broker presents its **own** MCP contract to the agent — a proxied tool's s
|
|
|
133
133
|
|
|
134
134
|
### 📦 **One-Command Install & Upgrade**
|
|
135
135
|
|
|
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.
|
|
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.
|
|
137
137
|
|
|
138
138
|
📦 **Full prerequisites, PAT scopes, and flags: [deploy/README.md](deploy/README.md)**
|
|
139
139
|
|
|
@@ -152,10 +152,11 @@ The broker presents its **own** MCP contract to the agent — a proxied tool's s
|
|
|
152
152
|
|
|
153
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
154
|
|
|
155
|
-
From npm ([`@cldmv/gh-broker`](https://www.npmjs.com/package/@cldmv/gh-broker)), with the prebuilt bundle:
|
|
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
156
|
|
|
157
157
|
```sh
|
|
158
|
-
sudo
|
|
158
|
+
sudo npm install -g --prefix /usr/local --ignore-scripts @cldmv/gh-broker
|
|
159
|
+
sudo gh-broker install <agent-user>
|
|
159
160
|
```
|
|
160
161
|
|
|
161
162
|
From a checkout:
|
|
@@ -166,13 +167,13 @@ cd gh-broker
|
|
|
166
167
|
sudo node deploy/install.mjs <agent-user>
|
|
167
168
|
```
|
|
168
169
|
|
|
169
|
-
Both take the same flags. The installer creates the service user
|
|
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).
|
|
170
171
|
|
|
171
172
|
---
|
|
172
173
|
|
|
173
174
|
## 🚀 Quick Start
|
|
174
175
|
|
|
175
|
-
|
|
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:
|
|
176
177
|
|
|
177
178
|
```json
|
|
178
179
|
// tools/call
|
|
@@ -201,6 +202,7 @@ src/
|
|
|
201
202
|
catalog.ts build the advertised tool list (real upstream schemas + custom + augmented)
|
|
202
203
|
server.ts Unix-socket MCP server toward the agent (StdioServerTransport)
|
|
203
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)
|
|
204
206
|
verbs/
|
|
205
207
|
report_unlabeled.ts issues/PRs with no labels, one repo or org-wide
|
|
206
208
|
report_security_alerts.ts open Dependabot/CodeQL/secret alerts, one repo or org-wide
|
|
@@ -216,6 +218,9 @@ src/
|
|
|
216
218
|
npm-registry.ts the npm registry requests npm_scaffold makes
|
|
217
219
|
npm-tarball.ts minimal npm tarball builder for the placeholder
|
|
218
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)
|
|
219
224
|
ghcr_push.ts ghcr_push / ghcr_push_status / ghcr_tags (GHCR classic PAT)
|
|
220
225
|
ghcr/ config policy, tar + image-archive reader, OCI registry client, push, job queue
|
|
221
226
|
link_issue.ts addClosingIssueReference (App token) — DEFERRED, not wired in
|
|
@@ -238,7 +243,7 @@ config.sample.json a full config with placeholder values, for reference only
|
|
|
238
243
|
|
|
239
244
|
## 📚 Documentation
|
|
240
245
|
|
|
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
|
|
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
|
|
242
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
|
|
243
248
|
- **[Deploy Architecture](docs/deploy-architecture.md)** — config merge, secret store backends, node handling, the installer's full flow
|
|
244
249
|
- **[Install Guide](deploy/README.md)** — prerequisites, PAT scopes, install/upgrade command, flags
|
package/config.example.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"listen": "
|
|
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
|
}
|
package/config.sample.json
CHANGED
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
|
|
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
|
|
31
|
-
sudo
|
|
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
|
-
|
|
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. **
|
|
38
|
-
3. creates the `gh-broker` service user
|
|
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
|
|
46
|
-
11. runs a **smoke test** (MCP handshake + a real read
|
|
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.
|
|
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
|
-
|
|
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": "
|
|
112
|
-
"args": ["
|
|
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
|
-
|
|
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
|
|
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 -
|
|
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();
|
package/deploy/gh-broker.service
CHANGED
|
@@ -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
|
|
19
|
-
# user
|
|
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
|