From 500fd910c411c53fc7279539073a042c44a34215 Mon Sep 17 00:00:00 2001 From: codex Date: Thu, 28 May 2026 02:18:53 -0400 Subject: [PATCH] feat(agent): add provider templates Assisted-by: Codex --- Dockerfile.codex | 20 ++++ README.md | 85 +++++++--------- claude_bottle/agent_provider.py | 79 +++++++++++++++ claude_bottle/backend/__init__.py | 15 +++ claude_bottle/backend/docker/bottle.py | 25 +++-- claude_bottle/backend/docker/bottle_plan.py | 5 +- claude_bottle/backend/docker/launch.py | 8 +- claude_bottle/backend/docker/prepare.py | 43 ++++++-- claude_bottle/backend/smolmachines/bottle.py | 23 ++++- .../backend/smolmachines/bottle_plan.py | 5 + claude_bottle/backend/smolmachines/launch.py | 11 ++- claude_bottle/backend/smolmachines/prepare.py | 35 ++++++- claude_bottle/cli/dashboard.py | 72 ++++++++++---- claude_bottle/cli/start.py | 23 +++-- claude_bottle/manifest.py | 97 +++++++++++++++++-- docs/prds/0026-agent-provider-templates.md | 8 +- tests/unit/test_docker_bottle.py | 39 ++++++++ tests/unit/test_manifest_egress.py | 36 +++++++ 18 files changed, 510 insertions(+), 119 deletions(-) create mode 100644 Dockerfile.codex create mode 100644 claude_bottle/agent_provider.py diff --git a/Dockerfile.codex b/Dockerfile.codex new file mode 100644 index 0000000..5d2c533 --- /dev/null +++ b/Dockerfile.codex @@ -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"] diff --git a/README.md b/README.md index 2154b1f..7548c3a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/claude_bottle/agent_provider.py b/claude_bottle/agent_provider.py new file mode 100644 index 0000000..6b496e3 --- /dev/null +++ b/claude_bottle/agent_provider.py @@ -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}") diff --git a/claude_bottle/backend/__init__.py b/claude_bottle/backend/__init__.py index 01bf5f0..bdb5f0a 100644 --- a/claude_bottle/backend/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -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, diff --git a/claude_bottle/backend/docker/bottle.py b/claude_bottle/backend/docker/bottle.py index 02cf4f3..5d4ba3e 100644 --- a/claude_bottle/backend/docker/bottle.py +++ b/claude_bottle/backend/docker/bottle.py @@ -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: diff --git a/claude_bottle/backend/docker/bottle_plan.py b/claude_bottle/backend/docker/bottle_plan.py index 1efb14e..723ad7c 100644 --- a/claude_bottle/backend/docker/bottle_plan.py +++ b/claude_bottle/backend/docker/bottle_plan.py @@ -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) - diff --git a/claude_bottle/backend/docker/launch.py b/claude_bottle/backend/docker/launch.py index 14b3442..935fadd 100644 --- a/claude_bottle/backend/docker/launch.py +++ b/claude_bottle/backend/docker/launch.py @@ -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() diff --git a/claude_bottle/backend/docker/prepare.py b/claude_bottle/backend/docker/prepare.py index 1738fd0..eab53c6 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -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) diff --git a/claude_bottle/backend/smolmachines/bottle.py b/claude_bottle/backend/smolmachines/bottle.py index 3ecc828..9b62240 100644 --- a/claude_bottle/backend/smolmachines/bottle.py +++ b/claude_bottle/backend/smolmachines/bottle.py @@ -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 diff --git a/claude_bottle/backend/smolmachines/bottle_plan.py b/claude_bottle/backend/smolmachines/bottle_plan.py index 76a1d6b..0da2822 100644 --- a/claude_bottle/backend/smolmachines/bottle_plan.py +++ b/claude_bottle/backend/smolmachines/bottle_plan.py @@ -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}") diff --git a/claude_bottle/backend/smolmachines/launch.py b/claude_bottle/backend/smolmachines/launch.py index db8c1da..4397398 100644 --- a/claude_bottle/backend/smolmachines/launch.py +++ b/claude_bottle/backend/smolmachines/launch.py @@ -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. diff --git a/claude_bottle/backend/smolmachines/prepare.py b/claude_bottle/backend/smolmachines/prepare.py index b43191b..493f924 100644 --- a/claude_bottle/backend/smolmachines/prepare.py +++ b/claude_bottle/backend/smolmachines/prepare.py @@ -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) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 71fedc8..2c488ec 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -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 -- 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 ` --continue || ` 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})" diff --git a/claude_bottle/cli/start.py b/claude_bottle/cli/start.py index 1373efa..51cde73 100644 --- a/claude_bottle/cli/start.py +++ b/claude_bottle/cli/start.py @@ -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 diff --git a/claude_bottle/manifest.py b/claude_bottle/manifest.py index 6d2ae8d..b8c5fcf 100644 --- a/claude_bottle/manifest.py +++ b/claude_bottle/manifest.py @@ -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, diff --git a/docs/prds/0026-agent-provider-templates.md b/docs/prds/0026-agent-provider-templates.md index 76de2e6..9f0f0b6 100644 --- a/docs/prds/0026-agent-provider-templates.md +++ b/docs/prds/0026-agent-provider-templates.md @@ -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 diff --git a/tests/unit/test_docker_bottle.py b/tests/unit/test_docker_bottle.py index 009545b..c2f7736 100644 --- a/tests/unit/test_docker_bottle.py +++ b/tests/unit/test_docker_bottle.py @@ -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() diff --git a/tests/unit/test_manifest_egress.py b/tests/unit/test_manifest_egress.py index 1a15cf2..50a5ed4 100644 --- a/tests/unit/test_manifest_egress.py +++ b/tests/unit/test_manifest_egress.py @@ -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):