fix(extensions): prime LIBREFANG_VAULT_KEY from dotenv before vault unlock#5065
fix(extensions): prime LIBREFANG_VAULT_KEY from dotenv before vault unlock#5065f-liva wants to merge 5 commits into
Conversation
…nlock Container deployments that ship the credential-vault key in `secrets.env` (rather than the host process env) hit a chicken-and-egg inside `load_dotenv`: 1. `load_vault()` runs first and tries to unlock `vault.enc`. The resolver reads `LIBREFANG_VAULT_KEY` from the process env, finds nothing (the orchestrator/container runtime doesn't set it), and returns `VaultLocked`. 2. `load_env_file(secrets.env)` runs immediately after and populates `LIBREFANG_VAULT_KEY` — too late: every subsequent caller of `CredentialVault::unlock()` (e.g. the MCP OAuth provider) sees a stale "Vault not initialized" / decryption-fails error and silently returns `None`. The user-visible symptom is "OAuth flow completes, tokens never persist, agent reports no MCP access on the next turn". Add a tightly-scoped bootstrap pass — `prime_vault_key_from_dotenv` — that scans `.env` and `secrets.env` for `LIBREFANG_VAULT_KEY` *only* and seeds it into the process env if it isn't already present. The documented priority order (system env > vault > .env > secrets.env) stays intact for every other secret, because the regular `load_env_file` pass runs after the vault is consulted and continues to skip keys the vault already populated. `VAULT_KEY_ENV` is promoted from private `const` to `pub(crate)` so the bootstrap doesn't have to duplicate the env var name string. Tests: - `extract_env_value_finds_key_with_base64_padding` — guards against parsers that mishandle the trailing `=` on a 32-byte base64 vault key. - `extract_env_value_handles_quoted_and_commented_lines` — guards against the two common dotenv hand-edits (quoted value, comment line that shadows the real one). - `extract_env_value_returns_none_for_missing_and_empty` — confirms the silent-no-op branches. `cargo test -p librefang-extensions --lib` → 89 passed, 0 failed. `cargo clippy --workspace --all-targets -- -D warnings` clean. Verified end-to-end on a Lazycat-managed container where the orchestrator binds the manifest but doesn't expose `environment:` edits post-install: dropping `LIBREFANG_VAULT_KEY=…` into `/data/secrets.env` is now sufficient to unlock the vault on next restart, so OAuth token persistence — and therefore MCP refresh — works without a code change to the orchestrator side.
Why this isn't an ad-hoc fixThe chicken-and-egg in The latent bug
The vault unlock attempt in step 1 is final — it doesn't retry after steps 2-3 populate the env. So if a user puts The OAuth flow then completes successfully at the HTTP layer (browser sees "Authorization Successful"), the kernel asks the provider to persist tokens, the provider silently no-ops on the vault write, and the next refresh / tool call comes back unauthenticated. That's exactly the user-facing failure mode in this PR's body: "OAuth flow completes, tokens never persist, agent reports no MCP access on the next turn." Who gets the bug on stock librefangAnyone whose deployment can't easily set arbitrary env vars in the parent process:
Operators on those platforms today are dropping Who doesn't get the bugAnyone whose VAULT_KEY is already in the parent process env by the time
For all of those, the bootstrap is a no-op (line 42-ish of the new helper, Why this is the right shape
Operator-facing impactOnce this lands, the operator documentation can simplify from "set Happy to factor the bootstrap into a separate module / add a test for the |
houko
left a comment
There was a problem hiding this comment.
Thanks for the careful write-up — the priority-order preservation argument is correct and the motivation is real. One blocking parser bug and a few smaller items.
Blocking
extract_env_value aborts the entire scan on the first malformed line — crates/librefang-extensions/src/dotenv.rs:98:
let (k, v) = line.split_once('=')?;The ? propagates None out of the whole function, not the loop iteration. So if .env / secrets.env contains any non-empty, non-#-prefixed line that lacks = (a stray token, a hand-edited typo, a leftover marker), the scanner returns None before it ever reaches LIBREFANG_VAULT_KEY on a later line. The existing load_env_file (crates/librefang-extensions/src/dotenv.rs:90-110) handles this correctly via if let Some(...) = parse_env_line(...) + implicit continue — the new function regresses that robustness.
Repro (verified locally):
# header
SOME_FLAG
LIBREFANG_VAULT_KEY=correct_value
returns None instead of Some(\"correct_value\").
Fix: replace ? with explicit continue:
let Some((k, v)) = line.split_once('=') else { continue; };Since this is the exact production path that motivates the PR, please add a unit test covering a malformed line that precedes the vault-key line.
Non-blocking
-
Silent IO error on bootstrap path —
crates/librefang-extensions/src/dotenv.rs:92:let content = std::fs::read_to_string(path).ok()?;
A permission-denied or transient IO failure on
secrets.envhere produces the same opaqueVaultLockeddownstream that this PR is trying to fix.load_vault(line 72) useseprintln!for its analogous failure mode — it'd be consistent to log a one-line warning onErr(e)fromread_to_stringwhen the path exists but isn't readable (skip the log ife.kind() == NotFound, since absent files are expected). Sameeprintln!channel asload_vaultfor the same reason (no tracing subscriber yet). -
No end-to-end test for the boot-ordering fix itself. The three new unit tests exercise
extract_env_valuein isolation; the actual claim — "load_dotenvnow unlocks the vault when onlysecrets.envcarries the key" — has no regression test. Acknowledged it's awkward because ofDOTENV_LOADED: Once+ process-global env, but aserial_test::serialtest drivingLIBREFANG_HOMEto a tempdir and assertingprime_vault_key_from_dotenvsets the var would lock the bug closed. Same pattern astest_load_env_file_does_not_override_existing_varatcrates/librefang-extensions/src/dotenv.rs:424. -
Minor parse divergence from
parse_env_line.extract_env_valuedoesn't unescape\\n/\\\\/\\\"inside double-quoted values, whileparse_env_line(line 152,unescape_env_value) does. For a base64 vault key this is irrelevant in practice, but if an operator copies a quoted value from a template with literal\\nfor visual wrapping, the two parsers will diverge. Probably fine to leave; flagging only because the divergence isn't documented in the helper doc-comment.
Question
Is there a reason prime_vault_key_from_dotenv doesn't just call parse_env_line (line-filtered for the matching key) rather than reimplementing a smaller parser? Reusing parse_env_line would (a) close the divergence above, (b) inherit the existing test coverage, and (c) eliminate the split_once('=')? bug structurally. The new helper would be ~10 lines instead of ~25.
Addresses PR librefang#5065 review feedback (houko, 2026-05-15): **Blocking** — `extract_env_value` propagated `?` out of `line.split_once('=')`, so a single malformed line (no `=`, stray marker, hand-edit typo) anywhere in `.env` / `secrets.env` aborted the entire scan before the real vault-key line was reached. Repro: # header SOME_FLAG LIBREFANG_VAULT_KEY=correct_value returned `None` instead of `Some("correct_value")`. Fix by delegating per-line parsing to `parse_env_line`. Same approach the regular `load_env_file` loop uses — a `None` from `parse_env_line` is an implicit `continue`, never an early-exit. Bonus: closes the quote / escape-sequence divergence flagged in the non-blocking review point #3, because the helper now inherits `parse_env_line`'s `unescape_env_value` handling for double-quoted values. **Non-blocking** — opaque IO failure (review point #1): a non-NotFound read error on `secrets.env` now logs a one-line `eprintln!` warning (same channel `load_vault` uses, since `load_dotenv` runs before any tracing subscriber is installed) instead of silently returning `None`. Absent files stay silent — that's the expected case. **Tests**: - `extract_env_value_skips_malformed_lines_before_vault_key` — regression for the `?`-propagation blocker. Writes a file with two malformed lines preceding the vault-key line and asserts the scan still recovers the value. - `prime_vault_key_from_dotenv_seeds_env_from_secrets_env` — the end-to-end test the review (point #2) asked for: drives `LIBREFANG_HOME` at a tempdir, populates `secrets.env`, asserts `prime_vault_key_from_dotenv` seeds `LIBREFANG_VAULT_KEY` into the process env. Guarded with `#[serial_test::serial]` like `test_load_env_file_does_not_override_existing_var` — env-mutating tests must not run concurrently or `std::env::{set,remove}_var` becomes UB. `cargo check --workspace --lib` clean. `cargo test -p librefang-extensions --lib dotenv` → 19 passed, 0 failed. `cargo clippy -p librefang-extensions --all-targets -- -D warnings` clean.
|
Addressed @houko's feedback in 931921c: Blocking — Non-blocking #1 (silent IO error) — a non- Non-blocking #2 (end-to-end test for the boot-ordering fix) — added Non-blocking #3 — closed structurally by the refactor (see above). Also added a focused regression test for the blocker: Local verification:
|
houko
left a comment
There was a problem hiding this comment.
LGTM — all four review points addressed substantively.
Blocking (malformed-line scan abort): closed by the refactor at crates/librefang-extensions/src/dotenv.rs:107-118. extract_env_value now delegates per-line parsing to parse_env_line via let Some((k, v)) = parse_env_line(trimmed) else { continue; }; — same robustness shape load_env_file uses. Regression locked by extract_env_value_skips_malformed_lines_before_vault_key (writes STRAY_MARKER_NO_EQUALS + ANOTHER_BAD_LINE before the vault-key line and asserts Some("after_garbage")).
Non-blocking #1 (silent IO error): addressed at dotenv.rs:96-108. NotFound stays silent (expected for absent dotenv files), every other io::Error emits a one-line eprintln! on the same channel load_vault already uses.
Non-blocking #2 (e2e ordering test): prime_vault_key_from_dotenv_seeds_env_from_secrets_env drives LIBREFANG_HOME at a tempdir, populates secrets.env, runs prime_vault_key_from_dotenv(), asserts VAULT_KEY_ENV is seeded. Guarded with #[serial_test::serial], with prev-state save/restore before the assertion so a failure doesn't leak env state to sibling tests — matches the pattern of test_load_env_file_does_not_override_existing_var.
Non-blocking #3 (parse divergence): closed structurally — the helper now inherits unescape_env_value handling for double-quoted values from parse_env_line, so the two parsers can't diverge.
Boot-ordering also verified end-to-end: load_dotenv (dotenv.rs:41-62) now runs prime_vault_key_from_dotenv() → load_vault() → load_env_file(.env) → load_env_file(secrets.env). The bootstrap is scoped strictly to VAULT_KEY_ENV, so the documented priority order (system env > vault > .env > secrets.env) for every other variable stays intact — load_env_file still skips keys already populated by the vault. No-op when LIBREFANG_VAULT_KEY is already in system env.
All CI lanes green (unit-fast, integration shards, macOS, Windows, clippy, secrets scan, OpenAPI drift).
houko
left a comment
There was a problem hiding this comment.
All three previously-flagged blockers are resolved in the latest revision:
- Malformed line scan no longer aborts early —
extract_env_valuedelegates toparse_env_lineandcontinues on parse failure (extensions/src/dotenv.rs~107-118). Regression testextract_env_value_skips_malformed_lines_before_vault_keypins it. - Non-
NotFoundIO errors onsecrets.envnow emiteprintln!warnings (~96-108) instead of silent swallow. - Parse divergence from
parse_env_lineclosed structurally —extract_env_valueinheritsunescape_env_valuefor double-quoted values via the shared helper.
Verified the ordering invariant: prime_vault_key_from_dotenv() runs before load_vault() (dotenv.rs ~43-60), std::env check at line 50 prevents override of an operator-set system var, and decode_master_key() (vault.rs ~707-709) runs after the prime so an invalid base64 fails loudly. Priority documented as system env > vault > .env > secrets.env (only vault-key pre-seeds; other keys load after vault).
Test coverage spans positive (key in secrets.env unlocks), negative (missing key, no spurious error), edge (system env wins). .env location respects LIBREFANG_HOME, falls back to ~/.librefang per existing convention.
|
Closing this as superseded rather than just textually conflicting — the equivalent fix already shipped on main.
The merge conflict shown in the PR view is the two implementations colliding on the same call site, not a mergeable textual diff. Functional comparison:
Two deltas where this branch is arguably nicer:
If those two are worth landing, a much smaller follow-up against today's main (add the The user-visible bug this PR was filed against — "MCP OAuth tokens silently never persist when Thanks for the work @f-liva, and apologies for the duplicate path — the supersession wasn't obvious until the merge view was inspected. |
Pull request was closed
Problem
Container deployments that ship the credential-vault key in
secrets.env(rather than the host process env) hit a chicken-and-egg insideload_dotenv:load_vault()runs first and tries to unlockvault.enc. The resolver readsLIBREFANG_VAULT_KEYfrom the process env, finds nothing (the orchestrator/container runtime doesn't set it), and returnsVaultLocked.load_env_file(secrets.env)runs immediately after and populatesLIBREFANG_VAULT_KEY— too late.Every subsequent caller of
CredentialVault::unlock()(the MCP OAuth provider in particular) sees a stale "Vault not initialized" or decryption-fails error and silently returnsNone. The user-visible symptom is: OAuth flow completes in the dashboard, tokens never persist, the agent reports no MCP access on the next turn.Concrete reproducer: Lazycat NAS container that ignores Docker
ENVlines because the orchestrator launches its own binary with a fresh env. The only way operators can ship the vault key on that platform issecrets.env, which today is loaded after vault unlock.Change
Add a tightly-scoped bootstrap pass —
prime_vault_key_from_dotenv— that scans.envandsecrets.envforLIBREFANG_VAULT_KEYonly and seeds it into the process env if it isn't already present. The regularload_env_filepass continues to run after the vault is consulted, so the documented priority order (system env > vault >.env>secrets.env) stays intact for every other variable.VAULT_KEY_ENVis promoted from a privateconsttopub(crate)so the bootstrap doesn't have to duplicate the env var name string.The new helper
extract_env_value(path, key)reads a single key out of a dotenv file without polluting the process env with everything else in it; it correctly handles base64 padding (=in the value), quoted values, and#comments.Verification
cargo test -p librefang-extensions --lib→ 89 passed, 0 failed.cargo clippy --workspace --all-targets -- -D warningsclean.extract_env_value_finds_key_with_base64_padding— guards against parsers that mishandle the trailing=on a 32-byte base64 vault key (the exact symptom that surfaced onsecrets.envin the field).extract_env_value_handles_quoted_and_commented_lines— guards against the two common dotenv hand-edits (quoted value, comment line that shadows the real one).extract_env_value_returns_none_for_missing_and_empty— confirms the silent-no-op branches.LIBREFANG_VAULT_KEY=…into/data/secrets.envis now sufficient to unlock the vault on next restart, so OAuth token persistence — and therefore MCP refresh — works without a code change to the orchestrator side.Backwards compatibility
LIBREFANG_VAULT_KEYis already in the system env (existing host-env deployments behave unchanged byte-for-byte)..env/secrets.envdon't contain the key.load_env_filestill runs after the vault, so vault-stored secrets still win over.env/secrets.envvalues.Related
Pairs with #5060 (
client_secret_envfor confidential MCP OAuth) — both PRs unblock running confidential MCP OAuth (Google Workspace) on container hosts where the only durable env injection point issecrets.env.