Skip to content

Let config.toml carry the daemon's environment - #1169

Merged
edwin-zvs merged 1 commit into
mainfrom
daemon-env-from-config
Aug 2, 2026
Merged

Let config.toml carry the daemon's environment#1169
edwin-zvs merged 1 commit into
mainfrom
daemon-env-from-config

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

The problem

A credential exported after the daemon started never reaches it. The daemon's environment is fixed at launch, and construct daemon restart re-exec()s the running image (crates/daemon/src/lib.rs), carrying the same environment across — so no number of restarts helps.

The failure is silent and inverted from the user's mental model. Setting DEEPSEEK_API_KEY makes smith work in a fresh shell immediately, while the route pickers stay empty, with nothing on screen to explain why. Found while debugging exactly that: the key was in ~/.zshrc, every hand-run tool could see it, and the one long-lived process that needed it could not.

The change

config.toml doesn't have this problem — it's re-read on every start. This adds [daemon.env]:

[daemon.env]
DEEPSEEK_API_KEY = "sk-..."

layered underneath the real environment. It fills gaps and never overrides, so a machine that exports its keys behaves exactly as before, and an operator can still override a declared value for a single run.

The layer is consulted wherever the daemon resolves a credential — built-in route targets (spec 0179), profiles that name an api_key_env 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, per spec 0168) — 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.

Two deliberate boundaries:

  • The floor is applied at spawn, not in the session's env map. That map is persisted as start params; baking credentials into per-session state would leak them to disk and freeze them against a later rotation in config.
  • Path overrides and the daemon's own CONSTRUCT_* knobs stay environment-only — they're read while locating the config file, before there is a table to consult. The config comments say so explicitly.

Spec: specs/0180-daemon-environment-from-config.md.

Verification

cargo test --workspace is green (0 failures). Nine new tests cover the precedence rules, blank-value handling, the built-in target appearing from config, a named api_key_env resolving through the layer, and /configure reporting a config-declared key as present.

Also checked live against an isolated daemon whose real environment has no DEEPSEEK_API_KEY, with the key declared only in config.toml:

== daemon env has no DEEPSEEK_API_KEY: <unset>
== router.list_routes:
  route='deepseek' models=['deepseek-v4-pro', 'deepseek-v4-flash'] unavailable=None
  DEEPSEEK PRESENT
== doctor:
  DeepSeek API key       [ok]  DEEPSEEK_API_KEY is set

Negative control — same daemon, declaration removed — reports DEEPSEEK MISSING and [--] DEEPSEEK_API_KEY not set.

No recording: the change has no distinct visual surface (the picker entry it produces already looks the way it looks today).

Relevant binary

Daemon-only change → construct (.claude/worktrees/daemon-env-from-config/target/debug/construct).

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.
@edwin-zvs
edwin-zvs merged commit a54c5ca into main Aug 2, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the daemon-env-from-config branch August 2, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant