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