feat(agent): add provider templates
Assisted-by: Codex
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
# claude-bottle Codex provider image.
|
||||||
|
#
|
||||||
|
# Mirrors the default Claude image shape: Node LTS, git/network tooling,
|
||||||
|
# non-root node user, and the provider CLI installed globally.
|
||||||
|
|
||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client socat curl dnsutils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN npm install -g --no-fund --no-audit @openai/codex@0.134.0 \
|
||||||
|
&& npm cache clean --force
|
||||||
|
|
||||||
|
USER node
|
||||||
|
WORKDIR /home/node
|
||||||
|
|
||||||
|
RUN mkdir -p /home/node/.codex
|
||||||
|
|
||||||
|
CMD ["codex"]
|
||||||
@@ -126,10 +126,12 @@ and MCP endpoints resolve without an agent-side change.
|
|||||||
└─────────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- **agent image** — built from the repo `Dockerfile` (`node:22-slim`
|
- **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
|
||||||
# (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",
|
||||||
|
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",
|
||||||
):
|
):
|
||||||
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
|
||||||
|
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(
|
||||||
self, argv: list[str], *, tty: bool = True,
|
self, argv: list[str], *, tty: bool = True,
|
||||||
@@ -92,10 +101,16 @@ 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]
|
||||||
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,
|
||||||
claude_tail += 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]
|
flags += ["--", "runuser", "-u", "node", "--", *claude_tail]
|
||||||
if not tty:
|
if not tty:
|
||||||
# No PTY allocated — no SIGWINCH to forward, no resize
|
# No PTY allocated — no SIGWINCH to forward, no resize
|
||||||
|
|||||||
@@ -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,7 +704,8 @@ 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:
|
||||||
capture_session_state(identity, exit_code=0)
|
if getattr(bottle, "agent_provider_template", "claude") == "claude":
|
||||||
|
capture_session_state(identity, exit_code=0)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
@@ -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,8 +1238,14 @@ 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")
|
||||||
capture_session_state(identity, exit_code)
|
exit_code = attach_claude(
|
||||||
|
bottle,
|
||||||
|
remote_control=False,
|
||||||
|
provider_template=provider_template,
|
||||||
|
)
|
||||||
|
if provider_template == "claude":
|
||||||
|
capture_session_state(identity, exit_code)
|
||||||
finally:
|
finally:
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
return f"[{plan.slug}] claude session ended (exit {exit_code})"
|
return f"[{plan.slug}] claude session ended (exit {exit_code})"
|
||||||
|
|||||||
@@ -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,7 +236,8 @@ 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.
|
||||||
capture_session_state(identity, exit_code)
|
if provider_template == "claude":
|
||||||
|
capture_session_state(identity, exit_code)
|
||||||
return 0
|
return 0
|
||||||
finally:
|
finally:
|
||||||
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
|
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from 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",
|
||||||
|
) -> "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):
|
||||||
|
|||||||
Reference in New Issue
Block a user