From 335cd2d1ef92caf1da6a6798c4b792c388835343 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sat, 5 Sep 2026 17:00:50 -0700 Subject: [PATCH 1/3] broker: delete the credential broker, and keep the machine bearer it hid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The broker is not a supported authentication mode. It enrolled hosted boxes automatically and it overrode a working native Codex login: `blitz-cred register` wrote `model_provider = "blitz"` into `.codex/config.toml`, and a box whose watcher was not running then answered HTTP 401. `plans/BROKER-RETIREMENT.md` (PR #198) planned this in two releases, behind a fleet-convergence gate and a credential-custody audit. Both control planes hold zero broker rows, verified today: prod `51bebbfa-…` and canary `8a3458ff-…` each report broker_boxes 0, broker_members 0, broker_keys 0, and canary reports no machine with a broker_box_id. Nothing is enrolled, so nothing needs draining and no broker disk holds a member's credential. One release does it. Deleted: `packages/broker` whole, the `register` and `watch` s6 services, the `blitz-register` boot script, both token helpers, the broker mint in the `claude` shim, the broker probes in `blitz-codex-session`, the register poke in cloud bootstrap, `core/registry.ts` and its four routes, the broker wire types, `BoxIdentity.isBroker`, the broker key cleanup in the destroy and orphan janitors, the broker CI job and the OCI publish. `/boxes/:id/feed` and the constant workspace environment route go with it. Both were compatibility surfaces for one caller, and that caller was the broker. Kept: `blitz-cred api-token`. Agent rules and the Git credential helper call it, and it is machine authentication rather than a broker feature. It moves to `packages/box/credential-helper` with the primitives it reaches, and keeps its name. Claude and Codex now read their own stores under HOME. Migration 0053 drops `broker_keys`, `broker_members` and `broker_boxes`, and rebuilds `machines` and `boxes` without the columns that referenced them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GocywGPN9b8DrcBXR9C4id --- .github/ISSUE_TEMPLATE/bug.yml | 1 - .github/ISSUE_TEMPLATE/feature.yml | 1 - .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/canary.yml | 2 +- .github/workflows/ci.yml | 21 +- .github/workflows/release.yml | 16 - CLAUDE.md | 52 ++- CONTRIBUTING.md | 16 +- README.md | 3 +- docs/BOX-IMAGE.md | 8 +- docs/DEPLOY-RUNBOOK.md | 2 +- docs/LODY-MERGE.md | 2 +- docs/LODY-MODELS.md | 14 +- env.defaults | 5 - lint-baseline.json | 2 +- package.json | 2 +- packages/box/Dockerfile | 18 +- packages/box/README.md | 8 +- packages/box/RECORD.md | 34 +- .../credential-helper/cmd/blitz-cred/main.go | 53 +++ .../cmd/blitz-cred/main_test.go | 64 +++ packages/box/credential-helper/go.mod | 3 + .../internal/atomicfile/atomic.go | 21 +- .../internal/atomicfile/atomic_test.go | 0 .../internal/controlplane/controlplane.go | 216 +++++++++ .../controlplane/controlplane_test.go | 375 +++++++++++++++ .../internal/filelock/filelock.go | 7 +- .../internal/filelock/filelock_test.go | 0 .../internal/store/store.go | 35 +- .../internal/workspace/apitoken.go | 22 + .../box/guest-tests/test/agent-shims.test.ts | 22 +- .../test/blitz-term-credentials.test.ts | 5 +- .../test/box-credential-service.test.ts | 6 + .../guest-tests/test/codex-session.test.ts | 32 -- .../test/credential-refresh.test.ts | 2 +- .../test/remote-control-service.test.ts | 4 +- .../box/rootfs/etc/profile.d/blitz-npm.sh | 11 +- .../dependencies.d/init-state | 0 .../dependencies.d/init-state} | 0 .../dependencies.d/init-state} | 0 .../dependencies.d/init-state} | 0 .../lody-daemon/dependencies.d/register | 0 .../etc/s6-overlay/s6-rc.d/register/type | 1 - .../rootfs/etc/s6-overlay/s6-rc.d/register/up | 2 - .../dependencies.d/{register => init-state} | 0 .../etc/s6-overlay/s6-rc.d/remote-control/run | 6 +- .../dependencies.d/{register => init-state} | 0 .../dependencies.d/{register => init-state} | 0 .../dependencies.d/{register => init-state} | 0 .../s6-rc.d/user/contents.d/register | 1 - .../s6-overlay/s6-rc.d/user/contents.d/watch | 1 - .../s6-rc.d/watch/dependencies.d/register | 1 - .../rootfs/etc/s6-overlay/s6-rc.d/watch/run | 25 - .../rootfs/etc/s6-overlay/s6-rc.d/watch/type | 1 - .../rootfs/usr/local/bin/blitz-cred-claude | 10 - .../box/rootfs/usr/local/bin/blitz-cred-codex | 7 - packages/box/rootfs/usr/local/bin/claude | 41 +- .../usr/local/libexec/blitz-codex-session | 30 +- .../rootfs/usr/local/libexec/blitz-payload | 12 +- .../rootfs/usr/local/libexec/blitz-register | 49 -- .../rootfs/usr/local/libexec/blitz-rules-boot | 12 +- packages/box/test/smoke.sh | 32 +- packages/broker/Dockerfile | 44 -- packages/broker/Dockerfile.dockerignore | 13 - packages/broker/README.md | 138 ------ packages/broker/RECORD.md | 192 -------- packages/broker/cmd/blitz-broker/main.go | 194 -------- packages/broker/cmd/blitz-cred/main.go | 108 ----- packages/broker/cmd/blitz-cred/main_test.go | 221 --------- packages/broker/deploy/provision-broker.sh | 327 ------------- .../broker/deploy/provision-broker.test.sh | 417 ----------------- packages/broker/deploy/verify-broker-box.sh | 217 --------- packages/broker/entrypoint.sh | 38 -- packages/broker/go.mod | 3 - packages/broker/internal/broker/accounts.go | 147 ------ .../broker/internal/broker/accounts_test.go | 140 ------ .../broker/internal/broker/authorized_keys.go | 62 --- packages/broker/internal/broker/credential.go | 23 - packages/broker/internal/broker/deposit.go | 104 ----- packages/broker/internal/broker/lock.go | 80 ---- packages/broker/internal/broker/mint.go | 73 --- packages/broker/internal/broker/reconcile.go | 76 --- .../broker/internal/broker/roaming_test.go | 329 ------------- .../broker/internal/broker/security_test.go | 193 -------- packages/broker/internal/broker/sync.go | 120 ----- packages/broker/internal/broker/types.go | 20 - .../internal/controlplane/controlplane.go | 421 ----------------- .../controlplane/controlplane_test.go | 261 ----------- .../broker/internal/controlplane/device.go | 156 ------- packages/broker/internal/enroll/enroll.go | 37 -- packages/broker/internal/feed/feed.go | 170 ------- packages/broker/internal/vendor/claude.go | 60 --- packages/broker/internal/vendor/codex.go | 48 -- packages/broker/internal/vendor/vendor.go | 106 ----- .../broker/internal/vendor/vendor_test.go | 30 -- .../broker/internal/workspace/apitoken.go | 29 -- packages/broker/internal/workspace/harness.go | 270 ----------- .../broker/internal/workspace/register.go | 280 ----------- .../broker/internal/workspace/roaming_test.go | 438 ------------------ packages/broker/internal/workspace/ssh.go | 176 ------- packages/broker/internal/workspace/watch.go | 136 ------ .../internal/workspace/workspace_test.go | 154 ------ packages/broker/sshd_config | 22 - packages/control-plane/README.md | 3 - packages/control-plane/RECORD.md | 24 +- packages/control-plane/core/app.ts | 7 - packages/control-plane/core/bootstrap.ts | 36 +- packages/control-plane/core/environment.ts | 42 -- packages/control-plane/core/janitors.ts | 3 +- packages/control-plane/core/machines.ts | 4 - packages/control-plane/core/oauth.ts | 20 +- packages/control-plane/core/registry.ts | 430 ----------------- packages/control-plane/core/types.ts | 12 +- packages/control-plane/core/wire.ts | 42 +- .../control-plane/core/workspace-records.ts | 1 - .../0053_drop_credential_broker.sql | 196 ++++++++ .../scripts/lib/box-image-inputs.mjs | 6 +- .../scripts/lib/box-payload-files.mjs | 27 +- .../scripts/lib/worker-source.mjs | 40 +- .../test/blitzdev-schema.test.ts | 16 +- packages/control-plane/test/bootstrap.test.ts | 54 +-- .../test/box-payload-files.test.mjs | 6 +- .../test/broker-retirement-migration.test.ts | 210 +++++++++ .../control-plane/test/control-plane.test.ts | 409 ---------------- .../control-plane/test/core-imports.test.ts | 3 +- .../test/deploy-tooling.test.mjs | 10 +- packages/control-plane/test/env.d.ts | 2 + packages/control-plane/test/helpers.ts | 3 - packages/control-plane/test/identity.test.ts | 4 +- .../test/plan-box-payload.test.mjs | 2 +- .../test/publish-box-payload.test.mjs | 4 +- .../control-plane/test/wire-drift.test.ts | 55 --- .../test/workspace-environment.test.ts | 48 +- packages/control-plane/vitest.config.ts | 4 + packages/control-plane/wrangler.toml.example | 1 - packages/schema/README.md | 3 +- packages/schema/src/agent-catalog.ts | 8 +- packages/schema/src/api.ts | 9 - packages/schema/src/broker.ts | 21 - packages/schema/src/index.ts | 1 - packages/schema/src/workspace.ts | 9 - packages/webapp/src/lody/agent-configs.ts | 14 +- packages/webapp/test/lody-daemon-harness.ts | 30 +- .../test/lody-session-roundtrip.test.ts | 2 +- tools/e2e/coverage.mjs | 250 +--------- tools/e2e/credentials.mjs | 2 +- 146 files changed, 1417 insertions(+), 8047 deletions(-) create mode 100644 packages/box/credential-helper/cmd/blitz-cred/main.go create mode 100644 packages/box/credential-helper/cmd/blitz-cred/main_test.go create mode 100644 packages/box/credential-helper/go.mod rename packages/{broker => box/credential-helper}/internal/atomicfile/atomic.go (60%) rename packages/{broker => box/credential-helper}/internal/atomicfile/atomic_test.go (100%) create mode 100644 packages/box/credential-helper/internal/controlplane/controlplane.go create mode 100644 packages/box/credential-helper/internal/controlplane/controlplane_test.go rename packages/{broker => box/credential-helper}/internal/filelock/filelock.go (93%) rename packages/{broker => box/credential-helper}/internal/filelock/filelock_test.go (100%) rename packages/{broker => box/credential-helper}/internal/store/store.go (71%) create mode 100644 packages/box/credential-helper/internal/workspace/apitoken.go rename packages/box/rootfs/etc/s6-overlay/s6-rc.d/{register => box-credential}/dependencies.d/init-state (100%) rename packages/box/rootfs/etc/s6-overlay/s6-rc.d/{box-credential/dependencies.d/register => dockerd/dependencies.d/init-state} (100%) rename packages/box/rootfs/etc/s6-overlay/s6-rc.d/{dockerd/dependencies.d/register => dufs/dependencies.d/init-state} (100%) rename packages/box/rootfs/etc/s6-overlay/s6-rc.d/{dufs/dependencies.d/register => lody-daemon/dependencies.d/init-state} (100%) delete mode 100644 packages/box/rootfs/etc/s6-overlay/s6-rc.d/lody-daemon/dependencies.d/register delete mode 100644 packages/box/rootfs/etc/s6-overlay/s6-rc.d/register/type delete mode 100644 packages/box/rootfs/etc/s6-overlay/s6-rc.d/register/up rename packages/box/rootfs/etc/s6-overlay/s6-rc.d/remote-control/dependencies.d/{register => init-state} (100%) rename packages/box/rootfs/etc/s6-overlay/s6-rc.d/rules/dependencies.d/{register => init-state} (100%) rename packages/box/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/{register => init-state} (100%) rename packages/box/rootfs/etc/s6-overlay/s6-rc.d/ttyd/dependencies.d/{register => init-state} (100%) delete mode 100644 packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/register delete mode 100644 packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/watch delete mode 100644 packages/box/rootfs/etc/s6-overlay/s6-rc.d/watch/dependencies.d/register delete mode 100755 packages/box/rootfs/etc/s6-overlay/s6-rc.d/watch/run delete mode 100644 packages/box/rootfs/etc/s6-overlay/s6-rc.d/watch/type delete mode 100755 packages/box/rootfs/usr/local/bin/blitz-cred-claude delete mode 100755 packages/box/rootfs/usr/local/bin/blitz-cred-codex delete mode 100755 packages/box/rootfs/usr/local/libexec/blitz-register delete mode 100644 packages/broker/Dockerfile delete mode 100644 packages/broker/Dockerfile.dockerignore delete mode 100644 packages/broker/README.md delete mode 100644 packages/broker/RECORD.md delete mode 100644 packages/broker/cmd/blitz-broker/main.go delete mode 100644 packages/broker/cmd/blitz-cred/main.go delete mode 100644 packages/broker/cmd/blitz-cred/main_test.go delete mode 100755 packages/broker/deploy/provision-broker.sh delete mode 100755 packages/broker/deploy/provision-broker.test.sh delete mode 100755 packages/broker/deploy/verify-broker-box.sh delete mode 100644 packages/broker/entrypoint.sh delete mode 100644 packages/broker/go.mod delete mode 100644 packages/broker/internal/broker/accounts.go delete mode 100644 packages/broker/internal/broker/accounts_test.go delete mode 100644 packages/broker/internal/broker/authorized_keys.go delete mode 100644 packages/broker/internal/broker/credential.go delete mode 100644 packages/broker/internal/broker/deposit.go delete mode 100644 packages/broker/internal/broker/lock.go delete mode 100644 packages/broker/internal/broker/mint.go delete mode 100644 packages/broker/internal/broker/reconcile.go delete mode 100644 packages/broker/internal/broker/roaming_test.go delete mode 100644 packages/broker/internal/broker/security_test.go delete mode 100644 packages/broker/internal/broker/sync.go delete mode 100644 packages/broker/internal/broker/types.go delete mode 100644 packages/broker/internal/controlplane/controlplane.go delete mode 100644 packages/broker/internal/controlplane/controlplane_test.go delete mode 100644 packages/broker/internal/controlplane/device.go delete mode 100644 packages/broker/internal/enroll/enroll.go delete mode 100644 packages/broker/internal/feed/feed.go delete mode 100644 packages/broker/internal/vendor/claude.go delete mode 100644 packages/broker/internal/vendor/codex.go delete mode 100644 packages/broker/internal/vendor/vendor.go delete mode 100644 packages/broker/internal/vendor/vendor_test.go delete mode 100644 packages/broker/internal/workspace/apitoken.go delete mode 100644 packages/broker/internal/workspace/harness.go delete mode 100644 packages/broker/internal/workspace/register.go delete mode 100644 packages/broker/internal/workspace/roaming_test.go delete mode 100644 packages/broker/internal/workspace/ssh.go delete mode 100644 packages/broker/internal/workspace/watch.go delete mode 100644 packages/broker/internal/workspace/workspace_test.go delete mode 100644 packages/broker/sshd_config delete mode 100644 packages/control-plane/core/registry.ts create mode 100644 packages/control-plane/migrations/0053_drop_credential_broker.sql create mode 100644 packages/control-plane/test/broker-retirement-migration.test.ts delete mode 100644 packages/schema/src/broker.ts diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 4ddb5dc1..67de92e8 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -10,7 +10,6 @@ body: - control-plane - webapp - box - - broker - schema / fixtures - deploy tooling - docs diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index 27a80feb..470ce443 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -10,7 +10,6 @@ body: - control-plane - webapp - box - - broker - schema / fixtures - deploy tooling - docs diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 34081cdf..27a3cbb7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,5 +9,5 @@ - [ ] `npm test` passes - [ ] `lint-baseline.json` was not raised (and was lowered if this change removes findings) - [ ] Any new payload crossing a runtime boundary has fixtures under `packages/schema/fixtures/` and conformance tests on both sides -- [ ] Go changes: `go test ./...` passes in the affected module(s) (`packages/broker`, `packages/box/gateway`) +- [ ] Go changes: `go test ./...` passes in each affected box Go module - [ ] Docs updated where behavior or setup changed (`docs/`, package READMEs) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index b0f3e22b..00a2262a 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -165,7 +165,7 @@ jobs: # Feature flags are deployment config, not image inputs. The repository # root remains the build context because the Dockerfile also copies the - # broker and schema fixtures. + # credential helper and schema fixtures. - name: Build the box image if: steps.plan.outputs.published == 'false' env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb280175..cab89a6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,25 +129,22 @@ jobs: env: BLITZDEV_MANAGED: "1" - broker: - name: Go broker + credential-helper: + name: Go box credential helper runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: - go-version-file: packages/broker/go.mod - cache-dependency-path: packages/broker/go.mod + go-version-file: packages/box/credential-helper/go.mod + cache-dependency-path: packages/box/credential-helper/go.mod + - run: test -z "$(gofmt -l .)" || { gofmt -d .; exit 1; } + working-directory: packages/box/credential-helper - run: go test ./... - working-directory: packages/broker + working-directory: packages/box/credential-helper - # The gateway's Go suite had been RED and nobody saw it: this job runs - # `go test` for `packages/broker` only, and the gateway is compiled by the - # box-image build, which does not run its tests. Phase 6 fixed the suite in - # passing; this job is what stops it happening again. It also holds one half - # of two cross-runtime contracts — the webApp ticket and the lody share claim - # — so a fixture added on the TS side and missed here fails in CI rather than - # on a box. + # The gateway has a separate test job because an image build only compiles it. + # This job also checks the webApp ticket and Lody share contracts. gateway: name: Go box gateway runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0715485c..4ea3df02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,6 @@ jobs: runs-on: ubuntu-latest outputs: box-digest: ${{ steps.box.outputs.digest }} - broker-digest: ${{ steps.broker.outputs.digest }} image-owner: ${{ steps.namespace.outputs.owner }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -63,19 +62,6 @@ jobs: ghcr.io/${{ env.IMAGE_OWNER }}/blitz-box:${{ github.ref_name }} ghcr.io/${{ env.IMAGE_OWNER }}/blitz-box:latest - - name: Build and push broker - id: broker - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 - with: - context: . - file: packages/broker/Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - provenance: true - tags: | - ghcr.io/${{ env.IMAGE_OWNER }}/blitz-broker:${{ github.ref_name }} - ghcr.io/${{ env.IMAGE_OWNER }}/blitz-broker:latest - # Deploys the control plane pinned to the box image this run just pushed, so # a version tag is a complete release with no manual publish step. Opt-in via # two repository secrets (docs/BOX-IMAGE.md § Automatic releases); without @@ -217,7 +203,6 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BOX_DIGEST: ${{ needs.images.outputs.box-digest }} - BROKER_DIGEST: ${{ needs.images.outputs.broker-digest }} IMAGE_OWNER: ${{ needs.images.outputs.image-owner }} steps: - name: Create or update release notes @@ -239,6 +224,5 @@ jobs: printf '%s\n\n' "$kept" printf '## Container image digests\n\n' printf -- '- `ghcr.io/%s/blitz-box:%s` — `%s`\n' "$IMAGE_OWNER" "$tag" "$BOX_DIGEST" - printf -- '- `ghcr.io/%s/blitz-broker:%s` — `%s`\n' "$IMAGE_OWNER" "$tag" "$BROKER_DIGEST" } >"$RUNNER_TEMP/release-notes.md" gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --notes-file "$RUNNER_TEMP/release-notes.md" diff --git a/CLAUDE.md b/CLAUDE.md index df1d5773..38a87c4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,15 +32,15 @@ npm test # control-plane, box guest tests, ui, guest node:test, ## Known debt (as of 2026-08-18) -- 66 anti-slop findings remain, all Tier C: external-boundary code that - needs real parsers (23 no-unknown-parameters, 27 no-runtime-typeof in - plain JS, 12 no-unsafe-dictionary-type, 4 no-unknown-returns). The counts - fell from 74 (31/27/12/4) on 2026-09-02 when the box credential wire and - the workspace credential store were deleted (plans/ORG-CREDENTIALS.md), - and before that from 102 (47/27/22/6) on 2026-08-29 when the native-chat - surface and the box actor were deleted; the baseline moved with them. Fixing one - requires characterization tests FIRST — these fixes can change accepted - inputs. Plan and history: GitHub issue #1. +- 42 anti-slop findings remain. All are Tier C external boundaries that need + real parsers (11 no-unknown-parameters, 21 no-runtime-typeof in plain JS, + 8 no-unsafe-dictionary-type, 2 no-unknown-returns). + The count fell from 43 when the control-plane broker registry was deleted. + Earlier credential changes lowered the count from 74 on 2026-09-02. + Native chat deletion lowered it from 102 on 2026-08-29. + The baseline moved with each change. Add characterization tests before a + fix because these fixes can change accepted inputs. + Plan and history: GitHub issue #1. - 6 `TODO(deslop-tier-c):` markers flag type assertions whose invariant is not actually enforced today (latent-bug candidates). Grep for the marker. - `TODO(house-canon):` markers flag direct fetch/console sites awaiting @@ -93,7 +93,17 @@ tests are deleted. Restoring recipes means rebuilding that delivery, not just remounting the routes. The box's device-code `enroll` service and the `blitz-cred enroll` verb went in the same change: every provisioned box gets its credential from phone-home before the container starts, so the service -had no path left to run (the broker VM keeps the shared device-flow client). +had no path left to run. + +Retired 2026-09-05: the credential broker. Its daemon, image release, SSH +custody, and box `register`, `token`, and `watch` paths are deleted. +The control-plane registry routes and broker wire types are also deleted. +Migration 0053 drops `broker_keys`, `broker_members`, and `broker_boxes`. +It also drops `boxes.is_broker`, `boxes.broker_box_id`, and +`machines.broker_box_id`. +Claude and Codex now use their native authentication stores. +The box-owned `blitz-cred api-token` survives with machine token families and +the `/agent/*` plane. Restoring the broker means rebuilding it, not re-enabling it. Retired 2026-09-05: the Org Drive and usage-capture surfaces. Their D1 tables, R2 object flows, WebDAV synchronizer, cron, schemas, routes, and webApp screens @@ -107,8 +117,10 @@ deleted. Workspace repository cloning remains under workspace-repository names. Retired 2026-09-05: permissive create-workspace and phone-home compatibility. Create requests now reject legacy machine, template, SSH, environment, and folder fields. Phone-home accepts canonical fields and returns only box and -token fields. Deployed-box token families, `/boxes/:id/feed`, the constant -workspace environment route, box-config v1, tunnel access, and port 7444 remain. +token fields. Deployed-box token families, box-config v1, tunnel access and +port 7444 remain. `/boxes/:id/feed` and the constant workspace environment +route were still listed here on 2026-09-05; the credential-broker retirement +below deleted both, because the broker was the only caller of either. Retired 2026-09-05: the `/integrations` API and `/settings/integrations` UI aliases. Canonical connection routes remain. @@ -185,17 +197,11 @@ agent must not undo: that used to hold it pinned the workspace owner, and that is the bug the structure now prevents. -Three compatibility surfaces are load-bearing and have no expiry date yet: -`GET /boxes/:id/feed` (served from `machines`), -`GET /workspaces/:id/environment` (a constant `{env:{}, startupScript:null, -filesReady:true}`, because deployed brokers poll it every second at boot and -wait for exactly those three fields), and the token families migration 0041 -copied hash-for-hash so no deployed guest had to re-enrol. - -The `workspace environment` cross-runtime contract is retired with its fixtures -and both conformance tests: no runtime reads the route any more, so what remains -is that constant three-field shim, pinned alone by -`control-plane/test/workspace-environment.test.ts`. +The token families migration 0041 copied each hash without changes. +This kept deployed guests enrolled during the member-machines change. + +The workspace environment contract, route, fixtures, and conformance tests are +deleted. No live box needs that compatibility path. Every field of `WorkspaceView` is required, including `members`, `credentials`, `myRole`, `defaultMachineTypeId` and `autoProvision`. The only diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f7c1445..dfe5d9be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ CLAUDE.md wins. ## Setup - Node.js 22.13 or newer (`engines` in the root `package.json`) and npm. -- Go 1.26+ for the Go components (broker and box gateway). +- Go 1.26+ for the box credential helper and gateway. - Docker for box-image work. ```sh @@ -50,11 +50,11 @@ pin which boundary, and which tests enforce them — is in ## Go components -Two Go modules sit outside the npm workspace graph and are not touched by -`npm test`. Test them directly: +Two Go modules sit outside the npm workspace graph. `npm test` does not run them. +Test them directly: ```sh -(cd packages/broker && go test ./...) +(cd packages/box/credential-helper && go test ./...) (cd packages/box/gateway && go test ./...) ``` @@ -81,13 +81,13 @@ subsystem when one applies. `.github/workflows/ci.yml`, on every push and pull request: - **JavaScript**: `npm ci`, then the three gates. -- **Go broker**: `go test ./...` in `packages/broker`. +- **Go box credential helper**: `go test ./...` in `packages/box/credential-helper`. - **Box image**: an amd64 `docker build` of `packages/box/Dockerfile` as a build check (no push). -Pushing a `v*` tag runs `.github/workflows/release.yml`, which builds and -publishes the box and broker images for amd64 **and** arm64 — so an -amd64-only CI pass does not guarantee the arm64 release build. +Pushing a `v*` tag runs `.github/workflows/release.yml`. +It builds the box image for amd64 and arm64. +An amd64-only CI pass does not guarantee the arm64 release build. ## Design records diff --git a/README.md b/README.md index f8ae89ea..3cce5c33 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,6 @@ Follow the [self-host guide](docs/SELF-HOST.md). - [`box`](packages/box/README.md) the complete workspace runtime: SSH, Docker, agent harnesses, terminal, files, and previews. - [`control-plane`](packages/control-plane/README.md) workspaces, member machines, roles, credential injection, volumes, and compute providers. - [`webApp`](packages/webapp/README.md) the browser webApp for creating, configuring, sharing, and working inside workspaces. -- [`broker`](packages/broker/README.md) short-lived Claude and Codex credential delivery for workspace fleets. - [`schema`](packages/schema/README.md) shared wire types and cross-runtime conformance fixtures. ## Docs @@ -65,7 +64,7 @@ Follow the [self-host guide](docs/SELF-HOST.md). - [Box image](docs/BOX-IMAGE.md) build, publish, and upgrade the workspace image. - [Contributing](CONTRIBUTING.md) the three gates, the lint ratchet, fixtures, commit style. - [Security](SECURITY.md) reporting, secret blast radius, the workspace trust model. -- Packages: [box](packages/box/README.md) · [control-plane](packages/control-plane/README.md) · [webapp](packages/webapp/README.md) · [broker](packages/broker/README.md) · [schema](packages/schema/README.md) +- Packages: [box](packages/box/README.md) · [control-plane](packages/control-plane/README.md) · [webapp](packages/webapp/README.md) · [schema](packages/schema/README.md) ## Roadmap diff --git a/docs/BOX-IMAGE.md b/docs/BOX-IMAGE.md index b08acc46..d2f6f34a 100644 --- a/docs/BOX-IMAGE.md +++ b/docs/BOX-IMAGE.md @@ -116,7 +116,7 @@ the `canary` environment: The base release id deliberately excludes payload-owned files, the gateway binary, the daemon, and the repository `env.defaults`. It includes the -base-owned credential broker sources. The Dockerfile owns the box defaults and +base-owned credential helper sources. The Dockerfile owns the box defaults and writes a comment-only `/etc/blitz/env.defaults` for deployed hosts that still pass it with `--env-file`. A payload-only merge therefore reuses the current image. Its baked stamp names only the bytes in the baked payload and may name the payload current when @@ -194,9 +194,9 @@ Hetzner types only. Adding an arm type means revisiting this. ## Mode A: publish to a registry -Pushing a git tag `v*` runs `.github/workflows/release.yml`, which builds -`blitz-box` (and `blitz-broker`) for `linux/amd64` and `linux/arm64` and -pushes them to GHCR under your repository owner: +Pushing a git tag `v*` runs `.github/workflows/release.yml`. +It builds `blitz-box` for `linux/amd64` and `linux/arm64`. +It pushes the image to GHCR under your repository owner: ```sh git tag v0.1.0 diff --git a/docs/DEPLOY-RUNBOOK.md b/docs/DEPLOY-RUNBOOK.md index 8e975322..683be6d7 100644 --- a/docs/DEPLOY-RUNBOOK.md +++ b/docs/DEPLOY-RUNBOOK.md @@ -52,7 +52,7 @@ npx wrangler deployments list --config packages/control-plane/wrangler.toml A `v*` tag is the only path. `.github/workflows/release.yml` holds the credentials and does the whole job, in this order: -1. Builds and pushes the box and broker images for amd64 and arm64. +1. Builds and pushes the box image for amd64 and arm64. 2. Waits for a human to approve the `production` environment. 3. Writes the deployment config from a repository secret. 4. Pins the box image digest it just built. diff --git a/docs/LODY-MERGE.md b/docs/LODY-MERGE.md index 204b13bb..e4b7efdd 100644 --- a/docs/LODY-MERGE.md +++ b/docs/LODY-MERGE.md @@ -441,7 +441,7 @@ cd ../.. npm run typecheck npm run lint:gate npm test -( cd packages/broker && go test ./... ) +( cd packages/box/credential-helper && go test ./... ) ( cd packages/box/gateway && test -z "$(gofmt -l .)" && go test ./... ) git diff --check ``` diff --git a/docs/LODY-MODELS.md b/docs/LODY-MODELS.md index ad1add36..fc7957ff 100644 --- a/docs/LODY-MODELS.md +++ b/docs/LODY-MODELS.md @@ -125,20 +125,18 @@ vendored static list was never consulted. ### What was blocking it (removed 2026-09-01) -`DISABLE_AUTOUPDATER=1` had been set in four places — the image-wide `ENV` in +`DISABLE_AUTOUPDATER=1` had been set in four places. +Three box places were the image-wide `ENV` in `packages/box/Dockerfile`, the PATH shim `rootfs/usr/local/bin/claude`, -`rootfs/etc/profile.d/blitz-npm.sh`, and `broker/internal/vendor/vendor.go`, -which stripped any inbound value and force-appended `=1` (asserted by a test in -`roaming_test.go`). Four sites because they are four different process-spawn -paths: s6 daemons inherit the image ENV, login shells rebuild from -`/etc/profile`, the shim covers any invocation, and the broker constructs the -child environment from scratch rather than inheriting it. +and `rootfs/etc/profile.d/blitz-npm.sh`. +These sites cover s6 daemons, login shells, and direct commands. +The now-retired broker set the fourth site in its spawn environment. The flag gated the **background** update check only — the explicit `claude update` subcommand ignored it, which is why the run above worked with the flag live in the environment. -All four are gone, `codex`'s shim now passes +All four are gone. `codex`'s shim now passes `-c check_for_update_on_startup=true`, and `@anthropic-ai/claude-code` is installed `@latest` at build time rather than pinned. Nothing holds a CLI version anymore. diff --git a/env.defaults b/env.defaults index 9d967637..21389e01 100644 --- a/env.defaults +++ b/env.defaults @@ -1,8 +1,3 @@ -# broker - -# absolute path: Persistent state directory used by the broker daemon. -BLITZ_BROKER_STATE_DIR=/var/lib/blitz-broker - # control-plane (Cloudflare Worker vars are documentation-only here; wrangler.toml owns their values.) # ALLOWED_EMAIL_DOMAINS (string): Comma-separated bare email domains; empty allows any domain on every sign-in; wrangler.toml supplies the runtime value. diff --git a/lint-baseline.json b/lint-baseline.json index e82fb2fa..07e7be96 100644 --- a/lint-baseline.json +++ b/lint-baseline.json @@ -1,5 +1,5 @@ { - "anti-slop/no-unknown-parameters": 12, + "anti-slop/no-unknown-parameters": 11, "anti-slop/no-runtime-typeof": 21, "anti-slop/no-unsafe-dictionary-type": 8, "anti-slop/no-unknown-returns": 2, diff --git a/package.json b/package.json index 99cf5c9c..2aab368f 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "provider:check": "npm run provider:check --workspace @blitzos/control-plane --", "openapi:generate": "npm run openapi:generate --workspace @blitzos/control-plane", "typecheck": "npm run typecheck --workspaces --if-present", - "test": "npm run test --workspaces --if-present && node --test tools/oxlint/blitz-house/*.test.mjs && sh packages/broker/deploy/provision-broker.test.sh" + "test": "npm run test --workspaces --if-present && node --test tools/oxlint/blitz-house/*.test.mjs" }, "devDependencies": { "@oxlint/plugins": "1.79.0", diff --git a/packages/box/Dockerfile b/packages/box/Dockerfile index ee1452c8..bcd41a82 100644 --- a/packages/box/Dockerfile +++ b/packages/box/Dockerfile @@ -3,10 +3,10 @@ FROM --platform=$BUILDPLATFORM golang:1.26.5-bookworm@sha256:53eeac89074db483fdf0ab3be1df32bf6e47562263d2d0d6baa7f26acb4957dd AS cred-build ARG TARGETARCH -WORKDIR /src/packages/broker -COPY packages/broker/go.mod ./ -COPY packages/broker/cmd/blitz-cred ./cmd/blitz-cred -COPY packages/broker/internal ./internal +WORKDIR /src/packages/box/credential-helper +COPY packages/box/credential-helper/go.mod ./ +COPY packages/box/credential-helper/cmd/blitz-cred ./cmd/blitz-cred +COPY packages/box/credential-helper/internal ./internal RUN CGO_ENABLED=0 GOOS=linux GOARCH="$TARGETARCH" go build -buildvcs=false -trimpath -ldflags="-s -w" -o /out/blitz-cred ./cmd/blitz-cred FROM --platform=$BUILDPLATFORM golang:1.26.5-bookworm@sha256:53eeac89074db483fdf0ab3be1df32bf6e47562263d2d0d6baa7f26acb4957dd AS gateway-build @@ -194,11 +194,9 @@ RUN set -eux; \ ln -s /workspace /srv/blitz-files/workspace; \ chown -R blitz:blitz /opt/blitz/npm -# /usr/local/bin comes FIRST, ahead of the npm prefix, so the PATH shims at -# /usr/local/bin/claude and /usr/local/bin/codex win over the real binaries they -# exec — and so a copy a member installs into their own writable prefix cannot -# shadow them. That shadowing is the single most common way a terminal ends up -# signed out while the same box holds a valid credential. +# /usr/local/bin comes first, so the stable Claude and Codex entry points win. +# The Codex shim selects native login paths. +# The Claude shim starts the native CLI without changing authentication. # # THE VENDOR CLIs UPDATE THEMSELVES, and that is the point. A new Anthropic # model reaches the Lody composer only when the `claude` binary is new enough to @@ -206,7 +204,7 @@ RUN set -eux; \ # (docs/LODY-MODELS.md). Holding the CLI at a build-time version therefore held # the model list at build time too, and made a box-image rebake the delivery # mechanism for models. It no longer is: `DISABLE_AUTOUPDATER` is gone from the -# image, the shims, the profile and the broker, and codex's shim now leaves its +# image, the shims, and the profile. Codex's shim now leaves its # startup update check on. # # What still protects the shims is the PATH order above, not a pin: an update diff --git a/packages/box/README.md b/packages/box/README.md index 0d2e9978..ded93b57 100644 --- a/packages/box/README.md +++ b/packages/box/README.md @@ -3,7 +3,7 @@ One OCI image provides one agent workspace. s6 starts key-only SSH, ttyd, dufs, the gateway, Docker, and control-plane helpers. It also initializes persistent state and the optional cgroup boundary. -Control-plane helpers register broker keys, refresh the bearer, sync rules, and deposit agent logins. +Control-plane helpers refresh the machine bearer and sync agent rules. Other longruns provide Claude Remote Control and payload updates. The optional Lody set contains the daemon, Unix bridge, project registrar, and watchdog. ttyd uses tmux for named, persistent terminal, Claude, and Codex sessions. @@ -15,7 +15,7 @@ cloudflared connects hosted browser traffic after provisioning supplies its toke Docker-in-Docker starts only when the container is privileged. The payload owns the complete s6 service tree and its launchers. -The base image owns the payload updater and `blitz-cred`. +The base image owns the payload updater and the box-owned `blitz-cred api-token` helper. `/var/lib/blitz` keeps SSH keys, agent HOME, Docker data, Lody data, tokens, and credentials. `/workspace` is a caller-owned bind mount. @@ -233,8 +233,8 @@ The payload channel changes a running container in place. It updates payload-owned commands, service helpers, the gateway, agent rules, and the Lody daemon. It also updates `/etc/blitz/sshd_config`, `/etc/gitconfig`, `/etc/profile.d/blitz-npm.sh`, and `/etc/tmux.conf`. It can add, remove, or redefine s6 services. -It rejects live changes to four recovery service definitions. -Those services are `cgroups`, `init-state`, `register`, and `payload`. +It rejects live changes to three recovery service definitions. +Those services are `cgroups`, `init-state`, and `payload`. An update that restarts Lody waits while the daemon reports active turns. The default wait cap is four hours. At that cap, it forces the restart and may disconnect those turns. diff --git a/packages/box/RECORD.md b/packages/box/RECORD.md index d429b204..04358f65 100644 --- a/packages/box/RECORD.md +++ b/packages/box/RECORD.md @@ -23,19 +23,18 @@ reserved for boxes already in the field. Successor plan: heartbeat. No exec jobs. No activity. No layout REST. No volume API. - Claude and Codex run as their pinned official CLIs inside tmux. They read the native HOME files on the state volume (`claude login` over ssh, once). -- `blitz-cred` (register/token/watch) comes from the open broker module. This - repo keeps no second shell implementation. -- One state volume at `/var/lib/blitz`: identity keypair + enrollment, SSH host - keys, authorized_keys, broker client state, HOME. The +- The box-owned `blitz-cred api-token` helper refreshes machine credentials. + It carries no agent or control-plane API schema. +- One state volume at `/var/lib/blitz`: identity keypair, machine credentials, + SSH host keys, authorized_keys, and HOME. The workspace directory is a caller bind mount at `/workspace`. - One unprivileged `blitz` user runs the work. Root does init, sshd, and UID mapping only. No password login. No root login. -- Supervision: pinned s6-overlay. Service graph: init-state → - `blitz-cred register` → sshd · ttyd · dufs · HTTP gateway · - `blitz-cred watch`. No CP config on the volume → register and watch are - SKIPPED (2026-08-11). The box runs alone: `docker run` → working box, - zero accounts; agent credentials = native HOME files (`claude login` over - ssh, once). The CP + broker are an opt-in overlay. +- Supervision: pinned s6-overlay. Service graph: cgroups → init-state → + sshd · ttyd · dufs · HTTP gateway · Docker · agent services. + The credential refresher and rules sync also start after init-state. + The box runs alone with native HOME credentials. + The control plane remains an optional overlay. - Image contents, all pinned by digest or version: `node:22-bookworm-slim` base (Node stays: the agent CLIs are Node; NodeSource dies), openssh, tmux, git, ttyd (checksummed release), dufs 0.46.0 (checksummed @@ -63,17 +62,12 @@ reserved for boxes already in the field. Successor plan: Never curl|sh. - Mac docs: Colima (decided 2026-08-11). Free, OSS. Other docker-compatible runtimes work, undocumented. -- Enrollment: the box never enrolls itself (device-code `enroll` service and - `blitz-cred enroll` deleted 2026-09-04). Hosted provisioning writes the - origin and `box-credential.json` from the phone-home answer before the - container starts; the broker image keeps the shared device-flow client. - The paragraph below is history: Skipped when a - credential already exists (hosted: phone_home delivered it) or no CP is - configured. +- Enrollment: the box never enrolls itself. + Hosted provisioning writes the origin and `box-credential.json` before the container starts. - Proof on later CP calls (decided 2026-08-11): the device-flow OAuth tokens. Short-lived access + rotating refresh. Opaque, hashed rows, constant-time compare. No mTLS, no request signing. The keypair serves the SSH surfaces. - `blitz-cred register` authenticates with this token (2026-08-11 record fix). + `blitz-cred api-token` refreshes this token for later agent-plane calls. - Docker in the box (decided 2026-08-11): DinD. The image ships an inner dockerd; the container runs privileged. Isolation boundary = the single-tenant VM (hosted) or the user's machine (BYOM), as before. @@ -152,7 +146,7 @@ report. OAuth tokens. Use them: short-lived access + rotating refresh, opaque, hashed rows, constant-time compare — the pattern the CP already uses for sessions. No mTLS. No DPoP. No request signing. The keypair serves the SSH - surfaces (broker mint/deposit). + surfaces. 2. Golden: thin snapshot. Bake = container runtime + the open image pre-pulled by digest. Boot ≈ 1 min. Clean-base-and-pull adds ~1–1.5 min (docker install + 1–2 GB pull) and puts the registry in the boot path. @@ -172,7 +166,7 @@ report. `scp` a key, or pipe `gh auth token` over ssh. The docs show the pattern. Prefer per-repo deploy keys over copying a main identity. 6. Second pass, cross-package synthesis (2026-08-11): the box runs with no - control plane (register/watch skipped; HOME credentials) · hosted + control plane (broker services absent; HOME credentials) · hosted enrollment = the phone_home response delivers the credential, no human · no enrollment code on the box since 2026-09-04 · the cross-runtime conformance fixtures live in the shared `schema` package and diff --git a/packages/box/credential-helper/cmd/blitz-cred/main.go b/packages/box/credential-helper/cmd/blitz-cred/main.go new file mode 100644 index 00000000..19e3f768 --- /dev/null +++ b/packages/box/credential-helper/cmd/blitz-cred/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + + "github.com/blitzdotdev/blitz-core/box/credential-helper/internal/workspace" +) + +const usageText = `usage: blitz-cred COMMAND + + api-token print a current control-plane bearer + help print this help` + +func main() { + if err := run(os.Args[1:], os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string, output io.Writer) error { + if len(args) == 0 { + return errors.New(usageText) + } + switch args[0] { + case "--help", "-h", "help": + if len(args) != 1 { + return errors.New("help takes no arguments") + } + _, err := fmt.Fprintln(output, usageText) + return err + case "api-token": + if len(args) != 1 { + return errors.New("api-token takes no arguments") + } + stateDir := os.Getenv("BLITZ_STATE_DIR") + if stateDir == "" { + return errors.New("BLITZ_STATE_DIR is required") + } + token, err := workspace.APIToken(context.Background(), stateDir, nil) + if err != nil { + return err + } + _, err = fmt.Fprintln(output, token) + return err + default: + return errors.New("unknown blitz-cred command") + } +} diff --git a/packages/box/credential-helper/cmd/blitz-cred/main_test.go b/packages/box/credential-helper/cmd/blitz-cred/main_test.go new file mode 100644 index 00000000..1315d9f4 --- /dev/null +++ b/packages/box/credential-helper/cmd/blitz-cred/main_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/blitzdotdev/blitz-core/box/credential-helper/internal/store" +) + +func TestHelpListsOnlyAPIToken(t *testing.T) { + var output strings.Builder + if err := run([]string{"help"}, &output); err != nil { + t.Fatal(err) + } + if output.String() != usageText+"\n" { + t.Fatalf("help output = %q", output.String()) + } + for _, removed := range []string{"register", "token", "watch", "enroll"} { + if strings.Contains(output.String(), "\n "+removed+" ") { + t.Errorf("help names removed verb %q", removed) + } + if err := run([]string{removed}, io.Discard); err == nil { + t.Errorf("removed verb %q succeeded", removed) + } + } +} + +func TestAPITokenStdoutContainsOnlyTokenAndOneNewline(t *testing.T) { + stateDir := t.TempDir() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/agent/api" { + http.NotFound(writer, request) + return + } + writer.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + writeState(t, stateDir, server.URL) + t.Setenv("BLITZ_STATE_DIR", stateDir) + + var output strings.Builder + if err := run([]string{"api-token"}, &output); err != nil { + t.Fatal(err) + } + if output.String() != "access\n" { + t.Fatalf("api-token output = %q", output.String()) + } +} + +func writeState(t *testing.T, stateDir, origin string) { + t.Helper() + credential := store.Credential{BoxID: "box", AccessToken: "access", RefreshToken: "refresh"} + if err := store.SaveCredential(stateDir, credential); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stateDir, store.OriginFile), []byte(origin+"\n"), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/packages/box/credential-helper/go.mod b/packages/box/credential-helper/go.mod new file mode 100644 index 00000000..6864c993 --- /dev/null +++ b/packages/box/credential-helper/go.mod @@ -0,0 +1,3 @@ +module github.com/blitzdotdev/blitz-core/box/credential-helper + +go 1.26.0 diff --git a/packages/broker/internal/atomicfile/atomic.go b/packages/box/credential-helper/internal/atomicfile/atomic.go similarity index 60% rename from packages/broker/internal/atomicfile/atomic.go rename to packages/box/credential-helper/internal/atomicfile/atomic.go index c1758a9c..ae87fe69 100644 --- a/packages/broker/internal/atomicfile/atomic.go +++ b/packages/box/credential-helper/internal/atomicfile/atomic.go @@ -7,12 +7,6 @@ import ( "syscall" ) -// Write replaces path atomically, leaving ownership to whatever the calling -// process would create. -func Write(path string, data []byte, mode os.FileMode) error { - return WriteOwned(path, data, mode, -1, -1) -} - // WritePreservingOwnership replaces path atomically while retaining the owner // of an existing target. A new target keeps the caller's default ownership. func WritePreservingOwnership(path string, data []byte, mode os.FileMode) error { @@ -29,17 +23,10 @@ func WritePreservingOwnership(path string, data []byte, mode os.FileMode) error } else if !os.IsNotExist(err) { return fmt.Errorf("stat existing file: %w", err) } - return WriteOwned(path, data, mode, uid, gid) + return writeOwned(path, data, mode, uid, gid) } -// WriteOwned is Write with the ownership set BEFORE the rename, so the file is -// never visible at its final path owned by the wrong user. Chowning after the -// rename leaves a window in which the real path holds real content under the -// creating process's ownership — small, but this is a credential directory and -// the reader on the other side is sshd. -// -// Pass -1, -1 to leave ownership alone, exactly as os.Chown defines it. -func WriteOwned(path string, data []byte, mode os.FileMode, uid, gid int) (err error) { +func writeOwned(path string, data []byte, mode os.FileMode, uid, gid int) (err error) { dir := filepath.Dir(path) temp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*") if err != nil { @@ -48,8 +35,6 @@ func WriteOwned(path string, data []byte, mode os.FileMode, uid, gid int) (err e tempName := temp.Name() defer temp.Close() defer os.Remove(tempName) - // Mode before content: the temporary file never holds bytes at a wider - // mode than the caller asked for, whatever the umask is. if err = temp.Chmod(mode); err != nil { return err } @@ -70,8 +55,6 @@ func WriteOwned(path string, data []byte, mode os.FileMode, uid, gid int) (err e if err = os.Rename(tempName, path); err != nil { return err } - // The rename is only durable once the DIRECTORY entry is on disk. Without - // this a crash can leave the old name pointing at nothing. directory, err := os.Open(dir) if err != nil { return err diff --git a/packages/broker/internal/atomicfile/atomic_test.go b/packages/box/credential-helper/internal/atomicfile/atomic_test.go similarity index 100% rename from packages/broker/internal/atomicfile/atomic_test.go rename to packages/box/credential-helper/internal/atomicfile/atomic_test.go diff --git a/packages/box/credential-helper/internal/controlplane/controlplane.go b/packages/box/credential-helper/internal/controlplane/controlplane.go new file mode 100644 index 00000000..fb03a16a --- /dev/null +++ b/packages/box/credential-helper/internal/controlplane/controlplane.go @@ -0,0 +1,216 @@ +package controlplane + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/blitzdotdev/blitz-core/box/credential-helper/internal/filelock" + "github.com/blitzdotdev/blitz-core/box/credential-helper/internal/store" +) + +const ( + agentAPIProbePath = "/agent/api" + responseMaxBytes = 1_048_576 + refreshLockWait = 30 * time.Second + RefreshLockFile = "box-credential.lock" +) + +type Client struct { + origin string + stateDir string + http *http.Client + refresh sync.Mutex +} + +func ValidateOrigin(raw string) (string, error) { + parsed, err := url.ParseRequestURI(raw) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") { + return "", errors.New("origin must be an absolute URL without a path") + } + if parsed.Scheme != "https" { + if parsed.Scheme != "http" || !isLocalhost(parsed.Hostname()) { + return "", errors.New("origin must use HTTPS (HTTP is allowed only for localhost)") + } + } + return strings.TrimSuffix(parsed.String(), "/"), nil +} + +func New(origin, stateDir string, httpClient *http.Client) (*Client, error) { + validated, err := ValidateOrigin(origin) + if err != nil { + return nil, err + } + if httpClient == nil { + httpClient = &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + } + return &Client{origin: validated, stateDir: stateDir, http: httpClient}, nil +} + +// ValidAccessToken probes the stored bearer. Only HTTP 401 starts a refresh. +// The box cannot inspect the expiry because the control plane owns it. +// A network error returns the stored token because the caller will expose that failure. +func (c *Client) ValidAccessToken(ctx context.Context) (string, error) { + credential, err := store.LoadCredential(c.stateDir) + if err != nil { + return "", err + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.origin+agentAPIProbePath, nil) + if err != nil { + return "", err + } + request.Header.Set("Authorization", "Bearer "+credential.AccessToken) + response, err := c.http.Do(request) + if err != nil { + return credential.AccessToken, nil + } + response.Body.Close() + if response.StatusCode != http.StatusUnauthorized { + return credential.AccessToken, nil + } + rotated, err := c.refreshCredential(ctx, credential.AccessToken) + if err != nil { + return "", err + } + return rotated.AccessToken, nil +} + +func (c *Client) refreshCredential(ctx context.Context, staleAccess string) (store.Credential, error) { + c.refresh.Lock() + defer c.refresh.Unlock() + if err := store.EnsureDir(c.stateDir); err != nil { + return store.Credential{}, err + } + var rotated store.Credential + err := filelock.With( + ctx, + filepath.Join(c.stateDir, RefreshLockFile), + refreshLockWait, + func() error { + credential, err := c.refreshLocked(ctx, staleAccess) + rotated = credential + return err + }, + ) + if err != nil { + return store.Credential{}, err + } + return rotated, nil +} + +// refreshLocked reads again after taking the separate lock inode. +// Another process can rotate the single-use token while this process waits. +func (c *Client) refreshLocked(ctx context.Context, staleAccess string) (store.Credential, error) { + credential, err := store.LoadCredential(c.stateDir) + if err != nil { + return store.Credential{}, err + } + if credential.AccessToken != staleAccess { + return credential, nil + } + form := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {credential.RefreshToken}, + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.origin+"/oauth/token", strings.NewReader(form.Encode())) + if err != nil { + return store.Credential{}, err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := c.http.Do(request) + if err != nil { + return store.Credential{}, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return store.Credential{}, statusError(response.StatusCode, "token refresh failed") + } + data, err := readLimited(response.Body, responseMaxBytes) + if err != nil { + return store.Credential{}, err + } + issued, err := decodeIssued(data) + if err != nil { + return store.Credential{}, err + } + if issued.BoxID != credential.BoxID { + return store.Credential{}, errors.New("token refresh changed box identity") + } + rotated := store.Credential{ + BoxID: issued.BoxID, AccessToken: issued.AccessToken, RefreshToken: issued.RefreshToken, + } + if err := store.SaveCredential(c.stateDir, rotated); err != nil { + return store.Credential{}, err + } + return rotated, nil +} + +type issuedTokens struct { + BoxID string `json:"box_id"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +func decodeIssued(data []byte) (issuedTokens, error) { + var issued issuedTokens + if err := decodeStrict(data, &issued); err != nil || issued.BoxID == "" || issued.AccessToken == "" || issued.RefreshToken == "" || !strings.EqualFold(issued.TokenType, "Bearer") || issued.ExpiresIn <= 0 { + return issuedTokens{}, errors.New("invalid token response") + } + return issued, nil +} + +func isLocalhost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + address := net.ParseIP(host) + return address != nil && address.IsLoopback() +} + +func statusError(status int, message string) error { + return fmt.Errorf("%s (HTTP %s)", message, strconv.Itoa(status)) +} + +func readLimited(reader io.Reader, limit int64) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(reader, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, errors.New("control plane response is too large") + } + return data, nil +} + +func decodeStrict(data []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("multiple JSON values") + } + return err + } + return nil +} diff --git a/packages/box/credential-helper/internal/controlplane/controlplane_test.go b/packages/box/credential-helper/internal/controlplane/controlplane_test.go new file mode 100644 index 00000000..c5cb7a3b --- /dev/null +++ b/packages/box/credential-helper/internal/controlplane/controlplane_test.go @@ -0,0 +1,375 @@ +package controlplane + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + "testing" + + "github.com/blitzdotdev/blitz-core/box/credential-helper/internal/store" +) + +func TestAccessTokenRequiresAuthenticatedProbeUnlessUnreachable(t *testing.T) { + t.Run("authenticated probe", func(t *testing.T) { + stateDir := stateWithCredential(t) + var authorization string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + authorization = request.Header.Get("Authorization") + writer.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + client := newTestClient(t, server.URL, stateDir, server.Client()) + token, err := client.ValidAccessToken(context.Background()) + if err != nil { + t.Fatal(err) + } + if token != "old-access" || authorization != "Bearer old-access" { + t.Fatalf("token = %q, Authorization = %q", token, authorization) + } + }) + + t.Run("unreachable control plane", func(t *testing.T) { + stateDir := stateWithCredential(t) + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + origin := server.URL + server.Close() + client := newTestClient(t, origin, stateDir, nil) + token, err := client.ValidAccessToken(context.Background()) + if err != nil { + t.Fatal(err) + } + if token != "old-access" { + t.Fatalf("token = %q", token) + } + }) +} + +func TestExactlyHTTP401TriggersRefresh(t *testing.T) { + for _, status := range []int{http.StatusOK, http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusInternalServerError} { + t.Run(fmt.Sprintf("HTTP %d", status), func(t *testing.T) { + stateDir := stateWithCredential(t) + var refreshes atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case agentAPIProbePath: + writer.WriteHeader(status) + case "/oauth/token": + refreshes.Add(1) + io.WriteString(writer, issuedJSON("new-access", "new-refresh")) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + client := newTestClient(t, server.URL, stateDir, server.Client()) + token, err := client.ValidAccessToken(context.Background()) + if err != nil { + t.Fatal(err) + } + wantToken := "old-access" + wantRefreshes := int32(0) + if status == http.StatusUnauthorized { + wantToken = "new-access" + wantRefreshes = 1 + } + if token != wantToken || refreshes.Load() != wantRefreshes { + t.Fatalf("token = %q, refreshes = %d", token, refreshes.Load()) + } + }) + } +} + +func TestRefreshIsSerializedAcrossProcessesWithSeparateLockFile(t *testing.T) { + stateDir := stateWithCredential(t) + var probes atomic.Int32 + var refreshes atomic.Int32 + bothProbed := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case agentAPIProbePath: + if request.Header.Get("Authorization") == "Bearer old-access" { + if probes.Add(1) == 2 { + close(bothProbed) + } + <-bothProbed + writer.WriteHeader(http.StatusUnauthorized) + return + } + writer.WriteHeader(http.StatusNoContent) + case "/oauth/token": + if refreshes.Add(1) != 1 { + writer.WriteHeader(http.StatusBadRequest) + return + } + io.WriteString(writer, issuedJSON("new-access", "new-refresh")) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + commands := make([]*exec.Cmd, 2) + results := make([]string, 2) + for index := range commands { + results[index] = filepath.Join(t.TempDir(), "result") + command := exec.Command(executable, "-test.run=^TestAPITokenProcessHelper$") + command.Env = append(os.Environ(), + "BLITZ_APITOKEN_PROCESS_HELPER=1", + "BLITZ_APITOKEN_STATE_DIR="+stateDir, + "BLITZ_APITOKEN_ORIGIN="+server.URL, + "BLITZ_APITOKEN_RESULT="+results[index], + ) + if err := command.Start(); err != nil { + t.Fatal(err) + } + commands[index] = command + } + for _, command := range commands { + if err := command.Wait(); err != nil { + t.Fatalf("helper failed: %v", err) + } + } + for _, result := range results { + data, err := os.ReadFile(result) + if err != nil { + t.Fatal(err) + } + if string(data) != "new-access" { + t.Fatalf("helper token = %q", data) + } + } + if refreshes.Load() != 1 { + t.Fatalf("refresh requests = %d", refreshes.Load()) + } + credentialInfo, err := os.Stat(store.CredentialPath(stateDir)) + if err != nil { + t.Fatal(err) + } + lockInfo, err := os.Stat(filepath.Join(stateDir, RefreshLockFile)) + if err != nil { + t.Fatal(err) + } + if os.SameFile(credentialInfo, lockInfo) { + t.Fatal("refresh lock uses the credential inode") + } +} + +func TestAPITokenProcessHelper(t *testing.T) { + if os.Getenv("BLITZ_APITOKEN_PROCESS_HELPER") != "1" { + return + } + client := newTestClient(t, os.Getenv("BLITZ_APITOKEN_ORIGIN"), os.Getenv("BLITZ_APITOKEN_STATE_DIR"), nil) + token, err := client.ValidAccessToken(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(os.Getenv("BLITZ_APITOKEN_RESULT"), []byte(token), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestCredentialIsRereadWhileHoldingRefreshLock(t *testing.T) { + stateDir := stateWithCredential(t) + lockPath := filepath.Join(stateDir, RefreshLockFile) + lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + t.Fatal(err) + } + defer lock.Close() + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil { + t.Fatal(err) + } + + probed := make(chan struct{}) + var refreshes atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case agentAPIProbePath: + close(probed) + writer.WriteHeader(http.StatusUnauthorized) + case "/oauth/token": + refreshes.Add(1) + writer.WriteHeader(http.StatusInternalServerError) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + client := newTestClient(t, server.URL, stateDir, server.Client()) + type result struct { + token string + err error + } + answer := make(chan result, 1) + go func() { + token, err := client.ValidAccessToken(context.Background()) + answer <- result{token: token, err: err} + }() + <-probed + newCredential := store.Credential{BoxID: "box", AccessToken: "new-access", RefreshToken: "new-refresh"} + if err := store.SaveCredential(stateDir, newCredential); err != nil { + t.Fatal(err) + } + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_UN); err != nil { + t.Fatal(err) + } + got := <-answer + if got.err != nil { + t.Fatal(got.err) + } + if got.token != "new-access" || refreshes.Load() != 0 { + t.Fatalf("token = %q, refreshes = %d", got.token, refreshes.Load()) + } +} + +func TestRotatedCredentialIsAtomicAndWrittenOnlyAfterRefreshAcceptance(t *testing.T) { + stateDir := stateWithCredential(t) + credentialPath := store.CredentialPath(stateDir) + beforeBytes, err := os.ReadFile(credentialPath) + if err != nil { + t.Fatal(err) + } + beforeInfo, err := os.Stat(credentialPath) + if err != nil { + t.Fatal(err) + } + var accept atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case agentAPIProbePath: + writer.WriteHeader(http.StatusUnauthorized) + case "/oauth/token": + if !accept.Load() { + writer.WriteHeader(http.StatusBadRequest) + return + } + io.WriteString(writer, issuedJSON("new-access", "new-refresh")) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + client := newTestClient(t, server.URL, stateDir, server.Client()) + if _, err := client.ValidAccessToken(context.Background()); err == nil { + t.Fatal("rejected refresh succeeded") + } + deniedBytes, err := os.ReadFile(credentialPath) + if err != nil { + t.Fatal(err) + } + deniedInfo, err := os.Stat(credentialPath) + if err != nil { + t.Fatal(err) + } + if string(deniedBytes) != string(beforeBytes) || !os.SameFile(beforeInfo, deniedInfo) { + t.Fatal("rejected refresh changed the credential") + } + + accept.Store(true) + token, err := client.ValidAccessToken(context.Background()) + if err != nil { + t.Fatal(err) + } + afterInfo, err := os.Stat(credentialPath) + if err != nil { + t.Fatal(err) + } + credential, err := store.LoadCredential(stateDir) + if err != nil { + t.Fatal(err) + } + if token != "new-access" || credential.AccessToken != "new-access" || credential.RefreshToken != "new-refresh" { + t.Fatalf("token = %q, credential = %+v", token, credential) + } + if os.SameFile(beforeInfo, afterInfo) { + t.Fatal("accepted refresh did not replace the credential atomically") + } +} + +func TestRefreshResponsesAreCappedAndStrictlyDecoded(t *testing.T) { + for name, body := range map[string]string{ + "oversized": strings.Repeat("x", responseMaxBytes+1), + "unknown field": `{"box_id":"box","access_token":"new-access","refresh_token":"new-refresh","token_type":"Bearer","expires_in":900,"extra":true}`, + } { + t.Run(name, func(t *testing.T) { + stateDir := stateWithCredential(t) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == agentAPIProbePath { + writer.WriteHeader(http.StatusUnauthorized) + return + } + io.WriteString(writer, body) + })) + defer server.Close() + client := newTestClient(t, server.URL, stateDir, server.Client()) + if _, err := client.ValidAccessToken(context.Background()); err == nil { + t.Fatal("invalid response was accepted") + } + credential, err := store.LoadCredential(stateDir) + if err != nil { + t.Fatal(err) + } + if credential.AccessToken != "old-access" || credential.RefreshToken != "old-refresh" { + t.Fatalf("credential changed to %+v", credential) + } + }) + } +} + +func TestOriginsRequireHTTPSExceptLocalhost(t *testing.T) { + accepted := []string{ + "https://cp.example", "http://localhost:8787", "http://127.0.0.1:8787", "http://[::1]:8787", + } + for _, origin := range accepted { + if _, err := ValidateOrigin(origin); err != nil { + t.Errorf("ValidateOrigin(%q): %v", origin, err) + } + } + rejected := []string{ + "http://cp.example", "https://cp.example/path", "https://user@cp.example", "https://cp.example?query=1", + } + for _, origin := range rejected { + if _, err := ValidateOrigin(origin); err == nil { + t.Errorf("ValidateOrigin(%q) succeeded", origin) + } + } +} + +func stateWithCredential(t *testing.T) string { + t.Helper() + stateDir := t.TempDir() + credential := store.Credential{BoxID: "box", AccessToken: "old-access", RefreshToken: "old-refresh"} + if err := store.SaveCredential(stateDir, credential); err != nil { + t.Fatal(err) + } + return stateDir +} + +func newTestClient(t *testing.T, origin, stateDir string, httpClient *http.Client) *Client { + t.Helper() + client, err := New(origin, stateDir, httpClient) + if err != nil { + t.Fatal(err) + } + return client +} + +func issuedJSON(accessToken, refreshToken string) string { + return fmt.Sprintf( + `{"box_id":"box","access_token":%q,"refresh_token":%q,"token_type":"Bearer","expires_in":900}`, + accessToken, + refreshToken, + ) +} diff --git a/packages/broker/internal/filelock/filelock.go b/packages/box/credential-helper/internal/filelock/filelock.go similarity index 93% rename from packages/broker/internal/filelock/filelock.go rename to packages/box/credential-helper/internal/filelock/filelock.go index ee30dbed..efbda1ff 100644 --- a/packages/broker/internal/filelock/filelock.go +++ b/packages/box/credential-helper/internal/filelock/filelock.go @@ -1,9 +1,8 @@ // Package filelock serialises a critical section across processes. // // It exists because an in-process mutex is the wrong tool for a box: every -// user of the control-plane credential is a separate short-lived process — -// `blitz-cred api-token`, the boot-time register, the feed watcher — and a -// sync.Mutex in one of them says nothing to the others. +// user of the control-plane credential is a separate short-lived process, and +// a sync.Mutex in one of them says nothing to the others. package filelock import ( @@ -101,7 +100,7 @@ func chownToDirectoryOwner(lock *os.File, path string) error { return fmt.Errorf("stat lock directory: %w", err) } // SAFETY: os.Stat returns the platform's syscall.Stat_t on supported Unix - // systems. Refuse to retain the created lock if that invariant fails. + // systems. Refuse the chown if that invariant does not hold. stat, ok := directory.Sys().(*syscall.Stat_t) if !ok { return fmt.Errorf("read lock directory ownership: unexpected stat type %T", directory.Sys()) diff --git a/packages/broker/internal/filelock/filelock_test.go b/packages/box/credential-helper/internal/filelock/filelock_test.go similarity index 100% rename from packages/broker/internal/filelock/filelock_test.go rename to packages/box/credential-helper/internal/filelock/filelock_test.go diff --git a/packages/broker/internal/store/store.go b/packages/box/credential-helper/internal/store/store.go similarity index 71% rename from packages/broker/internal/store/store.go rename to packages/box/credential-helper/internal/store/store.go index 3aecc5a1..c6bfa9c1 100644 --- a/packages/broker/internal/store/store.go +++ b/packages/box/credential-helper/internal/store/store.go @@ -10,7 +10,7 @@ import ( "path/filepath" "strings" - "github.com/blitzdotdev/blitz-core/broker/internal/atomicfile" + "github.com/blitzdotdev/blitz-core/box/credential-helper/internal/atomicfile" ) const ( @@ -32,17 +32,13 @@ func CredentialPath(dir string) string { return filepath.Join(dir, CredentialFile) } -func OriginPath(dir string) string { - return filepath.Join(dir, OriginFile) -} - func LoadCredential(dir string) (Credential, error) { data, err := os.ReadFile(CredentialPath(dir)) if err != nil { return Credential{}, err } - var credential Credential - if err := decodeCredential(data, &credential); err != nil { + credential, err := decodeCredential(data) + if err != nil { return Credential{}, fmt.Errorf("invalid box credential: %w", err) } if credential.BoxID == "" || credential.AccessToken == "" || credential.RefreshToken == "" { @@ -67,7 +63,7 @@ func SaveCredential(dir string, credential Credential) error { } func LoadOrigin(dir string) (string, error) { - data, err := os.ReadFile(OriginPath(dir)) + data, err := os.ReadFile(filepath.Join(dir, OriginFile)) if err != nil { return "", err } @@ -78,26 +74,17 @@ func LoadOrigin(dir string) (string, error) { return origin, nil } -func SaveOrigin(dir, origin string) error { - if origin == "" || strings.ContainsAny(origin, "\r\n") { - return errors.New("invalid origin") - } - if err := EnsureDir(dir); err != nil { - return err - } - return atomicfile.WritePreservingOwnership(OriginPath(dir), []byte(origin+"\n"), 0o644) -} - -func decodeCredential(data []byte, target any) error { +func decodeCredential(data []byte) (Credential, error) { + var credential Credential decoder := json.NewDecoder(bytes.NewReader(data)) - if err := decoder.Decode(target); err != nil { - return err + if err := decoder.Decode(&credential); err != nil { + return Credential{}, err } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { if err == nil { - return errors.New("multiple JSON values") + return Credential{}, errors.New("multiple JSON values") } - return err + return Credential{}, err } - return nil + return credential, nil } diff --git a/packages/box/credential-helper/internal/workspace/apitoken.go b/packages/box/credential-helper/internal/workspace/apitoken.go new file mode 100644 index 00000000..993a6800 --- /dev/null +++ b/packages/box/credential-helper/internal/workspace/apitoken.go @@ -0,0 +1,22 @@ +package workspace + +import ( + "context" + "net/http" + + "github.com/blitzdotdev/blitz-core/box/credential-helper/internal/controlplane" + "github.com/blitzdotdev/blitz-core/box/credential-helper/internal/store" +) + +// APIToken returns a machine bearer. The caller uses it with the control plane's agent API. +func APIToken(ctx context.Context, stateDir string, httpClient *http.Client) (string, error) { + origin, err := store.LoadOrigin(stateDir) + if err != nil { + return "", err + } + client, err := controlplane.New(origin, stateDir, httpClient) + if err != nil { + return "", err + } + return client.ValidAccessToken(ctx) +} diff --git a/packages/box/guest-tests/test/agent-shims.test.ts b/packages/box/guest-tests/test/agent-shims.test.ts index 41f83386..0b87fba0 100644 --- a/packages/box/guest-tests/test/agent-shims.test.ts +++ b/packages/box/guest-tests/test/agent-shims.test.ts @@ -29,6 +29,20 @@ const shimPath = (name: string) => const shim = (name: string) => readFileSync(shimPath(name), "utf8"); +/** + * The shim with its comments removed. + * + * The absence assertions below are about what the shim DOES. A comment that + * names the thing the shim no longer touches is the file explaining itself, and + * reading the whole file would fail on it — which is exactly what happened when + * the broker mint left and the shim said so. + */ +const shimCode = (name: string) => + shim(name) + .split("\n") + .filter((row) => !row.trimStart().startsWith("#")) + .join("\n"); + describe("vendor CLI PATH shims", () => { it.each(["claude", "codex"])("%s execs the pinned binary, not the name again", (name) => { // /usr/local/bin comes first on PATH, so a bare `exec claude` would @@ -47,7 +61,13 @@ describe("vendor CLI PATH shims", () => { // holding the CLI version held the model list with it. Assert the absence, // so re-adding it anywhere in this shim is a test failure and not a quiet // regression back to a stale model picker. - expect(shim("claude")).not.toContain("DISABLE_AUTOUPDATER"); + expect(shimCode("claude")).not.toContain("DISABLE_AUTOUPDATER"); + }); + + it("leaves supplied Claude authentication inputs unchanged", () => { + // The shim does not inspect, replace, or remove native authentication. + expect(shimCode("claude")).not.toContain("CLAUDE_CODE_OAUTH_TOKEN"); + expect(shimCode("claude")).not.toContain("blitz-cred"); }); it("leaves codex's startup update check on", () => { diff --git a/packages/box/guest-tests/test/blitz-term-credentials.test.ts b/packages/box/guest-tests/test/blitz-term-credentials.test.ts index 94e7603a..6154f597 100644 --- a/packages/box/guest-tests/test/blitz-term-credentials.test.ts +++ b/packages/box/guest-tests/test/blitz-term-credentials.test.ts @@ -148,9 +148,8 @@ describe("blitz-term carries no credential", () => { }); it("ignores a creds/env.d left behind by an older box image", async () => { - // The broker no longer writes this directory, but a box that boots on an - // upgraded image still has yesterday's file on its state volume. Sourcing - // it would re-export a value the workspace may already have revoked. + // An upgraded box can retain this directory from an older image. + // Sourcing it would restore a value the workspace already revoked. const box = makeTermBox(); const envDir = join(box.stateDir, "creds", "env.d"); mkdirSync(envDir, { recursive: true }); diff --git a/packages/box/guest-tests/test/box-credential-service.test.ts b/packages/box/guest-tests/test/box-credential-service.test.ts index 2214ec8d..07a709c6 100644 --- a/packages/box/guest-tests/test/box-credential-service.test.ts +++ b/packages/box/guest-tests/test/box-credential-service.test.ts @@ -51,6 +51,10 @@ describe("box-credential service", () => { path.join(bin, "chown"), "#!/bin/sh\nprintf '%s\\n' \"$*\" >>\"$BLITZ_TEST_CHOWN_LOG\"\n", ); + writeExecutable( + path.join(bin, "stat"), + "#!/bin/sh\nprintf '%s:%s\\n' \"$BLITZ_TEST_CURRENT_UID\" \"$BLITZ_TEST_CURRENT_GID\"\n", + ); writeExecutable( path.join(bin, "s6-setuidgid"), "#!/bin/sh\nprintf '%s\\n' \"$*\" >>\"$BLITZ_TEST_REFRESH_LOG\"\n", @@ -68,6 +72,8 @@ describe("box-credential service", () => { BLITZ_GID: String(expectedGid), BLITZ_CREDENTIAL_REFRESH_ONCE: "1", BLITZ_TEST_CHOWN_LOG: chownLog, + BLITZ_TEST_CURRENT_UID: String(currentUid), + BLITZ_TEST_CURRENT_GID: String(currentGid), BLITZ_TEST_REFRESH_LOG: refreshLog, }, }); diff --git a/packages/box/guest-tests/test/codex-session.test.ts b/packages/box/guest-tests/test/codex-session.test.ts index 2b835cbd..ab076358 100644 --- a/packages/box/guest-tests/test/codex-session.test.ts +++ b/packages/box/guest-tests/test/codex-session.test.ts @@ -18,11 +18,6 @@ const launcherPath = fileURLToPath( new URL("../../rootfs/usr/local/libexec/blitz-codex-session", import.meta.url), ); -/** The path the broker writes into config.toml as codex's auth hook. The - * launcher greps for exactly this, so the test has to name the same string the - * broker does (packages/broker/internal/workspace/harness.go). */ -const brokerAuthCommand = "/usr/local/bin/blitz-cred-codex"; - const directories: string[] = []; afterEach(() => { @@ -46,8 +41,6 @@ interface LaunchOptions { /** Device auth dies FROM SIGINT, which is what Ctrl-C actually does. */ deviceSignal?: boolean; args?: string[]; - /** Write a broker-style config.toml carrying the auth hook. */ - brokerWired?: boolean; /** Set an API key in the environment. */ apiKey?: string; } @@ -58,7 +51,6 @@ async function runLauncher(options: LaunchOptions = {}): Promise { deviceExit = 0, deviceSignal = false, args = [], - brokerWired = false, apiKey, } = options; @@ -71,17 +63,6 @@ async function runLauncher(options: LaunchOptions = {}): Promise { const callsPath = join(directory, "calls"); writeFileSync(callsPath, ""); - if (brokerWired) { - writeFileSync(join(home, ".codex", "config.toml"), [ - 'model_provider = "blitz"', - "", - "[model_providers.blitz.auth]", - `command = "${brokerAuthCommand}"`, - "refresh_interval_ms = 300000", - "", - ].join("\n")); - } - // `kill -INT 0` signals the whole process group, the way a pty delivers // Ctrl-C. The launcher is spawned detached below so that group contains only // the launcher and this stub — never the vitest runner. @@ -152,19 +133,6 @@ describe("blitz-codex-session", () => { ]); }); - it("starts Codex directly on a broker-wired box, without consulting login status", async () => { - // The broker authenticates codex through an auth hook in config.toml and - // deliberately writes no auth.json, so `codex login status` reports "Not - // logged in" on a workspace that works. Gating on status alone would - // hijack every hosted codex tab into a device prompt. - const result = await runLauncher({ brokerWired: true, statusExit: 1 }); - expect(result.status).toBe(0); - expect(result.calls).toEqual([ - ["codex", "--dangerously-bypass-approvals-and-sandbox"], - ]); - expect(result.stderr).not.toContain("Starting device authentication"); - }); - it("starts Codex directly when an API key is present", async () => { // codex reads the key itself, but its own status check ignores the // environment, so it reports signed out here too. diff --git a/packages/box/guest-tests/test/credential-refresh.test.ts b/packages/box/guest-tests/test/credential-refresh.test.ts index bb52c45b..2f47bfac 100644 --- a/packages/box/guest-tests/test/credential-refresh.test.ts +++ b/packages/box/guest-tests/test/credential-refresh.test.ts @@ -15,7 +15,7 @@ import { afterEach, describe, expect, it } from "vitest"; /** Drives the real `blitz-credential-refresh` one-shot with only blitz-cred * replaced. The stand-in records its argv, proving the script delegates token - * validity and rotation to the broker instead of growing a second refresh + * validity and rotation to blitz-cred instead of growing a second refresh * implementation. */ const scriptPath = fileURLToPath( diff --git a/packages/box/guest-tests/test/remote-control-service.test.ts b/packages/box/guest-tests/test/remote-control-service.test.ts index 1cf8e2de..8e21379a 100644 --- a/packages/box/guest-tests/test/remote-control-service.test.ts +++ b/packages/box/guest-tests/test/remote-control-service.test.ts @@ -29,7 +29,7 @@ const runCode = runScript describe("remote-control s6 service", () => { it("is a longrun registered in the user bundle", () => { expect(read("type").trim()).toBe("longrun"); - expect(read("dependencies.d/register")).toBeDefined(); + expect(read("dependencies.d/init-state")).toBeDefined(); const bundleEntry = fileURLToPath( new URL("../user/contents.d/remote-control", serviceDirectory), ); @@ -56,7 +56,7 @@ describe("remote-control s6 service", () => { it("bypasses the PATH shim and strips injected tokens", () => { // Remote Control rejects CLAUDE_CODE_OAUTH_TOKEN outright: "Long-lived - // tokens are limited to inference-only". /usr/local/bin/claude injects it. + // tokens are limited to inference-only". Remove any supplied value. expect(runCode).toMatch(/\/opt\/blitz\/npm\/bin\/claude rc/u); expect(runCode).not.toMatch(/\/usr\/local\/bin\/claude/u); for (const variable of [ diff --git a/packages/box/rootfs/etc/profile.d/blitz-npm.sh b/packages/box/rootfs/etc/profile.d/blitz-npm.sh index a605eabb..8d45ca3d 100644 --- a/packages/box/rootfs/etc/profile.d/blitz-npm.sh +++ b/packages/box/rootfs/etc/profile.d/blitz-npm.sh @@ -7,11 +7,12 @@ case ":$PATH:" in esac # ...and then put /usr/local/bin back in FRONT of it. The order is -# load-bearing, not cosmetic: /usr/local/bin/claude is the shim that mints a -# token and execs the pinned binary, and /opt/blitz/npm/bin/claude is that -# pinned binary. Leaving the npm prefix first means every terminal `claude` -# skips the shim and runs signed out, which is exactly what a stray -# `PATH=/opt/blitz/npm/bin:$PATH` did before this block existed. +# load-bearing, not cosmetic: /usr/local/bin/codex is the shim that turns the +# startup update check on before it execs the pinned binary, and +# /opt/blitz/npm/bin/codex is that pinned binary. Leaving the npm prefix first +# means every terminal `codex` skips the shim and stops updating itself, which +# is exactly what a stray `PATH=/opt/blitz/npm/bin:$PATH` did before this block +# existed. case ":$PATH:" in *:/usr/local/bin:*) PATH="/usr/local/bin:$(printf '%s' "$PATH" | sed -e 's#^/usr/local/bin:##' -e 's#:/usr/local/bin:#:#g' -e 's#:/usr/local/bin$##')" ;; *) PATH="/usr/local/bin:$PATH" ;; diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/register/dependencies.d/init-state b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/box-credential/dependencies.d/init-state similarity index 100% rename from packages/box/rootfs/etc/s6-overlay/s6-rc.d/register/dependencies.d/init-state rename to packages/box/rootfs/etc/s6-overlay/s6-rc.d/box-credential/dependencies.d/init-state diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/box-credential/dependencies.d/register b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/dockerd/dependencies.d/init-state similarity index 100% rename from packages/box/rootfs/etc/s6-overlay/s6-rc.d/box-credential/dependencies.d/register rename to packages/box/rootfs/etc/s6-overlay/s6-rc.d/dockerd/dependencies.d/init-state diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/dockerd/dependencies.d/register b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/dufs/dependencies.d/init-state similarity index 100% rename from packages/box/rootfs/etc/s6-overlay/s6-rc.d/dockerd/dependencies.d/register rename to packages/box/rootfs/etc/s6-overlay/s6-rc.d/dufs/dependencies.d/init-state diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/dufs/dependencies.d/register b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/lody-daemon/dependencies.d/init-state similarity index 100% rename from packages/box/rootfs/etc/s6-overlay/s6-rc.d/dufs/dependencies.d/register rename to packages/box/rootfs/etc/s6-overlay/s6-rc.d/lody-daemon/dependencies.d/init-state diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/lody-daemon/dependencies.d/register b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/lody-daemon/dependencies.d/register deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/register/type b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/register/type deleted file mode 100644 index bdd22a18..00000000 --- a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/register/type +++ /dev/null @@ -1 +0,0 @@ -oneshot diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/register/up b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/register/up deleted file mode 100644 index 43115c6d..00000000 --- a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/register/up +++ /dev/null @@ -1,2 +0,0 @@ -/command/with-contenv -/usr/local/libexec/blitz-register diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/remote-control/dependencies.d/register b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/remote-control/dependencies.d/init-state similarity index 100% rename from packages/box/rootfs/etc/s6-overlay/s6-rc.d/remote-control/dependencies.d/register rename to packages/box/rootfs/etc/s6-overlay/s6-rc.d/remote-control/dependencies.d/init-state diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/remote-control/run b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/remote-control/run index 2b83fab6..00442069 100755 --- a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/remote-control/run +++ b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/remote-control/run @@ -15,10 +15,8 @@ # writes, so the cheap file test is the login detector. There is deliberately # no deadline: the member may log in an hour after the box boots. # -# WHY NOT THE PATH SHIM: /usr/local/bin/claude injects CLAUDE_CODE_OAUTH_TOKEN -# from the broker. Remote Control rejects it outright ("Long-lived tokens are -# limited to inference-only"), so call the pinned binary and unset the three -# token variables a template environment could also set. +# WHY THE NATIVE BINARY: Remote Control rejects long-lived token inputs. +# Call the native CLI and remove the three token variables. # # WHY A PTY: Remote Control opens an interactive session. `script` supplies the # pty without involving tmux, so this can never own the box's tmux server. diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/rules/dependencies.d/register b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/rules/dependencies.d/init-state similarity index 100% rename from packages/box/rootfs/etc/s6-overlay/s6-rc.d/rules/dependencies.d/register rename to packages/box/rootfs/etc/s6-overlay/s6-rc.d/rules/dependencies.d/init-state diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/register b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/init-state similarity index 100% rename from packages/box/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/register rename to packages/box/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/init-state diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/ttyd/dependencies.d/register b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/ttyd/dependencies.d/init-state similarity index 100% rename from packages/box/rootfs/etc/s6-overlay/s6-rc.d/ttyd/dependencies.d/register rename to packages/box/rootfs/etc/s6-overlay/s6-rc.d/ttyd/dependencies.d/init-state diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/register b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/register deleted file mode 100644 index 8b137891..00000000 --- a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/register +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/watch b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/watch deleted file mode 100644 index 8b137891..00000000 --- a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/watch +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/watch/dependencies.d/register b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/watch/dependencies.d/register deleted file mode 100644 index 8b137891..00000000 --- a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/watch/dependencies.d/register +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/watch/run b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/watch/run deleted file mode 100755 index 782760ce..00000000 --- a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/watch/run +++ /dev/null @@ -1,25 +0,0 @@ -#!/command/with-contenv bash -# Hand a new harness login to the broker, then delete the workspace's copy. -# -# It WAITS for broker wiring instead of giving up on it. The previous version -# execed s6-pause when broker.json was absent, which is evaluated exactly once, -# at boot: a broker enrolled ten minutes later, or a `blitz-cred register` that -# succeeded on a retry, would never start this watcher, and every login on the -# box would stay stranded on local disk until the next reboot. -# -# A workspace with no broker simply sits here, which costs one sleeping process. -set -uo pipefail - -state_dir=${BLITZ_STATE_DIR:-/var/lib/blitz} -announced=0 -while [ ! -s "$state_dir/broker.json" ]; do - if [ "$announced" -eq 0 ]; then - echo "watch: waiting for broker config" - announced=1 - fi - sleep 5 -done - -exec /usr/local/bin/blitz-cgroup enter system -- /command/s6-setuidgid blitz /usr/bin/env \ - HOME="$state_dir/home" USER=blitz \ - /usr/local/bin/blitz-cred watch diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/watch/type b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/watch/type deleted file mode 100644 index 5883cff0..00000000 --- a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/watch/type +++ /dev/null @@ -1 +0,0 @@ -longrun diff --git a/packages/box/rootfs/usr/local/bin/blitz-cred-claude b/packages/box/rootfs/usr/local/bin/blitz-cred-claude deleted file mode 100755 index 4f88a464..00000000 --- a/packages/box/rootfs/usr/local/bin/blitz-cred-claude +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -# Print one short-lived Claude token on stdout, and nothing else. -# -# It exists because the consumer cannot pass arguments: codex's `[auth] command` -# takes a bare path, and the PATH shim wants one thing to run. The harness has -# to be in the NAME. -# -# Failure is silent and non-zero: every caller treats "no token" as "run signed -# out", which is a workspace a member can fix. -exec /usr/local/bin/blitz-cred token claude diff --git a/packages/box/rootfs/usr/local/bin/blitz-cred-codex b/packages/box/rootfs/usr/local/bin/blitz-cred-codex deleted file mode 100755 index c3dcb32a..00000000 --- a/packages/box/rootfs/usr/local/bin/blitz-cred-codex +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh -# Print one short-lived Codex token on stdout, and nothing else. -# -# This is the command codex itself runs, every refresh_interval_ms, from the -# block `blitz-cred register` writes into ~/.codex/config.toml. Codex passes NO -# arguments to it, so the harness has to be in the name. -exec /usr/local/bin/blitz-cred token codex diff --git a/packages/box/rootfs/usr/local/bin/claude b/packages/box/rootfs/usr/local/bin/claude index d6480dd0..fa402bdd 100755 --- a/packages/box/rootfs/usr/local/bin/claude +++ b/packages/box/rootfs/usr/local/bin/claude @@ -1,38 +1,15 @@ #!/bin/sh -# PATH shim for Claude Code. Every terminal `claude` comes through here, picks -# up a freshly minted token, and execs the pinned binary. +# PATH shim for Claude Code. Today it only execs, and that is the whole file. # -# WHY AN ENVIRONMENT VARIABLE, and specifically this one: the broker mints an -# OAuth access token (`sk-ant-oat01-…`). `CLAUDE_CODE_OAUTH_TOKEN` is the OAuth -# hook and accepts it. `ANTHROPIC_API_KEY` is the API-KEY hook, rejects an OAuth -# token outright, and switches a subscription to per-token billing on the way. -# A managed `apiKeyHelper` does not merely lose to a valid -# CLAUDE_CODE_OAUTH_TOKEN either — with both set, claude hangs — which is why -# /etc/claude-code/managed-settings.json is DELETED at register rather than -# overridden. +# IT NO LONGER MINTS. The shim used to fetch an OAuth token from the credential +# broker and hand it to the CLI. The broker is deleted. Claude reads its own +# store under HOME, and an explicitly supplied CLAUDE_CODE_OAUTH_TOKEN reaches +# it untouched. # -# WHY A SHIM AND NOT A LOGIN-SHELL EXPORT: a token exported once at login is the -# token you still have three hours later. Minting per process start is what -# keeps a long-lived terminal signed in. +# IT STILL EXISTS BECAUSE CALLERS NAME IT. The Lody adapter points at this exact +# path (`packages/webapp/src/lody/agent-configs.ts`), and the auto-updater +# rewrites the npm prefix in place, so a stable name is what those callers pin. # -# NOTHING HERE IS FATAL. An unreachable broker leaves claude running and saying -# it is signed out — a workspace a member can fix — rather than a dead command. -# -# An already-set token is left alone, so a caller can override. -# -# THE AUTO-UPDATER IS LEFT ON, deliberately. claude's version is what decides -# which models the Lody composer can offer (docs/LODY-MODELS.md), so holding it -# held the model list too. An update rewrites /opt/blitz/npm in place, and -# /usr/local/bin sits ahead of that prefix on PATH, so the updated binary is -# what this shim execs — not something that shadows it. - -if [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ -x /usr/local/bin/blitz-cred-claude ]; then - token="$(/usr/local/bin/blitz-cred-claude 2>/dev/null || true)" - if [ -n "$token" ]; then - CLAUDE_CODE_OAUTH_TOKEN="$token" - export CLAUDE_CODE_OAUTH_TOKEN - fi - unset token -fi +# The auto-updater stays on. New CLI versions bring new models with no rebake. exec /opt/blitz/npm/bin/claude "$@" diff --git a/packages/box/rootfs/usr/local/libexec/blitz-codex-session b/packages/box/rootfs/usr/local/libexec/blitz-codex-session index f91c5067..672fce7c 100755 --- a/packages/box/rootfs/usr/local/libexec/blitz-codex-session +++ b/packages/box/rootfs/usr/local/libexec/blitz-codex-session @@ -23,38 +23,20 @@ set -euo pipefail # lists --device-auth, --with-api-key and --with-access-token, and nothing # else), so a wrapper is the only place the choice can be made. # -# WHO IT IS FOR: a box with no credential broker and no API key — the -# self-hosted `docker run` install. On a hosted workspace all three probes -# below fall through and this region does nothing at all. +# WHO IT IS FOR: a signed-out box with no API key. +# Authenticated boxes skip this offer. # codex_authenticated reports whether codex can already reach a model. # -# It must probe all three ways this box authenticates codex, because -# `codex login status` alone answers only for the third and reports -# "Not logged in" for the other two. Verified against codex-cli 0.147.0: -# a config.toml carrying the broker block and no auth.json prints -# "Not logged in" and exits 1, and so does OPENAI_API_KEY on its own. -# Gating on status alone therefore hijacks every working hosted tab into a -# device prompt, which is the failure this function exists to prevent. +# It probes both native authentication paths. +# `codex login status` ignores API keys, so the environment check runs first. codex_authenticated() { - local codex_home=${CODEX_HOME:-${HOME:-}/.codex} - - # 1. The credential broker. `blitz-cred register` writes an auth hook into - # config.toml and deliberately writes NO auth.json — codex pulls its own - # token every refresh_interval_ms. The hook's path is the signal. - if [ -r "$codex_home/config.toml" ] && - grep -Fq '/usr/local/bin/blitz-cred-codex' "$codex_home/config.toml"; then - return 0 - fi - - # 2. A plain API key the member exported themselves. codex reads these - # itself; its own status check ignores them - # (enable_codex_api_key_env is false there). + # A supplied API key is native input. Codex's status command ignores it. if [ -n "${CODEX_API_KEY:-}" ] || [ -n "${OPENAI_API_KEY:-}" ]; then return 0 fi - # 3. A ChatGPT sign-in codex stored itself, which is what status reads. + # The status command reads Codex's native ChatGPT login. codex login status >/dev/null 2>&1 } diff --git a/packages/box/rootfs/usr/local/libexec/blitz-payload b/packages/box/rootfs/usr/local/libexec/blitz-payload index 18d0d444..6f2cd35c 100755 --- a/packages/box/rootfs/usr/local/libexec/blitz-payload +++ b/packages/box/rootfs/usr/local/libexec/blitz-payload @@ -1331,7 +1331,6 @@ async function verifyServiceFloor(currentRoot, nextRoot, manifest) { await Promise.all([ requireServiceType(nextRoot, 'cgroups', null), requireServiceType(nextRoot, 'init-state', null), - requireServiceType(nextRoot, 'register', null), requireServiceType(nextRoot, 'payload', 'longrun'), requireServiceType(nextRoot, 'user', 'bundle'), requireServiceType(nextRoot, 'user2', 'bundle'), @@ -1352,12 +1351,11 @@ async function verifyServiceFloor(currentRoot, nextRoot, manifest) { throw new VerifyFailure(`manifest.restart service is not a tree longrun: ${service}`); } } - // s6-rc-update would re-run a changed oneshot and take every dependent down - // with it. register has nine dependents; init-state and cgroups have the - // whole graph. A changed payload definition would restart the updater in - // the middle of its own switch. Their bodies live in payload libexec - // scripts, which can still change freely. - for (const service of ['cgroups', 'init-state', 'register', 'payload']) { + // s6-rc-update would rerun a changed oneshot and stop all dependents. + // init-state and cgroups have the whole graph. + // A changed payload definition would restart its updater during the switch. + // Their bodies use payload libexec scripts, which can still change. + for (const service of ['cgroups', 'init-state', 'payload']) { await compareFrozenService(currentRoot, nextRoot, service); } } diff --git a/packages/box/rootfs/usr/local/libexec/blitz-register b/packages/box/rootfs/usr/local/libexec/blitz-register deleted file mode 100755 index 929674af..00000000 --- a/packages/box/rootfs/usr/local/libexec/blitz-register +++ /dev/null @@ -1,49 +0,0 @@ -#!/command/with-contenv bash -# Enrol this workspace with the credential broker. Runs as root, once, at boot. -# -# NOTHING HERE IS FATAL, and that is the whole point. Every long-run service on -# this box is ordered behind this oneshot, so an s6 failure here would leave a -# workspace with no terminal and no SSH because a credential broker was -# unreachable. The broker is OPTIONAL — zero enrolled brokers is how the feature -# is turned off — and the correct outcome of every failure is a workspace that -# boots and says it is signed out. That is something a member can fix from -# inside; a workspace that never booted is not. -set -uo pipefail - -state_dir=$BLITZ_STATE_DIR - -# Claude Code gets NO managed settings file, and this is the root half of that. -# -# The broker mints an OAuth access token (`sk-ant-oat01-…`). `apiKeyHelper` is -# the API-KEY hook: whatever it returns is used as an API key, so it rejects an -# OAuth token AND switches a subscription to per-token billing. Worse, a managed -# apiKeyHelper does not simply lose to a valid CLAUDE_CODE_OAUTH_TOKEN — with -# both set, claude hangs. So the file cannot be overridden; it must not exist. -# -# blitz-core never writes it. /etc/claude-code is in the IMAGE root filesystem, -# not on the state volume, so anything found here was baked by an older image -# layer this box is still built on — the file arrives with the image, not with a -# volume that survived an upgrade. -# -# It runs ABOVE the origin check, and it must stay there. No control-plane -# origin is how the broker feature is turned OFF, and a box with the feature off -# is precisely the box a stale managed apiKeyHelper hangs: nothing else on the -# box removes the file, so every claude session on it wedges for the whole life -# of the box. Below the guard this removal never runs on the boxes that need it. -rm -f /etc/claude-code/managed-settings.json -rmdir /etc/claude-code 2>/dev/null || true - -if [ ! -s "$state_dir/origin" ]; then - echo "register: skipped (no control-plane origin)" - exit 0 -fi - -# `timeout` is the backstop, not the budget: blitz-cred register carries its own -# deadline and its own retries. This only catches a process wedged below the -# level any Go timer can see. -if ! timeout 60 /command/s6-setuidgid blitz /usr/bin/env \ - HOME="$state_dir/home" BLITZ_STATE_DIR="$state_dir" \ - /usr/local/bin/blitz-cred register; then - echo "register: broker enrolment did not complete; the workspace runs signed out" >&2 -fi -exit 0 diff --git a/packages/box/rootfs/usr/local/libexec/blitz-rules-boot b/packages/box/rootfs/usr/local/libexec/blitz-rules-boot index 42c51dca..dadefd95 100755 --- a/packages/box/rootfs/usr/local/libexec/blitz-rules-boot +++ b/packages/box/rootfs/usr/local/libexec/blitz-rules-boot @@ -3,17 +3,17 @@ set -euo pipefail state_dir=$BLITZ_STATE_DIR -# Needs both the control-plane origin and the box credential that enroll/register -# wrote. Without them there is nothing to fetch with; the baked rules that -# blitz-init-state installed stay in place. +# Provisioning writes the control-plane origin and the box credential. +# Without them, the box cannot fetch rules. +# The baked rules from blitz-init-state stay in place. if [ ! -s "$state_dir/origin" ] || [ ! -s "$state_dir/box-credential.json" ]; then echo "rules: skipped (no origin or box credential)" exit 0 fi -# Runs after register, so the box access token is freshly issued. blitz-rules -# keeps the baked fallback and exits 0 on any failure, but guard here as well so -# a rules refresh can never fail the boot transition. +# Runs after init-state, which installs the baked rules and prepares state. +# blitz-rules keeps the fallback and exits zero on failure. +# This guard also prevents a refresh failure from stopping boot. /command/s6-setuidgid blitz /usr/bin/env \ HOME="$state_dir/home" BLITZ_STATE_DIR="$state_dir" \ /usr/local/bin/blitz-rules sync \ diff --git a/packages/box/test/smoke.sh b/packages/box/test/smoke.sh index 0a8e5b79..75b6ca8f 100755 --- a/packages/box/test/smoke.sh +++ b/packages/box/test/smoke.sh @@ -170,9 +170,14 @@ done [ "$ready" = true ] || fail "enabled Lody services and loopback endpoints did not become ready within 180 seconds" services=$(docker exec "$container" /command/s6-rc -a list) -for service in init-state register payload sshd ttyd dufs gateway watch dockerd lody-daemon lody-bridge lody-watchdog lody-projects; do +for service in init-state payload sshd ttyd dufs gateway dockerd lody-daemon lody-bridge lody-watchdog lody-projects; do grep -qx "$service" <<<"$services" || fail "s6 graph is missing $service" done +for removed_service in register watch; do + if grep -qx "$removed_service" <<<"$services"; then + fail "s6 graph still contains $removed_service" + fi +done if grep -qx machine-stats <<<"$services"; then fail "machine-stats remains in the live service set" fi @@ -200,7 +205,7 @@ user2_entry=$(docker exec "$container" sh -c \ 'find /opt/blitz/payload/current/rootfs/etc/s6-overlay/s6-rc.d/user2/contents.d -mindepth 1 -maxdepth 1 -print -quit') [ -z "$user2_entry" ] || fail "the user2 bundle contents.d directory is not empty" echo "PASS the user2 bundle has an empty contents.d directory" -for service in payload sshd ttyd dufs gateway watch dockerd lody-daemon lody-bridge lody-watchdog lody-projects; do +for service in payload sshd ttyd dufs gateway dockerd lody-daemon lody-bridge lody-watchdog lody-projects; do docker exec "$container" /command/s6-svstat "/run/service/$service" | grep -q '^up' || fail "$service is not up" done @@ -274,9 +279,9 @@ done docker exec "$container" test ! -L /usr/local/libexec/blitz-payload \ || fail "the base-owned payload updater is indirected through the payload" docker exec "$container" test ! -L /usr/local/bin/blitz-cred \ - || fail "the base-owned credential broker is indirected through the payload" + || fail "the base-owned credential helper is indirected through the payload" docker exec "$container" test -x /usr/local/bin/blitz-cred \ - || fail "the base-owned credential broker is missing" + || fail "the base-owned credential helper is missing" docker exec "$container" test ! -L /etc/blitz/env.defaults \ || fail "the base-owned environment defaults are indirected through the payload" docker exec "$container" grep -qx 'exec /usr/local/libexec/blitz-payload' \ @@ -677,10 +682,9 @@ if [ "${LODY_BOOT_ONLY:-0}" = 1 ]; then fi docker logs "$container" >"$test_dir/container.log" 2>&1 -grep -q 'register: skipped (no control-plane origin)' "$test_dir/container.log" || fail "register did not skip cleanly" -grep -q 'watch: waiting for broker config' "$test_dir/container.log" || fail "watch did not wait cleanly" -docker exec "$container" test ! -e /var/lib/blitz/broker.json || fail "no-CP mode created broker config" -echo "PASS no-CP skips" +grep -q 'blitz-credential-refresh: skipped (no control-plane origin)' "$test_dir/container.log" \ + || fail "credential refresh did not skip cleanly" +echo "PASS no-CP credential refresh skip" # Terminal delivery: the shim must WIN the PATH over the pinned binary it execs, # in a plain login shell as well as in the image environment. A member-installed @@ -690,14 +694,16 @@ resolved=$(docker exec "$container" /bin/sh -lc 'command -v claude') [ "$resolved" = '/usr/local/bin/claude' ] || fail "login-shell claude resolves to $resolved, not the shim" resolved=$(docker exec "$container" /bin/sh -c 'command -v claude') [ "$resolved" = '/usr/local/bin/claude' ] || fail "claude resolves to $resolved, not the shim" -docker exec "$container" grep -q 'CLAUDE_CODE_OAUTH_TOKEN' /usr/local/bin/claude || - fail "the claude shim does not export CLAUDE_CODE_OAUTH_TOKEN" +docker exec "$container" grep -q 'exec /opt/blitz/npm/bin/claude' /usr/local/bin/claude || + fail "the claude shim does not run the native CLI" +if docker exec "$container" grep -q 'blitz-cred' /usr/local/bin/claude; then + fail "the claude shim still calls the credential helper" +fi docker exec "$container" test ! -e /etc/claude-code/managed-settings.json || fail "managed settings exist; a managed apiKeyHelper hangs claude when a token is also set" -# Signed out is fine; a dead command is not. With no broker the shim must still -# reach the real binary. +# Signed out is fine. The shim must still reach the native binary. docker exec "$container" /bin/sh -lc 'claude --version' >/dev/null || - fail "the claude shim does not run with no broker configured" + fail "the claude shim does not run the native binary" echo "PASS terminal delivery shim" listeners=$(docker exec "$container" ss -ltnH) diff --git a/packages/broker/Dockerfile b/packages/broker/Dockerfile deleted file mode 100644 index 62b044fa..00000000 --- a/packages/broker/Dockerfile +++ /dev/null @@ -1,44 +0,0 @@ -FROM golang:1.26.5-bookworm@sha256:53eeac89074db483fdf0ab3be1df32bf6e47562263d2d0d6baa7f26acb4957dd AS build - -WORKDIR /src -COPY packages/broker/go.mod ./ -COPY packages/broker/cmd ./cmd -COPY packages/broker/internal ./internal -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/blitz-broker ./cmd/blitz-broker - -FROM node:22.20.0-bookworm-slim@sha256:b21fe589dfbe5cc39365d0544b9be3f1f33f55f3c86c87a76ff65a02f8f5848e AS vendors - -RUN npm install --global --omit=dev \ - @anthropic-ai/claude-code@latest \ - @openai/codex@0.147.0 \ - && npm cache clean --force - -FROM debian:bookworm-slim@sha256:abd67ffcfa541b485a3dff59865ab629aa048a6c613e639d36e7456b0b229241 - -RUN set -eux; \ - sed -i \ - -e 's|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/20260811T000000Z|g' \ - -e 's|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/20260811T000000Z|g' \ - -e '/^Signed-By:/a Check-Valid-Until: no' \ - /etc/apt/sources.list.d/debian.sources; \ - apt-get update; \ - apt-get install -y --no-install-recommends \ - ca-certificates=20250419~deb12u1 \ - libstdc++6=12.2.0-14+deb12u1 \ - openssh-client=1:9.2p1-2+deb12u10 \ - openssh-server=1:9.2p1-2+deb12u10 \ - passwd=1:4.13+dfsg1-1+deb12u2 \ - procps=2:4.0.2-3; \ - rm -f /etc/ssh/ssh_host_*; \ - apt-get clean; \ - rm -rf /var/lib/apt/lists/* - -COPY --from=vendors /usr/local/ /usr/local/ -COPY --from=build /out/blitz-broker /usr/local/bin/blitz-broker -COPY env.defaults /etc/blitz/env.defaults -COPY packages/broker/sshd_config /etc/ssh/sshd_config -COPY --chmod=0755 packages/broker/entrypoint.sh /usr/local/bin/blitz-broker-entrypoint - -VOLUME ["/var/lib/blitz-broker"] -EXPOSE 22 -ENTRYPOINT ["/usr/local/bin/blitz-broker-entrypoint"] diff --git a/packages/broker/Dockerfile.dockerignore b/packages/broker/Dockerfile.dockerignore deleted file mode 100644 index a63571e5..00000000 --- a/packages/broker/Dockerfile.dockerignore +++ /dev/null @@ -1,13 +0,0 @@ -**/node_modules -.git -.idea -# Never ship local secrets or dev clutter into the build context. -# (env.defaults is a tracked template, copied into the image, and is NOT -# matched by these.) -.env -**/.env -scratchpad/ -plans/ -tests/ -**/dist -tools/ diff --git a/packages/broker/README.md b/packages/broker/README.md deleted file mode 100644 index 9abc84cd..00000000 --- a/packages/broker/README.md +++ /dev/null @@ -1,138 +0,0 @@ -# broker - -**Optional.** No part of a deployment breaks without it. Skip this package -until per-workspace agent login becomes a chore. - -The broker is fleet credential delivery: one dedicated box holds an agent -subscription login (Claude or Codex) and mints short-lived credentials for -every workspace its owner spawns, over forced-command SSH, before each agent -turn. The control plane's registry holds public keys and routing only — never -a credential. The no-broker alternative is documented in the -[box README](../box/README.md): sign in once inside each workspace with -`claude login` or `codex login --device-auth` over SSH; agent HOME persists on -the state volume. - -## Run and enroll - -The registry reference below works once a `v*` release has published images -(the release notes carry the immutable digest). With no release yet, build -locally from the repository root: -`docker build -f packages/broker/Dockerfile -t blitz-broker:local .` and use -that tag instead. - -Enrolling needs three things in place: - -- a deployed control plane (the `--origin`); -- the broker container running with its state volume; -- a host and SSH port that workspace boxes can reach (`--host`/`--port` are - what gets advertised to them). - -```sh -docker volume create blitz-broker -docker pull ghcr.io//blitz-broker@sha256: -docker run -d --name blitz-broker --restart unless-stopped --env-file env.defaults -p 2222:22 -v blitz-broker:/var/lib/blitz-broker ghcr.io//blitz-broker@sha256: -docker exec blitz-broker blitz-broker enroll --origin --host broker.example --port 2222 -``` - -The enroll command runs the device flow: it prints a verification URL and -user code, and a signed-in control-plane user confirms it. The confirming -account owns the broker, and the command registers the broker's advertised -address and SSH host key with the control plane. - -## Provisioning a broker box - -`deploy/provision-broker.sh` runs in **two passes**, with a human between them, -because every workspace **pins** this box's SSH host key: the control plane must -have that key on file before anything is told to trust the broker. - -```sh -BROKER_HOST=operator@broker.example \ -CONTROL_PLANE_ORIGIN=https://control.example \ -BROKER_IMAGE=ghcr.io/blitzdotdev/blitz-broker@sha256: \ -SSH_PORT=2222 \ - packages/broker/deploy/provision-broker.sh prepare - -# ... enroll and approve, see below ... - -BROKER_HOST=operator@broker.example SSH_PORT=2222 \ - packages/broker/deploy/provision-broker.sh verify -``` - -`BROKER_HOST` is the SSH target of the **Docker host** (`user@host` or `host`). -The operator supplies that machine; neither pass creates or changes provider -resources. `SSH_HOST` overrides the public address workspaces dial and otherwise -defaults to the host part of `BROKER_HOST`; `SSH_PORT` is the published port and -defaults to `22`. `BROKER_CONTAINER` and `BROKER_VOLUME` both default to -`blitz-broker`. Both passes accept `--dry-run`, which prints what the pass would -do and touches nothing. - -1. **`prepare`.** Creates the state volume, starts the container with - `--restart unless-stopped`, waits for `entrypoint.sh` to generate the SSH host - key on the volume, and prints that key plus the exact `blitz-broker enroll` - command for this host and port. Re-running it is safe: an existing container - is left on the image it was created with. -2. **Enroll, then approve.** Run the printed command on the host. It prints a - verification URL and a user code, waits for you to approve it in a browser, - and then registers `host`, `port` and the SSH host key itself over - `PUT /boxes/:id/broker` — which is what creates the `broker_boxes` row. There - is deliberately no HTTP endpoint that creates that row without a human - approving a device code, and there is no self-serve broker creation. -3. **`verify`.** Installs `deploy/verify-broker-box.sh` on the host, runs it as - root, and only then prints the host, port and host key to match against the - registered row. The gate runs **last**, and its exit status is load-bearing: - a box that does not verify fails the whole run before any success report is - printed. - -The state volume holds the SSH host key. Keep it: re-creating it mints a new key -and every already-enrolled workspace's pin breaks. - -Nothing in this flow carries a secret. Production used a pre-shared per-box token -and a hand-executed `wrangler d1 execute` INSERT; the device flow replaces both, -so there is no env file to render, nothing to copy to the host, and nothing to -keep out of a command line. The box credential the flow writes onto the state -volume is never read by these scripts — the gate proves it exists without -opening it. - -### The verify gate - -`deploy/verify-broker-box.sh` runs on the Docker host as root, takes no address, -and writes nothing. Every check in it is a way a real box has failed: - -- the Docker daemon is active and the container is running under a restart - policy that brings it back; -- `sshd` is alive **inside** the container — it is a background child of the - entrypoint, so a dead `sshd` leaves the container "running" and answering - nothing; -- the published port is bound according to the **kernel** (`ss -H -ltn`), not - according to Docker, because the failure this gate exists for is a listener - that is up but bound to nothing reachable; -- PID 1 is `blitz-broker sync`, and the box holds a control-plane credential and - has run a quiet window of polls — together, proof the control plane accepted - this box; -- the `authorized_keys` directory matches `sshd_config`'s `AuthorizedKeysFile` - and is `root:root` `0755`, because `StrictModes yes` makes `sshd` reject every - member key otherwise; -- the host clock is UTC and NTP-synchronised. The container shares it, and a - wrong clock breaks TLS to the control plane and every token lifetime. - -`deploy/provision-broker.test.sh` runs both scripts against fake -`docker`/`systemctl`/`ss`/`timedatectl`/`ssh`/`scp` on a temporary `PATH`. It -takes no real host: `sh packages/broker/deploy/provision-broker.test.sh`. - -### Decisions - -- **Broker boxes are shared across orgs.** One box holds many members' credential - homes, isolated by Unix user and by the per-key `command=`/`restrict` line - `blitz-broker sync` renders. -- **`member_cap` (default 25) is a blast-radius cap, not a capacity number.** It - is how many identities one broker compromise takes with it, counted in - distinct principals rather than boxes, and it is the reason a member's second - workspace sticks to the broker that already holds their credential instead of - being load-balanced away from it. -- **Zero `broker_boxes` rows means the feature is OFF.** Workspace enrolment - treats "no broker" and "every broker full" as the same clean skip: leave the - workspace signed out and wired to nothing, and exit 0. -- **There is no autoscaler.** A second broker is a human running both passes - against a different `BROKER_HOST`. An automatic create path on a box class that - sits outside every reaper, with no drain and no delete path, is a leak - generator. diff --git a/packages/broker/RECORD.md b/packages/broker/RECORD.md deleted file mode 100644 index ad08c600..00000000 --- a/packages/broker/RECORD.md +++ /dev/null @@ -1,192 +0,0 @@ -# oss/broker — credential broker (daemon + client), open - -Founder constraints (ratified 2026-08-11): - -- broker self-host option with documentation - - make zero assumptions about the host it is running on (no Hetzner, hcloud etc that is users' responsibility) -- just install the broker via docker pull, define control plane base urls, and setup broker CLI thats it - -Carve 2026-08-11. Full report: session scratchpad `codex-broker-carve.txt`. -Registry half: `packages/control-plane/RECORD.md`. The daemon is OPEN. -Inventory: 32 files, ~5.2k LOC → 24 open, 7 closed, 1 deleted. -No open questions remain. All were decided 2026-08-11 (see Decided). - -## In core (open) - -These mechanisms carry into the rewrite: - -- The Go daemon. - - Stock OpenSSH forced-command auth. One key = one operation. The caller never - picks mint/deposit/harness. - - Root sync process. Unprivileged mint/deposit processes. - - Authoritative reconciliation. Member present with empty keys = keep the - account. Member absent = deprovision. - - One exclusive per-member lock. The "N mints, one refresh" proof carries. - - Atomic write chain. No custom OAuth client. No second token cache. Vendor - CLIs own refresh. (`credential.go`, `mint.go`, `deposit.go`, `sync.go`, - `lock.go`, `atomic.go`, `vendor.go` + tests.) -- Deposit contract: stdin, staged HOME, vendor verification before replacement, - one exact `ok\n` ACK. Nothing else. No event log (see Deleted). -- The Docker runtime: Dockerfile + entrypoint. Persistent users, homes, config, - host keys on ONE state volume. Shares the base layer + state-volume pattern - with the box image (2026-08-11). README becomes the self-host guide. -- Vendor adapters: Claude + Codex. They hold command names and credential - layouts. Nothing proprietary. The daemon hands them the only refresh-token - copy, so they must be open to audit. -- Workspace-side client, moved out of the golden image into this module. The box - OCI image consumes it. The watcher reads refresh-token files, so the trust - rule keeps it open. -- Wire contract: opaque `version`, strict decode, 1 MiB cap, member-absent = - deprovision. All carry. No ceiling: `expires_at` leaves the schema, the wire, - and the authorized_keys render. - -## Closed (hosted fleet ops only) - -- Broker fleet lifecycle: provisioning, rollout, monitoring, replacement, - capacity. -- The D1/wrangler registration wrapper. Native Debian/systemd convergence and - verify scripts. `package.json` monorepo glue. -- The hosted principal adapter (memberships/orgs) and placement/cap policy. -- No credential-custody code is closed-exclusive. - -## Deleted (decision in parens) - -- Heartbeat expiry: `BROKER_KEY_GRACE_MS`, `WORKSPACE_ALIVE_AT`, lease join, - `live_at` version leg, the `Math.min(alive_at+grace, …)` render (heartbeat - dead). -- Restart-class machinery, end to end: `RESTART_CLASS_HARNESSES`, - `isInteractiveWorkspace`, opencode + kimi integrations, the watcher - restart/rewrite loop and its invented 4-hour validity (core = pull-class only). -- Dual-use box bearer on registration → the box OAuth token (2026-08-11). - Rule: HTTP plane = tokens; SSH plane = keypairs. The keypair never - authenticates an HTTP call. -- `adopt`/`adopt_pubkey` compatibility shims (fresh schema, fresh client). -- The redundant systemd timer (the daemon already polls at 1 s). -- The restic `backup-exclude.harness-credentials` coupling (drives/restic dead). - The client keeps its own credential-path list for deposits. -- The cross-account deposit event/log (founder, 2026-08-11). No log when a user - deposits a different vendor account. The token works or it does not. Deposit = - verify + store + ACK. This also removes the custody-changed-before-event - failure mode. - -## Security consequence to hold - -No ceiling (founder, 2026-08-11). Do not control key lifecycle. - -- A key is valid exactly while the feed serves it. -- Registry reachable: destroy revokes in ~1 poll (CASCADE). -- Registry unreachable: the last rendered keys stay valid until the next - successful sync. Revocation IS the feed. - -## Self-host install story (decided 2026-08-11) - -- Publish the image. CI builds and pushes to a public registry, digest-pinned. - The Dockerfile exists. Publishing is the missing task. -- One `enroll` command, run INSIDE the container - (`docker exec broker … enroll --origin `). It runs the same device flow - as the box (2026-08-11: one token family; the separate pull token is - deleted). It reads the container's own SSH host pubkey, registers - host/port/pubkey through the enrollment API, and writes the 0600 credential - config onto the state volume. This one command replaces the wrangler D1 - insert, the hand-seeded config file, and the host-key extraction. -- The config file exists only to hold the box credential. Secrets never go in - argv or env (env shows in `docker inspect`). The CLI writes the file. Nobody - hand-writes it. -- The enrollment API lives in core (`packages/control-plane/RECORD.md`). -- The daemon advertises exactly what the image installs: Claude Code + Codex. - -## The CLI: two binaries, one Go module - -Decided 2026-08-11. Shared internal packages. The two are never co-installed: -one ships in the broker image, one in the box OCI image. No image carries the -other's code. Custody code never enters the workspace box. Each image pins -independently. This absorbs ~600 lines of box-side shell -(`blitz-broker-register`, `blitz-cred-watch`, `blitz-cred`, `blitz-cred-codex`) -and the systemd wiring. - -`blitz-broker` — broker box (inside the broker container): - -| Command | Invoked by | Job | -|---|---|---| -| `enroll` | operator, once | device flow → box credential; register own SSH host pubkey + host/port → write 0600 config on the state volume | -| `sync` | entrypoint (daemon loop) | poll the feed, reconcile unix users + authorized_keys | -| `mint ` | sshd forced command only | refresh via vendor CLI when needed, print short-lived token | -| `deposit` | sshd forced command only | stdin blob → staged HOME → vendor verify → atomic replace → `ok` | - -`blitz-cred` — workspace box (installed in the box OCI image): - -| Command | Invoked by | Job | -|---|---|---| -| `enroll` | box first start, one-shot | device flow → box credential, 0600 on the state volume. Skipped: no CP config, or hosted already delivered it via phone_home | -| `register` | box boot, idempotent | generate mint/deposit keypairs, register pubkeys (auth = the box OAuth token), write pinned broker config + harness hooks | -| `token ` | harness hooks (codex `auth command`) | ssh mint over the pinned host, print token | -| `watch` | box service, interactive workspaces | detect a fresh vendor login, deposit it | - -`mint` and `deposit` are never typed by a human. They exist only as `command=` -targets in authorized_keys. Both `enroll` commands call ONE device-flow client -package inside the module (2026-08-11). - -## Decided (founder, 2026-08-11) - -- Harnesses v1: Claude Code + Codex only. pi / opencode later. opencode also - needs the restart-class problem solved first. -- No key ceiling. See Security consequence. -- One control plane per broker instance. -- Broker SSH port: configurable (host + port in registry and client). Default 22. - Docker port mapping works. -- Second pass, cross-package synthesis (2026-08-11): pull token deleted — the - broker box enrolls through the same device flow and the feed accepts the box - OAuth token · `blitz-cred` gains `enroll` · one shared device-flow client · - the broker image shares the base layer with the box image. Front door: - `packages/oss/README.md`. - -## FIX for the rewrite - -Law #9: these stop at a PR. Red-first negative tests are in the full report. - -- CRITICAL `sync.go:103`: root chowns `authorized_keys` to the member. A vendor - process running as the member can replace it with an unrestricted key between - polls. A `.ssh` symlink makes root write through an attacker path. Fix: - root-owned `AuthorizedKeysFile` location. -- HIGH `broker.ts:258` vs `keys.go:27`: harness-regex mismatch. One workspace - can poison the whole box feed past the 1 MiB cap. -- HIGH `broker.ts:443`: registration is delete/delete/insert/insert. Not atomic. -- HIGH `blitz-cred-watch:179`: ACK/unlink inode race. A fresher login can be - deleted. -- HIGH: lock-wait vs client timeout mismatch (75 s vs 60/20 s). A disconnect can - kill a mid-rewrite of the only credential. -- HIGH `members.ts:183`: a disabled member never leaves the feed. The credential - home persists. -- HIGH `sync.go:138`: `userdel` runs without proof that the member's processes - are dead. UID reuse exposes the next member's files. -- HIGH `blitz-broker-register:198`: `broker.env` shell-interpolation escape. -- HIGH `client.go:58`: the error path logs up to 4 KiB of registry response. - Token echo risk. -- HIGH `config.go:43`: no HTTPS requirement on the configured origin. -- HIGH `broker.ts:370`: member_cap check and insert race. -- MEDIUM: unpinned base image/apt/npm in the Dockerfile. Root pubkey login - permitted in container sshd. Keypair reuse checked by size only. Dry-run - leaves a raw token in `/tmp` (closed path). - -## Deferred: harness authentication status - -Do not add broker-backed Claude or Codex status reporting as part of the -WebApp authentication gate. Standalone boxes can ask the pinned vendor CLIs -directly; broker-enrolled boxes should report `unknown` until this contract is -designed and tested independently. - -Before adding a broker status command: - -- Define whether `signed-in` means only that credential material is present, - or that it is currently usable. The UI needs the latter. -- Treat expired credentials and credentials that cannot refresh as signed out; - a parseable access token alone is insufficient. -- Keep the status response secret-free. It must never print a token, refresh a - credential as a side effect, or interfere with an in-flight turn. -- Add direct server tests for the complete forced-command SSH path, including - `SSH_ORIGINAL_COMMAND`, allowlist enforcement, malformed commands, and both - supported providers. -- Document the compatibility window and deployment order across broker, - `blitz-cred`, box image, and WebApp versions. -- Prove the final protocol against a real broker container and a newly built - box image before enabling it in the UI. diff --git a/packages/broker/cmd/blitz-broker/main.go b/packages/broker/cmd/blitz-broker/main.go deleted file mode 100644 index 45b1bc56..00000000 --- a/packages/broker/cmd/blitz-broker/main.go +++ /dev/null @@ -1,194 +0,0 @@ -package main - -import ( - "context" - "errors" - "flag" - "fmt" - "io" - "os" - "os/user" - "path/filepath" - "strings" - "time" - - "github.com/blitzdotdev/blitz-core/broker/internal/broker" - "github.com/blitzdotdev/blitz-core/broker/internal/controlplane" - "github.com/blitzdotdev/blitz-core/broker/internal/enroll" - "github.com/blitzdotdev/blitz-core/broker/internal/feed" - "github.com/blitzdotdev/blitz-core/broker/internal/store" - "github.com/blitzdotdev/blitz-core/broker/internal/vendor" -) - -// commandTimeout is the last-resort bound on one forced command, and it is -// deliberately the LOOSEST of the three deadlines in play, not the tightest. -// -// The real bounds are broker.LockWait (how long this run queues) and -// vendor.TriggerTimeout (how long the vendor CLI itself may take). A caller -// deadline shorter than their sum would cancel the child context and KILL a -// vendor CLI half-way through rewriting the only copy of a credential — the -// 2026-08-07 incident class this whole design exists to avoid. So it sits -// above both, with slack, and only catches a process wedged on something no -// other timer covers. -// -// The workspace's ssh client gives up sooner (internal/workspace/ssh.go). That -// is fine and intended: the client sees a clean failure and retries, while the -// refresh it started runs to completion instead of being cut in half. -const commandTimeout = broker.LockWait + vendor.TriggerTimeout + 10*time.Second - -func main() { - if err := run(os.Args[1:], os.Stdout, os.Stdin); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run(args []string, output io.Writer, input io.Reader) error { - if len(args) == 0 { - return errors.New("usage: blitz-broker enroll|sync|mint|deposit") - } - stateDir := os.Getenv("BLITZ_BROKER_STATE_DIR") - if stateDir == "" { - return errors.New("BLITZ_BROKER_STATE_DIR is required") - } - switch args[0] { - case "enroll": - return runEnroll(args[1:], stateDir, output) - case "sync": - if len(args) != 1 { - return errors.New("sync takes no arguments") - } - return broker.Sync(context.Background(), stateDir, nil) - case "mint": - return runMint(args[1:], output) - case "deposit": - return runDeposit(args[1:], output, input) - default: - return errors.New("unknown blitz-broker command") - } -} - -func runEnroll(args []string, stateDir string, output io.Writer) error { - hostname, err := os.Hostname() - if err != nil { - return err - } - flags := flag.NewFlagSet("enroll", flag.ContinueOnError) - flags.SetOutput(io.Discard) - origin := flags.String("origin", "", "control-plane origin") - host := flags.String("host", hostname, "advertised SSH host") - port := flags.Int("port", 22, "advertised SSH port") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *origin == "" { - return errors.New("usage: blitz-broker enroll --origin URL [--host H] [--port N]") - } - if *host == "" || strings.ContainsAny(*host, " \t\r\n") || *port < 1 || *port > 65535 { - return errors.New("invalid advertised broker address") - } - if _, err := enroll.Run(context.Background(), stateDir, *origin, "blitz-broker", output, nil); err != nil { - return err - } - hostKeyData, err := os.ReadFile(filepath.Join(stateDir, "ssh", "ssh_host_ed25519_key.pub")) - if err != nil { - return errors.New("broker SSH host public key is unavailable") - } - hostKey := strings.TrimSpace(string(hostKeyData)) - if !feed.ValidPublicKey(hostKey) { - return errors.New("broker SSH host public key is invalid") - } - storedOrigin, err := store.LoadOrigin(stateDir) - if err != nil { - return err - } - client, err := controlplane.New(storedOrigin, stateDir, nil) - if err != nil { - return err - } - return client.RegisterBroker(context.Background(), *host, *port, hostKey) -} - -func runMint(args []string, output io.Writer) error { - _, allowed, definition, home, err := forcedCommand(args) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) - defer cancel() - token, err := broker.Mint(ctx, home, allowed, definition.Name, definition, nil) - if err != nil { - return err - } - // Fprintln, not Fprint: the mint reply is a line-oriented SSH protocol and - // the newline is the TERMINATOR, never part of the token. Both consumers - // strip it — internal/workspace/ssh.go trimMintedToken on this box's own - // side, and the box's `blitz-cred-` shim by way of $(...) — and - // broker.Mint refuses a token that carries whitespace of its own, so the - // terminator stays unambiguous. - _, err = fmt.Fprintln(output, token) - return err -} - -func runDeposit(args []string, output io.Writer, input io.Reader) error { - _, _, definition, home, err := forcedCommand(args) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) - defer cancel() - if err := broker.Deposit(ctx, home, definition, input, nil); err != nil { - return err - } - _, err = io.WriteString(output, "ok\n") - return err -} - -func forcedCommand(args []string) (string, []string, vendor.Definition, string, error) { - if len(args) != 2 || os.Getenv("SSH_CONNECTION") == "" { - return "", nil, vendor.Definition{}, "", errors.New("command is restricted to forced-command SSH") - } - member := args[0] - if !feed.ValidUnixName(member) { - return "", nil, vendor.Definition{}, "", errors.New("invalid forced-command member") - } - allowed, err := parseAllowlist(args[1]) - if err != nil { - return "", nil, vendor.Definition{}, "", err - } - requested := os.Getenv("SSH_ORIGINAL_COMMAND") - definition, err := vendor.Lookup(requested) - if err != nil || !contains(allowed, requested) { - return "", nil, vendor.Definition{}, "", errors.New("requested harness is not allowed") - } - current, err := user.Current() - if err != nil || current.Username != member { - return "", nil, vendor.Definition{}, "", errors.New("forced-command Unix user mismatch") - } - account, err := user.Lookup(member) - if err != nil { - return "", nil, vendor.Definition{}, "", errors.New("forced-command member does not exist") - } - return member, allowed, definition, account.HomeDir, nil -} - -func parseAllowlist(raw string) ([]string, error) { - if raw == "-" { - return []string{}, nil - } - parts := strings.Split(raw, ",") - seen := make(map[string]bool) - for _, part := range parts { - if !feed.ValidHarness(part) || seen[part] { - return nil, errors.New("invalid forced-command harness allowlist") - } - seen[part] = true - } - return parts, nil -} - -func contains(values []string, value string) bool { - for _, candidate := range values { - if candidate == value { - return true - } - } - return false -} diff --git a/packages/broker/cmd/blitz-cred/main.go b/packages/broker/cmd/blitz-cred/main.go deleted file mode 100644 index 73b5dcb9..00000000 --- a/packages/broker/cmd/blitz-cred/main.go +++ /dev/null @@ -1,108 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "time" - - "github.com/blitzdotdev/blitz-core/broker/internal/workspace" -) - -// registerTimeout caps the whole broker enrolment, retries included. -const registerTimeout = 45 * time.Second - -// usageText is the single description of the verb set: the help verbs print it -// and the no-argument case returns it as the error, so an agent that guessed -// wrong reads the same list whichever way it arrived. -// -// The credential verbs (list, get, env, import, put, git-helper) are gone on -// purpose: the box keeps only schema-free primitives, and credentials are the -// agent's own curl against the control plane's /agent/* API. `api-token` is -// the one local helper that path needs — it prints a bearer and knows nothing -// about what the bearer is for. -const usageText = `usage: blitz-cred COMMAND [ARGUMENTS] - - api-token print a currently-valid control-plane bearer, and nothing else - register register broker keys and pin the broker config - token claude|codex print the harness login token - watch deposit harness logins to the broker as they change` - -func main() { - if err := run(os.Args[1:], os.Stdout); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -// run is the whole CLI. No verb reads stdin any more: the credential verbs -// that did (import, put, git-helper) are deleted, and everything left either -// takes arguments or takes nothing. -func run(args []string, output io.Writer) error { - if len(args) == 0 { - return errors.New(usageText) - } - // Answered before the state check: `blitz-cred --help` asks what the verbs - // are, and an agent that runs it outside a box deserves the list rather than - // a complaint about the environment. - if args[0] == "--help" || args[0] == "-h" || args[0] == "help" { - _, err := fmt.Fprintln(output, usageText) - return err - } - stateDir := os.Getenv("BLITZ_STATE_DIR") - if stateDir == "" { - return errors.New("BLITZ_STATE_DIR is required") - } - switch args[0] { - case "register": - if len(args) != 1 { - return errors.New("register takes no arguments") - } - // Bounded, because this runs as a boot-time oneshot with the rest of - // the box's services ordered behind it. The retry budget inside - // Register fits comfortably; anything slower than this is an outage, - // and the right answer to an outage is a workspace that boots signed - // out rather than one that never boots. - ctx, cancel := context.WithTimeout(context.Background(), registerTimeout) - defer cancel() - return workspace.Register(ctx, stateDir, nil) - case "token": - // Harness logins only. The two PATH shims read this stdout verbatim as - // the token itself. - if len(args) != 2 || (args[1] != "claude" && args[1] != "codex") { - return errors.New("usage: blitz-cred token claude|codex") - } - token, err := workspace.Token(context.Background(), stateDir, args[1]) - if err != nil { - return err - } - _, err = output.Write(token) - return err - case "api-token": - if len(args) != 1 { - return errors.New("api-token takes no arguments") - } - token, err := workspace.APIToken(context.Background(), stateDir, nil) - if err != nil { - return err - } - // The token and one newline: this stdout feeds a command substitution - // inside an Authorization header, so anything else becomes part of the - // bearer the agent sends. - _, err = fmt.Fprintln(output, token) - return err - case "watch": - if len(args) != 1 { - return errors.New("watch takes no arguments") - } - home, err := os.UserHomeDir() - if err != nil { - return errors.New("current Unix user has no home directory") - } - return workspace.Watch(context.Background(), stateDir, home) - default: - return errors.New("unknown blitz-cred command") - } -} diff --git a/packages/broker/cmd/blitz-cred/main_test.go b/packages/broker/cmd/blitz-cred/main_test.go deleted file mode 100644 index a2b1d3d1..00000000 --- a/packages/broker/cmd/blitz-cred/main_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package main - -import ( - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/blitzdotdev/blitz-core/broker/internal/store" - "github.com/blitzdotdev/blitz-core/broker/internal/workspace" -) - - -// The verb list an agent reads when it guesses wrong. `--help` used to print -// "unknown blitz-cred command" and exit 1. -func TestHelpAndNoArgumentsNameEveryVerb(t *testing.T) { - verbs := []string{"api-token", "register", "token", "watch"} - // Empty on purpose: help answers before the state check, so it works on a - // machine that is not a box. - t.Setenv("BLITZ_STATE_DIR", "") - for _, help := range []string{"--help", "-h", "help"} { - var output strings.Builder - if err := run([]string{help}, &output); err != nil { - t.Fatalf("%s: %v", help, err) - } - for _, verb := range verbs { - if !strings.Contains(output.String(), verb) { - t.Errorf("%s output does not name %q: %q", help, verb, output.String()) - } - } - } - err := run(nil, io.Discard) - if err == nil { - t.Fatal("blitz-cred without arguments returned no error") - } - for _, verb := range verbs { - if !strings.Contains(err.Error(), verb) { - t.Errorf("no-argument error does not name %q: %q", verb, err.Error()) - } - } -} - -// The PATH shims (/usr/local/bin/blitz-cred-claude, blitz-cred-codex) and -// ~/.codex/config.toml's [auth] command read this stdout as the token itself, -// so it carries the broker's bytes and nothing else: no label, no newline. -func TestTokenHarnessOutputStaysRawForTheShims(t *testing.T) { - stateDir := t.TempDir() - broker := []byte(`{"host":"broker.example","port":2222,"member":"m-0123456789ab"}`) - if err := os.WriteFile(filepath.Join(stateDir, workspace.BrokerFile), broker, 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("BLITZ_STATE_DIR", stateDir) - // A fake ssh stands in for the broker: Token shells out to `ssh` and copies - // what it prints. The trailing newline is the mint reply's line terminator, - // which the broker client strips. - fakeBin := t.TempDir() - if err := os.WriteFile(filepath.Join(fakeBin, "ssh"), []byte("#!/bin/sh\nprintf 'broker-minted-token\\n'\n"), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) - - for _, harness := range []string{"claude", "codex"} { - var output strings.Builder - if err := run([]string{"token", harness}, &output); err != nil { - t.Fatalf("token %s: %v", harness, err) - } - if output.String() != "broker-minted-token" { - t.Fatalf("token %s output = %q", harness, output.String()) - } - } -} - -// `api-token` feeds a command substitution inside an Authorization header, so -// stdout is the bearer and one newline, nothing else. The box cannot read a -// token's age, so validity is established by use: one authenticated GET -// against the agent API, refresh only on a 401. -func TestAPITokenPrintsAStillValidToken(t *testing.T) { - stateDir := t.TempDir() - var probes []string - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - probes = append(probes, request.Method+" "+request.URL.Path+" "+request.Header.Get("Authorization")) - io.WriteString(writer, `{"openapi":"3.1.0"}`) - })) - defer server.Close() - prepareCPState(t, stateDir, server.URL) - - var output strings.Builder - if err := run([]string{"api-token"}, &output); err != nil { - t.Fatal(err) - } - if output.String() != "access\n" { - t.Fatalf("api-token output = %q", output.String()) - } - if len(probes) != 1 || probes[0] != "GET /agent/api Bearer access" { - t.Fatalf("probes = %v", probes) - } -} - -func TestAPITokenRefreshesOnceOnA401ThenPrintsTheRotatedToken(t *testing.T) { - stateDir := t.TempDir() - var refreshes int - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - switch request.URL.Path { - case "/agent/api": - // The stored access token has expired; only the rotated one passes. - if request.Header.Get("Authorization") == "Bearer access-2" { - io.WriteString(writer, `{"openapi":"3.1.0"}`) - return - } - writer.WriteHeader(http.StatusUnauthorized) - case "/oauth/token": - refreshes++ - if err := request.ParseForm(); err != nil { - t.Error(err) - } - if request.PostForm.Get("grant_type") != "refresh_token" || - request.PostForm.Get("refresh_token") != "refresh" { - t.Errorf("refresh form = %v", request.PostForm) - } - io.WriteString(writer, `{"box_id":"box","access_token":"access-2",`+ - `"refresh_token":"refresh-2","token_type":"Bearer","expires_in":900}`) - default: - t.Errorf("unexpected request %s %s", request.Method, request.URL.Path) - writer.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - prepareCPState(t, stateDir, server.URL) - - var output strings.Builder - if err := run([]string{"api-token"}, &output); err != nil { - t.Fatal(err) - } - if output.String() != "access-2\n" { - t.Fatalf("api-token output = %q", output.String()) - } - if refreshes != 1 { - t.Fatalf("refresh calls = %d", refreshes) - } - // The rotation is durable: the next caller reads the new pair off disk. - rotated, err := store.LoadCredential(stateDir) - if err != nil { - t.Fatal(err) - } - if rotated.AccessToken != "access-2" || rotated.RefreshToken != "refresh-2" { - t.Fatalf("stored credential = %+v", rotated) - } -} - -// An unreachable control plane still prints the stored token, with exit 0: -// the agent's own curl is about to hit the same network and will surface the -// real error, which beats this helper guessing at one. -func TestAPITokenPrintsTheStoredTokenWhenTheCPIsUnreachable(t *testing.T) { - stateDir := t.TempDir() - server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - origin := server.URL - // Close before the run: nothing listens on the port any more. - server.Close() - prepareCPState(t, stateDir, origin) - - var output strings.Builder - if err := run([]string{"api-token"}, &output); err != nil { - t.Fatal(err) - } - if output.String() != "access\n" { - t.Fatalf("api-token output = %q", output.String()) - } -} - -// A machine that never enrolled has no origin and no credential to print. -func TestAPITokenWithoutBoxStateFails(t *testing.T) { - t.Setenv("BLITZ_STATE_DIR", t.TempDir()) - if err := run([]string{"api-token"}, io.Discard); err == nil { - t.Fatal("api-token without box state returned no error") - } -} - -// The box credential wire is gone: an agent that still runs a deleted verb -// must read a loud refusal, never silence it could mistake for success — and -// the help must have stopped advertising them. -func TestRemovedVerbsAreRejected(t *testing.T) { - t.Setenv("BLITZ_STATE_DIR", t.TempDir()) - removed := [][]string{ - {"sync"}, - {"token", "github"}, - {"list"}, - {"get", "github"}, - {"env", "github"}, - {"import", ".env"}, - {"put", "STRIPE_API_KEY"}, - {"git-helper", "get"}, - } - for _, args := range removed { - if err := run(args, io.Discard); err == nil { - t.Errorf("blitz-cred %v returned no error", args) - } - } - var help strings.Builder - if err := run([]string{"--help"}, &help); err != nil { - t.Fatal(err) - } - for _, verb := range []string{"sync", "git-helper", "import", "put", "env"} { - if strings.Contains(help.String(), verb) { - t.Errorf("help still advertises %s: %q", verb, help.String()) - } - } -} - -func prepareCPState(t *testing.T, stateDir, origin string) { - t.Helper() - if err := store.SaveCredential(stateDir, store.Credential{BoxID: "box", AccessToken: "access", RefreshToken: "refresh"}); err != nil { - t.Fatal(err) - } - if err := store.SaveOrigin(stateDir, origin); err != nil { - t.Fatal(err) - } - t.Setenv("BLITZ_STATE_DIR", stateDir) -} diff --git a/packages/broker/deploy/provision-broker.sh b/packages/broker/deploy/provision-broker.sh deleted file mode 100755 index a8c26de2..00000000 --- a/packages/broker/deploy/provision-broker.sh +++ /dev/null @@ -1,327 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Provision one operator-provided credential broker host -# (plans/CREDENTIAL-ROAMING.md § Provisioning). -# -# provision-broker.sh prepare [--dry-run] -# provision-broker.sh verify [--dry-run] -# -# BROKER_HOST is the SSH target of the DOCKER HOST (user@host or host). This -# script never creates a machine and never calls a cloud provider: the operator -# supplies the host. SSH_HOST is the public address workspaces dial and defaults -# to the host part of BROKER_HOST; SSH_PORT is the published port and defaults -# to 22. -# -# TWO PASSES, with a human in between, because a workspace PINS this broker's -# SSH host key: the control plane must have that key on file before anything is -# told to trust the broker. The order is: -# -# 1. provision-broker.sh prepare start the broker container on the host, -# read the SSH host key it generated on its -# state volume, print the enroll command. -# 2. enroll, then approve the operator runs the printed -# `blitz-broker enroll` and approves the -# device code in a browser. THAT call is what -# creates the broker_boxes row, over -# PUT /boxes/:id/broker. -# 3. provision-broker.sh verify install and run the end-of-run gate on the -# host, then report the pinned host key. -# -# Production hand-executed `wrangler d1 execute` INSERT SQL at step 2, against a -# pre-shared per-box token the operator had to mint, ship in a 0600 env file and -# keep out of every argv. blitz-core replaces both with the device flow: the -# container authenticates itself and registers its own host/port/host-key, so -# this script carries NO secret at all — nothing to render, nothing to copy, -# nothing to redact. The human step is a browser approval, not a database write. -# Nothing here ever reads the box credential the flow writes onto the state -# volume; the gate proves it exists without opening it. -# -# The broker is infrastructure: it takes no lease, gets no ingress route, and no -# reaper touches it. `member_cap` (default 25) is a blast-radius cap, not a -# capacity number. A second broker is a human running both passes against a -# different BROKER_HOST. There is deliberately no autoscaler and no self-serve -# broker creation. - -STAGE_DIR="/tmp/blitz-broker-stage" -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" - -# The image's declared mount point (packages/broker/Dockerfile `VOLUME`). It is -# the image contract, not a preference: the state volume carries the SSH host -# key every enrolled workspace pins, so it must survive a container replacement. -STATE_MOUNT="/var/lib/blitz-broker" - -usage="usage: provision-broker.sh prepare|verify [--dry-run]" -pass="${1:-}" -case "${pass}" in - prepare | verify) shift ;; - *) - echo "${usage}" >&2 - exit 64 - ;; -esac - -dry_run="false" -while [[ $# -gt 0 ]]; do - case "$1" in - --dry-run) - dry_run="true" - shift - ;; - *) - echo "${usage}" >&2 - exit 64 - ;; - esac -done - -require_env() { - local name="$1" - if [[ -z "${!name:-}" ]]; then - echo "error: ${name} is required" >&2 - exit 1 - fi -} - -require_command() { - local name="$1" - if ! command -v "${name}" >/dev/null 2>&1; then - echo "error: ${name} is required" >&2 - exit 1 - fi -} - -# ssh joins its remote arguments into ONE string the remote login shell parses, -# so anything interpolated into a remote command line is remote shell source. -# An allowlist rather than an escape: image refs, container and volume names and -# hostnames all live inside this set, and there is no quoting puzzle to lose. -require_plain() { - local name="$1" - if [[ ! "${!name}" =~ ^[A-Za-z0-9._:/@=+-]+$ ]]; then - echo "error: ${name} must contain only [A-Za-z0-9._:/@=+-], got: ${!name}" >&2 - exit 1 - fi -} - -require_env BROKER_HOST -if [[ "${pass}" == "prepare" ]]; then - # Pass 1 composes the enroll command, so it needs the origin the broker will - # authenticate against and the exact image to run. - require_env CONTROL_PLANE_ORIGIN - # No default tag on purpose. Workspaces pin this box's host key, and a - # floating tag is how a silent image swap becomes a fleet-wide pin mismatch. - require_env BROKER_IMAGE -fi -if [[ "${pass}" == "verify" ]]; then - require_command scp -fi -require_command awk -require_command ssh - -broker_name="${BROKER_HOST##*@}" -workspace_ssh_host="${SSH_HOST:-${broker_name}}" -workspace_ssh_port="${SSH_PORT:-22}" -container="${BROKER_CONTAINER:-blitz-broker}" -volume="${BROKER_VOLUME:-blitz-broker}" - -if [[ ! "${workspace_ssh_port}" =~ ^[0-9]+$ ]] || - ((workspace_ssh_port < 1 || workspace_ssh_port > 65535)); then - echo "error: SSH_PORT must be a port number, got: ${workspace_ssh_port}" >&2 - exit 1 -fi -require_plain BROKER_HOST -require_plain workspace_ssh_host -require_plain container -require_plain volume -if [[ "${pass}" == "prepare" ]]; then - require_plain BROKER_IMAGE - # The origin is interpolated into enroll_command, which this pass PRINTS for - # an operator to paste into a shell — so it is shell source twice over, and - # the paste is the copy nothing here gets to re-check. Every URL an origin may - # legally be fits the allowlist: controlplane.ValidateOrigin already refuses a - # user-info, query or fragment, leaving scheme, host, port and an optional - # trailing slash, all of which are inside [A-Za-z0-9._:/@=+-]. - require_plain CONTROL_PLANE_ORIGIN -fi - -umask 077 -work_dir="$(mktemp -d)" -trap 'rm -rf -- "${work_dir}"' EXIT - -enroll_command="docker exec ${container} blitz-broker enroll --origin ${CONTROL_PLANE_ORIGIN:-} --host ${workspace_ssh_host} --port ${workspace_ssh_port}" - -if [[ "${dry_run}" == "true" ]]; then - if [[ "${pass}" == "prepare" ]]; then - cat <} -dry-run: remote read ${STATE_MOUNT}/ssh/ssh_host_ed25519_key.pub -dry-run: print the host public key workspaces pin, and the enroll command: -dry-run: ${enroll_command} -EOF - else - cat < ${STAGE_DIR}/verify-broker-box.sh -dry-run: remote install -> /usr/local/sbin/blitz-broker-verify.sh -dry-run: remote read ${STATE_MOUNT}/ssh/ssh_host_ed25519_key.pub -dry-run: remote BROKER_CONTAINER=${container} /usr/local/sbin/blitz-broker-verify.sh -EOF - fi - exit 0 -fi - -known_hosts="${work_dir}/known_hosts" -: >"${known_hosts}" -ssh_options=( - # accept-new, not a pin: on a first run there is no host key to pin yet. The - # pin belongs on the WORKSPACE side, against the host public key these passes - # print. The known_hosts file is per-run and thrown away, so a changed key - # never silently passes twice. - -o StrictHostKeyChecking=accept-new - -o UserKnownHostsFile="${known_hosts}" - -o BatchMode=yes - -o ConnectTimeout=5 -) - -report_host_key() { # remote_log - local remote_log="$1" host_key - host_key="$(awk -F'\t' '$1 == "BROKER_SSH_HOST_PUBKEY" { print $2 }' "${remote_log}")" - if [[ -z "${host_key}" ]]; then - echo "error: the broker host did not report its SSH host public key" >&2 - exit 1 - fi - printf '%s\n' "${host_key}" -} - -remote_log="${work_dir}/remote.log" - -if [[ "${pass}" == "prepare" ]]; then - ssh "${ssh_options[@]}" "${BROKER_HOST}" /bin/bash -s -- \ - "${container}" "${volume}" "${BROKER_IMAGE}" "${workspace_ssh_port}" "${STATE_MOUNT}" \ - <<'REMOTE' | tee "${remote_log}" -set -euo pipefail -container="$1" -volume="$2" -image="$3" -port="$4" -state_mount="$5" - -if ! command -v docker >/dev/null 2>&1; then - echo "error: docker is not installed on the broker host" >&2 - exit 1 -fi - -docker volume create "${volume}" >/dev/null - -# Idempotent: an existing container is left on the image it was created with. -# Replacing it silently would hand every enrolled workspace a host key mismatch -# if the volume were ever re-created with it, so that is an operator decision. -if [[ -z "$(docker ps -aq --filter "name=^${container}$")" ]]; then - # No --env-file: the image bakes env.defaults at /etc/blitz/env.defaults and - # entrypoint.sh sources it. Nothing here belongs in the container environment, - # which `docker inspect` shows to anyone on the host. - docker run -d \ - --name "${container}" \ - --restart unless-stopped \ - -p "${port}:22" \ - -v "${volume}:${state_mount}" \ - "${image}" >/dev/null -fi - -if [[ "$(docker inspect -f '{{.State.Running}}' "${container}")" != "true" ]]; then - docker start "${container}" >/dev/null -fi - -# Resolved the way entrypoint.sh resolves it, so a host that overrides -# BLITZ_BROKER_STATE_DIR in the container environment is not read at the wrong -# path and reported as a broker with no host key. -state_dir="$(docker exec "${container}" sh -c 'if [ -z "${BLITZ_BROKER_STATE_DIR:-}" ]; then set -a; . /etc/blitz/env.defaults; set +a; fi; printf %s "${BLITZ_BROKER_STATE_DIR}"')" -if [[ -z "${state_dir}" ]]; then - echo "error: the container reports no BLITZ_BROKER_STATE_DIR" >&2 - exit 1 -fi - -# entrypoint.sh generates the host key on first start, before it execs the sync -# loop. A fresh container needs a moment; an existing one answers immediately. -host_key="" -for ((attempt = 1; attempt <= 30; attempt++)); do - host_key="$(docker exec "${container}" cat "${state_dir}/ssh/ssh_host_ed25519_key.pub" 2>/dev/null || true)" - if [[ -n "${host_key}" ]]; then - break - fi - sleep 1 -done -if [[ -z "${host_key}" ]]; then - echo "error: ${container} generated no SSH host key in 30 seconds" >&2 - exit 1 -fi -printf 'BROKER_SSH_HOST_PUBKEY\t%s\n' "${host_key}" -printf 'BROKER_IMAGE_REF\t%s\n' "$(docker inspect -f '{{.Config.Image}}' "${container}")" -REMOTE - - ssh_host_pubkey="$(report_host_key "${remote_log}")" - image_ref="$(awk -F'\t' '$1 == "BROKER_IMAGE_REF" { print $2 }' "${remote_log}")" - - echo - echo "broker target: ${BROKER_HOST}" - echo "container / volume: ${container} / ${volume}" - echo "image: ${image_ref}" - echo "workspace SSH host: ${workspace_ssh_host}:${workspace_ssh_port} (key auth only)" - echo - echo "pass 1 of 2 done. Workspaces PIN the host key, so read it now:" - echo " ssh_host_public_key = ${ssh_host_pubkey}" - echo - echo "enroll the broker on the host, then approve the printed code in a browser." - echo "It prints a verification URL and a user code, waits for you, and then" - echo "registers host, port and this host key itself — there is no SQL to run:" - echo - echo " ${enroll_command}" - echo - echo "then run pass 2:" - echo " provision-broker.sh verify" - exit 0 -fi - -# PASS 2. Stage the gate, install it, then run it. Nothing secret crosses the -# wire: the only file copied is this repository's verify-broker-box.sh. -ssh "${ssh_options[@]}" "${BROKER_HOST}" /bin/bash -s -- "${STAGE_DIR}" <<'REMOTE' -set -euo pipefail -rm -rf -- "$1" -install -d -m 0700 "$1" -REMOTE - -scp "${ssh_options[@]}" "${SCRIPT_DIR}/verify-broker-box.sh" \ - "${BROKER_HOST}:${STAGE_DIR}/verify-broker-box.sh" - -ssh "${ssh_options[@]}" "${BROKER_HOST}" /bin/bash -s -- \ - "${STAGE_DIR}" "${container}" \ - <<'REMOTE' | tee "${remote_log}" -set -euo pipefail -stage_dir="$1" -container="$2" -install -m 0755 "${stage_dir}/verify-broker-box.sh" /usr/local/sbin/blitz-broker-verify.sh.new -mv -f /usr/local/sbin/blitz-broker-verify.sh.new /usr/local/sbin/blitz-broker-verify.sh -state_dir="$(docker exec "${container}" sh -c 'if [ -z "${BLITZ_BROKER_STATE_DIR:-}" ]; then set -a; . /etc/blitz/env.defaults; set +a; fi; printf %s "${BLITZ_BROKER_STATE_DIR}"')" -printf 'BROKER_SSH_HOST_PUBKEY\t%s\n' \ - "$(docker exec "${container}" cat "${state_dir}/ssh/ssh_host_ed25519_key.pub")" -rm -rf -- "${stage_dir}" -# THE GATE, and it runs LAST. A non-zero result fails the remote shell and, via -# pipefail on the tee above, this whole run — before the success report below is -# printed. That ordering is the point: production once shipped a "success" -# report for a box that was not working. -BROKER_CONTAINER="${container}" /usr/local/sbin/blitz-broker-verify.sh >&2 -REMOTE - -ssh_host_pubkey="$(report_host_key "${remote_log}")" - -echo -echo "broker target: ${BROKER_HOST}" -echo "container: ${container}" -echo "workspace SSH host: ${workspace_ssh_host}:${workspace_ssh_port} (key auth only)" -echo -echo "these MUST match the broker_boxes row the enroll step registered:" -echo " host = ${workspace_ssh_host}" -echo " port = ${workspace_ssh_port}" -echo " ssh_host_public_key = ${ssh_host_pubkey}" diff --git a/packages/broker/deploy/provision-broker.test.sh b/packages/broker/deploy/provision-broker.test.sh deleted file mode 100755 index 30d4bc83..00000000 --- a/packages/broker/deploy/provision-broker.test.sh +++ /dev/null @@ -1,417 +0,0 @@ -#!/bin/sh -set -eu - -# Asserts on what the broker provisioning scripts check and invoke, under fake -# docker/systemctl/ss/timedatectl/ssh/scp. Nothing here reaches a real host, a -# real daemon or a real control plane. -# -# POSIX sh on purpose, like packages/control-plane/test/shell-syntax.sh, so the -# repository can run it with `sh`. The scripts under test are bash and are -# invoked as bash. - -script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) -repo_root=$(CDPATH='' cd -- "$script_dir/../../.." && pwd) -provision="$script_dir/provision-broker.sh" -gate="$script_dir/verify-broker-box.sh" -# The box-side half of the broker feature. It is asserted from here because this -# is the only shell suite `npm test` runs, and the property below is a -# broker-feature-off property: no other suite would exercise it. -register="$repo_root/packages/box/rootfs/usr/local/libexec/blitz-register" - -test_dir=$(mktemp -d "${TMPDIR:-/tmp}/blitz-broker-deploy.XXXXXX") -trap 'rm -rf "$test_dir"' EXIT HUP INT TERM - -fail() { - printf 'FAIL: %s\n' "$*" >&2 - exit 1 -} - -# =========================================================================== -# 1. Both scripts parse, and neither reintroduces the SIGPIPE trap. -# =========================================================================== -bash -n "$provision" || fail "provision-broker.sh does not parse" -bash -n "$gate" || fail "verify-broker-box.sh does not parse" -bash -n "$register" || fail "blitz-register does not parse" - -# blitz-register must delete a stale /etc/claude-code/managed-settings.json -# BEFORE it returns on a box with no control-plane origin. No origin is how the -# broker feature is turned off, and a box with the feature off is exactly the -# box a managed apiKeyHelper hangs: with both it and CLAUDE_CODE_OAUTH_TOKEN in -# play claude does not lose to one, it wedges, and nothing else on the box -# removes the file. Line order is the whole property, so assert on it. -managed_settings_line=$(grep -n 'rm -f /etc/claude-code/managed-settings.json' "$register" | cut -d: -f1) -origin_guard_line=$(grep -n 'state_dir/origin' "$register" | cut -d: -f1) -[ -n "$managed_settings_line" ] || - fail "blitz-register no longer removes a stale managed-settings.json" -[ -n "$origin_guard_line" ] || - fail "blitz-register no longer skips on a missing control-plane origin" -[ "$managed_settings_line" -lt "$origin_guard_line" ] || - fail "blitz-register removes managed-settings.json below the origin guard: the boxes that need it most never reach the line" - -# `grep -q` exits on the first match and closes the pipe, the producer dies of -# SIGPIPE, and pipefail turns a SUCCESSFUL match into a failed check: green on a -# short log, red on every healthy long-lived box. The gate must capture into a -# variable first (production hit this on 2026-08-08). -if grep -Eq '(docker logs|journalctl)[^|]*\| *grep' "$gate"; then - fail "the gate pipes a log producer into grep; pipefail will fail on a successful match" -fi -# `head`/`tail` close the pipe the same way. -if grep -Eq '(docker port|docker logs)[^|]*\| *(head|tail)' "$gate"; then - fail "the gate pipes a docker producer into head/tail; that is the same SIGPIPE trap" -fi - -# The gate matches sync.go's positive line as a literal, across two runtimes. -# Edit the text on one side only and the gate goes on finding nothing: it would -# fail every healthy box, and it would say the control plane refused it. -grep -Fq 'broker feed applied; members: ' "$gate" || - fail "the gate no longer requires the sync loop's positive line" -grep -Fq 'broker feed applied; members: ' "$repo_root/packages/broker/internal/broker/sync.go" || - fail "the sync loop no longer prints the line the gate matches on" - -# =========================================================================== -# 2. Fakes. -# =========================================================================== -fake_bin="$test_dir/bin" -mkdir "$fake_bin" - -cat >"$fake_bin/systemctl" <<'SH' -#!/bin/sh -printf 'systemctl %s\n' "$*" >>"$BLITZ_TEST_ARGV_LOG" -case "$*" in - "is-active --quiet docker") - [ "${BLITZ_TEST_DOCKER_ACTIVE:-true}" = true ] || exit 3 ;; - *) printf 'unexpected systemctl invocation: %s\n' "$*" >&2; exit 2 ;; -esac -SH - -cat >"$fake_bin/ss" <<'SH' -#!/bin/sh -printf 'ss %s\n' "$*" >>"$BLITZ_TEST_ARGV_LOG" -if [ "${BLITZ_TEST_LISTENING:-true}" = true ]; then - printf 'LISTEN 0 4096 0.0.0.0:%s 0.0.0.0:*\n' "${BLITZ_TEST_PORT:-2222}" -fi -SH - -cat >"$fake_bin/timedatectl" <<'SH' -#!/bin/sh -printf 'timedatectl %s\n' "$*" >>"$BLITZ_TEST_ARGV_LOG" -case "$*" in - "show --property=Timezone --value") printf '%s\n' "${BLITZ_TEST_TIMEZONE:-UTC}" ;; - "show --property=NTPSynchronized --value") printf '%s\n' "${BLITZ_TEST_NTP:-yes}" ;; - *) printf 'unexpected timedatectl invocation: %s\n' "$*" >&2; exit 2 ;; -esac -SH - -# One fake for every docker subcommand the gate reaches for. Each toggle takes -# down exactly one condition so a failing assertion names one cause. -cat >"$fake_bin/docker" <<'SH' -#!/bin/sh -printf 'docker %s\n' "$*" >>"$BLITZ_TEST_ARGV_LOG" -subcommand=$1 -shift -case "$subcommand" in - inspect) - [ "${BLITZ_TEST_CONTAINER_EXISTS:-true}" = true ] || exit 1 - printf '%s %s\n' "${BLITZ_TEST_RUNNING:-true}" "${BLITZ_TEST_RESTART:-unless-stopped}" ;; - port) - [ "${BLITZ_TEST_PORT_PUBLISHED:-true}" = true ] || exit 1 - printf '0.0.0.0:%s\n[::]:%s\n' "${BLITZ_TEST_PORT:-2222}" "${BLITZ_TEST_PORT:-2222}" ;; - logs) - # Three shapes of sync log, one per cause. `silent` is the blackholed box: - # the control-plane HTTP client waits 30 s before it can report anything, so - # for the first half-minute a box with no route to the plane produces a log - # that carries neither the positive line nor a complaint. - case "${BLITZ_TEST_FEED:-applied}" in - applied) printf 'broker feed applied; members: 3\n' ;; - failing) printf 'broker feed unavailable; keeping rendered state\n' ;; - silent) ;; - *) printf 'unexpected BLITZ_TEST_FEED: %s\n' "${BLITZ_TEST_FEED}" >&2; exit 2 ;; - esac ;; - exec) - shift # the container name - case "$1" in - pgrep) [ "${BLITZ_TEST_SSHD_UP:-true}" = true ] || exit 1 ;; - ps) printf '%s\n' "${BLITZ_TEST_PID1:-/usr/local/bin/blitz-broker sync}" ;; - sh) printf '%s' "${BLITZ_TEST_STATE_DIR:-/var/lib/blitz-broker}" ;; - test) [ "${BLITZ_TEST_ENROLLED:-true}" = true ] || exit 1 ;; - awk) printf '%s\n' "${BLITZ_TEST_AUTHORIZED_KEYS_FILE:-/etc/blitz-broker/authorized_keys/%u}" ;; - stat) printf '%s\n' "${BLITZ_TEST_AUTHORIZED_KEYS_STAT:-755 root root}" ;; - *) printf 'unexpected docker exec: %s\n' "$*" >&2; exit 2 ;; - esac ;; - *) printf 'unexpected docker invocation: %s %s\n' "$subcommand" "$*" >&2; exit 2 ;; -esac -SH - -# The retry loops must not make the suite wait on a real clock. -cat >"$fake_bin/sleep" <<'SH' -#!/bin/sh -exit 0 -SH - -cat >"$fake_bin/ssh" <<'SH' -#!/bin/sh -printf 'ssh %s\n' "$*" >>"$BLITZ_TEST_ARGV_LOG" -for argument do - if [ "$argument" = /bin/bash ]; then - remote_script=$(command cat) - printf '%s\n' "$remote_script" >>"$BLITZ_TEST_STDIN_LOG" - printf '%s\n' "$remote_script" >"$BLITZ_TEST_STDIN_LAST" - case "$remote_script" in - *BROKER_SSH_HOST_PUBKEY*) - printf 'BROKER_SSH_HOST_PUBKEY\t%s\n' 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5FAKEHOST broker' - printf 'BROKER_IMAGE_REF\t%s\n' 'ghcr.io/blitzdotdev/blitz-broker@sha256:feed' - ;; - esac - # A box that does not pass the end-of-run gate: the remote shell runs set -e, - # so the ssh itself comes back non-zero. - case "${BLITZ_TEST_VERIFY_FAILS:-false}:$remote_script" in - true:*blitz-broker-verify.sh*) - printf 'error: nothing is listening on port 2222\n' >&2 - exit 1 ;; - esac - break - fi -done -exit 0 -SH - -cat >"$fake_bin/scp" <<'SH' -#!/bin/sh -printf 'scp %s\n' "$*" >>"$BLITZ_TEST_ARGV_LOG" -exit 0 -SH - -chmod 0755 "$fake_bin"/* -real_path=$PATH -PATH="$fake_bin:$PATH" -export PATH - -BLITZ_TEST_ARGV_LOG="$test_dir/argv.log" -BLITZ_TEST_STDIN_LOG="$test_dir/ssh-stdin.log" -BLITZ_TEST_STDIN_LAST="$test_dir/ssh-stdin.last" -export BLITZ_TEST_ARGV_LOG BLITZ_TEST_STDIN_LOG BLITZ_TEST_STDIN_LAST -: >"$BLITZ_TEST_ARGV_LOG" -: >"$BLITZ_TEST_STDIN_LOG" -: >"$BLITZ_TEST_STDIN_LAST" - -# =========================================================================== -# 3. verify-broker-box.sh, the end-of-run gate. -# -# It is invoked here EXACTLY as pass 2 invokes it — no arguments. Running it -# with an address the caller no longer passes is how the production suite -# stayed green while the gate died on a real box with -# "usage: verify-broker-box.sh " (2026-08-08). -# =========================================================================== -: >"$BLITZ_TEST_ARGV_LOG" -gate_output=$(bash "$gate" 2>&1) || - fail "a healthy box did not pass the gate: $gate_output" - -# `running` is not proof of a usable listener: ask the kernel what is bound. -grep -Fq 'ss -H -ltn sport = :2222' "$BLITZ_TEST_ARGV_LOG" || - fail "the gate never asks the kernel what the broker actually bound" -# sshd is a background child of the entrypoint, so the container stays "running" -# when it dies. The gate must look inside. -grep -Fq 'docker exec blitz-broker pgrep -x sshd' "$BLITZ_TEST_ARGV_LOG" || - fail "the gate never checks that sshd is alive inside the container" -# The blitz-core equivalent of production's `members:` line: an existing box -# credential plus sync's own positive line inside a bounded log window. -grep -Fq 'box-credential.json' "$BLITZ_TEST_ARGV_LOG" || - fail "the gate never checks that the box holds a control-plane credential" -# 45 s, not 10 s. The control-plane HTTP client's timeout is 30 s -# (internal/controlplane/controlplane.go New), so a shorter window is one a box -# with no route to the plane is still silent through. -grep -Fq 'docker logs --since 45s blitz-broker' "$BLITZ_TEST_ARGV_LOG" || - fail "the gate reads a log window shorter than one control-plane timeout cycle" -# The directory sshd reads must be the directory the daemon renders into. -grep -Fq 'AuthorizedKeysFile' "$BLITZ_TEST_ARGV_LOG" || - fail "the gate never cross-checks authorized_keys against sshd_config" -# A container clock is the host clock; a wrong one breaks TLS and token lifetime. -grep -Fq 'timedatectl show --property=NTPSynchronized --value' "$BLITZ_TEST_ARGV_LOG" || - fail "the gate never checks the clock" - -expect_gate_failure() { # message env-name=value... - expected_message=$1 - shift - gate_stderr="$test_dir/gate.stderr" - if env "$@" bash "$gate" >/dev/null 2>"$gate_stderr"; then - fail "$expected_message" - fi - grep -Fq 'error:' "$gate_stderr" || fail "$expected_message (no error line)" -} - -expect_gate_failure "a host with no docker daemon passed the gate" \ - BLITZ_TEST_DOCKER_ACTIVE=false -expect_gate_failure "a missing container passed the gate" \ - BLITZ_TEST_CONTAINER_EXISTS=false -expect_gate_failure "a stopped container passed the gate" \ - BLITZ_TEST_RUNNING=false -expect_gate_failure "a container that will not restart passed the gate" \ - BLITZ_TEST_RESTART=no -expect_gate_failure "a container with no sshd passed the gate" \ - BLITZ_TEST_SSHD_UP=false -expect_gate_failure "a container publishing no port passed the gate" \ - BLITZ_TEST_PORT_PUBLISHED=false -expect_gate_failure "a listener bound to nothing passed the gate" \ - BLITZ_TEST_LISTENING=false -expect_gate_failure "a container whose PID 1 is not the sync loop passed the gate" \ - BLITZ_TEST_PID1=/bin/sleep -expect_gate_failure "an un-enrolled broker passed the gate" \ - BLITZ_TEST_ENROLLED=false -expect_gate_failure "a sync loop reporting feed failures passed the gate" \ - BLITZ_TEST_FEED=failing -# The one the gate was built to catch and did not: a box whose route to the -# control plane is blackholed logs NOTHING until the 30 s HTTP timeout lands, so -# the old quiet-window gate passed it at t≈0. Absence of complaints is not -# evidence; the positive line is. -expect_gate_failure "a sync loop that logged neither success nor failure passed the gate" \ - BLITZ_TEST_FEED=silent -expect_gate_failure "an authorized_keys directory sshd does not read passed the gate" \ - BLITZ_TEST_AUTHORIZED_KEYS_FILE=.ssh/authorized_keys -expect_gate_failure "a member-owned authorized_keys directory passed the gate" \ - BLITZ_TEST_AUTHORIZED_KEYS_STAT="755 m-0123456789ab m-0123456789ab" -expect_gate_failure "a group-writable authorized_keys directory passed the gate" \ - BLITZ_TEST_AUTHORIZED_KEYS_STAT="775 root root" -expect_gate_failure "a non-UTC host passed the gate" \ - BLITZ_TEST_TIMEZONE=Europe/Berlin -expect_gate_failure "an unsynchronised clock passed the gate" \ - BLITZ_TEST_NTP=no - -# =========================================================================== -# 4. provision-broker.sh: two passes, required inputs, and the gate's exit -# status staying load-bearing. -# =========================================================================== -prepare_env="BROKER_HOST=operator@broker.example \ -CONTROL_PLANE_ORIGIN=https://cp.example \ -BROKER_IMAGE=ghcr.io/blitzdotdev/blitz-broker@sha256:feed \ -SSH_PORT=2222" - -# shellcheck disable=SC2086 # the assignments are deliberately word-split into env -env $prepare_env bash "$provision" prepare --dry-run \ - >"$test_dir/prepare-dry.stdout" 2>"$test_dir/prepare-dry.stderr" || - fail "pass 1 dry-run failed: $(cat "$test_dir/prepare-dry.stderr")" -grep -Fq 'ssh operator@broker.example' "$test_dir/prepare-dry.stdout" || - fail "pass 1 does not use BROKER_HOST as the SSH target" -grep -Fq 'blitz-broker enroll --origin https://cp.example --host broker.example --port 2222' \ - "$test_dir/prepare-dry.stdout" || - fail "pass 1 does not print the device-flow enroll command the operator runs" - -env BROKER_HOST=operator@broker.example bash "$provision" verify --dry-run \ - >"$test_dir/verify-dry.stdout" 2>"$test_dir/verify-dry.stderr" || - fail "pass 2 dry-run failed: $(cat "$test_dir/verify-dry.stderr")" -grep -Fq 'verify-broker-box.sh' "$test_dir/verify-dry.stdout" || - fail "pass 2 dry-run does not say it stages and runs the gate" - -expect_provision_failure() { # message pass env-name=value... - expected_message=$1 - provision_pass=$2 - shift 2 - provision_stderr="$test_dir/provision.stderr" - if env "$@" bash "$provision" "$provision_pass" --dry-run \ - >/dev/null 2>"$provision_stderr"; then - fail "$expected_message" - fi - grep -Fq 'error:' "$provision_stderr" || fail "$expected_message (no error line)" -} - -expect_provision_failure "pass 1 accepted a missing BROKER_HOST" prepare \ - CONTROL_PLANE_ORIGIN=https://cp.example BROKER_IMAGE=broker@sha256:feed -grep -Fq 'BROKER_HOST is required' "$test_dir/provision.stderr" || - fail "the missing BROKER_HOST error does not name the required input" -expect_provision_failure "pass 1 accepted a missing CONTROL_PLANE_ORIGIN" prepare \ - BROKER_HOST=broker.example BROKER_IMAGE=broker@sha256:feed -expect_provision_failure "pass 1 accepted a missing BROKER_IMAGE" prepare \ - BROKER_HOST=broker.example CONTROL_PLANE_ORIGIN=https://cp.example -expect_provision_failure "an invalid SSH_PORT was accepted" verify \ - BROKER_HOST=broker.example SSH_PORT=notaport -# ssh joins its remote arguments into one string the remote shell parses, so an -# image reference is remote shell source. -expect_provision_failure "an image reference carrying a shell command was accepted" prepare \ - BROKER_HOST=broker.example CONTROL_PLANE_ORIGIN=https://cp.example \ - 'BROKER_IMAGE=broker@sha256:feed; rm -rf /' -# So is the origin: it is interpolated into the enroll command pass 1 prints for -# an operator to paste into a shell. -expect_provision_failure "a control-plane origin carrying a shell command was accepted" prepare \ - BROKER_HOST=broker.example BROKER_IMAGE=broker@sha256:feed \ - 'CONTROL_PLANE_ORIGIN=https://cp.example; rm -rf /' - -# ---- pass 1 for real, against the fakes ------------------------------------ -: >"$BLITZ_TEST_ARGV_LOG" -: >"$BLITZ_TEST_STDIN_LOG" -# shellcheck disable=SC2086 # see above -env $prepare_env bash "$provision" prepare \ - >"$test_dir/prepare.stdout" 2>&1 || - fail "pass 1 failed: $(cat "$test_dir/prepare.stdout")" - -grep -Fq 'docker run -d' "$BLITZ_TEST_STDIN_LOG" || - fail "pass 1 never starts the broker container" -grep -Fq -- '--restart unless-stopped' "$BLITZ_TEST_STDIN_LOG" || - fail "pass 1 starts a container that will not survive its first crash" -grep -Fq 'ssh_host_ed25519_key.pub' "$BLITZ_TEST_STDIN_LOG" || - fail "pass 1 never reads the SSH host key workspaces pin" -# Workspaces PIN this key, so the operator has to be able to eyeball it. -grep -Fq 'ssh_host_public_key = ssh-ed25519 AAAAC3NzaC1lZDI1NTE5FAKEHOST' \ - "$test_dir/prepare.stdout" || - fail "pass 1 does not print the host public key workspaces pin" -grep -Fq 'blitz-broker enroll --origin https://cp.example --host broker.example --port 2222' \ - "$test_dir/prepare.stdout" || - fail "pass 1 does not print the enroll command" -# The device flow replaced production's hand-run INSERT. Nothing here should -# send an operator back to a SQL console. -if grep -Eq 'INSERT INTO|wrangler d1' "$test_dir/prepare.stdout"; then - fail "pass 1 still asks a human to hand-write the broker_boxes row" -fi -# Pass 1 prepares; it does not configure a box that is not registered yet. -if grep -Fq 'blitz-broker-verify.sh' "$BLITZ_TEST_STDIN_LOG"; then - fail "pass 1 ran the end-of-run gate before the broker was enrolled" -fi - -# ---- pass 2 for real ------------------------------------------------------- -: >"$BLITZ_TEST_ARGV_LOG" -: >"$BLITZ_TEST_STDIN_LOG" -env BROKER_HOST=operator@broker.example SSH_PORT=2222 \ - bash "$provision" verify >"$test_dir/verify.stdout" 2>&1 || - fail "pass 2 failed: $(cat "$test_dir/verify.stdout")" - -grep -Fq 'install -d -m 0700 ' "$BLITZ_TEST_STDIN_LOG" || - fail "the remote staging directory is not 0700" -grep -Fq 'verify-broker-box.sh' "$BLITZ_TEST_ARGV_LOG" || - fail "pass 2 never copies the gate to the host" -# The gate's exit status is LOAD-BEARING: no `|| true`, no `if`, nothing that -# turns a box that does not work into a successful provision — and it is the -# LAST thing the remote shell does, so its failure lands before any report. -grep -Eq '^BROKER_CONTAINER="\$\{container\}" /usr/local/sbin/blitz-broker-verify\.sh >&2$' \ - "$BLITZ_TEST_STDIN_LOG" || - fail "pass 2 either never runs the end-of-run gate or swallows its exit status" -last_remote_line=$(tail -n 1 "$BLITZ_TEST_STDIN_LAST") -case "$last_remote_line" in - *blitz-broker-verify.sh*) ;; - *) fail "the gate is not the last thing pass 2 runs on the host: $last_remote_line" ;; -esac -grep -Fq 'ssh_host_public_key = ssh-ed25519 AAAAC3NzaC1lZDI1NTE5FAKEHOST' \ - "$test_dir/verify.stdout" || - fail "pass 2 does not re-print the host public key for the operator to match" - -# A box that fails the gate is NOT a provisioned box: pass 2 must exit non-zero -# and must not print the report the operator matches against broker_boxes. -: >"$BLITZ_TEST_STDIN_LOG" -if env BROKER_HOST=operator@broker.example BLITZ_TEST_VERIFY_FAILS=true \ - bash "$provision" verify >"$test_dir/verify-fail.stdout" 2>&1; then - fail "pass 2 exited 0 although the box did not verify" -fi -if grep -Fq 'these MUST match' "$test_dir/verify-fail.stdout"; then - fail "pass 2 printed its success report although the box did not verify" -fi - -# =========================================================================== -# 5. shellcheck -# =========================================================================== -PATH=$real_path -export PATH -if command -v shellcheck >/dev/null 2>&1; then - shellcheck -x "$script_dir"/*.sh || fail "shellcheck reported findings" - printf '%s\n' "shellcheck: clean" -else - printf '%s\n' "shellcheck: not installed, skipped" >&2 -fi - -printf '%s\n' "provision-broker.test.sh: all assertions passed" diff --git a/packages/broker/deploy/verify-broker-box.sh b/packages/broker/deploy/verify-broker-box.sh deleted file mode 100755 index b168abbc..00000000 --- a/packages/broker/deploy/verify-broker-box.sh +++ /dev/null @@ -1,217 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# The end-of-run gate for a broker box (runs on the DOCKER HOST as root, last -# thing in provision-broker.sh verify). -# -# verify-broker-box.sh -# -# It takes no address, and it writes nothing. BROKER_CONTAINER selects the -# container when the operator did not use the default name; it is not an -# address, and every check below still asks the machine what is true rather than -# taking an answer from the caller. -# -# It exists because pass 2 reported success on a box that was not working. On -# 2026-08-07 the production listener flip raced the tunnel, sshd bound nothing, -# and the run died before the key sync was ever enabled — so the box answered on -# no address and never pulled a key. Every check below is a thing that was wrong -# on that box, re-aimed at the shape blitz-core actually ships: one container -# whose PID 1 is `blitz-broker sync` and whose sshd is a background child -# (packages/broker/entrypoint.sh), not a systemd service plus a timer. -# -# Nothing here writes and nothing is looked up by absolute path, so the test -# suite runs it directly against fake docker/systemctl/ss/timedatectl on PATH. - -fail() { - echo "error: $*" >&2 - exit 1 -} - -container="${BROKER_CONTAINER:-blitz-broker}" - -# How far back check 6 reads the container log, and how long it keeps re-reading -# before giving up. Both must EXCEED one full control-plane timeout cycle. -# -# internal/controlplane/controlplane.go New builds the HTTP client with a 30 s -# timeout and the sync loop polls once a second, so a box whose route to the -# plane is blackholed cannot log its first failure before t≈31 s. A 10 s window -# is a window that box is entirely silent through. internal/broker/sync.go -# re-states its positive line every feedHeartbeatInterval (30 s), so 45 s is -# also long enough that a box which IS reaching the plane always has one inside -# the window, whether it has been up ten seconds or ten days. -FEED_WINDOW_SECONDS=45 -FEED_POLL_SECONDS=2 -FEED_ATTEMPTS=23 - -# --------------------------------------------------------------------------- -# 1. The Docker daemon. `--restart unless-stopped` is the only thing that brings -# the broker back after a crash or a host reboot, and it means nothing while -# dockerd is down. A stopped daemon is the container-shaped version of a box -# that answers ssh on no address. -# --------------------------------------------------------------------------- -systemctl is-active --quiet docker || - fail "the docker service is not active: this host runs no broker" - -# --------------------------------------------------------------------------- -# 2. The container is running, and will come back on its own. PID 1 in it is -# `blitz-broker sync`, so the sync loop dying takes the whole container with -# it; without a restart policy the broker then simply disappears, and the -# next workspace to ask for a key finds nothing (production needed -# Restart=always on 2026-08-08 for exactly this). -# --------------------------------------------------------------------------- -state="$(docker inspect -f '{{.State.Running}} {{.HostConfig.RestartPolicy.Name}}' "${container}" 2>/dev/null)" || - fail "no container named ${container} on this host: nothing was provisioned" -read -r running restart_policy <<<"${state}" -[[ "${running}" == "true" ]] || - fail "container ${container} is not running: workspaces reach no broker" -case "${restart_policy}" in - always | unless-stopped) ;; - *) fail "container ${container} has restart policy '${restart_policy:-no}': the broker will not survive its first crash" ;; -esac - -# --------------------------------------------------------------------------- -# 3. sshd inside the container. entrypoint.sh starts it in the BACKGROUND and -# then execs the sync loop as PID 1, so a dead sshd leaves the container -# "running" and healthy-looking while it answers no connection at all. This -# is the same class of failure as the 2026-08-07 listener, and the container -# hides it better than systemd did. -# --------------------------------------------------------------------------- -docker exec "${container}" pgrep -x sshd >/dev/null 2>&1 || - fail "no sshd inside ${container}: the container is up and answers nothing" - -# --------------------------------------------------------------------------- -# 4. ... and that the published port is actually bound on the host. `running` is -# not proof — the failure this gate exists for is a listener that is up but -# bound to nothing reachable — so ask docker which port it published and then -# ask the kernel whether anything holds it. -# --------------------------------------------------------------------------- -published="$(docker port "${container}" 22/tcp 2>/dev/null)" || - fail "container ${container} publishes no port for 22/tcp: workspaces cannot dial it" -# Not a pipeline into head: see the SIGPIPE note in check 6. -first_binding="${published%%$'\n'*}" -published_port="${first_binding##*:}" -[[ "${published_port}" =~ ^[0-9]+$ ]] || - fail "cannot read the published broker port from '${first_binding}'" -if [[ -z "$(ss -H -ltn "sport = :${published_port}")" ]]; then - fail "nothing is listening on port ${published_port}; workspaces cannot reach this broker" -fi - -# --------------------------------------------------------------------------- -# 5. The sync loop, which is the whole point of the box: without it no member -# key ever lands in an authorized_keys file. In blitz-core it is PID 1 of the -# container rather than a systemd unit, so ask the container's process table -# instead of asking docker twice. -# --------------------------------------------------------------------------- -pid_one="$(docker exec "${container}" ps -p 1 -o args= 2>/dev/null)" || - fail "cannot read PID 1 in ${container}" -[[ "${pid_one}" == *"blitz-broker sync"* ]] || - fail "PID 1 in ${container} is '${pid_one}', not 'blitz-broker sync': this box will never pull a key" - -# --------------------------------------------------------------------------- -# 6. ... and PROOF that the control plane accepted this box, in two halves that -# are both required: -# -# a. the box credential exists on the state volume. store.SaveCredential -# writes it only after the device flow returned tokens, so its presence -# is the control plane having authenticated this box and issued it a -# credential. It is never read here — existence is the whole signal. -# b. `broker feed applied; members: N` appears in the last FEED_WINDOW_SECONDS -# of log. internal/broker/sync.go prints that line only after the plane -# answered a request carrying this box's bearer token and the box -# rendered the member list it got back, and re-prints it every 30 s for -# as long as the plane keeps answering. It is the equivalent of the -# `members: N (version V)` line production greps out of the journal. -# -# (b) is a POSITIVE requirement, not the absence of complaints. This gate -# used to pass on a quiet 10 s window, and quiet is what a box whose route to -# the control plane is blackholed looks like for the first ~31 s: the HTTP -# client's own timeout is 30 s, so the first failing poll has not logged yet. -# Such a box passed at t≈0. Requiring the line inside a window longer than -# that timeout cycle is what closes it — silence now fails, and the four -# failure markers still fail on their own so a box that IS complaining is -# named by its complaint rather than by a missing line. -# --------------------------------------------------------------------------- -state_dir="$(docker exec "${container}" sh -c 'if [ -z "${BLITZ_BROKER_STATE_DIR:-}" ]; then set -a; . /etc/blitz/env.defaults; set +a; fi; printf %s "${BLITZ_BROKER_STATE_DIR}"' 2>/dev/null)" || - fail "cannot resolve BLITZ_BROKER_STATE_DIR inside ${container}" -[[ -n "${state_dir}" ]] || - fail "${container} reports an empty BLITZ_BROKER_STATE_DIR" -docker exec "${container}" test -s "${state_dir}/box-credential.json" || - fail "${container} holds no box credential: 'blitz-broker enroll' was never run or never approved" - -synced="false" -for ((attempt = 1; attempt <= FEED_ATTEMPTS; attempt++)); do - # NOT a pipeline. `grep -q` exits on the first match and closes the pipe, the - # producer dies of SIGPIPE, and `set -o pipefail` turns a SUCCESSFUL match into - # a failed condition. It passes at provisioning time, when the log is short - # enough that the producer finishes first, and fails on every re-run of a box - # that has been up a while — observed on the production broker box on - # 2026-08-08, on a box that was healthy and logging every minute. Capture into - # a variable, then match. - recent="$(docker logs --since "${FEED_WINDOW_SECONDS}s" "${container}" 2>&1)" || - fail "cannot read the log of ${container}" - if [[ "${recent}" == *"broker feed applied; members: "* && - "${recent}" != *"broker feed unavailable"* && - "${recent}" != *"broker feed rejected"* && - "${recent}" != *"broker reconciliation incomplete"* && - "${recent}" != *"broker enrollment state is invalid"* ]]; then - synced="true" - break - fi - sleep "${FEED_POLL_SECONDS}" -done -[[ "${synced}" == "true" ]] || - fail "${container} did not log 'broker feed applied; members:' in a clean ${FEED_WINDOW_SECONDS}s window within $((FEED_ATTEMPTS * FEED_POLL_SECONDS)) seconds: it is not reaching the control plane with a credential the plane accepts" - -# --------------------------------------------------------------------------- -# 7. The authorized_keys directory sshd will actually read, cross-checked -# against sshd_config rather than assumed. `blitz-broker sync` renders member -# files into its own compiled-in directory; if sshd is configured to read a -# different one, every member is rejected and nothing anywhere says why. -# -# Ownership and mode are part of the same check, not decoration: sshd_config -# sets StrictModes yes, and for an AuthorizedKeysFile outside the member's -# home sshd refuses EVERY key unless the file and its parents are root-owned -# and not group- or world-writable. 0755 root:root is what entrypoint.sh -# creates and what RenderAuthorizedKeys re-asserts on each reconcile, so -# anything else means a hand edit that locks the whole box out. -# --------------------------------------------------------------------------- -authorized_keys_file="$(docker exec "${container}" awk '$1 == "AuthorizedKeysFile" { print $2; exit }' /etc/ssh/sshd_config 2>/dev/null)" || - fail "cannot read sshd_config inside ${container}" -[[ -n "${authorized_keys_file}" ]] || - fail "sshd_config in ${container} sets no AuthorizedKeysFile" -case "${authorized_keys_file}" in - /*/%u) ;; - *) fail "sshd_config AuthorizedKeysFile is '${authorized_keys_file}': the broker renders one root-owned file per member, so it must be an absolute /%u" ;; -esac -authorized_keys_dir="${authorized_keys_file%/%u}" -directory_state="$(docker exec "${container}" stat -c '%a %U %G' "${authorized_keys_dir}" 2>/dev/null)" || - fail "${authorized_keys_dir} does not exist in ${container}: sshd reads member keys from a directory nothing creates" -read -r directory_mode directory_owner directory_group <<<"${directory_state}" -[[ "${directory_owner}" == "root" && "${directory_group}" == "root" ]] || - fail "${authorized_keys_dir} is owned by ${directory_owner}:${directory_group}, expected root:root: StrictModes makes sshd reject every member key" -[[ "${directory_mode}" == "755" ]] || - fail "${authorized_keys_dir} is mode ${directory_mode}, expected 755: StrictModes makes sshd reject every member key under a group-writable path" - -# --------------------------------------------------------------------------- -# 8. The clock, on the host. The container has no clock of its own — it shares -# the host kernel's — so this reading covers both. -# -# Production's argument was expiry-time="YYYYMMDDHHMMSSZ" in authorized_keys, -# the last revocation left when sync cannot reach the control plane, which -# sshd evaluates against this clock. blitz-core does not render expiry-time -# (internal/broker/authorized_keys.go emits restrict,command= and the pubkey, -# nothing more; "no ceiling" is a written decision), so that specific argument -# does not apply yet. The check stays because a wrong clock still breaks TLS -# to the control plane and the OAuth access-token lifetime the daemon runs on -# — and here revocation IS the feed, so a broker that cannot fetch is a broker -# that cannot revoke. -# --------------------------------------------------------------------------- -timezone="$(timedatectl show --property=Timezone --value)" -if [[ "${timezone}" != "UTC" ]]; then - fail "host timezone is ${timezone}, expected UTC: every timestamp this broker reasons about is wrong" -fi -if [[ "$(timedatectl show --property=NTPSynchronized --value)" != "yes" ]]; then - fail "the clock is not NTP synchronised: this broker's TLS and token lifetimes are unreliable" -fi - -echo "verified: ${container} running on :${published_port}, sshd up, sync loop enrolled and applying the control-plane feed, ${authorized_keys_dir} root:root 0755, clock UTC/synced" diff --git a/packages/broker/entrypoint.sh b/packages/broker/entrypoint.sh deleted file mode 100644 index d22ac1fd..00000000 --- a/packages/broker/entrypoint.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash -set -euo pipefail - -if [[ -z ${BLITZ_BROKER_STATE_DIR+x} ]]; then - set -a - source /etc/blitz/env.defaults - set +a -fi -state_dir="$BLITZ_BROKER_STATE_DIR" -case "$state_dir" in - /*) ;; - *) echo "BLITZ_BROKER_STATE_DIR must be absolute" >&2; exit 1 ;; -esac -if [[ "$state_dir" == "/" ]]; then - echo "BLITZ_BROKER_STATE_DIR cannot be /" >&2 - exit 1 -fi - -install -d -m 0755 "$state_dir" -install -d -m 0755 "$state_dir/members" /run/sshd /etc/blitz-broker/authorized_keys - -hostkey_dir="$state_dir/ssh" -if [[ ! -e "$hostkey_dir" ]]; then - hostkey_tmp="$(mktemp -d "$state_dir/.ssh.XXXXXX")" - trap 'rm -rf -- "$hostkey_tmp"' EXIT - ssh-keygen -q -t ed25519 -N "" -f "$hostkey_tmp/ssh_host_ed25519_key" - chmod 0600 "$hostkey_tmp/ssh_host_ed25519_key" - chmod 0644 "$hostkey_tmp/ssh_host_ed25519_key.pub" - mv -T "$hostkey_tmp" "$hostkey_dir" - trap - EXIT -fi -if [[ ! -f "$hostkey_dir/ssh_host_ed25519_key" || ! -f "$hostkey_dir/ssh_host_ed25519_key.pub" ]]; then - echo "SSH host-key state is incomplete" >&2 - exit 1 -fi - -/usr/sbin/sshd -f /etc/ssh/sshd_config -h "$hostkey_dir/ssh_host_ed25519_key" -exec /usr/local/bin/blitz-broker sync diff --git a/packages/broker/go.mod b/packages/broker/go.mod deleted file mode 100644 index 9aea278a..00000000 --- a/packages/broker/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/blitzdotdev/blitz-core/broker - -go 1.26.0 diff --git a/packages/broker/internal/broker/accounts.go b/packages/broker/internal/broker/accounts.go deleted file mode 100644 index dfe0bca3..00000000 --- a/packages/broker/internal/broker/accounts.go +++ /dev/null @@ -1,147 +0,0 @@ -package broker - -import ( - "errors" - "fmt" - "io" - "os" - "os/exec" - "os/user" - "path/filepath" - "strconv" -) - -type Accounts interface { - Ensure(string, string) error - Deprovision(string, string) error -} - -type SystemAccounts struct { - lookupUser func(string) (*user.User, error) - lookupGroup func(string) (*user.Group, error) - run func(string, ...string) error - lchown func(string, int, int) error -} - -func (accounts SystemAccounts) Ensure(name, home string) error { - lookupUser := accounts.lookupUser - if lookupUser == nil { - lookupUser = user.Lookup - } - lookupGroup := accounts.lookupGroup - if lookupGroup == nil { - lookupGroup = user.LookupGroup - } - run := accounts.run - if run == nil { - run = runSystem - } - lchown := accounts.lchown - if lchown == nil { - lchown = os.Lchown - } - - account, err := lookupUser(name) - if errors.Is(err, user.UnknownUserError(name)) { - args := []string{"--create-home", "--home-dir", home, "--shell", "/bin/sh"} - expectedGID := "" - group, groupErr := lookupGroup(name) - switch { - case groupErr == nil: - expectedGID = group.Gid - gid, parseErr := strconv.Atoi(expectedGID) - if parseErr != nil || gid < 0 { - return fmt.Errorf("managed group %s has invalid GID", name) - } - args = append(args, "--gid", expectedGID) - case errors.Is(groupErr, user.UnknownGroupError(name)): - default: - return groupErr - } - args = append(args, "--", name) - if err := run("useradd", args...); err != nil { - return err - } - account, err = lookupUser(name) - if err == nil && expectedGID != "" && account.Gid != expectedGID { - return fmt.Errorf("managed user %s has unexpected primary group", name) - } - } - if err != nil { - return err - } - if account.HomeDir != home { - return fmt.Errorf("managed user %s has unexpected home", name) - } - uid, err := strconv.Atoi(account.Uid) - if err != nil { - return err - } - gid, err := strconv.Atoi(account.Gid) - if err != nil { - return err - } - if err := os.MkdirAll(home, 0o700); err != nil { - return err - } - if err := os.Chmod(home, 0o700); err != nil { - return err - } - return filepath.WalkDir(home, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - return lchown(path, uid, gid) - }) -} - -func (SystemAccounts) Deprovision(name, home string) error { - if err := runAllowNoMatch("pkill", "-KILL", "-u", name); err != nil { - return err - } - if err := processCheck(name); err != nil { - return err - } - if _, err := user.Lookup(name); err == nil { - if err := runSystem("userdel", "--remove", "--", name); err != nil { - return err - } - } else if !errors.Is(err, user.UnknownUserError(name)) { - return err - } - return os.RemoveAll(home) -} - -func processCheck(name string) error { - cmd := exec.Command("pkill", "-0", "-u", name) - cmd.Stdout = io.Discard - cmd.Stderr = io.Discard - err := cmd.Run() - if err == nil { - return fmt.Errorf("processes remain for %s", name) - } - var exit *exec.ExitError - if errors.As(err, &exit) && exit.ExitCode() == 1 { - return nil - } - return errors.New("could not verify member processes stopped") -} - -func runAllowNoMatch(command string, args ...string) error { - err := runSystem(command, args...) - var exit *exec.ExitError - if errors.As(err, &exit) && exit.ExitCode() == 1 { - return nil - } - return err -} - -func runSystem(command string, args ...string) error { - cmd := exec.Command(command, args...) - cmd.Stdout = io.Discard - cmd.Stderr = io.Discard - if err := cmd.Run(); err != nil { - return fmt.Errorf("%s failed: %w", command, err) - } - return nil -} diff --git a/packages/broker/internal/broker/accounts_test.go b/packages/broker/internal/broker/accounts_test.go deleted file mode 100644 index 6d68e894..00000000 --- a/packages/broker/internal/broker/accounts_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package broker - -import ( - "fmt" - "os/user" - "path/filepath" - "reflect" - "testing" -) - -type fakeAccountProvisioner struct { - users map[string]*user.User - groups map[string]*user.Group - commands [][]string - chownUIDGID [][2]int -} - -func (fake *fakeAccountProvisioner) lookupUser(name string) (*user.User, error) { - if account, ok := fake.users[name]; ok { - copy := *account - return ©, nil - } - return nil, user.UnknownUserError(name) -} - -func (fake *fakeAccountProvisioner) lookupGroup(name string) (*user.Group, error) { - if group, ok := fake.groups[name]; ok { - copy := *group - return ©, nil - } - return nil, user.UnknownGroupError(name) -} - -func (fake *fakeAccountProvisioner) run(command string, args ...string) error { - fake.commands = append(fake.commands, append([]string{command}, args...)) - if command != "useradd" { - return fmt.Errorf("unexpected command %q", command) - } - name := args[len(args)-1] - home := flagValue(args, "--home-dir") - gid := flagValue(args, "--gid") - if gid == "" { - gid = "2001" - fake.groups[name] = &user.Group{Name: name, Gid: gid} - } - fake.users[name] = &user.User{Username: name, Uid: "1001", Gid: gid, HomeDir: home} - return nil -} - -func (fake *fakeAccountProvisioner) lchown(_ string, uid, gid int) error { - fake.chownUIDGID = append(fake.chownUIDGID, [2]int{uid, gid}) - return nil -} - -func flagValue(args []string, flag string) string { - for index := 0; index+1 < len(args); index++ { - if args[index] == flag { - return args[index+1] - } - } - return "" -} - -func TestSystemAccountsEnsure(t *testing.T) { - for _, test := range []struct { - name string - unixName string - existingGID string - existingUser bool - reruns int - wantCommand []string - wantUIDGID [2]int - }{ - { - name: "fresh_name", - unixName: "alice", - reruns: 1, - wantCommand: []string{"useradd", "--create-home", "--home-dir", "HOME", "--shell", "/bin/sh", "--", "alice"}, - wantUIDGID: [2]int{1001, 2001}, - }, - { - name: "existing_group", - unixName: "operator", - existingGID: "37", - reruns: 1, - wantCommand: []string{"useradd", "--create-home", "--home-dir", "HOME", "--shell", "/bin/sh", "--gid", "37", "--", "operator"}, - wantUIDGID: [2]int{1001, 37}, - }, - { - name: "existing_user_idempotent_rerun", - unixName: "bob", - existingUser: true, - reruns: 2, - wantUIDGID: [2]int{3001, 3002}, - }, - } { - t.Run(test.name, func(t *testing.T) { - home := filepath.Join(t.TempDir(), test.unixName) - fake := &fakeAccountProvisioner{ - users: make(map[string]*user.User), - groups: make(map[string]*user.Group), - } - if test.existingGID != "" { - fake.groups[test.unixName] = &user.Group{Name: test.unixName, Gid: test.existingGID} - } - if test.existingUser { - fake.users[test.unixName] = &user.User{ - Username: test.unixName, - Uid: "3001", - Gid: "3002", - HomeDir: home, - } - } - accounts := SystemAccounts{ - lookupUser: fake.lookupUser, - lookupGroup: fake.lookupGroup, - run: fake.run, - lchown: fake.lchown, - } - for range test.reruns { - if err := accounts.Ensure(test.unixName, home); err != nil { - t.Fatal(err) - } - } - - wantCommands := [][]string(nil) - if test.wantCommand != nil { - command := append([]string(nil), test.wantCommand...) - command[3] = home - wantCommands = [][]string{command} - } - if !reflect.DeepEqual(fake.commands, wantCommands) { - t.Fatalf("system commands = %#v; want %#v", fake.commands, wantCommands) - } - if len(fake.chownUIDGID) == 0 || fake.chownUIDGID[len(fake.chownUIDGID)-1] != test.wantUIDGID { - t.Fatalf("last chown uid/gid = %#v; want %#v", fake.chownUIDGID, test.wantUIDGID) - } - }) - } -} diff --git a/packages/broker/internal/broker/authorized_keys.go b/packages/broker/internal/broker/authorized_keys.go deleted file mode 100644 index a73f3e26..00000000 --- a/packages/broker/internal/broker/authorized_keys.go +++ /dev/null @@ -1,62 +0,0 @@ -package broker - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/blitzdotdev/blitz-core/broker/internal/atomicfile" -) - -const brokerBinary = "/usr/local/bin/blitz-broker" - -func RenderAuthorizedKeys(dir string, member Member) (string, error) { - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", err - } - if err := os.Chmod(dir, 0o755); err != nil { - return "", err - } - if os.Geteuid() == 0 { - if err := os.Chown(dir, 0, 0); err != nil { - return "", err - } - } - path := filepath.Join(dir, member.UnixName) - // Root-owned BEFORE the rename, not after. sshd refuses to read an - // authorized_keys file it does not trust the ownership of, and a window in - // which the real path is live under the wrong owner is a window in which a - // member either cannot log in or — worse, if the directory were ever - // member-writable — could rewrite their own forced command. - owner := -1 - if os.Geteuid() == 0 { - owner = 0 - } - if err := atomicfile.WriteOwned(path, authorizedKeys(member), 0o644, owner, owner); err != nil { - return "", err - } - return path, nil -} - -func authorizedKeys(member Member) []byte { - harnesses := append([]string(nil), member.Harnesses...) - sort.Strings(harnesses) - allowlist := strings.Join(harnesses, ",") - if allowlist == "" { - allowlist = "-" - } - keys := append([]Key(nil), member.Keys...) - sort.Slice(keys, func(i, j int) bool { - if keys[i].Op == keys[j].Op { - return keys[i].Pubkey < keys[j].Pubkey - } - return keys[i].Op < keys[j].Op - }) - var rendered strings.Builder - for _, key := range keys { - fmt.Fprintf(&rendered, "restrict,command=\"%s %s %s %s\" %s\n", brokerBinary, key.Op, member.UnixName, allowlist, key.Pubkey) - } - return []byte(rendered.String()) -} diff --git a/packages/broker/internal/broker/credential.go b/packages/broker/internal/broker/credential.go deleted file mode 100644 index 91691d89..00000000 --- a/packages/broker/internal/broker/credential.go +++ /dev/null @@ -1,23 +0,0 @@ -package broker - -import ( - "errors" - "io" - "os" -) - -func readCredential(path string) ([]byte, error) { - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - data, err := io.ReadAll(io.LimitReader(file, FeedMaxBytes+1)) - if err != nil { - return nil, err - } - if len(data) > FeedMaxBytes { - return nil, errors.New("credential exceeds 1 MiB") - } - return data, nil -} diff --git a/packages/broker/internal/broker/deposit.go b/packages/broker/internal/broker/deposit.go deleted file mode 100644 index a7bb43fc..00000000 --- a/packages/broker/internal/broker/deposit.go +++ /dev/null @@ -1,104 +0,0 @@ -package broker - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "time" - - "github.com/blitzdotdev/blitz-core/broker/internal/atomicfile" - "github.com/blitzdotdev/blitz-core/broker/internal/vendor" -) - -// Deposit stages an incoming credential, verifies it under a staging HOME, -// stores the result, and only then lets the caller ACK. -// -// EVERY failure path returns before the stored credential is touched. That is -// the property the whole verb is built around: the workspace deletes its own -// copy on ACK, so a deposit that half-succeeded would destroy the credential -// from both ends at once. -// -// Cross-account replacement is allowed and is NOT recorded. Deposit is verify, -// store, ACK, and nothing else (packages/broker/RECORD.md, founder 2026-08-11). -// The log used to be the last write on the success path, which put an -// unbounded append between a stored credential and the ACK the workspace waits -// for: a full disk or a bad mode turned a deposit that had already succeeded -// into a failure, the watcher kept its copy, and it re-deposited every second -// — a real vendor round trip per tick, forever. -func Deposit(ctx context.Context, home string, definition vendor.Definition, input io.Reader, runner vendor.Runner) error { - blob, err := io.ReadAll(io.LimitReader(input, FeedMaxBytes+1)) - if err != nil { - return errors.New("could not read deposited credential") - } - if len(blob) > FeedMaxBytes { - return errors.New("deposited credential exceeds 1 MiB") - } - if runner == nil { - runner = vendor.Run - } - return withMemberLock(ctx, home, func() error { - stage, err := os.MkdirTemp(home, ".deposit-*") - if err != nil { - return err - } - defer os.RemoveAll(stage) - stagedPath := filepath.Join(stage, filepath.FromSlash(definition.CredentialPath)) - if err := os.MkdirAll(filepath.Dir(stagedPath), 0o700); err != nil { - return err - } - if err := atomicfile.Write(stagedPath, blob, 0o600); err != nil { - return err - } - - // Refuse a blob that announces its own death before spending a vendor - // round trip on it. Verification proves the ACCESS token works right - // now and says nothing about the refresh chain behind it, so a - // credential with a live access token and a dead refresh token would - // verify, replace the working one, and fail at the next refresh — days - // later, with no copy left anywhere. - if definition.ReadRefreshExpiry != nil { - refreshExpiry, err := definition.ReadRefreshExpiry(blob) - if err != nil { - return fmt.Errorf("the incoming credential is unusable; stored credential unchanged: %w", err) - } - if !refreshExpiry.IsZero() && !refreshExpiry.After(time.Now()) { - return errors.New("the incoming credential's refresh token has already expired; stored credential unchanged") - } - } - - // HOME points at the staging tree, so the vendor CLI rotates the - // STAGED copy and never reaches the stored one. A failure here returns - // before any write to the stored path. - if err := runner(ctx, definition.Command, definition.VerifyArgs, stage); err != nil { - return fmt.Errorf("verification failed; stored credential unchanged: %w", err) - } - verified, err := readCredential(stagedPath) - if err != nil { - return errors.New("verification left no usable credential; stored credential unchanged") - } - if len(verified) == 0 { - return errors.New("verification blanked the credential; stored credential unchanged") - } - // Read it back the way Mint will. A blob that verified but that Mint - // cannot parse would fail closed on every later mint; refuse it now, - // while the working credential is still on disk. - _, expiry, err := definition.ReadToken(verified) - if err != nil || !expiry.After(time.Now()) { - return errors.New("verification did not produce a valid credential; stored credential unchanged") - } - - target := filepath.Join(home, filepath.FromSlash(definition.CredentialPath)) - if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { - return err - } - if err := os.Chmod(filepath.Dir(target), 0o700); err != nil { - return err - } - // The last write on the success path, so the ACK the caller sends next - // means exactly "the stored credential is the one you handed me". - return atomicfile.Write(target, verified, 0o600) - }) -} diff --git a/packages/broker/internal/broker/lock.go b/packages/broker/internal/broker/lock.go deleted file mode 100644 index 5a7cd892..00000000 --- a/packages/broker/internal/broker/lock.go +++ /dev/null @@ -1,80 +0,0 @@ -package broker - -import ( - "context" - "errors" - "os" - "path/filepath" - "syscall" - "time" - - "github.com/blitzdotdev/blitz-core/broker/internal/vendor" -) - -// LockWait bounds how long a mint or a deposit queues behind another operation -// on the same member. -// -// INVARIANT: LockWait MUST stay greater than vendor.TriggerTimeout. The -// holder's critical section is at most one vendor run plus two small file -// reads, and the longest vendor run is a refresh. A waiter that gave up sooner -// than the holder's own deadline would report "busy" for a refresh that was -// about to succeed, and its retry would arrive to find the same lock still -// held — a member with an expired token would then never mint. Raise -// vendor.TriggerTimeout and you MUST raise this with it; the slack below is -// for the file reads on either side of the vendor run, nothing more. -const LockWait = vendor.TriggerTimeout + 15*time.Second - -// Compile-time proof of the invariant above. If LockWait ever stops exceeding -// vendor.TriggerTimeout this is a negative constant, the conversion overflows, -// and the package does not build. There is no runtime path to check on. -const _ uint64 = uint64(LockWait - vendor.TriggerTimeout) - -// ErrLockBusy is reported to the caller as a clear, retryable busy exit — -// never as a silent success, and never as a credential operation that skipped -// the lock. -var ErrLockBusy = errors.New("another operation for this member holds the credential lock") - -// lockWait is what the loop actually waits, so a test does not have to sit -// through a real 75 seconds to prove the busy path. The constant above is the -// contract; this is the knob. -var lockWait = LockWait - -// withMemberLock serialises every operation that touches a member's credential -// directory. Two vendor CLI processes that both read an expired credential -// would POST the same single-use refresh token, and the loser would blank the -// file in place — on a box that holds the only copy. -// -// The lock is held across read, trigger AND re-read, not just the write. The -// window that matters is the whole refresh, not the moment of storing. -// -// flock(2) locks attach to the open file description, so two opens contend -// whether they come from two processes (sshd forks one per connection) or two -// goroutines. -func withMemberLock(ctx context.Context, home string, fn func() error) error { - lock, err := os.OpenFile(filepath.Join(home, ".blitz-credential.lock"), os.O_CREATE|os.O_RDWR, 0o600) - if err != nil { - return err - } - defer lock.Close() - deadline := time.NewTimer(lockWait) - defer deadline.Stop() - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - for { - err = syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) - if err == nil { - defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) - return fn() - } - if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) { - return err - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-deadline.C: - return ErrLockBusy - case <-ticker.C: - } - } -} diff --git a/packages/broker/internal/broker/mint.go b/packages/broker/internal/broker/mint.go deleted file mode 100644 index 148e08ac..00000000 --- a/packages/broker/internal/broker/mint.go +++ /dev/null @@ -1,73 +0,0 @@ -package broker - -import ( - "context" - "errors" - "path/filepath" - "strings" - "time" - - "github.com/blitzdotdev/blitz-core/broker/internal/vendor" -) - -const refreshWindow = 5 * time.Minute - -func Mint(ctx context.Context, home string, allowed []string, requested string, definition vendor.Definition, runner vendor.Runner) (string, error) { - if !contains(allowed, requested) || requested != definition.Name { - return "", errors.New("requested harness is not allowed") - } - if runner == nil { - runner = vendor.Run - } - var token string - err := withMemberLock(ctx, home, func() error { - path := filepath.Join(home, filepath.FromSlash(definition.CredentialPath)) - data, err := readCredential(path) - if err != nil { - return errors.New("vendor credential is unavailable") - } - current, expiry, err := definition.ReadToken(data) - if err != nil { - return err - } - if !expiry.After(time.Now().Add(refreshWindow)) { - if err := runner(ctx, definition.Command, definition.RefreshArgs, home); err != nil { - return err - } - data, err = readCredential(path) - if err != nil { - return errors.New("refreshed vendor credential is unavailable") - } - current, expiry, err = definition.ReadToken(data) - if err != nil { - return err - } - } - if !expiry.After(time.Now()) || current == "" { - return errors.New("vendor CLI did not produce a valid access token") - } - // A token is what the vendor CLI put in the credential file, and this - // process does not get to clean it up: the mint reply is one line, so - // whitespace inside the token is indistinguishable from the terminator - // and every consumer would silently disagree about where the token ends. - // The shim strips it, a direct env write does not, and the vendor rejects - // an Authorization header carrying a newline. Refusing here fails the mint - // loudly, at the one place that can still name the harness, instead of - // handing out a token that half the box will corrupt. - if current != strings.TrimSpace(current) { - return errors.New("vendor CLI produced an access token carrying whitespace") - } - token = current - return nil - }) - return token, err -} - -func contains(values []string, value string) bool { - for _, candidate := range values { - if candidate == value { - return true - } - } - return false -} diff --git a/packages/broker/internal/broker/reconcile.go b/packages/broker/internal/broker/reconcile.go deleted file mode 100644 index e735d7b4..00000000 --- a/packages/broker/internal/broker/reconcile.go +++ /dev/null @@ -1,76 +0,0 @@ -package broker - -import ( - "errors" - "os" - "path/filepath" - - "github.com/blitzdotdev/blitz-core/broker/internal/feed" -) - -type Reconciler struct { - StateDir string - AuthorizedKeysDir string - Accounts Accounts -} - -func (r Reconciler) Reconcile(current Feed) error { - if r.Accounts == nil { - r.Accounts = SystemAccounts{} - } - if err := os.MkdirAll(filepath.Join(r.StateDir, "members"), 0o755); err != nil { - return err - } - managed, err := r.managedNames() - if err != nil { - return err - } - wanted := make(map[string]bool) - var failures []error - for _, member := range current.Members { - wanted[member.UnixName] = true - home := filepath.Join(r.StateDir, "members", member.UnixName) - if err := r.Accounts.Ensure(member.UnixName, home); err != nil { - failures = append(failures, err) - continue - } - if _, err := RenderAuthorizedKeys(r.AuthorizedKeysDir, member); err != nil { - failures = append(failures, err) - } - } - for name := range managed { - if wanted[name] || current.Preserve[name] || !feed.ValidUnixName(name) { - continue - } - if _, err := RenderAuthorizedKeys(r.AuthorizedKeysDir, Member{UnixName: name, Harnesses: []string{}, Keys: []Key{}}); err != nil { - failures = append(failures, err) - continue - } - home := filepath.Join(r.StateDir, "members", name) - if err := r.Accounts.Deprovision(name, home); err != nil { - failures = append(failures, err) - continue - } - if err := os.Remove(filepath.Join(r.AuthorizedKeysDir, name)); err != nil && !errors.Is(err, os.ErrNotExist) { - failures = append(failures, err) - } - } - return errors.Join(failures...) -} - -func (r Reconciler) managedNames() (map[string]bool, error) { - result := make(map[string]bool) - for _, dir := range []string{filepath.Join(r.StateDir, "members"), r.AuthorizedKeysDir} { - entries, err := os.ReadDir(dir) - if errors.Is(err, os.ErrNotExist) { - continue - } - if err != nil { - return nil, err - } - for _, entry := range entries { - result[entry.Name()] = true - } - } - return result, nil -} diff --git a/packages/broker/internal/broker/roaming_test.go b/packages/broker/internal/broker/roaming_test.go deleted file mode 100644 index 9ce06dde..00000000 --- a/packages/broker/internal/broker/roaming_test.go +++ /dev/null @@ -1,329 +0,0 @@ -package broker - -import ( - "context" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "github.com/blitzdotdev/blitz-core/broker/internal/feed" - "github.com/blitzdotdev/blitz-core/broker/internal/vendor" -) - -const liveClaudeCredential = `{"claudeAiOauth":{"accessToken":"live","refreshToken":"refresh","expiresAt":4102444800000}}` - -// TestLockWaitOutlastsTheVendorTrigger is the runtime half of the invariant -// lock.go proves at compile time. Both halves are cheap and neither subsumes -// the other: the constant assertion cannot survive someone turning LockWait -// into a var, and this cannot survive being deleted. -// -// If a waiter gave up sooner than the holder's own deadline it would report -// busy for a refresh that was about to succeed, and its retry would find the -// same lock still held — a member with an expired token would then never mint. -func TestLockWaitOutlastsTheVendorTrigger(t *testing.T) { - if LockWait <= vendor.TriggerTimeout { - t.Fatalf("LockWait %s must exceed vendor.TriggerTimeout %s", LockWait, vendor.TriggerTimeout) - } -} - -// TestMemberLockRefusesAConcurrentOperationAsBusy proves the second caller is -// told it is busy rather than being let through to touch the same credential, -// and that it is told in a form callers can retry on. -func TestMemberLockRefusesAConcurrentOperationAsBusy(t *testing.T) { - home := t.TempDir() - previous := lockWait - lockWait = 50 * time.Millisecond - defer func() { lockWait = previous }() - - held := make(chan struct{}) - release := make(chan struct{}) - var wait sync.WaitGroup - wait.Add(1) - go func() { - defer wait.Done() - _ = withMemberLock(context.Background(), home, func() error { - close(held) - <-release - return nil - }) - }() - <-held - - err := withMemberLock(context.Background(), home, func() error { - t.Error("the second operation entered the critical section") - return nil - }) - close(release) - wait.Wait() - - if err == nil { - t.Fatal("a contended lock reported success") - } - if !strings.Contains(err.Error(), "lock") { - t.Fatalf("busy error = %v, want it to name the lock", err) - } -} - -// TestMintKilledMidRefreshLeavesTheStoredCredentialIntact is the 2026-08-07 -// incident, as a test. A vendor CLI that is rewriting the only copy of a -// credential when its deadline lands gets killed mid-write; what must NOT -// happen is that the member is left holding a blank file. -// -// The fake vendor sleeps past the caller's deadline, so exec kills it exactly -// where the incident killed the real one. -func TestMintKilledMidRefreshLeavesTheStoredCredentialIntact(t *testing.T) { - home := t.TempDir() - path := filepath.Join(home, filepath.FromSlash(vendor.Claude.CredentialPath)) - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatal(err) - } - // Expired, so Mint runs the refresh path rather than serving the file. - expired := []byte(`{"claudeAiOauth":{"accessToken":"expired","refreshToken":"refresh","expiresAt":1}}`) - if err := os.WriteFile(path, expired, 0o600); err != nil { - t.Fatal(err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) - defer cancel() - slept := make(chan struct{}) - _, err := Mint(ctx, home, []string{"claude"}, "claude", vendor.Claude, - func(runContext context.Context, _ string, _ []string, _ string) error { - close(slept) - <-runContext.Done() - return runContext.Err() - }) - <-slept - if err == nil { - t.Fatal("Mint returned a token after its vendor run was killed") - } - - got, readErr := os.ReadFile(path) - if readErr != nil { - t.Fatalf("the stored credential is gone: %v", readErr) - } - if string(got) != string(expired) { - t.Fatalf("stored credential changed after a killed refresh: %q", got) - } -} - -// TestMintRefusesAnAccessTokenCarryingWhitespace covers the one thing the mint -// wire format cannot express. The reply is a single line — main.go writes the -// token with fmt.Fprintln — and every consumer down the chain copies the bytes -// into CLAUDE_CODE_OAUTH_TOKEN verbatim, so a token that carries whitespace of -// its own is indistinguishable from the terminator and two consumers disagree -// about where it ends: the PATH shim's $(...) eats it, a caller that sets the -// variable directly keeps it, and the vendor rejects an Authorization header -// holding a newline. Failing the mint is the only answer that names the harness. -func TestMintRefusesAnAccessTokenCarryingWhitespace(t *testing.T) { - home := t.TempDir() - path := filepath.Join(home, filepath.FromSlash(vendor.Claude.CredentialPath)) - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatal(err) - } - // A live, far-from-expiry credential, so no refresh runs and the token that - // comes back is exactly what the vendor CLI wrote. - dirty := `{"claudeAiOauth":{"accessToken":"sk-ant-oat01-live\n","refreshToken":"refresh","expiresAt":4102444800000}}` - if err := os.WriteFile(path, []byte(dirty), 0o600); err != nil { - t.Fatal(err) - } - - token, err := Mint(context.Background(), home, []string{"claude"}, "claude", vendor.Claude, - func(context.Context, string, []string, string) error { - t.Error("a live credential was sent to the vendor CLI for a refresh") - return nil - }) - if err == nil { - t.Fatalf("Mint returned a token carrying whitespace: %q", token) - } - if token != "" { - t.Errorf("Mint refused the token and returned it anyway: %q", token) - } -} - -// TestFeedHeartbeatRestatesOnACadence pins the decision the provisioning gate -// reads. The positive line used to be printed only when the feed CHANGED, so -// after the first apply the ETag made every later poll `unchanged` and the line -// never came back — a windowed gate on a box that had been up a while would -// find nothing and would have to treat silence as success, which is also what a -// blackholed box produces. Sync refuses to run as non-root, so the cadence is -// only testable through this helper. -func TestFeedHeartbeatRestatesOnACadence(t *testing.T) { - start := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) - if !feedHeartbeatDue(time.Time{}, start) { - t.Error("the first answered poll did not state the line") - } - if feedHeartbeatDue(start, start.Add(feedHeartbeatInterval-time.Second)) { - t.Error("the line was restated before the interval elapsed") - } - if !feedHeartbeatDue(start, start.Add(feedHeartbeatInterval)) { - t.Error("the line was not restated once the interval elapsed") - } - // The gate reads a 45 s window (packages/broker/deploy/verify-broker-box.sh) - // and requires the line inside it, so a cadence at or above that window - // would fail a box that is working. - if feedHeartbeatInterval >= 45*time.Second { - t.Fatalf("feedHeartbeatInterval %s does not fit inside the gate's window", feedHeartbeatInterval) - } -} - -// TestDepositRefusesACredentialWhoseRefreshTokenIsAlreadyDead closes the gap -// verification cannot: a live access token proves nothing about the refresh -// chain behind it, and storing a dead one replaces a working credential with -// one that fails days later, on the only copy. -func TestDepositRefusesACredentialWhoseRefreshTokenIsAlreadyDead(t *testing.T) { - home := t.TempDir() - path := filepath.Join(home, filepath.FromSlash(vendor.Claude.CredentialPath)) - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(liveClaudeCredential), 0o600); err != nil { - t.Fatal(err) - } - - dead := `{"claudeAiOauth":{"accessToken":"new","refreshToken":"spent","expiresAt":4102444800000,"refreshTokenExpiresAt":1}}` - ran := false - err := Deposit(context.Background(), home, vendor.Claude, strings.NewReader(dead), - func(context.Context, string, []string, string) error { - ran = true - return nil - }) - if err == nil { - t.Fatal("Deposit stored a credential whose refresh token had already expired") - } - if ran { - t.Error("the vendor CLI was run for a credential that was refused on its face") - } - got, readErr := os.ReadFile(path) - if readErr != nil { - t.Fatal(readErr) - } - if string(got) != liveClaudeCredential { - t.Fatalf("stored credential changed after a refused deposit: %q", got) - } -} - -// TestDepositReplacesAcrossAccountsAndWritesNothingElse pins the whole of what -// a successful deposit leaves behind: the replacement credential, and nothing -// else in the member's home. -// -// The "nothing else" half is the contract, not tidiness. An extra write after -// the stored credential is already replaced sits between success and the ACK -// the workspace is waiting for, and a failure there makes the workspace keep -// its copy and re-deposit on the next tick — a vendor round trip a second, for -// a deposit that had already succeeded. -func TestDepositReplacesAcrossAccountsAndWritesNothingElse(t *testing.T) { - home := t.TempDir() - path := filepath.Join(home, filepath.FromSlash(vendor.Claude.CredentialPath)) - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(liveClaudeCredential), 0o600); err != nil { - t.Fatal(err) - } - - replacement := `{"claudeAiOauth":{"accessToken":"other-account","refreshToken":"other","expiresAt":4102444800000}}` - if err := Deposit(context.Background(), home, vendor.Claude, strings.NewReader(replacement), - func(context.Context, string, []string, string) error { return nil }); err != nil { - t.Fatal(err) - } - - stored, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(stored) != replacement { - t.Fatalf("stored credential = %q, want the deposited one", stored) - } - - entries, err := os.ReadDir(home) - if err != nil { - t.Fatal(err) - } - for _, entry := range entries { - // The credential directory and the per-member lock are the whole - // expected surface; the staging directory is removed on the way out. - if entry.Name() == ".claude" || entry.Name() == ".blitz-credential.lock" { - continue - } - t.Errorf("deposit left %q in the member's home", entry.Name()) - } -} - -// TestUnixNameGateRefusesEveryNameOutsideTheDerivedShape covers the CREATE -// half. The name reaches useradd argv and becomes a path, so anything the -// control plane did not derive must not get through — the shared `blitz` -// login most of all, because it would put every member's only credential copy -// in one home. -func TestUnixNameGateRefusesEveryNameOutsideTheDerivedShape(t *testing.T) { - for _, name := range []string{ - "m-0123456789ab", - "m-ffffffffffff", - } { - if !feed.ValidUnixName(name) { - t.Errorf("derived name %q was refused", name) - } - } - for _, name := range []string{ - "blitz", "root", "operator", "alice", - "m-0123456789a", "m-0123456789abc", "m-0123456789ag", - "M-0123456789AB", "../root", "m-", "", "m-0123456789ab ", - } { - if feed.ValidUnixName(name) { - t.Errorf("name %q got past the gate", name) - } - } -} - -// TestReconcileNeverDeletesAHomeItDidNotCreate covers the DELETE half — the -// half that runs as root and removes directories. The sweep is gated on the -// SAME pattern as creation, so an account the broker never made can never be -// swept, whatever the feed says. -func TestReconcileNeverDeletesAHomeItDidNotCreate(t *testing.T) { - stateDir := t.TempDir() - members := filepath.Join(stateDir, "members") - if err := os.MkdirAll(members, 0o755); err != nil { - t.Fatal(err) - } - unmanaged := []string{"blitz", "root", "operator"} - stale := "m-dddddddddddd" - for _, name := range append(append([]string{}, unmanaged...), stale) { - if err := os.MkdirAll(filepath.Join(members, name), 0o700); err != nil { - t.Fatal(err) - } - } - - accounts := &recordingAccounts{} - reconciler := Reconciler{ - StateDir: stateDir, - AuthorizedKeysDir: filepath.Join(t.TempDir(), "authorized_keys"), - Accounts: accounts, - } - // An empty feed: every existing home is now unwanted. - if err := reconciler.Reconcile(Feed{Version: "v", Preserve: map[string]bool{}}); err != nil { - t.Fatal(err) - } - - if len(accounts.deprovisioned) != 1 || accounts.deprovisioned[0] != stale { - t.Fatalf("deprovisioned = %#v, want only %q", accounts.deprovisioned, stale) - } - for _, name := range unmanaged { - if _, err := os.Stat(filepath.Join(members, name)); err != nil { - t.Errorf("unmanaged home %q was swept: %v", name, err) - } - } -} - -type recordingAccounts struct { - deprovisioned []string -} - -func (accounts *recordingAccounts) Ensure(string, string) error { return nil } - -func (accounts *recordingAccounts) Deprovision(name, home string) error { - accounts.deprovisioned = append(accounts.deprovisioned, name) - return os.RemoveAll(home) -} - diff --git a/packages/broker/internal/broker/security_test.go b/packages/broker/internal/broker/security_test.go deleted file mode 100644 index 85df614b..00000000 --- a/packages/broker/internal/broker/security_test.go +++ /dev/null @@ -1,193 +0,0 @@ -package broker - -import ( - "context" - "errors" - "io" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "testing" - - "github.com/blitzdotdev/blitz-core/broker/internal/vendor" -) - -func TestDepositVerifyFailureLeavesCredentialUntouched(t *testing.T) { - home := t.TempDir() - definition := vendor.Claude - path := filepath.Join(home, filepath.FromSlash(definition.CredentialPath)) - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatal(err) - } - old := []byte(`{"claudeAiOauth":{"accessToken":"old","refreshToken":"old-refresh","expiresAt":4102444800000}}`) - if err := os.WriteFile(path, old, 0o600); err != nil { - t.Fatal(err) - } - - err := Deposit(context.Background(), home, definition, strings.NewReader(`{"claudeAiOauth":{"accessToken":"new"}}`), func(context.Context, string, []string, string) error { - return errors.New("verification failed") - }) - if err == nil { - t.Fatal("Deposit succeeded after vendor verification failed") - } - got, readErr := os.ReadFile(path) - if readErr != nil { - t.Fatal(readErr) - } - if string(got) != string(old) { - t.Fatalf("credential changed after failed verification: %q", got) - } -} - -func TestDecodeFeedRejectsPoisonedEntriesWithoutDroppingValidEntry(t *testing.T) { - body := `{ - "version":"opaque", - "members":[ - {"unixName":"m-aaaaaaaaaaaa","harnesses":["claude"],"keys":[]}, - {"unixName":"../root","harnesses":["claude"],"keys":[]}, - {"unixName":"blitz","harnesses":["claude"],"keys":[]}, - {"unixName":"m-bbbbbbbbbbbb","harnesses":["unknown"],"keys":[]} - ] -}` - feed, err := DecodeFeed(strings.NewReader(body)) - if err != nil { - t.Fatal(err) - } - if len(feed.Members) != 1 || feed.Members[0].UnixName != "m-aaaaaaaaaaaa" { - t.Fatalf("valid members = %#v, want only m-aaaaaaaaaaaa", feed.Members) - } - if feed.Rejected != 3 { - t.Fatalf("rejected = %d, want 3", feed.Rejected) - } - if !feed.Preserve["m-bbbbbbbbbbbb"] { - t.Fatal("existing member state would be removed because of a poisoned harness") - } -} - -func TestDecodeFeedRequiresVersion(t *testing.T) { - if _, err := DecodeFeed(strings.NewReader(`{"members":[]}`)); err == nil { - t.Fatal("DecodeFeed accepted a feed without its required version") - } -} - -func TestMintRefusesHarnessOutsideMemberList(t *testing.T) { - called := false - _, err := Mint(context.Background(), t.TempDir(), []string{"claude"}, "codex", vendor.Codex, func(context.Context, string, []string, string) error { - called = true - return nil - }) - if err == nil { - t.Fatal("Mint accepted a harness outside the member allowlist") - } - if called { - t.Fatal("vendor CLI ran before the harness gate") - } -} - -func TestAuthorizedKeysUsesRootOwnedPathAndModes(t *testing.T) { - if AuthorizedKeysDir != "/etc/blitz-broker/authorized_keys" { - t.Fatalf("AuthorizedKeysDir = %q", AuthorizedKeysDir) - } - root := t.TempDir() - path, err := RenderAuthorizedKeys(root, Member{ - UnixName: "m-aaaaaaaaaaaa", - Harnesses: []string{"claude"}, - Keys: []Key{{Pubkey: "ssh-ed25519 AAAAalice", Op: "mint"}}, - }) - if err != nil { - t.Fatal(err) - } - if path != filepath.Join(root, "m-aaaaaaaaaaaa") { - t.Fatalf("path = %q", path) - } - dirInfo, err := os.Stat(root) - if err != nil { - t.Fatal(err) - } - if got := dirInfo.Mode().Perm(); got != 0o755 { - t.Fatalf("authorized_keys dir mode = %o", got) - } - fileInfo, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - if got := fileInfo.Mode().Perm(); got != 0o644 { - t.Fatalf("authorized_keys mode = %o", got) - } - content, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(content), "expiry") || strings.Contains(string(content), "expires") { - t.Fatalf("authorized_keys contains expiry text: %q", content) - } - if !strings.HasPrefix(string(content), `restrict,command="/usr/local/bin/blitz-broker mint m-aaaaaaaaaaaa claude" `) { - t.Fatalf("unexpected authorized_keys line: %q", content) - } -} - -func TestDepositCapsInput(t *testing.T) { - blob := io.LimitReader(strings.NewReader(strings.Repeat("x", FeedMaxBytes+1)), FeedMaxBytes+1) - err := Deposit(context.Background(), t.TempDir(), vendor.Claude, blob, func(context.Context, string, []string, string) error { - return nil - }) - if err == nil { - t.Fatal("Deposit accepted a blob over 1 MiB") - } -} - -func TestConcurrentMintsRunOneVendorRefresh(t *testing.T) { - home := t.TempDir() - credentialPath := filepath.Join(home, filepath.FromSlash(vendor.Claude.CredentialPath)) - if err := os.MkdirAll(filepath.Dir(credentialPath), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(credentialPath, []byte(`{"claudeAiOauth":{"accessToken":"expired","refreshToken":"refresh","expiresAt":1}}`), 0o600); err != nil { - t.Fatal(err) - } - bin := t.TempDir() - countPath := filepath.Join(t.TempDir(), "refresh-count") - script := "#!/bin/sh\nset -eu\n" + - "count=0\n" + - "if [ -f " + strconv.Quote(countPath) + " ]; then count=$(sed -n '1p' " + strconv.Quote(countPath) + "); fi\n" + - "count=$((count + 1))\n" + - "printf '%s\\n' \"$count\" > " + strconv.Quote(countPath) + "\n" + - "tmp=$HOME/.claude/.credentials.json.tmp\n" + - "printf '%s\\n' '{\"claudeAiOauth\":{\"accessToken\":\"fresh\",\"refreshToken\":\"refresh\",\"expiresAt\":4102444800000}}' > \"$tmp\"\n" + - "mv \"$tmp\" \"$HOME/.claude/.credentials.json\"\n" - if err := os.WriteFile(filepath.Join(bin, "claude"), []byte(script), 0o700); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) - - const calls = 12 - var wait sync.WaitGroup - errorsFound := make(chan error, calls) - for range calls { - wait.Add(1) - go func() { - defer wait.Done() - token, err := Mint(context.Background(), home, []string{"claude"}, "claude", vendor.Claude, nil) - if err == nil && token != "fresh" { - err = errors.New("mint returned the wrong token") - } - errorsFound <- err - }() - } - wait.Wait() - close(errorsFound) - for err := range errorsFound { - if err != nil { - t.Fatal(err) - } - } - count, err := os.ReadFile(countPath) - if err != nil { - t.Fatal(err) - } - if string(count) != "1\n" { - t.Fatalf("vendor refresh count = %q, want 1", count) - } -} diff --git a/packages/broker/internal/broker/sync.go b/packages/broker/internal/broker/sync.go deleted file mode 100644 index dd83d222..00000000 --- a/packages/broker/internal/broker/sync.go +++ /dev/null @@ -1,120 +0,0 @@ -package broker - -import ( - "bytes" - "context" - "errors" - "log" - "net/http" - "os" - "time" - - "github.com/blitzdotdev/blitz-core/broker/internal/controlplane" - "github.com/blitzdotdev/blitz-core/broker/internal/store" -) - -const pollInterval = time.Second - -// feedAppliedPrefix is matched as a literal by -// packages/broker/deploy/verify-broker-box.sh. Changing the text breaks the -// provisioning gate silently — it would go on finding nothing and reporting a -// box that never reached the control plane. -const feedAppliedPrefix = "broker feed applied; members: " - -// feedHeartbeatInterval bounds how stale the positive line may get on a box -// that is working. -// -// The line used to be printed only when the feed CHANGED. After the first apply -// the ETag makes every later poll `unchanged`, so on a long-lived box it was -// never printed again, and any gate that reads a window of log had to treat -// SILENCE as success — which is also what a box whose route to the control -// plane is blackholed produces. Re-stating it on a cadence is what turns the -// line into something a bounded window can require. It sits inside the gate's -// 45 s window (verify-broker-box.sh check 6) with room to spare, so a box that -// is reaching the plane always lands at least one line in it. -const feedHeartbeatInterval = 30 * time.Second - -// feedHeartbeatDue decides whether the positive line is owed again. Split out -// of the loop because Sync refuses to run as anything but root, so this is the -// only part of the cadence a test can reach. -func feedHeartbeatDue(lastStated, now time.Time) bool { - return lastStated.IsZero() || !now.Before(lastStated.Add(feedHeartbeatInterval)) -} - -func Sync(ctx context.Context, stateDir string, httpClient *http.Client) error { - if os.Geteuid() != 0 { - return errors.New("sync must run as root") - } - reconciler := Reconciler{ - StateDir: stateDir, - AuthorizedKeysDir: AuthorizedKeysDir, - Accounts: SystemAccounts{}, - } - etag := "" - members := 0 - var lastStated time.Time - for { - restate := false - origin, originErr := store.LoadOrigin(stateDir) - _, credentialErr := store.LoadCredential(stateDir) - if originErr == nil && credentialErr == nil { - client, err := controlplane.New(origin, stateDir, httpClient) - if err == nil { - body, nextETag, unchanged, fetchErr := client.FetchFeed(ctx, etag) - switch { - case fetchErr != nil: - log.Print("broker feed unavailable; keeping rendered state") - case unchanged: - // A 304 is the control plane answering, with the same - // authentication a 200 needs. It is the steady state of a - // working box, so it carries the heartbeat. - restate = feedHeartbeatDue(lastStated, time.Now()) - case body != nil: - current, decodeErr := DecodeFeed(bytes.NewReader(body)) - if decodeErr != nil { - log.Print("broker feed rejected; keeping rendered state") - } else if reconcileErr := reconciler.Reconcile(current); reconcileErr != nil { - log.Print("broker reconciliation incomplete; retrying") - } else { - if current.Rejected > 0 { - log.Printf("broker feed skipped %d invalid member entries", current.Rejected) - } - members = len(current.Members) - etag = nextETag - restate = true - } - } - } - } else if !errors.Is(originErr, os.ErrNotExist) && !errors.Is(credentialErr, os.ErrNotExist) { - log.Print("broker enrollment state is invalid") - } - if restate { - // The one POSITIVE line this loop prints. It says exactly this: at - // this moment the control plane answered a request carrying this - // box's bearer token, and the member list this box has rendered is - // the one the plane last sent. It is printed on every apply and - // re-printed every feedHeartbeatInterval while the plane keeps - // answering, so a reader may conclude from its ABSENCE across a - // window longer than that interval that this box is not talking to - // the control plane — whether it is unenrolled, unauthorised or - // blackholed. Absence over a SHORTER window means nothing. - // packages/broker/deploy/verify-broker-box.sh is the reader. - log.Printf("%s%d", feedAppliedPrefix, members) - lastStated = time.Now() - } - if err := wait(ctx, pollInterval); err != nil { - return err - } - } -} - -func wait(ctx context.Context, duration time.Duration) error { - timer := time.NewTimer(duration) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} diff --git a/packages/broker/internal/broker/types.go b/packages/broker/internal/broker/types.go deleted file mode 100644 index f1e9d5c1..00000000 --- a/packages/broker/internal/broker/types.go +++ /dev/null @@ -1,20 +0,0 @@ -package broker - -import ( - "io" - - "github.com/blitzdotdev/blitz-core/broker/internal/feed" -) - -const ( - FeedMaxBytes = feed.MaxBytes - AuthorizedKeysDir = "/etc/blitz-broker/authorized_keys" -) - -type Key = feed.Key -type Member = feed.Member -type Feed = feed.Feed - -func DecodeFeed(reader io.Reader) (Feed, error) { - return feed.Decode(reader) -} diff --git a/packages/broker/internal/controlplane/controlplane.go b/packages/broker/internal/controlplane/controlplane.go deleted file mode 100644 index f30bc63b..00000000 --- a/packages/broker/internal/controlplane/controlplane.go +++ /dev/null @@ -1,421 +0,0 @@ -package controlplane - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "path/filepath" - "strconv" - "strings" - "sync" - "time" - "unicode" - - "github.com/blitzdotdev/blitz-core/broker/internal/feed" - "github.com/blitzdotdev/blitz-core/broker/internal/filelock" - "github.com/blitzdotdev/blitz-core/broker/internal/store" -) - -const responseMaxBytes = 1_048_576 - -type Client struct { - origin string - stateDir string - http *http.Client - refresh sync.Mutex -} - -type Broker struct { - Host string `json:"host"` - Port int `json:"port"` - SSHHostPublicKey string `json:"sshHostPublicKey"` -} - -type KeyRegistration struct { - Broker - MemberUnixName string -} - -func ValidateOrigin(raw string) (string, error) { - parsed, err := url.ParseRequestURI(raw) - if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") { - return "", errors.New("origin must be an absolute URL without a path") - } - if parsed.Scheme != "https" { - if parsed.Scheme != "http" || !isLocalhost(parsed.Hostname()) { - return "", errors.New("origin must use HTTPS (HTTP is allowed only for localhost)") - } - } - return strings.TrimSuffix(parsed.String(), "/"), nil -} - -func New(origin, stateDir string, httpClient *http.Client) (*Client, error) { - validated, err := ValidateOrigin(origin) - if err != nil { - return nil, err - } - if httpClient == nil { - httpClient = &http.Client{ - Timeout: 30 * time.Second, - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - } - } - return &Client{origin: validated, stateDir: stateDir, http: httpClient}, nil -} - -func (c *Client) RegisterBroker(ctx context.Context, host string, port int, hostKey string) error { - if !validHost(host) || port < 1 || port > 65535 || !feed.ValidPublicKey(hostKey) { - return errors.New("invalid broker registration") - } - body, err := json.Marshal(Broker{Host: host, Port: port, SSHHostPublicKey: hostKey}) - if err != nil { - return err - } - response, err := c.authenticated(ctx, http.MethodPut, func(boxID string) string { - return "/boxes/" + url.PathEscape(boxID) + "/broker" - }, body) - if err != nil { - return err - } - defer response.Body.Close() - if response.StatusCode != http.StatusNoContent { - return statusError(response.StatusCode, "broker registration failed") - } - return nil -} - -// ErrNoBrokerCapacity means the control plane has no broker box to put this -// workspace on: none is enrolled, or every one of them is at its member_cap. -// -// It is a normal answer, not a fault. The broker is optional — zero enrolled -// brokers is how the feature is turned off — so the caller's job is to leave -// the workspace cleanly signed out, not to fail. A workspace whose services -// refused to start is one a human cannot fix from inside; a signed-out one is. -// -// The control plane raises 409 on this route for exactly this reason and no -// other, and answers `no_broker_capacity` in the body to say so. -var ErrNoBrokerCapacity = errors.New("no_broker_capacity") - -func (c *Client) RegisterKeys(ctx context.Context, keys []feed.Key) (KeyRegistration, error) { - if len(keys) == 0 { - return KeyRegistration{}, errors.New("at least one key is required") - } - for _, key := range keys { - if !feed.ValidPublicKey(key.Pubkey) || (key.Op != "mint" && key.Op != "deposit") { - return KeyRegistration{}, errors.New("invalid broker key") - } - } - body, err := json.Marshal(struct { - Keys []feed.Key `json:"keys"` - }{Keys: keys}) - if err != nil { - return KeyRegistration{}, err - } - response, err := c.authenticated(ctx, http.MethodPost, func(boxID string) string { - return "/boxes/" + url.PathEscape(boxID) + "/keys" - }, body) - if err != nil { - return KeyRegistration{}, err - } - defer response.Body.Close() - if response.StatusCode == http.StatusConflict { - return KeyRegistration{}, ErrNoBrokerCapacity - } - if response.StatusCode != http.StatusOK { - return KeyRegistration{}, statusError(response.StatusCode, "key registration failed") - } - data, err := readLimited(response.Body, responseMaxBytes) - if err != nil { - return KeyRegistration{}, err - } - var result struct { - Broker Broker `json:"broker"` - MemberUnixName string `json:"memberUnixName"` - } - if err := decodeStrict(data, &result); err != nil { - return KeyRegistration{}, errors.New("invalid key registration response") - } - if !validHost(result.Broker.Host) || result.Broker.Port < 1 || result.Broker.Port > 65535 || !feed.ValidPublicKey(result.Broker.SSHHostPublicKey) || !feed.ValidUnixName(result.MemberUnixName) { - return KeyRegistration{}, errors.New("invalid broker response") - } - return KeyRegistration{Broker: result.Broker, MemberUnixName: result.MemberUnixName}, nil -} - -// agentAPIProbePath is the cheap authenticated read ValidAccessToken uses to -// find out whether the stored access token is still accepted. GET /agent/api -// is the control plane's self-describing agent API document; any authenticated -// route would do, and this one is the cheapest read the agent surface has. -const agentAPIProbePath = "/agent/api" - -// ValidAccessToken returns an access token the control plane currently -// accepts, rotating once through the flock-serialised refresh when the stored -// one has expired. -// -// The box does not know the token's age — expiry lives server-side — so -// validity is established by use: one authenticated GET, and a 401 is the -// only answer that triggers a refresh. An unreachable control plane returns -// the stored token as it is, deliberately: the caller is about to send that -// bearer somewhere anyway, and its own request will surface the real network -// error instead of this helper guessing at one. -func (c *Client) ValidAccessToken(ctx context.Context) (string, error) { - credential, err := store.LoadCredential(c.stateDir) - if err != nil { - return "", err - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.origin+agentAPIProbePath, nil) - if err != nil { - return "", err - } - request.Header.Set("Authorization", "Bearer "+credential.AccessToken) - response, err := c.http.Do(request) - if err != nil { - return credential.AccessToken, nil - } - response.Body.Close() - if response.StatusCode != http.StatusUnauthorized { - return credential.AccessToken, nil - } - rotated, err := c.refreshCredential(ctx, credential.AccessToken) - if err != nil { - return "", err - } - return rotated.AccessToken, nil -} - -func (c *Client) FetchFeed(ctx context.Context, etag string) ([]byte, string, bool, error) { - credential, err := store.LoadCredential(c.stateDir) - if err != nil { - return nil, "", false, err - } - path := "/boxes/" + url.PathEscape(credential.BoxID) + "/feed" - request := func(access string) (*http.Response, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.origin+path, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+access) - if etag != "" { - req.Header.Set("If-None-Match", etag) - } - return c.http.Do(req) - } - response, err := request(credential.AccessToken) - if err != nil { - return nil, "", false, err - } - if response.StatusCode == http.StatusUnauthorized { - response.Body.Close() - credential, err = c.refreshCredential(ctx, credential.AccessToken) - if err != nil { - return nil, "", false, err - } - response, err = request(credential.AccessToken) - if err != nil { - return nil, "", false, err - } - } - defer response.Body.Close() - if response.StatusCode == http.StatusNotModified { - return nil, etag, true, nil - } - if response.StatusCode != http.StatusOK { - return nil, "", false, statusError(response.StatusCode, "feed request failed") - } - responseETag := response.Header.Get("ETag") - if responseETag == "" { - return nil, "", false, errors.New("feed response is missing ETag") - } - body, err := readLimited(response.Body, feed.MaxBytes) - if err != nil { - return nil, "", false, err - } - return body, responseETag, false, nil -} - -func (c *Client) authenticated(ctx context.Context, method string, path func(string) string, body []byte) (*http.Response, error) { - credential, err := store.LoadCredential(c.stateDir) - if err != nil { - return nil, err - } - request := func(current store.Credential) (*http.Response, error) { - req, err := http.NewRequestWithContext(ctx, method, c.origin+path(current.BoxID), bytes.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+current.AccessToken) - req.Header.Set("Content-Type", "application/json") - return c.http.Do(req) - } - response, err := request(credential) - if err != nil { - return nil, err - } - if response.StatusCode != http.StatusUnauthorized { - return response, nil - } - response.Body.Close() - credential, err = c.refreshCredential(ctx, credential.AccessToken) - if err != nil { - return nil, err - } - response, err = request(credential) - return response, err -} - -// refreshLockWait bounds how long a rotation queues behind another process's -// rotation. The critical section is one HTTP round trip plus two small file -// operations, so a waiter that is still here has met a holder that is stuck, -// not one that is slow. -const refreshLockWait = 30 * time.Second - -// RefreshLockFile is the flock every rotation contends on. It sits beside the -// credential rather than inside it: the credential is replaced by rename, and -// a lock whose inode is swapped mid-hold locks nothing. -const RefreshLockFile = "box-credential.lock" - -// refreshCredential rotates this box's control-plane credential. -// -// The whole read-refresh-write is held under a cross-process flock, and that -// is the point of this function rather than an implementation detail. The -// control plane makes a refresh token single-use: redeeming it rotates the -// family, and the box only writes the new pair AFTER the server has already -// rotated. Two processes that both read an expired credential would POST the -// same single-use token, and the loser would report a bare HTTP 400 for a box -// that is in fact healthy. broker.withMemberLock guards the vendor credential -// against exactly this; the box's own credential went without. -// -// The re-read INSIDE the lock is what makes the loser correct rather than -// merely quiet: by the time it holds the lock the winner has already written, -// so the stale-access check hands it the fresh credential and no second -// rotation happens at all. -func (c *Client) refreshCredential(ctx context.Context, staleAccess string) (store.Credential, error) { - c.refresh.Lock() - defer c.refresh.Unlock() - if err := store.EnsureDir(c.stateDir); err != nil { - return store.Credential{}, err - } - var rotated store.Credential - err := filelock.With( - ctx, - filepath.Join(c.stateDir, RefreshLockFile), - refreshLockWait, - func() error { - credential, err := c.refreshLocked(ctx, staleAccess) - rotated = credential - return err - }, - ) - if err != nil { - return store.Credential{}, err - } - return rotated, nil -} - -func (c *Client) refreshLocked(ctx context.Context, staleAccess string) (store.Credential, error) { - credential, err := store.LoadCredential(c.stateDir) - if err != nil { - return store.Credential{}, err - } - if credential.AccessToken != staleAccess { - return credential, nil - } - form := url.Values{ - "grant_type": {"refresh_token"}, - "refresh_token": {credential.RefreshToken}, - } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.origin+"/oauth/token", strings.NewReader(form.Encode())) - if err != nil { - return store.Credential{}, err - } - request.Header.Set("Content-Type", "application/x-www-form-urlencoded") - response, err := c.http.Do(request) - if err != nil { - return store.Credential{}, err - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return store.Credential{}, statusError(response.StatusCode, "token refresh failed") - } - data, err := readLimited(response.Body, responseMaxBytes) - if err != nil { - return store.Credential{}, err - } - issued, err := decodeIssued(data) - if err != nil { - return store.Credential{}, err - } - if issued.BoxID != credential.BoxID { - return store.Credential{}, errors.New("token refresh changed box identity") - } - rotated := store.Credential{BoxID: issued.BoxID, AccessToken: issued.AccessToken, RefreshToken: issued.RefreshToken} - if err := store.SaveCredential(c.stateDir, rotated); err != nil { - return store.Credential{}, err - } - return rotated, nil -} - -type issuedTokens struct { - BoxID string `json:"box_id"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` -} - -func decodeIssued(data []byte) (issuedTokens, error) { - var issued issuedTokens - if err := decodeStrict(data, &issued); err != nil || issued.BoxID == "" || issued.AccessToken == "" || issued.RefreshToken == "" || !strings.EqualFold(issued.TokenType, "Bearer") || issued.ExpiresIn <= 0 { - return issuedTokens{}, errors.New("invalid token response") - } - return issued, nil -} - -func isLocalhost(host string) bool { - if strings.EqualFold(host, "localhost") { - return true - } - address := net.ParseIP(host) - return address != nil && address.IsLoopback() -} - -func validHost(host string) bool { - return host != "" && !strings.ContainsFunc(host, unicode.IsSpace) -} - -func statusError(status int, message string) error { - return fmt.Errorf("%s (HTTP %s)", message, strconv.Itoa(status)) -} - -func readLimited(reader io.Reader, limit int64) ([]byte, error) { - data, err := io.ReadAll(io.LimitReader(reader, limit+1)) - if err != nil { - return nil, err - } - if int64(len(data)) > limit { - return nil, errors.New("control plane response is too large") - } - return data, nil -} - -func decodeStrict(data []byte, target any) error { - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return err - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - if err == nil { - return errors.New("multiple JSON values") - } - return err - } - return nil -} diff --git a/packages/broker/internal/controlplane/controlplane_test.go b/packages/broker/internal/controlplane/controlplane_test.go deleted file mode 100644 index 44e520a4..00000000 --- a/packages/broker/internal/controlplane/controlplane_test.go +++ /dev/null @@ -1,261 +0,0 @@ -package controlplane - -import ( - "context" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/blitzdotdev/blitz-core/broker/internal/feed" - "github.com/blitzdotdev/blitz-core/broker/internal/store" -) - -func TestDeviceFlowPendingThenSuccess(t *testing.T) { - var polls atomic.Int32 - var server *httptest.Server - server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if err := request.ParseForm(); err != nil { - t.Error(err) - } - switch request.URL.Path { - case "/oauth/device_authorization": - if request.Form.Get("client_id") != "blitz-cred" { - t.Errorf("client_id = %q", request.Form.Get("client_id")) - } - fmt.Fprintf(writer, `{"device_code":"device","user_code":"ABCD-EFGH","verification_uri":"%s/verify","verification_uri_complete":"%s/verify?user_code=ABCD-EFGH","expires_in":60,"interval":0}`, server.URL, server.URL) - case "/oauth/token": - if request.Form.Get("grant_type") != deviceGrant || request.Form.Get("device_code") != "device" || request.Form.Get("client_id") != "blitz-cred" { - t.Errorf("unexpected token form: %#v", request.Form) - } - if polls.Add(1) == 1 { - writer.WriteHeader(http.StatusBadRequest) - io.WriteString(writer, `{"error":"authorization_pending"}`) - return - } - io.WriteString(writer, `{"box_id":"box","access_token":"access","refresh_token":"refresh","token_type":"Bearer","expires_in":900}`) - default: - http.NotFound(writer, request) - } - })) - defer server.Close() - - var output strings.Builder - credential, err := (DeviceFlow{HTTP: server.Client(), Sleep: func(context.Context, time.Duration) error { return nil }}).Enroll(context.Background(), server.URL, "blitz-cred", &output) - if err != nil { - t.Fatal(err) - } - if credential != (store.Credential{BoxID: "box", AccessToken: "access", RefreshToken: "refresh"}) { - t.Fatalf("credential = %#v", credential) - } - if output.String() != server.URL+"/verify\nABCD-EFGH\n" { - t.Fatalf("device output = %q", output.String()) - } -} - -func TestFeedETagAnd401RefreshRetry(t *testing.T) { - stateDir := t.TempDir() - if err := store.SaveCredential(stateDir, store.Credential{BoxID: "box", AccessToken: "old-access", RefreshToken: "old-refresh"}); err != nil { - t.Fatal(err) - } - var feedCalls atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - switch request.URL.Path { - case "/oauth/token": - body, _ := io.ReadAll(request.Body) - form, _ := url.ParseQuery(string(body)) - if form.Get("grant_type") != "refresh_token" || form.Get("refresh_token") != "old-refresh" { - t.Errorf("refresh form = %#v", form) - } - io.WriteString(writer, `{"box_id":"box","access_token":"new-access","refresh_token":"new-refresh","token_type":"Bearer","expires_in":900}`) - case "/boxes/box/feed": - feedCalls.Add(1) - if request.Header.Get("Authorization") == "Bearer old-access" { - writer.WriteHeader(http.StatusUnauthorized) - io.WriteString(writer, `{"secret":"must-not-leak"}`) - return - } - if request.Header.Get("Authorization") != "Bearer new-access" { - t.Errorf("Authorization = %q", request.Header.Get("Authorization")) - } - writer.Header().Set("ETag", `"v1"`) - if request.Header.Get("If-None-Match") == `"v1"` { - writer.WriteHeader(http.StatusNotModified) - return - } - io.WriteString(writer, `{"version":"v1","members":[]}`) - default: - http.NotFound(writer, request) - } - })) - defer server.Close() - client, err := New(server.URL, stateDir, server.Client()) - if err != nil { - t.Fatal(err) - } - body, etag, unchanged, err := client.FetchFeed(context.Background(), "") - if err != nil { - t.Fatal(err) - } - if string(body) != `{"version":"v1","members":[]}` || etag != `"v1"` || unchanged { - t.Fatalf("body=%q etag=%q unchanged=%v", body, etag, unchanged) - } - credential, err := store.LoadCredential(stateDir) - if err != nil { - t.Fatal(err) - } - if credential.AccessToken != "new-access" || credential.RefreshToken != "new-refresh" { - t.Fatalf("credential was not rotated: %#v", credential) - } - body, etag, unchanged, err = client.FetchFeed(context.Background(), etag) - if err != nil { - t.Fatal(err) - } - if body != nil || etag != `"v1"` || !unchanged { - t.Fatalf("304 result body=%q etag=%q unchanged=%v", body, etag, unchanged) - } - if feedCalls.Load() != 3 { - t.Fatalf("feed calls = %d, want old + retry + 304", feedCalls.Load()) - } -} - -func TestOversizedFeedIsRejected(t *testing.T) { - stateDir := t.TempDir() - if err := store.SaveCredential(stateDir, store.Credential{BoxID: "box", AccessToken: "access", RefreshToken: "refresh"}); err != nil { - t.Fatal(err) - } - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - writer.Header().Set("ETag", `"large"`) - io.WriteString(writer, strings.Repeat("x", feed.MaxBytes+1)) - })) - defer server.Close() - client, err := New(server.URL, stateDir, server.Client()) - if err != nil { - t.Fatal(err) - } - _, _, _, err = client.FetchFeed(context.Background(), "") - if err == nil { - t.Fatal("oversized feed was accepted") - } -} - -func TestHTTPErrorDoesNotEchoResponseBody(t *testing.T) { - stateDir := t.TempDir() - if err := store.SaveCredential(stateDir, store.Credential{BoxID: "box", AccessToken: "access", RefreshToken: "refresh"}); err != nil { - t.Fatal(err) - } - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - writer.WriteHeader(http.StatusForbidden) - io.WriteString(writer, `{"access_token":"body-secret"}`) - })) - defer server.Close() - client, err := New(server.URL, stateDir, server.Client()) - if err != nil { - t.Fatal(err) - } - _, _, _, err = client.FetchFeed(context.Background(), "") - if err == nil || strings.Contains(err.Error(), "body-secret") { - t.Fatalf("unsafe error = %v", err) - } -} - -func TestOriginRequiresHTTPSExceptLoopback(t *testing.T) { - for _, accepted := range []string{"https://cp.example", "http://localhost:8787", "http://127.0.0.1:8787", "http://[::1]:8787"} { - if _, err := ValidateOrigin(accepted); err != nil { - t.Errorf("ValidateOrigin(%q): %v", accepted, err) - } - } - for _, rejected := range []string{"http://cp.example", "https://cp.example/path", "https://user@cp.example", "https://cp.example?x=1"} { - if _, err := ValidateOrigin(rejected); err == nil { - t.Errorf("ValidateOrigin(%q) succeeded", rejected) - } - } -} - -// TestConcurrentRefreshRotatesOnce is the regression for the bug that stranded -// boxes: two clients on one state directory, both holding the same expired -// access token, both driven at the control plane at once. -// -// The control plane makes a refresh token single-use, so the second POST of -// the same token is a 400 the box cannot recover from. The flock plus the -// re-read inside it means the second client never sends that POST at all: by -// the time it holds the lock the first has written, and the stale-access check -// hands it the fresh credential. -func TestConcurrentRefreshRotatesOnce(t *testing.T) { - stateDir := t.TempDir() - if err := store.SaveCredential(stateDir, store.Credential{ - BoxID: "box", AccessToken: "old-access", RefreshToken: "old-refresh", - }); err != nil { - t.Fatal(err) - } - var refreshCalls atomic.Int32 - release := make(chan struct{}) - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - switch request.URL.Path { - case "/oauth/token": - body, _ := io.ReadAll(request.Body) - form, _ := url.ParseQuery(string(body)) - // Single-use, exactly as the control plane treats it: the second - // redemption of a spent token is invalid_grant. - if form.Get("refresh_token") != "old-refresh" { - writer.WriteHeader(http.StatusBadRequest) - io.WriteString(writer, `{"error":"invalid_grant"}`) - return - } - if refreshCalls.Add(1) == 1 { - // Hold the winner inside the critical section so the other - // client is guaranteed to arrive while the rotation is open. - <-release - } - io.WriteString(writer, `{"box_id":"box","access_token":"new-access","refresh_token":"new-refresh","token_type":"Bearer","expires_in":900}`) - case "/boxes/box/feed": - if request.Header.Get("Authorization") == "Bearer old-access" { - writer.WriteHeader(http.StatusUnauthorized) - return - } - writer.Header().Set("ETag", `"v1"`) - io.WriteString(writer, `{"version":"v1","members":[]}`) - default: - http.NotFound(writer, request) - } - })) - defer server.Close() - - // Two clients, because two processes are what the box actually runs. One - // Client with one mutex would pass this test without the file lock. - results := make(chan error, 2) - for range 2 { - go func() { - client, err := New(server.URL, stateDir, server.Client()) - if err != nil { - results <- err - return - } - _, _, _, err = client.FetchFeed(context.Background(), "") - results <- err - }() - } - // Both are now either in the rotation or queued behind it. - time.Sleep(200 * time.Millisecond) - close(release) - for range 2 { - if err := <-results; err != nil { - t.Fatalf("fetch failed: %v", err) - } - } - if calls := refreshCalls.Load(); calls != 1 { - t.Fatalf("refresh POSTs = %d, want 1: the spent token was redeemed twice", calls) - } - credential, err := store.LoadCredential(stateDir) - if err != nil { - t.Fatal(err) - } - if credential.AccessToken != "new-access" || credential.RefreshToken != "new-refresh" { - t.Fatalf("credential was not rotated: %#v", credential) - } -} diff --git a/packages/broker/internal/controlplane/device.go b/packages/broker/internal/controlplane/device.go deleted file mode 100644 index f36b2261..00000000 --- a/packages/broker/internal/controlplane/device.go +++ /dev/null @@ -1,156 +0,0 @@ -package controlplane - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "github.com/blitzdotdev/blitz-core/broker/internal/store" -) - -const deviceGrant = "urn:ietf:params:oauth:grant-type:device_code" - -type DeviceFlow struct { - HTTP *http.Client - Sleep func(context.Context, time.Duration) error -} - -type deviceAuthorization struct { - DeviceCode string `json:"device_code"` - UserCode string `json:"user_code"` - VerificationURI string `json:"verification_uri"` - VerificationURIComplete string `json:"verification_uri_complete"` - ExpiresIn int `json:"expires_in"` - Interval int `json:"interval"` -} - -func (flow DeviceFlow) Enroll(ctx context.Context, rawOrigin, clientID string, output io.Writer) (store.Credential, error) { - origin, err := ValidateOrigin(rawOrigin) - if err != nil { - return store.Credential{}, err - } - if clientID == "" { - return store.Credential{}, errors.New("client_id is required") - } - httpClient := flow.HTTP - if httpClient == nil { - httpClient = &http.Client{ - Timeout: 30 * time.Second, - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - } - } - sleep := flow.Sleep - if sleep == nil { - sleep = sleepContext - } - authorization, err := requestDeviceAuthorization(ctx, httpClient, origin, clientID) - if err != nil { - return store.Credential{}, err - } - if _, err := fmt.Fprintf(output, "%s\n%s\n", authorization.VerificationURI, authorization.UserCode); err != nil { - return store.Credential{}, err - } - interval := time.Duration(authorization.Interval) * time.Second - deadline := time.Now().Add(time.Duration(authorization.ExpiresIn) * time.Second) - for { - if !time.Now().Before(deadline) { - return store.Credential{}, errors.New("device authorization expired") - } - if err := sleep(ctx, interval); err != nil { - return store.Credential{}, err - } - issued, pending, slowDown, err := pollDeviceToken(ctx, httpClient, origin, clientID, authorization.DeviceCode) - if err != nil { - return store.Credential{}, err - } - if pending { - if slowDown { - interval += 5 * time.Second - } - continue - } - return store.Credential{BoxID: issued.BoxID, AccessToken: issued.AccessToken, RefreshToken: issued.RefreshToken}, nil - } -} - -func requestDeviceAuthorization(ctx context.Context, client *http.Client, origin, clientID string) (deviceAuthorization, error) { - response, err := postForm(ctx, client, origin+"/oauth/device_authorization", url.Values{"client_id": {clientID}}) - if err != nil { - return deviceAuthorization{}, err - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return deviceAuthorization{}, statusError(response.StatusCode, "device authorization failed") - } - data, err := readLimited(response.Body, responseMaxBytes) - if err != nil { - return deviceAuthorization{}, err - } - var authorization deviceAuthorization - if err := decodeStrict(data, &authorization); err != nil || authorization.DeviceCode == "" || authorization.UserCode == "" || authorization.VerificationURI == "" || authorization.VerificationURIComplete == "" || authorization.ExpiresIn <= 0 || authorization.Interval < 0 { - return deviceAuthorization{}, errors.New("invalid device authorization response") - } - if _, err := url.ParseRequestURI(authorization.VerificationURI); err != nil { - return deviceAuthorization{}, errors.New("invalid verification URI") - } - return authorization, nil -} - -func pollDeviceToken(ctx context.Context, client *http.Client, origin, clientID, deviceCode string) (issuedTokens, bool, bool, error) { - response, err := postForm(ctx, client, origin+"/oauth/token", url.Values{ - "grant_type": {deviceGrant}, - "device_code": {deviceCode}, - "client_id": {clientID}, - }) - if err != nil { - return issuedTokens{}, false, false, err - } - defer response.Body.Close() - data, err := readLimited(response.Body, responseMaxBytes) - if err != nil { - return issuedTokens{}, false, false, err - } - if response.StatusCode == http.StatusOK { - issued, err := decodeIssued(data) - return issued, false, false, err - } - var oauthError struct { - Error string `json:"error"` - } - if err := decodeStrict(data, &oauthError); err == nil { - switch oauthError.Error { - case "authorization_pending": - return issuedTokens{}, true, false, nil - case "slow_down": - return issuedTokens{}, true, true, nil - } - } - return issuedTokens{}, false, false, statusError(response.StatusCode, "device token request failed") -} - -func postForm(ctx context.Context, client *http.Client, endpoint string, values url.Values) (*http.Response, error) { - request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(values.Encode())) - if err != nil { - return nil, err - } - request.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return client.Do(request) -} - -func sleepContext(ctx context.Context, duration time.Duration) error { - timer := time.NewTimer(duration) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} diff --git a/packages/broker/internal/enroll/enroll.go b/packages/broker/internal/enroll/enroll.go deleted file mode 100644 index 988b41ee..00000000 --- a/packages/broker/internal/enroll/enroll.go +++ /dev/null @@ -1,37 +0,0 @@ -package enroll - -import ( - "context" - "errors" - "io" - "net/http" - "os" - - "github.com/blitzdotdev/blitz-core/broker/internal/controlplane" - "github.com/blitzdotdev/blitz-core/broker/internal/store" -) - -func Run(ctx context.Context, stateDir, origin, clientID string, output io.Writer, httpClient *http.Client) (store.Credential, error) { - validated, err := controlplane.ValidateOrigin(origin) - if err != nil { - return store.Credential{}, err - } - if err := store.SaveOrigin(stateDir, validated); err != nil { - return store.Credential{}, err - } - credential, err := store.LoadCredential(stateDir) - if err == nil { - return credential, nil - } - if !errors.Is(err, os.ErrNotExist) { - return store.Credential{}, err - } - credential, err = (controlplane.DeviceFlow{HTTP: httpClient}).Enroll(ctx, validated, clientID, output) - if err != nil { - return store.Credential{}, err - } - if err := store.SaveCredential(stateDir, credential); err != nil { - return store.Credential{}, err - } - return credential, nil -} diff --git a/packages/broker/internal/feed/feed.go b/packages/broker/internal/feed/feed.go deleted file mode 100644 index 47d22a20..00000000 --- a/packages/broker/internal/feed/feed.go +++ /dev/null @@ -1,170 +0,0 @@ -package feed - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "regexp" - "strings" -) - -const MaxBytes = 1_048_576 - -// unixNamePattern is the single gate this binary owns over member names, and -// it is deliberately narrow. A name that gets past it reaches useradd/userdel -// argv, becomes a path under the members directory, and — the same pattern, -// the same call — decides which homes reconcile is allowed to DELETE -// (internal/broker/reconcile.go). Nothing outside `m-<12 hex>` is ever created -// or removed, so the box's own accounts and any hand-made directory can never -// be swept by the deprovision half. -// -// `m-<12 hex>` is what the control plane derives, server-side, per member -// (packages/control-plane/core/registry.ts brokerUnixName). The old -// `^[a-z][a-z0-9-]{0,31}$` admitted the shared literal `blitz`, which put -// every member's credential in ONE home on a box that holds the only copy of -// each — the isolation boundary of the whole design, gone. -// -// Rejecting a name rejects that MEMBER and preserves their existing account -// rather than failing the whole feed: a producer that starts emitting a shape -// this binary does not understand must not cost every other member their keys. -var unixNamePattern = regexp.MustCompile(`^m-[0-9a-f]{12}$`) - -type Key struct { - Pubkey string `json:"pubkey"` - Op string `json:"op"` -} - -type Member struct { - UnixName string `json:"unixName"` - Harnesses []string `json:"harnesses"` - Keys []Key `json:"keys"` -} - -type Feed struct { - Version string - Members []Member - Preserve map[string]bool - Rejected int -} - -func ReadLimited(reader io.Reader) ([]byte, error) { - data, err := io.ReadAll(io.LimitReader(reader, MaxBytes+1)) - if err != nil { - return nil, err - } - if len(data) > MaxBytes { - return nil, errors.New("broker feed exceeds 1 MiB") - } - return data, nil -} - -func Decode(reader io.Reader) (Feed, error) { - data, err := ReadLimited(reader) - if err != nil { - return Feed{}, err - } - var envelope struct { - Version string `json:"version"` - Members []json.RawMessage `json:"members"` - } - if err := decodeStrict(data, &envelope); err != nil { - return Feed{}, errors.New("invalid broker feed") - } - if envelope.Version == "" || envelope.Members == nil { - return Feed{}, errors.New("invalid broker feed") - } - result := Feed{Version: envelope.Version, Preserve: make(map[string]bool)} - seen := make(map[string]bool) - for _, raw := range envelope.Members { - var member Member - if err := decodeStrict(raw, &member); err != nil { - result.Rejected++ - continue - } - validName := unixNamePattern.MatchString(member.UnixName) - if validName && !validHarnesses(member.Harnesses) { - result.Preserve[member.UnixName] = true - result.Rejected++ - continue - } - if !validName || !validKeys(member.Keys) || seen[member.UnixName] { - if validName { - result.Preserve[member.UnixName] = true - } - result.Rejected++ - continue - } - seen[member.UnixName] = true - result.Members = append(result.Members, member) - } - return result, nil -} - -func ValidUnixName(name string) bool { - return unixNamePattern.MatchString(name) -} - -func ValidHarness(name string) bool { - return name == "claude" || name == "codex" -} - -func validHarnesses(harnesses []string) bool { - if harnesses == nil { - return false - } - seen := make(map[string]bool) - for _, harness := range harnesses { - if !ValidHarness(harness) || seen[harness] { - return false - } - seen[harness] = true - } - return true -} - -func validKeys(keys []Key) bool { - if keys == nil { - return false - } - for _, key := range keys { - if (key.Op != "mint" && key.Op != "deposit") || !ValidPublicKey(key.Pubkey) { - return false - } - } - return true -} - -func ValidPublicKey(publicKey string) bool { - if strings.ContainsAny(publicKey, "\r\n") { - return false - } - fields := strings.Fields(publicKey) - if len(fields) < 2 { - return false - } - switch fields[0] { - case "ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com": - default: - return false - } - _, err := base64.StdEncoding.DecodeString(fields[1]) - return err == nil -} - -func decodeStrict(data []byte, target any) error { - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return err - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - if err == nil { - return fmt.Errorf("multiple JSON values") - } - return err - } - return nil -} diff --git a/packages/broker/internal/vendor/claude.go b/packages/broker/internal/vendor/claude.go deleted file mode 100644 index 5d9fc765..00000000 --- a/packages/broker/internal/vendor/claude.go +++ /dev/null @@ -1,60 +0,0 @@ -package vendor - -import ( - "encoding/json" - "errors" - "time" -) - -var Claude = Definition{ - Name: "claude", - Command: "claude", - CredentialPath: ".claude/.credentials.json", - RefreshArgs: []string{"auth", "status", "--json"}, - VerifyArgs: []string{"auth", "status", "--json"}, - ReadToken: readClaudeToken, - // The only one of the two harnesses that publishes a refresh-token - // deadline. Codex stores an opaque refresh token and announces nothing, - // so for codex the residual documented on Definition stands unnarrowed. - ReadRefreshExpiry: readClaudeRefreshExpiry, -} - -func readClaudeToken(data []byte) (string, time.Time, error) { - var credential struct { - OAuth struct { - AccessToken string `json:"accessToken"` - ExpiresAt int64 `json:"expiresAt"` - } `json:"claudeAiOauth"` - } - if err := json.Unmarshal(data, &credential); err != nil { - return "", time.Time{}, errors.New("invalid Claude credential") - } - if credential.OAuth.AccessToken == "" || credential.OAuth.ExpiresAt <= 0 { - return "", time.Time{}, errors.New("incomplete Claude credential") - } - return credential.OAuth.AccessToken, time.UnixMilli(credential.OAuth.ExpiresAt), nil -} - -// readClaudeRefreshExpiry reads `refreshTokenExpiresAt`. Absent is FINE and -// returns the zero time — older credential files simply do not carry the -// field, and refusing them would refuse every login written before it existed. -// Present-but-nonsense is not fine: a negative or zero value means the file no -// longer holds what this code thinks it holds, and guessing would be how a -// dead credential gets stored as the only copy. -func readClaudeRefreshExpiry(data []byte) (time.Time, error) { - var credential struct { - OAuth struct { - RefreshExpiresAt *int64 `json:"refreshTokenExpiresAt"` - } `json:"claudeAiOauth"` - } - if err := json.Unmarshal(data, &credential); err != nil { - return time.Time{}, errors.New("invalid Claude credential") - } - if credential.OAuth.RefreshExpiresAt == nil { - return time.Time{}, nil - } - if *credential.OAuth.RefreshExpiresAt <= 0 { - return time.Time{}, errors.New("invalid Claude refresh-token expiry") - } - return time.UnixMilli(*credential.OAuth.RefreshExpiresAt), nil -} diff --git a/packages/broker/internal/vendor/codex.go b/packages/broker/internal/vendor/codex.go deleted file mode 100644 index 8dea9273..00000000 --- a/packages/broker/internal/vendor/codex.go +++ /dev/null @@ -1,48 +0,0 @@ -package vendor - -import ( - "encoding/base64" - "encoding/json" - "errors" - "strings" - "time" -) - -var Codex = Definition{ - Name: "codex", - Command: "codex", - CredentialPath: ".codex/auth.json", - RefreshArgs: []string{"debug", "models"}, - VerifyArgs: []string{"debug", "models"}, - ReadToken: readCodexToken, -} - -func readCodexToken(data []byte) (string, time.Time, error) { - var credential struct { - AuthMode string `json:"auth_mode"` - Tokens struct { - AccessToken string `json:"access_token"` - } `json:"tokens"` - } - if err := json.Unmarshal(data, &credential); err != nil { - return "", time.Time{}, errors.New("invalid Codex credential") - } - if credential.AuthMode != "chatgpt" || credential.Tokens.AccessToken == "" { - return "", time.Time{}, errors.New("Codex credential is not ChatGPT OAuth") - } - parts := strings.Split(credential.Tokens.AccessToken, ".") - if len(parts) != 3 { - return "", time.Time{}, errors.New("invalid Codex access token") - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return "", time.Time{}, errors.New("invalid Codex access token") - } - var claims struct { - Expires int64 `json:"exp"` - } - if err := json.Unmarshal(payload, &claims); err != nil || claims.Expires <= 0 { - return "", time.Time{}, errors.New("invalid Codex access token") - } - return credential.Tokens.AccessToken, time.Unix(claims.Expires, 0), nil -} diff --git a/packages/broker/internal/vendor/vendor.go b/packages/broker/internal/vendor/vendor.go deleted file mode 100644 index a539a55c..00000000 --- a/packages/broker/internal/vendor/vendor.go +++ /dev/null @@ -1,106 +0,0 @@ -package vendor - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "os/exec" - "strings" - "time" -) - -// TriggerTimeout bounds one vendor CLI run — the path that makes the CLI go -// and FETCH a new token. That is a real network round trip. -// -// The danger of a tight bound here is NOT a slow mint. exec.CommandContext -// KILLS the child, so a deadline that lands mid-refresh kills the vendor CLI -// while it is rewriting the ONLY copy of the credential. That is exactly how -// the 2026-08-07 production incident left `accessToken: ""` and -// `refreshToken: ""` on disk, on a box with no backup by design. The measured -// cost of these commands on that host was 6.4 s (claude) and 4.8 s (codex); -// 60 s is ~9x that and still bounds a CLI that hangs forever. -// -// Every caller passes this, and Run applies it itself, so no caller can -// accidentally hand the vendor a shorter deadline. -const TriggerTimeout = 60 * time.Second - -type Runner func(context.Context, string, []string, string) error - -type Definition struct { - Name string - Command string - CredentialPath string - RefreshArgs []string - VerifyArgs []string - ReadToken func([]byte) (string, time.Time, error) - - // ReadRefreshExpiry reports when the REFRESH token dies, for the harnesses - // that publish one. Nil, or a zero time, means the harness publishes none - // and the caller must not infer anything from that. - // - // It exists because verifying a deposit proves the ACCESS token works now - // and says nothing about the refresh chain behind it. A blob whose access - // token is live but whose refresh token is already dead would pass - // verification and then die at the first refresh — up to ten days later, - // on the only copy. Where a harness announces the deadline, Deposit reads - // it and refuses; where it does not, that residual stands. - ReadRefreshExpiry func([]byte) (time.Time, error) -} - -func Lookup(name string) (Definition, error) { - switch name { - case Claude.Name: - return Claude, nil - case Codex.Name: - return Codex, nil - default: - return Definition{}, fmt.Errorf("unsupported harness %q", name) - } -} - -// Run executes one vendor CLI invocation. Notes on what is deliberate: -// -// - exec.CommandContext, not a shell: no word splitting, no interpolation, -// and argv comes from the compile-time Definition table only. -// - stdin is nil, so the child reads /dev/null and can never be handed the -// caller's stdin — the deposited blob included. -// - HOME is the only thing that decides which credential the CLI touches, -// which is how Deposit stages a blob without going near the stored one. -// - stderr is DISCARDED. A vendor CLI is free to print a token in a -// diagnostic, and forwarding that would put a secret in a log line. -// - the deadline is TriggerTimeout, applied HERE. See the constant. -func Run(ctx context.Context, command string, args []string, home string) error { - ctx, cancel := context.WithTimeout(ctx, TriggerTimeout) - defer cancel() - cmd := exec.CommandContext(ctx, command, args...) - cmd.Env = homeEnvironment(home) - cmd.Stdin = nil - cmd.Stdout = io.Discard - cmd.Stderr = io.Discard - if err := cmd.Run(); err != nil { - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return errors.New("vendor CLI timed out") - } - return errors.New("vendor CLI rejected the credential") - } - return nil -} - -func homeEnvironment(home string) []string { - environment := make([]string, 0, len(os.Environ())+2) - for _, item := range os.Environ() { - if !strings.HasPrefix(item, "HOME=") && !strings.HasPrefix(item, "CODEX_HOME=") && - !strings.HasPrefix(item, "CLAUDE_CONFIG_DIR=") { - environment = append(environment, item) - } - } - // The vendor CLIs update themselves, here as everywhere else on the box. - // This process used to force the updater off, on the theory that a refresh - // which rewrites the only copy of a credential must not also move the - // binary under itself. That is not a boundary worth holding here alone: an - // update lands in the same npm-global prefix every other entry point reads, - // so pinning this one path bought a version skew rather than a guarantee. - return append(environment, "HOME="+home) -} diff --git a/packages/broker/internal/vendor/vendor_test.go b/packages/broker/internal/vendor/vendor_test.go deleted file mode 100644 index 6b808ec8..00000000 --- a/packages/broker/internal/vendor/vendor_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package vendor - -import ( - "encoding/base64" - "fmt" - "testing" - "time" -) - -func TestClaudeAndCodexRealCredentialLayouts(t *testing.T) { - expiry := time.Unix(2_000_000_000, 0) - claude := []byte(fmt.Sprintf(`{"claudeAiOauth":{"accessToken":"claude-token","refreshToken":"refresh","expiresAt":%d}}`, expiry.UnixMilli())) - token, expires, err := Claude.ReadToken(claude) - if err != nil { - t.Fatal(err) - } - if token != "claude-token" || !expires.Equal(expiry) { - t.Fatalf("Claude token=%q expiry=%v", token, expires) - } - - payload := base64.RawURLEncoding.EncodeToString([]byte(`{"exp":2000000000}`)) - codex := []byte(fmt.Sprintf(`{"auth_mode":"chatgpt","tokens":{"access_token":"x.%s.y","refresh_token":"refresh"}}`, payload)) - token, expires, err = Codex.ReadToken(codex) - if err != nil { - t.Fatal(err) - } - if token != "x."+payload+".y" || !expires.Equal(expiry) { - t.Fatalf("Codex token=%q expiry=%v", token, expires) - } -} diff --git a/packages/broker/internal/workspace/apitoken.go b/packages/broker/internal/workspace/apitoken.go deleted file mode 100644 index c12bfede..00000000 --- a/packages/broker/internal/workspace/apitoken.go +++ /dev/null @@ -1,29 +0,0 @@ -package workspace - -import ( - "context" - "net/http" - - "github.com/blitzdotdev/blitz-core/broker/internal/controlplane" - "github.com/blitzdotdev/blitz-core/broker/internal/store" -) - -// APIToken answers `blitz-cred api-token`: a machine bearer the control plane -// currently accepts, for the agent's own curl against the /agent/* API. -// -// This is the one credential-shaped primitive left on the box, and it carries -// zero API schema on purpose. It knows how to keep a bearer fresh — the -// stored pair, the single-use refresh, the cross-process flock — and nothing -// about what the bearer unlocks; the endpoint list lives in the control -// plane's own OpenAPI document at GET /agent/api. -func APIToken(ctx context.Context, stateDir string, httpClient *http.Client) (string, error) { - origin, err := store.LoadOrigin(stateDir) - if err != nil { - return "", err - } - client, err := controlplane.New(origin, stateDir, httpClient) - if err != nil { - return "", err - } - return client.ValidAccessToken(ctx) -} diff --git a/packages/broker/internal/workspace/harness.go b/packages/broker/internal/workspace/harness.go deleted file mode 100644 index 33a53277..00000000 --- a/packages/broker/internal/workspace/harness.go +++ /dev/null @@ -1,270 +0,0 @@ -package workspace - -import ( - "errors" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/blitzdotdev/blitz-core/broker/internal/atomicfile" -) - -// The codex block is written between markers so a re-register replaces exactly -// our lines and touches nothing the member wrote. -// -// TWO regions, not one, because TOML is position-sensitive: a bare key after a -// [table] header belongs to that table. The bare `model_provider` therefore has -// to go at the TOP of the file and the provider tables at the BOTTOM. A single -// region would swallow the member's own settings between them. -const ( - codexHeadBegin = "# BEGIN blitz-broker (top-level keys)" - codexHeadEnd = "# END blitz-broker (top-level keys)" - codexTailBegin = "# BEGIN blitz-broker (provider)" - codexTailEnd = "# END blitz-broker (provider)" - - // codexAuthCommand takes no arguments: codex passes none to the command it - // finds here, so the harness has to be in the name. - codexAuthCommand = "/usr/local/bin/blitz-cred-codex" - - // codexRefreshInterval is how often codex re-runs that command, in - // milliseconds. Five minutes: well inside the shortest access-token life - // of the two harnesses, so a long-running session never reaches the - // vendor with a token that died under it. - codexRefreshInterval = 300000 - - codexConfigPath = ".codex/config.toml" - - codexManagedNote = "# Managed by blitz-cred register; edits between the markers are lost." - - // codexPreservedPrefix carries the member's own top-level model_provider - // across the window in which the broker owns that key. - // - // A COMMENT, inside the marked region, because nothing else survives the - // round trip. The value cannot stay a live key — a second top-level - // model_provider is a duplicate-key parse error and codex refuses the whole - // file — and it cannot live outside the region, because stripMarkedRegions - // would leave it behind and the next wire would find two. Inside the region - // it is deleted and re-emitted with the block it belongs to, it parses as - // nothing at all, and a member reading their own config can see exactly - // which setting the broker is standing in for and what it will get back. - codexPreservedPrefix = "# blitz-broker preserved: " -) - -const ( - codexHeadPrologue = codexHeadBegin + "\n" + codexManagedNote + "\n" - codexHeadEpilogue = `model_provider = "blitz"` + "\n" + codexHeadEnd - codexHead = codexHeadPrologue + codexHeadEpilogue -) - -// codexTail interpolates codexRefreshInterval rather than repeating it. The -// literal used to be written out here as well, so the constant above documented -// a value it did not control and tuning it changed nothing on disk. -var codexTail = codexTailBegin + ` -[model_providers.blitz] -name = "BlitzOS credential broker" -base_url = "https://chatgpt.com/backend-api/codex" -wire_api = "responses" - -[model_providers.blitz.auth] -command = "` + codexAuthCommand + `" -refresh_interval_ms = ` + strconv.Itoa(codexRefreshInterval) + ` -` + codexTailEnd - -// wireHarnesses points the vendor CLIs at the broker. -// -// Claude gets NOTHING written here, deliberately. The broker mints an OAuth -// access token (`sk-ant-oat01-…`), which reaches claude as -// CLAUDE_CODE_OAUTH_TOKEN — exported by the PATH shim for terminals, or set -// directly by an embedding caller. There is no config file in that path, and -// there must not be one: `apiKeyHelper` is the API-KEY hook, it rejects an -// OAuth token outright, and a managed apiKeyHelper does not lose to a valid -// CLAUDE_CODE_OAUTH_TOKEN — with both set, claude hangs. Deleting -// /etc/claude-code/managed-settings.json is the root half of this, done by the -// box's register unit before it drops privileges. -// -// Codex is the opposite: it has a real pull hook in its own config, so it -// fetches its own token every refresh_interval_ms and never needs a file -// written down. -func wireHarnesses(home string) error { - return writeCodexConfig(filepath.Join(home, filepath.FromSlash(codexConfigPath)), true) -} - -// unwireHarnesses removes the broker's lines when there is no broker to point -// at, and gives the member back the model_provider wiring took from them. -// Leaving the block would send codex to a helper that cannot mint, which fails -// slowly and looks like a broken account rather than an unconfigured one; not -// restoring the member's own key would leave a box that landed in -// no_broker_capacity running codex on its default provider, with the setting -// the member chose deleted and nothing anywhere recording that it existed. -func unwireHarnesses(home string) error { - err := writeCodexConfig(filepath.Join(home, filepath.FromSlash(codexConfigPath)), false) - if errors.Is(err, os.ErrNotExist) { - return nil - } - return err -} - -func writeCodexConfig(path string, wire bool) error { - existing, err := os.ReadFile(path) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - if err != nil && !wire { - // Nothing to strip and nothing to create. - return os.ErrNotExist - } - // The member's own model_provider, from whichever of the two places holds - // it: a live top-level assignment on a file this has never touched, or the - // preserved comment inside our own head region on every wire after the - // first. A live assignment wins, because it is the member overruling us and - // it is the line about to be deleted; on a re-wire there is none, so the - // preserved comment carries the value forward and the render is unchanged. - preserved := preservedModelProvider(string(existing)) - body := stripMarkedRegions(string(existing)) - own := topLevelModelProvider(body) - switch { - case wire: - if own != "" { - preserved = own - } - // A second top-level model_provider would be a duplicate-key parse - // error, so drop the member's own — but only the assignments BEFORE - // the first [table] header, which are the only top-level ones. - body = dropTopLevelModelProvider(body) - case own == "" && preserved != "": - // Back at the top, above every [table] header, because that is the only - // position where a bare key is top-level again. A file that already - // carries a live one gets nothing back: that line is the member's, it is - // already where it belongs, and a second would be the duplicate-key - // parse error this whole dance exists to avoid. - body = preserved + "\n" + body - } - body = strings.Trim(body, "\n") - - head := codexHeadRegion(preserved) - var rendered string - switch { - case wire && body != "": - rendered = head + "\n" + body + "\n" + codexTail + "\n" - case wire: - rendered = head + "\n" + codexTail + "\n" - case body == "": - // The file held nothing but our block. Remove it rather than leaving - // an empty config for codex to parse. - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - return nil - default: - rendered = body + "\n" - } - if string(existing) == rendered { - return nil - } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return err - } - return atomicfile.Write(path, []byte(rendered), 0o600) -} - -// stripMarkedRegions removes every marked region, including a duplicate left -// by an interrupted earlier write. -func stripMarkedRegions(content string) string { - for _, marker := range [][2]string{{codexHeadBegin, codexHeadEnd}, {codexTailBegin, codexTailEnd}} { - for { - start := strings.Index(content, marker[0]) - if start < 0 { - break - } - end := strings.Index(content[start:], marker[1]) - if end < 0 { - // An unterminated marker: everything from it on is ours, and - // leaving half a block would be a parse error. - content = content[:start] - break - } - after := start + end + len(marker[1]) - if after < len(content) && content[after] == '\n' { - after++ - } - content = content[:start] + content[after:] - } - } - return content -} - -// codexHeadRegion renders the head region, carrying the member's own -// model_provider as a comment when there is one to carry. An empty value emits -// the plain region: a file that never had a top-level model_provider must not -// come back from an unwire with one this code invented. -func codexHeadRegion(preserved string) string { - if preserved == "" { - return codexHead - } - return codexHeadPrologue + codexPreservedPrefix + preserved + "\n" + codexHeadEpilogue -} - -// preservedModelProvider reads back the line codexHeadRegion wrote. -// -// It looks only INSIDE the head region. A member who writes the same comment -// somewhere else in their file has written a comment, and reading it would make -// the value survive a strip it was never part of. -func preservedModelProvider(content string) string { - start := strings.Index(content, codexHeadBegin) - if start < 0 { - return "" - } - region := content[start:] - if end := strings.Index(region, codexHeadEnd); end >= 0 { - region = region[:end] - } - for _, line := range strings.Split(region, "\n") { - if value, found := strings.CutPrefix(strings.TrimLeft(line, " \t"), codexPreservedPrefix); found { - return strings.TrimSpace(value) - } - } - return "" -} - -// topLevelModelProvider returns the first assignment dropTopLevelModelProvider -// would delete, trimmed. The two read the file the same way on purpose: a key -// this reports and that one leaves behind would be preserved AND kept, which is -// the duplicate-key parse error the whole two-region layout exists to avoid. -func topLevelModelProvider(content string) string { - for _, line := range strings.Split(content, "\n") { - trimmed := strings.TrimLeft(line, " \t") - if strings.HasPrefix(trimmed, "[") { - return "" - } - if isModelProviderAssignment(trimmed) { - return strings.TrimSpace(trimmed) - } - } - return "" -} - -func dropTopLevelModelProvider(content string) string { - lines := strings.Split(content, "\n") - kept := make([]string, 0, len(lines)) - inTable := false - for _, line := range lines { - trimmed := strings.TrimLeft(line, " \t") - if strings.HasPrefix(trimmed, "[") { - inTable = true - } - if !inTable && isModelProviderAssignment(trimmed) { - continue - } - kept = append(kept, line) - } - return strings.Join(kept, "\n") -} - -func isModelProviderAssignment(trimmed string) bool { - const key = "model_provider" - if !strings.HasPrefix(trimmed, key) { - return false - } - return strings.HasPrefix(strings.TrimLeft(trimmed[len(key):], " \t"), "=") -} diff --git a/packages/broker/internal/workspace/register.go b/packages/broker/internal/workspace/register.go deleted file mode 100644 index 21a51bca..00000000 --- a/packages/broker/internal/workspace/register.go +++ /dev/null @@ -1,280 +0,0 @@ -package workspace - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/blitzdotdev/blitz-core/broker/internal/atomicfile" - "github.com/blitzdotdev/blitz-core/broker/internal/controlplane" - "github.com/blitzdotdev/blitz-core/broker/internal/feed" - "github.com/blitzdotdev/blitz-core/broker/internal/store" -) - -const ( - MintKeyFile = "mint_key" - DepositKeyFile = "deposit_key" - BrokerFile = "broker.json" - KnownHostsFile = "known_hosts" - credentialMaxSize = 1_048_576 -) - -type BrokerConfig struct { - Host string `json:"host"` - Port int `json:"port"` - Member string `json:"member"` -} - -// registerAttempts is how many times key registration is tried before giving -// up. A workspace boots at the same moment its network does, so the first call -// routinely races DNS or a tunnel coming up; one attempt turns that race into -// a workspace with no broker for its whole life, because nothing retries -// afterwards. -const registerAttempts = 3 - -// registerRetryDelay is the pause between attempts. Deliberately short: this -// runs on the boot path with other services waiting behind it, and the failure -// it covers is a few hundred milliseconds of network, not an outage. An outage -// is what the no-broker path below is for. -var registerRetryDelay = 500 * time.Millisecond - -// Register enrols this workspace with the credential broker and points the -// harnesses at it. -// -// It generates the workspace's keypairs and registers only the PUBLIC halves; -// the private halves are written here and never leave. It is idempotent — -// existing keys are reused — so a re-attach does not invalidate the lines the -// broker already has. -// -// NOTHING ABOUT IT IS FATAL BY DESIGN. The broker is optional: no enrolled -// broker, or every broker full, means the feature is off for this box, and the -// right outcome is a workspace that runs signed out with no stale wiring left -// behind. See ErrNoBrokerCapacity. -func Register(ctx context.Context, stateDir string, httpClient *http.Client) error { - origin, err := store.LoadOrigin(stateDir) - if err != nil { - return err - } - client, err := controlplane.New(origin, stateDir, httpClient) - if err != nil { - return err - } - mintPublic, err := ensureKeyPair(stateDir, MintKeyFile) - if err != nil { - return err - } - depositPublic, err := ensureKeyPair(stateDir, DepositKeyFile) - if err != nil { - return err - } - keys := []feed.Key{ - {Pubkey: mintPublic, Op: "mint"}, - {Pubkey: depositPublic, Op: "deposit"}, - } - var registered controlplane.KeyRegistration - for attempt := 1; ; attempt++ { - registered, err = client.RegisterKeys(ctx, keys) - if err == nil || errors.Is(err, controlplane.ErrNoBrokerCapacity) { - break - } - // Not retryable: a refusal is the same refusal next time, and the - // caller's context going away means the box is shutting down. - if attempt >= registerAttempts || ctx.Err() != nil { - break - } - timer := time.NewTimer(registerRetryDelay) - select { - case <-ctx.Done(): - timer.Stop() - case <-timer.C: - } - } - if errors.Is(err, controlplane.ErrNoBrokerCapacity) { - // Remove the wiring rather than leaving it: a broker.json pointing at - // a box this workspace is no longer a member of would make every mint - // fail slowly, on a host that has no account for it. Gone is honest. - return clearBrokerWiring(stateDir) - } - if err != nil { - return err - } - knownHost := registered.Host - if registered.Port != 22 { - knownHost = "[" + strings.Trim(registered.Host, "[]") + "]:" + strconv.Itoa(registered.Port) - } - if err := atomicfile.Write(filepath.Join(stateDir, KnownHostsFile), []byte(knownHost+" "+registered.SSHHostPublicKey+"\n"), 0o600); err != nil { - return err - } - config, err := json.Marshal(BrokerConfig{Host: registered.Host, Port: registered.Port, Member: registered.MemberUnixName}) - if err != nil { - return err - } - if err := atomicfile.Write(filepath.Join(stateDir, BrokerFile), append(config, '\n'), 0o600); err != nil { - return err - } - return wireHarnesses(homeDir(stateDir)) -} - -// clearBrokerWiring removes everything that says "there is a broker" — the -// config the mint path reads, the pinned host key, and the harness-side -// wiring. The keypairs stay: they are this workspace's identity, they are -// registered nowhere yet, and regenerating them on the next boot would only -// churn. -func clearBrokerWiring(stateDir string) error { - var failures []error - for _, name := range []string{BrokerFile, KnownHostsFile} { - if err := os.Remove(filepath.Join(stateDir, name)); err != nil && !errors.Is(err, os.ErrNotExist) { - failures = append(failures, err) - } - } - if err := unwireHarnesses(homeDir(stateDir)); err != nil { - failures = append(failures, err) - } - return errors.Join(failures...) -} - -// homeDir is where the workspace account's dotfiles live. The box runs the -// register oneshot with HOME already pointed here, so honour it and fall back -// to the state directory's own home only when it is unset. -func homeDir(stateDir string) string { - if home := os.Getenv("HOME"); home != "" { - return home - } - return filepath.Join(stateDir, "home") -} - -func ensureKeyPair(stateDir, name string) (string, error) { - if err := store.EnsureDir(stateDir); err != nil { - return "", err - } - privatePath := filepath.Join(stateDir, name) - publicPath := privatePath + ".pub" - privateInfo, privateErr := os.Stat(privatePath) - publicData, publicErr := os.ReadFile(publicPath) - if privateErr == nil { - if !privateInfo.Mode().IsRegular() { - return "", errors.New("SSH private key is not a regular file") - } - derived, err := derivePublic(privatePath) - if err != nil { - return "", err - } - if publicErr == nil { - if !samePublicKey(derived, string(publicData)) { - return "", errors.New("SSH public key does not match its private key") - } - } else if errors.Is(publicErr, os.ErrNotExist) { - if err := atomicfile.Write(publicPath, []byte(derived+"\n"), 0o600); err != nil { - return "", err - } - } else { - return "", publicErr - } - if err := os.Chmod(privatePath, 0o600); err != nil { - return "", err - } - if err := os.Chmod(publicPath, 0o600); err != nil { - return "", err - } - return strings.TrimSpace(derived), nil - } - if !errors.Is(privateErr, os.ErrNotExist) { - return "", privateErr - } - stage, err := os.MkdirTemp(stateDir, ".keygen-*") - if err != nil { - return "", err - } - defer os.RemoveAll(stage) - stagedPrivate := filepath.Join(stage, "key") - cmd := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "blitz-credential", "-f", stagedPrivate) - cmd.Stdout = io.Discard - cmd.Stderr = io.Discard - if err := cmd.Run(); err != nil { - return "", errors.New("ssh-keygen failed") - } - privateData, err := os.ReadFile(stagedPrivate) - if err != nil { - return "", err - } - publicData, err = os.ReadFile(stagedPrivate + ".pub") - if err != nil { - return "", err - } - public := strings.TrimSpace(string(publicData)) - if !feed.ValidPublicKey(public) { - return "", errors.New("ssh-keygen produced an invalid public key") - } - if err := atomicfile.Write(publicPath, []byte(public+"\n"), 0o600); err != nil { - return "", err - } - if err := atomicfile.Write(privatePath, privateData, 0o600); err != nil { - return "", err - } - return public, nil -} - -func derivePublic(privatePath string) (string, error) { - cmd := exec.Command("ssh-keygen", "-y", "-f", privatePath) - var output bytes.Buffer - cmd.Stdout = &output - cmd.Stderr = io.Discard - if err := cmd.Run(); err != nil { - return "", errors.New("existing SSH private key is invalid") - } - if output.Len() > credentialMaxSize { - return "", errors.New("SSH public key is too large") - } - public := strings.TrimSpace(output.String()) - if !feed.ValidPublicKey(public) { - return "", errors.New("existing SSH private key produced an invalid public key") - } - return public, nil -} - -func samePublicKey(left, right string) bool { - leftFields := strings.Fields(left) - rightFields := strings.Fields(right) - return len(leftFields) >= 2 && len(rightFields) >= 2 && leftFields[0] == rightFields[0] && leftFields[1] == rightFields[1] -} - -func LoadBroker(stateDir string) (BrokerConfig, error) { - data, err := os.ReadFile(filepath.Join(stateDir, BrokerFile)) - if err != nil { - return BrokerConfig{}, err - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - var config BrokerConfig - if err := decoder.Decode(&config); err != nil { - return BrokerConfig{}, errors.New("invalid broker config") - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return BrokerConfig{}, errors.New("invalid broker config") - } - if config.Host == "" || strings.ContainsAny(config.Host, " \t\r\n") || config.Port < 1 || config.Port > 65535 || !feed.ValidUnixName(config.Member) { - return BrokerConfig{}, errors.New("invalid broker config") - } - return config, nil -} - -func keyPath(stateDir, operation string) (string, error) { - switch operation { - case "mint": - return filepath.Join(stateDir, MintKeyFile), nil - case "deposit": - return filepath.Join(stateDir, DepositKeyFile), nil - default: - return "", fmt.Errorf("unknown broker operation %q", operation) - } -} diff --git a/packages/broker/internal/workspace/roaming_test.go b/packages/broker/internal/workspace/roaming_test.go deleted file mode 100644 index 0b6110b9..00000000 --- a/packages/broker/internal/workspace/roaming_test.go +++ /dev/null @@ -1,438 +0,0 @@ -package workspace - -import ( - "context" - "crypto/sha256" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/blitzdotdev/blitz-core/broker/internal/store" - "github.com/blitzdotdev/blitz-core/broker/internal/vendor" -) - -// TestRegisterTreatsNoBrokerCapacityAsACleanSkip is the resilience property the -// whole boot path depends on: the broker is OPTIONAL. Zero enrolled brokers is -// how the feature is turned off, and every broker being full is the same -// answer. Neither may leave a workspace that failed to start — a signed-out -// workspace is one a human can fix from inside, and a workspace whose services -// refused to run is not. -func TestRegisterTreatsNoBrokerCapacityAsACleanSkip(t *testing.T) { - stateDir := t.TempDir() - home := t.TempDir() - t.Setenv("HOME", home) - seedBox(t, stateDir) - - // Wiring from a previous, successful registration. - writeFile(t, filepath.Join(stateDir, BrokerFile), `{"host":"old.example","port":22,"member":"m-0123456789ab"}`) - writeFile(t, filepath.Join(stateDir, KnownHostsFile), "old.example ssh-ed25519 AAAA\n") - writeFile(t, filepath.Join(home, codexConfigPath), codexHead+"\nmodel = \"gpt-5\"\n"+codexTail+"\n") - - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - writer.WriteHeader(http.StatusConflict) - _, _ = writer.Write([]byte(`{"error":"no_broker_capacity","retryAction":null}`)) - })) - defer server.Close() - if err := store.SaveOrigin(stateDir, server.URL); err != nil { - t.Fatal(err) - } - - if err := Register(context.Background(), stateDir, server.Client()); err != nil { - t.Fatalf("Register failed on a capacity refusal instead of skipping: %v", err) - } - - // The stale wiring is GONE. Leaving it would point every mint at a box - // that has no account for this member, failing slowly instead of clearly. - for _, name := range []string{BrokerFile, KnownHostsFile} { - if _, err := os.Stat(filepath.Join(stateDir, name)); !os.IsNotExist(err) { - t.Errorf("%s survived a capacity refusal", name) - } - } - // The member's own codex settings survive; only our block goes. - config, err := os.ReadFile(filepath.Join(home, codexConfigPath)) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(config), codexAuthCommand) { - t.Errorf("the codex broker block survived a capacity refusal: %q", config) - } - if !strings.Contains(string(config), `model = "gpt-5"`) { - t.Errorf("the member's own codex settings were destroyed: %q", config) - } - // The keypairs stay: they are this workspace's identity, and churning them - // every boot would invalidate lines a broker may already hold. - if _, err := os.Stat(filepath.Join(stateDir, MintKeyFile)); err != nil { - t.Errorf("the workspace keypair was discarded: %v", err) - } -} - -// TestRegisterRetriesATransientFailure covers the boot race: a workspace -// registers at the moment its own network is coming up, and nothing retries -// afterwards, so one attempt turns a lost half-second into a box with no -// broker for its entire life. -func TestRegisterRetriesATransientFailure(t *testing.T) { - stateDir := t.TempDir() - t.Setenv("HOME", t.TempDir()) - seedBox(t, stateDir) - previous := registerRetryDelay - registerRetryDelay = time.Millisecond - defer func() { registerRetryDelay = previous }() - - var calls int - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - calls++ - if calls < registerAttempts { - writer.WriteHeader(http.StatusBadGateway) - return - } - _, _ = writer.Write([]byte(`{"memberUnixName":"m-0123456789ab","broker":{"host":"broker.example","port":22,"sshHostPublicKey":"ssh-ed25519 AAAA"}}`)) - })) - defer server.Close() - if err := store.SaveOrigin(stateDir, server.URL); err != nil { - t.Fatal(err) - } - - if err := Register(context.Background(), stateDir, server.Client()); err != nil { - t.Fatal(err) - } - if calls != registerAttempts { - t.Fatalf("registration attempts = %d, want %d", calls, registerAttempts) - } - if _, err := os.Stat(filepath.Join(stateDir, BrokerFile)); err != nil { - t.Fatalf("a successful retry wrote no broker config: %v", err) - } -} - -// TestRegisterWritesTheCodexPullHookInTwoRegions pins the shape codex needs. -// TOML is position-sensitive: a bare key after a [table] header belongs to that -// table, so the bare `model_provider` has to sit above the member's own content -// and the provider tables below it. One region would swallow whatever is -// between them. -func TestRegisterWritesTheCodexPullHookInTwoRegions(t *testing.T) { - home := t.TempDir() - path := filepath.Join(home, codexConfigPath) - writeFile(t, path, "model_provider = \"mine\"\nmodel = \"gpt-5\"\n\n[tui]\nnotifications = true\n") - - if err := wireHarnesses(home); err != nil { - t.Fatal(err) - } - rendered, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - config := string(rendered) - - headAt := strings.Index(config, codexHeadBegin) - ownAt := strings.Index(config, "[tui]") - tailAt := strings.Index(config, codexTailBegin) - if headAt < 0 || ownAt < 0 || tailAt < 0 || !(headAt < ownAt && ownAt < tailAt) { - t.Fatalf("regions are out of order: %q", config) - } - // Read off the constant, not a copy of its value: the literal used to be - // written out twice, so tuning codexRefreshInterval moved nothing and no - // test noticed. - if !strings.Contains(config, "refresh_interval_ms = "+strconv.Itoa(codexRefreshInterval)) { - t.Errorf("the pull hook does not carry codexRefreshInterval: %q", config) - } - if !strings.Contains(config, `command = "`+codexAuthCommand+`"`) { - t.Errorf("the pull hook does not call the broker: %q", config) - } - // Exactly one LIVE top-level model_provider, and it is ours: a second one - // is a duplicate-key parse error and codex would refuse the whole file. - if live := liveModelProviderLines(config); len(live) != 1 || live[0] != `model_provider = "blitz"` { - t.Errorf("live top-level model_provider lines = %q, want only the broker's: %q", live, config) - } - // The member's own value is not destroyed, it is parked in a comment the - // marked region owns, which is what unwireHarnesses gives back. - if !strings.Contains(config, codexPreservedPrefix+`model_provider = "mine"`) { - t.Errorf("the member's own model_provider was deleted with no way back: %q", config) - } - if !strings.Contains(config, `model = "gpt-5"`) || !strings.Contains(config, "notifications = true") { - t.Errorf("the member's own settings were lost: %q", config) - } - - // Re-registering replaces exactly our lines and is otherwise a no-op. - if err := wireHarnesses(home); err != nil { - t.Fatal(err) - } - again, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(again) != config { - t.Fatalf("re-registering changed the file:\nfirst: %q\nsecond: %q", config, again) - } - if strings.Count(string(again), codexHeadBegin) != 1 || strings.Count(string(again), codexTailBegin) != 1 { - t.Fatalf("re-registering duplicated a region: %q", again) - } - // The preserved line has to be read back out of the region it lives in on - // every later wire. Reading it from the wrong place, or emitting it without - // stripping the previous one, shows up here as two. - if strings.Count(string(again), codexPreservedPrefix) != 1 { - t.Fatalf("re-registering did not keep exactly one preserved model_provider: %q", again) - } -} - -// TestUnwireGivesTheMemberBackTheirOwnModelProvider is the recovery path for a -// box that later lands in no_broker_capacity. Wiring MUST delete the member's -// top-level model_provider, because a second one is a duplicate-key parse error -// that makes codex refuse the whole file; unwiring without putting it back -// leaves codex on its default provider, with the member's own choice gone from -// the only place it was written down and nothing on the box able to say what it -// had been. -func TestUnwireGivesTheMemberBackTheirOwnModelProvider(t *testing.T) { - home := t.TempDir() - path := filepath.Join(home, codexConfigPath) - writeFile(t, path, "model_provider = \"mine\"\nmodel = \"gpt-5\"\n\n[tui]\nnotifications = true\n") - - if err := wireHarnesses(home); err != nil { - t.Fatal(err) - } - if err := unwireHarnesses(home); err != nil { - t.Fatal(err) - } - restored, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - config := string(restored) - // Top-level again, above every [table] header: a bare key below one belongs - // to that table and would configure something else entirely. - if got := topLevelModelProvider(config); got != `model_provider = "mine"` { - t.Fatalf("restored top-level model_provider = %q, want the member's own: %q", got, config) - } - if strings.Contains(config, codexAuthCommand) || strings.Contains(config, codexPreservedPrefix) { - t.Errorf("unwiring left the broker's own lines behind: %q", config) - } - if !strings.Contains(config, `model = "gpt-5"`) || !strings.Contains(config, "notifications = true") { - t.Errorf("the member's other settings were lost: %q", config) - } -} - -// TestWireInventsNoModelProviderTheMemberNeverSet keeps the preserved line a -// record of something the member wrote rather than a default. A file with no -// top-level model_provider that came back from an unwire carrying one would be -// configured by this code for a provider nobody chose. -func TestWireInventsNoModelProviderTheMemberNeverSet(t *testing.T) { - home := t.TempDir() - path := filepath.Join(home, codexConfigPath) - const own = "model = \"gpt-5\"\n" - writeFile(t, path, own) - - if err := wireHarnesses(home); err != nil { - t.Fatal(err) - } - wired, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(wired), codexPreservedPrefix) { - t.Fatalf("wiring invented a preserved model_provider: %q", wired) - } - if err := unwireHarnesses(home); err != nil { - t.Fatal(err) - } - unwired, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(unwired) != own { - t.Fatalf("unwiring a file with nothing preserved changed it: %q, want %q", unwired, own) - } -} - -// TestTokenStripsTheMintReplysLineTerminator pins the Go half of the -// trailing-newline chain. `blitz-broker mint` ends its reply with fmt.Fprintln, -// and everything downstream copies these bytes into CLAUDE_CODE_OAUTH_TOKEN -// verbatim — including callers that set the variable directly, where a newline -// reaches the vendor inside an Authorization header and is rejected with an -// error naming nothing on this box. The terminal shim only ever survived -// because $(...) strips it by accident. -func TestTokenStripsTheMintReplysLineTerminator(t *testing.T) { - stateDir := seedBrokerWiring(t) - fakeSSH(t, "printf '%s\\n' 'sk-ant-oat01-live'\n") - - token, err := Token(context.Background(), stateDir, "claude") - if err != nil { - t.Fatal(err) - } - if string(token) != "sk-ant-oat01-live" { - t.Fatalf("token = %q, want it stripped of the line terminator", token) - } -} - -// TestTokenRefusesAReplyThatHoldsOnlyATerminator covers what trimming exposes: -// a reply of one newline is not empty on the wire, so the length check inside -// runSSH lets it through, and the caller would set CLAUDE_CODE_OAUTH_TOKEN to -// the empty string — a workspace that looks signed in and fails every call. -func TestTokenRefusesAReplyThatHoldsOnlyATerminator(t *testing.T) { - stateDir := seedBrokerWiring(t) - fakeSSH(t, "printf '\\n'\n") - - if _, err := Token(context.Background(), stateDir, "claude"); err == nil { - t.Fatal("Token accepted a mint reply that carried no token") - } -} - -// seedBrokerWiring writes the state a mint needs to reach the ssh binary: a -// broker to dial and a key to dial it with. -func seedBrokerWiring(t *testing.T) string { - t.Helper() - stateDir := t.TempDir() - writeFile(t, filepath.Join(stateDir, BrokerFile), `{"host":"broker.example","port":22,"member":"m-0123456789ab"}`) - writeFile(t, filepath.Join(stateDir, KnownHostsFile), "broker.example ssh-ed25519 AAAA\n") - writeFile(t, filepath.Join(stateDir, MintKeyFile), "private") - return stateDir -} - -// fakeSSH puts a script named `ssh` first on PATH. runSSH resolves the binary -// by name, so this is the seam that lets a test drive the real reply-parsing -// path instead of a re-implementation of it. -func fakeSSH(t *testing.T, body string) { - t.Helper() - bin := t.TempDir() - if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte("#!/bin/sh\n"+body), 0o700); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) -} - -// liveModelProviderLines returns every model_provider assignment codex would -// parse as a real key, comments excluded. -func liveModelProviderLines(config string) []string { - var live []string - for _, line := range strings.Split(config, "\n") { - if isModelProviderAssignment(strings.TrimLeft(line, " \t")) { - live = append(live, line) - } - } - return live -} - -// TestWatcherDeletesTheWorkspaceCopyOnAck is "single copy by construction". The -// broker is the only thing that refreshes a credential and the only place a -// second workspace can get one; a workspace that kept its copy would be an -// unmanaged, never-refreshed replica sitting on a disposable VM. -func TestWatcherDeletesTheWorkspaceCopyOnAck(t *testing.T) { - home := t.TempDir() - path := filepath.Join(home, filepath.FromSlash(vendor.Claude.CredentialPath)) - writeFile(t, path, "a-login") - - var deposits int - watcher := NewWatcher(home, func(context.Context, string, []byte) error { - deposits++ - return nil - }) - if err := watcher.Tick(context.Background()); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("the workspace kept its copy after the broker ACKed: %v", err) - } - - // A later login producing byte-identical content is still a login. The - // remembered digest must not suppress it. - writeFile(t, path, "a-login") - if err := watcher.Tick(context.Background()); err != nil { - t.Fatal(err) - } - if deposits != 2 { - t.Fatalf("deposits = %d, want 2", deposits) - } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatal("the second copy was not removed") - } -} - -// TestWatcherKeepsACopyTheBrokerNeverReceived is the other half. A login that -// lands DURING a deposit leaves different bytes on disk; deleting those would -// destroy a credential the broker never got, from the only two places it -// exists at once. -func TestWatcherKeepsACopyTheBrokerNeverReceived(t *testing.T) { - home := t.TempDir() - path := filepath.Join(home, filepath.FromSlash(vendor.Claude.CredentialPath)) - writeFile(t, path, "old-login") - - watcher := NewWatcher(home, func(context.Context, string, []byte) error { - writeFile(t, path, "fresher-login") - return nil - }) - if err := watcher.Tick(context.Background()); err != nil { - t.Fatal(err) - } - got, err := os.ReadFile(path) - if err != nil { - t.Fatalf("a login that raced the deposit was deleted: %v", err) - } - if string(got) != "fresher-login" { - t.Fatalf("credential = %q, want the fresher login", got) - } -} - -// TestWatcherReportsACopyItCouldNotRemove: the broker has it and so does this -// workspace, which is exactly the state the design forbids. It has to be -// audible, and it must not turn into a deposit every second. -func TestWatcherReportsACopyItCouldNotRemove(t *testing.T) { - home := t.TempDir() - path := filepath.Join(home, filepath.FromSlash(vendor.Claude.CredentialPath)) - writeFile(t, path, "a-login") - if err := os.Chmod(filepath.Dir(path), 0o500); err != nil { - t.Fatal(err) - } - defer os.Chmod(filepath.Dir(path), 0o700) - - var deposits int - watcher := NewWatcher(home, func(context.Context, string, []byte) error { - deposits++ - return nil - }) - if err := watcher.Tick(context.Background()); err == nil { - t.Fatal("an unremovable workspace copy was reported as success") - } - if err := watcher.Tick(context.Background()); err != nil { - t.Fatalf("the second tick re-reported a copy it had already deposited: %v", err) - } - if deposits != 1 { - t.Fatalf("deposits = %d, want 1 — the watcher hammered the broker", deposits) - } -} - -func TestRemoveIfUnchangedLeavesAFileThatMovedOn(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "credential") - writeFile(t, path, "new") - removed, err := removeIfUnchanged(path, sha256.Sum256([]byte("old"))) - if err != nil { - t.Fatal(err) - } - if removed { - t.Fatal("removeIfUnchanged deleted a file whose contents had changed") - } - if _, err := os.Stat(path); err != nil { - t.Fatalf("the file is gone: %v", err) - } -} - -func seedBox(t *testing.T, stateDir string) { - t.Helper() - if err := store.SaveCredential(stateDir, store.Credential{ - BoxID: "box", AccessToken: "access", RefreshToken: "refresh", - }); err != nil { - t.Fatal(err) - } -} - -func writeFile(t *testing.T, path, content string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatal(err) - } -} diff --git a/packages/broker/internal/workspace/ssh.go b/packages/broker/internal/workspace/ssh.go deleted file mode 100644 index 28badcca..00000000 --- a/packages/broker/internal/workspace/ssh.go +++ /dev/null @@ -1,176 +0,0 @@ -package workspace - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "os/exec" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/blitzdotdev/blitz-core/broker/internal/feed" -) - -const sshTimeout = 60 * time.Second - -func Token(ctx context.Context, stateDir, harness string) ([]byte, error) { - if !feed.ValidHarness(harness) { - return nil, errors.New("invalid token request") - } - output, err := runSSH(ctx, stateDir, "mint", harness, nil) - if err != nil { - return nil, err - } - token := trimMintedToken(output) - if len(token) == 0 { - return nil, errors.New("broker minted an empty access token") - } - return token, nil -} - -// trimMintedToken strips the mint reply's line terminator, and any other -// surrounding whitespace, off the bytes the broker wrote to stdout. -// -// The mint reply is line-oriented: `blitz-broker mint` ends the token with -// fmt.Fprintln (cmd/blitz-broker/main.go), so the newline is the terminator and -// is never part of the token. Everything downstream of this function copies -// these bytes VERBATIM into CLAUDE_CODE_OAUTH_TOKEN, and only one of those -// consumers strips anything by accident: the PATH shim substitutes the helper -// with $(...), which eats trailing newlines, while a caller that sets the -// variable directly keeps them. A token with a trailing newline reaches the vendor -// as an Authorization header value containing a newline — rejected, with an -// error that names nothing on this box. -// -// Trimming here rather than at each consumer is what makes that impossible to -// reintroduce: this is the single point every workspace-side caller of the mint -// verb goes through. Deposit's ACK is deliberately NOT trimmed — "ok\n" is an -// exact wire contract with the broker, not a payload. -func trimMintedToken(output []byte) []byte { - return bytes.TrimSpace(output) -} - -func Deposit(ctx context.Context, stateDir, harness string, blob []byte) error { - if !feed.ValidHarness(harness) || len(blob) > credentialMaxSize { - return errors.New("invalid deposit request") - } - output, err := runSSH(ctx, stateDir, "deposit", harness, bytes.NewReader(blob)) - if err != nil { - return err - } - if string(output) != "ok\n" { - return errors.New("broker returned an invalid deposit acknowledgement") - } - return nil -} - -func runSSH(parent context.Context, stateDir, operation, remoteCommand string, input io.Reader) ([]byte, error) { - broker, err := LoadBroker(stateDir) - if err != nil { - return nil, err - } - identity, err := keyPath(stateDir, operation) - if err != nil { - return nil, err - } - ctx, cancel := context.WithTimeout(parent, sshTimeout) - defer cancel() - cmd := exec.CommandContext(ctx, "ssh", sshArguments(stateDir, identity, broker, remoteCommand)...) - cmd.Stdin = input - var output cappedBuffer - var diagnostic cappedBuffer - cmd.Stdout = &output - // The broker's stderr, kept. Without it every broker-side refusal — - // "requested harness is not allowed", "the credential lock timed out", - // "the incoming credential's refresh token has already expired" — arrived - // here as the single word "failed", and the box had no way to tell a - // member who must log in again from a box wired to the wrong harness. - // - // It is a DIAGNOSTIC channel, not a credential one: the token only ever - // travels on stdout, so nothing minted can be routed here, and the buffer - // is capped like stdout so a chatty remote cannot make this process - // allocate without bound. - cmd.Stderr = &diagnostic - if err := cmd.Run(); err != nil { - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return nil, errors.New("broker SSH request timed out") - } - if reason := brokerReason(diagnostic); reason != "" { - return nil, fmt.Errorf("broker SSH request failed: %s", reason) - } - return nil, errors.New("broker SSH request failed") - } - if output.exceeded { - return nil, errors.New("broker SSH response is too large") - } - if len(output.Bytes()) == 0 { - return nil, errors.New("broker SSH response is empty") - } - return output.Bytes(), nil -} - -// brokerReason turns the remote's stderr into one short line fit for a log. -// -// The LAST non-empty line, because ssh writes its own connection chatter first -// and the broker's own message is what a reader needs. Bounded and stripped of -// control characters so a hostile remote cannot forge log lines or repaint a -// terminal through this path. -func brokerReason(diagnostic cappedBuffer) string { - const maxReason = 200 - var reason string - for _, line := range strings.Split(diagnostic.String(), "\n") { - if trimmed := strings.TrimSpace(line); trimmed != "" { - reason = trimmed - } - } - reason = strings.Map(func(r rune) rune { - if r < 0x20 || r == 0x7f { - return -1 - } - return r - }, reason) - if len(reason) > maxReason { - reason = reason[:maxReason] + "…" - } - return reason -} - -func sshArguments(stateDir, identity string, broker BrokerConfig, remoteCommand string) []string { - return []string{ - "-T", - "-i", identity, - "-o", "IdentitiesOnly=yes", - "-o", "BatchMode=yes", - "-o", "StrictHostKeyChecking=yes", - "-o", "UserKnownHostsFile=" + filepath.Join(stateDir, KnownHostsFile), - "-o", "GlobalKnownHostsFile=/dev/null", - "-o", "PasswordAuthentication=no", - "-o", "ConnectTimeout=55", - "-p", strconv.Itoa(broker.Port), - broker.Member + "@" + broker.Host, - remoteCommand, - } -} - -type cappedBuffer struct { - bytes.Buffer - exceeded bool -} - -func (buffer *cappedBuffer) Write(data []byte) (int, error) { - original := len(data) - remaining := credentialMaxSize + 1 - buffer.Len() - if remaining <= 0 { - buffer.exceeded = true - return original, nil - } - if len(data) > remaining { - data = data[:remaining] - buffer.exceeded = true - } - _, _ = buffer.Buffer.Write(data) - return original, nil -} diff --git a/packages/broker/internal/workspace/watch.go b/packages/broker/internal/workspace/watch.go deleted file mode 100644 index 9b95f0a2..00000000 --- a/packages/broker/internal/workspace/watch.go +++ /dev/null @@ -1,136 +0,0 @@ -package workspace - -import ( - "context" - "crypto/sha256" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "time" - - "github.com/blitzdotdev/blitz-core/broker/internal/vendor" -) - -type Depositor func(context.Context, string, []byte) error - -type Watcher struct { - Home string - Deposit Depositor - deposited map[string][sha256.Size]byte -} - -func NewWatcher(home string, deposit Depositor) *Watcher { - return &Watcher{Home: home, Deposit: deposit, deposited: make(map[string][sha256.Size]byte)} -} - -// Tick deposits any credential that changed since the last pass and then -// DELETES the workspace's copy. -// -// The delete is the point, not housekeeping. The broker is meant to hold the -// only copy of a member's refresh token: it is the only thing that refreshes, -// the only thing that can blank one, and the only place a second workspace can -// get one from. A workspace that kept its copy would be a second, unmanaged, -// never-refreshed replica of the credential on a disposable VM. -// -// The copy is removed only when the file still holds exactly the bytes that -// were ACKed. A login that lands DURING the deposit leaves different bytes on -// disk, and deleting those would destroy a credential the broker never -// received; the next tick deposits it instead. -func (watcher *Watcher) Tick(ctx context.Context) error { - var failures []error - // The credential paths come off the vendor Definition table, not a config - // file: the broker's mint, the broker's deposit and this watcher then read - // one field, and the list cannot go missing or drift out from under them. - for _, definition := range []vendor.Definition{vendor.Claude, vendor.Codex} { - path := filepath.Join(watcher.Home, filepath.FromSlash(definition.CredentialPath)) - blob, err := readWatchedFile(path) - if errors.Is(err, os.ErrNotExist) { - continue - } - if err != nil { - failures = append(failures, err) - continue - } - digest := sha256.Sum256(blob) - if previous, ok := watcher.deposited[definition.Name]; ok && previous == digest { - continue - } - if err := watcher.Deposit(ctx, definition.Name, blob); err != nil { - failures = append(failures, err) - continue - } - // Record the bytes sent, not a post-ACK reread. A concurrent login is - // sent next tick. - watcher.deposited[definition.Name] = digest - - removed, err := removeIfUnchanged(path, digest) - if err != nil { - // The broker has it; this workspace also still has it. Say so - // rather than re-depositing every second, which would tell the - // broker nothing new and hammer it. - failures = append(failures, fmt.Errorf("could not remove the workspace copy of the %s credential: %w", definition.Name, err)) - continue - } - if removed { - // Forget the digest with the file. A later login that happens to - // produce byte-identical content is still a login, and it must be - // deposited rather than skipped as already-seen. - delete(watcher.deposited, definition.Name) - } - } - return errors.Join(failures...) -} - -// removeIfUnchanged deletes path only if it still hashes to digest. Reporting -// false without an error means the file moved on under us and the caller must -// leave it alone. -func removeIfUnchanged(path string, digest [sha256.Size]byte) (bool, error) { - current, err := readWatchedFile(path) - if errors.Is(err, os.ErrNotExist) { - return true, nil - } - if err != nil { - return false, err - } - if sha256.Sum256(current) != digest { - return false, nil - } - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return false, err - } - return true, nil -} - -func Watch(ctx context.Context, stateDir, home string) error { - watcher := NewWatcher(home, func(callContext context.Context, harness string, blob []byte) error { - return Deposit(callContext, stateDir, harness, blob) - }) - for { - _ = watcher.Tick(ctx) - timer := time.NewTimer(time.Second) - select { - case <-ctx.Done(): - timer.Stop() - return ctx.Err() - case <-timer.C: - } - } -} - -func readWatchedFile(path string) ([]byte, error) { - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - data, err := io.ReadAll(io.LimitReader(file, credentialMaxSize+1)) - if err != nil { - return nil, err - } - if len(data) > credentialMaxSize { - return nil, errors.New("vendor credential exceeds 1 MiB") - } - return data, nil -} diff --git a/packages/broker/internal/workspace/workspace_test.go b/packages/broker/internal/workspace/workspace_test.go deleted file mode 100644 index 335be39a..00000000 --- a/packages/broker/internal/workspace/workspace_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package workspace - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "slices" - "testing" - - "github.com/blitzdotdev/blitz-core/broker/internal/feed" - "github.com/blitzdotdev/blitz-core/broker/internal/store" - "github.com/blitzdotdev/blitz-core/broker/internal/vendor" -) - -func TestWatcherRedepositsLoginThatChangesDuringDeposit(t *testing.T) { - home := t.TempDir() - path := filepath.Join(home, filepath.FromSlash(vendor.Claude.CredentialPath)) - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("old-login"), 0o600); err != nil { - t.Fatal(err) - } - var deposits [][]byte - watcher := NewWatcher(home, func(_ context.Context, harness string, blob []byte) error { - if harness != "claude" { - t.Fatalf("harness = %q", harness) - } - deposits = append(deposits, append([]byte(nil), blob...)) - if len(deposits) == 1 { - if err := os.WriteFile(path, []byte("fresher-login"), 0o600); err != nil { - t.Fatal(err) - } - } - return nil - }) - if err := watcher.Tick(context.Background()); err != nil { - t.Fatal(err) - } - if err := watcher.Tick(context.Background()); err != nil { - t.Fatal(err) - } - if err := watcher.Tick(context.Background()); err != nil { - t.Fatal(err) - } - if len(deposits) != 2 || string(deposits[0]) != "old-login" || string(deposits[1]) != "fresher-login" { - t.Fatalf("deposits = %q", deposits) - } -} - -func TestSSHArgumentsPinOnlyTheRegisteredHostKey(t *testing.T) { - args := sshArguments("/state", "/state/mint_key", BrokerConfig{Host: "broker.example", Port: 2222, Member: "m-0123456789ab"}, "claude") - for _, required := range []string{ - "StrictHostKeyChecking=yes", - "UserKnownHostsFile=/state/known_hosts", - "GlobalKnownHostsFile=/dev/null", - "IdentitiesOnly=yes", - "ConnectTimeout=55", - } { - if !slices.Contains(args, required) { - t.Errorf("SSH args missing %q: %#v", required, args) - } - } - if args[len(args)-2] != "m-0123456789ab@broker.example" || args[len(args)-1] != "claude" { - t.Fatalf("SSH target/command = %#v", args[len(args)-2:]) - } -} - -func TestRegisterCreatesIdempotentKeysAndPinnedBrokerFiles(t *testing.T) { - stateDir := t.TempDir() - if err := store.SaveCredential(stateDir, store.Credential{BoxID: "box", AccessToken: "access", RefreshToken: "refresh"}); err != nil { - t.Fatal(err) - } - var calls int - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if request.URL.Path != "/boxes/box/keys" || request.Method != http.MethodPost { - http.NotFound(writer, request) - return - } - calls++ - if request.Header.Get("Authorization") != "Bearer access" { - t.Errorf("Authorization = %q", request.Header.Get("Authorization")) - } - var body struct { - Keys []feed.Key `json:"keys"` - } - data, _ := io.ReadAll(request.Body) - if err := json.Unmarshal(data, &body); err != nil { - t.Error(err) - } - if len(body.Keys) != 2 || body.Keys[0].Op != "mint" || body.Keys[1].Op != "deposit" || !feed.ValidPublicKey(body.Keys[0].Pubkey) || !feed.ValidPublicKey(body.Keys[1].Pubkey) { - t.Errorf("keys = %#v", body.Keys) - } - io.WriteString(writer, `{"memberUnixName":"m-0123456789ab","broker":{"host":"broker.example","port":2222,"sshHostPublicKey":"ssh-ed25519 AAAA"}}`) - })) - defer server.Close() - if err := store.SaveOrigin(stateDir, server.URL); err != nil { - t.Fatal(err) - } - if err := Register(context.Background(), stateDir, server.Client()); err != nil { - t.Fatal(err) - } - mintBefore, err := os.ReadFile(filepath.Join(stateDir, MintKeyFile)) - if err != nil { - t.Fatal(err) - } - if err := Register(context.Background(), stateDir, server.Client()); err != nil { - t.Fatal(err) - } - mintAfter, err := os.ReadFile(filepath.Join(stateDir, MintKeyFile)) - if err != nil { - t.Fatal(err) - } - if string(mintBefore) != string(mintAfter) || calls != 2 { - t.Fatalf("registration was not idempotent: calls=%d", calls) - } - for _, name := range []string{MintKeyFile, MintKeyFile + ".pub", DepositKeyFile, DepositKeyFile + ".pub"} { - info, err := os.Stat(filepath.Join(stateDir, name)) - if err != nil { - t.Fatal(err) - } - if info.Mode().Perm() != 0o600 { - t.Errorf("%s mode = %o", name, info.Mode().Perm()) - } - } - knownHosts, err := os.ReadFile(filepath.Join(stateDir, KnownHostsFile)) - if err != nil { - t.Fatal(err) - } - if string(knownHosts) != "[broker.example]:2222 ssh-ed25519 AAAA\n" { - t.Fatalf("known_hosts = %q", knownHosts) - } - config, err := LoadBroker(stateDir) - if err != nil { - t.Fatal(err) - } - if config != (BrokerConfig{Host: "broker.example", Port: 2222, Member: "m-0123456789ab"}) { - t.Fatalf("broker config = %#v", config) - } -} - -func TestLoadBrokerRejectsMissingMember(t *testing.T) { - stateDir := t.TempDir() - if err := os.WriteFile(filepath.Join(stateDir, BrokerFile), []byte(`{"host":"broker.example","port":22}`), 0o600); err != nil { - t.Fatal(err) - } - if _, err := LoadBroker(stateDir); err == nil { - t.Fatal("broker config without member was accepted") - } -} diff --git a/packages/broker/sshd_config b/packages/broker/sshd_config deleted file mode 100644 index 107c081b..00000000 --- a/packages/broker/sshd_config +++ /dev/null @@ -1,22 +0,0 @@ -Port 22 -Protocol 2 -PidFile /run/sshd.pid -UsePAM yes -PermitRootLogin no -PasswordAuthentication no -KbdInteractiveAuthentication no -PubkeyAuthentication yes -AuthenticationMethods publickey -AuthorizedKeysFile /etc/blitz-broker/authorized_keys/%u -StrictModes yes -PermitEmptyPasswords no -AllowAgentForwarding no -AllowTcpForwarding no -GatewayPorts no -X11Forwarding no -PermitTunnel no -PermitTTY no -PermitUserEnvironment no -UseDNS no -PrintMotd no -LogLevel ERROR diff --git a/packages/control-plane/README.md b/packages/control-plane/README.md index e5eff43c..3779a887 100644 --- a/packages/control-plane/README.md +++ b/packages/control-plane/README.md @@ -18,9 +18,6 @@ Self-hosters set `APP_URL` to their Worker origin. credential, so hosted enrollment needs no human. - Sessions: Google OAuth → HttpOnly session cookie. Signup policy is set by the `SIGNUP_MODE` and `ALLOWED_EMAIL_DOMAINS` vars. -- The broker registry: pubkeys and routing only, never a credential. Broker - boxes pull their own member slice; no box can list the fleet. - ## Standalone deployment Use a dedicated Hetzner project: janitor operations must never share a project diff --git a/packages/control-plane/RECORD.md b/packages/control-plane/RECORD.md index 77be81a8..7a395936 100644 --- a/packages/control-plane/RECORD.md +++ b/packages/control-plane/RECORD.md @@ -41,31 +41,9 @@ Decisions: `plans/PORT-DESIGN.md` (the original session notes are not in this re Passkeys DELETED (2026-08-11, implementation review): operator-key login only. Sessions stay opaque hashed rows; the principal seam admits passkey/SSO later without core changes. -- Credential broker registry. Purpose: a subscription account can auth agents in - every workspace its owner spawns. - - Core holds pubkeys + routing only. Never a credential. - - A workspace registers mint/deposit pubkeys. The owner comes from the - authenticated box row. Never from the body. - - Broker boxes PULL their member/key list. Feed auth = the box OAuth access - token (2026-08-11: one token family; the separate pull token is deleted). - ETag/304. The pull shape stops one rogue box from listing the fleet. - - Mint = forced-command SSH on the broker box. - - Members use the sessions principal seam. - - No key ceiling (founder, 2026-08-11). `expires_at` leaves the schema. A key - is valid while the feed serves it. Revocation = ON DELETE CASCADE + the feed. - - Enrollment API: register/remove a broker box (host, port, SSH host pubkey) - + set the broker role flag. A broker box is a box: it enrolls through the - same device flow; no separate pull token exists. This replaces raw D1 - inserts. `blitz-broker enroll` calls it. - - Registration auth = the box OAuth token (fixed 2026-08-11; the earlier - keypair line contradicted box decision 2). Rule: HTTP plane = tokens. - SSH plane = keypairs. - - Broker fleet ops stay closed. The Go daemon is open. Record: - `packages/broker/RECORD.md`. - Box identity: device-flow enrollment endpoints + box OAuth tokens. Short-lived access + rotating refresh. Opaque hashed rows, constant-time - compare. ONE token family serves every box→CP call: registry registration, - the broker pull feed (2026-08-11). + compare. One token family serves each device-code box call. - Readiness: cloud-init `phone_home`, one shot (decided 2026-08-11). The POST carries "boot finished" + the SSH host public keys. Target = a single-use capability URL, minted per provision. User-data is readable inside the VM, diff --git a/packages/control-plane/core/app.ts b/packages/control-plane/core/app.ts index c0575cc5..72cd4642 100644 --- a/packages/control-plane/core/app.ts +++ b/packages/control-plane/core/app.ts @@ -4,7 +4,6 @@ import { addAgentRuleLibraryRoutes, addAgentRulesRoutes } from "./agent-rules.js import { addBoxConfigRoutes } from "./box-config.js"; import { addBoxImageRoutes } from "./box-images.js"; import { addCredentialRoutes } from "./connections/mint.js"; -import { addWorkspaceEnvironmentRoutes } from "./environment.js"; import { addEntitlementsRoutes, SeatLimitReached, seatLimitEnvelope } from "./entitlements.js"; import { frameworkHttpError, HttpError } from "./http.js"; import { addMachineRoutes } from "./machines.js"; @@ -16,7 +15,6 @@ import { addOperatorTokenRoutes, findOperatorTokenPrincipal } from "./operator-t import type { Principal } from "./principals.js"; import { addOrgComputeCredentialRoutes } from "./compute/org-credentials.js"; import { addGrantProposalRoutes } from "./grant-proposals.js"; -import { addRegistryRoutes } from "./registry.js"; import type { CoreContext, CoreRouter, RuntimeFactory } from "./runtime.js"; import { addSessionRoutes } from "./sessions.js"; import { addVersionRoutes } from "./version.js"; @@ -97,9 +95,6 @@ export function installControlPlaneRoutes( addOAuthRoutes(router, runtimeFactory, requireMembershipPrincipal); addWebAppStateRoutes(router, runtimeFactory, requireMembershipPrincipal); addAgentRuleLibraryRoutes(router, runtimeFactory, requireMembershipPrincipal); - // Box-authenticated, so it is registered ahead of the session-authenticated - // /workspaces/:id routes it shares a prefix with. - addWorkspaceEnvironmentRoutes(router, runtimeFactory); // Mostly box-authenticated (/workspaces/self/*), registered ahead for the // same reason. Its session routes arm whole-workspace image updates and set // one machine's payload hold; neither collides with later registrations. @@ -131,8 +126,6 @@ export function installControlPlaneRoutes( addMachineRoutes(router, runtimeFactory, requireMembershipPrincipal); addCredentialRoutes(router, runtimeFactory, requireMembershipPrincipal); addVolumeRoutes(router, runtimeFactory, requireMembershipPrincipal); - addRegistryRoutes(router, runtimeFactory); - router.get("/machine-types", async (context) => { const principal = await requireMembershipPrincipal(context); if (principal.orgId === null) throw new HttpError(403, "active membership required"); diff --git a/packages/control-plane/core/bootstrap.ts b/packages/control-plane/core/bootstrap.ts index 1af87419..59281c1a 100644 --- a/packages/control-plane/core/bootstrap.ts +++ b/packages/control-plane/core/bootstrap.ts @@ -258,10 +258,10 @@ export function buildBootstrapScript(options: BootstrapOptions): string { // one detached best-effort retry loop inside the box: each pass skips // repos that already have a .git (idempotent across reboots), falls back // from Git's negotiated HTTP/2 to HTTP/1.1, and retries every 5s for up to - // 10 minutes, because cloning can only succeed once registration completes - // and the baked /etc/gitconfig credential helper (`blitz-git-credential`, - // CP-direct) can mint. `|| true` overall: a failed clone never fails the - // boot; output lands in /var/lib/blitz/repo-clone.log. + // 10 minutes. Cloning needs the provisioned machine credential. + // The baked /etc/gitconfig helper uses it through the control plane. + // `|| true` prevents a failed clone from stopping boot. + // Output goes to /var/lib/blitz/repo-clone.log. const repos = options.repos ?? []; for (const repo of repos) { // The save-time validator is the real gate; this re-check keeps the @@ -491,9 +491,8 @@ systemctl enable --now blitz-volume-shutdown.service mkdir -p /var/lib/blitz/workspace ${sshPublicKeyProvisioning} # A retained volume belongs to the previous box identity. Its token family is -# revoked when that workspace is destroyed, and allowing the box init to see -# those files makes its register one-shot fail before sshd can start. The new -# credentials are installed after this VM proves its host key to phone-home. +# revoked when that workspace is destroyed. Remove those unusable credentials. +# New credentials arrive after this VM proves its host key to phone-home. rm -f /var/lib/blitz/box-credential.json /var/lib/blitz/origin port_22_free() { @@ -695,8 +694,8 @@ log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >>"$UPDATE_LOG" } -# A box that has not registered yet has neither file; that is the normal -# pre-enrollment state, not an error. +# A box without provisioned machine credentials has neither file. +# This state is not an error. [ -s "$CREDENTIAL_PATH" ] || exit 0 [ -s "$ORIGIN_PATH" ] || exit 0 current_origin=$(sed -n '1p' "$ORIGIN_PATH") @@ -883,25 +882,6 @@ systemctl daemon-reload systemctl enable --now blitz-box-update.timer # ---- end host-side box updater ---- -echo "blitz bootstrap: credential registration poke start outer_timeout_seconds=40 inner_timeout_seconds=30" -register_status=0 -timeout --foreground --kill-after=5s 40s \ - docker exec \ - --user 1000:1000 \ - --env HOME=/var/lib/blitz/home \ - --env USER=blitz \ - blitz-box \ - timeout --foreground --kill-after=5s 30s \ - blitz-cred register || - { - register_status=$? - echo "blitz bootstrap: credential registration poke failed or timed out (exit $register_status); continuing bootstrap because registration poke is best-effort" - true - } -if (( register_status == 0 )); then - echo "blitz bootstrap: credential registration poke complete" -fi - trap - ERR echo "blitz bootstrap completed" `; diff --git a/packages/control-plane/core/environment.ts b/packages/control-plane/core/environment.ts index d5579f56..ca58fd3e 100644 --- a/packages/control-plane/core/environment.ts +++ b/packages/control-plane/core/environment.ts @@ -1,46 +1,4 @@ -import { HttpError } from "./http.js"; -import { authenticateBox } from "./oauth.js"; -import type { CoreRouter, RuntimeFactory } from "./runtime.js"; -import type { WorkspaceEnvironmentResponse } from "./wire.js"; - /** Largest legal create/update body: userData 48 KiB, a credential manifest, * a member roster and JSON escaping on top. JSON.parse runs before any of it * is validated, so the ceiling stays close to real. */ export const WORKSPACE_REQUEST_MAX_BYTES = 128 * 1024; - -/** - * The legacy workspace-environment route. - * - * The feature is gone: static secrets live in `org_credentials` and only the - * agent API serves them, and the startup script has no runner left. The - * route stays because DEPLOYED broker binaries poll it every second at boot - * and wait for a 200 carrying all three fields with `filesReady: true`. A 404 - * or a missing field makes every already-deployed box poll forever, so this - * answers the empty set unconditionally — no workspace lookup, no readiness - * gate, nothing that can turn into a retry. - * - * It is a compatibility shim with an expiry: it can go once no box that polls - * it is still running. - */ -export function addWorkspaceEnvironmentRoutes( - router: CoreRouter, - runtimeFactory: RuntimeFactory, -): void { - router.get("/workspaces/:id/environment", async (context) => { - const runtime = runtimeFactory(context); - const box = await authenticateBox(context.req.raw, runtime.db); - if (box === null) throw new HttpError(401, "invalid box access token"); - if (box.workspaceId === null) { - throw new HttpError(403, "box is not attached to a workspace"); - } - const idParam = context.req.param("id"); - if (idParam !== "self" && box.workspaceId !== idParam) { - throw new HttpError(403, "a box may only read its own workspace environment"); - } - return context.json({ - env: {}, - startupScript: null, - filesReady: true, - }); - }); -} diff --git a/packages/control-plane/core/janitors.ts b/packages/control-plane/core/janitors.ts index 754efb81..b374fa53 100644 --- a/packages/control-plane/core/janitors.ts +++ b/packages/control-plane/core/janitors.ts @@ -112,7 +112,6 @@ export async function runOrphanSweep(runtime: CoreRuntime): Promise { const transition = await transaction(runtime.db, [ revokeMachineLeasesQuery(row.id), { q: "DELETE FROM machine_token_families WHERE machine_id = ?1", v: [row.id] }, - { q: "DELETE FROM broker_keys WHERE machine_id = ?1", v: [row.id] }, { q: `UPDATE machines SET state = ?1, destroy_keeps_row = 0, vm_id = NULL, ssh_host = NULL, @@ -123,7 +122,7 @@ export async function runOrphanSweep(runtime: CoreRuntime): Promise { v: [finalState, Date.now(), row.id], }, ]); - if (transition[3]?.length !== 1) continue; + if (transition[2]?.length !== 1) continue; } else { await rows(runtime.db, { q: "UPDATE machines SET vm_id = NULL WHERE id = ?1", diff --git a/packages/control-plane/core/machines.ts b/packages/control-plane/core/machines.ts index 7d22d44f..0c6c413e 100644 --- a/packages/control-plane/core/machines.ts +++ b/packages/control-plane/core/machines.ts @@ -469,10 +469,6 @@ export async function destroyMachine( await transaction(runtime.db, [ revokeMachineLeasesQuery(machine.id), - // The guest's authorized_keys lines go with the VM. Destroy stays the - // revocation path for the broker: the keys leave, and the member's account - // survives because the feed is driven by `broker_members`, not by keys. - { q: "DELETE FROM broker_keys WHERE machine_id = ?1", v: [machine.id] }, { q: `UPDATE machines SET state = ?1, destroy_keeps_row = 0, vm_id = NULL, ssh_host = NULL, diff --git a/packages/control-plane/core/oauth.ts b/packages/control-plane/core/oauth.ts index 26c507e2..b361cf8a 100644 --- a/packages/control-plane/core/oauth.ts +++ b/packages/control-plane/core/oauth.ts @@ -48,7 +48,6 @@ interface BoxTokenRow { principal_id: string; workspace_id: string | null; membership_id: string | null; - is_broker: number; platform_operator: number; } @@ -138,9 +137,8 @@ async function refreshGrant( const db = runtimeFactory(context).db; const oldHash = await hashSecret(refreshToken); const now = Date.now(); - // A machine and a box present the same kind of token, so both families are - // asked. The machine family is the workspace guest; `box_token_families` is - // what is left of the old table — brokers and device-code enrolments. + // A machine and a device-code box present the same token type. Both token + // families must support the same refresh and crash-recovery rules. const row = await machineRefreshRow(db, oldHash) ?? await boxRefreshRow(db, oldHash); const slot = row === null ? null : await refreshSlot(refreshToken, row, now); if (row === null || slot === null) return oauthError(context, "invalid_grant"); @@ -209,7 +207,7 @@ async function machineRefreshRow(db: Db, hash: string): Promise { q: `SELECT f.access_hash, f.refresh_hash, f.access_issued_at, f.previous_refresh_hash, f.previous_rotated_at, 'box' AS family, b.id, b.principal_id, b.workspace_id, - NULL AS membership_id, b.is_broker, + NULL AS membership_id, COALESCE(u.platform_operator, 0) AS platform_operator FROM box_token_families f JOIN boxes b ON b.id = f.box_id @@ -245,7 +243,7 @@ export async function authenticateBox( const hash = await hashSecret(token); const row = await first(db, { q: `SELECT f.access_hash, f.access_issued_at, m.id, m.workspace_id, - m.membership_id, ms.user_id AS principal_id, 0 AS is_broker, + m.membership_id, ms.user_id AS principal_id, COALESCE(u.platform_operator, 0) AS platform_operator FROM machine_token_families f JOIN machines m ON m.id = f.machine_id @@ -255,7 +253,7 @@ export async function authenticateBox( v: [hash], }) ?? await first(db, { q: `SELECT f.access_hash, f.access_issued_at, b.id, b.principal_id, - b.workspace_id, NULL AS membership_id, b.is_broker, + b.workspace_id, NULL AS membership_id, COALESCE(u.platform_operator, 0) AS platform_operator FROM box_token_families f JOIN boxes b ON b.id = f.box_id @@ -272,7 +270,6 @@ export async function authenticateBox( principalId: row.principal_id, workspaceId: row.workspace_id, membershipId: row.membership_id, - isBroker: row.is_broker === 1, platformOperator: row.platform_operator === 1, }; } @@ -321,9 +318,8 @@ export function machinePrincipal( * may destroy, its own box's included; the token is the member's, and it is not * pretended to be less. * - * Null on any break in that chain (no bearer, unknown token, a broker box with - * no membership, a membership that is no longer active), so the caller falls - * through to the next authentication source rather than through a hole. + * Null on any break in that chain. This includes a missing bearer, an unknown + * token, or an inactive membership. The caller then tries the next source. */ export async function authenticateMachinePrincipal( request: Request, diff --git a/packages/control-plane/core/registry.ts b/packages/control-plane/core/registry.ts deleted file mode 100644 index 94f3238a..00000000 --- a/packages/control-plane/core/registry.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { hashSecret } from "./crypto.js"; -import type { Db, Query } from "./db.js"; -import { first, rows, transaction } from "./db.js"; -import { - HttpError, - isRecord, - isSshPublicKey, - isString, - positiveInteger, - readJson, - requiredString, -} from "./http.js"; -import { authenticateBox } from "./oauth.js"; -import type { CoreContext, CoreRouter, RuntimeFactory } from "./runtime.js"; -import type { BoxIdentity } from "./types.js"; -import { - FEED_MAX_BYTES, - type FeedKey, - type FeedMember, - type FeedResponse, - type RegisterKeysResponse, -} from "./wire.js"; - -/** A machine, with the identity it acts as resolved from its membership. The - * `boxes` row that used to carry a stored `principal_id` is gone for workspace - * guests; this join is what replaced it. */ -interface MachineRegistryRow { - id: string; - principal_id: string; - workspace_id: string; - broker_box_id: string | null; -} - -interface FeedRow { - unix_name: string; - harnesses: string; - pubkey: string | null; - operation: "mint" | "deposit" | null; -} - -interface BrokerRow { - host: string; - port: number; - ssh_host_public_key: string; -} - -/** - * `m-<12 hex>` — the broker-side unix account for one member. Derived - * SERVER-SIDE from the principal id; a caller never supplies it, and it is - * never read back out of `principals.unix_name`. - * - * WHY IT IS NOT `principals.unix_name`: that column is the workspace-box login - * and is the literal `blitz` for everyone. On a workspace box, where one box - * belongs to one member, a shared name is harmless. On a BROKER box, which - * holds the only copy of every member's vendor refresh token and hosts all of - * them at once, a shared name means one `/home` directory, one credential file, - * and every member evicting every other. The isolation boundary of this whole - * design is one unix account per member, so the name has to be per member — - * and only here. `principals.unix_name` is deliberately left alone. - * - * The 12 hex characters are an INVARIANT this function must guarantee, not a - * property of the input. `packages/broker/internal/feed/feed.go` gates every - * member on `^m-[0-9a-f]{12}$` and rejects PER MEMBER, not per feed: the bad - * entry is dropped and every other member in the same response is applied - * normally. The consumer states the rule in its own comment — a producer that - * starts emitting a shape the binary does not understand "must not cost every - * other member their keys". - * - * Containment is not absolution, and this is the failure that makes this - * producer load-bearing. A member whose name arrives malformed is simply ABSENT - * from the decoded feed, and absence is the deprovision signal: - * `internal/broker/reconcile.go` sweeps every managed account that is neither - * wanted nor in the preserve set, and feed.go only preserves names that PASSED - * the pattern. Emitting a short name for a member who already has a home is - * therefore identical to emitting nothing for them — `userdel --remove` over - * the only copy of their vendor refresh token. One member instead of a boxful, - * with nothing in the loop reporting it. - * - * DEVIATION from the production original, which slices the id's own hex - * characters and only falls back to a hash. `broker_members` now carries the - * same `UNIQUE(broker_box_id, unix_name)` production hangs its collision - * backstop on (migrations/0020_broker_members.sql), so the old reason given - * here — that blitz-core had no table to hang the constraint on — is gone. The - * digest stays for what catching a collision COSTS: the constraint is a - * detector, not a repair. It surfaces as a failed INSERT in `POST - * /boxes/:id/keys`, and nothing in blitz-core renames the loser, so that member - * cannot register keys until a human intervenes. A digest gives 48 - * uniformly-distributed bits whatever the id looks like — including the short - * non-UUID principal ids blitz-core mints, where a prefix-of-hex has almost no - * hex to slice and would collapse ids differing only in a suffix. The - * constraint is the last line; the digest is what keeps us off it. - */ -async function brokerUnixName(principalId: string): Promise { - return `m-${(await hashSecret(principalId)).slice(0, 12)}`; -} - -function parseHarnesses(value: string): string[] { - try { - const parsed: unknown = JSON.parse(value); - return Array.isArray(parsed) && parsed.every((item) => isString(item)) - ? parsed - : []; - } catch { - return []; - } -} - -async function requireOwnBox( - context: CoreContext, - runtimeFactory: RuntimeFactory, -): Promise { - const box = await authenticateBox(context.req.raw, runtimeFactory(context).db); - if (box === null) throw new HttpError(401, "invalid box access token"); - if (box.id !== context.req.param("id")) { - throw new HttpError(403, "a box may only act as itself"); - } - return box; -} - -async function machineRegistryRow(db: Db, id: string): Promise { - return first(db, { - q: `SELECT m.id, m.workspace_id, m.broker_box_id, ms.user_id AS principal_id - FROM machines m JOIN memberships ms ON ms.id = m.membership_id - WHERE m.id = ?1 LIMIT 1`, - v: [id], - }); -} - -async function isBrokerBox(db: Db, id: string): Promise { - const row = await first<{ box_id: string }>(db, { - q: "SELECT box_id FROM broker_boxes WHERE box_id = ?1 LIMIT 1", - v: [id], - }); - return row !== null; -} - -/** - * The least loaded broker box that is still under its `member_cap`, or null - * when every box is full — at which point a human provisions another - * (packages/broker/deploy). Null is also what zero enrolled brokers returns, - * and the two are deliberately the same answer: the caller's job either way is - * to leave the workspace signed out and cleanly wired to nothing. - * - * Load is counted in MEMBERSHIPS, not boxes. `member_cap` is a blast-radius - * cap — how many identities one broker compromise takes — and one member - * opening ten workspaces adds ten boxes but only one credential home. Counting - * boxes would evict a heavy user's eleventh workspace off a box that holds one - * credential; counting live boxes would also let a box fill past its cap with - * the homes of members who happen to have nothing running. - */ -async function leastLoadedBroker(db: Db, excludeBoxId: string): Promise { - const row = await first<{ box_id: string }>(db, { - q: `SELECT broker.box_id - FROM broker_boxes broker - LEFT JOIN broker_members member ON member.broker_box_id = broker.box_id - WHERE broker.box_id <> ?1 - GROUP BY broker.box_id - HAVING COUNT(member.principal_id) < broker.member_cap - ORDER BY COUNT(member.principal_id), broker.box_id - LIMIT 1`, - v: [excludeBoxId], - }); - return row?.box_id ?? null; -} - -/** - * The broker box this member's credential already lives on. - * - * Roaming is the whole point: every workspace a member owns has to reach the - * same credential home, so their next workspace must land on the box that - * already holds their credential rather than wherever the load balancer would - * put a stranger. Without this, a second workspace splits a member across two - * brokers and the second one is signed out with no way to fix itself. - * - * It reads the MEMBERSHIP, not the member's other boxes. The credential home - * is what stickiness is about, and the home outlives every workspace — so a - * member who destroys their last workspace and opens a new one comes back to - * the same broker instead of being placed as a stranger next to a home they - * already own. - * - * `member_cap` is deliberately NOT consulted here. The cap sizes the blast - * radius of a NEW identity landing on a box; this member's credential is - * already there, and refusing them would strand a box they own. - * - * The JOIN to `broker_boxes` keeps a de-enrolled broker out of the answer. The - * CASCADE on `broker_members.broker_box_id` should already have removed the - * row, so this is belt and braces — but the two failure modes are not - * comparable. Missing the JOIN and reading a dangling row hands the caller a - * box that is not enrolled, which `POST /boxes/:id/keys` can only turn into a - * 500; failing to find a row costs nothing, because placement then falls - * through to `leastLoadedBroker` and the member lands somewhere real. - */ -async function stickyBroker(db: Db, principalId: string): Promise { - const row = await first<{ broker_box_id: string }>(db, { - q: `SELECT member.broker_box_id AS broker_box_id - FROM broker_members member - JOIN broker_boxes broker ON broker.box_id = member.broker_box_id - WHERE member.principal_id = ?1 - LIMIT 1`, - v: [principalId], - }); - return row?.broker_box_id ?? null; -} - -function parseBrokerKeys(value: unknown): FeedKey[] { - if (!isRecord(value) || !Array.isArray(value.keys) || value.keys.length === 0) { - throw new HttpError(400, "keys must be a non-empty array"); - } - return value.keys.map((key) => { - if (!isRecord(key)) throw new HttpError(400, "each key must be an object"); - const pubkey = requiredString(key.pubkey, "pubkey"); - if (!isSshPublicKey(pubkey)) { - throw new HttpError(400, "pubkey must be an SSH public key"); - } - if (key.op !== "mint" && key.op !== "deposit") { - throw new HttpError(400, "op must be mint or deposit"); - } - return { pubkey, op: key.op }; - }); -} - -export function addRegistryRoutes( - router: CoreRouter, - runtimeFactory: RuntimeFactory, -): void { - router.put("/boxes/:id/broker", async (context) => { - const box = await requireOwnBox(context, runtimeFactory); - const value = await readJson(context.req.raw); - if (!isRecord(value)) throw new HttpError(400, "request body must be an object"); - const host = requiredString(value.host, "host", 512); - const port = positiveInteger(value.port, "port"); - if (port > 65_535) throw new HttpError(400, "port must be at most 65535"); - const hostKey = requiredString(value.sshHostPublicKey, "sshHostPublicKey"); - if (!isSshPublicKey(hostKey)) { - throw new HttpError(400, "sshHostPublicKey must be an SSH public key"); - } - await transaction(runtimeFactory(context).db, [ - { - // A broker is still a `boxes` row: it enrols through the device-code - // flow and belongs to no workspace, so it never became a machine. - q: "UPDATE boxes SET is_broker = 1, broker_box_id = NULL WHERE id = ?1", - v: [box.id], - }, - { - q: `INSERT INTO broker_boxes (box_id, host, port, ssh_host_public_key) - VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(box_id) DO UPDATE SET - host = excluded.host, port = excluded.port, - ssh_host_public_key = excluded.ssh_host_public_key`, - v: [box.id, host, port, hostKey], - }, - ]); - return context.body(null, 204); - }); - - router.delete("/boxes/:id/broker", async (context) => { - const box = await requireOwnBox(context, runtimeFactory); - await transaction(runtimeFactory(context).db, [ - { q: "DELETE FROM broker_boxes WHERE box_id = ?1", v: [box.id] }, - { q: "UPDATE boxes SET is_broker = 0 WHERE id = ?1", v: [box.id] }, - ]); - return context.body(null, 204); - }); - - router.post("/boxes/:id/keys", async (context) => { - const box = await requireOwnBox(context, runtimeFactory); - const db = runtimeFactory(context).db; - const current = await machineRegistryRow(db, box.id); - if (current === null) { - throw new HttpError(403, "only workspace machines may register keys"); - } - const keys = parseBrokerKeys(await readJson(context.req.raw)); - // Membership, then the box's own pin, then placement. The pin is a derived - // copy of the membership and so can never outvote one; it sits in the chain - // for boxes wired to a broker BEFORE `broker_members` existed. - // migrations/0018 does not backfill — `unix_name` is a SHA-256 digest no - // SQL statement can compute — so those boxes carry their assignment in the - // only place that still has it, and their next registration rebuilds the - // membership on the broker they are already talking to. Without this the - // rebuild would go through `leastLoadedBroker` and move a live member's - // credential home to whichever box is emptiest. - const assigned = - (await stickyBroker(db, current.principal_id)) ?? - current.broker_box_id ?? - (await leastLoadedBroker(db, box.id)); - // `no_broker_capacity` is a MACHINE TOKEN, not prose, and it is the only - // 409 this route raises. The workspace reads it, removes any stale broker - // wiring it is holding, and exits 0 (packages/broker/internal/workspace). - // Zero enrolled brokers and every broker full are the same answer on - // purpose: the feature is simply off for this box, and a workspace that - // runs signed-out is one a human can fix, where a workspace whose services - // refused to start is not. - if (assigned === null) throw new HttpError(409, "no_broker_capacity"); - - const queries: Query[] = [ - // The membership is what places this member, and it is written FIRST so - // the box row below can be derived from it. `DO NOTHING` makes the - // placement above advisory: an existing membership wins, so two - // workspaces registering at once cannot end up on two brokers. - { - q: `INSERT INTO broker_members (principal_id, broker_box_id, unix_name, created_at) - VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(principal_id) DO NOTHING`, - v: [ - current.principal_id, - assigned, - await brokerUnixName(current.principal_id), - Date.now(), - ], - }, - // Follows the membership rather than only filling a NULL, so a machine - // left pointing at a broker the member is no longer on re-wires itself on - // its next boot instead of talking to a box that will not mint for it. - { - q: `UPDATE machines - SET broker_box_id = ( - SELECT broker_box_id FROM broker_members WHERE principal_id = ?1 - ) - WHERE id = ?2`, - v: [current.principal_id, box.id], - }, - ]; - for (const key of keys) { - queries.push({ - q: `INSERT OR IGNORE INTO broker_keys (id, machine_id, pubkey, operation) - VALUES (?1, ?2, ?3, ?4)`, - v: [crypto.randomUUID(), box.id, key.pubkey, key.op], - }); - } - await transaction(db, queries); - // Read the placement back rather than answering with what this request - // proposed: the membership row is authoritative, and it may be one another - // registration wrote. - const membership = await first<{ broker_box_id: string; unix_name: string }>(db, { - q: "SELECT broker_box_id, unix_name FROM broker_members WHERE principal_id = ?1", - v: [current.principal_id], - }); - if (membership === null) throw new Error("broker membership missing after assignment"); - const broker = await first(db, { - q: `SELECT host, port, ssh_host_public_key - FROM broker_boxes - WHERE box_id = ?1`, - v: [membership.broker_box_id], - }); - if (broker === null) throw new Error("assigned broker is not enrolled"); - const response: RegisterKeysResponse = { - // The name the FEED will serve, from the same row, so the login this box - // is handed and the account the broker creates cannot disagree. - memberUnixName: membership.unix_name, - broker: { - host: broker.host, - port: broker.port, - sshHostPublicKey: broker.ssh_host_public_key, - }, - }; - return context.json(response, 200); - }); - - router.get("/boxes/:id/feed", async (context) => { - const box = await requireOwnBox(context, runtimeFactory); - const db = runtimeFactory(context).db; - if (!(await isBrokerBox(db, box.id))) throw new HttpError(403, "box is not a broker"); - // Driven by MEMBERSHIPS, with the machines LEFT-joined on. A member whose - // machines have all been destroyed still appears, with an empty key list: - // that is the wire's "keep this account, serve it no keys" state. Deriving - // this from live machines instead — as it once did from boxes — made - // destroying a member's last workspace their deprovision signal, and the - // broker answers that signal by deleting the home holding the only copy of - // their vendor refresh token. - // - // The keys still come from machines, and only from machines, so destroy - // remains the revocation path: the machine row goes, `broker_keys` - // CASCADEs with it, and the next poll removes those authorized_keys lines. - const result = await rows(db, { - q: `SELECT member.unix_name AS unix_name, p.harnesses AS harnesses, - keys.pubkey AS pubkey, keys.operation AS operation - FROM broker_members member - JOIN principals p ON p.id = member.principal_id - LEFT JOIN machines machine - ON machine.broker_box_id = member.broker_box_id - AND machine.membership_id IN ( - SELECT id FROM memberships WHERE user_id = member.principal_id - ) - LEFT JOIN broker_keys keys ON keys.machine_id = machine.id - WHERE member.broker_box_id = ?1 - ORDER BY member.unix_name, machine.id, keys.operation, keys.pubkey`, - v: [box.id], - }); - - const membersByName = new Map(); - for (const row of result) { - let member = membersByName.get(row.unix_name); - if (member === undefined) { - member = { - // The name the registration response above already handed this - // member's boxes, read back off the same row. A derivation on each - // side could drift; one stored name cannot. - unixName: row.unix_name, - harnesses: parseHarnesses(row.harnesses), - keys: [], - }; - membersByName.set(row.unix_name, member); - } - if ( - row.pubkey !== null && - row.operation !== null && - !member.keys.some( - (key) => key.pubkey === row.pubkey && key.op === row.operation, - ) - ) { - member.keys.push({ pubkey: row.pubkey, op: row.operation }); - } - } - const members = [...membersByName.values()]; - const version = await hashSecret(JSON.stringify(members)); - const response: FeedResponse = { version, members }; - const body = JSON.stringify(response); - if (new TextEncoder().encode(body).byteLength > FEED_MAX_BYTES) { - throw new Error("broker feed exceeds FEED_MAX_BYTES"); - } - const etag = `"${version}"`; - if (context.req.header("if-none-match") === etag) { - return context.body(null, 304, { ETag: etag }); - } - return context.body(body, 200, { - "Content-Type": "application/json; charset=UTF-8", - ETag: etag, - }); - }); -} diff --git a/packages/control-plane/core/types.ts b/packages/control-plane/core/types.ts index 514c524a..6c6fddde 100644 --- a/packages/control-plane/core/types.ts +++ b/packages/control-plane/core/types.ts @@ -2,19 +2,15 @@ * Who a guest is when it calls the control plane. * * Two kinds of guest present a token. A MACHINE is one member's VM in one - * workspace: `workspaceId` and `membershipId` are set, and both are read off - * the `machines` row AT CALL TIME. Nothing about the acting principal is - * stored beside the credential, which is what stops a machine acting as - * somebody it no longer belongs to. A BOX is a broker or a device-code - * enrolment: it has no workspace and acts as the principal that enrolled it. + * workspace. A BOX is a device-code enrolment with no workspace. + * Machine identity comes from `machines` at call time. This prevents a + * machine from acting as a prior member. */ export interface BoxIdentity { id: string; principalId: string; workspaceId: string | null; - /** The org membership a machine acts as. Null for a broker or device box, - * which has no workspace and therefore no workspace membership. */ + /** The org membership a machine acts as. Null for a device-code box. */ membershipId: string | null; - isBroker: boolean; platformOperator: boolean; } diff --git a/packages/control-plane/core/wire.ts b/packages/control-plane/core/wire.ts index 64de652b..345a716b 100644 --- a/packages/control-plane/core/wire.ts +++ b/packages/control-plane/core/wire.ts @@ -2,8 +2,6 @@ import type { BoxPayloadConfig } from "./wire-box-payload.js"; export * from "./wire-box-payload.js"; -export const FEED_MAX_BYTES = 1_048_576; -export const HARNESSES = ["claude", "codex"] as const; export const FILES_MULTIPART_CHUNK_BYTES = 32 * 1024 * 1024; export type JsonValue = string | number | boolean | null | JsonObject | JsonValue[]; @@ -16,15 +14,6 @@ export interface CredentialManifest { integrations: Record; } -export interface WorkspaceEnvironment { - env: Record; - startupScript: string | null; -} - -export interface WorkspaceEnvironmentResponse extends WorkspaceEnvironment { - filesReady: boolean; -} - /** The envelope `GET /workspaces/self/agent-rules` returns to a box. * * This crosses a runtime boundary the other views do not: the producer is the @@ -353,12 +342,12 @@ export interface CheckGithubRepositoriesResponse { * * It mirrors the per-provider model and effort lists the pinned harness CLIs * accept; "default" is expressed by omitting the model or effort, so it is not - * listed. The providers are the TUI harness list (`HARNESSES` above) — one - * constant, derived, never re-spelled. The canonical copy lives in + * listed. The provider tuple is defined here with the catalog it governs. + * The canonical copy lives in * `packages/schema/src/agent-catalog.ts` (core code may not import packages); * `test/wire-drift.test.ts` holds the two together. Extend both copies in the * same change. */ -export const AGENT_PROVIDERS = HARNESSES; +export const AGENT_PROVIDERS = ["claude", "codex"] as const; export type AgentProvider = (typeof AGENT_PROVIDERS)[number]; @@ -455,15 +444,6 @@ export interface PollResponse { workspaces: WorkspaceView[]; } -export interface RegisterKeysResponse { - memberUnixName: string; - broker: { - host: string; - port: number; - sshHostPublicKey: string; - }; -} - export interface ApiError { error: string; retryAction: RetryAction; @@ -525,19 +505,3 @@ export interface DeleteVolumeResponse { } export const INVITE_TTL_DAYS = 7; - -export interface FeedResponse { - version: string; - members: FeedMember[]; -} - -export interface FeedMember { - unixName: string; - harnesses: string[]; - keys: FeedKey[]; -} - -export interface FeedKey { - pubkey: string; - op: "mint" | "deposit"; -} diff --git a/packages/control-plane/core/workspace-records.ts b/packages/control-plane/core/workspace-records.ts index 2db2c197..e36f0e79 100644 --- a/packages/control-plane/core/workspace-records.ts +++ b/packages/control-plane/core/workspace-records.ts @@ -65,7 +65,6 @@ export interface MachineRow { tunnel_id: string | null; tunnel_hostname: string | null; dns_record_id: string | null; - broker_box_id: string | null; box_update_requested: number; box_image_reported: string | null; disk_used_percent: number | null; diff --git a/packages/control-plane/migrations/0053_drop_credential_broker.sql b/packages/control-plane/migrations/0053_drop_credential_broker.sql new file mode 100644 index 00000000..d0c2c645 --- /dev/null +++ b/packages/control-plane/migrations/0053_drop_credential_broker.sql @@ -0,0 +1,196 @@ +-- Remove the credential custody schema after the fleet reached zero rows. +-- Parent rebuilds preserve device and machine authentication data. +PRAGMA defer_foreign_keys = ON; + +-- These tables do not contain data that survives retirement. +DROP TABLE broker_keys; +DROP TABLE broker_members; + +-- Rename child tables first. SQLite then keeps their foreign keys attached +-- to the retired parent tables during the rebuild. +ALTER TABLE credential_events RENAME TO credential_events_broker_retired; +ALTER TABLE machine_token_families RENAME TO machine_token_families_broker_retired; +ALTER TABLE box_token_families RENAME TO box_token_families_broker_retired; +ALTER TABLE credential_leases RENAME TO credential_leases_broker_retired; +ALTER TABLE machines RENAME TO machines_broker_retired; +ALTER TABLE boxes RENAME TO boxes_broker_retired; + +-- Rebuild machines without its foreign key into the retired table. +CREATE TABLE machines ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + membership_id TEXT NOT NULL REFERENCES memberships(id), + state TEXT NOT NULL CHECK (state IN + ('provisioning', 'running', 'stopped', 'error', + 'destroying', 'destroyed')), + machine_type_id TEXT NOT NULL, + compute_credential_source TEXT NOT NULL DEFAULT 'deployment' + CHECK (compute_credential_source IN ('org', 'deployment')), + vm_id TEXT, + volume_id TEXT, + ssh_host TEXT, + ssh_port INTEGER, + ssh_user TEXT, + ssh_host_public_key TEXT, + phone_home_hash TEXT, + phone_home_used INTEGER NOT NULL DEFAULT 0 CHECK (phone_home_used IN (0, 1)), + tunnel_id TEXT, + tunnel_hostname TEXT, + dns_record_id TEXT, + box_update_requested INTEGER NOT NULL DEFAULT 0 CHECK (box_update_requested IN (0, 1)), + box_image_reported TEXT, + disk_used_percent INTEGER + CHECK (disk_used_percent IS NULL OR + disk_used_percent BETWEEN 0 AND 100), + disk_reported_at INTEGER, + payload_reported TEXT, + daemon_reported TEXT, + payload_outcome TEXT + CHECK (payload_outcome IS NULL OR payload_outcome IN + ('booted', 'applied', 'deferred', 'rolled-back', + 'unsupported', 'fetch-failed', 'verify-failed', + 'start-failed', 'up-to-date')), + payload_reported_at INTEGER, + payload_hold INTEGER NOT NULL DEFAULT 0 CHECK (payload_hold IN (0, 1)), + created_by_plane TEXT NOT NULL DEFAULT 'session' + CHECK (created_by_plane IN ('session', 'machine')), + destroy_keeps_row INTEGER NOT NULL DEFAULT 0 CHECK (destroy_keeps_row IN (0, 1)), + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (workspace_id, membership_id) +); + +INSERT INTO machines ( + id, workspace_id, membership_id, state, machine_type_id, + compute_credential_source, vm_id, volume_id, ssh_host, ssh_port, ssh_user, + ssh_host_public_key, phone_home_hash, phone_home_used, tunnel_id, + tunnel_hostname, dns_record_id, box_update_requested, box_image_reported, + disk_used_percent, disk_reported_at, payload_reported, daemon_reported, + payload_outcome, payload_reported_at, payload_hold, created_by_plane, + destroy_keeps_row, error, created_at, updated_at +) +SELECT + id, workspace_id, membership_id, state, machine_type_id, + compute_credential_source, vm_id, volume_id, ssh_host, ssh_port, ssh_user, + ssh_host_public_key, phone_home_hash, phone_home_used, tunnel_id, + tunnel_hostname, dns_record_id, box_update_requested, box_image_reported, + disk_used_percent, disk_reported_at, payload_reported, daemon_reported, + payload_outcome, payload_reported_at, payload_hold, created_by_plane, + destroy_keeps_row, error, created_at, updated_at +FROM machines_broker_retired; + +-- Rebuild boxes without the role and placement columns. +-- Former custody hosts do not become device-code boxes after retirement. +CREATE TABLE boxes ( + id TEXT PRIMARY KEY, + principal_id TEXT NOT NULL REFERENCES principals(id), + workspace_id TEXT UNIQUE REFERENCES workspaces(id), + created_at INTEGER NOT NULL +); + +INSERT INTO boxes (id, principal_id, workspace_id, created_at) +SELECT id, principal_id, workspace_id, created_at +FROM boxes_broker_retired +WHERE is_broker = 0; + +-- Rebuild the child tables against the new parents. +CREATE TABLE machine_token_families ( + machine_id TEXT PRIMARY KEY REFERENCES machines(id) ON DELETE CASCADE, + vm_id TEXT, + access_hash TEXT NOT NULL UNIQUE, + refresh_hash TEXT NOT NULL UNIQUE, + previous_refresh_hash TEXT, + previous_rotated_at INTEGER, + access_issued_at INTEGER NOT NULL, + generation INTEGER NOT NULL +); + +INSERT INTO machine_token_families +SELECT * FROM machine_token_families_broker_retired; + +CREATE TABLE box_token_families ( + box_id TEXT PRIMARY KEY REFERENCES boxes(id) ON DELETE CASCADE, + access_hash TEXT NOT NULL UNIQUE, + refresh_hash TEXT NOT NULL UNIQUE, + access_issued_at INTEGER NOT NULL, + generation INTEGER NOT NULL, + previous_refresh_hash TEXT, + previous_rotated_at INTEGER +); + +INSERT INTO box_token_families + (box_id, access_hash, refresh_hash, access_issued_at, generation, + previous_refresh_hash, previous_rotated_at) +SELECT + family.box_id, family.access_hash, family.refresh_hash, + family.access_issued_at, family.generation, + family.previous_refresh_hash, family.previous_rotated_at +FROM box_token_families_broker_retired family +JOIN boxes ON boxes.id = family.box_id; + +-- Lease rows are audit records. Rebuild their references without deleting +-- those records or changing their values. +CREATE TABLE credential_leases ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + box_id TEXT REFERENCES boxes(id) ON DELETE SET NULL, + connection_id TEXT NOT NULL REFERENCES connections(id), + user_id TEXT, + scopes TEXT NOT NULL, + mode TEXT NOT NULL CHECK (mode IN ('inject','proxy')), + token_hash TEXT UNIQUE, + issued_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + state TEXT NOT NULL CHECK (state IN ('active','revoked','expired')), + grant_id TEXT REFERENCES user_oauth_grants(id), + machine_id TEXT REFERENCES machines(id) +); + +INSERT INTO credential_leases + (id, workspace_id, box_id, connection_id, user_id, scopes, mode, + token_hash, issued_at, expires_at, state, grant_id, machine_id) +SELECT + lease.id, lease.workspace_id, + CASE WHEN boxes.id IS NULL THEN NULL ELSE lease.box_id END, + lease.connection_id, lease.user_id, lease.scopes, lease.mode, + lease.token_hash, lease.issued_at, lease.expires_at, lease.state, + lease.grant_id, lease.machine_id +FROM credential_leases_broker_retired lease +LEFT JOIN boxes ON boxes.id = lease.box_id; + +-- Events are append-only audit records. Rebuild their lease reference and +-- preserve every recorded result. +CREATE TABLE credential_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lease_id TEXT REFERENCES credential_leases(id), + event TEXT NOT NULL CHECK (event IN ('minted','revoked','denied','approved')), + detail TEXT, + created_at INTEGER NOT NULL +); + +INSERT INTO credential_events (id, lease_id, event, detail, created_at) +SELECT id, lease_id, event, detail, created_at +FROM credential_events_broker_retired; + +-- Remove old children before their parents. This prevents cascade data loss. +DROP TABLE credential_events_broker_retired; +DROP TABLE machine_token_families_broker_retired; +DROP TABLE box_token_families_broker_retired; +DROP TABLE credential_leases_broker_retired; +DROP TABLE machines_broker_retired; +DROP TABLE boxes_broker_retired; + +-- No remaining table references this target now. +DROP TABLE broker_boxes; + +CREATE INDEX machines_workspace ON machines(workspace_id, created_at); +CREATE INDEX machines_membership ON machines(membership_id, workspace_id); +CREATE INDEX machines_state ON machines(state, updated_at); +CREATE INDEX boxes_principal ON boxes(principal_id); +CREATE INDEX leases_workspace ON credential_leases(workspace_id, state); +CREATE INDEX leases_expiry ON credential_leases(state, expires_at); +CREATE INDEX leases_token ON credential_leases(token_hash) WHERE token_hash IS NOT NULL; +CREATE INDEX leases_grant ON credential_leases(grant_id, state); + +PRAGMA foreign_key_check; diff --git a/packages/control-plane/scripts/lib/box-image-inputs.mjs b/packages/control-plane/scripts/lib/box-image-inputs.mjs index 39e9c75f..8b1dc780 100644 --- a/packages/control-plane/scripts/lib/box-image-inputs.mjs +++ b/packages/control-plane/scripts/lib/box-image-inputs.mjs @@ -8,9 +8,9 @@ // are the base-owned rootfs files: the payload updater. The complete s6 // service tree and ordinary box configuration belong to the payload. export const BOX_IMAGE_INPUTS = Object.freeze([ - "packages/broker/cmd/blitz-cred", - "packages/broker/go.mod", - "packages/broker/internal", + "packages/box/credential-helper/cmd/blitz-cred", + "packages/box/credential-helper/go.mod", + "packages/box/credential-helper/internal", "packages/box/Dockerfile", "packages/box/Dockerfile.dockerignore", "packages/box/rootfs/usr/local/libexec/blitz-payload", diff --git a/packages/control-plane/scripts/lib/box-payload-files.mjs b/packages/control-plane/scripts/lib/box-payload-files.mjs index dae8ee42..1d1725c2 100644 --- a/packages/control-plane/scripts/lib/box-payload-files.mjs +++ b/packages/control-plane/scripts/lib/box-payload-files.mjs @@ -16,7 +16,7 @@ export const PAYLOAD_ROOTFS_PATHS = Object.freeze([ "etc/blitz/sshd_config", "etc/gitconfig", "etc/profile.d/blitz-npm.sh", - "etc/s6-overlay/s6-rc.d/box-credential/dependencies.d/register", + "etc/s6-overlay/s6-rc.d/box-credential/dependencies.d/init-state", "etc/s6-overlay/s6-rc.d/box-credential/run", "etc/s6-overlay/s6-rc.d/box-credential/type", "etc/s6-overlay/s6-rc.d/cgroups/type", @@ -24,10 +24,10 @@ export const PAYLOAD_ROOTFS_PATHS = Object.freeze([ "etc/s6-overlay/s6-rc.d/cloudflared/dependencies.d/init-state", "etc/s6-overlay/s6-rc.d/cloudflared/run", "etc/s6-overlay/s6-rc.d/cloudflared/type", - "etc/s6-overlay/s6-rc.d/dockerd/dependencies.d/register", + "etc/s6-overlay/s6-rc.d/dockerd/dependencies.d/init-state", "etc/s6-overlay/s6-rc.d/dockerd/run", "etc/s6-overlay/s6-rc.d/dockerd/type", - "etc/s6-overlay/s6-rc.d/dufs/dependencies.d/register", + "etc/s6-overlay/s6-rc.d/dufs/dependencies.d/init-state", "etc/s6-overlay/s6-rc.d/dufs/run", "etc/s6-overlay/s6-rc.d/dufs/type", "etc/s6-overlay/s6-rc.d/gateway/dependencies.d/dufs", @@ -39,7 +39,7 @@ export const PAYLOAD_ROOTFS_PATHS = Object.freeze([ "etc/s6-overlay/s6-rc.d/lody-bridge/dependencies.d/lody-daemon", "etc/s6-overlay/s6-rc.d/lody-bridge/run", "etc/s6-overlay/s6-rc.d/lody-bridge/type", - "etc/s6-overlay/s6-rc.d/lody-daemon/dependencies.d/register", + "etc/s6-overlay/s6-rc.d/lody-daemon/dependencies.d/init-state", "etc/s6-overlay/s6-rc.d/lody-daemon/run", "etc/s6-overlay/s6-rc.d/lody-daemon/type", "etc/s6-overlay/s6-rc.d/lody-projects/dependencies.d/lody-daemon", @@ -51,19 +51,16 @@ export const PAYLOAD_ROOTFS_PATHS = Object.freeze([ "etc/s6-overlay/s6-rc.d/payload/dependencies.d/init-state", "etc/s6-overlay/s6-rc.d/payload/run", "etc/s6-overlay/s6-rc.d/payload/type", - "etc/s6-overlay/s6-rc.d/register/dependencies.d/init-state", - "etc/s6-overlay/s6-rc.d/register/type", - "etc/s6-overlay/s6-rc.d/register/up", - "etc/s6-overlay/s6-rc.d/remote-control/dependencies.d/register", + "etc/s6-overlay/s6-rc.d/remote-control/dependencies.d/init-state", "etc/s6-overlay/s6-rc.d/remote-control/run", "etc/s6-overlay/s6-rc.d/remote-control/type", - "etc/s6-overlay/s6-rc.d/rules/dependencies.d/register", + "etc/s6-overlay/s6-rc.d/rules/dependencies.d/init-state", "etc/s6-overlay/s6-rc.d/rules/type", "etc/s6-overlay/s6-rc.d/rules/up", - "etc/s6-overlay/s6-rc.d/sshd/dependencies.d/register", + "etc/s6-overlay/s6-rc.d/sshd/dependencies.d/init-state", "etc/s6-overlay/s6-rc.d/sshd/run", "etc/s6-overlay/s6-rc.d/sshd/type", - "etc/s6-overlay/s6-rc.d/ttyd/dependencies.d/register", + "etc/s6-overlay/s6-rc.d/ttyd/dependencies.d/init-state", "etc/s6-overlay/s6-rc.d/ttyd/run", "etc/s6-overlay/s6-rc.d/ttyd/type", "etc/s6-overlay/s6-rc.d/user/contents.d/box-credential", @@ -78,23 +75,16 @@ export const PAYLOAD_ROOTFS_PATHS = Object.freeze([ "etc/s6-overlay/s6-rc.d/user/contents.d/lody-projects", "etc/s6-overlay/s6-rc.d/user/contents.d/lody-watchdog", "etc/s6-overlay/s6-rc.d/user/contents.d/payload", - "etc/s6-overlay/s6-rc.d/user/contents.d/register", "etc/s6-overlay/s6-rc.d/user/contents.d/remote-control", "etc/s6-overlay/s6-rc.d/user/contents.d/rules", "etc/s6-overlay/s6-rc.d/user/contents.d/sshd", "etc/s6-overlay/s6-rc.d/user/contents.d/ttyd", - "etc/s6-overlay/s6-rc.d/user/contents.d/watch", "etc/s6-overlay/s6-rc.d/user/type", "etc/s6-overlay/s6-rc.d/user2/type", - "etc/s6-overlay/s6-rc.d/watch/dependencies.d/register", - "etc/s6-overlay/s6-rc.d/watch/run", - "etc/s6-overlay/s6-rc.d/watch/type", "etc/tmux.conf", "opt/blitz/skel/agent-rules.md", "usr/local/bin/blitz", "usr/local/bin/blitz-cgroup", - "usr/local/bin/blitz-cred-claude", - "usr/local/bin/blitz-cred-codex", "usr/local/bin/blitz-rules", "usr/local/bin/claude", "usr/local/bin/codex", @@ -105,7 +95,6 @@ export const PAYLOAD_ROOTFS_PATHS = Object.freeze([ "usr/local/libexec/blitz-init-state", "usr/local/libexec/blitz-lody-bridge", "usr/local/libexec/blitz-lody-projects", - "usr/local/libexec/blitz-register", "usr/local/libexec/blitz-rules-boot", "usr/local/libexec/blitz-ssh-session", "usr/local/libexec/blitz-term", diff --git a/packages/control-plane/scripts/lib/worker-source.mjs b/packages/control-plane/scripts/lib/worker-source.mjs index ed79ccb5..609e5873 100644 --- a/packages/control-plane/scripts/lib/worker-source.mjs +++ b/packages/control-plane/scripts/lib/worker-source.mjs @@ -56,7 +56,6 @@ export const CORE_MANIFEST = Object.freeze([ "core/org-credential-import.ts", "core/org-credential-routes.ts", "core/org-credentials.ts", "core/principals.ts", - "core/registry.ts", "core/session-shares.ts", "core/sessions.ts", "core/version.ts", @@ -233,7 +232,7 @@ export const BLITZDEV_CONFIG = Object.freeze({ }, // One VM per (workspace, member). The volume is the durable half: a type // change destroys the VM and keeps the disk. - { name: "machines", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "state", type: "text", sqlType: "text", notNull: true, check: "state IN ('provisioning', 'running', 'stopped', 'error', 'destroying', 'destroyed')" }, { name: "machine_type_id", type: "text", sqlType: "text", notNull: true }, { name: "compute_credential_source", type: "text", sqlType: "text", notNull: true, default: { l: "deployment" }, check: "compute_credential_source IN ('org', 'deployment')" }, { name: "vm_id", type: "text", sqlType: "text" }, { name: "volume_id", type: "text", sqlType: "text" }, { name: "ssh_host", type: "text", sqlType: "text" }, { name: "ssh_port", type: "integer", sqlType: "integer" }, { name: "ssh_user", type: "text", sqlType: "text" }, { name: "ssh_host_public_key", type: "text", sqlType: "text" }, { name: "phone_home_hash", type: "text", sqlType: "text" }, { name: "phone_home_used", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "phone_home_used IN (0, 1)" }, { name: "tunnel_id", type: "text", sqlType: "text" }, { name: "tunnel_hostname", type: "text", sqlType: "text" }, { name: "dns_record_id", type: "text", sqlType: "text" }, { name: "broker_box_id", type: "text", sqlType: "text", foreignKey: { table: "broker_boxes", column: "box_id", onDelete: "SET NULL" } }, { name: "box_update_requested", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "box_update_requested IN (0, 1)" }, { name: "box_image_reported", type: "text", sqlType: "text" }, { name: "disk_used_percent", type: "integer", sqlType: "integer", check: "disk_used_percent IS NULL OR (disk_used_percent BETWEEN 0 AND 100)" }, { name: "disk_reported_at", type: "integer", sqlType: "integer" }, { name: "payload_reported", type: "text", sqlType: "text" }, { name: "daemon_reported", type: "text", sqlType: "text" }, { name: "payload_outcome", type: "text", sqlType: "text", check: "payload_outcome IS NULL OR payload_outcome IN ('booted', 'applied', 'deferred', 'rolled-back', 'unsupported', 'fetch-failed', 'verify-failed', 'start-failed', 'up-to-date')" }, { name: "payload_reported_at", type: "integer", sqlType: "integer" }, { name: "payload_hold", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "payload_hold IN (0, 1)" }, { name: "created_by_plane", type: "text", sqlType: "text", notNull: true, default: { l: "session" }, check: "created_by_plane IN ('session', 'machine')" }, { name: "destroy_keeps_row", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "destroy_keeps_row IN (0, 1)" }, { name: "error", type: "text", sqlType: "text" }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "identity", unique: true, fields: ["workspace_id", "membership_id"] }, { name: "workspace", fields: ["workspace_id", "created_at"] }, { name: "state", fields: ["state", "updated_at"] }], extensions: [DENY_ALL_RULES] }, + { name: "machines", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "state", type: "text", sqlType: "text", notNull: true, check: "state IN ('provisioning', 'running', 'stopped', 'error', 'destroying', 'destroyed')" }, { name: "machine_type_id", type: "text", sqlType: "text", notNull: true }, { name: "compute_credential_source", type: "text", sqlType: "text", notNull: true, default: { l: "deployment" }, check: "compute_credential_source IN ('org', 'deployment')" }, { name: "vm_id", type: "text", sqlType: "text" }, { name: "volume_id", type: "text", sqlType: "text" }, { name: "ssh_host", type: "text", sqlType: "text" }, { name: "ssh_port", type: "integer", sqlType: "integer" }, { name: "ssh_user", type: "text", sqlType: "text" }, { name: "ssh_host_public_key", type: "text", sqlType: "text" }, { name: "phone_home_hash", type: "text", sqlType: "text" }, { name: "phone_home_used", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "phone_home_used IN (0, 1)" }, { name: "tunnel_id", type: "text", sqlType: "text" }, { name: "tunnel_hostname", type: "text", sqlType: "text" }, { name: "dns_record_id", type: "text", sqlType: "text" }, { name: "box_update_requested", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "box_update_requested IN (0, 1)" }, { name: "box_image_reported", type: "text", sqlType: "text" }, { name: "disk_used_percent", type: "integer", sqlType: "integer", check: "disk_used_percent IS NULL OR (disk_used_percent BETWEEN 0 AND 100)" }, { name: "disk_reported_at", type: "integer", sqlType: "integer" }, { name: "payload_reported", type: "text", sqlType: "text" }, { name: "daemon_reported", type: "text", sqlType: "text" }, { name: "payload_outcome", type: "text", sqlType: "text", check: "payload_outcome IS NULL OR payload_outcome IN ('booted', 'applied', 'deferred', 'rolled-back', 'unsupported', 'fetch-failed', 'verify-failed', 'start-failed', 'up-to-date')" }, { name: "payload_reported_at", type: "integer", sqlType: "integer" }, { name: "payload_hold", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "payload_hold IN (0, 1)" }, { name: "created_by_plane", type: "text", sqlType: "text", notNull: true, default: { l: "session" }, check: "created_by_plane IN ('session', 'machine')" }, { name: "destroy_keeps_row", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "destroy_keeps_row IN (0, 1)" }, { name: "error", type: "text", sqlType: "text" }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "identity", unique: true, fields: ["workspace_id", "membership_id"] }, { name: "workspace", fields: ["workspace_id", "created_at"] }, { name: "state", fields: ["state", "updated_at"] }], extensions: [DENY_ALL_RULES] }, { name: "workspace_members", fields: [{ name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "role", type: "text", sqlType: "text", notNull: true, check: "role IN ('admin', 'member', 'viewer')" }, { name: "added_by_membership_id", type: "text", sqlType: "text", foreignKey: { table: "memberships", column: "id" } }, { name: "added_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "identity", unique: true, fields: ["workspace_id", "membership_id"] }, { name: "membership", fields: ["membership_id", "workspace_id"] }], extensions: [DENY_ALL_RULES] }, // Sealed org-scoped statics (plans/ORG-CREDENTIALS.md §5). The live-name // partial unique index and the grant-subject expression index live in the @@ -292,12 +291,9 @@ export const BLITZDEV_CONFIG = Object.freeze({ { name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "principal_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "principals", column: "id" } }, { name: "workspace_id", type: "text", sqlType: "text", unique: true, foreignKey: { table: "workspaces", column: "id" } }, - { name: "broker_box_id", type: "text", sqlType: "text", foreignKey: { table: "broker_boxes", column: "box_id", onDelete: "SET NULL" } }, - { name: "is_broker", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "is_broker IN (0, 1)" }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, ], indexes: [ - { name: "broker", fields: "broker_box_id" }, { name: "principal", fields: "principal_id" }, ], extensions: [DENY_ALL_RULES], @@ -313,39 +309,9 @@ export const BLITZDEV_CONFIG = Object.freeze({ ], extensions: [DENY_ALL_RULES], }, - // The workspace guest's credential. `box_token_families` above is what is - // left of the old table: brokers and device-code enrolments. + // The workspace guest uses a machine token. Device-code boxes use the + // separate family above, so each identity keeps its existing token path. { name: "machine_token_families", fields: [{ name: "machine_id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid", foreignKey: { table: "machines", column: "id", onDelete: "CASCADE" } }, { name: "vm_id", type: "text", sqlType: "text" }, { name: "access_hash", type: "text", sqlType: "text", notNull: true, unique: true }, { name: "refresh_hash", type: "text", sqlType: "text", notNull: true, unique: true }, { name: "previous_refresh_hash", type: "text", sqlType: "text" }, { name: "previous_rotated_at", type: "integer", sqlType: "integer" }, { name: "access_issued_at", type: "integer", sqlType: "integer", notNull: true }, { name: "generation", type: "integer", sqlType: "integer", notNull: true }], indexes: [], extensions: [DENY_ALL_RULES] }, - { - name: "broker_boxes", - fields: [ - { name: "box_id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid", foreignKey: { table: "boxes", column: "id", onDelete: "CASCADE" } }, - { name: "host", type: "text", sqlType: "text", notNull: true }, - { name: "port", type: "integer", sqlType: "integer", notNull: true }, - { name: "ssh_host_public_key", type: "text", sqlType: "text", notNull: true }, - { name: "member_cap", type: "integer", sqlType: "integer", notNull: true, default: { l: 25 }, check: "member_cap > 0" }, - ], - extensions: [DENY_ALL_RULES], - }, - { - name: "broker_keys", - fields: [ - { name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, - { name: "machine_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "machines", column: "id", onDelete: "CASCADE" } }, - { name: "pubkey", type: "text", sqlType: "text", notNull: true }, - { name: "operation", type: "text", sqlType: "text", notNull: true, check: "operation IN ('mint', 'deposit')" }, - ], - indexes: [ - { name: "machine", fields: "machine_id" }, - { name: "identity", unique: true, fields: ["machine_id", "pubkey", "operation"] }, - ], - extensions: [DENY_ALL_RULES], - }, - // Written flat, unlike its neighbours: this file is 13 lines under the - // 700-line max-lines warn and the expanded form crosses it. CLAUDE.md's - // drift runbook reads that warn list as a ratchet, so a new table pays for - // its own room here rather than growing the list. - { name: "broker_members", fields: [{ name: "principal_id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid", foreignKey: { table: "principals", column: "id", onDelete: "CASCADE" } }, { name: "broker_box_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "broker_boxes", column: "box_id", onDelete: "CASCADE" } }, { name: "unix_name", type: "text", sqlType: "text", notNull: true }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "box", fields: "broker_box_id" }, { name: "identity", unique: true, fields: ["broker_box_id", "unix_name"] }], extensions: [DENY_ALL_RULES] }, { name: "connections", fields: [ diff --git a/packages/control-plane/test/blitzdev-schema.test.ts b/packages/control-plane/test/blitzdev-schema.test.ts index cca10783..93018d70 100644 --- a/packages/control-plane/test/blitzdev-schema.test.ts +++ b/packages/control-plane/test/blitzdev-schema.test.ts @@ -32,9 +32,6 @@ const expectedTables = [ "boxes", "box_token_families", "machine_token_families", - "broker_boxes", - "broker_keys", - "broker_members", "connections", "user_oauth_grants", "provider_health", @@ -63,9 +60,9 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed schema [vendor-only expect(databaseSettingsSchema.parse(BLITZDEV_CONFIG)).toEqual(BLITZDEV_CONFIG); }); - it("contains the thirty domain tables plus the deny-all file support table", () => { + it("contains the twenty-seven domain tables plus the file support table", () => { expect(BLITZDEV_CONFIG.tables.map((table) => table.name)).toEqual(expectedTables); - expect(BLITZDEV_CONFIG.tables).toHaveLength(31); + expect(BLITZDEV_CONFIG.tables).toHaveLength(28); for (const table of BLITZDEV_CONFIG.tables) { expect(table.extensions).toEqual([DENY_ALL_RULES]); } @@ -189,8 +186,8 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed schema [vendor-only expect.objectContaining({ name: "access", check: "access IN ('read','write')" }), ]), }); - // The machine's own credential. `box_token_families` beside it is what is - // left of the old table: brokers and device-code enrolments. + // Machine and device-code credentials use separate token families. This + // preserves each existing authentication path. expect(BLITZDEV_CONFIG.tables.find(({ name }) => name === "machine_token_families")).toMatchObject({ fields: expect.arrayContaining([ expect.objectContaining({ @@ -308,12 +305,7 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed schema [vendor-only "idx_org_credentials_org", "idx_org_credential_grants_credential", "idx_webapp_state_identity", - "idx_boxes_broker", "idx_boxes_principal", - "idx_broker_keys_machine", - "idx_broker_keys_identity", - "idx_broker_members_box", - "idx_broker_members_identity", "idx_connections_org_name", "idx_connections_org", "idx_user_oauth_grants_live", diff --git a/packages/control-plane/test/bootstrap.test.ts b/packages/control-plane/test/bootstrap.test.ts index 1502afe4..9b6261e9 100644 --- a/packages/control-plane/test/bootstrap.test.ts +++ b/packages/control-plane/test/bootstrap.test.ts @@ -368,7 +368,7 @@ INOTIFY`; expect(userData).not.toContain("term-3"); }); - it("pokes registration after both enrollment files are installed with a bounded logged best-effort command", () => { + it("does not invoke removed credential helper verbs after provisioning", () => { const userData = registryUserData(); const credential = userData.indexOf( @@ -382,51 +382,15 @@ INOTIFY`; "chmod 0644 /var/lib/blitz/origin", origin, ); - const registerStart = userData.indexOf( - 'echo "blitz bootstrap: credential registration poke start outer_timeout_seconds=40 inner_timeout_seconds=30"', - originMode, - ); - const outerTimeout = userData.indexOf( - "timeout --foreground --kill-after=5s 40s", - registerStart, - ); - const dockerExec = userData.indexOf("docker exec", outerTimeout); - const containerUser = userData.indexOf("--user 1000:1000", dockerExec); - const homeEnv = userData.indexOf( - "--env HOME=/var/lib/blitz/home", - containerUser, - ); - const userEnv = userData.indexOf("--env USER=blitz", homeEnv); - const container = userData.indexOf("blitz-box", userEnv); - const innerTimeout = userData.indexOf( - "timeout --foreground --kill-after=5s 30s", - container, - ); - const register = userData.indexOf("blitz-cred register", innerTimeout); const completed = userData.indexOf('echo "blitz bootstrap completed"'); - const registerBlock = userData.slice(registerStart, completed); expect(credential).toBeGreaterThan(-1); expect(origin).toBeGreaterThan(credential); expect(originMode).toBeGreaterThan(origin); - expect(registerStart).toBeGreaterThan(originMode); - expect(outerTimeout).toBeGreaterThan(registerStart); - expect(dockerExec).toBeGreaterThan(outerTimeout); - expect(containerUser).toBeGreaterThan(dockerExec); - expect(homeEnv).toBeGreaterThan(containerUser); - expect(userEnv).toBeGreaterThan(homeEnv); - expect(container).toBeGreaterThan(userEnv); - expect(innerTimeout).toBeGreaterThan(container); - expect(register).toBeGreaterThan(innerTimeout); - expect(completed).toBeGreaterThan(register); - expect(registerBlock).toContain( - "credential registration poke", - ); - expect(registerBlock).toContain( - "continuing bootstrap because registration poke is best-effort", - ); - expect(registerBlock).not.toContain("watch will retry"); - expect(userData.slice(register, completed)).toMatch(/\|\|[\s\S]*true/u); + expect(completed).toBeGreaterThan(originMode); + expect(userData).not.toContain("blitz-cred register"); + expect(userData).not.toContain("blitz-cred token"); + expect(userData).not.toContain("blitz-cred watch"); }); // ---- box update path (blitz-box-run + host updater) ---- @@ -453,7 +417,7 @@ INOTIFY`; expect(userData).toContain("box_image=${1:?usage: blitz-box-run }"); }); - it("installs the host-side updater with its timer after enrollment lands", () => { + it("installs the host-side updater after machine credentials land", () => { const userData = registryUserData(); const originInstall = userData.indexOf( @@ -463,13 +427,13 @@ INOTIFY`; const service = userData.indexOf("cat >/etc/systemd/system/blitz-box-update.service", updater); const timer = userData.indexOf("cat >/etc/systemd/system/blitz-box-update.timer", service); const enable = userData.indexOf("systemctl enable --now blitz-box-update.timer", timer); - const poke = userData.indexOf("credential registration poke start", enable); + const completed = userData.indexOf('echo "blitz bootstrap completed"', enable); expect(originInstall).toBeGreaterThan(-1); expect(updater).toBeGreaterThan(originInstall); expect(service).toBeGreaterThan(updater); expect(timer).toBeGreaterThan(service); expect(enable).toBeGreaterThan(timer); - expect(poke).toBeGreaterThan(enable); + expect(completed).toBeGreaterThan(enable); expect(userData).toContain("OnUnitActiveSec=5min"); expect(userData).toContain('"$current_origin/workspaces/self/box-config"'); expect(userData).toContain("/workspaces/self/box-update-result"); @@ -536,7 +500,7 @@ INOTIFY`; ); }); - it("persists exactly the three broker credential fields", () => { + it("persists exactly the three machine credential fields", () => { const userData = registryUserData(); const projection = userData.match( /credential = \{\n(?(?: "[^"]+": response\["[^"]+"\],\n)+)\}/u, diff --git a/packages/control-plane/test/box-payload-files.test.mjs b/packages/control-plane/test/box-payload-files.test.mjs index bf2e1088..a954f3f3 100644 --- a/packages/control-plane/test/box-payload-files.test.mjs +++ b/packages/control-plane/test/box-payload-files.test.mjs @@ -89,7 +89,7 @@ test("the payload inventory owns the complete s6 tree and every eligible rootfs assert.equal(new Set(PAYLOAD_FILES).size, PAYLOAD_FILES.length); assert.deepEqual(PAYLOAD_FILES, [...PAYLOAD_FILES].sort()); assert.ok(!PAYLOAD_FILES.includes("rootfs/etc/blitz/env.defaults")); - for (const service of ["cgroups", "init-state", "register", "rules"]) { + for (const service of ["cgroups", "init-state", "rules"]) { assert.ok(PAYLOAD_FILES.includes(`rootfs/etc/s6-overlay/s6-rc.d/${service}/up`)); } }); @@ -107,9 +107,9 @@ test("restart dependencies come from service sources plus the narrow override ta assert.ok(restart["lody-bridge"].includes("rootfs/usr/local/libexec/blitz-lody-bridge")); assert.ok(restart.sshd.includes("rootfs/etc/blitz/sshd_config")); assert.equal(restart["machine-stats"], undefined); - assert.equal(restart.watch.includes("rootfs/usr/local/bin/blitz-cred"), false); + assert.equal(restart.watch, undefined); assert.equal(restart.ttyd.includes("rootfs/usr/local/libexec/blitz-term"), false); - for (const oneshot of ["cgroups", "init-state", "register", "rules"]) { + for (const oneshot of ["cgroups", "init-state", "rules"]) { assert.equal(restart[oneshot], undefined); } for (const dependencies of Object.values(restart)) { diff --git a/packages/control-plane/test/broker-retirement-migration.test.ts b/packages/control-plane/test/broker-retirement-migration.test.ts new file mode 100644 index 00000000..2121d465 --- /dev/null +++ b/packages/control-plane/test/broker-retirement-migration.test.ts @@ -0,0 +1,210 @@ +import { applyD1Migrations } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { describe, expect, it } from "vitest"; + +const RETIREMENT_MIGRATION = "0053_drop_credential_broker.sql"; + +interface SchemaRow { + type: string; + name: string; + table_name: string; + sql: string | null; +} + +interface NameRow { + name: string; +} + +interface MachineFixtureRow { + id: string; + vm_id: string | null; +} + +interface LeaseFixtureRow { + id: string; + box_id: string | null; + machine_id: string | null; +} + +interface EventFixtureRow { + id: number; + lease_id: string | null; +} + +async function schema(db: D1Database): Promise { + const result = await db.prepare( + `SELECT type, name, tbl_name AS table_name, sql + FROM sqlite_master + WHERE name NOT LIKE 'sqlite_%' AND name <> 'd1_migrations' + ORDER BY type, name`, + ).all(); + return result.results; +} + +async function columnNames(db: D1Database, table: string): Promise { + const result = await db.prepare(`PRAGMA table_info(${table})`).all(); + return result.results.map(({ name }) => name); +} + +async function seedBrokerEraRows(db: D1Database): Promise { + await db.batch([ + db.prepare( + `INSERT INTO principals (id, unix_name, harnesses) + VALUES ('member-principal', 'blitz', '["codex"]'), + ('custody-principal', 'blitz', '["codex"]')`, + ), + db.prepare( + `INSERT INTO users + (id, google_user_id, email, name, platform_operator, created_at, updated_at) + VALUES ('user', 'google-user', 'user@example.com', 'User', 0, 1, 1)`, + ), + db.prepare( + `INSERT INTO orgs + (id, slug, name, vm_limit, created_at, updated_at, created_by_user_id) + VALUES ('org', 'org', 'Org', 10, 1, 1, 'user')`, + ), + db.prepare( + `INSERT INTO memberships (id, user_id, org_id, role, status) + VALUES ('membership', 'user', 'org', 'admin', 'active')`, + ), + db.prepare( + `INSERT INTO workspaces + (id, owner_id, revision, created_at, updated_at, org_id, + owner_membership_id, default_machine_type_id, auto_provision) + VALUES ('workspace', 'member-principal', 1, 1, 1, 'org', + 'membership', 'small', 1)`, + ), + db.prepare( + `INSERT INTO boxes + (id, principal_id, workspace_id, broker_box_id, is_broker, created_at) + VALUES ('custody-box', 'custody-principal', NULL, NULL, 1, 1), + ('device-box', 'member-principal', NULL, NULL, 0, 1)`, + ), + db.prepare( + `INSERT INTO broker_boxes + (box_id, host, port, ssh_host_public_key, member_cap) + VALUES ('custody-box', 'custody.example', 22, 'ssh-ed25519 AAAAcustody', 25)`, + ), + db.prepare( + `UPDATE boxes SET broker_box_id = 'custody-box' WHERE id = 'device-box'`, + ), + db.prepare( + `INSERT INTO machines + (id, workspace_id, membership_id, state, machine_type_id, + vm_id, broker_box_id, created_at, updated_at) + VALUES ('machine', 'workspace', 'membership', 'running', 'small', + 'vm-machine', 'custody-box', 1, 1)`, + ), + db.prepare( + `INSERT INTO broker_members + (principal_id, broker_box_id, unix_name, created_at) + VALUES ('member-principal', 'custody-box', 'm-123456789abc', 1)`, + ), + db.prepare( + `INSERT INTO broker_keys (id, machine_id, pubkey, operation) + VALUES ('key', 'machine', 'ssh-ed25519 AAAAmint', 'mint')`, + ), + db.prepare( + `INSERT INTO machine_token_families + (machine_id, vm_id, access_hash, refresh_hash, access_issued_at, generation) + VALUES ('machine', 'vm-machine', 'machine-access', 'machine-refresh', 1, 1)`, + ), + db.prepare( + `INSERT INTO box_token_families + (box_id, access_hash, refresh_hash, access_issued_at, generation) + VALUES ('custody-box', 'custody-access', 'custody-refresh', 1, 1), + ('device-box', 'device-access', 'device-refresh', 1, 1)`, + ), + db.prepare( + `INSERT INTO connections + (id, name, provider, kind, custody, config, created_by, created_at, + org_id, created_by_membership_id, scoped_name) + VALUES ('connection', 'connection', 'github', 'oauth', 'cp', '{}', + 'member-principal', 1, 'org', 'membership', 'connection')`, + ), + db.prepare( + `INSERT INTO credential_leases + (id, workspace_id, box_id, connection_id, scopes, mode, + issued_at, expires_at, state, machine_id) + VALUES ('device-lease', 'workspace', 'device-box', 'connection', '[]', + 'inject', 1, 2, 'active', 'machine'), + ('custody-lease', 'workspace', 'custody-box', 'connection', '[]', + 'inject', 1, 2, 'active', 'machine')`, + ), + db.prepare( + `INSERT INTO credential_events (lease_id, event, detail, created_at) + VALUES ('device-lease', 'minted', '{"result":"kept"}', 1)`, + ), + ]); +} + +describe("credential custody schema retirement", () => { + it("matches fresh and seeded upgrades without losing surviving data", async () => { + const priorMigrations = env.TEST_MIGRATIONS.filter( + ({ name }) => name !== RETIREMENT_MIGRATION, + ); + expect(priorMigrations).toHaveLength(env.TEST_MIGRATIONS.length - 1); + + await applyD1Migrations(env.MIGRATION_FRESH, env.TEST_MIGRATIONS); + await applyD1Migrations(env.MIGRATION_UPGRADED, priorMigrations); + await seedBrokerEraRows(env.MIGRATION_UPGRADED); + await applyD1Migrations(env.MIGRATION_UPGRADED, env.TEST_MIGRATIONS); + + const freshSchema = await schema(env.MIGRATION_FRESH); + const upgradedSchema = await schema(env.MIGRATION_UPGRADED); + expect(upgradedSchema).toEqual(freshSchema); + // ONE ASSERTION PER TABLE, and that is not style. `arrayContaining` matches + // only when EVERY name is present, so `.not.toEqual(arrayContaining([...]))` + // passes as soon as one of the three is gone — two could survive unseen. + // The schema equality above cannot catch it either: a migration that drops + // nothing leaves both databases equally wrong. + const upgradedTables = upgradedSchema + .filter(({ type }) => type === "table") + .map(({ name }) => name); + for (const table of ["broker_keys", "broker_members", "broker_boxes"]) { + expect(upgradedTables, `${table} survived the retirement migration`).not.toContain(table); + } + expect(await columnNames(env.MIGRATION_UPGRADED, "machines")) + .not.toContain("broker_box_id"); + expect(await columnNames(env.MIGRATION_UPGRADED, "boxes")) + .toEqual(["id", "principal_id", "workspace_id", "created_at"]); + + const machine = await env.MIGRATION_UPGRADED.prepare( + "SELECT id, vm_id FROM machines WHERE id = 'machine'", + ).first(); + expect(machine).toEqual({ id: "machine", vm_id: "vm-machine" }); + + const machineTokens = await env.MIGRATION_UPGRADED.prepare( + "SELECT machine_id AS name FROM machine_token_families ORDER BY machine_id", + ).all(); + expect(machineTokens.results).toEqual([{ name: "machine" }]); + + const boxes = await env.MIGRATION_UPGRADED.prepare( + "SELECT id AS name FROM boxes ORDER BY id", + ).all(); + expect(boxes.results).toEqual([{ name: "device-box" }]); + + const tokenBoxes = await env.MIGRATION_UPGRADED.prepare( + "SELECT box_id AS name FROM box_token_families ORDER BY box_id", + ).all(); + expect(tokenBoxes.results).toEqual([{ name: "device-box" }]); + + const leases = await env.MIGRATION_UPGRADED.prepare( + `SELECT id, box_id, machine_id FROM credential_leases ORDER BY id`, + ).all(); + expect(leases.results).toEqual([ + { id: "custody-lease", box_id: null, machine_id: "machine" }, + { id: "device-lease", box_id: "device-box", machine_id: "machine" }, + ]); + + const events = await env.MIGRATION_UPGRADED.prepare( + "SELECT id, lease_id FROM credential_events ORDER BY id", + ).all(); + expect(events.results).toEqual([{ id: 1, lease_id: "device-lease" }]); + + const violations = await env.MIGRATION_UPGRADED.prepare( + "PRAGMA foreign_key_check", + ).all(); + expect(violations.results).toEqual([]); + }); +}); diff --git a/packages/control-plane/test/control-plane.test.ts b/packages/control-plane/test/control-plane.test.ts index 9406e9f8..3b3f4a5c 100644 --- a/packages/control-plane/test/control-plane.test.ts +++ b/packages/control-plane/test/control-plane.test.ts @@ -1,8 +1,6 @@ import type { - FeedResponse, ListMachineTypesResponse, PollResponse, - RegisterKeysResponse, WorkspaceView, } from "@blitzos/schema"; import { env } from "cloudflare:workers"; @@ -43,7 +41,6 @@ import { phoneHomeUrl, resetDatabase, testRuntime, - userSession, machineIdFor, } from "./helpers.js"; @@ -51,15 +48,6 @@ interface WorkspaceResponse { workspace: WorkspaceView; } -/** - * The broker unix name `core/registry.ts` derives for the `operator` - * principal. A golden, not a re-derivation: recomputing it with the same - * primitive the route uses would pass for any algorithm, including one that - * silently started handing two members the same home. - */ -const OPERATOR_BROKER_NAME = "m-06e55b633481"; -const BROKER_NAME_PATTERN = /^m-[0-9a-f]{12}$/u; - /** * Listing the Hetzner catalog reads two endpoints: the paged /server_types, * and /pricing for the billing currency. One canned response cannot serve @@ -77,76 +65,6 @@ function hetznerPricing(currency: string): Response { return Response.json({ pricing: { currency, vat_rate: "0.000000" } }); } -/** Enrol `box` as a broker box reachable at `host`. */ -async function enrollBroker( - app: ReturnType["app"], - box: { box_id: string; access_token: string }, - host: string, -): Promise { - const response = await appRequest(app, `/boxes/${box.box_id}/broker`, { - method: "PUT", - headers: { - Authorization: `Bearer ${box.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ host, port: 22, sshHostPublicKey: "ssh-ed25519 AAAAbroker" }), - }); - if (response.status !== 204) throw new Error(`broker enrolment failed: ${response.status}`); -} - -/** Create a workspace for `cookie`'s principal and phone it home into a box. */ -async function workspaceBox( - app: ReturnType["app"], - providers: ReturnType["providers"], - cookie: string, -): Promise<{ box_id: string; access_token: string; workspaceId: string }> { - const workspace = await createWorkspace(app, cookie); - const ready = await app.request( - phoneHomeUrl(providers, workspace.id), - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ pub_key_ed25519: "ssh-ed25519 AAAAhost" }), - }, - { DB: env.DB }, - ); - const box = await ready.json<{ box_id: string; access_token: string }>(); - return { ...box, workspaceId: workspace.id }; -} - -/** The broker's own view of one box: what `blitz-broker sync` would apply. */ -async function brokerFeed( - app: ReturnType["app"], - broker: { box_id: string; access_token: string }, -): Promise { - const response = await appRequest(app, `/boxes/${broker.box_id}/feed`, { - headers: { Authorization: `Bearer ${broker.access_token}` }, - }); - if (response.status !== 200) throw new Error(`feed failed: ${response.status}`); - return response.json(); -} - -/** Register one mint + one deposit key for a workspace box. */ -function registerKeys( - app: ReturnType["app"], - box: { box_id: string; access_token: string }, - suffix = "", -): Promise { - return appRequest(app, `/boxes/${box.box_id}/keys`, { - method: "POST", - headers: { - Authorization: `Bearer ${box.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - keys: [ - { pubkey: `ssh-ed25519 AAAAmint${suffix}`, op: "mint" }, - { pubkey: `ssh-ed25519 AAAAdeposit${suffix}`, op: "deposit" }, - ], - }), - }); -} - describe("control plane security and lifecycle", () => { beforeEach(async () => { await resetDatabase(); @@ -1387,55 +1305,6 @@ describe("control plane security and lifecycle", () => { expect(error).not.toMatch(/[\u0000-\u001f\u007f]/u); }); - it("rejects a box A token acting as box B in the registry", async () => { - const { app } = harness(); - const cookie = await operatorSession(app); - const boxA = await enrollBox(app, cookie); - const boxB = await enrollBox(app, cookie); - const response = await appRequest(app, `/boxes/${boxB.box_id}/broker`, { - method: "PUT", - headers: { - Authorization: `Bearer ${boxA.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - host: "broker-b.example", - port: 22, - sshHostPublicKey: "ssh-ed25519 AAAAbrokerb", - }), - }); - expect(response.status).toBe(403); - }); - - it("rejects broker box A pulling broker box B's slice", async () => { - const { app } = harness(); - const cookie = await operatorSession(app); - const boxA = await enrollBox(app, cookie); - const boxB = await enrollBox(app, cookie); - for (const [box, host] of [ - [boxA, "broker-a.example"], - [boxB, "broker-b.example"], - ] as const) { - const enrollment = await appRequest(app, `/boxes/${box.box_id}/broker`, { - method: "PUT", - headers: { - Authorization: `Bearer ${box.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - host, - port: 22, - sshHostPublicKey: "ssh-ed25519 AAAAbroker", - }), - }); - expect(enrollment.status).toBe(204); - } - const response = await appRequest(app, `/boxes/${boxB.box_id}/feed`, { - headers: { Authorization: `Bearer ${boxA.access_token}` }, - }); - expect(response.status).toBe(403); - }); - /** A box writes its new pair to disk only after this endpoint has already * rotated. A box that dies in that window still holds the token it came * with, and before the grace window that box was stranded for good: one hash @@ -1611,284 +1480,6 @@ describe("control plane security and lifecycle", () => { expect(providers.volumes.get(volume.volume.id)?.status).toBe("available"); }); - it("assigns workspace keys to the least-loaded broker and serves ETag/304", async () => { - const { app, providers } = harness(); - const cookie = await operatorSession(app); - const broker = await enrollBox(app, cookie); - expect( - ( - await appRequest(app, `/boxes/${broker.box_id}/broker`, { - method: "PUT", - headers: { - Authorization: `Bearer ${broker.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - host: "broker.example", - port: 22, - sshHostPublicKey: "ssh-ed25519 AAAAbroker", - }), - }) - ).status, - ).toBe(204); - - const workspace = await createWorkspace(app, cookie); - const callback = phoneHomeUrl(providers, workspace.id); - const ready = await app.request( - callback, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ pub_key_ed25519: "ssh-ed25519 AAAAhost" }), - }, - { DB: env.DB }, - ); - const box = await ready.json<{ box_id: string; access_token: string }>(); - const registration = await appRequest(app, `/boxes/${box.box_id}/keys`, { - method: "POST", - headers: { - Authorization: `Bearer ${box.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - keys: [ - { pubkey: "ssh-ed25519 AAAAmint", op: "mint" }, - { pubkey: "ssh-ed25519 AAAAdeposit", op: "deposit" }, - ], - }), - }); - expect(registration.status).toBe(200); - expect(await registration.json()).toEqual({ - memberUnixName: OPERATOR_BROKER_NAME, - broker: { - host: "broker.example", - port: 22, - sshHostPublicKey: "ssh-ed25519 AAAAbroker", - }, - }); - - const feed = await appRequest(app, `/boxes/${broker.box_id}/feed`, { - headers: { Authorization: `Bearer ${broker.access_token}` }, - }); - expect(feed.status).toBe(200); - const etag = feed.headers.get("etag"); - const body = await feed.json(); - expect(body.members).toHaveLength(1); - expect(body.members[0]).toMatchObject({ - unixName: OPERATOR_BROKER_NAME, - harnesses: ["claude", "codex"], - }); - expect(body.members[0]?.keys).toEqual( - expect.arrayContaining([ - { pubkey: "ssh-ed25519 AAAAmint", op: "mint" }, - { pubkey: "ssh-ed25519 AAAAdeposit", op: "deposit" }, - ]), - ); - if (etag === null) throw new Error("feed ETag missing"); - const notModified = await appRequest(app, `/boxes/${broker.box_id}/feed`, { - headers: { - Authorization: `Bearer ${broker.access_token}`, - "If-None-Match": etag, - }, - }); - expect(notModified.status).toBe(304); - - const removed = await appRequest(app, `/boxes/${broker.box_id}/broker`, { - method: "DELETE", - headers: { Authorization: `Bearer ${broker.access_token}` }, - }); - expect(removed.status).toBe(204); - const assignment = await env.DB - .prepare("SELECT broker_box_id FROM boxes WHERE id = ?1") - .bind(box.box_id) - .first("broker_box_id"); - expect(assignment).toBeNull(); - }); - - it("derives one broker unix name per member and never reuses the box login", async () => { - const { app, providers } = harness(); - const operator = await operatorSession(app); - const broker = await enrollBox(app, operator); - await enrollBroker(app, broker, "broker.example"); - - const mine = await workspaceBox(app, providers, operator); - const theirs = await workspaceBox(app, providers, await userSession("stranger")); - const [minename, theirname] = await Promise.all( - [mine, theirs].map(async (box, index) => { - const response = await registerKeys(app, box, String(index)); - expect(response.status).toBe(200); - return (await response.json()).memberUnixName; - }), - ); - - // The isolation boundary: two members, two accounts, two homes. A shared - // name would put both credentials in one directory on the broker box. - expect(minename).toBe(OPERATOR_BROKER_NAME); - expect(minename).not.toBe(theirname); - expect(minename).toMatch(BROKER_NAME_PATTERN); - expect(theirname).toMatch(BROKER_NAME_PATTERN); - // And it is NOT the workspace-box login. `principals.unix_name` stays the - // shared `blitz` on purpose — one workspace box belongs to one member, so - // a shared name there costs nothing, and changing it would rewrite every - // box's home path for no gain. - const stored = await env.DB - .prepare("SELECT unix_name FROM principals WHERE id = 'operator'") - .first("unix_name"); - expect(stored).toBe("blitz"); - expect(minename).not.toBe(stored); - - // The feed must answer with the SAME names, or the box is handed a login - // the broker never creates. - const feed = await appRequest(app, `/boxes/${broker.box_id}/feed`, { - headers: { Authorization: `Bearer ${broker.access_token}` }, - }); - const body = await feed.json(); - expect(body.members.map((member) => member.unixName).sort()).toEqual( - [minename, theirname].sort(), - ); - }); - - it("keeps every workspace a member owns on the same broker box", async () => { - const { app, providers } = harness(); - const operator = await operatorSession(app); - const first = await enrollBox(app, operator); - await enrollBroker(app, first, "broker-one.example"); - - const one = await workspaceBox(app, providers, operator); - expect((await registerKeys(app, one, "1")).status).toBe(200); - - // A second, completely empty broker now exists. Load balancing would send - // the member's next workspace there — and strand it, because their - // credential lives on the first box. - const second = await enrollBox(app, operator); - await enrollBroker(app, second, "broker-two.example"); - - const two = await workspaceBox(app, providers, operator); - const response = await registerKeys(app, two, "2"); - expect(response.status).toBe(200); - expect((await response.json()).broker.host).toBe( - "broker-one.example", - ); - }); - - it("destroying the last workspace keeps the member in the broker feed", async () => { - const { app, providers } = harness(); - const operator = await operatorSession(app); - const broker = await enrollBox(app, operator); - await enrollBroker(app, broker, "broker.example"); - - const only = await workspaceBox(app, providers, operator); - expect((await registerKeys(app, only, "1")).status).toBe(200); - - const destroy = await appRequest(app, `/workspaces/${only.workspaceId}`, { - method: "DELETE", - headers: { Cookie: operator }, - }); - expect(destroy.status).toBe(200); - expect((await destroy.json()).workspace.phase).toBe("destroyed"); - - // The member has no `boxes` row left anywhere — destroy hard-deletes it. - // Absence from this feed is the broker's DEPROVISION signal - // (packages/broker/internal/broker/reconcile.go sweeps every managed - // account that is neither wanted nor preserved), and the account it would - // delete owns the only copy of this member's vendor refresh token. So the - // member must still be here, with an empty key list: keep the account, - // serve it no keys. - const feed = await brokerFeed(app, broker); - expect(feed.members).toHaveLength(1); - expect(feed.members[0]).toEqual({ - unixName: OPERATOR_BROKER_NAME, - harnesses: ["claude", "codex"], - keys: [], - }); - }); - - it("a second workspace re-attaches to the same broker and the same unix name", async () => { - const { app, providers } = harness(); - const operator = await operatorSession(app); - const broker = await enrollBox(app, operator); - await enrollBroker(app, broker, "broker.example"); - - const only = await workspaceBox(app, providers, operator); - const before = await registerKeys(app, only, "1"); - expect(before.status).toBe(200); - const first = await before.json(); - - expect( - ( - await appRequest(app, `/workspaces/${only.workspaceId}`, { - method: "DELETE", - headers: { Cookie: operator }, - }) - ).status, - ).toBe(200); - - // A second, emptier broker. Load balancing would send the next workspace - // there, because counting memberships the member's own box now looks like - // the busy one. - const rival = await enrollBox(app, operator); - await enrollBroker(app, rival, "broker-two.example"); - - // Roaming across an empty gap. The member owns no box at all at this - // point, so nothing about the OLD workspace can carry the placement — only - // the membership row can. It has to hand back the same home on the same - // box, or the new workspace logs into an account that holds no credential. - const again = await workspaceBox(app, providers, operator); - const after = await registerKeys(app, again, "2"); - expect(after.status).toBe(200); - const second = await after.json(); - expect(second.memberUnixName).toBe(first.memberUnixName); - expect(second.broker.host).toBe(first.broker.host); - - // Same account, mintable again. Exactly two keys, because the destroyed - // workspace's pair went with its `boxes` row: surviving the sweep must not - // cost the feed its revocation path. - const feed = await brokerFeed(app, broker); - expect(feed.members).toHaveLength(1); - expect(feed.members[0]?.unixName).toBe(first.memberUnixName); - expect(feed.members[0]?.keys).toEqual([ - { pubkey: "ssh-ed25519 AAAAdeposit2", op: "deposit" }, - { pubkey: "ssh-ed25519 AAAAmint2", op: "mint" }, - ]); - }); - - it("refuses a new member with no_broker_capacity once member_cap is reached", async () => { - const { app, providers } = harness(); - const operator = await operatorSession(app); - const broker = await enrollBox(app, operator); - await enrollBroker(app, broker, "broker.example"); - await env.DB - .prepare("UPDATE broker_boxes SET member_cap = 1 WHERE box_id = ?1") - .bind(broker.box_id) - .run(); - - const mine = await workspaceBox(app, providers, operator); - expect((await registerKeys(app, mine, "1")).status).toBe(200); - - // The cap counts identities, not boxes: a second workspace for the SAME - // member adds no credential home and must still be admitted. - const alsoMine = await workspaceBox(app, providers, operator); - expect((await registerKeys(app, alsoMine, "2")).status).toBe(200); - - // A different member is a different home, and the box is full. - const theirs = await workspaceBox(app, providers, await userSession("stranger")); - const refused = await registerKeys(app, theirs, "3"); - expect(refused.status).toBe(409); - expect(await refused.json<{ error: string }>()).toMatchObject({ - error: "no_broker_capacity", - }); - }); - - it("answers no_broker_capacity when no broker box is enrolled at all", async () => { - const { app, providers } = harness(); - const box = await workspaceBox(app, providers, await operatorSession(app)); - const response = await registerKeys(app, box); - expect(response.status).toBe(409); - expect(await response.json<{ error: string }>()).toMatchObject({ - error: "no_broker_capacity", - }); - }); - it("sweeps stale creation to error and completes orphaned destroy work", async () => { const { app, providers } = harness(); const cookie = await operatorSession(app); diff --git a/packages/control-plane/test/core-imports.test.ts b/packages/control-plane/test/core-imports.test.ts index 2198f07e..7ce319df 100644 --- a/packages/control-plane/test/core-imports.test.ts +++ b/packages/control-plane/test/core-imports.test.ts @@ -77,7 +77,6 @@ const expected = [ "compute/workspace-placement.ts", "compute/registry.ts", "compute/types.ts", - "registry.ts", "runtime.ts", "session-shares.ts", "sessions.ts", @@ -123,6 +122,6 @@ describe("portable core imports", () => { (values: string[]) => values.every((value) => value.startsWith("./") || value.startsWith("../")), ); } - expect(expected).toHaveLength(98); + expect(expected).toHaveLength(97); }); }); diff --git a/packages/control-plane/test/deploy-tooling.test.mjs b/packages/control-plane/test/deploy-tooling.test.mjs index a0a70e0c..bfd2a24d 100644 --- a/packages/control-plane/test/deploy-tooling.test.mjs +++ b/packages/control-plane/test/deploy-tooling.test.mjs @@ -490,13 +490,13 @@ test("no image path changed means no rebuild", () => { test("a base-owned box change requires a rebuild", () => { const decision = boxImageDecision("abc1234", [ - "packages/broker/cmd/blitz-cred/main.go", + "packages/box/credential-helper/cmd/blitz-cred/main.go", "packages/box/rootfs/usr/local/libexec/blitz-payload", "packages/webapp/src/App.tsx", ]); assert.equal(decision.rebuild, true); assert.deepEqual(decision.paths, [ - "packages/broker/cmd/blitz-cred/main.go", + "packages/box/credential-helper/cmd/blitz-cred/main.go", "packages/box/rootfs/usr/local/libexec/blitz-payload", ]); }); @@ -547,9 +547,9 @@ test("IMAGE_PATHS pins the Dockerfile and updater but excludes s6 service topolo for (const required of [ "packages/box/Dockerfile", "packages/box/Dockerfile.dockerignore", - "packages/broker/cmd/blitz-cred", - "packages/broker/go.mod", - "packages/broker/internal", + "packages/box/credential-helper/cmd/blitz-cred", + "packages/box/credential-helper/go.mod", + "packages/box/credential-helper/internal", "packages/box/rootfs/usr/local/libexec/blitz-payload", "packages/control-plane/scripts/lib/box-payload-files.mjs", ]) { diff --git a/packages/control-plane/test/env.d.ts b/packages/control-plane/test/env.d.ts index 455d5abe..e47a373d 100644 --- a/packages/control-plane/test/env.d.ts +++ b/packages/control-plane/test/env.d.ts @@ -3,6 +3,8 @@ import type { D1Migration } from "@cloudflare/vitest-pool-workers"; declare global { interface Env { TEST_MIGRATIONS: D1Migration[]; + MIGRATION_FRESH: D1Database; + MIGRATION_UPGRADED: D1Database; CRED_MASTER_KEY: string; /** "1" when the host environment opts into the vendor-only blitz.dev * managed-toolchain suites (BLITZDEV_MANAGED=1), otherwise "". */ diff --git a/packages/control-plane/test/helpers.ts b/packages/control-plane/test/helpers.ts index 5fbb630b..4bb55633 100644 --- a/packages/control-plane/test/helpers.ts +++ b/packages/control-plane/test/helpers.ts @@ -578,9 +578,6 @@ export async function resetDatabase(): Promise { "volume_ownership", "user_oauth_grants", "connections", - "broker_keys", - "broker_members", - "broker_boxes", "machine_token_families", "box_token_families", "machines", diff --git a/packages/control-plane/test/identity.test.ts b/packages/control-plane/test/identity.test.ts index 1d4e8fb6..117de367 100644 --- a/packages/control-plane/test/identity.test.ts +++ b/packages/control-plane/test/identity.test.ts @@ -244,8 +244,8 @@ describe("identity phase 1", () => { // The device-code box that predates identity. It is still a `boxes` // row: only workspace guests became machines. env.DB.prepare( - `INSERT INTO boxes (id, principal_id, workspace_id, is_broker, created_at) - VALUES ('legacy-box', 'operator', NULL, 0, ?1)`, + `INSERT INTO boxes (id, principal_id, workspace_id, created_at) + VALUES ('legacy-box', 'operator', NULL, ?1)`, ).bind(now), env.DB.prepare( `INSERT INTO webapp_state (principal_id, workspace_id, doc, updated_at) diff --git a/packages/control-plane/test/plan-box-payload.test.mjs b/packages/control-plane/test/plan-box-payload.test.mjs index 4ac4a594..daaf5727 100644 --- a/packages/control-plane/test/plan-box-payload.test.mjs +++ b/packages/control-plane/test/plan-box-payload.test.mjs @@ -269,7 +269,7 @@ test("base edits stay stable while service graphs and source modes move the payl assert.notEqual(withService, afterServiceEdit); mkdirSync(path.join(serviceRoot, "dependencies.d")); - writeFileSync(path.join(serviceRoot, "dependencies.d/register"), ""); + writeFileSync(path.join(serviceRoot, "dependencies.d/init-state"), ""); const withDependency = await buildPlannedPayload({ repo: repository, binariesDirectory }); assert.notEqual(withDependency, withService); rmSync(path.join(serviceRoot, "dependencies.d"), { recursive: true }); diff --git a/packages/control-plane/test/publish-box-payload.test.mjs b/packages/control-plane/test/publish-box-payload.test.mjs index d11940c5..6669bd1c 100644 --- a/packages/control-plane/test/publish-box-payload.test.mjs +++ b/packages/control-plane/test/publish-box-payload.test.mjs @@ -52,7 +52,7 @@ function sha256(bytes) { const V1_RESTART_SERVICES = new Set([ "box-credential", "cloudflared", "dockerd", "dufs", "gateway", "lody-bridge", - "lody-daemon", "lody-projects", "lody-watchdog", "remote-control", "sshd", "ttyd", "watch", + "lody-daemon", "lody-projects", "lody-watchdog", "remote-control", "sshd", "ttyd", ]); // Frozen protocol 1 grammar. Unknown top-level fields are deliberately ignored. @@ -203,7 +203,7 @@ test("stages a deterministic payload archive and a self-verifying manifest", asy manifest.restart.ttyd.includes("rootfs/usr/local/libexec/blitz-term"), false, ); - for (const oneshot of ["cgroups", "init-state", "register", "rules"]) { + for (const oneshot of ["cgroups", "init-state", "rules"]) { assert.equal(manifest.restart[oneshot], undefined); } }); diff --git a/packages/control-plane/test/wire-drift.test.ts b/packages/control-plane/test/wire-drift.test.ts index 430ad427..71e12859 100644 --- a/packages/control-plane/test/wire-drift.test.ts +++ b/packages/control-plane/test/wire-drift.test.ts @@ -45,19 +45,6 @@ const volume: SharedShape = { attachedTo: "workspace", }; -const environment: SharedShape< - wire.WorkspaceEnvironment, - schema.WorkspaceEnvironment -> = { - env: { API_ORIGIN: "https://api.example" }, - startupScript: "npm install\n", -}; - -const environmentResponse: SharedShape< - wire.WorkspaceEnvironmentResponse, - schema.WorkspaceEnvironmentResponse -> = { ...environment, filesReady: true }; - const agentRulesResponse: SharedShape< wire.AgentRulesResponse, schema.AgentRulesResponse @@ -609,18 +596,6 @@ const pollResponse: SharedShape = { workspaces: [workspace], }; -const registerKeysResponse: SharedShape< - wire.RegisterKeysResponse, - schema.RegisterKeysResponse -> = { - memberUnixName: "operator", - broker: { - host: "broker.example", - port: 2222, - sshHostPublicKey: "ssh-ed25519 AAAAbroker", - }, -}; - const apiError: SharedShape = { error: "workspace is still creating", retryAction: "poll", @@ -682,22 +657,6 @@ const deleteVolumeResponse: SharedShape< schema.DeleteVolumeResponse > = { id: volume.id }; -const feedKey: SharedShape = { - pubkey: "ssh-ed25519 AAAAkey", - op: "mint", -}; - -const feedMember: SharedShape = { - unixName: "operator", - harnesses: ["claude", "codex"], - keys: [feedKey], -}; - -const feedResponse: SharedShape = { - version: "version", - members: [feedMember], -}; - // The credential module keeps its own copy of the same views in // core/connections/types.ts. It is the second hand-mirrored wire in the // repository and had no drift coverage at all, which is how MintResult grew a @@ -849,8 +808,6 @@ const fullFieldValues = [ pricedMachineType, machineTypeFailure, volume, - environment, - environmentResponse, agentRulesResponse, payloadFile, payloadArchive, @@ -883,7 +840,6 @@ const fullFieldValues = [ createWorkspaceRequest, createWorkspaceResponse, pollResponse, - registerKeysResponse, apiError, seatLimitError, entitlementsRequest, @@ -894,9 +850,6 @@ const fullFieldValues = [ createVolumeResponse, listVolumesResponse, deleteVolumeResponse, - feedKey, - feedMember, - feedResponse, catalogAdminForm, catalogEntry, userGrant, @@ -960,8 +913,6 @@ describe("local wire copies", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); @@ -1001,7 +952,6 @@ describe("local wire copies", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); @@ -1010,9 +960,6 @@ describe("local wire copies", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); }); it("keeps the credential module's copies exactly equal to @blitzos/schema", () => { @@ -1033,8 +980,6 @@ describe("local wire copies", () => { }); it("keeps every duplicated constant and every field-bearing JSON shape covered", () => { - expect(wire.FEED_MAX_BYTES).toBe(schema.FEED_MAX_BYTES); - expect(wire.HARNESSES).toEqual(schema.HARNESSES); expect(wire.AGENT_PROVIDERS).toEqual(schema.AGENT_PROVIDERS); expect(wire.AGENT_MODELS).toEqual(schema.AGENT_MODELS); expect(wire.AGENT_EFFORTS).toEqual(schema.AGENT_EFFORTS); diff --git a/packages/control-plane/test/workspace-environment.test.ts b/packages/control-plane/test/workspace-environment.test.ts index 91f95e2a..045561b2 100644 --- a/packages/control-plane/test/workspace-environment.test.ts +++ b/packages/control-plane/test/workspace-environment.test.ts @@ -1,60 +1,14 @@ -import type { WorkspaceEnvironmentResponse } from "@blitzos/schema"; import { beforeEach, describe, expect, it } from "vitest"; import { appRequest, - boxTokenFor, - createWorkspace, harness, operatorSession, resetDatabase, } from "./helpers.js"; -/** - * The workspace environment is retired (plans/MEMBER-MACHINES.md §1). - * - * Static secrets live in `org_credentials` and only the agent API serves - * them; the startup script has no runner left. What survives is a shim, - * and these tests pin exactly what it owes: DEPLOYED broker binaries poll - * `GET /workspaces/self/environment` every second at boot and wait for a 200 - * carrying all three fields with `filesReady: true`. A 404 or a dropped field - * makes every already-deployed box poll forever. - */ -describe("workspace environment (legacy shim)", () => { +describe("retired workspace create fields", () => { beforeEach(resetDatabase); - it("answers the empty set with all three fields and filesReady true", async () => { - const { app, providers } = harness(); - const cookie = await operatorSession(app); - const workspace = await createWorkspace(app, cookie); - const token = await boxTokenFor(app, providers, workspace.id); - - for (const path of ["/workspaces/self/environment", `/workspaces/${workspace.id}/environment`]) { - const response = await appRequest(app, path, { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(response.status, path).toBe(200); - const body = await response.json(); - // Exactly these keys: the box decodes with DisallowUnknownFields and - // waits for filesReady, so a missing or extra field is a boot that never - // finishes. - expect(Object.keys(body).sort()).toEqual(["env", "filesReady", "startupScript"]); - expect(body).toEqual({ env: {}, startupScript: null, filesReady: true }); - } - }); - - it("still refuses an unauthenticated caller and another workspace's id", async () => { - const { app, providers } = harness(); - const cookie = await operatorSession(app); - const mine = await createWorkspace(app, cookie); - const other = await createWorkspace(app, cookie); - const token = await boxTokenFor(app, providers, mine.id); - - expect((await appRequest(app, "/workspaces/self/environment")).status).toBe(401); - expect((await appRequest(app, `/workspaces/${other.id}/environment`, { - headers: { Authorization: `Bearer ${token}` }, - })).status).toBe(403); - }); - it("rejects a legacy environment field on a create", async () => { const { app } = harness(); const cookie = await operatorSession(app); diff --git a/packages/control-plane/vitest.config.ts b/packages/control-plane/vitest.config.ts index 010909f9..55ab6cc4 100644 --- a/packages/control-plane/vitest.config.ts +++ b/packages/control-plane/vitest.config.ts @@ -9,6 +9,10 @@ export default defineConfig(async () => { cloudflareTest({ wrangler: { configPath: "./wrangler.toml" }, miniflare: { + d1Databases: { + MIGRATION_FRESH: "migration-fresh", + MIGRATION_UPGRADED: "migration-upgraded", + }, bindings: { TEST_MIGRATIONS: migrations, CRED_MASTER_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", diff --git a/packages/control-plane/wrangler.toml.example b/packages/control-plane/wrangler.toml.example index 8bcebe03..7071f4b0 100644 --- a/packages/control-plane/wrangler.toml.example +++ b/packages/control-plane/wrangler.toml.example @@ -128,7 +128,6 @@ run_worker_first = [ "/box-image", "/box-image/*", "/box-payload/*", - "/boxes/*", "/connect/*", "/connections", "/connections/*", diff --git a/packages/schema/README.md b/packages/schema/README.md index 702480fa..c65e7fa3 100644 --- a/packages/schema/README.md +++ b/packages/schema/README.md @@ -4,7 +4,7 @@ One contract for the whole system. - Workspace view types. The two enums: `phase` = `creating | ready | destroying | destroyed | error`, `retryAction` = `poll | destroy | create | - null`. Broker wire. Volume shape. Cross-runtime conformance fixtures. + null`. Volume shape. Cross-runtime conformance fixtures. - The same corpora gate box publication and pin the webapp, control-plane and Go readers of each contract. - control-plane implements it · webapp imports it · box tests against it. @@ -12,4 +12,3 @@ One contract for the whole system. The fixture corpora live under [`fixtures/`](fixtures/) — one directory per cross-runtime contract. The table mapping each contract to its fixtures and both-side conformance tests is in the root [CLAUDE.md](../../CLAUDE.md). - diff --git a/packages/schema/src/agent-catalog.ts b/packages/schema/src/agent-catalog.ts index 5a52c901..b7915a8b 100644 --- a/packages/schema/src/agent-catalog.ts +++ b/packages/schema/src/agent-catalog.ts @@ -1,15 +1,13 @@ -import { HARNESSES } from "./broker.js"; - /** The shared model → provider catalog. * * It mirrors the per-provider model and effort lists the pinned harness CLIs * accept; "default" is expressed by omitting the model or effort, so it is not - * listed. The providers are the TUI harness list (`HARNESSES` in broker.ts) — - * one constant, derived, never re-spelled. The control plane keeps a + * listed. The provider tuple is defined here with the catalog it governs. + * The control plane keeps a * byte-identical copy in `control-plane/core/wire.ts` (core code may not * import packages); `test/wire-drift.test.ts` holds the two together. Extend * both copies in the same change. */ -export const AGENT_PROVIDERS = HARNESSES; +export const AGENT_PROVIDERS = ["claude", "codex"] as const; export type AgentProvider = (typeof AGENT_PROVIDERS)[number]; diff --git a/packages/schema/src/api.ts b/packages/schema/src/api.ts index 8a30db25..2002a1d9 100644 --- a/packages/schema/src/api.ts +++ b/packages/schema/src/api.ts @@ -66,15 +66,6 @@ export interface PollResponse { workspaces: WorkspaceView[]; } -export interface RegisterKeysResponse { - memberUnixName: string; - broker: { - host: string; - port: number; - sshHostPublicKey: string; - }; -} - export interface ApiError { error: string; retryAction: RetryAction; diff --git a/packages/schema/src/broker.ts b/packages/schema/src/broker.ts deleted file mode 100644 index fb110bab..00000000 --- a/packages/schema/src/broker.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const FEED_MAX_BYTES = 1_048_576; - -export const HARNESSES = ["claude", "codex"] as const; - -export interface FeedResponse { - /** Opaque. */ - version: string; - members: FeedMember[]; -} - -export interface FeedMember { - unixName: string; - harnesses: string[]; - /** Empty keeps the account; absence from the feed deprovisions it. */ - keys: FeedKey[]; -} - -export interface FeedKey { - pubkey: string; - op: "mint" | "deposit"; -} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 4f87422c..6fdf9516 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -3,7 +3,6 @@ export * from "./agent-rules.js"; export * from "./api.js"; export * from "./box-config.js"; export * from "./box-payload.js"; -export * from "./broker.js"; export * from "./credential.js"; export * from "./json.js"; export * from "./machine.js"; diff --git a/packages/schema/src/workspace.ts b/packages/schema/src/workspace.ts index 94551a38..70466621 100644 --- a/packages/schema/src/workspace.ts +++ b/packages/schema/src/workspace.ts @@ -1,14 +1,5 @@ import type { BoxPayloadOutcome } from "./box-payload.js"; -export interface WorkspaceEnvironment { - env: Record; - startupScript: string | null; -} - -export interface WorkspaceEnvironmentResponse extends WorkspaceEnvironment { - filesReady: boolean; -} - export const PHASES = [ "creating", "ready", diff --git a/packages/webapp/src/lody/agent-configs.ts b/packages/webapp/src/lody/agent-configs.ts index 623dd827..55de598e 100644 --- a/packages/webapp/src/lody/agent-configs.ts +++ b/packages/webapp/src/lody/agent-configs.ts @@ -15,14 +15,10 @@ * and spawns our binary: the box's own `claude`, which keeps itself current and * so decides which models this composer can offer (docs/LODY-MODELS.md). * - * AND WHICH BINARY. `/usr/local/bin/claude` is the box's PATH SHIM, not the - * vendor CLI: it mints a fresh OAuth token through `blitz-cred-claude` and execs - * `/opt/blitz/npm/bin/claude`. Pointing the override at the vendor binary - * directly would hand the adapter an unauthenticated CLI, because nothing else - * in the daemon's environment carries `CLAUDE_CODE_OAUTH_TOKEN`. Credentials - * therefore stay on the existing box path and never enter `config.env` — that - * row is a synced CRDT, and `session/create.env` is the per-turn escape hatch - * phase 6 uses instead. + * AND WHICH BINARY. `/usr/local/bin/claude` is the box's PATH shim. + * It executes `/opt/blitz/npm/bin/claude` without changing authentication. + * The native HOME store remains the only login source. + * Credentials never enter `config.env`, which is a synced CRDT row. * * `kimi` and `grok` are never registered: they are managed-runtime-only and * there is no override to pin them with. `deepseek` is a builtin agent but not a @@ -34,7 +30,7 @@ import { resyncMachineFlockRows } from "@lody/components/hooks/use-machine-flock import { runStartupAcpCapabilitiesRefresh } from "@lody/components/providers/startup-acp-capabilities-refresh"; import type { LodyAtomStore, LodyWorkspaceRuntime } from "./runtime.js"; -/** The shim, not `/opt/blitz/npm/bin/claude`; see the module comment. */ +/** The PATH shim keeps native authentication and update behavior consistent. */ export const BLITZ_CLAUDE_EXECUTABLE = "/usr/local/bin/claude"; export const BLITZ_CODEX_PATH = "/usr/local/bin/codex"; diff --git a/packages/webapp/test/lody-daemon-harness.ts b/packages/webapp/test/lody-daemon-harness.ts index 553e385b..ad1dee14 100644 --- a/packages/webapp/test/lody-daemon-harness.ts +++ b/packages/webapp/test/lody-daemon-harness.ts @@ -20,7 +20,7 @@ * this box is 75 bytes, and `/lody-data/run/lody-oss-loro-data-plane.sock` * is 118. So the data dir is a short `os.tmpdir()` path, not the scratchpad. */ -import { spawn, spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { cpSync, @@ -624,23 +624,10 @@ export async function startLodyHarness(): Promise { } /** - * Whether a live agent turn can be attempted on this machine, by EITHER of the - * two paths a Claude Code process can be signed in on. - * - * The box path is the shim: `/usr/local/bin/claude` asks `blitz-cred-claude` - * for a fresh OAuth token on every process start, and that token comes from the - * workspace's connected Claude account through the broker. A box has no - * `~/.claude/.credentials.json` at all, so the file check below answers `false` - * on exactly the machine the product runs on — which is how the phase-2 exit - * test came to report "the daemon accepted the dispatch" as a pass on a box - * that could not have run a turn. - * - * The mint is checked by EXIT STATUS, never by reading what it prints: a - * credential that reaches a variable in this process is a credential that can - * reach a log. + * Whether the native Claude credential can support a live agent turn. + * The check reads only expiry metadata and never reads a token. */ export function claudeCredentialAvailable(): boolean { - if (claudeTokenMintable()) return true; const home = process.env.HOME; if (home === undefined) return false; const path = join(home, ".claude", ".credentials.json"); @@ -657,14 +644,3 @@ export function claudeCredentialAvailable(): boolean { return false; } } - -/** The box shim's own source of truth: `blitz-cred-claude` exits non-zero and - * prints nothing when the workspace has no Claude connection behind it. Absent - * on a developer laptop, where the file path above is the answer instead. */ -export function claudeTokenMintable(): boolean { - const minter = "/usr/local/bin/blitz-cred-claude"; - if (!existsSync(minter)) return false; - // Never `stdio: 'pipe'`: the token must not enter this process at all. - const probe = spawnSync(minter, [], { stdio: "ignore", timeout: 20_000 }); - return probe.status === 0; -} diff --git a/packages/webapp/test/lody-session-roundtrip.test.ts b/packages/webapp/test/lody-session-roundtrip.test.ts index 92afa138..9f823d1d 100644 --- a/packages/webapp/test/lody-session-roundtrip.test.ts +++ b/packages/webapp/test/lody-session-roundtrip.test.ts @@ -211,7 +211,7 @@ describe.skipIf(!lodyDaemonAvailable())("phase 2: a session round-trips against // TODO(lody-phase3): with no usable agent credential this asserts only // that the daemon ACCEPTED the dispatch, not that an adapter launched and // streamed. Canary must prove the rest: one turn through - // `/usr/local/bin/claude` with `blitz-cred-claude` minting the token, + // `/usr/local/bin/claude` with a valid native login, // asserting an assistant entry appears in the same session doc and the // daemon log shows the ACP spawn (plans/evidence/lody-phase1.md blocker 5). return; diff --git a/tools/e2e/coverage.mjs b/tools/e2e/coverage.mjs index 63ba4e2e..03de2b0a 100755 --- a/tools/e2e/coverage.mjs +++ b/tools/e2e/coverage.mjs @@ -17,7 +17,6 @@ const SELECTABLE_SUITE_NAMES = Object.freeze([ "quota-seam", "volumes", "destroy-while-creating", - "broker-best-effort", ]); const SUITE_ENVIRONMENT_VARIABLE = "COVERAGE_SUITE"; @@ -146,16 +145,6 @@ const details = { quota: null, volumePersistence: null, destroyWhileCreating: null, - broker: { - build: "not attempted", - container: "not created", - enrollment: "not attempted", - approvalStatus: null, - stateProof: null, - deregistration: "not attempted", - transcript: [], - gap: null, - }, teardown: null, }; @@ -683,239 +672,6 @@ function processError(result) { return output.length > 0 ? output : `exit=${result.status ?? "unknown"}; signal=${result.signal ?? "none"}`; } -async function brokerEnrollment(containerName, advertisedPort) { - const child = spawn( - "docker", - [ - "exec", - containerName, - "blitz-broker", - "enroll", - "--origin", - cpUrl, - "--host", - "127.0.0.1", - "--port", - String(advertisedPort), - ], - { cwd: repoRoot, env: dockerEnvironment(), stdio: ["ignore", "pipe", "pipe"] }, - ); - let stdout = ""; - let stderr = ""; - let instructionsResolved = false; - let resolveInstructions; - const instructions = new Promise((resolveValue) => { resolveInstructions = resolveValue; }); - const inspectInstructions = () => { - if (instructionsResolved) return; - const lines = stdout.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean); - const verificationUri = lines.find((line) => /^https?:\/\//u.test(line)); - const userCode = lines.find((line) => /^[A-F0-9]{4}-[A-F0-9]{4}$/u.test(line)); - if (verificationUri !== undefined && userCode !== undefined) { - instructionsResolved = true; - resolveInstructions({ verificationUri, userCode }); - } - }; - child.stdout.on("data", (chunk) => { - stdout = appendCapped(stdout, chunk); - inspectInstructions(); - }); - child.stderr.on("data", (chunk) => { stderr = appendCapped(stderr, chunk); }); - const closed = new Promise((resolveClose) => { - child.once("error", (error) => resolveClose({ status: null, signal: null, error })); - child.once("close", (status, signal) => resolveClose({ status, signal, error: null })); - }); - const first = await Promise.race([ - instructions.then((value) => ({ kind: "instructions", value })), - closed.then((value) => ({ kind: "closed", value })), - new Promise((resolveTimeout) => { - const timer = setTimeout(() => resolveTimeout({ kind: "timeout" }), 60_000); - instructions.finally(() => clearTimeout(timer)); - closed.finally(() => clearTimeout(timer)); - }), - ]); - let approvalStatus = null; - let verificationUri = null; - if (first.kind === "instructions") { - verificationUri = first.value.verificationUri; - const expectedUri = `${cpUrl}/oauth/device/approve`; - assert(verificationUri === expectedUri, `broker returned unexpected verification URI ${verificationUri}`); - const { response, body } = await controlPlane("/oauth/device/approve", { - method: "POST", - body: JSON.stringify({ user_code: first.value.userCode }), - }); - approvalStatus = response.status; - if (response.status !== 204) { - child.kill("SIGTERM"); - throw new Error(`POST /oauth/device/approve HTTP ${response.status}; expected 204${apiError(body)}`); - } - } else if (first.kind === "timeout") { - child.kill("SIGTERM"); - throw new Error("broker enroll emitted no device instructions within 60s"); - } - let result; - if (first.kind === "closed") { - result = first.value; - } else { - let closeTimer; - result = await Promise.race([ - closed.finally(() => clearTimeout(closeTimer)), - new Promise((resolveTimeout) => { - closeTimer = setTimeout(() => { - child.kill("SIGTERM"); - resolveTimeout({ status: null, signal: "timeout", error: null }); - }, 120_000); - }), - ]); - } - return { ...result, stdout, stderr, approvalStatus, verificationUri }; -} - -async function deregisterBroker(containerName) { - const script = String.raw` -const { readFile } = await import("node:fs/promises"); -const credential = JSON.parse(await readFile("/var/lib/blitz-broker/box-credential.json", "utf8")); -const response = await fetch(process.argv[1] + "/boxes/" + encodeURIComponent(credential.box_id) + "/broker", { - method: "DELETE", - headers: { Authorization: "Bearer " + credential.access_token }, -}); -console.log("HTTP " + response.status); -if (response.status !== 204) process.exit(1); -`; - return runCommand( - "docker", - ["exec", containerName, "node", "--input-type=module", "--eval", script, cpUrl], - { env: dockerEnvironment(), timeoutMs: 30_000 }, - ); -} - -async function runBrokerSuite() { - const image = `blitz-broker:coverage-${runToken}`; - const containerName = `blitz-coverage-broker-${runToken}`; - const volumeName = `blitz-coverage-broker-state-${runToken}`; - let containerCreated = false; - let volumeCreated = false; - let enrolled = false; - let primaryError = null; - try { - console.log(`EVIDENCE broker build image=${image}; Dockerfile=packages/broker/Dockerfile`); - const build = await runCommand( - "docker", - ["build", "--progress=plain", "--file", "packages/broker/Dockerfile", "--tag", image, "packages/broker"], - { env: dockerEnvironment(), timeoutMs: 1_200_000, heartbeatLabel: "broker build running" }, - ); - if (build.status !== 0) { - details.broker.gap = `local image build failed: ${processError(build)}`; - throw new Error(details.broker.gap); - } - details.broker.build = `PASS in ${Math.round(build.durationMs / 1_000)}s`; - - const volumeCreate = await runCommand( - "docker", - ["volume", "create", "--label", `blitz.coverage.run=${runLabel}`, volumeName], - { env: dockerEnvironment(), timeoutMs: 30_000 }, - ); - assert(volumeCreate.status === 0, `docker volume create failed: ${processError(volumeCreate)}`); - volumeCreated = true; - - const run = await runCommand( - "docker", - [ - "run", - "--detach", - "--name", - containerName, - "--label", - `blitz.coverage.run=${runLabel}`, - "--publish", - "127.0.0.1::22", - "--volume", - `${volumeName}:/var/lib/blitz-broker`, - image, - ], - { env: dockerEnvironment(), timeoutMs: 60_000 }, - ); - assert(run.status === 0, `docker run failed: ${processError(run)}`); - containerCreated = true; - details.broker.container = "PASS running with labeled state volume"; - await sleep(2_000); - const portResult = await runCommand( - "docker", - ["port", containerName, "22/tcp"], - { env: dockerEnvironment(), timeoutMs: 30_000 }, - ); - assert(portResult.status === 0, `docker port failed: ${processError(portResult)}`); - const portMatch = portResult.stdout.trim().match(/:(\d+)$/u); - assert(portMatch !== null, `docker port returned an invalid mapping: ${redact(portResult.stdout)}`); - - const enrollment = await brokerEnrollment(containerName, Number(portMatch[1])); - details.broker.approvalStatus = enrollment.approvalStatus; - details.broker.transcript = [ - ...enrollment.stdout.split(/\r?\n/u), - ...enrollment.stderr.split(/\r?\n/u).map((line) => (line === "" ? "" : `stderr: ${line}`)), - `exit=${enrollment.status ?? "null"}; signal=${enrollment.signal ?? "none"}`, - ].filter(Boolean).map((line) => redact(line, 1_000)); - if (enrollment.status !== 0) { - const reason = redact(enrollment.stderr || enrollment.stdout, 2_000).replace(/\s+/gu, " "); - details.broker.gap = `enrollment stopped after device authorization${enrollment.approvalStatus === 204 ? " and approval" : ""}: ${reason || `exit=${enrollment.status}`}`; - throw new Error(details.broker.gap); - } - enrolled = true; - details.broker.enrollment = "PASS device authorization, operator approval, token exchange, and PUT /boxes/:id/broker"; - - const state = await runCommand( - "docker", - [ - "exec", - containerName, - "sh", - "-c", - "test -s /var/lib/blitz-broker/origin && test -s /var/lib/blitz-broker/box-credential.json && stat -c 'credential_mode=%a credential_bytes=%s' /var/lib/blitz-broker/box-credential.json", - ], - { env: dockerEnvironment(), timeoutMs: 30_000 }, - ); - assert(state.status === 0, `broker state proof failed: ${processError(state)}`); - assert(/^credential_mode=600 credential_bytes=\d+$/u.test(state.stdout.trim()), `unexpected credential proof: ${redact(state.stdout)}`); - details.broker.stateProof = state.stdout.trim(); - details.broker.gap = null; - return `image built; container ran; enroll exit=0; approval HTTP 204; ${state.stdout.trim()}; registry endpoint accepted broker`; - } catch (error) { - primaryError = error; - if (details.broker.gap === null) details.broker.gap = shortError(error); - throw error; - } finally { - const cleanupErrors = []; - if (enrolled && containerCreated) { - const deregister = await deregisterBroker(containerName); - if (deregister.status === 0 && deregister.stdout.trim() === "HTTP 204") { - details.broker.deregistration = "PASS DELETE /boxes/:id/broker HTTP 204"; - } else { - details.broker.deregistration = `FAIL ${processError(deregister)}`; - cleanupErrors.push(`broker deregistration failed: ${processError(deregister)}`); - } - } - if (containerCreated) { - const removed = await runCommand("docker", ["rm", "--force", containerName], { - env: dockerEnvironment(), - timeoutMs: 30_000, - }); - if (removed.status !== 0) cleanupErrors.push(`container cleanup failed: ${processError(removed)}`); - } - if (volumeCreated) { - const removed = await runCommand("docker", ["volume", "rm", "--force", volumeName], { - env: dockerEnvironment(), - timeoutMs: 30_000, - }); - if (removed.status !== 0) cleanupErrors.push(`Docker volume cleanup failed: ${processError(removed)}`); - } - if (primaryError === null && cleanupErrors.length > 0) { - throw new Error(cleanupErrors.join("; ")); - } - if (cleanupErrors.length > 0) { - console.log(`FAIL broker-cleanup: ${redact(cleanupErrors.join("; "))}`); - } - } -} - let preflightOk = false; try { const preflight = await runSuite("preflight", async () => { @@ -1156,12 +912,9 @@ try { } }); - if (selectedSuiteNames.has("broker-best-effort")) { - await runSuite("broker-best-effort", runBrokerSuite, { required: false }); - } } else { for (const name of options.selectedSuiteNames) { - suites.push({ name, ok: false, required: name !== "broker-best-effort", durationMs: 0, evidence: "preflight prerequisite failed" }); + suites.push({ name, ok: false, required: true, durationMs: 0, evidence: "preflight prerequisite failed" }); console.log(`FAIL ${name}: preflight prerequisite failed`); } } @@ -1287,7 +1040,6 @@ try { console.log("EVIDENCE volume-persistence " + redact(JSON.stringify(details.volumePersistence), 8_000)); console.log("EVIDENCE destroy-timeline " + redact(JSON.stringify(details.destroyWhileCreating), 8_000)); -console.log("EVIDENCE broker-transcript " + redact(JSON.stringify(details.broker), 8_000)); console.log("EVIDENCE teardown-proof " + redact(JSON.stringify(details.teardown), 8_000)); const requiredFailures = suites.filter((suite) => suite.required && !suite.ok); diff --git a/tools/e2e/credentials.mjs b/tools/e2e/credentials.mjs index 2daf7b24..d6b65ff7 100644 --- a/tools/e2e/credentials.mjs +++ b/tools/e2e/credentials.mjs @@ -487,7 +487,7 @@ try { await runStep("A", "build-box-cli", async () => { mkdirSync(workDir, { recursive: true, mode: 0o700 }); const built = spawnSync("go", ["build", "-o", cliPath, "./cmd/blitz-cred"], { - cwd: join(repoRoot, "packages/broker"), + cwd: join(repoRoot, "packages/box/credential-helper"), encoding: "utf8", timeout: 180_000, env: childEnvironment({ GOOS: "linux", GOARCH: "amd64" }), From 06830c6b8f9453a0fa2bc323728faf293e20dd8e Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sat, 5 Sep 2026 17:10:19 -0700 Subject: [PATCH 2/3] broker: the managed build carries a third copy of the core manifest Deleting a core/ file needs four hand-written edits in three files. blitzdev-emitter.test.ts is gated behind BLITZDEV_MANAGED=1, so a plain npm test does not reach it. Its list and its length now match the other two. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GocywGPN9b8DrcBXR9C4id --- packages/control-plane/test/blitzdev-emitter.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/control-plane/test/blitzdev-emitter.test.ts b/packages/control-plane/test/blitzdev-emitter.test.ts index 2d107f36..6960f321 100644 --- a/packages/control-plane/test/blitzdev-emitter.test.ts +++ b/packages/control-plane/test/blitzdev-emitter.test.ts @@ -104,7 +104,6 @@ const expected = [ "core/org-credential-routes.ts", "core/org-credentials.ts", "core/principals.ts", - "core/registry.ts", "core/session-shares.ts", "core/sessions.ts", "core/version.ts", @@ -150,7 +149,7 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed emitter [vendor-onl expect(UPLOAD_MANIFEST).toEqual(expected); expect(first.files.map((file) => file.path)).toEqual(expected); expect(first).toEqual(second); - expect(first.files).toHaveLength(102); + expect(first.files).toHaveLength(101); expect(first.files.every((file) => file.bytes <= 1024 * 1024)).toBe(true); }); From 9365dea8fef3ebb36be54564753ad0a898baa22b Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sat, 5 Sep 2026 19:10:05 -0700 Subject: [PATCH 3/3] box: the credential-refresh script needs its execute bit The new script shipped 0644. s6 answered `s6-applyuidgid: fatal: unable to exec /usr/local/libexec/blitz-credential-refresh: Permission denied`, and only the box image build saw it. Nothing else asserted the mode. rootfs-exec-bits.test.ts now checks every script under usr/local/bin and usr/local/libexec. s6-rc.d run and up files stay 0644 on purpose: s6 runs them through its own launcher. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GocywGPN9b8DrcBXR9C4id --- .../guest-tests/test/rootfs-exec-bits.test.ts | 36 +++++++++++++++++++ .../local/libexec/blitz-credential-refresh | 0 2 files changed, 36 insertions(+) create mode 100644 packages/box/guest-tests/test/rootfs-exec-bits.test.ts mode change 100644 => 100755 packages/box/rootfs/usr/local/libexec/blitz-credential-refresh diff --git a/packages/box/guest-tests/test/rootfs-exec-bits.test.ts b/packages/box/guest-tests/test/rootfs-exec-bits.test.ts new file mode 100644 index 00000000..d4e8f60b --- /dev/null +++ b/packages/box/guest-tests/test/rootfs-exec-bits.test.ts @@ -0,0 +1,36 @@ +import { readdirSync, statSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +/** + * Every script the image EXECS directly must carry the execute bit. + * + * WHY THIS EXISTS. `blitz-credential-refresh` shipped 0644 once. Nothing here + * noticed, all three gates passed, and the failure surfaced only in the box + * image build, as `s6-applyuidgid: fatal: unable to exec + * /usr/local/libexec/blitz-credential-refresh: Permission denied`. That is a + * container boot away from the mistake — this is one `statSync` away. + * + * ONLY THESE TWO DIRECTORIES. `s6-rc.d/*` `run` and `up` files are 0644 on + * purpose: s6 reads them and runs them through its own launcher, so an execute + * bit there would say something this repo does not mean. + */ +const rootfs = (path: string) => + fileURLToPath(new URL(`../../rootfs/${path}`, import.meta.url)); + +const EXECUTED_DIRECTORIES = ["usr/local/bin", "usr/local/libexec"] as const; + +const scripts = EXECUTED_DIRECTORIES.flatMap((directory) => + readdirSync(rootfs(directory)).map((name) => [`${directory}/${name}`] as const), +); + +describe("scripts the image execs directly", () => { + it("finds every directory this suite claims to cover", () => { + // A renamed directory would leave this suite asserting about nothing. + expect(scripts.length).toBeGreaterThan(EXECUTED_DIRECTORIES.length); + }); + + it.each(scripts)("%s is executable", (path) => { + expect(statSync(rootfs(path)).mode & 0o111).toBe(0o111); + }); +}); diff --git a/packages/box/rootfs/usr/local/libexec/blitz-credential-refresh b/packages/box/rootfs/usr/local/libexec/blitz-credential-refresh old mode 100644 new mode 100755