From 656dc88d769fc8e586ec9efbac22897004121b8a Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 11 May 2026 14:39:44 -0400 Subject: [PATCH] refactor(env): make env resolution backend-agnostic 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). --- claude_bottle/backend/docker/backend.py | 30 ++++++++-- claude_bottle/env.py | 73 +++++++++++++------------ claude_bottle/manifest.py | 6 +- claude_bottle/pipelock.py | 2 +- claude_bottle/util.py | 4 +- cli.py | 17 +----- 6 files changed, 70 insertions(+), 62 deletions(-) diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 89668d5..70a880a 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -19,7 +19,7 @@ from pathlib import Path from typing import Iterator, Sequence from ... import pipelock -from ...env import resolve_env_into +from ...env import ResolvedEnv, resolve_env from ...log import die, info from ...manifest import SshEntry from ...util import expand_tilde @@ -101,14 +101,12 @@ class DockerBottleBackend(BottleBackend): env_file = stage_dir / "agent.env" args_file = stage_dir / "docker-args" prompt_file = stage_dir / "prompt.txt" - env_file.write_text("") - env_file.chmod(0o600) - args_file.write_text("") prompt_file.write_text("") prompt_file.chmod(0o600) 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) allowlist_summary = pipelock.pipelock_allowlist_summary(bottle) @@ -131,6 +129,28 @@ class DockerBottleBackend(BottleBackend): 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: """Decide where the pipelock yaml lives in `stage_dir`, delegate to PipelockProxy to write it, and return the resolved diff --git a/claude_bottle/env.py b/claude_bottle/env.py index 0e70a3a..e8a5739 100644 --- a/claude_bottle/env.py +++ b/claude_bottle/env.py @@ -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; "?" uses 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) diff --git a/claude_bottle/manifest.py b/claude_bottle/manifest.py index a65d981..d86bd4f 100644 --- a/claude_bottle/manifest.py +++ b/claude_bottle/manifest.py @@ -119,9 +119,9 @@ class Bottle: if "runtime" in d: die( f"bottle '{name}' has a 'runtime' field, which is no longer " - f"supported. gVisor (runsc) is now auto-detected when " - f"registered with Docker; remove the 'runtime' field from " - f"the bottle definition." + f"supported. gVisor (runsc) is now auto-detected by the " + f"backend; remove the 'runtime' field from the bottle " + f"definition." ) env: dict[str, str] = {} diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index 7e6cfa5..4d9967f 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -107,7 +107,7 @@ class PipelockProxyPlan: class PipelockProxy(ABC): """The pipelock egress proxy. Encapsulates the YAML-config 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( self, bottle: Bottle, slug: str, yaml_path: Path diff --git a/claude_bottle/util.py b/claude_bottle/util.py index 1fb877d..c8108f3 100644 --- a/claude_bottle/util.py +++ b/claude_bottle/util.py @@ -1,7 +1,7 @@ """Cross-cutting utility helpers used by multiple modules. -Top-level (i.e. backend-agnostic) — Docker-specific helpers live in -claude_bottle/backend/docker/util.py.""" +Top-level (i.e. backend-agnostic) — backend-specific helpers live one +level deeper, under their backend package.""" from __future__ import annotations diff --git a/cli.py b/cli.py index a7c21c6..8ad8158 100755 --- a/cli.py +++ b/cli.py @@ -1,19 +1,6 @@ #!/usr/bin/env python3 -"""cli.py — manage claude-bottle containers. - -usage: cli.py [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. -""" +"""cli.py — entry point for the claude-bottle CLI. Run with --help (or +no args) for the command list.""" from __future__ import annotations