feat(agent): add provider templates
Assisted-by: Codex
This commit is contained in:
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user