refactor(env): make env resolution backend-agnostic
test / run tests/run_tests.py (pull_request) Successful in 14s

resolve_env_into(...) becomes resolve_env(manifest, agent) -> ResolvedEnv
(forwarded names + literals). The docker backend now owns env-file /
argv serialization and the --env-file newline check. Also drops stray
Docker references from manifest.py, pipelock.py, util.py, and trims
the duplicated command list from cli.py's docstring (usage() in
claude_bottle/cli/__init__.py is now the only listing).
This commit is contained in:
2026-05-11 14:39:44 -04:00
parent 988c0bdad3
commit 656dc88d76
6 changed files with 70 additions and 62 deletions
+37 -36
View File
@@ -1,28 +1,27 @@
"""Env resolver. Walks the env entries for one agent and produces:
"""Env resolver. Walks the env entries for one agent and produces a
backend-neutral ResolvedEnv describing how the bottle should receive
each variable:
1. The list of `docker run` arg fragments needed to forward each var.
Both `secret` and `interpolated` entries become `-e NAME` (no
`=value`) so Docker inherits the value from this process env
without rendering it on argv or persisting it to disk.
Only `literal` entries are written to a host-disk env-file.
2. The export side-effect of populating this process's env with
secret values prompted from the user, and with interpolated
values copied from the matching host var, so `-e NAME` actually
has something to inherit.
- `forwarded` — names whose values have been placed into this
process's env (from a tty prompt for `secret`, from the matching
host var for `interpolated`). The backend is expected to pass
these to the bottle by-name so the value never appears on argv,
in a file, or in a log line.
- `literals` — name→value pairs that the manifest carries verbatim.
The backend serializes these however its launcher accepts env
(an env-file, an API payload, etc.).
Each env entry is a string. Mode is selected by sentinel prefix:
"?" secret (prompt at runtime). Bare "?" uses default prompt;
"?" -> secret (prompt at runtime). Bare "?" uses default prompt;
"?<message>" uses <message> as the prompt body.
"${HOST_VAR}" interpolated from $HOST_VAR in the host process env
any other str literal (the string is the value verbatim)
"${HOST_VAR}" -> interpolated from $HOST_VAR in the host process env
any other str -> literal (the string is the value verbatim)
Critical rules:
- NEVER echo, log, or interpolate the value of a secret or
interpolated env var. Both are treated as potentially sensitive:
nothing about their value (other than presence) ever lands on
disk, in a log line, or on argv.
- The env-file written for literals lives under mktemp -d with mode
600, removed by the caller's cleanup.
- Errors mention only the variable NAME, never any portion of the value.
"""
@@ -32,7 +31,7 @@ import getpass
import os
import re
import sys
from pathlib import Path
from dataclasses import dataclass, field
from .log import die
from .manifest import Manifest
@@ -40,6 +39,18 @@ from .manifest import Manifest
_INTERPOLATED_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
@dataclass(frozen=True)
class ResolvedEnv:
"""Backend-neutral env resolution result.
`forwarded` names have already been exported into os.environ by
resolve_env; the backend forwards by-name. `literals` carry their
values verbatim and are serialized by the backend."""
forwarded: list[str] = field(default_factory=list)
literals: dict[str, str] = field(default_factory=dict)
def env_entry_kind(raw: str) -> str:
"""Returns 'secret', 'interpolated', or 'literal'."""
if raw.startswith("?"):
@@ -97,17 +108,14 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
return value
def resolve_env_into(
manifest: Manifest,
agent: str,
env_file: Path,
out_args: Path,
) -> None:
def resolve_env(manifest: Manifest, agent: str) -> ResolvedEnv:
"""Iterate the agent's env entries:
- secret: always prompt; export into this process; append `-e NAME` to out_args
- interpolated: copy host value; export under target name; append `-e NAME`
- literal: append `NAME=VALUE` to env_file
- secret: always prompt; export into this process; mark forwarded
- interpolated: copy host value; export under target name; mark forwarded
- literal: include in the literals map verbatim
"""
forwarded: list[str] = []
literals: dict[str, str] = {}
bottle = manifest.bottle_for(agent)
for name, raw in bottle.env.items():
if not name:
@@ -117,8 +125,7 @@ def resolve_env_into(
prompt_body = env_entry_secret_prompt(raw)
value = _read_secret_silent(name, prompt_body)
os.environ[name] = value
with out_args.open("a") as f:
f.write(f"-e\n{name}\n")
forwarded.append(name)
elif kind == "interpolated":
host_var = env_entry_interpolated_from(raw)
host_value = os.environ.get(host_var, "")
@@ -128,13 +135,7 @@ def resolve_env_into(
f"but ${host_var} is unset or empty in the host environment."
)
os.environ[name] = host_value
with out_args.open("a") as f:
f.write(f"-e\n{name}\n")
forwarded.append(name)
else: # literal
if "\n" in raw:
die(
f"env entry {name} (literal) contains a newline; "
f"docker --env-file cannot represent multi-line values."
)
with env_file.open("a") as f:
f.write(f"{name}={raw}\n")
literals[name] = raw
return ResolvedEnv(forwarded=forwarded, literals=literals)