Public: anyone can read this; the crew can change it.
One place for a secret: an app uses a sealed secret by name, and rotating it… #12184
Check · Review · Land · Live done — Landed and live in production.
A secret now reaches an app by name from one place, and an app's list holds two kinds: plain variables and sealed secrets. Tests on the new code pass, but several suites could not be run clean here; they are listed at the end.
shipped production is serving it deploy 867ab48
12 earlier comments · 1 event
A secret now reaches an app by name from one place, and an app's list holds two kinds: plain variables and sealed secrets. Tests on the new code pass, but several suites could not be run clean here; they are listed at the end.
What changed
- Two kinds in one list.
app_envgainskindandvalue_plain(migration20261114070100-app-env-kind).
- A variable is plain: stored and shown in the clear, and skipped by rotate-secret-key because its value_enc is ''.
- A secret is sealed and write-only, as before. Every existing row defaults to secret, so nothing is exposed.
- When no kind is chosen, a name ending _KEY, _TOKEN, _SECRET or holding PASSWORD is a secret and anything else is a variable.
- Setting a name again replaces it, kind included, so that is how a row is converted.
- The web form, the API (kind in the PUT body) and MCP app_env all take kind.
- Needs and grants.
- forge.json accepts env: ["SIMPLEFIN_TOKEN"], validated in parseManifest.
- A forge or org secret reaches an app only after a forge admin grants that name to that app. Grants live in the new secret_grant table (migration 20261114070000-secret-grants).
- An app's own value is nearer than a granted one. AppsRuntime.secretsFor adds the granted names, and the app terminal gets them too.
- The early return for a guest's unapproved preview is unchanged, so it receives nothing of either kind.
- Settings page. Settings → App has a "Secrets this app uses" table showing each name as this app's own, granted from the org or forge, set higher up but not granted, or missing. A forge admin gets Grant and Revoke buttons (POST
/:owner/:name/settings/app/secrets/grantand/revoke). The Environment list now shows the kind, shows variable values, and has a kind select. - Rotation.
- Setting a forge or org secret again restarts every app it is granted to.
- Granting or revoking also restarts the app.
- The secret_start ledger records which value each environment last started with.
- A secret's row on the Secrets page lists the apps that use it and whether each started with the current value.
- Every read is still a secret.read event naming app:<id>:<env> and the name, never the value.
- New-app flow. The new-app form has three optional name/value pairs. They are stored as the app's own secrets before the first start, and the first issue names them (never the values) so the crew reads them from
process.env. - Docs.
docs/APPS.mdhas a new "One place for a secret" section and an updated Environment section.docs/design/areas/secrets.mdhas a short note.
Tests
- New:
test/app-secrets.test.tsandtest/app-secrets-web.test.tscover each "done when" item except the guest-preview one, which has no test of its own (see below). - Updated:
mcp-app-envused a plain name and expected write-only, andsnapshot-drillusedSECRET_WORDand needs a real sealed row. Both now name the secret kind explicitly. - Snapshot drill and restore now pick only sealed
app_envrows (value_enc <> ''), because a variable has nothing to unseal. tscandscripts/gate.tsare clean. No existing test run throughfiles=$(bun scripts/fast.ts)came back red because of this change.
Not done, or not clean
- There is no end-to-end test for a guest pull's preview with a granted secret. The existing
guest-pull-envtest covers the same early return insecretsForbefore either kind is read. - The repo-scope
secretrows (backups, cloud and similar forge-internal code) are untouched, so the repo settings card is still there. App-level secrets live inapp_env. - The terminal Variables screen lists names only and does not show a variable's value or kind.
- Granting is for forge admins only. The repo owner sees the table but cannot grant.
- Org grants take the repository's owner as the org. An app owned by a person has no org scope.
- Failing before this change, so not mine:
- rotate-secret-key fails because repo_upstream.credential_enc is missing from SEALED_COLUMNS.
- bring.test.ts and settings-routes.test.ts fail.
- new-app-timing times out in this sandbox.
- design-lint flags src/web/studio/cards.ts.
To try it: in an app's Settings → App, set FOO_TOKEN and GREETING and see secret and variable. Put SIMPLEFIN_TOKEN under Settings → Secrets, list it in forge.json env, then press Grant on the app's settings page. Set it again under Secrets and the row lists the app.
The gate failure on #12184 is fixed and pushed. scripts/gate.ts now passes locally, and tsc and the new secrets and migration tests pass.
- Comment wrapping: I rewrapped the ten comment lines the check named to under 100 columns, including the migration header. The migration pin was unaffected.
- Both doors and pipeline first: these rules would have failed next. I answered them with a
No door:line and aDirection:line in the commit message, as the gate allows. TheNo door:line says the secrets table is part of the app settings page, the terminal already has a Variables screen, and granting is a forge admin's click. The terminal screen does not show a variable's kind or value yet.
I did not rerun the wider set of existing suites this time. Last run, bring, settings-routes, new-app-timing and rotate-secret-key failed on the base too, so I did not fix them here. The forge's check will run the full set on this push.
From today's boundary review, for this pull before it lands (all three are the same grant mechanism you built, applied in the two places it does not reach yet; no second mechanism):
- src/apps/tty.ts: main now reads an app's terminal variables through
runtime.secretsFor(...)(commit 2f810dd), the one reader its process has, so a waiting guest pull's terminal holds none. This pull edits the same lines and still reads there directly, withoutguestWaits: it would hand a waiting guest's terminal the granted forge/org secrets too. On rebase drop this pull's tty.ts hunk and putsealedForinsidesecretsForonly (add astartflag there for the terminal'sstart=false). - Crew runs:
grantSecretsin src/crew/grant.ts still callsresolveScopeddown to the forge scope for EVERY repository, so a repository's own declaration (cloud.json, a writer's issue) can give its run any forge-wide secret. It should passonly: ["repo"]plus the names GRANTED to that repository, before anything is opened; a declared but ungranted name ends in the run's log and one needs-you line: "this run asked for X; the forge's owner has not granted X to <owner/repo>: Grant it". The failing test for this is the todo in test/boundary-secrets-store.test.ts named for this pull: make it pass. secret_grantis keyed by app_id, but a crew run is on a repository that may have no app (the forge's own repo asking for a cloud key): the grant must be reachable by repository.
Reasonableness, the owner's rule: the forge's owner granting a name to his own repository is one click at the moment it is first asked for; nothing is silently refused; nothing else gains a confirmation.
This pull now applies the same grant to the three places you named: the terminal, crew runs and repositories with no app. The rest of the diff is the original one-place-for-a-secret work. The suites and checks I ran pass; the full check has not run on this push. Three of the failures the last check named fail on origin/main too.
What changed
- Terminal. The tty.ts hunk is dropped.
runtime.secretsFor(...)has astartflag, which is false for the terminal, so it reads granted names without recording a start. A waiting guest pull's terminal still holds none. - Crew runs.
grantSecretsinsrc/crew/grant.tsnow reads the repository's own scope first. It reads the org or forge scope only for a name that was granted to that repository, from the one scope the grant names, and only for names the run declared. A declared name that exists higher up and isn't granted is never opened; it comes back asungranted. - Ungranted names. The run log gets "this run asked for X; the forge's owner has not granted X to owner/repo: Grant it". Once per name, the agent also comments on the issue with a link to
/owner/name/settings/app#secrets. That comment is my reading of the one needs-you line. Nothing is refused silently, and the run goes on without the value. - Grants by repository.
secret_grantis keyed byrepo_id, so a repository with no app can hold one. The migration is new in this pull, so I edited it in place and re-pinned it. The app's start and the crew's runs read the same row, and a rotation still restarts the app of each repository the secret was granted to.grantToRepoandrevokeFromReporeplace the per-app actions. - The click. The existing
POST /settings/app/secrets/grantroute now works without an app. For a forge admin, the settings block also lists every forge or org secret name not yet granted, each with one Grant button. Nothing else gains a confirmation.
Tests
- The todo in
test/boundary-secrets-store.test.tsis now a passing test. It checks that a member's repository gets nothing until the owner grants the name, then gets it. test/run-secrets.test.tshas new tests forungrantedand for the once-only comment.cloud.test.ts,cloud-gate.test.tsandrun-secrets.test.tsnow grant before declaring.- Four tests (
boundary-secrets-store,apps-api,app-state-etag,snapshot) failed because the test was wrong, not the code. Their variable names (GREETING,SECRET_WORD,EVENTED_VAR,BROWSED_VAR) don't look like secrets, so they were stored in the clear. I renamed them to secret-looking names so they still test write-only values. grantsOfwas exported and never called, which failed the hollow-work test. I deleted it.
What I didn't do
- I did not touch the failures that also fail on
origin/main: the spawn sites and spawn pipes (src/data/query.ts), the Server-Timing sweep, and the design-audit count and settings-routes tests.origin/mainshows the same failures for all four. - I did not try to reproduce the other failures in the check log that I didn't run.
- There is no separate grants page. The grant control stays on the app settings page, which already worked for repositories with no app.
Try it
Set a forge secret, have a crew issue declare it in its front-matter, and read the run log and the issue comment. Click Grant at /owner/name/settings/app#secrets, and the next run holds the value.
Branch history
Because I rebased onto main, I pushed a merge of origin/main onto the old branch head instead of a force push.
🤖 Generated with Claude Code
The gate failed on one comment line, src/core/app_secrets.ts:118, which was 105 columns. I rewrapped it to fit 100 and pushed.
The fixed bun scripts/gate.ts now passes, and tsc --noEmit printed no errors. I did not run the test suite again, because only the comment text changed. The forge's check will run the full suite on this push.
The pull's description is unchanged from my earlier message.
I merged main into the branch and pushed it.
There was one conflict, in the import block of src/crew/runner.ts. main added the runGitAuth import and this pull added needsYou and ungrantedSentence, so I kept both.
bun scripts/gate.ts and tsc --noEmit passed, and so did the six secrets and cloud test files I re-ran (run-secrets, app-secrets, app-secrets-web, boundary-secrets-store, cloud, cloud-gate). I did not run the rest of the suite; the forge's check will run it on this push.
The pull description from my earlier message still applies.
I merged main into the branch and pushed it. The only conflict was in docs/APPS.md.
In that conflict I kept my two kinds of setting (variable and secret) from this pull. I also kept main's new paragraph saying that an app's output is searched for its values and written [redacted].
bun scripts/gate.ts and tsc --noEmit pass. I re-ran seven test files (run-secrets, app-secrets, app-secrets-web, boundary-secrets-store, cloud-gate, apps-api, app-state-etag) and all 57 tests pass. I did not run the rest of the suite; the forge's check will.
The pull description from my earlier message still applies.
I fixed the one failure this pull caused, and I haven't run the other three tests locally.
The docs/MCP.md tool table was stale for the app_env tool. I regenerated it with bun scripts/mcp-table.ts. test/mcp-table.test.ts now passes, 8 of 8, and the fix is pushed as 89a812cac.
I left the other failures alone:
- Red on main: the check log shows
test/ssh-site.test.tsandtest/unread-tables.test.tsare also red on main at c48dffd, so they aren't counted against this pull. - Real child forge test: "a real child forge > boots in its own forge:<name> cell…" timed out at about 36 s. It's one of the tests that skip in my sandbox, so I couldn't run it here. It touches nothing this pull changed. If it fails again on the next check, that log is the thing to read.
I didn't run bun x tsc --noEmit or the gate for this docs-only fix.
Changes requested. The grant mechanism is sound, and the three points from the boundary review are applied. These need fixing in one round:
- Unsafe default for the kind (high). An unchosen kind is a plain variable unless the name matches
_KEY/_TOKEN/_SECRET/PASSWORD. Names likeDATABASE_URL,REDIS_URL,SMTP_DSNandSTRIPE_WEBHOOK, which often carry credentials, would be stored in the clear invalue_plain, listed back by the page, the API and MCP, and copied into snapshots and backups. Fail closed: default tosecretand makevariablean explicit choice. Add a test that a credential-bearing URL is sealed by default. - Both doors. The terminal is not updated. Its Variables screen shows neither kind nor value, and a person cannot grant or revoke or see needs/has/missing there. The commit-message
No door:line is not a substitute. Add thesrc/tui/screen with its entry where AGENTS.md says, and writedocs/features/<name>.mdfor the how-to. The long how-to now indocs/APPS.mdshould move there. - Missing test. Mason's own note says there is no test for a guest pull's preview with a granted secret. The change reads secrets in a new place,
sealedForinsidesecretsFor, so add a test that a waiting guest's preview and its terminal receive neither a granted secret nor a variable. Also add a test that a variable's plaintext is not in the events outbox. - Name-existence oracle (medium). In
grantSecrets, any writer who can declare a name in a front-matter learns from the run log and the issue comment whether a forge- or org-wide secret of that name exists (ungrantedversusmissing). Say the same sentence for both cases, or show it only to a forge admin. - Design (principle 1, 8). The settings block explains itself in a long paragraph that repeats docs/APPS.md. Cut it to one plain sentence. The new-app form's three name/value pairs are the same kind of extra text.
- Grant failure message.
appSecretGrantsays "Only a forge admin can grant" for every failure, includingno_such_secretandnot_found. Say which one it was. - Clean.
src/web/pages/app_secrets.tsimportsadminFormfromsettings.ts, a page importing from a page. Move it to a shared module. TherestartAppcall swallows errors with.catch(() => {}), so log or report a failed restart. Also,listAppEnvNamesnow returns values, so check that every caller outside the admin gate (API list, MCP list, terminal) is admin-gated.
All seven points are applied and pushed. The type check, gate, design-lint and the tests I chose to cover the change pass. scripts/fast.ts named 2,515 files, so I picked the covering tests myself and did not run the full suite.
- Fail closed. An unchosen kind is now a
secret. The settings form drops "Kind: by name", and Secret is the default option.looksSecretand its name-matching rule are deleted. A test setsDATABASE_URL=postgres://user:hunter2@…with no kind and checks it is sealed and absent from the listing. - Both doors. The terminal now has two screens:
- Variables (appenv.ts): shows each kind and a variable's value, and the add flow asks Secret or Variable.
- Secrets this app uses (src/tui/screens/appsecrets.ts): lists needs, has and missing, wired into the palette next to Variables. a grants and x revokes, both forge admin only, with the page's own error words (GRANT_ERRORS).
The how-to moved from docs/APPS.md to docs/features/app-secrets.md, and docs/APPS.md keeps a one-line pointer.
- Tests added.
- A waiting guest's preview and its terminal path (start=false) get neither a granted secret nor a variable. Once approved, both arrive.
- Neither a variable's plaintext nor a secret's appears in the events outbox.
- A new test/tui-appsecrets.test.ts covers the terminal screen.
- Name oracle. A declared name that is set higher up and one set nowhere now get the same log line and the same issue comment, and the docs say so.
grant.missingandgrant.ungrantedstay distinct in the data, and I did not add a separate test for the same-sentence behaviour. - Design. The settings block is one sentence, "Names this app asks for in forge.json, and where each comes from." The new-app form's explanatory paragraph is gone, and the Environment note is down to one line.
- Grant failure message. The messages now say which failure it was (
not_found,invalid_scope,no_such_secret) on both the page and the terminal. - Clean.
- adminForm and loadAdminRepo moved to src/web/admin_form.ts. I repointed every settings page that imported adminForm from settings.ts (crew-settings, detach, file_holds, danger, labels, prompts, hooks), not only mine.
- A failed restart after a secret change is now logged instead of swallowed.
- listAppEnvNames is reached outside listAppEnv only by bring.ts, which uses the names alone. Everything else, including the API, MCP, terminal and settings page, goes through listAppEnv, which requires repository admin.
Test changes. The existing terminal Variables tests (test/tui-app-env.test.ts, test/tui-appenv.test.ts) each take one more Enter, because the add flow has a new Secret/Variable step. One assertion in test/app-secrets.test.ts used to set GREETING with no kind and expect a variable. It now passes variable explicitly.
Not changed. g would have been the natural grant key, but the list's cursor movement already uses it for "jump to top", so grant is a.
Earlier check failures. I regenerated the docs/MCP.md tool table after the first check failed. The ssh-site and unread-tables tests were already red on main at c48dffd, and the "real child forge" boot test timed out at about 36 s. Those two tests and that boot test were not run here and are untouched by this pull.
Try it. In an app's Settings → App, add "env": ["X_TOKEN"] to forge.json, set X_TOKEN at the forge, and use Grant. In the terminal, press : and pick Secrets this app uses.
🤖 Generated with Claude Code
I merged main into the branch and pushed. The type check and gate now pass, and the nine test files I ran around the change pass (52 tests). I didn't run the full suite.
The only conflict was the import block in src/web/pages/labels.ts. This pull moved adminForm to src/web/admin_form.ts, and main added a bind.ts import on the same lines. I kept both: adminForm from the new module, settingsPageResponse and timedAdminRepo from settings.ts, and main's BIND and countAttr import.
The gate flagged a comment in src/actions/app_env_words.ts that ran 102 columns, over its 100-column limit. My first rewrap was still too long, so the first push, 539ca804b, still had that gate error. I shortened it to 94 columns and pushed again as c22431f60.
I couldn't reproduce any of the failures that count against this pull, so I merged current main into the branch and pushed it. I haven't seen a green check on the pushed head yet.
- Reds that are also red on
main: the check log saysretry-after,first-ask-new-appandunread-tablesfail identically onmain. I re-ran the first two here after the merge and they still fail.retry-afterflags the 503 insrc/http/api/user.ts.first-ask-new-appexpects a/#crew-key-notelink and gets/feed#crew-key-note. I left both alone and did not re-rununread-tables. - The one the check blamed on this pull,
tui-system: it passes here, before and after the merge. - The rest: the code-browser tests in
test/web-code.test.ts, the shift test intest/render-subtitles-shift.test.tsand the dashboard N+1 test intest/surfaces-adversarial.test.tsalso pass here. They failed only inside the full sharded run, so I'm treating them as load-related flakes. - Gate and types:
bun scripts/gate.tsandbun x tsc --noEmitare clean after the merge. I did not run the full suite, since the forge does that on push.
I changed no code. This push is only the merge of main, so the pull description is unchanged from before.
The one failure that was this pull's own, the tui-system telemetry grant test, is fixed and pushed. I haven't seen the forge's check on the new head.
The cause: the test draws the System dialog in a 50-row terminal, and the dialog doesn't scroll. When another test file has already run in the same process, the CPU history and largest-directories sections fill in and add three lines. That pushes the "telemetry:read held by" line off the bottom. I reproduced it by running test/tui-appenv.test.ts before test/tui-system.test.ts. The cause is shared state between test files, not the secrets change. I changed the test to use a 70-row terminal (test/tui-system.test.ts:316), and both files pass together now.
I ran bun x tsc --noEmit and bun scripts/gate.ts, and neither printed anything. I did not re-run the full suite.
Failures that aren't this pull's: the check log says retry-after, first-ask-new-app and boundary-tenancy-cell are red on main too, so I left them alone. The first two fail here the same way.
retry-afterflags a 503 insrc/http/api/user.ts.first-ask-new-appexpects the link/#crew-key-note. The page renders/feed#crew-key-note.
I merged main into the branch, resolved the seven conflicts and pushed. I haven't seen the forge's check on the new head.
Conflicts. main made production the default scope for app variables. This pull adds a kind, variable or secret, on top of that.
- Source files: in
src/actions/apps.ts,src/http/api/app.tsandsrc/web/pages/settings.tsI kept both sides. The kind is still passed through, and the scope falls back toDEFAULT_ENV_SCOPE.src/http/mcp/tools/app_env.tsnow saysproductionis the default and still documentskind. - Tests: the terminal flow is now name, value, scope, kind. The first choice in each picker is production and secret, so the
tui-app-envandtui-appenvtests press Enter three times after the value instead of arrowing down. The API test expectsproductionas the default scope. - One test corrected:
test/app-secrets.test.tsconverted a secret to a variable at scopeall. Because the default is nowproduction, that wrote a second row instead of converting the first. It now converts atproduction.
Checks. bun scripts/gate.ts and bun x tsc --noEmit print nothing. About 214 tests across the env, secrets, snapshot, cloud and terminal files pass. The one failure is retry-after, which is already red on main: it flags the 503 in src/http/api/user.ts, not code from this pull. I did not run the full suite, because the forge runs it on push. scripts/fast.ts named 2532 files, so I picked the covering files myself.
Approved. I could not diff against 89a812cac2ea (the commit is not in my clone), so I judged the head from the diff in the pull.
Earlier asks, all met in that diff:
- An unchosen kind is now a sealed secret, and the name-guessing rule is gone.
- The terminal has both doors: Variables shows kind and value, and Secrets this app uses can grant and revoke for a forge admin. Both use the page error words.
- The how-to is in docs/features/app-secrets.md and README.md is untouched.
- Grants are keyed by repository. A declared name that is set higher up and one set nowhere get the same log line and issue comment, so a declaration does not reveal which names exist.
- Crew runs read only the repository scope plus granted names. The terminal goes through secretsFor with start=false.
- adminForm moved out of settings.ts. The design block is one sentence and uses existing classes.
- Test coverage for these is in the pull.
Non-blocking: there is no test that asserts the identical missing/ungranted sentence. The retry-after failure on main is not this pull.
A secret now reaches an app by name from one place, and an app's list holds two kinds: plain variables and sealed secrets. Tests on the new code pass, but several suites could not be run clean here; they are listed at the end.
What changed
app_envgainskindandvalue_plain(migration20261114070100-app-env-kind).- A variable is plain: stored and shown in the clear, and skipped by
rotate-secret-keybecause itsvalue_encis''. - A secret is sealed and write-only, as before. Every existing row defaults to secret, so nothing is exposed. - When no kind is chosen, a name ending_KEY,_TOKEN,_SECRETor holdingPASSWORDis a secret and anything else is a variable. - Setting a name again replaces it, kind included, so that is how a row is converted. - The web form, the API (kindin the PUT body) and MCPapp_envall takekind.-
forge.jsonacceptsenv: ["SIMPLEFIN_TOKEN"], validated inparseManifest. - A forge or org secret reaches an app only after a forge admin grants that name to that app. Grants live in the newsecret_granttable (migration20261114070000-secret-grants). - An app's own value is nearer than a granted one.AppsRuntime.secretsForadds the granted names, and the app terminal gets them too. - The early return for a guest's unapproved preview is unchanged, so it receives nothing of either kind./:owner/:name/settings/app/secrets/grantand/revoke). The Environment list now shows the kind, shows variable values, and has a kind select.- Setting a forge or org secret again restarts every app it is granted to. - Granting or revoking also restarts the app. - The
secret_startledger records which value each environment last started with. - A secret's row on the Secrets page lists the apps that use it and whether each started with the current value. - Every read is still asecret.readevent namingapp:<id>:<env>and the name, never the value.process.env.docs/APPS.mdhas a new "One place for a secret" section and an updated Environment section.docs/design/areas/secrets.mdhas a short note.Tests
test/app-secrets.test.tsandtest/app-secrets-web.test.tscover each "done when" item except the guest-preview one, which has no test of its own (see below).mcp-app-envused a plain name and expected write-only, andsnapshot-drillusedSECRET_WORDand needs a real sealed row. Both now name the secret kind explicitly.app_envrows (value_enc <> ''), because a variable has nothing to unseal.tscandscripts/gate.tsare clean. No existing test run throughfiles=$(bun scripts/fast.ts)came back red because of this change.Not done, or not clean
guest-pull-envtest covers the same early return insecretsForbefore either kind is read.secretrows (backups, cloud and similar forge-internal code) are untouched, so the repo settings card is still there. App-level secrets live inapp_env.-
rotate-secret-keyfails becauserepo_upstream.credential_encis missing fromSEALED_COLUMNS. -bring.test.tsandsettings-routes.test.tsfail. -new-app-timingtimes out in this sandbox. -design-lintflagssrc/web/studio/cards.ts.To try it: in an app's Settings → App, set
FOO_TOKENandGREETINGand see secret and variable. PutSIMPLEFIN_TOKENunder Settings → Secrets, list it inforge.jsonenv, then press Grant on the app's settings page. Set it again under Secrets and the row lists the app.Closes #12180