${ENV_VAR} interpolation in config loading (2/13) - #205
Merged
Conversation
There was a problem hiding this comment.
Pull request overview
Adds ${ENV_VAR} / ${ENV_VAR:-default} interpolation to config loading (post-merge across layers), plus scalar re-coercion so interpolated values behave like literal YAML types (e.g., enabled: ${FLAG} with FLAG=false disables).
Changes:
- Introduces recursive env-var interpolation with required/optional semantics and
$$escaping, applied after deep-merge during config load. - Adds a scalar coercion layer (
@coerced) so declaredbool/int/float(incl.Optionalandlist[...]) fields are normalized after interpolation. - Improves CLI UX by rendering config-load failures (e.g., missing required env vars) as clean Click errors; adds extensive tests and documentation.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
tests/test_config_env.py |
New end-to-end and unit tests covering env interpolation + scalar coercion behavior. |
nerve/config.py |
Implements env interpolation, wires it into load_config/load_mcp_servers, and applies @coerced across config from_dict builders. |
nerve/coerce.py |
New module providing lenient scalar coercion and the @coerced decorator for config builders. |
nerve/cli.py |
Converts ConfigError during config load into a clean ClickException (no traceback). |
docs/config.md |
Documents env references, escaping, and post-interpolation scalar coercion semantics. |
config.example.yaml |
Adds comments documenting env-var reference support and escaping. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
alex-clickhouse
force-pushed
the
config-refactor/02-env-vars
branch
from
July 28, 2026 12:11
a65720e to
e6288f5
Compare
alex-clickhouse
force-pushed
the
config-refactor/02-env-vars
branch
from
July 28, 2026 12:36
e6288f5 to
1eeb089
Compare
alex-clickhouse
marked this pull request as ready for review
July 28, 2026 14:06
alex-clickhouse
force-pushed
the
config-refactor/02-env-vars
branch
from
July 28, 2026 14:22
1eeb089 to
880b7fd
Compare
alex-clickhouse
force-pushed
the
config-refactor/02-env-vars
branch
2 times, most recently
from
July 29, 2026 10:31
6dd6caa to
ee7e26a
Compare
alex-clickhouse
force-pushed
the
config-refactor/02-env-vars
branch
2 times, most recently
from
July 30, 2026 10:06
9bee224 to
f9cf1ca
Compare
alex-clickhouse
force-pushed
the
config-refactor/02-env-vars
branch
from
July 31, 2026 08:36
ccd94d8 to
3a69fbc
Compare
Any string value may reference ${VAR} or ${VAR:-default}. Only the braced form
is interpolated, so bcrypt hashes, JWT secrets and connection strings containing
a bare $ are never touched; $$ escapes a literal $. Interpolation runs once after
the local overlay is merged, so the secrets layer can reference the environment
too. An unresolved required reference fails the load and names every missing
variable.
Interpolation returns strings, which would otherwise destroy every declared
scalar type: `enabled: ${FLAG}` with FLAG=false becomes the string "false", and
bool("false") is true. A coercion decorator converts values back to their
declared types, covering bool, int, float, Optional and list. An eager
bool(d.get(...)) inside a from_dict defeats it, so those were removed.
Co-Authored-By: Claude <noreply@anthropic.com>
`_COERCERS` covers bool/int/float, so `_classify` returned None for
`list[str]` and left the interpolated string in place. Consumers then
iterate it: `GMAIL_ACCOUNTS=person@example.com` built eighteen gmail
source runners, one per character, and never synced the account. The
same shape silently disarms `sync.github.deny_repos` (a plain name
matches nothing, and a value containing `*` denies everything), inverts
`allow_repos` into dropping every notification, and turns
`backup.workspace_excludes` into 1-character globs.
`list[str]` now coerces, and a bare string is **wrapped** rather than
split. Splitting is what the old docstring rejected the whole type for,
and it was right: a comma is legal inside a string, and three of the four
default `langfuse.redact_patterns` are regexes containing `{20,}`.
Numeric lists keep their comma split, where the separator is unambiguous.
Six builders widened the value themselves before the decorator could
intervene, which no post-hoc check can distinguish from a genuine list.
They now hand the raw value through, via one `_str_list` helper. Blank
`langfuse.redact_patterns` keeps its defaults rather than reading as
"redact nothing"; an explicit `[]` still means off.
The existing sweep could not have caught this: it asks `_classify` which
fields to probe, so a type the coercion ignores is a type it never tests.
The new guard derives its field set from the declared annotations
instead, and its skip bucket is asserted to hold only lists of
dataclasses, so a new `list[<scalar>]` cannot land there unprobed.
Co-Authored-By: Claude <noreply@anthropic.com>
`bool(d.get(...))` again, in a field that landed on main after this
branch was written: `bool("false")` is `True`, so spelling it
`${STAR_HOOK}` with the variable set to `false` — or writing a quoted
`"false"` — enables the hook rather than disabling it. It is opt-in and
fires an agent turn on a star/unstar transition, so the wrong answer is
the one that acts.
Found by the sweep rather than by reading the diff: it walks every
declared scalar in the config tree, so a field nobody here wrote is
covered the moment it appears. That is the whole reason the test is
exhaustive instead of a list of known cases.
Co-Authored-By: Claude <noreply@anthropic.com>
pufit
force-pushed
the
config-refactor/02-env-vars
branch
from
August 1, 2026 02:33
3a69fbc to
4851c60
Compare
pufit
approved these changes
Aug 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
${VAR}(required — loading fails, listing every unresolved name) and${VAR:-default}(optional, shell:-semantics) in any string value in any config layer. Interpolation runs once, after all layers are merged. Only the braced form is touched, so bcrypt hashes ($2b$...) and connection strings are safe as-is.Because interpolation is string substitution, an interpolated value arrives as
str. Fields declaredint,floatorbool— includingX | Noneandlist[X]— are converted back to their declared type when the config object is built. Booleans are parsed, not tested for truthiness, and an unparseable value keeps the field's documented default with a log line namingClass.field.Why
Secrets should be suppliable from the environment or a secret store rather than written into a file, especially once a config layer is git-tracked and meant to be committed.
The type conversion is what makes that usable rather than a trap. Without it,
port: ${PORT}arrives as"8900"and raises at its first arithmetic orsocket.bind. Worse,enabled: ${FLAG}withFLAG=falseis a non-empty string and therefore on — a flag that reads as disabled and behaves as enabled. A quoted YAML scalar (enabled: "false") reaches the same code path with no environment variable involved at all.Lockdown makes this load-bearing rather than a convenience: it discards the machine-local layers, so every per-machine value has to be an environment reference in the tracked settings. That is exactly the shape that breaks without conversion.
Parsing rather than guessing matters in both directions. An empty variable (
FLAG=) and a bareenabled:are both off, so blanking a variable reliably disables a feature instead of falling back to whatever the tracked config said; an unrecognised value falls back to the declared default rather than flipping the flag to the opposite of both what it says and what the config declares.Added in review
A
${VAR}reference on alist[str]field was consumed character bycharacter.
_COERCERScovers bool/int/float, so_classifyreturnedNonefor
list[str]and left the interpolated string in place for consumers toiterate.
GMAIL_ACCOUNTS=person@example.combuilt eighteen gmail source runners,one per character, and never synced the account. The same shape silently disarms
sync.github.deny_repos(a plain name matches nothing, and a value containing*denies everything), invertsallow_reposinto dropping every notification,and turns
backup.workspace_excludesinto one-character globs. This matters moreunder lockdown, where a per-machine value has to be an env reference.
A bare string is now wrapped as one element rather than split. The old
docstring rejected the whole type on the grounds that splitting is unsafe, and it
was right — a comma is legal inside a string, and three of the four default
langfuse.redact_patternsare regexes containing{20,}. Wrapping has no suchproblem. Numeric lists keep their comma split, where the separator is
unambiguous.
Six builders widened the value themselves before the decorator could intervene,
which no later check can distinguish from a genuine list; they now hand the raw
value through.
The existing sweep could not have caught this: it asks
_classifywhich fieldsto probe, so a type the coercion ignores is a type it never tests. The new guard
derives its field set from the declared annotations instead, and asserts its skip
bucket can only hold lists of dataclasses, so a new
list[<scalar>]cannot landthere unprobed.
🤖 Generated with Claude Code