feat(agent): add provider templates
test / unit (pull_request) Successful in 28s
test / integration (pull_request) Successful in 40s

Assisted-by: Codex
This commit is contained in:
2026-05-28 02:18:53 -04:00
parent e03d90962d
commit 500fd910c4
18 changed files with 510 additions and 119 deletions
+20
View File
@@ -0,0 +1,20 @@
# claude-bottle Codex provider image.
#
# Mirrors the default Claude image shape: Node LTS, git/network tooling,
# non-root node user, and the provider CLI installed globally.
FROM node:22-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client socat curl dnsutils \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g --no-fund --no-audit @openai/codex@0.134.0 \
&& npm cache clean --force
USER node
WORKDIR /home/node
RUN mkdir -p /home/node/.codex
CMD ["codex"]
+34 -51
View File
@@ -126,10 +126,12 @@ and MCP endpoints resolve without an agent-side change.
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘
``` ```
- **agent image** — built from the repo `Dockerfile` (`node:22-slim` - **agent image** — built from the provider template Dockerfile
base) on first run; runs `claude` with the manifest-granted skills, (`Dockerfile` for Claude, `Dockerfile.codex` for Codex, or
env vars, and `~/.gitconfig` (the latter for the git-gate's `agent_provider.dockerfile`) on first run; runs the selected agent
`insteadOf` rules when `bottle.git` is set). CLI with the manifest-granted skills, env vars, and `~/.gitconfig`
(the latter for the git-gate's `insteadOf` rules when `bottle.git`
is set).
- **pipelock image** — per-agent sidecar. Terminates the agent's - **pipelock image** — per-agent sidecar. Terminates the agent's
outbound HTTP/HTTPS, enforces the resolved allowlist, runs DLP outbound HTTP/HTTPS, enforces the resolved allowlist, runs DLP
scanning. Design in `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md` scanning. Design in `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md`
@@ -261,8 +263,8 @@ child's declared fields overlay. Merge rules:
- `git.remotes:` — dict merge by host, child wins on host collision. - `git.remotes:` — dict merge by host, child wins on host collision.
An explicit `git.remotes: {}` clears the parent's remotes; omitting An explicit `git.remotes: {}` clears the parent's remotes; omitting
`git.remotes` inherits the parent's remotes. `git.remotes` inherits the parent's remotes.
- `egress:`, `supervise:` — full replace when the child declares the - `agent_provider:`, `egress:`, `supervise:` — full replace when the
field. child declares the field.
```yaml ```yaml
--- ---
@@ -287,6 +289,10 @@ parents die at parse with a clear pointer. Bottles remain
env: env:
GIT_AUTHOR_NAME: didericis GIT_AUTHOR_NAME: didericis
agent_provider:
template: claude # default; codex is also supported
dockerfile: "" # optional custom agent Dockerfile
git: git:
user: user:
name: "Eric Bauerfeld" name: "Eric Bauerfeld"
@@ -298,56 +304,33 @@ git:
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
KnownHostKey: ssh-ed25519 AAAA... KnownHostKey: ssh-ed25519 AAAA...
# Routes declared here are held by a per-bottle cred-proxy sidecar, # Egress routes are held by a per-bottle sidecar, not the agent.
# not the agent. Each route names a path the agent dials, the # Auth token values go into the sidecar's environ, never into the
# upstream the proxy forwards to, an auth_scheme, and a token_ref # agent. Provider-specific roles add non-secret placeholder env vars
# (host env var). The value goes into the sidecar's environ via # so the selected CLI starts while egress strips/re-injects auth.
# `docker create -e`, never touches argv or disk. Optional `role`
# tags drive agent-side rewrites: anthropic-base-url (sets
# ANTHROPIC_BASE_URL), npm-registry (writes ~/.npmrc), git-insteadof
# (writes ~/.gitconfig), tea-login (writes ~/.config/tea/config.yml).
# See docs/prds/0010-cred-proxy.md.
cred_proxy:
routes:
- path: /anthropic/
upstream: https://api.anthropic.com
auth_scheme: Bearer
token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN
role: anthropic-base-url
- path: /gh-api/
upstream: https://api.github.com
auth_scheme: Bearer
token_ref: GH_PAT
- path: /gh-git/
upstream: https://github.com
auth_scheme: Bearer
token_ref: GH_PAT
role: git-insteadof
- path: /npm/
upstream: https://registry.npmjs.org
auth_scheme: Bearer
token_ref: NPM_TOKEN
role: npm-registry
# Egress is forced through a per-agent pipelock sidecar on a Docker
# `--internal` network — without the proxy the agent has no route
# off-box. The effective allowlist is the union of baked-in defaults
# (api.anthropic.com, claude.ai, ...) and the hostnames listed here.
# Pipelock also runs DLP scanning and detects URL-embedded
# high-entropy secrets. The resolved allowlist is shown in the y/N
# preflight before launch.
egress: egress:
allowlist: routes:
- github.com - host: api.anthropic.com
- registry.npmjs.org role: claude_code_oauth
- pypi.org auth:
scheme: Bearer
token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN
- host: api.github.com
auth:
scheme: Bearer
token_ref: GH_PAT
--- ---
The `gitea-dev` bottle. Backs my work on personal projects: Anthropic The `gitea-dev` bottle. Backs my work on personal projects: provider
OAuth via cred-proxy, gitea.dideric.is over SSH (with PAT for tea auth through egress and gitea.dideric.is over SSH.
API), and npm for publishing scoped packages.
```` ````
For a Codex-backed bottle, set `agent_provider.template: codex` and
use the `codex_auth` egress role for the OpenAI API route. The built-in
Codex template uses `Dockerfile.codex`; set `agent_provider.dockerfile`
to build the agent from a custom Dockerfile while keeping the
claude-bottle sidecars in place.
### Example agent (`~/.claude-bottle/agents/gitea-helper.md`) ### Example agent (`~/.claude-bottle/agents/gitea-helper.md`)
````markdown ````markdown
+79
View File
@@ -0,0 +1,79 @@
"""Agent provider runtime mapping.
The manifest owns the user-facing AgentProvider shape. This module is
the launch-time table that turns a provider template into an executable
command, default image, and prompt/auth behavior.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
PROVIDER_CLAUDE = "claude"
PROVIDER_CODEX = "codex"
PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX})
@dataclass(frozen=True)
class AgentProviderRuntime:
template: str
command: str
image: str
dockerfile: str
auth_role: str
placeholder_env: str
prompt_mode: str
bypass_args: tuple[str, ...]
resume_args: tuple[str, ...]
remote_control_args: tuple[str, ...]
_REPO_ROOT = Path(__file__).resolve().parent.parent
_RUNTIMES = {
PROVIDER_CLAUDE: AgentProviderRuntime(
template=PROVIDER_CLAUDE,
command="claude",
image="claude-bottle:latest",
dockerfile="",
auth_role="claude_code_oauth",
placeholder_env="CLAUDE_CODE_OAUTH_TOKEN",
prompt_mode="claude_append_file",
bypass_args=("--dangerously-skip-permissions",),
resume_args=("--continue",),
remote_control_args=("--remote-control",),
),
PROVIDER_CODEX: AgentProviderRuntime(
template=PROVIDER_CODEX,
command="codex",
image="claude-bottle-codex:latest",
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
auth_role="codex_auth",
placeholder_env="OPENAI_API_KEY",
prompt_mode="codex_read_prompt_file",
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
resume_args=("resume", "--last"),
remote_control_args=(),
),
}
def runtime_for(template: str) -> AgentProviderRuntime:
return _RUNTIMES[template]
def prompt_args(
prompt_mode: str, prompt_path: str | None, *, argv: list[str] | None = None,
) -> list[str]:
if not prompt_path:
return []
if prompt_mode == "claude_append_file":
return ["--append-system-prompt-file", prompt_path]
if prompt_mode == "codex_read_prompt_file":
if argv and "resume" in argv:
return []
return [f"Read and follow the instructions in {prompt_path}."]
raise ValueError(f"unknown provider prompt mode: {prompt_mode}")
+15
View File
@@ -215,6 +215,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
bottle = manifest.bottle_for(spec.agent_name) bottle = manifest.bottle_for(spec.agent_name)
self._validate_skills(agent.skills) self._validate_skills(agent.skills)
self._validate_git_entries(bottle.git) self._validate_git_entries(bottle.git)
self._validate_agent_provider_dockerfile(spec)
def _validate_skills(self, skills: Sequence[str]) -> None: def _validate_skills(self, skills: Sequence[str]) -> None:
"""Each named skill must be a directory under the host's """Each named skill must be a directory under the host's
@@ -238,6 +239,20 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
if not os.path.isfile(key): if not os.path.isfile(key):
die(f"git upstream key file not found for '{entry.Name}': {key}") die(f"git upstream key file not found for '{entry.Name}': {key}")
def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None:
bottle = spec.manifest.bottle_for(spec.agent_name)
dockerfile = bottle.agent_provider.dockerfile
if not dockerfile:
return
path = Path(expand_tilde(dockerfile))
if not path.is_absolute():
path = Path(spec.user_cwd) / path
if not path.is_file():
die(
f"agent_provider.dockerfile for bottle "
f"'{spec.manifest.agents[spec.agent_name].bottle}' not found: {path}"
)
@abstractmethod @abstractmethod
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT: def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
"""Backend-specific plan resolution: image/container names, """Backend-specific plan resolution: image/container names,
+15 -10
View File
@@ -1,16 +1,11 @@
"""DockerBottle — concrete Bottle handle yielded by """DockerBottle — concrete Bottle handle yielded by DockerBottleBackend."""
DockerBottleBackend.launch.
Holds the container name plus the in-container prompt path so
exec_claude can transparently add --append-system-prompt-file when a
prompt was provisioned.
"""
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
from typing import Callable from typing import Callable
from ...agent_provider import prompt_args
from .. import Bottle, ExecResult from .. import Bottle, ExecResult
@@ -22,22 +17,32 @@ class DockerBottle(Bottle):
container: str, container: str,
teardown: Callable[[], None], teardown: Callable[[], None],
prompt_path_in_container: str | None, prompt_path_in_container: str | None,
*,
agent_command: str = "claude",
agent_prompt_mode: str = "claude_append_file",
): ):
self.name = container self.name = container
self._teardown = teardown self._teardown = teardown
self._prompt_path = prompt_path_in_container self._prompt_path = prompt_path_in_container
self._agent_command = agent_command
self._agent_prompt_mode = agent_prompt_mode
self.agent_command = agent_command
self.agent_provider_template = (
"codex" if agent_command == "codex" else "claude"
)
self._closed = False self._closed = False
def claude_argv( def claude_argv(
self, argv: list[str], *, tty: bool = True, self, argv: list[str], *, tty: bool = True,
) -> list[str]: ) -> list[str]:
full_argv = list(argv) full_argv = list(argv)
if self._prompt_path: full_argv.extend(
full_argv.extend(["--append-system-prompt-file", self._prompt_path]) prompt_args(self._agent_prompt_mode, self._prompt_path, argv=full_argv)
)
cmd = ["docker", "exec"] cmd = ["docker", "exec"]
if tty: if tty:
cmd.append("-it") cmd.append("-it")
cmd.extend([self.name, "claude", *full_argv]) cmd.extend([self.name, self._agent_command, *full_argv])
return cmd return cmd
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
+4 -1
View File
@@ -51,6 +51,9 @@ class DockerBottlePlan(BottlePlan):
# is opt-in via the manifest's bottle.supervise field. # is opt-in via the manifest's bottle.supervise field.
supervise_plan: SupervisePlan | None supervise_plan: SupervisePlan | None
use_runsc: bool use_runsc: bool
agent_command: str = "claude"
agent_prompt_mode: str = "claude_append_file"
agent_provider_template: str = "claude"
def print(self, *, remote_control: bool) -> None: def print(self, *, remote_control: bool) -> None:
"""Render the y/N preflight summary to stderr — compact form """Render the y/N preflight summary to stderr — compact form
@@ -73,6 +76,7 @@ class DockerBottlePlan(BottlePlan):
print(file=sys.stderr) print(file=sys.stderr)
info(f"agent : {spec.agent_name}") info(f"agent : {spec.agent_name}")
info(f"provider : {self.agent_provider_template}")
print_multi("env ", env_names) print_multi("env ", env_names)
print_multi("skills ", list(agent.skills)) print_multi("skills ", list(agent.skills))
info(f"bottle : {agent.bottle}") info(f"bottle : {agent.bottle}")
@@ -91,4 +95,3 @@ class DockerBottlePlan(BottlePlan):
egress_lines.append(f"{r.host}{auth}") egress_lines.append(f"{r.host}{auth}")
print_multi(" egress ", egress_lines) print_multi(" egress ", egress_lines)
print(file=sys.stderr) print(file=sys.stderr)
+7 -1
View File
@@ -207,6 +207,12 @@ def launch(
# Step 9: yield. exec_claude continues to use `docker exec -it` # Step 9: yield. exec_claude continues to use `docker exec -it`
# — the agent runs `sleep infinity` per the renderer's # — the agent runs `sleep infinity` per the renderer's
# service spec. # service spec.
yield DockerBottle(plan.container_name, teardown, prompt_path) yield DockerBottle(
plan.container_name,
teardown,
prompt_path,
agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode,
)
finally: finally:
teardown() teardown()
+35 -8
View File
@@ -14,6 +14,7 @@ import os
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from ...agent_provider import runtime_for
from ...egress import Egress from ...egress import Egress
from ...env import ResolvedEnv, resolve_env from ...env import ResolvedEnv, resolve_env
from ...git_gate import GitGate from ...git_gate import GitGate
@@ -58,6 +59,8 @@ def resolve_plan(
manifest = spec.manifest manifest = spec.manifest
agent = manifest.agents[spec.agent_name] agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name) bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider
provider_runtime = runtime_for(provider.template)
# PRD 0016 follow-up: identity, not bare slug. A fresh `start` # PRD 0016 follow-up: identity, not bare slug. A fresh `start`
# mints a random-suffixed identity (so parallel runs of the same # mints a random-suffixed identity (so parallel runs of the same
@@ -89,8 +92,14 @@ def resolve_plan(
if per_bottle_dockerfile(slug) is not None: if per_bottle_dockerfile(slug) is not None:
image_default = per_bottle_image_tag(slug) image_default = per_bottle_image_tag(slug)
dockerfile_path = str(per_bottle_dockerfile_path(slug)) dockerfile_path = str(per_bottle_dockerfile_path(slug))
elif provider.dockerfile:
image_default = f"claude-bottle:{provider.template}-{slug}"
dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
elif provider_runtime.dockerfile:
image_default = provider_runtime.image
dockerfile_path = provider_runtime.dockerfile
else: else:
image_default = "claude-bottle:latest" image_default = provider_runtime.image
image = os.environ.get("CLAUDE_BOTTLE_IMAGE", image_default) image = os.environ.get("CLAUDE_BOTTLE_IMAGE", image_default)
derived_image = "" derived_image = ""
runtime_image = image runtime_image = image
@@ -171,8 +180,16 @@ def resolve_plan(
# PRD 0017 chunk 3 moved them behind the # PRD 0017 chunk 3 moved them behind the
# `list-egress-routes` MCP tool so the agent gets live # `list-egress-routes` MCP tool so the agent gets live
# state rather than a launch-time snapshot.) # state rather than a launch-time snapshot.)
dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile" supervise_dockerfile_path = (
dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else "" Path(dockerfile_path)
if dockerfile_path
else Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
)
dockerfile_content = (
supervise_dockerfile_path.read_text()
if supervise_dockerfile_path.is_file()
else ""
)
supervise_dir = supervise_state_dir(slug) supervise_dir = supervise_state_dir(slug)
supervise_dir.mkdir(parents=True, exist_ok=True) supervise_dir.mkdir(parents=True, exist_ok=True)
supervise_plan = supervise.prepare( supervise_plan = supervise.prepare(
@@ -192,12 +209,12 @@ def resolve_plan(
# placeholder. The placeholder isn't any real token value, so # placeholder. The placeholder isn't any real token value, so
# leaking it would tell an attacker only that egress is in # leaking it would tell an attacker only that egress is in
# front. Manifest validation enforces singleton on this role. # front. Manifest validation enforces singleton on this role.
has_anthropic_auth = any( has_provider_auth = any(
"claude_code_oauth" in r.roles provider_runtime.auth_role in r.roles for r in egress_plan.routes
for r in egress_plan.routes
) )
if has_anthropic_auth: if has_provider_auth:
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" forwarded_env[provider_runtime.placeholder_env] = "egress-placeholder"
if provider.template == "claude" and has_provider_auth:
# Belt-and-braces: turn off telemetry endpoints (statsig, # Belt-and-braces: turn off telemetry endpoints (statsig,
# error reporting) that egress can't gate by auth. # error reporting) that egress can't gate by auth.
forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
@@ -225,6 +242,9 @@ def resolve_plan(
egress_plan=egress_plan, egress_plan=egress_plan,
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
use_runsc=use_runsc, use_runsc=use_runsc,
agent_command=provider_runtime.command,
agent_prompt_mode=provider_runtime.prompt_mode,
agent_provider_template=provider.template,
) )
@@ -243,3 +263,10 @@ def _write_env_file(resolved: ResolvedEnv, env_file: Path) -> None:
env_lines.append(f"{name}={value}") env_lines.append(f"{name}={value}")
env_file.write_text("\n".join(env_lines) + ("\n" if env_lines else "")) env_file.write_text("\n".join(env_lines) + ("\n" if env_lines else ""))
env_file.chmod(0o600) env_file.chmod(0o600)
def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
path = Path(os.path.expanduser(path_value))
if not path.is_absolute():
path = Path(spec.user_cwd) / path
return str(path)
+19 -4
View File
@@ -21,6 +21,7 @@ import subprocess
import sys import sys
from typing import Mapping from typing import Mapping
from ...agent_provider import prompt_args
from .. import Bottle, ExecResult from .. import Bottle, ExecResult
from . import pty_resize as _pty_resize from . import pty_resize as _pty_resize
from . import smolvm as _smolvm from . import smolvm as _smolvm
@@ -72,6 +73,8 @@ class SmolmachinesBottle(Bottle):
*, *,
prompt_path: str | None = None, prompt_path: str | None = None,
guest_env: Mapping[str, str] | None = None, guest_env: Mapping[str, str] | None = None,
agent_command: str = "claude",
agent_prompt_mode: str = "claude_append_file",
) -> None: ) -> None:
self.name = machine_name self.name = machine_name
# In-VM path to the agent's prompt file. None when the # In-VM path to the agent's prompt file. None when the
@@ -83,6 +86,12 @@ class SmolmachinesBottle(Bottle):
# Forwarded on every `smolvm machine exec` via `-e K=V` # Forwarded on every `smolvm machine exec` via `-e K=V`
# because exec doesn't inherit from machine_create's env. # because exec doesn't inherit from machine_create's env.
self._guest_env = dict(guest_env or {}) self._guest_env = dict(guest_env or {})
self._agent_command = agent_command
self._agent_prompt_mode = agent_prompt_mode
self.agent_command = agent_command
self.agent_provider_template = (
"codex" if agent_command == "codex" else "claude"
)
def claude_argv( def claude_argv(
self, argv: list[str], *, tty: bool = True, self, argv: list[str], *, tty: bool = True,
@@ -92,10 +101,16 @@ class SmolmachinesBottle(Bottle):
flags += ["-i", "-t"] flags += ["-i", "-t"]
flags += _env_flags_for("node") flags += _env_flags_for("node")
flags += _guest_env_flags(self._guest_env) flags += _guest_env_flags(self._guest_env)
claude_tail = ["claude"] claude_tail = [self._agent_command]
if self._prompt_path: provider_prompt_args = prompt_args(
claude_tail += ["--append-system-prompt-file", self._prompt_path] self._agent_prompt_mode, self._prompt_path, argv=argv,
claude_tail += argv )
if self._agent_prompt_mode == "codex_read_prompt_file":
claude_tail += argv
claude_tail += provider_prompt_args
else:
claude_tail += provider_prompt_args
claude_tail += argv
flags += ["--", "runuser", "-u", "node", "--", *claude_tail] flags += ["--", "runuser", "-u", "node", "--", *claude_tail]
if not tty: if not tty:
# No PTY allocated — no SIGWINCH to forward, no resize # No PTY allocated — no SIGWINCH to forward, no resize
@@ -92,6 +92,10 @@ class SmolmachinesBottlePlan(BottlePlan):
agent_proxy_url: str = "" agent_proxy_url: str = ""
agent_git_gate_host: str = "" agent_git_gate_host: str = ""
agent_supervise_url: str = "" agent_supervise_url: str = ""
agent_command: str = "claude"
agent_prompt_mode: str = "claude_append_file"
agent_provider_template: str = "claude"
agent_dockerfile_path: str = ""
def print(self, *, remote_control: bool) -> None: def print(self, *, remote_control: bool) -> None:
"""Compact y/N preflight. Same shape as the Docker """Compact y/N preflight. Same shape as the Docker
@@ -113,6 +117,7 @@ class SmolmachinesBottlePlan(BottlePlan):
print(file=sys.stderr) print(file=sys.stderr)
info(f"agent : {spec.agent_name}") info(f"agent : {spec.agent_name}")
info(f"provider : {self.agent_provider_template}")
print_multi("env ", env_names) print_multi("env ", env_names)
print_multi("skills ", list(agent.skills)) print_multi("skills ", list(agent.skills))
info(f"bottle : {agent.bottle}") info(f"bottle : {agent.bottle}")
+8 -3
View File
@@ -219,7 +219,10 @@ def launch(
# output doesn't garble the dashboard's preflight modal: # output doesn't garble the dashboard's preflight modal:
# both the curses-endwin path and the tmux pane-routing # both the curses-endwin path and the tmux pane-routing
# path redirect stderr around `launch` already. # path redirect stderr around `launch` already.
agent_from_path = _ensure_smolmachine(plan.agent_image_ref) agent_from_path = _ensure_smolmachine(
plan.agent_image_ref,
dockerfile=plan.agent_dockerfile_path,
)
# smolvm VM. --from carries the pre-packed .smolmachine # smolvm VM. --from carries the pre-packed .smolmachine
# artifact; --allow-cidr + -e carry the per-bottle TSI # artifact; --allow-cidr + -e carry the per-bottle TSI
@@ -286,6 +289,8 @@ def launch(
plan.machine_name, plan.machine_name,
prompt_path=prompt_path, prompt_path=prompt_path,
guest_env=plan.guest_env, guest_env=plan.guest_env,
agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode,
) )
finally: finally:
stack.close() stack.close()
@@ -413,7 +418,7 @@ def _resolve_token_env(
return egress_resolve_token_values(ep.token_env_map, dict(host_env)) return egress_resolve_token_values(ep.token_env_map, dict(host_env))
def _ensure_smolmachine(image_ref: str) -> Path: def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
"""Build the agent docker image and convert it into a """Build the agent docker image and convert it into a
`.smolmachine` artifact, caching the result under `.smolmachine` artifact, caching the result under
`~/.cache/claude-bottle/smolmachines/` keyed by the docker image `~/.cache/claude-bottle/smolmachines/` keyed by the docker image
@@ -438,7 +443,7 @@ def _ensure_smolmachine(image_ref: str) -> Path:
so we skip the whole pipeline when the cached sidecar is so we skip the whole pipeline when the cached sidecar is
already on disk for this image ID.""" already on disk for this image ID."""
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True) _SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
docker_mod.build_image(image_ref, _REPO_DIR) docker_mod.build_image(image_ref, _REPO_DIR, dockerfile=dockerfile)
# `sha256:abcd...` -> `abcd...` first 16 chars: short enough to # `sha256:abcd...` -> `abcd...` first 16 chars: short enough to
# keep filenames manageable, long enough to make collisions # keep filenames manageable, long enough to make collisions
# astronomically unlikely. # astronomically unlikely.
+30 -5
View File
@@ -14,6 +14,7 @@ import os
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from ...agent_provider import runtime_for
from ...backend import BottleSpec from ...backend import BottleSpec
from ...backend.docker.bottle_state import ( from ...backend.docker.bottle_state import (
BottleMetadata, BottleMetadata,
@@ -55,6 +56,8 @@ def resolve_plan(
manifest = spec.manifest manifest = spec.manifest
bottle = manifest.bottle_for(spec.agent_name) bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider
provider_runtime = runtime_for(provider.template)
slug = spec.identity or bottle_identity(spec.agent_name) slug = spec.identity or bottle_identity(spec.agent_name)
@@ -117,8 +120,12 @@ def resolve_plan(
# the agent gets a non-secret placeholder here (matches the # the agent gets a non-secret placeholder here (matches the
# docker backend's forwarded_env logic in # docker backend's forwarded_env logic in
# claude_bottle/backend/docker/prepare.py). # claude_bottle/backend/docker/prepare.py).
if any("claude_code_oauth" in r.roles for r in egress_plan.routes): has_provider_auth = any(
guest_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" provider_runtime.auth_role in r.roles for r in egress_plan.routes
)
if has_provider_auth:
guest_env[provider_runtime.placeholder_env] = "egress-placeholder"
if provider.template == "claude" and has_provider_auth:
guest_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") guest_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
guest_env.setdefault("DISABLE_ERROR_REPORTING", "1") guest_env.setdefault("DISABLE_ERROR_REPORTING", "1")
@@ -145,9 +152,16 @@ def resolve_plan(
# Stash the agent image ref — `launch.launch` runs the # Stash the agent image ref — `launch.launch` runs the
# build → pack pipeline at bringup. Honors CLAUDE_BOTTLE_IMAGE # build → pack pipeline at bringup. Honors CLAUDE_BOTTLE_IMAGE
# to match the docker backend's `resolve_plan` default. # to match the docker backend's `resolve_plan` default.
agent_image_ref = os.environ.get( agent_dockerfile_path = ""
"CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest" if provider.dockerfile:
) agent_dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
image_default = f"claude-bottle:{provider.template}-{slug}"
elif provider_runtime.dockerfile:
agent_dockerfile_path = provider_runtime.dockerfile
image_default = provider_runtime.image
else:
image_default = provider_runtime.image
agent_image_ref = os.environ.get("CLAUDE_BOTTLE_IMAGE", image_default)
return SmolmachinesBottlePlan( return SmolmachinesBottlePlan(
spec=spec, spec=spec,
@@ -164,4 +178,15 @@ def resolve_plan(
git_gate_plan=git_gate_plan, git_gate_plan=git_gate_plan,
egress_plan=egress_plan, egress_plan=egress_plan,
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
agent_command=provider_runtime.command,
agent_prompt_mode=provider_runtime.prompt_mode,
agent_provider_template=provider.template,
agent_dockerfile_path=agent_dockerfile_path,
) )
def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
path = Path(os.path.expanduser(path_value))
if not path.is_absolute():
path = Path(spec.user_cwd) / path
return str(path)
+54 -18
View File
@@ -26,6 +26,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from .. import supervise as _supervise from .. import supervise as _supervise
from ..agent_provider import runtime_for
from ..backend import ( from ..backend import (
ActiveAgent, ActiveAgent,
BottleSpec, BottleSpec,
@@ -693,7 +694,7 @@ def _stop_bottle_flow(
return ( return (
f"[{slug}] not dashboard-owned — use ./cli.py cleanup" f"[{slug}] not dashboard-owned — use ./cli.py cleanup"
) )
cm, _bottle, identity = bottles.pop(slug) cm, bottle, identity = bottles.pop(slug)
def _do_teardown() -> None: def _do_teardown() -> None:
# Best-effort snapshot before teardown so the operator # Best-effort snapshot before teardown so the operator
@@ -703,7 +704,8 @@ def _stop_bottle_flow(
# existing preserve marker (if any) is honored by # existing preserve marker (if any) is honored by
# settle_state below. # settle_state below.
try: try:
capture_session_state(identity, exit_code=0) if getattr(bottle, "agent_provider_template", "claude") == "claude":
capture_session_state(identity, exit_code=0)
except BaseException: except BaseException:
pass pass
try: try:
@@ -761,21 +763,24 @@ def _in_tmux() -> bool:
return bool(os.environ.get("TMUX")) return bool(os.environ.get("TMUX"))
def _claude_runtime_args(*, resume: bool, remote_control: bool = False) -> list[str]: def _claude_runtime_args(
*, resume: bool, remote_control: bool = False, provider_template: str = "claude",
) -> list[str]:
"""The argv the dashboard hands to `bottle.claude_argv` """The argv the dashboard hands to `bottle.claude_argv`
on every attach matches what `attach_claude` builds for the on every attach matches what `attach_claude` builds for the
foreground handoff so both surfaces produce the same claude foreground handoff so both surfaces produce the same claude
invocation.""" invocation."""
args = ["--dangerously-skip-permissions"] runtime = runtime_for(provider_template)
args = list(runtime.bypass_args)
if remote_control: if remote_control:
args.append("--remote-control") args.extend(runtime.remote_control_args)
if resume: if resume:
args.append("--continue") args.extend(runtime.resume_args)
return args return args
def _build_resume_argv_with_fallback( def _build_resume_argv_with_fallback(
bottle, *, remote_control: bool = False, bottle, *, remote_control: bool = False, provider_template: str = "claude",
) -> list[str]: ) -> list[str]:
"""Build a backend-exec argv that runs `claude --continue` and """Build a backend-exec argv that runs `claude --continue` and
falls back to plain `claude` if no prior session exists. falls back to plain `claude` if no prior session exists.
@@ -796,20 +801,34 @@ def _build_resume_argv_with_fallback(
`smolvm machine exec --name <m> -- runuser -u node --`). `smolvm machine exec --name <m> -- runuser -u node --`).
Splitting at `claude` keeps the framing as the prefix and Splitting at `claude` keeps the framing as the prefix and
wraps just the claude tail in `sh -c`.""" wraps just the claude tail in `sh -c`."""
base_args = ["--dangerously-skip-permissions"] if provider_template != "claude":
if remote_control: return bottle.claude_argv(
base_args.append("--remote-control") _claude_runtime_args(
resume=True,
remote_control=remote_control,
provider_template=provider_template,
)
)
base_args = _claude_runtime_args(
resume=False,
remote_control=remote_control,
provider_template=provider_template,
)
base_exec = bottle.claude_argv(base_args) base_exec = bottle.claude_argv(base_args)
# Split exec-framing prefix from the claude-and-args tail so # Split exec-framing prefix from the claude-and-args tail so
# we can compose `<claude…> --continue || <claude…>` inside # we can compose `<claude…> --continue || <claude…>` inside
# `sh -c`. The `claude` token is the marker. # `sh -c`. The provider command token is the marker.
claude_idx = base_exec.index("claude") command = getattr(bottle, "agent_command", runtime_for(provider_template).command)
claude_idx = base_exec.index(command)
prefix = base_exec[:claude_idx] prefix = base_exec[:claude_idx]
claude_cmd = " ".join(shlex.quote(a) for a in base_exec[claude_idx:]) claude_cmd = " ".join(shlex.quote(a) for a in base_exec[claude_idx:])
resume_args = " ".join(
shlex.quote(a) for a in runtime_for(provider_template).resume_args
)
return [ return [
*prefix, *prefix,
"sh", "-c", "sh", "-c",
f"{claude_cmd} --continue || {claude_cmd}", f"{claude_cmd} {resume_args} || {claude_cmd}",
] ]
@@ -1018,8 +1037,12 @@ def _attach_via_handoff(
`_attach_in_tmux` when tmux misbehaves).""" `_attach_in_tmux` when tmux misbehaves)."""
curses.endwin() curses.endwin()
try: try:
provider_template = getattr(bottle, "agent_provider_template", "claude")
exit_code = attach_claude( exit_code = attach_claude(
bottle, remote_control=False, resume=resume, bottle,
remote_control=False,
resume=resume,
provider_template=provider_template,
) )
except BaseException: except BaseException:
stdscr.refresh() stdscr.refresh()
@@ -1049,14 +1072,21 @@ def _attach_in_tmux(
auto-attach after a stop) leave it False so the operator auto-attach after a stop) leave it False so the operator
stays in the dashboard pane.""" stays in the dashboard pane."""
if resume: if resume:
provider_template = getattr(bottle, "agent_provider_template", "claude")
# `--continue` exits non-zero when no prior session # `--continue` exits non-zero when no prior session
# exists (agent spun up but never typed at). Wrap with a # exists (agent spun up but never typed at). Wrap with a
# shell-level fallback so the pane lands in a fresh # shell-level fallback so the pane lands in a fresh
# claude instead of crashing. # claude instead of crashing.
claude_argv = _build_resume_argv_with_fallback(bottle) claude_argv = _build_resume_argv_with_fallback(
bottle, provider_template=provider_template,
)
else: else:
provider_template = getattr(bottle, "agent_provider_template", "claude")
claude_argv = bottle.claude_argv( claude_argv = bottle.claude_argv(
_claude_runtime_args(resume=False), _claude_runtime_args(
resume=False,
provider_template=provider_template,
),
) )
pane_id = _ensure_right_pane(tmux_state, claude_argv) pane_id = _ensure_right_pane(tmux_state, claude_argv)
if pane_id is None: if pane_id is None:
@@ -1208,8 +1238,14 @@ def _new_agent_flow(
# Foreground handoff: claude owns the terminal until exit, # Foreground handoff: claude owns the terminal until exit,
# then we restore curses. # then we restore curses.
try: try:
exit_code = attach_claude(bottle, remote_control=False) provider_template = getattr(plan, "agent_provider_template", "claude")
capture_session_state(identity, exit_code) exit_code = attach_claude(
bottle,
remote_control=False,
provider_template=provider_template,
)
if provider_template == "claude":
capture_session_state(identity, exit_code)
finally: finally:
stdscr.refresh() stdscr.refresh()
return f"[{plan.slug}] claude session ended (exit {exit_code})" return f"[{plan.slug}] claude session ended (exit {exit_code})"
+15 -8
View File
@@ -18,6 +18,7 @@ import tempfile
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
from ..agent_provider import runtime_for
from ..backend import ( from ..backend import (
Bottle, Bottle,
BottleSpec, BottleSpec,
@@ -114,6 +115,7 @@ def prepare_with_preflight(
def attach_claude( def attach_claude(
bottle: Bottle, *, remote_control: bool = False, resume: bool = False, bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
provider_template: str = "claude",
) -> int: ) -> int:
"""Run claude inside `bottle` as an interactive session. Blocks """Run claude inside `bottle` as an interactive session. Blocks
until the session ends; returns the claude process's exit code. until the session ends; returns the claude process's exit code.
@@ -129,17 +131,16 @@ def attach_claude(
dashboard, which calls it from inside a `curses.endwin dashboard, which calls it from inside a `curses.endwin
stdscr.refresh()` handoff so the curses surface gets out of the stdscr.refresh()` handoff so the curses surface gets out of the
terminal's way while claude has it.""" terminal's way while claude has it."""
runtime = runtime_for(provider_template)
info( info(
"attaching interactive claude session " f"attaching interactive {provider_template} session "
"(Ctrl-D or 'exit' to leave; container will be removed)" "(Ctrl-D or 'exit' to leave; container will be removed)"
) )
claude_args = ["--dangerously-skip-permissions"] claude_args = list(runtime.bypass_args)
if remote_control: if remote_control:
claude_args.append("--remote-control") claude_args.extend(runtime.remote_control_args)
if resume: if resume:
# `--continue` jumps straight to the most recent session claude_args.extend(runtime.resume_args)
# without showing the picker `--resume` would surface.
claude_args.append("--continue")
return bottle.exec_claude(claude_args, tty=True) return bottle.exec_claude(claude_args, tty=True)
@@ -217,7 +218,12 @@ def _launch_bottle(
backend = get_bottle_backend(backend_name) backend = get_bottle_backend(backend_name)
with backend.launch(plan) as bottle: with backend.launch(plan) as bottle:
exit_code = attach_claude(bottle, remote_control=remote_control) provider_template = getattr(plan, "agent_provider_template", "claude")
exit_code = attach_claude(
bottle,
remote_control=remote_control,
provider_template=provider_template,
)
info( info(
f"session ended (exit {exit_code}); " f"session ended (exit {exit_code}); "
f"container {bottle.name} will be removed" f"container {bottle.name} will be removed"
@@ -230,7 +236,8 @@ def _launch_bottle(
# way. snapshot_transcript is best-effort so the # way. snapshot_transcript is best-effort so the
# capability-block path's prior snapshot isn't clobbered # capability-block path's prior snapshot isn't clobbered
# when the container is already gone. # when the container is already gone.
capture_session_state(identity, exit_code) if provider_template == "claude":
capture_session_state(identity, exit_code)
return 0 return 0
finally: finally:
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount # PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
+91 -6
View File
@@ -48,6 +48,7 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Mapping, cast from typing import Mapping, cast
from .agent_provider import PROVIDER_TEMPLATES
from .log import die, warn from .log import die, warn
from .yaml_subset import YamlSubsetError, parse_frontmatter from .yaml_subset import YamlSubsetError, parse_frontmatter
@@ -180,6 +181,7 @@ EGRESS_AUTH_SCHEMES = ("Bearer", "token")
# special happens on the agent side. # special happens on the agent side.
EGRESS_ROLES = frozenset({ EGRESS_ROLES = frozenset({
"claude_code_oauth", "claude_code_oauth",
"codex_auth",
}) })
# Singleton roles may appear on at most one route per bottle. # Singleton roles may appear on at most one route per bottle.
@@ -188,8 +190,55 @@ EGRESS_ROLES = frozenset({
# ambiguous for any future role-aware logic. # ambiguous for any future role-aware logic.
EGRESS_SINGLETON_ROLES = frozenset({ EGRESS_SINGLETON_ROLES = frozenset({
"claude_code_oauth", "claude_code_oauth",
"codex_auth",
}) })
PROVIDER_EGRESS_ROLES = {
"claude": frozenset({"claude_code_oauth"}),
"codex": frozenset({"codex_auth"}),
}
@dataclass(frozen=True)
class AgentProvider:
"""Provider/template for the agent process inside a bottle.
`template` selects a built-in launch/runtime contract. `dockerfile`
optionally points at a custom agent-image Dockerfile while leaving
claude-bottle's sidecar infrastructure intact.
"""
template: str = "claude"
dockerfile: str = ""
@classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "AgentProvider":
d = _as_json_object(raw, f"bottle '{bottle_name}' agent_provider")
for k in d:
if k not in {"template", "dockerfile"}:
die(
f"bottle '{bottle_name}' agent_provider has unknown key {k!r}; "
f"allowed: template, dockerfile"
)
template = d.get("template", "claude")
if not isinstance(template, str) or not template:
die(
f"bottle '{bottle_name}' agent_provider.template must be a "
f"non-empty string"
)
if template not in PROVIDER_TEMPLATES:
die(
f"bottle '{bottle_name}' agent_provider.template {template!r} "
f"is not one of {', '.join(sorted(PROVIDER_TEMPLATES))}"
)
dockerfile = d.get("dockerfile", "")
if not isinstance(dockerfile, str):
die(
f"bottle '{bottle_name}' agent_provider.dockerfile must be a "
f"string (was {type(dockerfile).__name__})"
)
return cls(template=template, dockerfile=dockerfile)
@dataclass(frozen=True) @dataclass(frozen=True)
class GitUser: class GitUser:
@@ -428,7 +477,9 @@ class EgressConfig:
routes: tuple[EgressRoute, ...] = () routes: tuple[EgressRoute, ...] = ()
@classmethod @classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "EgressConfig": def from_dict(
cls, bottle_name: str, raw: object, *, provider_template: str = "claude",
) -> "EgressConfig":
d = _as_json_object(raw, f"bottle '{bottle_name}' egress") d = _as_json_object(raw, f"bottle '{bottle_name}' egress")
routes_raw = d.get("routes") routes_raw = d.get("routes")
routes: tuple[EgressRoute, ...] = () routes: tuple[EgressRoute, ...] = ()
@@ -443,7 +494,9 @@ class EgressConfig:
EgressRoute.from_dict(bottle_name, i, entry) EgressRoute.from_dict(bottle_name, i, entry)
for i, entry in enumerate(routes_list) for i, entry in enumerate(routes_list)
) )
_validate_egress_routes(bottle_name, routes) _validate_egress_routes(
bottle_name, routes, provider_template=provider_template,
)
for k in d: for k in d:
if k != "routes": if k != "routes":
die( die(
@@ -456,6 +509,7 @@ class EgressConfig:
@dataclass(frozen=True) @dataclass(frozen=True)
class Bottle: class Bottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict) env: Mapping[str, str] = field(default_factory=_empty_str_dict)
agent_provider: AgentProvider = field(default_factory=AgentProvider)
git: tuple[GitEntry, ...] = () git: tuple[GitEntry, ...] = ()
# Per-bottle git identity (issue #86). Empty default — bottles # Per-bottle git identity (issue #86). Empty default — bottles
# that don't set `git.user:` in the manifest skip the # that don't set `git.user:` in the manifest skip the
@@ -526,8 +580,17 @@ class Bottle:
if git_raw is not None: if git_raw is not None:
git, git_user = _parse_git_config(name, git_raw) git, git_user = _parse_git_config(name, git_raw)
agent_provider = (
AgentProvider.from_dict(name, d["agent_provider"])
if "agent_provider" in d
else AgentProvider()
)
egress = ( egress = (
EgressConfig.from_dict(name, d["egress"]) EgressConfig.from_dict(
name, d["egress"],
provider_template=agent_provider.template,
)
if "egress" in d if "egress" in d
else EgressConfig() else EgressConfig()
) )
@@ -540,8 +603,8 @@ class Bottle:
) )
return cls( return cls(
env=env, git=git, git_user=git_user, egress=egress, env=env, agent_provider=agent_provider, git=git,
supervise=supervise_raw, git_user=git_user, egress=egress, supervise=supervise_raw,
) )
@@ -823,6 +886,8 @@ def _parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
def _validate_egress_routes( def _validate_egress_routes(
bottle_name: str, bottle_name: str,
routes: tuple[EgressRoute, ...], routes: tuple[EgressRoute, ...],
*,
provider_template: str = "claude",
) -> None: ) -> None:
"""Cross-validation for `bottle.egress.routes`: """Cross-validation for `bottle.egress.routes`:
@@ -854,6 +919,16 @@ def _validate_egress_routes(
f"routes with role {role!r} (hosts: {hosts}); this role drives a " f"routes with role {role!r} (hosts: {hosts}); this role drives a "
f"single launch-step side effect — pick one." f"single launch-step side effect — pick one."
) )
allowed_roles = PROVIDER_EGRESS_ROLES[provider_template]
for route in routes:
for role in route.Role:
if role not in allowed_roles:
die(
f"bottle '{bottle_name}' egress route for host "
f"{route.Host!r} has role {role!r}, but provider "
f"{provider_template!r} only accepts roles "
f"{', '.join(sorted(allowed_roles)) or '(none)'}"
)
def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None: def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None:
@@ -881,7 +956,7 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
# sets dies with a "did you mean" pointer — typos shouldn't silently # sets dies with a "did you mean" pointer — typos shouldn't silently
# ghost into an empty config. # ghost into an empty config.
_BOTTLE_KEYS = frozenset( _BOTTLE_KEYS = frozenset(
{"env", "extends", "git", "egress", "supervise"} {"env", "extends", "agent_provider", "git", "egress", "supervise"}
) )
_AGENT_KEYS_REQUIRED = frozenset({"bottle"}) _AGENT_KEYS_REQUIRED = frozenset({"bottle"})
_AGENT_KEYS_OPTIONAL = frozenset({"skills"}) _AGENT_KEYS_OPTIONAL = frozenset({"skills"})
@@ -1056,12 +1131,22 @@ def _merge_bottles(
# Presence-driven full-replace for the remaining list-valued + # Presence-driven full-replace for the remaining list-valued +
# scalar fields. # scalar fields.
merged_egress = child.egress if "egress" in child_raw else parent.egress merged_egress = child.egress if "egress" in child_raw else parent.egress
merged_agent_provider = (
child.agent_provider
if "agent_provider" in child_raw
else parent.agent_provider
)
merged_supervise = ( merged_supervise = (
child.supervise if "supervise" in child_raw else parent.supervise child.supervise if "supervise" in child_raw else parent.supervise
) )
_validate_egress_routes(
name, merged_egress.routes,
provider_template=merged_agent_provider.template,
)
return Bottle( return Bottle(
env=merged_env, env=merged_env,
agent_provider=merged_agent_provider,
git=merged_git, git=merged_git,
git_user=merged_git_user, git_user=merged_git_user,
egress=merged_egress, egress=merged_egress,
+4 -4
View File
@@ -6,7 +6,7 @@
## Summary ## Summary
Support multiple agent harnesses, starting with Codex and Claude, while keeping agent files provider-agnostic and bottle files responsible for boundaries. Support Claude and Codex agent providers while keeping agent files provider-agnostic and bottle files responsible for boundaries.
## Problem ## Problem
@@ -22,7 +22,7 @@ Today claude-bottle is hard-wired around Claude Code assumptions. When Claude ru
## Non-goals ## Non-goals
- Do not implement support for additional harnesses such as `pi`, `aider`, or other future providers. - Do not implement support for providers beyond Claude and Codex.
- Do not move security boundaries into agent files. - Do not move security boundaries into agent files.
- Do not allow custom Dockerfiles to remove or bypass required claude-bottle infrastructure. - Do not allow custom Dockerfiles to remove or bypass required claude-bottle infrastructure.
- Do not add new runtime dependencies unless the existing Docker/Codex tooling cannot satisfy the minimum cut. - Do not add new runtime dependencies unless the existing Docker/Codex tooling cannot satisfy the minimum cut.
@@ -82,8 +82,8 @@ agent_provider:
## Open questions ## Open questions
- What is the exact Codex auth role name and environment-variable contract? - The initial Codex auth role is `codex_auth`; it provides a non-secret `OPENAI_API_KEY` placeholder to the agent while egress holds the real token.
- Which state-folder artifacts are Claude-specific today, and which are provider-neutral? - Existing state-folder transcript capture is Claude-specific and should remain gated to Claude until the follow-up state/transcript refactor.
## References ## References
+39
View File
@@ -22,6 +22,16 @@ def _bottle(prompt_path: str | None = None) -> DockerBottle:
) )
def _codex_bottle(prompt_path: str | None = None) -> DockerBottle:
return DockerBottle(
container="claude-bottle-dev-abc",
teardown=lambda: None,
prompt_path_in_container=prompt_path,
agent_command="codex",
agent_prompt_mode="codex_read_prompt_file",
)
class TestClaudeArgv(unittest.TestCase): class TestClaudeArgv(unittest.TestCase):
def test_minimal_argv_no_prompt(self): def test_minimal_argv_no_prompt(self):
argv = _bottle().claude_argv([]) argv = _bottle().claude_argv([])
@@ -79,6 +89,35 @@ class TestClaudeArgv(unittest.TestCase):
_bottle("/x").claude_argv(original) _bottle("/x").claude_argv(original)
self.assertEqual(["--continue"], original) self.assertEqual(["--continue"], original)
def test_codex_provider_uses_codex_command(self):
argv = _codex_bottle().claude_argv(
["--dangerously-bypass-approvals-and-sandbox"],
)
self.assertEqual(
["docker", "exec", "-it", "claude-bottle-dev-abc", "codex",
"--dangerously-bypass-approvals-and-sandbox"],
argv,
)
def test_codex_provider_passes_prompt_reference_as_initial_prompt(self):
argv = _codex_bottle("/home/node/.claude-bottle-prompt.txt").claude_argv([])
self.assertEqual(
["docker", "exec", "-it", "claude-bottle-dev-abc", "codex",
"Read and follow the instructions in "
"/home/node/.claude-bottle-prompt.txt."],
argv,
)
def test_codex_resume_does_not_append_initial_prompt(self):
argv = _codex_bottle("/home/node/.claude-bottle-prompt.txt").claude_argv(
["--dangerously-bypass-approvals-and-sandbox", "resume", "--last"],
)
self.assertEqual(
["docker", "exec", "-it", "claude-bottle-dev-abc", "codex",
"--dangerously-bypass-approvals-and-sandbox", "resume", "--last"],
argv,
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+36
View File
@@ -18,6 +18,18 @@ def _bottle(routes):
}).bottles["dev"] }).bottles["dev"]
def _provider_bottle(provider, routes):
return Manifest.from_json_obj({
"bottles": {
"dev": {
"agent_provider": {"template": provider},
"egress": {"routes": routes},
}
},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
class TestMinimalRoute(unittest.TestCase): class TestMinimalRoute(unittest.TestCase):
def test_host_only(self): def test_host_only(self):
b = _bottle([{"host": "api.example.com"}]) b = _bottle([{"host": "api.example.com"}])
@@ -178,6 +190,30 @@ class TestRole(unittest.TestCase):
"auth": {"scheme": "Bearer", "token_ref": "T2"}}, "auth": {"scheme": "Bearer", "token_ref": "T2"}},
]) ])
def test_codex_auth_role_allowed_for_codex_provider(self):
b = _provider_bottle("codex", [{
"host": "api.openai.com",
"role": "codex_auth",
"auth": {"scheme": "Bearer", "token_ref": "OPENAI_TOKEN"},
}])
self.assertEqual(("codex_auth",), b.egress.routes[0].Role)
def test_claude_role_rejected_for_codex_provider(self):
with self.assertRaises(Die):
_provider_bottle("codex", [{
"host": "api.anthropic.com",
"role": "claude_code_oauth",
"auth": {"scheme": "Bearer", "token_ref": "T"},
}])
def test_codex_role_rejected_for_default_claude_provider(self):
with self.assertRaises(Die):
_bottle([{
"host": "api.openai.com",
"role": "codex_auth",
"auth": {"scheme": "Bearer", "token_ref": "T"},
}])
class TestRouteValidation(unittest.TestCase): class TestRouteValidation(unittest.TestCase):
def test_duplicate_hosts_rejected(self): def test_duplicate_hosts_rejected(self):