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`
base) on first run; runs `claude` with the manifest-granted skills,
env vars, and `~/.gitconfig` (the latter for the git-gate's
`insteadOf` rules when `bottle.git` is set).
- **agent image** — built from the provider template Dockerfile
(`Dockerfile` for Claude, `Dockerfile.codex` for Codex, or
`agent_provider.dockerfile`) on first run; runs the selected agent
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
outbound HTTP/HTTPS, enforces the resolved allowlist, runs DLP
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.
An explicit `git.remotes: {}` clears the parent's remotes; omitting
`git.remotes` inherits the parent's remotes.
- `egress:`, `supervise:` — full replace when the child declares the
field.
- `agent_provider:`, `egress:`, `supervise:` — full replace when the
child declares the field.
```yaml
---
@@ -287,6 +289,10 @@ parents die at parse with a clear pointer. Bottles remain
env:
GIT_AUTHOR_NAME: didericis
agent_provider:
template: claude # default; codex is also supported
dockerfile: "" # optional custom agent Dockerfile
git:
user:
name: "Eric Bauerfeld"
@@ -298,56 +304,33 @@ git:
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
KnownHostKey: ssh-ed25519 AAAA...
# Routes declared here are held by a per-bottle cred-proxy sidecar,
# not the agent. Each route names a path the agent dials, the
# upstream the proxy forwards to, an auth_scheme, and a token_ref
# (host env var). The value goes into the sidecar's environ via
# `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 routes are held by a per-bottle sidecar, not the agent.
# Auth token values go into the sidecar's environ, never into the
# agent. Provider-specific roles add non-secret placeholder env vars
# so the selected CLI starts while egress strips/re-injects auth.
egress:
allowlist:
- github.com
- registry.npmjs.org
- pypi.org
routes:
- host: api.anthropic.com
role: claude_code_oauth
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
OAuth via cred-proxy, gitea.dideric.is over SSH (with PAT for tea
API), and npm for publishing scoped packages.
The `gitea-dev` bottle. Backs my work on personal projects: provider
auth through egress and gitea.dideric.is over SSH.
````
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`)
````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)
self._validate_skills(agent.skills)
self._validate_git_entries(bottle.git)
self._validate_agent_provider_dockerfile(spec)
def _validate_skills(self, skills: Sequence[str]) -> None:
"""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):
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
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
"""Backend-specific plan resolution: image/container names,
+15 -10
View File
@@ -1,16 +1,11 @@
"""DockerBottle — concrete Bottle handle yielded by
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.
"""
"""DockerBottle — concrete Bottle handle yielded by DockerBottleBackend."""
from __future__ import annotations
import subprocess
from typing import Callable
from ...agent_provider import prompt_args
from .. import Bottle, ExecResult
@@ -22,22 +17,32 @@ class DockerBottle(Bottle):
container: str,
teardown: Callable[[], None],
prompt_path_in_container: str | None,
*,
agent_command: str = "claude",
agent_prompt_mode: str = "claude_append_file",
):
self.name = container
self._teardown = teardown
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
def claude_argv(
self, argv: list[str], *, tty: bool = True,
) -> list[str]:
full_argv = list(argv)
if self._prompt_path:
full_argv.extend(["--append-system-prompt-file", self._prompt_path])
full_argv.extend(
prompt_args(self._agent_prompt_mode, self._prompt_path, argv=full_argv)
)
cmd = ["docker", "exec"]
if tty:
cmd.append("-it")
cmd.extend([self.name, "claude", *full_argv])
cmd.extend([self.name, self._agent_command, *full_argv])
return cmd
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.
supervise_plan: SupervisePlan | None
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:
"""Render the y/N preflight summary to stderr — compact form
@@ -73,6 +76,7 @@ class DockerBottlePlan(BottlePlan):
print(file=sys.stderr)
info(f"agent : {spec.agent_name}")
info(f"provider : {self.agent_provider_template}")
print_multi("env ", env_names)
print_multi("skills ", list(agent.skills))
info(f"bottle : {agent.bottle}")
@@ -91,4 +95,3 @@ class DockerBottlePlan(BottlePlan):
egress_lines.append(f"{r.host}{auth}")
print_multi(" egress ", egress_lines)
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`
# — the agent runs `sleep infinity` per the renderer's
# 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:
teardown()
+35 -8
View File
@@ -14,6 +14,7 @@ import os
from datetime import datetime, timezone
from pathlib import Path
from ...agent_provider import runtime_for
from ...egress import Egress
from ...env import ResolvedEnv, resolve_env
from ...git_gate import GitGate
@@ -58,6 +59,8 @@ def resolve_plan(
manifest = spec.manifest
agent = manifest.agents[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`
# 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:
image_default = per_bottle_image_tag(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:
image_default = "claude-bottle:latest"
image_default = provider_runtime.image
image = os.environ.get("CLAUDE_BOTTLE_IMAGE", image_default)
derived_image = ""
runtime_image = image
@@ -171,8 +180,16 @@ def resolve_plan(
# PRD 0017 chunk 3 moved them behind the
# `list-egress-routes` MCP tool so the agent gets live
# state rather than a launch-time snapshot.)
dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else ""
supervise_dockerfile_path = (
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.mkdir(parents=True, exist_ok=True)
supervise_plan = supervise.prepare(
@@ -192,12 +209,12 @@ def resolve_plan(
# placeholder. The placeholder isn't any real token value, so
# leaking it would tell an attacker only that egress is in
# front. Manifest validation enforces singleton on this role.
has_anthropic_auth = any(
"claude_code_oauth" in r.roles
for r in egress_plan.routes
has_provider_auth = any(
provider_runtime.auth_role in r.roles for r in egress_plan.routes
)
if has_anthropic_auth:
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
if has_provider_auth:
forwarded_env[provider_runtime.placeholder_env] = "egress-placeholder"
if provider.template == "claude" and has_provider_auth:
# Belt-and-braces: turn off telemetry endpoints (statsig,
# error reporting) that egress can't gate by auth.
forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
@@ -225,6 +242,9 @@ def resolve_plan(
egress_plan=egress_plan,
supervise_plan=supervise_plan,
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_file.write_text("\n".join(env_lines) + ("\n" if env_lines else ""))
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
from typing import Mapping
from ...agent_provider import prompt_args
from .. import Bottle, ExecResult
from . import pty_resize as _pty_resize
from . import smolvm as _smolvm
@@ -72,6 +73,8 @@ class SmolmachinesBottle(Bottle):
*,
prompt_path: str | None = None,
guest_env: Mapping[str, str] | None = None,
agent_command: str = "claude",
agent_prompt_mode: str = "claude_append_file",
) -> None:
self.name = machine_name
# 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`
# because exec doesn't inherit from machine_create's env.
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(
self, argv: list[str], *, tty: bool = True,
@@ -92,10 +101,16 @@ class SmolmachinesBottle(Bottle):
flags += ["-i", "-t"]
flags += _env_flags_for("node")
flags += _guest_env_flags(self._guest_env)
claude_tail = ["claude"]
if self._prompt_path:
claude_tail += ["--append-system-prompt-file", self._prompt_path]
claude_tail += argv
claude_tail = [self._agent_command]
provider_prompt_args = prompt_args(
self._agent_prompt_mode, self._prompt_path, argv=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]
if not tty:
# No PTY allocated — no SIGWINCH to forward, no resize
@@ -92,6 +92,10 @@ class SmolmachinesBottlePlan(BottlePlan):
agent_proxy_url: str = ""
agent_git_gate_host: 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:
"""Compact y/N preflight. Same shape as the Docker
@@ -113,6 +117,7 @@ class SmolmachinesBottlePlan(BottlePlan):
print(file=sys.stderr)
info(f"agent : {spec.agent_name}")
info(f"provider : {self.agent_provider_template}")
print_multi("env ", env_names)
print_multi("skills ", list(agent.skills))
info(f"bottle : {agent.bottle}")
+8 -3
View File
@@ -219,7 +219,10 @@ def launch(
# output doesn't garble the dashboard's preflight modal:
# both the curses-endwin path and the tmux pane-routing
# 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
# artifact; --allow-cidr + -e carry the per-bottle TSI
@@ -286,6 +289,8 @@ def launch(
plan.machine_name,
prompt_path=prompt_path,
guest_env=plan.guest_env,
agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode,
)
finally:
stack.close()
@@ -413,7 +418,7 @@ def _resolve_token_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
`.smolmachine` artifact, caching the result under
`~/.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
already on disk for this image ID."""
_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
# keep filenames manageable, long enough to make collisions
# astronomically unlikely.
+30 -5
View File
@@ -14,6 +14,7 @@ import os
from datetime import datetime, timezone
from pathlib import Path
from ...agent_provider import runtime_for
from ...backend import BottleSpec
from ...backend.docker.bottle_state import (
BottleMetadata,
@@ -55,6 +56,8 @@ def resolve_plan(
manifest = spec.manifest
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)
@@ -117,8 +120,12 @@ def resolve_plan(
# the agent gets a non-secret placeholder here (matches the
# docker backend's forwarded_env logic in
# claude_bottle/backend/docker/prepare.py).
if any("claude_code_oauth" in r.roles for r in egress_plan.routes):
guest_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
has_provider_auth = any(
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("DISABLE_ERROR_REPORTING", "1")
@@ -145,9 +152,16 @@ def resolve_plan(
# Stash the agent image ref — `launch.launch` runs the
# build → pack pipeline at bringup. Honors CLAUDE_BOTTLE_IMAGE
# to match the docker backend's `resolve_plan` default.
agent_image_ref = os.environ.get(
"CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest"
)
agent_dockerfile_path = ""
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(
spec=spec,
@@ -164,4 +178,15 @@ def resolve_plan(
git_gate_plan=git_gate_plan,
egress_plan=egress_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 .. import supervise as _supervise
from ..agent_provider import runtime_for
from ..backend import (
ActiveAgent,
BottleSpec,
@@ -693,7 +694,7 @@ def _stop_bottle_flow(
return (
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:
# Best-effort snapshot before teardown so the operator
@@ -703,7 +704,8 @@ def _stop_bottle_flow(
# existing preserve marker (if any) is honored by
# settle_state below.
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:
pass
try:
@@ -761,21 +763,24 @@ def _in_tmux() -> bool:
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`
on every attach matches what `attach_claude` builds for the
foreground handoff so both surfaces produce the same claude
invocation."""
args = ["--dangerously-skip-permissions"]
runtime = runtime_for(provider_template)
args = list(runtime.bypass_args)
if remote_control:
args.append("--remote-control")
args.extend(runtime.remote_control_args)
if resume:
args.append("--continue")
args.extend(runtime.resume_args)
return args
def _build_resume_argv_with_fallback(
bottle, *, remote_control: bool = False,
bottle, *, remote_control: bool = False, provider_template: str = "claude",
) -> list[str]:
"""Build a backend-exec argv that runs `claude --continue` and
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 --`).
Splitting at `claude` keeps the framing as the prefix and
wraps just the claude tail in `sh -c`."""
base_args = ["--dangerously-skip-permissions"]
if remote_control:
base_args.append("--remote-control")
if provider_template != "claude":
return bottle.claude_argv(
_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)
# Split exec-framing prefix from the claude-and-args tail so
# we can compose `<claude…> --continue || <claude…>` inside
# `sh -c`. The `claude` token is the marker.
claude_idx = base_exec.index("claude")
# `sh -c`. The provider command token is the marker.
command = getattr(bottle, "agent_command", runtime_for(provider_template).command)
claude_idx = base_exec.index(command)
prefix = 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 [
*prefix,
"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)."""
curses.endwin()
try:
provider_template = getattr(bottle, "agent_provider_template", "claude")
exit_code = attach_claude(
bottle, remote_control=False, resume=resume,
bottle,
remote_control=False,
resume=resume,
provider_template=provider_template,
)
except BaseException:
stdscr.refresh()
@@ -1049,14 +1072,21 @@ def _attach_in_tmux(
auto-attach after a stop) leave it False so the operator
stays in the dashboard pane."""
if resume:
provider_template = getattr(bottle, "agent_provider_template", "claude")
# `--continue` exits non-zero when no prior session
# exists (agent spun up but never typed at). Wrap with a
# shell-level fallback so the pane lands in a fresh
# 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:
provider_template = getattr(bottle, "agent_provider_template", "claude")
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)
if pane_id is None:
@@ -1208,8 +1238,14 @@ def _new_agent_flow(
# Foreground handoff: claude owns the terminal until exit,
# then we restore curses.
try:
exit_code = attach_claude(bottle, remote_control=False)
capture_session_state(identity, exit_code)
provider_template = getattr(plan, "agent_provider_template", "claude")
exit_code = attach_claude(
bottle,
remote_control=False,
provider_template=provider_template,
)
if provider_template == "claude":
capture_session_state(identity, exit_code)
finally:
stdscr.refresh()
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 typing import Callable
from ..agent_provider import runtime_for
from ..backend import (
Bottle,
BottleSpec,
@@ -114,6 +115,7 @@ def prepare_with_preflight(
def attach_claude(
bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
provider_template: str = "claude",
) -> int:
"""Run claude inside `bottle` as an interactive session. Blocks
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
stdscr.refresh()` handoff so the curses surface gets out of the
terminal's way while claude has it."""
runtime = runtime_for(provider_template)
info(
"attaching interactive claude session "
f"attaching interactive {provider_template} session "
"(Ctrl-D or 'exit' to leave; container will be removed)"
)
claude_args = ["--dangerously-skip-permissions"]
claude_args = list(runtime.bypass_args)
if remote_control:
claude_args.append("--remote-control")
claude_args.extend(runtime.remote_control_args)
if resume:
# `--continue` jumps straight to the most recent session
# without showing the picker `--resume` would surface.
claude_args.append("--continue")
claude_args.extend(runtime.resume_args)
return bottle.exec_claude(claude_args, tty=True)
@@ -217,7 +218,12 @@ def _launch_bottle(
backend = get_bottle_backend(backend_name)
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(
f"session ended (exit {exit_code}); "
f"container {bottle.name} will be removed"
@@ -230,7 +236,8 @@ def _launch_bottle(
# way. snapshot_transcript is best-effort so the
# capability-block path's prior snapshot isn't clobbered
# when the container is already gone.
capture_session_state(identity, exit_code)
if provider_template == "claude":
capture_session_state(identity, exit_code)
return 0
finally:
# 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 typing import Mapping, cast
from .agent_provider import PROVIDER_TEMPLATES
from .log import die, warn
from .yaml_subset import YamlSubsetError, parse_frontmatter
@@ -180,6 +181,7 @@ EGRESS_AUTH_SCHEMES = ("Bearer", "token")
# special happens on the agent side.
EGRESS_ROLES = frozenset({
"claude_code_oauth",
"codex_auth",
})
# 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.
EGRESS_SINGLETON_ROLES = frozenset({
"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)
class GitUser:
@@ -428,7 +477,9 @@ class EgressConfig:
routes: tuple[EgressRoute, ...] = ()
@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")
routes_raw = d.get("routes")
routes: tuple[EgressRoute, ...] = ()
@@ -443,7 +494,9 @@ class EgressConfig:
EgressRoute.from_dict(bottle_name, i, entry)
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:
if k != "routes":
die(
@@ -456,6 +509,7 @@ class EgressConfig:
@dataclass(frozen=True)
class Bottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
agent_provider: AgentProvider = field(default_factory=AgentProvider)
git: tuple[GitEntry, ...] = ()
# Per-bottle git identity (issue #86). Empty default — bottles
# that don't set `git.user:` in the manifest skip the
@@ -526,8 +580,17 @@ class Bottle:
if git_raw is not None:
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 = (
EgressConfig.from_dict(name, d["egress"])
EgressConfig.from_dict(
name, d["egress"],
provider_template=agent_provider.template,
)
if "egress" in d
else EgressConfig()
)
@@ -540,8 +603,8 @@ class Bottle:
)
return cls(
env=env, git=git, git_user=git_user, egress=egress,
supervise=supervise_raw,
env=env, agent_provider=agent_provider, git=git,
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(
bottle_name: str,
routes: tuple[EgressRoute, ...],
*,
provider_template: str = "claude",
) -> None:
"""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"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:
@@ -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
# ghost into an empty config.
_BOTTLE_KEYS = frozenset(
{"env", "extends", "git", "egress", "supervise"}
{"env", "extends", "agent_provider", "git", "egress", "supervise"}
)
_AGENT_KEYS_REQUIRED = frozenset({"bottle"})
_AGENT_KEYS_OPTIONAL = frozenset({"skills"})
@@ -1056,12 +1131,22 @@ def _merge_bottles(
# Presence-driven full-replace for the remaining list-valued +
# scalar fields.
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 = (
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(
env=merged_env,
agent_provider=merged_agent_provider,
git=merged_git,
git_user=merged_git_user,
egress=merged_egress,
+4 -4
View File
@@ -6,7 +6,7 @@
## 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
@@ -22,7 +22,7 @@ Today claude-bottle is hard-wired around Claude Code assumptions. When Claude ru
## 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 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.
@@ -82,8 +82,8 @@ agent_provider:
## Open questions
- What is the exact Codex auth role name and environment-variable contract?
- Which state-folder artifacts are Claude-specific today, and which are provider-neutral?
- 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.
- Existing state-folder transcript capture is Claude-specific and should remain gated to Claude until the follow-up state/transcript refactor.
## 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):
def test_minimal_argv_no_prompt(self):
argv = _bottle().claude_argv([])
@@ -79,6 +89,35 @@ class TestClaudeArgv(unittest.TestCase):
_bottle("/x").claude_argv(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__":
unittest.main()
+36
View File
@@ -18,6 +18,18 @@ def _bottle(routes):
}).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):
def test_host_only(self):
b = _bottle([{"host": "api.example.com"}])
@@ -178,6 +190,30 @@ class TestRole(unittest.TestCase):
"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):
def test_duplicate_hosts_rejected(self):