@cldmv/gh-broker 0.7.0 → 0.8.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 +134 -40
- package/config.example.json +21 -8
- package/config.sample.json +45 -0
- package/deploy/README.md +73 -15
- package/deploy/install.mjs +166 -101
- package/deploy/install.sh +0 -0
- package/deploy/lib/config-merge.mjs +174 -16
- package/deploy/lib/credentials.mjs +143 -0
- package/deploy/lib/install-args.mjs +144 -0
- package/deploy/lib/npm-setup.mjs +164 -0
- package/deploy/lib/secret-store.mjs +20 -2
- package/deploy/lib/system.mjs +26 -2
- package/dist/index.mjs +1638 -202
- package/dist/index.mjs.map +4 -4
- package/docs/TOOLS.md +192 -1
- package/docs/changelog/v0/v0.8.0.md +115 -0
- package/docs/deploy-architecture.md +13 -11
- package/package.json +7 -7
- package/src/availability.ts +42 -0
- package/src/catalog.ts +18 -1
- package/src/config.ts +29 -2
- package/src/router.ts +43 -2
- package/src/secrets.ts +34 -4
- package/src/types.ts +25 -1
- package/src/verbs/ghcr/archive.ts +314 -0
- package/src/verbs/ghcr/config.ts +142 -0
- package/src/verbs/ghcr/jobs.ts +141 -0
- package/src/verbs/ghcr/push.ts +192 -0
- package/src/verbs/ghcr/registry.ts +275 -0
- package/src/verbs/ghcr/tar.ts +203 -0
- package/src/verbs/ghcr_push.ts +264 -0
- package/src/verbs/npm-registry.ts +168 -0
- package/src/verbs/npm-tarball.ts +76 -0
- package/src/verbs/npm_scaffold.ts +325 -0
- package/src/verbs/release-analysis.ts +3 -1
- package/tests/catalog.test.vitest.mts +55 -1
- package/tests/config-merge.test.vitest.mts +275 -8
- package/tests/config.test.vitest.mts +75 -1
- package/tests/credentials.test.vitest.mts +127 -0
- package/tests/ghcr-archive.test.vitest.mts +439 -0
- package/tests/ghcr-config.test.vitest.mts +96 -0
- package/tests/ghcr-push.test.vitest.mts +526 -0
- package/tests/ghcr-registry.test.vitest.mts +206 -0
- package/tests/helpers/ghcr-fixtures.mts +373 -0
- package/tests/install-args.test.vitest.mts +152 -0
- package/tests/issue-labels.test.vitest.mts +15 -0
- package/tests/npm-scaffold.test.vitest.mts +479 -0
- package/tests/npm-setup.test.vitest.mts +196 -0
- package/tests/paths.test.vitest.mts +5 -0
- package/tests/prompt.test.vitest.mts +128 -0
- package/tests/release-prs.test.vitest.mts +10 -0
- package/tests/router.test.vitest.mts +108 -2
- package/tests/secret-store.test.vitest.mts +64 -3
- package/tests/secrets.test.vitest.mts +53 -0
- package/tests/security-alerts.test.vitest.mts +10 -0
- package/tests/server.test.vitest.mts +187 -7
- package/tests/smoke.test.vitest.mts +177 -0
- package/tests/system.test.vitest.mts +355 -0
- package/tests/verb-helpers.test.vitest.mts +46 -0
- package/.configs/fix-headers.json +0 -3
- package/.configs/vitest.config.mjs +0 -44
- package/.github/dependabot.yml +0 -111
- package/.github/workflows/branch-retention.yml +0 -44
- package/.github/workflows/bundle-size.yml +0 -52
- package/.github/workflows/ci.yml +0 -389
- package/.github/workflows/cla.yml +0 -66
- package/.github/workflows/codeql.yml +0 -87
- package/.github/workflows/dependabot-auto-merge.yml +0 -62
- package/.github/workflows/dependabot-recreate.yml +0 -67
- package/.github/workflows/dependency-review.yml +0 -46
- package/.github/workflows/feature-pr.yml +0 -75
- package/.github/workflows/hotfix-redirector.yml +0 -67
- package/.github/workflows/hotfixes-release.yml +0 -70
- package/.github/workflows/labeler.yml +0 -48
- package/.github/workflows/master-commit-audit.yml +0 -56
- package/.github/workflows/member-auto-merge.yml +0 -75
- package/.github/workflows/next-release.yml +0 -76
- package/.github/workflows/next-reset.yml +0 -50
- package/.github/workflows/pr-notify.yml +0 -44
- package/.github/workflows/pr-title-normalizer.yml +0 -52
- package/.github/workflows/provenance.yml +0 -51
- package/.github/workflows/publish.yml +0 -146
- package/.github/workflows/release-merge.yml +0 -104
- package/.github/workflows/release-notify.yml +0 -41
- package/.github/workflows/scorecard.yml +0 -64
- package/.github/workflows/stale.yml +0 -50
- package/.github/workflows/tag-health.yml +0 -70
- package/.github/workflows/update-major-version-tags.yml +0 -94
- package/.github/workflows/v4-bootstrap.yml +0 -115
- package/.github/workflows/welcome.yml +0 -42
package/docs/TOOLS.md
CHANGED
|
@@ -5,7 +5,7 @@ Every tool the broker advertises falls into one of three categories, set per ent
|
|
|
5
5
|
| Category | Config flag | What happens | Example |
|
|
6
6
|
|---|---|---|---|
|
|
7
7
|
| **Proxied** | (neither flag) | Forwarded to the hosted GitHub MCP untouched — the advertised schema and response are exactly what the hosted MCP defines (the one addition: `issue_write`'s description carries a warning that `labels` replaces the whole set). | `list_issues`, `pull_request_read` (base behavior) |
|
|
8
|
-
| **Custom** | `"custom": true` | Handled entirely locally — no call to the hosted MCP at all. The broker builds its own `Octokit` from the resolved PAT and does the work itself (raw REST/GraphQL, or composing several reads). | `report_unlabeled`, `report_security_alerts`, `report_release_prs`, `report_ci_status`, `issue_labels`, `resolve_review_thread`, `reply_review_thread` |
|
|
8
|
+
| **Custom** | `"custom": true` | Handled entirely locally — no call to the hosted MCP at all. The broker builds its own `Octokit` from the resolved PAT and does the work itself (raw REST/GraphQL, or composing several reads). | `report_unlabeled`, `report_security_alerts`, `report_release_prs`, `report_ci_status`, `issue_labels`, `npm_scaffold`, `resolve_review_thread`, `reply_review_thread`, `ghcr_push`, `ghcr_push_status`, `ghcr_tags` |
|
|
9
9
|
| **Augmented** | `"augment": true` | Forwarded to the hosted MCP like a plain proxy, then the broker post-processes the result before returning it. | `pull_request_read`'s `ciVerbosity` extension |
|
|
10
10
|
|
|
11
11
|
The broker is not obligated to make a proxied tool's advertised schema or response match the hosted MCP's exactly — see the `augment` category above. It presents its own MCP contract to the agent; a tool can extend what the hosted MCP offers.
|
|
@@ -328,6 +328,61 @@ A failure before the first write (the read of the issue's labels, or a label loo
|
|
|
328
328
|
|
|
329
329
|
---
|
|
330
330
|
|
|
331
|
+
## `npm_scaffold`
|
|
332
|
+
|
|
333
|
+
Create a new npm package and register its GitHub Actions trusted publisher, so the repo's own publish workflow can release it through OIDC from then on. Write, `mode: "write"`, `custom: true`. It needs the optional `npm` integration (set up by the installer, see [deploy/README.md](../deploy/README.md#npm-integration-npm_scaffold)); without it, or while its token is not stored, the broker does not expose the tool at all (it is left out of the tool list, a call is refused like an unlisted tool, and startup logs the reason).
|
|
334
|
+
|
|
335
|
+
npm only accepts a trusted publisher on a package that already exists, and that first publish needs a human's 2FA. The broker holds an npm granular access token created **without** 2FA bypass, and each call carries a one-time password the user reads from their authenticator. The token alone cannot write anything; the OTP is the human's approval for that one call. Ask the user for each code right before the call that uses it, since codes expire in about 30 seconds.
|
|
336
|
+
|
|
337
|
+
```json
|
|
338
|
+
{ "owner": "cldmv", "repo": "new-thing", "package": "@cldmv/new-thing", "otp": "123456" }
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
| Param | Type | Default | Notes |
|
|
342
|
+
|---|---|---|---|
|
|
343
|
+
| `owner` | string | — (required) | A configured owner. Its **read** PAT does the GitHub reads; the verb never writes to GitHub. |
|
|
344
|
+
| `repo` | string | — (required) | The repo whose `package.json` names the package. |
|
|
345
|
+
| `package` | string | — (required) | Lowercase scoped name, e.g. `@cldmv/new-thing`. Must be under a scope in `config.npm.scopes`. |
|
|
346
|
+
| `otp` | string | — (required) | 6–8 digit one-time password. Never logged or returned. |
|
|
347
|
+
| `workflow` | string | `publish.yml` | The repo's publish workflow file in `.github/workflows`; the trusted publisher names it. |
|
|
348
|
+
|
|
349
|
+
**Guardrails, checked before any npm write.**
|
|
350
|
+
|
|
351
|
+
- The scope is allowed by `config.npm.scopes`.
|
|
352
|
+
- The repo exists under exactly the owner and name given (a renamed or transferred repo, which GitHub answers with a redirect, is refused), isn't archived, its `package.json` on the default branch names exactly this package and is not `"private": true`, and `.github/workflows/<workflow>` exists there.
|
|
353
|
+
- The package does not exist on the registry yet. A package that exists is refused unless it is this verb's own placeholder from an earlier call (a single `0.0.0-stage` version marked `scaffoldedBy: "gh-broker"`), which is how an interrupted call resumes; the placeholder's recorded repository must be the one in this call. The lookup uses `?write=true`, so it reads the registry's primary copy rather than a possibly stale cache. The verb can never publish a version of a real package.
|
|
354
|
+
|
|
355
|
+
**What it writes.**
|
|
356
|
+
|
|
357
|
+
1. **Placeholder publish** (`PUT /<package>`, the request `npm publish` sends). The broker builds the tarball itself, so the agent supplies no files: a `package.json` (name, `0.0.0-stage`, the repo's `license` or `UNLICENSED`, repository, homepage, the `scaffoldedBy` marker) and a README pointing at the repo. Access is `public` for a public repo and `restricted` for a private one, the rule the CI publish uses too.
|
|
358
|
+
2. **Trusted publisher** (`POST /-/package/<package>/trust`, the request `npm trust github <package> --repo <owner>/<repo> --file <workflow> --allow-publish` sends). Permission `createPackage` is npm's internal name for publish.
|
|
359
|
+
|
|
360
|
+
**One code per write, so two calls.** npm requires a one-time password for every write, and a code works once. The first call publishes the placeholder and returns `status: "needs_otp"` with `done: ["publish"]` and `remaining: ["trust"]`, without sending the spent code again. Ask the user for the next code (after the authenticator changes) and call again: the publish is skipped, since the placeholder now exists, and the trusted publisher is added. A refused or expired code also returns `needs_otp` with what is left, and when npm answers that the package already has a trusted publisher (HTTP 409), the verb reads the existing configuration: if it is this repo and workflow the call is done, otherwise it fails naming what is configured (and if npm wants another code just to show it, it says so and points at the package's access page).
|
|
361
|
+
|
|
362
|
+
**Result.**
|
|
363
|
+
|
|
364
|
+
```json
|
|
365
|
+
{
|
|
366
|
+
"status": "done",
|
|
367
|
+
"package": "@cldmv/new-thing",
|
|
368
|
+
"repository": "CLDMV/new-thing",
|
|
369
|
+
"workflow": "publish.yml",
|
|
370
|
+
"access": "public",
|
|
371
|
+
"done": ["trust"],
|
|
372
|
+
"skipped": ["publish"],
|
|
373
|
+
"version": "0.0.0-stage",
|
|
374
|
+
"url": "https://www.npmjs.com/package/@cldmv/new-thing",
|
|
375
|
+
"trustId": "…",
|
|
376
|
+
"message": "@cldmv/new-thing exists with a trusted publisher for CLDMV/new-thing (.github/workflows/publish.yml); its next release can publish through OIDC."
|
|
377
|
+
}
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
That is the second call's result; `skipped` lists the steps an earlier call already did. Any other registry or GitHub failure fails the call with npm's or GitHub's own message; neither the OTP nor the token ever appears in a result, an error or the audit log.
|
|
381
|
+
|
|
382
|
+
**Calls.** GitHub: one repo read and two file reads (read PAT). npm: one package lookup, at most one publish and one trust request.
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
331
386
|
## `resolve_review_thread` / `reply_review_thread`
|
|
332
387
|
|
|
333
388
|
Resolve/unresolve, or reply inside, a pull-request review thread (e.g. addressing a Copilot review comment) — gaps the hosted MCP has no tool for. Both run on the **write** PAT (`Pull requests: write`), via the `resolveReviewThread`/`unresolveReviewThread`/`addPullRequestReviewThreadReply` GraphQL mutations.
|
|
@@ -340,6 +395,142 @@ Resolve/unresolve, or reply inside, a pull-request review thread (e.g. addressin
|
|
|
340
395
|
|
|
341
396
|
---
|
|
342
397
|
|
|
398
|
+
## `ghcr_push`
|
|
399
|
+
|
|
400
|
+
Push a container image to `ghcr.io/<owner>/<image>:<tag>` from a `docker save` archive, without the agent ever holding a registry credential. Write, `mode: "write"`, `custom: true`. It runs on the owner's **GHCR credential** from `config.ghcr` (a classic PAT with `write:packages`), not on the owner's GitHub PATs: GHCR does not accept fine-grained PATs. The three `ghcr_*` tools are only exposed when `config.ghcr` is present and at least one of its owners' credentials is stored; otherwise they are left out of the tool list, refused like an unlisted tool, and startup logs why.
|
|
401
|
+
|
|
402
|
+
```sh
|
|
403
|
+
# as the agent
|
|
404
|
+
docker save your-image:1.4.0 -o /srv/gh-broker/ghcr-drop/your-image-1.4.0.tar
|
|
405
|
+
chmod g+r /srv/gh-broker/ghcr-drop/your-image-1.4.0.tar
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
```json
|
|
409
|
+
{ "owner": "your-org", "image": "your-image", "tag": "1.4.0", "archive": "your-image-1.4.0.tar" }
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
| Param | Type | Default | Notes |
|
|
413
|
+
|---|---|---|---|
|
|
414
|
+
| `owner` | string | — (required) | Must be configured in `config.ghcr.owners` (registry credential + allowlist), with its credential stored. No `config.owners` entry or GitHub PAT is needed. |
|
|
415
|
+
| `image` | string | — (required) | The name under `ghcr.io/<owner>/`, e.g. `your-image`. Lowercase OCI path syntax. Must match one of `config.ghcr.owners.<owner>.images`. |
|
|
416
|
+
| `tag` | string | — (required) | OCI tag grammar: `[A-Za-z0-9_][A-Za-z0-9._-]{0,127}`. |
|
|
417
|
+
| `archive` | string | — (required) | Path to the archive inside `config.ghcr.dropDir`, absolute or relative to it. |
|
|
418
|
+
|
|
419
|
+
The call validates everything, exchanges the PAT for a registry token, checks a protected tag, and then **returns at once** with a job id; the upload itself runs in the background (images are 1–3 GB). Poll [`ghcr_push_status`](#ghcr_push_status).
|
|
420
|
+
|
|
421
|
+
```json
|
|
422
|
+
{
|
|
423
|
+
"owner": "your-org",
|
|
424
|
+
"image": "ghcr.io/your-org/your-image",
|
|
425
|
+
"tag": "1.4.0",
|
|
426
|
+
"archive": "/srv/gh-broker/ghcr-drop/your-image-1.4.0.tar",
|
|
427
|
+
"archiveBytes": 1288490188,
|
|
428
|
+
"format": "oci-layout",
|
|
429
|
+
"job": "4c0f2a8e-61b1-4f3e-9b0e-5d1a7e9b2c11",
|
|
430
|
+
"state": "queued",
|
|
431
|
+
"createdAt": "2026-10-31T18:02:11.412Z"
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
**Archive formats.** Both outputs of `docker save` are read: the OCI image layout written by Docker 25+ (`index.json` + `blobs/sha256/…`, also what `docker buildx build --output type=oci` writes) and the older docker-archive (`manifest.json` + `<id>/layer.tar`). An archive must hold exactly one single-platform image; buildx attestation manifests are ignored. Layers that are already gzip- or zstd-compressed are pushed byte-for-byte, and so is the manifest when nothing had to change. Layers stored uncompressed (what `docker save` writes for the classic image store) are gzipped while they upload, and the manifest is then written as an OCI image manifest.
|
|
436
|
+
|
|
437
|
+
**Push flow.** The OCI distribution API, driven from Node with no docker socket and no extra binaries: for each blob a `HEAD` (skip it when the registry already has it), otherwise `POST` an upload, `PATCH` the bytes, and `PUT` with the digest; then `PUT` the manifest under the tag. To make the `HEAD` check work for layers it compresses, the broker gzips each such layer once to learn its digest before uploading it, so re-pushing an unchanged image uploads nothing. That costs a second pass of CPU, but nothing is buffered in memory or written to disk. An expired registry token mid-push triggers one fresh token exchange.
|
|
438
|
+
|
|
439
|
+
**Refusals, all before any upload.**
|
|
440
|
+
|
|
441
|
+
| Check | Refused when |
|
|
442
|
+
|---|---|
|
|
443
|
+
| Arguments | an unknown key, a missing or mistyped field, an invalid image name or tag |
|
|
444
|
+
| Owner | not in `config.ghcr.owners` |
|
|
445
|
+
| Image | not on the owner's `images` allowlist (`*` matches within one path segment, never across `/`) |
|
|
446
|
+
| Archive path | outside `dropDir` (checked on the path text before anything is opened) |
|
|
447
|
+
| Archive file | a symlink, reached through a symlinked directory that leads outside `dropDir`, a hard link (`nlink > 1`), a FIFO, device or directory, empty, larger than `maxArchiveBytes`, or not readable by the broker |
|
|
448
|
+
| Archive content | not an image archive, several images, an unsupported or foreign layer, a blob that is missing or doesn't match its digest or size |
|
|
449
|
+
| Credential | the token exchange fails (expired PAT, missing `write:packages`) |
|
|
450
|
+
| Release tag | `protectReleaseTags` is on, the tag looks like `1.2.3` / `v1.2.3`, and it already exists. Checked at submit and again right before the manifest `PUT`, so two queued pushes of the same new release tag can't both land |
|
|
451
|
+
|
|
452
|
+
**Queue.** Jobs run one at a time in submission order. At most 8 can be queued or running; a ninth push is refused until one finishes. Job state is kept in memory only, so a broker restart forgets every job. A push interrupted that way leaves nothing tagged, because the tag only moves with the final manifest `PUT`.
|
|
453
|
+
|
|
454
|
+
**Audit.** Two audit lines per push, besides the router's usual one: `queued` (job id, tag, archive path, size, format, layer count) and the outcome (job id, tag, manifest digest, size, blobs uploaded and skipped, archive path, or the error).
|
|
455
|
+
|
|
456
|
+
**What it can't do.** Talk to any registry but `ghcr.io`, push outside `ghcr.io/<owner>/<allowlisted image>`, delete a package or version, change visibility, or read a file outside the drop directory. The broker can't delete the archive afterwards either (the drop directory belongs to the agent), so clean it up yourself.
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
## `ghcr_push_status`
|
|
461
|
+
|
|
462
|
+
Progress of a `ghcr_push` job, then its result. Read, `mode: "read"`, `custom: true`. No network call.
|
|
463
|
+
|
|
464
|
+
```json
|
|
465
|
+
{ "owner": "your-org", "job": "4c0f2a8e-61b1-4f3e-9b0e-5d1a7e9b2c11" }
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
Omit `job` to list this owner's recent jobs (the newest 50 finished jobs are kept, plus everything queued or running). A job id from another owner reads as unknown.
|
|
469
|
+
|
|
470
|
+
```json
|
|
471
|
+
{
|
|
472
|
+
"job": "4c0f2a8e-61b1-4f3e-9b0e-5d1a7e9b2c11",
|
|
473
|
+
"state": "succeeded",
|
|
474
|
+
"image": "ghcr.io/your-org/your-image",
|
|
475
|
+
"tag": "1.4.0",
|
|
476
|
+
"progress": { "phase": "done", "layersTotal": 9, "blobsUploaded": 4, "blobsSkipped": 6, "bytesTotal": 431209876, "bytesSent": 118220311 },
|
|
477
|
+
"result": { "digest": "sha256:9f2c…", "mediaType": "application/vnd.oci.image.manifest.v1+json", "size": 431209876, "blobsUploaded": 4, "blobsSkipped": 6 }
|
|
478
|
+
}
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
`state` is `queued`, `running`, `succeeded` or `failed` (then `error` says why). `progress.phase` moves through `preparing` (computing digests of layers that will be compressed), `uploading`, `manifest` and `done`. `bytesTotal` covers every blob the image references; `bytesSent` counts only what was actually uploaded.
|
|
482
|
+
|
|
483
|
+
---
|
|
484
|
+
|
|
485
|
+
## `ghcr_tags`
|
|
486
|
+
|
|
487
|
+
List the tags of an allowlisted image, e.g. to check a push landed. Read, `mode: "read"`, `custom: true`. Runs on the GHCR credential with a pull-only registry token.
|
|
488
|
+
|
|
489
|
+
```json
|
|
490
|
+
{ "owner": "your-org", "image": "your-image" }
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
```json
|
|
494
|
+
{ "image": "ghcr.io/your-org/your-image", "exists": true, "tags": ["1.3.2", "1.4.0", "latest"] }
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
`exists: false` with an empty list means the repository hasn't been pushed yet. The same allowlist as `ghcr_push` applies, so this can't be used to probe other images.
|
|
498
|
+
|
|
499
|
+
---
|
|
500
|
+
|
|
501
|
+
## GHCR configuration and threat model
|
|
502
|
+
|
|
503
|
+
```json
|
|
504
|
+
"ghcr": {
|
|
505
|
+
"dropDir": "/srv/gh-broker/ghcr-drop",
|
|
506
|
+
"protectReleaseTags": true,
|
|
507
|
+
"owners": {
|
|
508
|
+
"your-org": { "username": "your-login", "credential": "cred:your-org-packages", "images": ["your-image", "your-image-worker-*"] }
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
| Key | Default | Notes |
|
|
514
|
+
|---|---|---|
|
|
515
|
+
| `dropDir` | — (required) | Absolute, normalized, at least two levels deep, and not under `/etc`, `/run`, `/home`, `/tmp`, `/root` or other system paths. The unit runs with `ProtectHome=true` and `PrivateTmp=true`, so `/srv` is the natural place. The installer creates it `2750 <agent>:gh-broker`. |
|
|
516
|
+
| `protectReleaseTags` | `true` | Refuse to overwrite an existing tag matching `^v?\d+\.\d+\.\d+$`. |
|
|
517
|
+
| `maxArchiveBytes` | 16 GiB | Largest archive accepted. |
|
|
518
|
+
| `owners.<o>.username` | — (required) | GitHub login the classic PAT belongs to; sent as the Basic-auth user in the token exchange. |
|
|
519
|
+
| `owners.<o>.credential` | — (required) | `cred:<name>` ref to a classic PAT with `write:packages` only. The credential itself is optional: while no owner's credential is stored the `ghcr_*` tools are not exposed, and a call for an owner whose credential is missing is refused. |
|
|
520
|
+
| `owners.<o>.images` | — (required) | Allowlist of image names under `ghcr.io/<o>/`. `*` matches `[a-z0-9._-]*` within one segment. |
|
|
521
|
+
|
|
522
|
+
Unknown keys anywhere in the block are refused. The block is operator data, not shipped policy: `config.example.json` carries none of it. Set it with `--ghcr-owner <org> --ghcr-username <login> --ghcr-image <glob>` (plus `--ghcr-drop-dir`, `--ghcr-max-archive-bytes`, `--[no-]protect-release-tags`) or with a `ghcr` block in an installer `--config` file, which is the way to configure several owners. The installer keeps the deployed block on upgrade. See [`deploy/README.md`](../deploy/README.md#configuring-the-install).
|
|
523
|
+
|
|
524
|
+
**Threat model.** The agent is untrusted with credentials but trusted to decide what to push within the allowlist.
|
|
525
|
+
|
|
526
|
+
- *Credential exfiltration.* The PAT is sent only in the Basic-auth header of `https://ghcr.io/token`. The registry token is sent only to `https://ghcr.io`: every URL the registry returns (upload `Location`, pagination `Link`) is refused unless it is on that origin, and redirects are never followed. The registry host is a constant, not configuration. Every error message passes through a scrubber that replaces both secret values and anything shaped like an auth header or a GitHub token, and registry error bodies are reduced to their `code: message` pairs. Results, audit lines and log lines are built from the same scrubbed strings.
|
|
527
|
+
- *Confused-deputy file reads.* The broker can read files the agent can't, including its own credentials. Pushing such a file as a "layer" would put it in a registry the agent can pull from. So the archive path is checked as text first, then opened once with `O_NOFOLLOW | O_NONBLOCK`, and the open descriptor is checked: a regular file with a single link, whose real path (`/proc/self/fd/<n>`) is inside the real drop directory. Every later read is a positional read on that descriptor. Links inside the tar are resolved against the tar's own members only.
|
|
528
|
+
- *Reach.* Push and list only, only to allowlisted images of configured owners. No delete, no visibility changes. The PAT should carry `write:packages` and nothing else (`write:packages` implies `read:packages`). `delete:packages` must not be granted.
|
|
529
|
+
- *Resource abuse.* Archive size, tar member count, JSON member size, layer count and the job queue are all bounded.
|
|
530
|
+
- *Release integrity.* With `protectReleaseTags`, a published `1.2.3` can't be replaced, so a compromised agent can't swap a released image. Mutable tags such as `latest` can still move; keep the allowlist tight.
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
343
534
|
## Extending this: adding a new custom verb or augmented tool
|
|
344
535
|
|
|
345
536
|
- **Custom verb**: implement in `src/verbs/<name>.ts` (export the function + a `*_SCHEMA` JSON Schema constant), add a `case` in `router.ts`'s `dispatchCustom`, advertise it in `catalog.ts`'s `if (rule.custom)` branch, and add a `{ "name": ..., "mode": ..., "custom": true }` entry to `config.example.json`.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# gh-broker v0.8.0 Changelog
|
|
2
|
+
|
|
3
|
+
**Release Date**: October 2026
|
|
4
|
+
**Release Type**: Minor
|
|
5
|
+
**Branch**: `release/0.8.0`
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
v0.8.0 adds two write integrations that let an agent do work which used to need a token in its own hands or a human at a web form. `npm_scaffold` creates a new npm package and registers its trusted publisher, so the repo's CI can publish the first real release through OIDC. `ghcr_push` pushes a `docker save` archive to the GitHub Container Registry with a credential the agent never sees, with `ghcr_push_status` and `ghcr_tags` to follow the job and check the result.
|
|
12
|
+
|
|
13
|
+
Both are optional. Their credentials (an npm granular token and a classic `write:packages` PAT) can be skipped at install, and while one isn't stored the broker starts normally and leaves the matching tools out of the tool list. The installer also gains a `--secret <cred-name>=<token>` flag, and supplying a token explicitly now replaces the stored one on the `file` backend as well.
|
|
14
|
+
|
|
15
|
+
Owner-specific settings no longer ship in `config.example.json`: the installer takes them from `--config <file>` or a flag per setting, and an existing install keeps what it has.
|
|
16
|
+
|
|
17
|
+
Nothing is breaking. Every existing verb and proxied tool behaves as in v0.7.0. The only runtime dependency change is `@modelcontextprotocol/sdk` 1.32.1 → 1.32.2.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## ✨ Features
|
|
22
|
+
|
|
23
|
+
### `npm_scaffold`: create an npm package and its trusted publisher ([#127](https://github.com/CLDMV/gh-broker/pull/127))
|
|
24
|
+
|
|
25
|
+
npm only accepts a trusted publisher on a package that already exists, so every new package needed a hand-run first publish and a form on npmjs.com. `npm_scaffold` (`mode: "write"`, `custom: true`) does both.
|
|
26
|
+
|
|
27
|
+
- **Placeholder publish.** The broker builds a tarball itself (a `package.json` at `0.0.0-stage` marked `scaffoldedBy: "gh-broker"`, and a README pointing at the repo) and publishes it. Access is `public` for a public repo and `restricted` for a private one.
|
|
28
|
+
- **Trusted publisher.** It then registers the repo's publish workflow (default `publish.yml`) as the package's GitHub Actions trusted publisher, the same request `npm trust github` sends.
|
|
29
|
+
- **One OTP per write.** The broker's npm token has no 2FA bypass, so each npm write carries a one-time password from the user. The first call publishes and returns `status: "needs_otp"` with the trust step remaining; a second call with the next code adds the trusted publisher. A refused or expired code also returns `needs_otp` with what is left.
|
|
30
|
+
- **Create-only.** The scope must be in `config.npm.scopes`. The repo must exist under exactly the owner and name given (a redirect from a rename or transfer is refused), must not be archived, and its `package.json` on the default branch must name this package and not be private. The publish workflow must exist. A package already on the registry is refused, unless it is this verb's own placeholder for the same repo, which is how an interrupted call resumes. The lookup reads the registry primary (`?write=true`), not a cache.
|
|
31
|
+
- **Existing trust.** If npm answers that a trusted publisher already exists (HTTP 409), the configuration is read back: the same repo and workflow counts as done, anything else fails naming what is configured.
|
|
32
|
+
- **No secrets out.** Neither the OTP nor the token appears in a result, an error or the audit log.
|
|
33
|
+
|
|
34
|
+
Installer support: `--npm` runs an npm web login, asks for the npm password and an OTP (both hidden, the password is never stored), and creates a granular token for the chosen scopes (`--npm-scope`, repeatable) with `--npm-token-days` lifetime (default 90). `--npm-org` (repeatable) also grants org read-write and is saved as `npm.tokenOrgs` so a rotation creates the same kind of token. Unattended installs take an existing token from `--secrets-file` or `GH_BROKER_SECRET_NPM_TOKEN`. Rotating says plainly that the old token stays valid until it expires or is revoked.
|
|
35
|
+
|
|
36
|
+
Full reference: [docs/TOOLS.md](https://github.com/CLDMV/gh-broker/blob/master/docs/TOOLS.md#npm_scaffold) and [deploy/README.md](https://github.com/CLDMV/gh-broker/blob/master/deploy/README.md#npm-integration-npm_scaffold).
|
|
37
|
+
|
|
38
|
+
### `ghcr_push`, `ghcr_push_status` and `ghcr_tags`: GHCR pushes without an agent-held token ([#128](https://github.com/CLDMV/gh-broker/pull/128))
|
|
39
|
+
|
|
40
|
+
`ghcr_push` pushes an image archive to `ghcr.io/<owner>/<image>:<tag>` using the owner's GHCR credential from the new `config.ghcr` block, a classic PAT with `write:packages` (GHCR does not accept fine-grained PATs).
|
|
41
|
+
|
|
42
|
+
- **Archives.** Both `docker save` formats are read: the OCI image layout from Docker 25+ (also `buildx --output type=oci`) and the older docker-archive. An archive must hold exactly one single-platform image; attestation manifests are ignored. Compressed layers are pushed byte-for-byte, uncompressed layers are gzipped while they upload.
|
|
43
|
+
- **Push flow.** The OCI distribution API from Node, with no docker socket and no extra binaries. Blobs the registry already has are skipped, so re-pushing an unchanged image uploads nothing. Nothing is buffered in memory or written to disk.
|
|
44
|
+
- **Background jobs.** The call validates, exchanges the PAT for a registry token, checks tag protection and returns a job id at once. Jobs run one at a time, at most 8 queued or running. `ghcr_push_status` reports the phase, blob counts and bytes, then the manifest digest or the error. Job state is in memory only; a push cut off by a restart leaves nothing tagged.
|
|
45
|
+
- **`ghcr_tags`** lists an allowlisted image's tags with a pull-only token, to check that a push landed.
|
|
46
|
+
- **Guardrails, all before any upload.** The image must match the owner's `images` allowlist (`*` stays within one path segment). The archive must sit inside `ghcr.dropDir`: the path is checked as text, the file is opened once with `O_NOFOLLOW`, and the open descriptor must be a regular file with a single link whose real path is inside the real drop directory, so a symlink, hard link or symlinked subdirectory can't point the broker at a file the agent can't read. With `protectReleaseTags` (default on), an existing `1.2.3` / `v1.2.3` tag is never overwritten, checked at submit and again before the manifest is written.
|
|
47
|
+
- **Credential containment.** The PAT is only sent to `https://ghcr.io/token`, the registry token only to `https://ghcr.io`, redirects are never followed, and every error passes through a scrubber before it reaches a result, audit line or log.
|
|
48
|
+
|
|
49
|
+
GHCR is set up per install with `--ghcr-owner`, `--ghcr-username` and `--ghcr-image` (or a `--config` file), and the credential is `<org>-packages`. The installer creates the drop directory as `2750 <agent-user>:gh-broker`.
|
|
50
|
+
|
|
51
|
+
Full reference and threat model: [docs/TOOLS.md](https://github.com/CLDMV/gh-broker/blob/master/docs/TOOLS.md#ghcr_push) and [deploy/README.md](https://github.com/CLDMV/gh-broker/blob/master/deploy/README.md#configuring-the-install).
|
|
52
|
+
|
|
53
|
+
### Optional credentials hide their tools instead of blocking startup ([#127](https://github.com/CLDMV/gh-broker/pull/127), [#128](https://github.com/CLDMV/gh-broker/pull/128))
|
|
54
|
+
|
|
55
|
+
The npm token and the GHCR credentials are optional. The installer lets them be skipped (Enter at the prompt; an unattended run skips with a note), and only stored credentials go into the systemd drop-in. At startup the broker leaves a tool whose credential isn't stored out of the tool list, refuses a call to it like an unlisted tool, and logs why. Only the owners' GitHub PATs are required.
|
|
56
|
+
|
|
57
|
+
### Generic shipped config: `--config` and a flag per setting ([#135](https://github.com/CLDMV/gh-broker/pull/135))
|
|
58
|
+
|
|
59
|
+
`config.example.json` now ships only the generic defaults: the socket, the upstream and the tools allowlist. It used to carry this project's own owner, GHCR account and image list, and the installer forced them onto every install, so anyone installing the package was asked for `cldmv-read`, `cldmv-write` and `cldmv-packages`.
|
|
60
|
+
|
|
61
|
+
Owner-specific settings now come from the operator, layered from lowest to highest precedence: the shipped defaults, the deployed config, a `--config <file>`, then the CLI flags. There is a flag for every setting: `--owner <org>` (repeatable, credentials `<org>-read` / `<org>-write`), `--default-owner`, `--listen`, `--upstream`, `--installation-id`, `--app-id`, `--app-private-key`, `--secrets-path`, `--npm-registry`, and for GHCR `--ghcr-owner`, `--ghcr-username`, `--ghcr-image`, `--ghcr-drop-dir`, `--ghcr-max-archive-bytes` and `--[no-]protect-release-tags`.
|
|
62
|
+
|
|
63
|
+
- The tools allowlist still always comes from the repo. A `--config` file that sets `tools`, has an unknown top-level key or holds anything shaped like a token is refused.
|
|
64
|
+
- A first interactive install asks for the owner; an unattended one with no owner fails and names `--owner` / `--config`.
|
|
65
|
+
- An upgrade without config flags keeps the deployed owners and GHCR settings as they are.
|
|
66
|
+
- `config.sample.json` shows a full config with placeholder values. Nothing loads it.
|
|
67
|
+
|
|
68
|
+
Full reference: [deploy/README.md](https://github.com/CLDMV/gh-broker/blob/master/deploy/README.md#configuring-the-install).
|
|
69
|
+
|
|
70
|
+
### Installer: `--secret <cred-name>=<token>` ([#127](https://github.com/CLDMV/gh-broker/pull/127))
|
|
71
|
+
|
|
72
|
+
A fourth way to supply a token, repeatable, taking precedence over `--secrets-file`, `GH_BROKER_SECRET_<NAME>` and the prompt. A name the config doesn't use is refused, so a typo can't silently do nothing. It shows up in the process list and shell history, so the file or env are still the better choice for real tokens.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 🐛 Bug Fixes
|
|
77
|
+
|
|
78
|
+
### The `file` secrets backend ignored a newly supplied token ([#127](https://github.com/CLDMV/gh-broker/pull/127))
|
|
79
|
+
|
|
80
|
+
When a token was passed through `--secrets-file` or env for a credential that was already stored, the `file` backend kept the old value. It now replaces it, which is what `systemd-creds` already did and what the docs described.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## 📚 Documentation
|
|
85
|
+
|
|
86
|
+
- **NEW:** [docs/changelog/v0/v0.8.0.md](./v0.8.0.md), this changelog.
|
|
87
|
+
- `docs/TOOLS.md` gains sections for `npm_scaffold`, `ghcr_push`, `ghcr_push_status`, `ghcr_tags`, and the GHCR configuration and threat model.
|
|
88
|
+
- `deploy/README.md` is now generic (`<org>` placeholders in place of this project's own setup) and documents the npm integration, the GHCR classic PAT and drop directory, `--config`, every config flag and `--secret`. `docs/deploy-architecture.md` lists `ghcr` as an example-owned config field.
|
|
89
|
+
- README: feature sections for npm package creation and container image pushes, and `✨ What's New` promoted to v0.8.0.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 🔧 Dependencies
|
|
94
|
+
|
|
95
|
+
All patch bumps, in [#132](https://github.com/CLDMV/gh-broker/pull/132), which replaces the Dependabot PRs [#121](https://github.com/CLDMV/gh-broker/pull/121) and [#131](https://github.com/CLDMV/gh-broker/pull/131).
|
|
96
|
+
|
|
97
|
+
### Runtime
|
|
98
|
+
|
|
99
|
+
- `@modelcontextprotocol/sdk` `^1.32.1` → `^1.32.2`.
|
|
100
|
+
|
|
101
|
+
### Development
|
|
102
|
+
|
|
103
|
+
- `@cldmv/fix-headers` `^2.2.1` → `^2.2.3`, `@cldmv/vitest-runner` `^1.5.4` → `^1.5.6`, `@types/node` `^26.6.5` → `^26.6.6`, `vitest` and `@vitest/coverage-v8` → `^5.0.5`, and the transitive `vite` → 8.3.5.
|
|
104
|
+
|
|
105
|
+
`@octokit/auth-app`, `@octokit/core`, `esbuild` and `typescript` are unchanged. The new verbs add no dependencies.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Upgrade notes
|
|
110
|
+
|
|
111
|
+
- A fresh unattended install now needs `--owner <org>` (or a `--config` file with an `owners` block); it used to pick up the owner shipped in `config.example.json`. Upgrades are not affected.
|
|
112
|
+
- No breaking changes. Without an `npm` block or a stored GHCR credential, the broker behaves exactly as v0.7.0 and the new tools are not exposed.
|
|
113
|
+
- Re-run the installer to deploy: it merges the new allowlist entries into the live config, rebuilds and restarts. Existing owners and settings are kept. MCP clients need to reconnect to see new tools.
|
|
114
|
+
- To enable `npm_scaffold`, run the installer with `--npm` (add `--npm-org <org>` if creating packages in the scope needs org permissions).
|
|
115
|
+
- To enable `ghcr_push`, create a classic PAT with `write:packages` only ([create the token](https://github.com/settings/tokens/new?scopes=write:packages&description=gh-broker%20ghcr)), and run the installer with `--ghcr-owner <org> --ghcr-username <login> --ghcr-image <name>` (repeat `--ghcr-image` per image). It asks for the token as `<org>-packages`, or takes it from `--secret <org>-packages=<token>`.
|
|
@@ -2,25 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
How gh-broker is installed, configured, and upgraded. The guiding goals are **portability** (copy the repo to another machine, run one installer, it works), a **clean upgrade path** (updates never lose operator-set data), and **platform-neutrality** (Linux today, with seams so macOS/Windows are additive later — not a rewrite).
|
|
4
4
|
|
|
5
|
-
## Config: one portable, token-free file, regenerated by
|
|
5
|
+
## Config: one portable, token-free file, regenerated by layering
|
|
6
6
|
|
|
7
|
-
There is a single config file. It is **portable** and **contains no secrets** — only the tool allowlist, owner names, and credential *references* (`"read": "cred:
|
|
7
|
+
There is a single config file. It is **portable** and **contains no secrets** — only the tool allowlist, owner names, and credential *references* (`"read": "cred:acme-read"`). Keeping tokens out of it is what makes it safe to copy, commit, and carry between machines; a file designed to be copied must never hold live credentials.
|
|
8
8
|
|
|
9
|
-
On install *and* upgrade the deployed config is **regenerated by
|
|
9
|
+
On install *and* upgrade the deployed config is **regenerated by layering**, not overwritten. Lowest to highest precedence:
|
|
10
10
|
|
|
11
11
|
```
|
|
12
|
-
new_config = merge(example_defaults, current_config)
|
|
12
|
+
new_config = flags( config_file( merge(example_defaults, current_config) ) )
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
- `example_defaults` — `config.example.json`, shipped with the code. Updated freely on upgrade; this is where
|
|
15
|
+
- `example_defaults` — `config.example.json`, shipped with the code and generic: `listen`, `upstream` and the `tools` allowlist, with nothing specific to any deployment. Updated freely on upgrade; this is where allowlist changes arrive.
|
|
16
16
|
- `current_config` — whatever is already deployed on this machine (absent on a first install).
|
|
17
|
+
- `config_file` — the operator's `--config <file>`, same shape as the deployed config, merged on top. Only `cred:` references are allowed; a value shaped like a token is refused.
|
|
18
|
+
- `flags` — one command-line flag per setting (`--owner`, `--listen`, `--ghcr-owner`, ...), applied last.
|
|
17
19
|
|
|
18
|
-
The merge is **field-level**, because the
|
|
20
|
+
The merge is **field-level**, because the layers have different owners:
|
|
19
21
|
|
|
20
22
|
| Field | Source of truth | Why |
|
|
21
23
|
|---|---|---|
|
|
22
|
-
| `tools` (the allowlist) | **example** (repo) | It is a security control. A removal or tightening in an update must apply; a naive "current wins" would silently keep a tool the repo dropped. |
|
|
23
|
-
| `owners`, `app`, `listen`, `upstream`, `defaultOwner` | **current** (deployed),
|
|
24
|
+
| `tools` (the allowlist) | **example** (repo), always | It is a security control. A removal or tightening in an update must apply; a naive "current wins" would silently keep a tool the repo dropped. Neither a `--config` file nor a flag can change it. |
|
|
25
|
+
| `owners`, `ghcr`, `app`, `secrets`, `npm`, `listen`, `upstream`, `defaultOwner` | **operator**: flags, then `--config`, then **current** (deployed), then the example | Deployment data. The example ships none of the owner-specific ones, so a first install gets its owners from `--owner`, `--config` or an interactive prompt. Higher layers merge on top of the deployed copy (per owner and per field), and a run with no config flags leaves the deployed values untouched. `ghcr` holds only a `cred:` reference, never the token. |
|
|
24
26
|
|
|
25
27
|
So an upgrade brings new defaults and allowlist changes through the example, while everything the operator configured survives. Nothing is blindly clobbered.
|
|
26
28
|
|
|
@@ -57,7 +59,7 @@ The symlink is the single indirection: the CLI (via `PATH`) and the service reso
|
|
|
57
59
|
|
|
58
60
|
## Tooling: Node, with one optional shell bootstrap
|
|
59
61
|
|
|
60
|
-
All install/deploy *logic* is Node (`deploy/install.mjs` + `deploy/lib/*.mjs`): config
|
|
62
|
+
All install/deploy *logic* is Node (`deploy/install.mjs` + `deploy/lib/*.mjs`): config layering and validation (validation reuses the daemon's own loader via `node dist/index.mjs --check`), the interactive prompts, the privileged system operations (shelled out to `useradd` / `systemctl` / `rsync`), building `dist/`/`node_modules/` as the checkout's own owner via `sudo -u '#<uid>'` rather than leaving them root-owned, and the smoke test (a real MCP client over the socket). One language, testable with the repo's vitest suite.
|
|
61
63
|
|
|
62
64
|
The only thing that can't be Node is bootstrapping node itself. So the entrypoint is **`sudo node deploy/install.mjs`** with node ≥20 as the one prerequisite — which runs on any platform node runs on. For zero-prerequisite convenience on Linux, an **optional** `deploy/install.sh` (~20 lines) sets up nvm + node + the symlink if node is absent, then `exec`s the Node installer. It is a convenience, not the real installer.
|
|
63
65
|
|
|
@@ -68,13 +70,13 @@ The only thing that can't be Node is bootstrapping node itself. So the entrypoin
|
|
|
68
70
|
1. detect platform + check prerequisites (`socat`, `rsync`, `systemctl`, `sudo`),
|
|
69
71
|
2. **create or confirm the agent user** — hard stop if it can't (no silent warn-and-continue),
|
|
70
72
|
3. ensure node on a stable path + the `/usr/local/bin/node` symlink,
|
|
71
|
-
4. **regenerate the config**
|
|
73
|
+
4. **regenerate the config** by layering the example, the deployed config, `--config` and the flags (a first install with no owner asks for one),
|
|
72
74
|
5. **choose the secret backend and store the PATs** — keep existing, migrate a legacy `systemd-creds` token, or take it from `--secrets-file` / env / a prompt (see the Secrets section),
|
|
73
75
|
6. validate the config with the real loader (`node dist/index.mjs --check`),
|
|
74
76
|
7. build + deploy (unit → `/usr/local/bin/node`, `dist/`, config, secrets),
|
|
75
77
|
8. **smoke test** — MCP `initialize` + one real read over the socket → ✅/❌.
|
|
76
78
|
|
|
77
|
-
The same flow runs **fully unattended** via flags — `--agent-user`, `--secrets-provider`, `--secrets-file` / `GH_BROKER_SECRET_<NAME>`, `--create-agent-user`, `--non-interactive
|
|
79
|
+
The same flow runs **fully unattended** via flags — `--agent-user`, `--secrets-provider`, `--secrets-file` / `GH_BROKER_SECRET_<NAME>`, `--create-agent-user`, `--non-interactive`, and the config flags (`--owner`, `--config`, ...); prompts are the default when flags are omitted. See [`../deploy/README.md`](../deploy/README.md) and `--help`.
|
|
78
80
|
|
|
79
81
|
## What is deliberately deferred
|
|
80
82
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cldmv/gh-broker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Local credential-routing MCP proxy: holds per-org GitHub PATs out of the agent's reach, routes by repo owner, exposes a config-driven default-deny toolset in front of GitHub's hosted MCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -50,16 +50,16 @@
|
|
|
50
50
|
"homepage": "https://github.com/CLDMV/gh-broker#readme",
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@cldmv/configs": "^1.3.0",
|
|
53
|
-
"@cldmv/fix-headers": "^2.2.
|
|
54
|
-
"@cldmv/vitest-runner": "^1.5.
|
|
55
|
-
"@types/node": "^26.6.
|
|
56
|
-
"@vitest/coverage-v8": "^5.0.
|
|
53
|
+
"@cldmv/fix-headers": "^2.2.3",
|
|
54
|
+
"@cldmv/vitest-runner": "^1.5.6",
|
|
55
|
+
"@types/node": "^26.6.6",
|
|
56
|
+
"@vitest/coverage-v8": "^5.0.5",
|
|
57
57
|
"esbuild": "^0.28.2",
|
|
58
58
|
"typescript": "^6.0.3",
|
|
59
|
-
"vitest": "^5.0.
|
|
59
|
+
"vitest": "^5.0.5"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@modelcontextprotocol/sdk": "^1.32.
|
|
62
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
63
63
|
"@octokit/auth-app": "^8.3.1",
|
|
64
64
|
"@octokit/core": "^7.0.8"
|
|
65
65
|
},
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @Project: @cldmv/gh-broker
|
|
4
|
+
* @Filename: /src/availability.ts
|
|
5
|
+
* @Date: 2026-10-31T12:09:43-07:00 (1793473783)
|
|
6
|
+
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
+
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
+
* -----
|
|
9
|
+
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
+
* @Last modified time: 2026-10-31T13:14:38-07:00 (1793477678)
|
|
11
|
+
* -----
|
|
12
|
+
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
+
*
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Tools that depend on an optional integration. An allowlisted tool whose integration isn't
|
|
18
|
+
* configured, or whose credential isn't stored, is unavailable: the catalog doesn't advertise
|
|
19
|
+
* it and the router refuses it like a tool that isn't allowed. Everything else keeps working.
|
|
20
|
+
* Catalog and router both ask here, so they can never disagree.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { Config, SecretStore, ToolRule } from "./types.ts";
|
|
24
|
+
import { ghcrCredRefs } from "./verbs/ghcr/config.ts";
|
|
25
|
+
|
|
26
|
+
const GHCR_TOOLS = new Set(["ghcr_push", "ghcr_push_status", "ghcr_tags"]);
|
|
27
|
+
|
|
28
|
+
/** Why `rule` can't be used on this broker, or undefined when it can. */
|
|
29
|
+
export function unavailableReason(rule: ToolRule, config: Config, secrets: SecretStore): string | undefined {
|
|
30
|
+
if (rule.name === "npm_scaffold") {
|
|
31
|
+
if (!config.npm) return "the npm integration is not configured (enable it with the installer's --npm)";
|
|
32
|
+
if (!secrets.has(config.npm.token)) return `its credential "${config.npm.token.replace(/^cred:/, "")}" is not stored (re-run the installer with --npm)`;
|
|
33
|
+
}
|
|
34
|
+
if (GHCR_TOOLS.has(rule.name)) {
|
|
35
|
+
if (!config.ghcr) return "GHCR pushes are not configured (config.ghcr)";
|
|
36
|
+
const refs = ghcrCredRefs(config.ghcr);
|
|
37
|
+
if (!refs.some((ref) => secrets.has(ref))) {
|
|
38
|
+
return `no GHCR credential is stored (${refs.map((r) => `"${r.replace(/^cred:/, "")}"`).join(", ")}; re-run the installer to add one)`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
package/src/catalog.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
8
|
* -----
|
|
9
9
|
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
-
* @Last modified time: 2026-10-
|
|
10
|
+
* @Last modified time: 2026-10-31T12:12:11-07:00 (1793473931)
|
|
11
11
|
* -----
|
|
12
12
|
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
13
|
*
|
|
@@ -24,6 +24,9 @@ import { REPORT_SECURITY_ALERTS_SCHEMA } from "./verbs/report_security_alerts.ts
|
|
|
24
24
|
import { REPORT_RELEASE_PRS_SCHEMA } from "./verbs/report_release_prs.ts";
|
|
25
25
|
import { ISSUE_LABELS_SCHEMA } from "./verbs/issue_labels.ts";
|
|
26
26
|
import { REPORT_CI_STATUS_SCHEMA } from "./verbs/report_ci_status.ts";
|
|
27
|
+
import { NPM_SCAFFOLD_SCHEMA } from "./verbs/npm_scaffold.ts";
|
|
28
|
+
import { unavailableReason } from "./availability.ts";
|
|
29
|
+
import { GHCR_CATALOG } from "./verbs/ghcr_push.ts";
|
|
27
30
|
|
|
28
31
|
/**
|
|
29
32
|
* Build the advertised tool list ONCE at startup. Proxied tools carry their real
|
|
@@ -62,6 +65,11 @@ export async function buildCatalog(config: Config, secrets: SecretStore, upstrea
|
|
|
62
65
|
|
|
63
66
|
const tools: Tool[] = [];
|
|
64
67
|
for (const rule of config.tools) {
|
|
68
|
+
const unavailable = unavailableReason(rule, config, secrets);
|
|
69
|
+
if (unavailable) {
|
|
70
|
+
console.error(`catalog: "${rule.name}" is not exposed: ${unavailable}`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
65
73
|
if (rule.custom) {
|
|
66
74
|
// DEFERRED: link_issue (App-token-only addClosingIssueReference) is not wired in — see docs.
|
|
67
75
|
// Intentionally not advertised even if an allowlist entry is added; re-enable by restoring
|
|
@@ -87,6 +95,13 @@ export async function buildCatalog(config: Config, secrets: SecretStore, upstrea
|
|
|
87
95
|
'Edit the labels on one issue or PR without touching any other label on it. add / remove take one label or several (combinable in one call); swap { prefix, to } removes every label starting with prefix and adds to (e.g. prefix "status: ", to "status: verified"); set replaces the whole set and only as an explicit choice. Labels already present (add) or absent (remove) are no-ops. A label name to add that does not exist in the repo refuses the call and changes nothing, unless create_missing is true. Returns the resulting label set plus what was added, removed and created. Runs on the write PAT (Issues: write).',
|
|
88
96
|
inputSchema: ISSUE_LABELS_SCHEMA
|
|
89
97
|
});
|
|
98
|
+
} else if (rule.name === "npm_scaffold") {
|
|
99
|
+
tools.push({
|
|
100
|
+
name: "npm_scaffold",
|
|
101
|
+
description:
|
|
102
|
+
"Create a new npm package and register its GitHub Actions trusted publisher, so the repo's publish workflow can release it through OIDC from then on. Publishes a broker-built placeholder (0.0.0-stage, no code) and never publishes a version of an existing package. Every npm write needs its own one-time password from the user, so it takes two calls: the first publishes the placeholder and returns status needs_otp; ask the user for the next code and call again to add the trusted publisher. Ask for each code right before the call. A refused or expired code also returns needs_otp with the remaining steps. Checks first that the repo's package.json names the package and that the workflow file exists. Uses the broker's npm token (no 2FA bypass) and the read PAT.",
|
|
103
|
+
inputSchema: NPM_SCAFFOLD_SCHEMA
|
|
104
|
+
});
|
|
90
105
|
} else if (rule.name === "report_unlabeled") {
|
|
91
106
|
tools.push({
|
|
92
107
|
name: "report_unlabeled",
|
|
@@ -115,6 +130,8 @@ export async function buildCatalog(config: Config, secrets: SecretStore, upstrea
|
|
|
115
130
|
"Report CI health for a PR, or every open PR in a repo, at a chosen verbosity: 0 counts only, 1 adds which run/job failed, 2 adds which step failed, 3 adds a tail of the failing job's log. Detail beyond tier 0 is only fetched for runs that are actually failing. Runs on the read PAT.",
|
|
116
131
|
inputSchema: REPORT_CI_STATUS_SCHEMA
|
|
117
132
|
});
|
|
133
|
+
} else if (GHCR_CATALOG[rule.name]) {
|
|
134
|
+
tools.push({ name: rule.name, ...GHCR_CATALOG[rule.name]! });
|
|
118
135
|
}
|
|
119
136
|
continue;
|
|
120
137
|
}
|
package/src/config.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
8
|
* -----
|
|
9
9
|
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
-
* @Last modified time: 2026-10-
|
|
10
|
+
* @Last modified time: 2026-10-31T11:23:22-07:00 (1793471002)
|
|
11
11
|
* -----
|
|
12
12
|
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
13
|
*
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { readFileSync } from "node:fs";
|
|
17
17
|
import type { Config, Mode, ToolRule } from "./types.ts";
|
|
18
|
+
import { parseGhcrConfig } from "./verbs/ghcr/config.ts";
|
|
18
19
|
|
|
19
20
|
const MODES: readonly Mode[] = ["read", "write"];
|
|
20
21
|
|
|
@@ -113,6 +114,30 @@ export function loadConfig(path: string): Config {
|
|
|
113
114
|
};
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
let npm: Config["npm"];
|
|
118
|
+
if (c.npm !== undefined) {
|
|
119
|
+
if (typeof c.npm !== "object" || c.npm === null || Array.isArray(c.npm)) throw new Error("config.npm: must be an object");
|
|
120
|
+
const n = c.npm as Record<string, unknown>;
|
|
121
|
+
if (typeof n.token !== "string" || !n.token.startsWith("cred:")) throw new Error('config.npm.token: must be a "cred:<name>" ref');
|
|
122
|
+
if (!Array.isArray(n.scopes) || n.scopes.length === 0) throw new Error('config.npm.scopes: must be a non-empty array of scopes, e.g. ["@cldmv"]');
|
|
123
|
+
const scopes = n.scopes.map((s, i) => {
|
|
124
|
+
if (typeof s !== "string" || !/^@[a-z0-9][a-z0-9._~-]*$/.test(s)) throw new Error(`config.npm.scopes[${i}]: must be a lowercase npm scope like "@cldmv"`);
|
|
125
|
+
return s;
|
|
126
|
+
});
|
|
127
|
+
if (n.registry !== undefined && (typeof n.registry !== "string" || !/^https:\/\//.test(n.registry))) {
|
|
128
|
+
throw new Error("config.npm.registry: must be an https URL");
|
|
129
|
+
}
|
|
130
|
+
if (n.tokenOrgs !== undefined && (!Array.isArray(n.tokenOrgs) || !n.tokenOrgs.every((o) => typeof o === "string" && /^[a-z0-9][a-z0-9._~-]*$/.test(o)))) {
|
|
131
|
+
throw new Error('config.npm.tokenOrgs: must be an array of lowercase npm org names, e.g. ["cldmv"]');
|
|
132
|
+
}
|
|
133
|
+
npm = {
|
|
134
|
+
token: n.token,
|
|
135
|
+
scopes,
|
|
136
|
+
...(typeof n.registry === "string" ? { registry: n.registry } : {}),
|
|
137
|
+
...(Array.isArray(n.tokenOrgs) ? { tokenOrgs: n.tokenOrgs as string[] } : {})
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
116
141
|
return {
|
|
117
142
|
listen: c.listen,
|
|
118
143
|
upstream: c.upstream,
|
|
@@ -120,7 +145,9 @@ export function loadConfig(path: string): Config {
|
|
|
120
145
|
owners,
|
|
121
146
|
...(app ? { app } : {}),
|
|
122
147
|
...(secrets ? { secrets } : {}),
|
|
123
|
-
...(defaultOwner ? { defaultOwner } : {})
|
|
148
|
+
...(defaultOwner ? { defaultOwner } : {}),
|
|
149
|
+
...(npm ? { npm } : {}),
|
|
150
|
+
...(c.ghcr !== undefined ? { ghcr: parseGhcrConfig(c.ghcr) } : {})
|
|
124
151
|
};
|
|
125
152
|
}
|
|
126
153
|
|