From 1280cc68ef71a67a741258b80b72a32e8b51fd25 Mon Sep 17 00:00:00 2001 From: Edwin Date: Sun, 2 Aug 2026 09:50:33 -0700 Subject: [PATCH] Let config.toml carry the daemon's environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A key exported after the daemon started never reaches it: the daemon's environment is fixed at launch, and `construct daemon restart` re-execs the running image, carrying that same environment across. So setting DEEPSEEK_API_KEY made smith work in a fresh shell while the router's pickers stayed empty, with nothing to explain the gap — and no number of restarts fixed it. config.toml doesn't have that problem; it is re-read on every start. Add `[daemon.env]`, a table layered underneath the real environment: it fills gaps, never overrides, so a machine that exports its keys behaves exactly as before and an operator can still override a declared value for one run. The layer is consulted wherever the daemon resolves a credential — built-in route targets, profiles that name a variable or fall back to a provider's defaults, /configure, and doctor (which loads the same config with no daemon running, so it keeps reporting what the daemon would see) — and is applied as the base environment of every process the daemon spawns, so a declared credential reaches a harness exactly as an exported one would. That floor goes on at spawn rather than in the session's env map: that map is persisted as start params, and baking credentials into per-session state would both leak them to disk and freeze them against a rotation in config. Path overrides and the daemon's own CONSTRUCT_* knobs stay environment-only — they are read while locating the config file, before there is a table. --- crates/daemon/src/adapter.rs | 7 + crates/daemon/src/availability.rs | 41 +++- crates/daemon/src/config.rs | 141 ++++++++++++-- crates/daemon/src/daemon_env.rs | 185 +++++++++++++++++++ crates/daemon/src/doctor.rs | 7 + crates/daemon/src/lib.rs | 6 + crates/daemon/src/session.rs | 5 + crates/daemon/src/session/lifecycle.rs | 7 + specs/0180-daemon-environment-from-config.md | 94 ++++++++++ 9 files changed, 475 insertions(+), 18 deletions(-) create mode 100644 crates/daemon/src/daemon_env.rs create mode 100644 specs/0180-daemon-environment-from-config.md diff --git a/crates/daemon/src/adapter.rs b/crates/daemon/src/adapter.rs index 73fca5fc..a4e980c7 100644 --- a/crates/daemon/src/adapter.rs +++ b/crates/daemon/src/adapter.rs @@ -267,6 +267,13 @@ impl Adapter { let mut cmd = Command::new(&binary); cmd.args(&args); + // `[daemon.env]` is the floor under every adapter (spec 0180): a + // credential declared in config.toml must reach the harness exactly + // as an exported one would. Applied here rather than in the + // session's env map so it is never persisted into start params — + // the value is re-read from config on each spawn, and rotating it + // takes effect on the next one. + cmd.envs(crate::daemon_env::child_env_base()); for (k, v) in env { cmd.env(k, v); } diff --git a/crates/daemon/src/availability.rs b/crates/daemon/src/availability.rs index feb367d6..8ac89f39 100644 --- a/crates/daemon/src/availability.rs +++ b/crates/daemon/src/availability.rs @@ -96,10 +96,12 @@ pub fn probe_generic_adapter( } } +/// Credential presence as the daemon resolves it: the real environment, +/// then `[daemon.env]` from config.toml (spec 0180). A key declared in +/// config must read as present here, or `/configure` and `doctor` would +/// report "not set" for a provider the router is happily routing to. fn env_present(name: &str) -> bool { - std::env::var(name) - .map(|v| !v.trim().is_empty()) - .unwrap_or(false) + crate::daemon_env::present(name) } /// Probe real availability for one configured harness (spec 0068). The @@ -802,4 +804,37 @@ mod tests { ); assert!(auto.detail.contains("subscriptions and Ollama")); } + + /// A credential declared in `[daemon.env]` must read as present here + /// (spec 0180). `/configure` and `doctor` both render these entries, so + /// reporting "not set" for a key the router is already using would send + /// the user looking for a problem that isn't there. + #[tokio::test] + async fn a_key_declared_in_daemon_env_reads_as_available() { + let _lock = crate::router::oauth::test_env_guard(); + let saved = std::env::var("DEEPSEEK_API_KEY").ok(); + std::env::remove_var("DEEPSEEK_API_KEY"); + crate::daemon_env::install([("DEEPSEEK_API_KEY", "sk-from-config")]); + + let cache = std::sync::Mutex::new(AvailabilityCache::default()); + let methods = smith_auth_methods(&cache).await; + + crate::daemon_env::install(Vec::<(String, String)>::new()); + if let Some(v) = saved { + std::env::set_var("DEEPSEEK_API_KEY", v); + } + + // Asserted on the DeepSeek entry alone, not on overall smith + // availability: the developer's own exported keys would satisfy the + // latter no matter what this test installed. + let deepseek = methods + .iter() + .find(|m| m.id == "deepseek_api_key") + .expect("deepseek entry present"); + assert!( + deepseek.available, + "a key from [daemon.env] must count as set: {}", + deepseek.detail + ); + } } diff --git a/crates/daemon/src/config.rs b/crates/daemon/src/config.rs index 948daa6b..e2d00124 100644 --- a/crates/daemon/src/config.rs +++ b/crates/daemon/src/config.rs @@ -283,11 +283,43 @@ enabled = true # api_key_env = "GROK_API_KEY" # or XAI_API_KEY # model = "grok-4.3" +# ── Daemon environment ──────────────────────────────────────────────────────── +# +# `[daemon.env]` is layered UNDERNEATH the daemon's real environment: each pair +# applies only where that variable is not already set to a non-empty value, so +# whatever you export for a given run always wins. +# +# It covers the credentials the daemon itself resolves — built-in route targets +# (e.g. DeepSeek), `[smith.models.*]` profiles that name no key, and what +# /configure and `construct doctor` report — and is passed down as the base +# environment of every process the daemon spawns: session adapters, title +# generation, suggestions. +# +# Why this exists: the daemon's environment is fixed when it launches, and +# `construct daemon restart` re-execs the running image, carrying that same +# environment across. A key exported after the daemon started stays invisible +# until the process is fully stopped and re-spawned from a shell that has it. +# config.toml is re-read on every start, so a key declared here takes effect on +# a plain restart. +# +# Values here are stored in plaintext — `chmod 600 config.toml`, or keep the +# credential in the environment and leave this table out. +# +# [daemon.env] +# DEEPSEEK_API_KEY = "sk-..." +# META_API_KEY = "..." + # ── Environment variable reference ──────────────────────────────────────────── # -# These env vars are read by the daemon or adapters at runtime. They are NOT -# part of config.toml — set them in the shell that launches the daemon (or in -# a systemd unit / launchd plist / wrapper script). +# These env vars are read by the daemon or adapters at runtime. Set them in the +# shell that launches the daemon (or in a systemd unit / launchd plist / wrapper +# script), or declare them in [daemon.env] above. +# +# Two groups are environment-only and ignore [daemon.env]: the path overrides +# below (read while locating config.toml, before there is a table to consult), +# and the daemon's own CONSTRUCT_* knobs (web UI port, remote listener, +# templates dir). Everything a spawned adapter reads, and every credential the +# daemon resolves, honors the table. # # Path overrides (XDG-style): # CONSTRUCT_HOME — base directory for config, state, data, and run path overrides @@ -499,6 +531,8 @@ pub struct Config { #[serde(default)] pub adapters: BTreeMap, #[serde(default)] + pub daemon: DaemonConfig, + #[serde(default)] pub defaults: Defaults, #[serde(default)] pub orchestrator: OrchestratorConfig, @@ -653,10 +687,20 @@ impl SmithConfig { } } +/// `[daemon]` — settings for the daemon process itself. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct DaemonConfig { + /// `[daemon.env]` — environment the daemon layers *under* its real one + /// (spec 0180), and passes down to every session it spawns. Lets a + /// credential be declared in config.toml, where a restart picks it up, + /// instead of exported into the shell that happens to launch the + /// daemon. A variable that is really set wins; this only fills gaps. + #[serde(default)] + pub env: BTreeMap, +} + fn env_var_present(name: &str) -> bool { - std::env::var(name) - .map(|v| !v.trim().is_empty()) - .unwrap_or(false) + crate::daemon_env::present(name) } /// One `[smith.models.]` entry (spec 0030). @@ -720,10 +764,7 @@ impl ModelProfile { .map(str::trim) .filter(|s| !s.is_empty()) { - return std::env::var(var) - .ok() - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) + return crate::daemon_env::var(var) .ok_or_else(|| format!("{var} is not set in the daemon's environment")); } if let Some(key) = self.api_key.as_deref().map(str::trim).filter(|s| !s.is_empty()) { @@ -731,11 +772,8 @@ impl ModelProfile { } let defaults = self.default_key_envs(); for var in defaults { - if let Ok(v) = std::env::var(var) { - let v = v.trim().to_string(); - if !v.is_empty() { - return Ok(v); - } + if let Some(v) = crate::daemon_env::var(var) { + return Ok(v); } } if defaults.is_empty() { @@ -1400,6 +1438,79 @@ mod tests { assert!(!profiles.contains_key(DEEPSEEK_ROUTE_NAME)); } + /// Run `f` with the real `DEEPSEEK_API_KEY` forced to `shell_value` and + /// `cfg`'s `[daemon.env]` installed as the fallback layer, clearing the + /// overlay before the env guard is released so no other test observes it. + fn with_daemon_env(cfg: &Config, shell_value: Option<&str>, f: impl FnOnce() -> T) -> T { + with_deepseek_key(shell_value, || { + crate::daemon_env::install(cfg.daemon.env.clone()); + let out = f(); + crate::daemon_env::install(Vec::<(String, String)>::new()); + out + }) + } + + /// The point of `[daemon.env]` (spec 0180): a key declared in config.toml + /// creates the built-in target on a machine that exported nothing, so a + /// plain `daemon restart` is enough to start routing to it. + #[test] + fn a_key_declared_in_daemon_env_creates_the_builtin_target() { + let toml = r#" + [daemon.env] + DEEPSEEK_API_KEY = "sk-from-config" + "#; + let cfg: Config = toml::from_str(toml).expect("parse"); + let profiles = with_daemon_env(&cfg, None, || { + let profiles = cfg.smith.route_profiles(); + let key = profiles + .get(DEEPSEEK_ROUTE_NAME) + .expect("built-in target") + .resolve_api_key(); + assert_eq!(key.as_deref(), Ok("sk-from-config")); + profiles + }); + assert!(profiles.contains_key(DEEPSEEK_ROUTE_NAME)); + } + + /// Config fills gaps, it never overrides: whatever the operator exported + /// for this run is what the endpoint is called with. + #[test] + fn an_exported_key_wins_over_daemon_env() { + let toml = r#" + [daemon.env] + DEEPSEEK_API_KEY = "sk-from-config" + "#; + let cfg: Config = toml::from_str(toml).expect("parse"); + with_daemon_env(&cfg, Some("sk-from-shell"), || { + let profiles = cfg.smith.route_profiles(); + let key = profiles + .get(DEEPSEEK_ROUTE_NAME) + .expect("built-in target") + .resolve_api_key(); + assert_eq!(key.as_deref(), Ok("sk-from-shell")); + }); + } + + /// A profile naming `api_key_env` resolves that variable through the same + /// two layers — otherwise declaring the key and the profile that reads it + /// in one file still wouldn't work. + #[test] + fn a_named_api_key_env_resolves_from_daemon_env() { + let toml = r#" + [daemon.env] + WORK_DEEPSEEK_KEY = "sk-work" + + [smith.models.work] + provider = "deepseek" + api_key_env = "WORK_DEEPSEEK_KEY" + "#; + let cfg: Config = toml::from_str(toml).expect("parse"); + with_daemon_env(&cfg, None, || { + let profile = cfg.smith.models.get("work").expect("declared profile"); + assert_eq!(profile.resolve_api_key().as_deref(), Ok("sk-work")); + }); + } + /// User config always wins: a declared `deepseek` profile is never /// overwritten by the built-in, even with the key set. #[test] diff --git a/crates/daemon/src/daemon_env.rs b/crates/daemon/src/daemon_env.rs new file mode 100644 index 00000000..0ed52cfb --- /dev/null +++ b/crates/daemon/src/daemon_env.rs @@ -0,0 +1,185 @@ +//! The environment the daemon resolves credentials from (spec 0180). +//! +//! The daemon's own process environment is fixed at launch and cannot be +//! refreshed: `/construct restart` re-`exec()`s the running image, which +//! carries the same environment across, so a key exported after the daemon +//! started stays invisible until something stops and re-spawns the process +//! from a shell that has it. That made "export the key" the only way to +//! reach an API-key surface, and a surprising one — editing config.toml is +//! picked up by a restart, exporting a variable is not. +//! +//! `[daemon.env]` closes that: a table of `KEY = "value"` pairs that the +//! daemon layers *underneath* its real environment. Reads go through this +//! module rather than `std::env::var` directly, and the same pairs are +//! merged into the base environment of every session the daemon spawns, so +//! declaring a credential in config.toml behaves like exporting it in the +//! shell that launched the daemon. +//! +//! Precedence is one-directional: a variable that is really set (to a +//! non-empty value) always wins. Config fills gaps, it does not override an +//! operator who exported something for this specific run. +//! +//! This does not cover the `CONSTRUCT_*` knobs that select paths and assets +//! — those are read while locating the config file, before there is a table +//! to consult, and must stay real environment. + +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +fn overlay() -> &'static RwLock> { + static OVERLAY: OnceLock>> = OnceLock::new(); + OVERLAY.get_or_init(|| RwLock::new(HashMap::new())) +} + +/// Install `[daemon.env]` as the fallback layer. Called once at daemon +/// startup and again by `doctor`, which loads the same config with no +/// daemon running — the two must resolve credentials identically or the +/// diagnosis describes a machine the daemon isn't on (spec 0168). +pub fn install(pairs: I) +where + I: IntoIterator, + K: Into, + V: Into, +{ + let mut map: HashMap = pairs + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(); + map.retain(|k, v| !k.trim().is_empty() && !v.trim().is_empty()); + *overlay().write().unwrap_or_else(|e| e.into_inner()) = map; +} + +/// Resolve `name`: the real environment first, then `[daemon.env]`. +/// Empty and whitespace-only values count as unset on both layers — a +/// blank key is a missing key everywhere else in the credential paths. +pub fn var(name: &str) -> Option { + if let Some(v) = std::env::var(name) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + { + return Some(v); + } + overlay() + .read() + .unwrap_or_else(|e| e.into_inner()) + .get(name) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +/// Whether `name` resolves to a non-empty value on either layer. +pub fn present(name: &str) -> bool { + var(name).is_some() +} + +/// The pairs a spawned session should start from: every `[daemon.env]` +/// entry the child would not already inherit. Entries the real environment +/// already provides are left out, so the child sees exactly what this +/// module would resolve — one precedence rule, both directions. +pub fn child_env_base() -> HashMap { + overlay() + .read() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .filter(|(k, _)| { + !std::env::var(k.as_str()) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) + }) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Run `f` with the overlay set to `pairs` and `name` forced to `value` + /// in the real environment, restoring both afterwards. Takes the + /// crate-wide env guard: the process environment and this overlay are + /// both global while tests run in parallel. + fn with_env( + name: &str, + value: Option<&str>, + pairs: &[(&str, &str)], + f: impl FnOnce() -> T, + ) -> T { + let _lock = crate::router::oauth::test_env_guard(); + let saved = std::env::var(name).ok(); + match value { + Some(v) => std::env::set_var(name, v), + None => std::env::remove_var(name), + } + install(pairs.iter().map(|(k, v)| (*k, *v))); + let out = f(); + install(Vec::<(String, String)>::new()); + match saved { + Some(v) => std::env::set_var(name, v), + None => std::env::remove_var(name), + } + out + } + + #[test] + fn config_fills_a_gap_in_the_real_environment() { + with_env("CONSTRUCT_TEST_KEY", None, &[("CONSTRUCT_TEST_KEY", "from-config")], || { + assert_eq!(var("CONSTRUCT_TEST_KEY").as_deref(), Some("from-config")); + assert!(present("CONSTRUCT_TEST_KEY")); + }); + } + + #[test] + fn the_real_environment_wins_over_config() { + with_env( + "CONSTRUCT_TEST_KEY", + Some("from-shell"), + &[("CONSTRUCT_TEST_KEY", "from-config")], + || { + assert_eq!(var("CONSTRUCT_TEST_KEY").as_deref(), Some("from-shell")); + }, + ); + } + + /// A variable exported as empty is not a value — config still fills it, + /// matching how every credential path treats a blank key. + #[test] + fn an_empty_real_value_does_not_shadow_config() { + with_env( + "CONSTRUCT_TEST_KEY", + Some(" "), + &[("CONSTRUCT_TEST_KEY", "from-config")], + || { + assert_eq!(var("CONSTRUCT_TEST_KEY").as_deref(), Some("from-config")); + }, + ); + } + + #[test] + fn a_blank_config_value_is_not_a_value() { + with_env("CONSTRUCT_TEST_KEY", None, &[("CONSTRUCT_TEST_KEY", " ")], || { + assert_eq!(var("CONSTRUCT_TEST_KEY"), None); + assert!(!present("CONSTRUCT_TEST_KEY")); + }); + } + + /// Children inherit the real environment on their own, so the base map + /// carries only what config adds — never a value that would override + /// what the operator exported. + #[test] + fn child_base_carries_config_only_where_the_shell_is_silent() { + with_env( + "CONSTRUCT_TEST_KEY", + Some("from-shell"), + &[("CONSTRUCT_TEST_KEY", "from-config"), ("CONSTRUCT_TEST_OTHER", "only-config")], + || { + let base = child_env_base(); + assert_eq!(base.get("CONSTRUCT_TEST_OTHER").map(String::as_str), Some("only-config")); + assert!( + !base.contains_key("CONSTRUCT_TEST_KEY"), + "an exported value must not be overridden by config: {base:?}" + ); + }, + ); + } +} diff --git a/crates/daemon/src/doctor.rs b/crates/daemon/src/doctor.rs index 0a18465a..ea297f84 100644 --- a/crates/daemon/src/doctor.rs +++ b/crates/daemon/src/doctor.rs @@ -623,6 +623,13 @@ fn config_section(paths: &Paths) -> (Section, Config) { } }; + // Doctor runs with no daemon, so it must build the same environment the + // daemon would from the same config before probing any credential + // (spec 0180) — otherwise it reports "no API key" for a provider that + // works, which is worse than not checking. On a parse failure this + // installs nothing, matching the fallback config it just chose. + crate::daemon_env::install(cfg.daemon.env.clone()); + let orchestrator = match cfg.orchestrator.effective_harness() { Some(h) => Finding::info("config.orchestrator", "operator", format!("enabled ({h})")), None => Finding::info("config.orchestrator", "operator", "disabled"), diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index b241c3fe..bdbed345 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -20,6 +20,7 @@ mod availability; mod channel_publication; mod config; mod cost_history; +mod daemon_env; pub mod doctor; mod loops; pub mod plugins; @@ -132,6 +133,11 @@ pub async fn run(socket_override: Option) -> Result<()> { }; let mut config = config::Config::load_or_default(&paths)?; + // Layer `[daemon.env]` under the real environment before anything + // resolves a credential (spec 0180). Done here, immediately after the + // config is read, so every later reader — route targets, availability + // probes, spawned sessions — sees the same environment. + daemon_env::install(config.daemon.env.clone()); // Merge installed-plugin contributions (spec 0152) into the same // adapter map user config and built-ins land in, so a plugin harness // is indistinguishable from a community adapter downstream. diff --git a/crates/daemon/src/session.rs b/crates/daemon/src/session.rs index 74dd3910..bcb4fed0 100644 --- a/crates/daemon/src/session.rs +++ b/crates/daemon/src/session.rs @@ -6561,6 +6561,9 @@ async fn generate_auto_title( .args(&prefix_args) .arg("--title-mode") .arg(&prompt) + // Same credential floor the adapters get (spec 0180), so a key + // declared in `[daemon.env]` names sessions as an exported one does. + .envs(crate::daemon_env::child_env_base()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -6846,6 +6849,8 @@ async fn generate_suggestions( let child = tokio::process::Command::new(&binary) .args(&prefix_args) .arg("--suggest-mode") + // Same credential floor as the adapters (spec 0180). + .envs(crate::daemon_env::child_env_base()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/crates/daemon/src/session/lifecycle.rs b/crates/daemon/src/session/lifecycle.rs index f56e4efb..b10cb019 100644 --- a/crates/daemon/src/session/lifecycle.rs +++ b/crates/daemon/src/session/lifecycle.rs @@ -135,6 +135,13 @@ impl SessionManager { // --env KEY=VAL`), overridden in turn by daemon-meta. So a // CLI flag always wins over config.toml, and daemon meta // always wins over both. + // + // `[daemon.env]` sits below all of this but is deliberately NOT + // merged here: this map is persisted as the session's start params, + // and baking credentials into per-session state on disk would both + // leak them and freeze them (a key rotated in config.toml would + // never reach an existing session). The floor is applied at spawn + // instead — see `Adapter::spawn_reconnectable` (spec 0180). let mut env_with_meta = adapter_cfg.env.clone(); for (k, v) in ¶ms.env { env_with_meta.insert(k.clone(), v.clone()); diff --git a/specs/0180-daemon-environment-from-config.md b/specs/0180-daemon-environment-from-config.md new file mode 100644 index 00000000..a8895ff0 --- /dev/null +++ b/specs/0180-daemon-environment-from-config.md @@ -0,0 +1,94 @@ +# 0180-daemon-environment-from-config + +Status: accepted +Date: 2026-08-02 +Area: convention +Scope: Declaring in config.toml the environment the daemon resolves credentials from and passes to the processes it spawns. + +## Decision + +Config may declare a table of environment variables that the daemon layers +**underneath its real environment**. A variable that is genuinely set to a +non-empty value always wins; the declared table only fills gaps. + +The layered environment applies to two things, and they must stay in step: + +- **Every credential the daemon itself resolves** — built-in route targets, + declared endpoint profiles (whether they name a variable or fall back to a + provider's default ones), and every surface that reports whether a + credential is present. A key declared in config must never produce a + working route while the same machine's status output calls it missing. +- **The base environment of every process the daemon spawns** — session + adapters and the short-lived helpers it runs for ancillary generation. + Declaring a credential must reach a harness exactly as exporting it would. + +Two classes stay environment-only, because config cannot reach them: the +variables that locate the config file itself, and the daemon's own runtime +knobs, which are not credentials and have config surfaces of their own where +they need one. + +The declared values are **not** copied into any per-session state that is +persisted. The floor is re-read from config on each spawn, so rotating a +value takes effect on the next one. + +## Reason + +A daemon's environment is fixed when its process starts, and an in-place +restart carries that same environment across — so a credential exported after +the daemon started stays invisible to it indefinitely, through any number of +restarts. The failure is silent and inverted from the user's mental model: +the key is plainly in their shell, every tool they run by hand can see it, and +the one long-lived process that needs it cannot. + +Config is the surface that does not have this problem: it is re-read on every +start. Letting it carry environment closes the gap without changing process +lifecycle semantics, and it works identically for a daemon started by hand, by +a client, or by a service manager — where "the shell that launched it" is not +a meaningful thing to point at. + +Keeping the real environment on top preserves every existing deployment: a +machine that exports its keys behaves exactly as before, and an operator can +still override a declared value for one run. + +## Consequences + +- Credentials may now live in a config file in plaintext. That is the user's + choice to make, but the surface must say so where the table is documented, + and the environment must remain a first-class alternative rather than a + legacy path. +- Any future reader of a credential variable inside the daemon must go + through the layered lookup rather than reading the process environment + directly, or it will disagree with the rest of the daemon on a machine that + uses the table. The same applies to out-of-daemon diagnostics that claim to + report what the daemon sees: they must build the layer from the same config + first. +- Spawned processes inherit declared values, so a declared credential is + visible to every harness the fleet runs, including ones that merely host a + shell. This matches what exporting the variable would have done, and is the + reason the table is documented as an environment, not as a keystore. +- Making the floor apply at spawn rather than at session creation means it is + not captured in persisted session state; a session created before a value + was declared picks it up on its next spawn with no migration. + +## Non-Goals + +- Not a secret manager: no encryption, no indirection to an external store, + no per-session scoping. It is exactly an environment, declared in a file. +- Not a way to configure the daemon's own startup — anything read before the + config file is located cannot come from it. +- Does not change how any harness resolves a model or credential internally; + it only changes what environment that resolution happens in. + +## Examples + +- A machine exports nothing and declares one provider's key in config. A + restart makes that provider a route target, its models appear in the native + catalogs, status output reports the key as present, and sessions on that + harness can use it — the same state the machine would have reached by + exporting the key before the daemon started. +- The same machine also exports that key, with a different value, for one + run. Every surface uses the exported value; the declared one is inert until + the export goes away. +- The declared value is edited and the daemon restarted in place. The new + value is in effect everywhere, including for sessions that already existed + before the edit.