10 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{{ 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_KEYVENICE_API_KEY
FABRO_JWT_PRIVATE_KEY and FABRO_JWT_PUBLIC_KEY are removed. SESSION_SECRET is the single auth root.
For conventional provider API-key names, VaultCredentialSource::new and
SqlVaultCredentialSource::new check the process environment before the vault. Their
vault_only constructors disable that lookup. OAuth records and interpolated secret headers
resolve through the vault separately. The owning process chooses the source:
- Server process —
lib/apps/fabro-server/src/server.rsconstructsSqlVaultCredentialSource::vault_only(...). Model calls, provider listing and gateway completions therefore do not use process-environment provider API keys. - Worker process —
runtime_specinlib/apps/fabro-cli/src/commands/run/petri_worker.rsconstructsVaultCredentialSource::new. The worker environment is cleared and repopulated fromWORKER_ENV_ALLOWLISTinlib/apps/fabro-server/src/spawn_env.rs, which excludes conventional provider API keys, including Bedrock bearer tokens. Exporting them in the server's shell has no effect on runs. - Direct
fabro exec(no server target) —CommandContext::llm_sourceinlib/apps/fabro-cli/src/command_context.rsopens the local SQL secret store and constructsSqlVaultCredentialSource::new. Conventional API keys use environment-first lookup, then that local store. fabro exec --server ...—FABRO_SERVERalso selects this mode. The CLI sends completions through the gateway; the target server resolves credentials using its vault-only source.- Direct SDK usage — the caller selects a
CredentialProvider. For example,VaultCredentialSource::environment_only()explicitly reads process-environment keys.
Bedrock SigV4 is a separate mechanism: the AWS default credential chain's identity inputs are deliberately allowlisted into workers so sessions can refresh. This does not enable environment lookup for Bedrock bearer tokens. Providers with no authentication need no key.
Installation can accept an explicit stdin or named-environment credential input and persist
it in the vault; this is provisioning, not a runtime fallback. Endpoint catalog overrides such
as OPENAI_BASE_URL are also independent of credential lookup. The public contract lives in
docs/public/core-concepts/models.mdx under Credential sources.
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 |
{{ 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. Two namespaces resolve: secrets (vault,
consumption time) and vars (non-sensitive run variables, substituted early at run creation).
{{ env.NAME }} tokens still parse but never resolve; they fail loudly with a migration message. A
token whose namespace is unavailable in the resolution context also fails loudly.
The reference implementation is LLM provider default_headers, whose {{ secrets.* }} values are
resolved against the vault in lib/foundation/fabro-auth/src/vault_source.rs
(interpolated_headers) and re-sent as credential headers:
[llm.providers.example.default_headers]
authorization = "Bearer {{ secrets.EXAMPLE_TOKEN }}"
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 or import of optional integration secrets from process env or server.env. The compatibility migrations for those sources and pre-token/OAuth vault entries have been removed. The separate one-time import of current-format secrets.json entries into SQLite remains supported.
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.* }} 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.