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
+25 -5
View File
@@ -19,7 +19,7 @@ from pathlib import Path
from typing import Iterator, Sequence from typing import Iterator, Sequence
from ... import pipelock from ... import pipelock
from ...env import resolve_env_into from ...env import ResolvedEnv, resolve_env
from ...log import die, info from ...log import die, info
from ...manifest import SshEntry from ...manifest import SshEntry
from ...util import expand_tilde from ...util import expand_tilde
@@ -101,14 +101,12 @@ class DockerBottleBackend(BottleBackend):
env_file = stage_dir / "agent.env" env_file = stage_dir / "agent.env"
args_file = stage_dir / "docker-args" args_file = stage_dir / "docker-args"
prompt_file = stage_dir / "prompt.txt" prompt_file = stage_dir / "prompt.txt"
env_file.write_text("")
env_file.chmod(0o600)
args_file.write_text("")
prompt_file.write_text("") prompt_file.write_text("")
prompt_file.chmod(0o600) prompt_file.chmod(0o600)
proxy_plan = self.prepare_proxy(spec, stage_dir) proxy_plan = self.prepare_proxy(spec, stage_dir)
resolve_env_into(manifest, spec.agent_name, env_file, args_file) resolved = resolve_env(manifest, spec.agent_name)
self._write_env_files(resolved, env_file, args_file)
prompt_file.write_text(agent.prompt) prompt_file.write_text(agent.prompt)
allowlist_summary = pipelock.pipelock_allowlist_summary(bottle) allowlist_summary = pipelock.pipelock_allowlist_summary(bottle)
@@ -131,6 +129,28 @@ class DockerBottleBackend(BottleBackend):
use_runsc=use_runsc, use_runsc=use_runsc,
) )
def _write_env_files(
self, resolved: ResolvedEnv, env_file: Path, args_file: Path
) -> None:
"""Serialize a ResolvedEnv into the two on-disk formats the launch
step consumes: `--env-file` syntax for literals (NAME=VALUE per
line) and a paired `-e\\nNAME\\n` stream for forwarded names.
Both files are created here (mode 600 on the literals file,
which may carry sensitive verbatim values from the manifest)."""
env_lines: list[str] = []
for name, value in resolved.literals.items():
if "\n" in value:
die(
f"env entry {name} (literal) contains a newline; "
f"docker --env-file cannot represent multi-line values."
)
env_lines.append(f"{name}={value}")
env_file.write_text("\n".join(env_lines) + ("\n" if env_lines else ""))
env_file.chmod(0o600)
args_lines = [f"-e\n{name}" for name in resolved.forwarded]
args_file.write_text("\n".join(args_lines) + ("\n" if args_lines else ""))
def prepare_proxy(self, spec: BottleSpec, stage_dir: Path) -> pipelock.PipelockProxyPlan: def prepare_proxy(self, spec: BottleSpec, stage_dir: Path) -> pipelock.PipelockProxyPlan:
"""Decide where the pipelock yaml lives in `stage_dir`, delegate """Decide where the pipelock yaml lives in `stage_dir`, delegate
to PipelockProxy to write it, and return the resolved to PipelockProxy to write it, and return the resolved
+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. - `forwarded` — names whose values have been placed into this
Both `secret` and `interpolated` entries become `-e NAME` (no process's env (from a tty prompt for `secret`, from the matching
`=value`) so Docker inherits the value from this process env host var for `interpolated`). The backend is expected to pass
without rendering it on argv or persisting it to disk. these to the bottle by-name so the value never appears on argv,
Only `literal` entries are written to a host-disk env-file. in a file, or in a log line.
2. The export side-effect of populating this process's env with - `literals` — name→value pairs that the manifest carries verbatim.
secret values prompted from the user, and with interpolated The backend serializes these however its launcher accepts env
values copied from the matching host var, so `-e NAME` actually (an env-file, an API payload, etc.).
has something to inherit.
Each env entry is a string. Mode is selected by sentinel prefix: 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. "?<message>" uses <message> as the prompt body.
"${HOST_VAR}" interpolated from $HOST_VAR in the host process env "${HOST_VAR}" -> interpolated from $HOST_VAR in the host process env
any other str literal (the string is the value verbatim) any other str -> literal (the string is the value verbatim)
Critical rules: Critical rules:
- NEVER echo, log, or interpolate the value of a secret or - NEVER echo, log, or interpolate the value of a secret or
interpolated env var. Both are treated as potentially sensitive: interpolated env var. Both are treated as potentially sensitive:
nothing about their value (other than presence) ever lands on nothing about their value (other than presence) ever lands on
disk, in a log line, or on argv. 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. - Errors mention only the variable NAME, never any portion of the value.
""" """
@@ -32,7 +31,7 @@ import getpass
import os import os
import re import re
import sys import sys
from pathlib import Path from dataclasses import dataclass, field
from .log import die from .log import die
from .manifest import Manifest from .manifest import Manifest
@@ -40,6 +39,18 @@ from .manifest import Manifest
_INTERPOLATED_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$") _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: def env_entry_kind(raw: str) -> str:
"""Returns 'secret', 'interpolated', or 'literal'.""" """Returns 'secret', 'interpolated', or 'literal'."""
if raw.startswith("?"): if raw.startswith("?"):
@@ -97,17 +108,14 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
return value return value
def resolve_env_into( def resolve_env(manifest: Manifest, agent: str) -> ResolvedEnv:
manifest: Manifest,
agent: str,
env_file: Path,
out_args: Path,
) -> None:
"""Iterate the agent's env entries: """Iterate the agent's env entries:
- secret: always prompt; export into this process; append `-e NAME` to out_args - secret: always prompt; export into this process; mark forwarded
- interpolated: copy host value; export under target name; append `-e NAME` - interpolated: copy host value; export under target name; mark forwarded
- literal: append `NAME=VALUE` to env_file - literal: include in the literals map verbatim
""" """
forwarded: list[str] = []
literals: dict[str, str] = {}
bottle = manifest.bottle_for(agent) bottle = manifest.bottle_for(agent)
for name, raw in bottle.env.items(): for name, raw in bottle.env.items():
if not name: if not name:
@@ -117,8 +125,7 @@ def resolve_env_into(
prompt_body = env_entry_secret_prompt(raw) prompt_body = env_entry_secret_prompt(raw)
value = _read_secret_silent(name, prompt_body) value = _read_secret_silent(name, prompt_body)
os.environ[name] = value os.environ[name] = value
with out_args.open("a") as f: forwarded.append(name)
f.write(f"-e\n{name}\n")
elif kind == "interpolated": elif kind == "interpolated":
host_var = env_entry_interpolated_from(raw) host_var = env_entry_interpolated_from(raw)
host_value = os.environ.get(host_var, "") 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." f"but ${host_var} is unset or empty in the host environment."
) )
os.environ[name] = host_value os.environ[name] = host_value
with out_args.open("a") as f: forwarded.append(name)
f.write(f"-e\n{name}\n")
else: # literal else: # literal
if "\n" in raw: literals[name] = raw
die( return ResolvedEnv(forwarded=forwarded, literals=literals)
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")
+3 -3
View File
@@ -119,9 +119,9 @@ class Bottle:
if "runtime" in d: if "runtime" in d:
die( die(
f"bottle '{name}' has a 'runtime' field, which is no longer " f"bottle '{name}' has a 'runtime' field, which is no longer "
f"supported. gVisor (runsc) is now auto-detected when " f"supported. gVisor (runsc) is now auto-detected by the "
f"registered with Docker; remove the 'runtime' field from " f"backend; remove the 'runtime' field from the bottle "
f"the bottle definition." f"definition."
) )
env: dict[str, str] = {} env: dict[str, str] = {}
+1 -1
View File
@@ -107,7 +107,7 @@ class PipelockProxyPlan:
class PipelockProxy(ABC): class PipelockProxy(ABC):
"""The pipelock egress proxy. Encapsulates the YAML-config """The pipelock egress proxy. Encapsulates the YAML-config
generation; the sidecar's start/stop lifecycle is backend-specific generation; the sidecar's start/stop lifecycle is backend-specific
and lives on concrete subclasses (e.g. DockerPipelockProxy).""" and lives on concrete subclasses."""
def prepare( def prepare(
self, bottle: Bottle, slug: str, yaml_path: Path self, bottle: Bottle, slug: str, yaml_path: Path
+2 -2
View File
@@ -1,7 +1,7 @@
"""Cross-cutting utility helpers used by multiple modules. """Cross-cutting utility helpers used by multiple modules.
Top-level (i.e. backend-agnostic) — Docker-specific helpers live in Top-level (i.e. backend-agnostic) — backend-specific helpers live one
claude_bottle/backend/docker/util.py.""" level deeper, under their backend package."""
from __future__ import annotations from __future__ import annotations
+2 -15
View File
@@ -1,19 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""cli.py — manage claude-bottle containers. """cli.py — entry point for the claude-bottle CLI. Run with --help (or
no args) for the command list."""
usage: cli.py <command> [args...]
Commands:
build build (or rebuild) the claude-bottle Docker image.
cleanup stop and remove all active claude-bottle containers.
edit open an agent in vim for editing.
info print env, skills, and prompt details for a named agent.
init interactively create a new agent and add it to claude-bottle.json.
list list available agents or active containers.
start boot a sandboxed container for a named agent and attach an
interactive claude-code session. The container is torn down
when the session ends.
"""
from __future__ import annotations from __future__ import annotations