PRD 0026: Agent Provider Templates #91

Merged
didericis merged 19 commits from prd-0026-agent-provider-templates into main 2026-05-28 20:04:41 -04:00
18 changed files with 510 additions and 119 deletions
Showing only changes of commit 500fd910c4 - Show all commits
+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
1
@@ -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`
1
@@ -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
didericis marked this conversation as resolved Outdated
Outdated
Review

It should be easy to declare a new claude agent/that shouldn't require needing to specify all the egress routes, but I want the configuration to be in one place. A bit torn how to do that...

I think it probably makes sense to have a top level "claude" bottle we extend from which sets both the "agent_provider" and the "egress" routes. OR we could have the egress routes be added via this template cue here, but that smells like a bad side design to me. Thoughts?

It should be easy to declare a new claude agent/that shouldn't require needing to specify all the egress routes, but I want the configuration to be in one place. A bit torn how to do that... I think it probably makes sense to have a top level "claude" bottle we extend from which sets both the "agent_provider" and the "egress" routes. OR we could have the egress routes be added via this template cue here, but that smells like a bad side design to me. Thoughts?
# 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
1
+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",
didericis marked this conversation as resolved Outdated
Outdated
Review

Image should be changed to "bot-bottle-claude:latest"

Actually we should rename the entire project from "claude-bottle" to "bot-bottle". Try to do that in a single commit. Will follow up with gitea project renames after.

Image should be changed to "bot-bottle-claude:latest" Actually we should rename the entire project from "claude-bottle" to "bot-bottle". Try to do that in a single commit. Will follow up with gitea project renames after.
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",
didericis marked this conversation as resolved Outdated
Outdated
Review

It seems like there are two different prompt modes: codex_read_prompt_file and claude_append_file. Could they be shortened to just read_prompt_file and append_file? What makes them provider specific?

Also would be nice to add type hints to available options here.

It seems like there are two different prompt modes: `codex_read_prompt_file` and `claude_append_file`. Could they be shortened to just `read_prompt_file` and `append_file`? What makes them provider specific? Also would be nice to add type hints to available options here.
):
self.name = container
self._teardown = teardown
self._prompt_path = prompt_path_in_container
self._agent_command = agent_command
didericis marked this conversation as resolved Outdated
Outdated
Review

why is this added twice? Once as self._agent_command, another time as self.agent_command?

why is this added twice? Once as `self._agent_command`, another time as `self.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(
didericis marked this conversation as resolved Outdated
Outdated
Review

Am assuming this is agent agnostic now/should be renamed agent_argv, correct?

Am assuming this is agent agnostic now/should be renamed `agent_argv`, correct?
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]
didericis marked this conversation as resolved Outdated
Outdated
Review

this claude specific naming in the function body should be fixed)

this claude specific naming in the function body should be fixed)
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)
didericis marked this conversation as resolved Outdated
Outdated
Review

Change capture_session_state to capture_claude_session_state, and add a comment stating the session state is agent specific (FIXME: would be better if we just froze all the data in the container and made this agent agnostic, should be a spike on how to do that/make a new issue)

Change `capture_session_state` to `capture_claude_session_state`, and add a comment stating the session state is agent specific (FIXME: would be better if we just froze all the data in the container and made this agent agnostic, should be a spike on how to do that/make a new issue)
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",
didericis marked this conversation as resolved Outdated
Outdated
Review

think we want to be more specific about what "provider_template" is throughout the code: rename variables like this to agent_provider_template

think we want to be more specific about what "provider_template" is throughout the code: rename variables like this to `agent_provider_template`
) -> "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.
1
@@ -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):