refactor(env): make env resolution backend-agnostic
test / run tests/run_tests.py (pull_request) Successful in 14s
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:
@@ -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
@@ -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")
|
|
||||||
|
|||||||
@@ -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] = {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user