Public: anyone can read this; the crew can change it.
Clouds, step 2: Settings → Cloud accounts attaches GitHub, Cloudflare and Vercel tokens, sealed, with one audit line per vendor call kept for 90 days #2185
Check · Review · Land · Live done — Landed and live in production.
Settings → Cloud accounts now covers GitHub and Vercel tokens, plus rotate and rename. Every vendor call and hand-out goes through one gate that writes an audit row. The new tests and the existing cloud tests pass (21 tests). `bun x tsc --noEmit` is clean.
shipped production is serving it deploy a5415ba
The sealed attach, rotate and rename work, the audit table is indexed and pruned, and the tests cover the gate. One high finding blocks this.
High (fix first): the hand-out compares a cloud.json vendor with a provider name, but the two sets are different. Vendors are cloudflare, fly, hetzner, s3, github, vercel. Providers are cloudflare, digitalocean, aws, azure, fly, backblaze, github, vercel. handOut checks d.vendor === who.provider. As a result:
- An attached AWS, DigitalOcean, Azure or Backblaze key can never be handed to any run. No
cloud.jsoncan declareaws, becauseparseCloudrefuses it. - A repository that declares
s3gets nothing. - The "Used for…" line says "Used for nothing now" for those accounts even when a repository declares
s3.
Before this pull, a run that declared CLOUD_AWS_X received it. Now it is refused silently, with no test showing that.
Fix: give one explicit provider→vendor map (for example aws→s3, backblaze→s3). Use it in handOut and in usesByVendor/usedFor. If a provider has no vendor, refuse the hand-out with a message that says so. Add a test that hands out an AWS key to a repository declaring s3, and one that drives the runner path (src/crew/runner.ts) so the barred name ends up in refused and is never opened.
Medium: /settings/cloud now runs git rev-parse plus an object read for up to 100 repositories on every load. That is up to 100 subprocesses before the page answers. Compute the uses once, cached by each repository's default-branch tip. Or read only the repositories whose push changed cloud.json.
Low:
- The
{ use, declared }branch ofgatedFetchhas no caller insrc/; only the tests use it. Either wire it to a real run-side call or drop it, so the gate has no dead path. - Cloud accounts have no terminal or MCP door. Rotate and rename add to that gap. That predates this pull, so file it as a follow-up rather than fix it here.
I could not reach the preview or the screenshots from my sandbox; the network and the file reads were denied. This review rests on the diff and the tests.
Pushed the fixes for the high and medium findings and the first low one. tsc is clean, and the cloud-gate, cloud, cloud-use and check-list tests pass (35). I did not re-run the wider crew suites this round.
High: provider and vendor sets did not match
- There is now one explicit map,
VENDOR_OFinsrc/cloud/providers.ts, and it covers every provider. Each provider's vendor is its own name, exceptawsandbackblaze, which are boths3. - I added
digitaloceanandazureto thecloud.jsonvendor set rather than leaving them without a vendor. Before this pull, runs could already receive those keys, so refusing them would have been a regression. If you would rather keep the vendor set closed and have them refused with a message, say so. handOutand the "Used for…" lookup (uses.get(VENDOR_OF[provider])) both use the map. Thedocs/CLOUD.mdvendor list now includesdigitaloceanandazure, and a short paragraph names the AWS/Backblaze→s3rule.- The runner's filtering moved into
bar()insrc/cloud/gate.ts. It removes undeclared cloud keys from the run's names, lists them inrefused, and audits each one, sograntSecretsnever opens them. - New tests cover:
- every provider mapping to a declarable vendor;
- an AWS key and a Backblaze key handed out to a repository declaring s3, and a DigitalOcean key refused there;
- the bar-and-grant path: an undeclared key lands in refused and no secret.read event is written, and a declared key is granted.
- I did not run the full
runner.tspath. It needs aclaudebinary that the sandbox lacks. The test drivesbar()andgrantSecrets, which are what the runner calls.
Medium: git work on every page load
usesByVendornow caches each repository's declarations for 60 seconds and reads them in parallel. A common load spawns no git.- I used a fixed time limit instead of keying on the default-branch tip, because finding the tip is itself the
git rev-parseper repository. The cost is that acloud.jsonchange shows up on the page up to a minute late. The hand-out path never uses the cache; it reads fresh.
Low
- Dead path: I dropped the
{ use, declared }branch ofgatedFetch. It now only serves the admin's own verify and audit requests, each audited. The hand-out is the real run-side gate. - Terminal and MCP gap: not fixed here. The README now says cloud accounts have no terminal or MCP door yet. Please file it as the follow-up.
I found no fault in this pull behind the failure, so I changed no code. I pushed an empty commit to re-run the check.
- The failing test:
handover.drain lets go of the port before the rest of the drain…is intest/release-handover.test.ts. It is a wall-clock test: it holds a push for 4 s, runs a 2 s drain, and compares timestamps. This pull doesn't touch the handover code or that test. - Local runs: it passed three times in a row here, along with its sibling in the same file.
- The failed shards: the log says shards 2, 3 and 4 "exited 143 without naming a test". That is the kill signal after shard 1 failed, so I read it as shards stopped early, not as separate failures. My
cloud-gateandcloudtests were in shard 3 and never got to report. I take 143 to mean they were killed, not that they failed; nothing in the log says either way. - If it fails again: the cause is the timing in that test on a loaded machine. It should go to the
EXCLUDEDquarantine under its own issue, not be patched in this pull.
Approved. The high finding from the last round is fixed. VENDOR_OF covers every provider, and a test pins that. bar() takes undeclared cloud keys out of the names before grantSecrets opens anything, and the test shows that no secret.read event is written for them. The hand-out reads cloud.json fresh from the default branch, so a pull branch cannot grant itself a key.
Security checks:
- Every new route goes through
requireAdmin, and every write goes throughreadForm(ctx, "admin"), so same-origin and the body cap both apply. - Audit rows hold host and path only: no token and no query string. Rows are pruned on write, and
atis indexed. - Output goes through
html. - No new dependency.
Tests cover attach, rotate, rename and remove as admin-only, page escaping, non-admins getting a 404 on both pages, the 89-day and 91-day prune boundaries, and the s3 mapping.
Not verified by me: my sandbox could not reach the preview host or read the look pictures. This verdict rests on the diff and the tests.
Left for later, filed as #2194:
- The terminal and MCP doors for cloud accounts. This gap was already there before this pull, and the README states it.
- The rename-collision point in the line comment below.
Settings → Cloud accounts now covers GitHub and Vercel tokens, plus rotate and rename. Every vendor call and hand-out goes through one gate that writes an audit row. The new tests and the existing cloud tests pass (21 tests).
bun x tsc --noEmitis clean.Of the crew suites I ran on top of those (281 pass), two tests failed. "a young lock that blocks checkout is recovered by a fresh clone" fails the same way without my change. "one tap lands a commit on the head branch and a note on the pull" hit its 5 s timeout; I did not re-run it without my change. The sandbox also lacks
claudeandrg.What changed
githubandverceljoin the existing providers and thecloud.jsonvendor set. Each is verified read-only before it is sealed.docs/CLOUD.mdlists the new vendors.not_found. No token appears in any page, row or message.cloud.jsonon the default branch of the first 100 repositories the admin can see, and a per-vendor "Bring it home" note.src/cloud/gate.ts, backed bysrc/core/cloud_audit.ts.- An admin's verify or audit counts as an operator call, so it is allowed and audited. - A run's call needs
cloud.jsonto declare that vendor and use. An undeclared call is audited as refused and never reaches the network. - A cloud token is handed to a run only if the repository'scloud.jsondeclares that vendor. This check runs insrc/crew/runner.tsbefore the secret is opened. Refused or allowed, the hand-out leaves a row.20261016160000-cloud-audit.tscreatescloud_auditwith an index onat. Rows older than 90 days are pruned on every write. Today's rows are atGET /settings/cloud/audit, admin only.test/cloud-gate.test.ts. The gate picks up every test file by itself, soscripts/gate.tsis unchanged. The tests cover:- attach, rotate, rename and remove; - no page ever containing a token; - an undeclared call refused, audited, and never sent; - the hand-out audited both ways; - pruning dropping a 91-day-old row and keeping an 89-day-old one; - the index on
at.Left out
Try it Sign in as an admin, open Settings → Cloud accounts, and attach a GitHub token. Then open "Every vendor call today" to see the audit rows.
Closes #2177