server-secrets-strategy.md described only two credential mechanisms — bootstrap
ServerSecrets and vault-only optional integrations — and stated its most
restrictive rule in terms of "server runtime", which is ambiguous now that every
run is a server process plus a worker. It omitted the third mechanism actually
used by operator-configured integrations: settings-declared credentials in
InterpString fields, resolved at consumption time from {{ env.NAME }} or
{{ secrets.NAME }}, as LLM provider extra_headers already does.
Add a "Which process resolves what" table keyed on resolving process and timing,
a "Settings-declared credentials" section with the extra_headers precedent, and a
mechanism table at the head of "Adding A New Server Secret". Replace "server
runtime" with per-process statements, and describe where CredentialResolver's
process-env fallback is actually live.
Also correct six docs that told operators to export provider keys for "standalone
local runs". There is no CLI-local run execution: runs always execute in a worker
whose environment is cleared and repopulated from WORKER_ENV_ALLOWLIST, which
excludes provider API keys. Those instructions could not have worked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9.7 KiB
Server Secrets Strategy
This document defines how Fabro handles server-level secrets.
Fabro always runs as a server process plus one worker process per run. There is no CLI-local run execution, so the operative question for any credential is which process holds the value, and when does it resolve — see Which process resolves what.
Core Rules
ServerSecretsis the canonical reader for bootstrap server secrets only.- It reads bootstrap secrets from
process envand<storage>/server.env. - Resolution is snapshot-based: env and file are read once at construction, then treated as immutable for the life of the process.
process envwins overserver.envon conflicts.- Optional integration secrets are vault-only in the server process. Do not add optional server integrations to
ServerSecrets, and do not add bespoke env fallback paths to it. - Not every credential is a
ServerSecretsor vault lookup. A third mechanism exists: settings-declared credentials inInterpStringfields, resolved at consumption time from{{ env.NAME }}or{{ secrets.NAME }}. See Settings-declared credentials. fabro server startnever generates secrets. Missing required secrets are a startup error.std::env::set_varandstd::env::remove_varare banned workspace-wide. Tests are not exempt. Enforced by clippy viadisallowed_methodsinclippy.toml; intentional exceptions must be annotated with a scoped#[expect(clippy::disallowed_methods, reason = "...")]at the call site.
Bootstrap Server Secrets
These values may be read via state.server_secret(...) because the server can need them before optional integrations are available:
| Secret | Used by |
|---|---|
SESSION_SECRET |
Cookie encryption and JWT signing derivation |
FABRO_DEV_TOKEN |
Dev-token user auth when server.auth.methods includes dev-token |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN |
Static S3 object-store credentials for server storage builders |
These optional integration secrets are not server bootstrap secrets. They are provisioned into the vault:
- LLM provider API keys and OAuth credential records
GITHUB_TOKENGITHUB_APP_PRIVATE_KEYGITHUB_APP_CLIENT_SECRETGITHUB_APP_WEBHOOK_SECRETFABRO_SLACK_APP_TOKENFABRO_SLACK_BOT_TOKENDAYTONA_API_KEYBRAVE_SEARCH_API_KEY
FABRO_JWT_PRIVATE_KEY and FABRO_JWT_PUBLIC_KEY are removed. SESSION_SECRET is the single auth root.
Provisioning into the vault is not the same as the resolver being vault-only. CredentialResolver
owns a documented process-env fallback that runs after the vault lookup
(lib/foundation/fabro-auth/src/resolve.rs:198-204), and CredentialRef::Env(name) is a
first-class credential source (resolve.rs:350). Which paths that fallback is live on is a
per-process question:
- Server process — inert.
lib/apps/fabro-server/src/server.rs:2453buildsSqlVaultCredentialSource::vault_only(...), so the env lookup always returnsNone. - Worker process — wired but effectively inert for provider keys.
build_llm_source(lib/components/fabro-workflow/src/pipeline/initialize.rs:275) builds the run's credential source withVaultCredentialSource::new, which carries the process-env fallback; the provider-listing path does the same viawith_env_lookup(process_env_var)(operations/start.rs:529). But the worker's env was cleared and repopulated fromWORKER_ENV_ALLOWLIST(lib/apps/fabro-server/src/spawn_env.rs:6), which does not include provider API keys. Exporting a provider key in the server's shell therefore has no effect on runs. fabro execand directfabro-llmSDK usage — live. These have no vault and read process env deliberately.
Which process resolves what
Both the server and per-run workers always exist. Name the resolving process and the timing rather than saying "server runtime", which is ambiguous.
| Value | Resolved by | When |
|---|---|---|
| Bootstrap server secret | Server process, via ServerSecrets |
Once at construction, then immutable |
| Optional integration secret | Server process or worker, via the vault | At use |
{{ vars.NAME }} |
Server process | When the run is created, from that run's variable snapshot |
{{ env.NAME }} |
The process that owns the value (usually the worker) | At consumption time |
{{ secrets.NAME }} |
The process that owns the value, against the server vault | At consumption time |
docs/public/agents/mcp.mdx documents the same split for MCP server configuration and is a good
worked example of the shape.
Settings-declared credentials
Some credentials are declared in settings rather than looked up by name. Those fields are
InterpString (lib/foundation/fabro-types/src/settings/interp.rs), which supports narrow
{{ namespace.NAME }} tokens with no template logic. Three namespaces resolve: env (process
environment, consumption time), secrets (vault, consumption time), and vars (non-sensitive run
variables, substituted early at run creation). A token whose namespace is unavailable in the
resolution context fails loudly.
The reference implementation is LLM provider extra_headers, resolved against env plus vault at
lib/foundation/fabro-auth/src/resolve.rs:376-378:
[llm.providers.example.extra_headers]
authorization = "Bearer {{ secrets.EXAMPLE_TOKEN }}"
x-tenant = "{{ env.EXAMPLE_TENANT }}"
Use this mechanism when the credential belongs to an operator-configured integration declared in
settings.toml, rather than being a fixed secret name the code looks up. It is not a ServerSecrets
field and is not covered by the bootstrap rules above.
When such a value is passed to a subprocess, treat it like any other authority-bearing value: see Subprocess Boundaries.
Startup
- Foreground and daemon startup use the same validation path.
- Required-at-startup secrets are:
SESSION_SECRETFABRO_DEV_TOKENwhen dev-token auth is enabledGITHUB_APP_CLIENT_SECRETfrom the vault when GitHub auth is enabled
- Requiredness is independent from source. GitHub auth can require a vault secret at startup even though it is not a bootstrap
ServerSecretsvalue. - Other optional integration secrets remain lazy/feature-specific rather than universal boot blockers.
Provisioning
Bootstrap secrets come from one of two sources:
- Platform env for 12-factor deployments
server.envwritten by install flows
Optional integration secrets are provisioned into the vault, usually with fabro secret set or fabro install.
There is no startup-time secret generation. A temporary startup migration moves recognized legacy optional secrets from process env or server.env into the vault, removes matching server.env entries after writing a backup, and logs conflicts by key name only. Runtime lookup remains vault-only after that migration step. See migrations-strategy.md for the migration pattern.
Subprocess Boundaries
- Worker and render-graph subprocesses start from
env_clear()and re-add only explicit allowlisted variables. - Authority-bearing values are re-injected intentionally. For worker subprocesses this is
FABRO_WORKER_TOKEN, plus any explicitly required internal value such as a vault-derivedGITHUB_APP_PRIVATE_KEY; it is not user auth state such asFABRO_DEV_TOKENorauth.json. - The worker reads
FABRO_WORKER_TOKENfrom its env at startup (inmain()before Tokio initializes) and immediately callsstd::env::remove_varto scrub it. The token then flows through function arguments torunner::execute. Every descendant process (hooks, sandbox commands, MCP stdio, etc.) therefore inherits a worker env that no longer contains the bearer, so an unscrubbed spawn site cannot leak it. - The daemon child inherits the parent env unchanged except for output-format hygiene (
FABRO_JSONremoval).
Tests
- In-process tests must inject bootstrap server secrets with construction-time stubs (
EnvSource,StubEnv) or by writingserver.env. - In-process tests for optional integrations must write the vault and must not rely on process env or
server.env. - Subprocess tests must set child env with
Command::env. - Tests must not mutate the process-wide environment.
Rotation
- Secret rotation requires restart.
- Live rotation is intentionally unsupported.
Adding A New Server Secret
First pick the mechanism. These are the only three:
| Kind | Provisioned via | Read via |
|---|---|---|
| Bootstrap server secret | Platform env or install-written server.env |
state.server_secret(...) |
| Optional integration secret | Vault (fabro secret set, fabro install) |
state.vault_secret(...) |
| Settings-declared credential | {{ secrets.* }} or {{ env.* }} in an InterpString settings field |
Resolved at consumption time by the owning process |
Then:
- For the first two kinds, classify it in
fabro-staticasBootstraporOptionalVault. Settings-declared credentials are not classified there — they have no fixed secret name. - For bootstrap secrets, provision through platform env or install-written
server.env, then read throughstate.server_secret(...). - For optional integration secrets, provision through the vault and read through
state.vault_secret(...). - For settings-declared credentials, follow Settings-declared credentials and model the field on provider
extra_headers. - Decide explicitly whether startup should fail when it is absent.
- If a worker or render subprocess needs it, re-inject it explicitly rather than broadening inheritance casually. If the injected value is a credential, scrub it at worker startup the way
FABRO_WORKER_TOKENis scrubbed, so descendants do not inherit it.