feat(agent): add provider templates
Assisted-by: Codex
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
"""Agent provider runtime mapping.
|
||||
|
||||
The manifest owns the user-facing AgentProvider shape. This module is
|
||||
the launch-time table that turns a provider template into an executable
|
||||
command, default image, and prompt/auth behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROVIDER_CLAUDE = "claude"
|
||||
PROVIDER_CODEX = "codex"
|
||||
PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentProviderRuntime:
|
||||
template: str
|
||||
command: str
|
||||
image: str
|
||||
dockerfile: str
|
||||
auth_role: str
|
||||
placeholder_env: str
|
||||
prompt_mode: str
|
||||
bypass_args: tuple[str, ...]
|
||||
resume_args: tuple[str, ...]
|
||||
remote_control_args: tuple[str, ...]
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
_RUNTIMES = {
|
||||
PROVIDER_CLAUDE: AgentProviderRuntime(
|
||||
template=PROVIDER_CLAUDE,
|
||||
command="claude",
|
||||
image="claude-bottle:latest",
|
||||
dockerfile="",
|
||||
auth_role="claude_code_oauth",
|
||||
placeholder_env="CLAUDE_CODE_OAUTH_TOKEN",
|
||||
prompt_mode="claude_append_file",
|
||||
bypass_args=("--dangerously-skip-permissions",),
|
||||
resume_args=("--continue",),
|
||||
remote_control_args=("--remote-control",),
|
||||
),
|
||||
PROVIDER_CODEX: AgentProviderRuntime(
|
||||
template=PROVIDER_CODEX,
|
||||
command="codex",
|
||||
image="claude-bottle-codex:latest",
|
||||
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
|
||||
auth_role="codex_auth",
|
||||
placeholder_env="OPENAI_API_KEY",
|
||||
prompt_mode="codex_read_prompt_file",
|
||||
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
||||
resume_args=("resume", "--last"),
|
||||
remote_control_args=(),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def runtime_for(template: str) -> AgentProviderRuntime:
|
||||
return _RUNTIMES[template]
|
||||
|
||||
|
||||
def prompt_args(
|
||||
prompt_mode: str, prompt_path: str | None, *, argv: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
if not prompt_path:
|
||||
return []
|
||||
if prompt_mode == "claude_append_file":
|
||||
return ["--append-system-prompt-file", prompt_path]
|
||||
if prompt_mode == "codex_read_prompt_file":
|
||||
if argv and "resume" in argv:
|
||||
return []
|
||||
return [f"Read and follow the instructions in {prompt_path}."]
|
||||
raise ValueError(f"unknown provider prompt mode: {prompt_mode}")
|
||||
@@ -215,6 +215,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
self._validate_skills(agent.skills)
|
||||
self._validate_git_entries(bottle.git)
|
||||
self._validate_agent_provider_dockerfile(spec)
|
||||
|
||||
def _validate_skills(self, skills: Sequence[str]) -> None:
|
||||
"""Each named skill must be a directory under the host's
|
||||
@@ -238,6 +239,20 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
if not os.path.isfile(key):
|
||||
die(f"git upstream key file not found for '{entry.Name}': {key}")
|
||||
|
||||
def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None:
|
||||
bottle = spec.manifest.bottle_for(spec.agent_name)
|
||||
dockerfile = bottle.agent_provider.dockerfile
|
||||
if not dockerfile:
|
||||
return
|
||||
path = Path(expand_tilde(dockerfile))
|
||||
if not path.is_absolute():
|
||||
path = Path(spec.user_cwd) / path
|
||||
if not path.is_file():
|
||||
die(
|
||||
f"agent_provider.dockerfile for bottle "
|
||||
f"'{spec.manifest.agents[spec.agent_name].bottle}' not found: {path}"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
|
||||
"""Backend-specific plan resolution: image/container names,
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
"""DockerBottle — concrete Bottle handle yielded by
|
||||
DockerBottleBackend.launch.
|
||||
|
||||
Holds the container name plus the in-container prompt path so
|
||||
exec_claude can transparently add --append-system-prompt-file when a
|
||||
prompt was provisioned.
|
||||
"""
|
||||
"""DockerBottle — concrete Bottle handle yielded by DockerBottleBackend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Callable
|
||||
|
||||
from ...agent_provider import prompt_args
|
||||
from .. import Bottle, ExecResult
|
||||
|
||||
|
||||
@@ -22,22 +17,32 @@ class DockerBottle(Bottle):
|
||||
container: str,
|
||||
teardown: Callable[[], None],
|
||||
prompt_path_in_container: str | None,
|
||||
*,
|
||||
agent_command: str = "claude",
|
||||
agent_prompt_mode: str = "claude_append_file",
|
||||
):
|
||||
self.name = container
|
||||
self._teardown = teardown
|
||||
self._prompt_path = prompt_path_in_container
|
||||
self._agent_command = agent_command
|
||||
self._agent_prompt_mode = agent_prompt_mode
|
||||
self.agent_command = agent_command
|
||||
self.agent_provider_template = (
|
||||
"codex" if agent_command == "codex" else "claude"
|
||||
)
|
||||
self._closed = False
|
||||
|
||||
def claude_argv(
|
||||
self, argv: list[str], *, tty: bool = True,
|
||||
) -> list[str]:
|
||||
full_argv = list(argv)
|
||||
if self._prompt_path:
|
||||
full_argv.extend(["--append-system-prompt-file", self._prompt_path])
|
||||
full_argv.extend(
|
||||
prompt_args(self._agent_prompt_mode, self._prompt_path, argv=full_argv)
|
||||
)
|
||||
cmd = ["docker", "exec"]
|
||||
if tty:
|
||||
cmd.append("-it")
|
||||
cmd.extend([self.name, "claude", *full_argv])
|
||||
cmd.extend([self.name, self._agent_command, *full_argv])
|
||||
return cmd
|
||||
|
||||
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
|
||||
|
||||
@@ -51,6 +51,9 @@ class DockerBottlePlan(BottlePlan):
|
||||
# is opt-in via the manifest's bottle.supervise field.
|
||||
supervise_plan: SupervisePlan | None
|
||||
use_runsc: bool
|
||||
agent_command: str = "claude"
|
||||
agent_prompt_mode: str = "claude_append_file"
|
||||
agent_provider_template: str = "claude"
|
||||
|
||||
def print(self, *, remote_control: bool) -> None:
|
||||
"""Render the y/N preflight summary to stderr — compact form
|
||||
@@ -73,6 +76,7 @@ class DockerBottlePlan(BottlePlan):
|
||||
|
||||
print(file=sys.stderr)
|
||||
info(f"agent : {spec.agent_name}")
|
||||
info(f"provider : {self.agent_provider_template}")
|
||||
print_multi("env ", env_names)
|
||||
print_multi("skills ", list(agent.skills))
|
||||
info(f"bottle : {agent.bottle}")
|
||||
@@ -91,4 +95,3 @@ class DockerBottlePlan(BottlePlan):
|
||||
egress_lines.append(f"{r.host}{auth}")
|
||||
print_multi(" egress ", egress_lines)
|
||||
print(file=sys.stderr)
|
||||
|
||||
|
||||
@@ -207,6 +207,12 @@ def launch(
|
||||
# Step 9: yield. exec_claude continues to use `docker exec -it`
|
||||
# — the agent runs `sleep infinity` per the renderer's
|
||||
# service spec.
|
||||
yield DockerBottle(plan.container_name, teardown, prompt_path)
|
||||
yield DockerBottle(
|
||||
plan.container_name,
|
||||
teardown,
|
||||
prompt_path,
|
||||
agent_command=plan.agent_command,
|
||||
agent_prompt_mode=plan.agent_prompt_mode,
|
||||
)
|
||||
finally:
|
||||
teardown()
|
||||
|
||||
@@ -14,6 +14,7 @@ import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from ...agent_provider import runtime_for
|
||||
from ...egress import Egress
|
||||
from ...env import ResolvedEnv, resolve_env
|
||||
from ...git_gate import GitGate
|
||||
@@ -58,6 +59,8 @@ def resolve_plan(
|
||||
manifest = spec.manifest
|
||||
agent = manifest.agents[spec.agent_name]
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
provider = bottle.agent_provider
|
||||
provider_runtime = runtime_for(provider.template)
|
||||
|
||||
# PRD 0016 follow-up: identity, not bare slug. A fresh `start`
|
||||
# mints a random-suffixed identity (so parallel runs of the same
|
||||
@@ -89,8 +92,14 @@ def resolve_plan(
|
||||
if per_bottle_dockerfile(slug) is not None:
|
||||
image_default = per_bottle_image_tag(slug)
|
||||
dockerfile_path = str(per_bottle_dockerfile_path(slug))
|
||||
elif provider.dockerfile:
|
||||
image_default = f"claude-bottle:{provider.template}-{slug}"
|
||||
dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
|
||||
elif provider_runtime.dockerfile:
|
||||
image_default = provider_runtime.image
|
||||
dockerfile_path = provider_runtime.dockerfile
|
||||
else:
|
||||
image_default = "claude-bottle:latest"
|
||||
image_default = provider_runtime.image
|
||||
image = os.environ.get("CLAUDE_BOTTLE_IMAGE", image_default)
|
||||
derived_image = ""
|
||||
runtime_image = image
|
||||
@@ -171,8 +180,16 @@ def resolve_plan(
|
||||
# PRD 0017 chunk 3 moved them behind the
|
||||
# `list-egress-routes` MCP tool so the agent gets live
|
||||
# state rather than a launch-time snapshot.)
|
||||
dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
|
||||
dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else ""
|
||||
supervise_dockerfile_path = (
|
||||
Path(dockerfile_path)
|
||||
if dockerfile_path
|
||||
else Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
|
||||
)
|
||||
dockerfile_content = (
|
||||
supervise_dockerfile_path.read_text()
|
||||
if supervise_dockerfile_path.is_file()
|
||||
else ""
|
||||
)
|
||||
supervise_dir = supervise_state_dir(slug)
|
||||
supervise_dir.mkdir(parents=True, exist_ok=True)
|
||||
supervise_plan = supervise.prepare(
|
||||
@@ -192,12 +209,12 @@ def resolve_plan(
|
||||
# placeholder. The placeholder isn't any real token value, so
|
||||
# leaking it would tell an attacker only that egress is in
|
||||
# front. Manifest validation enforces singleton on this role.
|
||||
has_anthropic_auth = any(
|
||||
"claude_code_oauth" in r.roles
|
||||
for r in egress_plan.routes
|
||||
has_provider_auth = any(
|
||||
provider_runtime.auth_role in r.roles for r in egress_plan.routes
|
||||
)
|
||||
if has_anthropic_auth:
|
||||
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
||||
if has_provider_auth:
|
||||
forwarded_env[provider_runtime.placeholder_env] = "egress-placeholder"
|
||||
if provider.template == "claude" and has_provider_auth:
|
||||
# Belt-and-braces: turn off telemetry endpoints (statsig,
|
||||
# error reporting) that egress can't gate by auth.
|
||||
forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
||||
@@ -225,6 +242,9 @@ def resolve_plan(
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
use_runsc=use_runsc,
|
||||
agent_command=provider_runtime.command,
|
||||
agent_prompt_mode=provider_runtime.prompt_mode,
|
||||
agent_provider_template=provider.template,
|
||||
)
|
||||
|
||||
|
||||
@@ -243,3 +263,10 @@ def _write_env_file(resolved: ResolvedEnv, env_file: Path) -> None:
|
||||
env_lines.append(f"{name}={value}")
|
||||
env_file.write_text("\n".join(env_lines) + ("\n" if env_lines else ""))
|
||||
env_file.chmod(0o600)
|
||||
|
||||
|
||||
def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
|
||||
path = Path(os.path.expanduser(path_value))
|
||||
if not path.is_absolute():
|
||||
path = Path(spec.user_cwd) / path
|
||||
return str(path)
|
||||
|
||||
@@ -21,6 +21,7 @@ import subprocess
|
||||
import sys
|
||||
from typing import Mapping
|
||||
|
||||
from ...agent_provider import prompt_args
|
||||
from .. import Bottle, ExecResult
|
||||
from . import pty_resize as _pty_resize
|
||||
from . import smolvm as _smolvm
|
||||
@@ -72,6 +73,8 @@ class SmolmachinesBottle(Bottle):
|
||||
*,
|
||||
prompt_path: str | None = None,
|
||||
guest_env: Mapping[str, str] | None = None,
|
||||
agent_command: str = "claude",
|
||||
agent_prompt_mode: str = "claude_append_file",
|
||||
) -> None:
|
||||
self.name = machine_name
|
||||
# In-VM path to the agent's prompt file. None when the
|
||||
@@ -83,6 +86,12 @@ class SmolmachinesBottle(Bottle):
|
||||
# Forwarded on every `smolvm machine exec` via `-e K=V`
|
||||
# because exec doesn't inherit from machine_create's env.
|
||||
self._guest_env = dict(guest_env or {})
|
||||
self._agent_command = agent_command
|
||||
self._agent_prompt_mode = agent_prompt_mode
|
||||
self.agent_command = agent_command
|
||||
self.agent_provider_template = (
|
||||
"codex" if agent_command == "codex" else "claude"
|
||||
)
|
||||
|
||||
def claude_argv(
|
||||
self, argv: list[str], *, tty: bool = True,
|
||||
@@ -92,10 +101,16 @@ class SmolmachinesBottle(Bottle):
|
||||
flags += ["-i", "-t"]
|
||||
flags += _env_flags_for("node")
|
||||
flags += _guest_env_flags(self._guest_env)
|
||||
claude_tail = ["claude"]
|
||||
if self._prompt_path:
|
||||
claude_tail += ["--append-system-prompt-file", self._prompt_path]
|
||||
claude_tail += argv
|
||||
claude_tail = [self._agent_command]
|
||||
provider_prompt_args = prompt_args(
|
||||
self._agent_prompt_mode, self._prompt_path, argv=argv,
|
||||
)
|
||||
if self._agent_prompt_mode == "codex_read_prompt_file":
|
||||
claude_tail += argv
|
||||
claude_tail += provider_prompt_args
|
||||
else:
|
||||
claude_tail += provider_prompt_args
|
||||
claude_tail += argv
|
||||
flags += ["--", "runuser", "-u", "node", "--", *claude_tail]
|
||||
if not tty:
|
||||
# No PTY allocated — no SIGWINCH to forward, no resize
|
||||
|
||||
@@ -92,6 +92,10 @@ class SmolmachinesBottlePlan(BottlePlan):
|
||||
agent_proxy_url: str = ""
|
||||
agent_git_gate_host: str = ""
|
||||
agent_supervise_url: str = ""
|
||||
agent_command: str = "claude"
|
||||
agent_prompt_mode: str = "claude_append_file"
|
||||
agent_provider_template: str = "claude"
|
||||
agent_dockerfile_path: str = ""
|
||||
|
||||
def print(self, *, remote_control: bool) -> None:
|
||||
"""Compact y/N preflight. Same shape as the Docker
|
||||
@@ -113,6 +117,7 @@ class SmolmachinesBottlePlan(BottlePlan):
|
||||
|
||||
print(file=sys.stderr)
|
||||
info(f"agent : {spec.agent_name}")
|
||||
info(f"provider : {self.agent_provider_template}")
|
||||
print_multi("env ", env_names)
|
||||
print_multi("skills ", list(agent.skills))
|
||||
info(f"bottle : {agent.bottle}")
|
||||
|
||||
@@ -219,7 +219,10 @@ def launch(
|
||||
# output doesn't garble the dashboard's preflight modal:
|
||||
# both the curses-endwin path and the tmux pane-routing
|
||||
# path redirect stderr around `launch` already.
|
||||
agent_from_path = _ensure_smolmachine(plan.agent_image_ref)
|
||||
agent_from_path = _ensure_smolmachine(
|
||||
plan.agent_image_ref,
|
||||
dockerfile=plan.agent_dockerfile_path,
|
||||
)
|
||||
|
||||
# smolvm VM. --from carries the pre-packed .smolmachine
|
||||
# artifact; --allow-cidr + -e carry the per-bottle TSI
|
||||
@@ -286,6 +289,8 @@ def launch(
|
||||
plan.machine_name,
|
||||
prompt_path=prompt_path,
|
||||
guest_env=plan.guest_env,
|
||||
agent_command=plan.agent_command,
|
||||
agent_prompt_mode=plan.agent_prompt_mode,
|
||||
)
|
||||
finally:
|
||||
stack.close()
|
||||
@@ -413,7 +418,7 @@ def _resolve_token_env(
|
||||
return egress_resolve_token_values(ep.token_env_map, dict(host_env))
|
||||
|
||||
|
||||
def _ensure_smolmachine(image_ref: str) -> Path:
|
||||
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
||||
"""Build the agent docker image and convert it into a
|
||||
`.smolmachine` artifact, caching the result under
|
||||
`~/.cache/claude-bottle/smolmachines/` keyed by the docker image
|
||||
@@ -438,7 +443,7 @@ def _ensure_smolmachine(image_ref: str) -> Path:
|
||||
so we skip the whole pipeline when the cached sidecar is
|
||||
already on disk for this image ID."""
|
||||
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
docker_mod.build_image(image_ref, _REPO_DIR)
|
||||
docker_mod.build_image(image_ref, _REPO_DIR, dockerfile=dockerfile)
|
||||
# `sha256:abcd...` -> `abcd...` first 16 chars: short enough to
|
||||
# keep filenames manageable, long enough to make collisions
|
||||
# astronomically unlikely.
|
||||
|
||||
@@ -14,6 +14,7 @@ import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from ...agent_provider import runtime_for
|
||||
from ...backend import BottleSpec
|
||||
from ...backend.docker.bottle_state import (
|
||||
BottleMetadata,
|
||||
@@ -55,6 +56,8 @@ def resolve_plan(
|
||||
|
||||
manifest = spec.manifest
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
provider = bottle.agent_provider
|
||||
provider_runtime = runtime_for(provider.template)
|
||||
|
||||
slug = spec.identity or bottle_identity(spec.agent_name)
|
||||
|
||||
@@ -117,8 +120,12 @@ def resolve_plan(
|
||||
# the agent gets a non-secret placeholder here (matches the
|
||||
# docker backend's forwarded_env logic in
|
||||
# claude_bottle/backend/docker/prepare.py).
|
||||
if any("claude_code_oauth" in r.roles for r in egress_plan.routes):
|
||||
guest_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
||||
has_provider_auth = any(
|
||||
provider_runtime.auth_role in r.roles for r in egress_plan.routes
|
||||
)
|
||||
if has_provider_auth:
|
||||
guest_env[provider_runtime.placeholder_env] = "egress-placeholder"
|
||||
if provider.template == "claude" and has_provider_auth:
|
||||
guest_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
||||
guest_env.setdefault("DISABLE_ERROR_REPORTING", "1")
|
||||
|
||||
@@ -145,9 +152,16 @@ def resolve_plan(
|
||||
# Stash the agent image ref — `launch.launch` runs the
|
||||
# build → pack pipeline at bringup. Honors CLAUDE_BOTTLE_IMAGE
|
||||
# to match the docker backend's `resolve_plan` default.
|
||||
agent_image_ref = os.environ.get(
|
||||
"CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest"
|
||||
)
|
||||
agent_dockerfile_path = ""
|
||||
if provider.dockerfile:
|
||||
agent_dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
|
||||
image_default = f"claude-bottle:{provider.template}-{slug}"
|
||||
elif provider_runtime.dockerfile:
|
||||
agent_dockerfile_path = provider_runtime.dockerfile
|
||||
image_default = provider_runtime.image
|
||||
else:
|
||||
image_default = provider_runtime.image
|
||||
agent_image_ref = os.environ.get("CLAUDE_BOTTLE_IMAGE", image_default)
|
||||
|
||||
return SmolmachinesBottlePlan(
|
||||
spec=spec,
|
||||
@@ -164,4 +178,15 @@ def resolve_plan(
|
||||
git_gate_plan=git_gate_plan,
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
agent_command=provider_runtime.command,
|
||||
agent_prompt_mode=provider_runtime.prompt_mode,
|
||||
agent_provider_template=provider.template,
|
||||
agent_dockerfile_path=agent_dockerfile_path,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
|
||||
path = Path(os.path.expanduser(path_value))
|
||||
if not path.is_absolute():
|
||||
path = Path(spec.user_cwd) / path
|
||||
return str(path)
|
||||
|
||||
@@ -26,6 +26,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .. import supervise as _supervise
|
||||
from ..agent_provider import runtime_for
|
||||
from ..backend import (
|
||||
ActiveAgent,
|
||||
BottleSpec,
|
||||
@@ -693,7 +694,7 @@ def _stop_bottle_flow(
|
||||
return (
|
||||
f"[{slug}] not dashboard-owned — use ./cli.py cleanup"
|
||||
)
|
||||
cm, _bottle, identity = bottles.pop(slug)
|
||||
cm, bottle, identity = bottles.pop(slug)
|
||||
|
||||
def _do_teardown() -> None:
|
||||
# Best-effort snapshot before teardown so the operator
|
||||
@@ -703,7 +704,8 @@ def _stop_bottle_flow(
|
||||
# existing preserve marker (if any) is honored by
|
||||
# settle_state below.
|
||||
try:
|
||||
capture_session_state(identity, exit_code=0)
|
||||
if getattr(bottle, "agent_provider_template", "claude") == "claude":
|
||||
capture_session_state(identity, exit_code=0)
|
||||
except BaseException:
|
||||
pass
|
||||
try:
|
||||
@@ -761,21 +763,24 @@ def _in_tmux() -> bool:
|
||||
return bool(os.environ.get("TMUX"))
|
||||
|
||||
|
||||
def _claude_runtime_args(*, resume: bool, remote_control: bool = False) -> list[str]:
|
||||
def _claude_runtime_args(
|
||||
*, resume: bool, remote_control: bool = False, provider_template: str = "claude",
|
||||
) -> list[str]:
|
||||
"""The argv the dashboard hands to `bottle.claude_argv`
|
||||
on every attach — matches what `attach_claude` builds for the
|
||||
foreground handoff so both surfaces produce the same claude
|
||||
invocation."""
|
||||
args = ["--dangerously-skip-permissions"]
|
||||
runtime = runtime_for(provider_template)
|
||||
args = list(runtime.bypass_args)
|
||||
if remote_control:
|
||||
args.append("--remote-control")
|
||||
args.extend(runtime.remote_control_args)
|
||||
if resume:
|
||||
args.append("--continue")
|
||||
args.extend(runtime.resume_args)
|
||||
return args
|
||||
|
||||
|
||||
def _build_resume_argv_with_fallback(
|
||||
bottle, *, remote_control: bool = False,
|
||||
bottle, *, remote_control: bool = False, provider_template: str = "claude",
|
||||
) -> list[str]:
|
||||
"""Build a backend-exec argv that runs `claude --continue` and
|
||||
falls back to plain `claude` if no prior session exists.
|
||||
@@ -796,20 +801,34 @@ def _build_resume_argv_with_fallback(
|
||||
`smolvm machine exec --name <m> -- runuser -u node --`).
|
||||
Splitting at `claude` keeps the framing as the prefix and
|
||||
wraps just the claude tail in `sh -c`."""
|
||||
base_args = ["--dangerously-skip-permissions"]
|
||||
if remote_control:
|
||||
base_args.append("--remote-control")
|
||||
if provider_template != "claude":
|
||||
return bottle.claude_argv(
|
||||
_claude_runtime_args(
|
||||
resume=True,
|
||||
remote_control=remote_control,
|
||||
provider_template=provider_template,
|
||||
)
|
||||
)
|
||||
base_args = _claude_runtime_args(
|
||||
resume=False,
|
||||
remote_control=remote_control,
|
||||
provider_template=provider_template,
|
||||
)
|
||||
base_exec = bottle.claude_argv(base_args)
|
||||
# Split exec-framing prefix from the claude-and-args tail so
|
||||
# we can compose `<claude…> --continue || <claude…>` inside
|
||||
# `sh -c`. The `claude` token is the marker.
|
||||
claude_idx = base_exec.index("claude")
|
||||
# `sh -c`. The provider command token is the marker.
|
||||
command = getattr(bottle, "agent_command", runtime_for(provider_template).command)
|
||||
claude_idx = base_exec.index(command)
|
||||
prefix = base_exec[:claude_idx]
|
||||
claude_cmd = " ".join(shlex.quote(a) for a in base_exec[claude_idx:])
|
||||
resume_args = " ".join(
|
||||
shlex.quote(a) for a in runtime_for(provider_template).resume_args
|
||||
)
|
||||
return [
|
||||
*prefix,
|
||||
"sh", "-c",
|
||||
f"{claude_cmd} --continue || {claude_cmd}",
|
||||
f"{claude_cmd} {resume_args} || {claude_cmd}",
|
||||
]
|
||||
|
||||
|
||||
@@ -1018,8 +1037,12 @@ def _attach_via_handoff(
|
||||
`_attach_in_tmux` when tmux misbehaves)."""
|
||||
curses.endwin()
|
||||
try:
|
||||
provider_template = getattr(bottle, "agent_provider_template", "claude")
|
||||
exit_code = attach_claude(
|
||||
bottle, remote_control=False, resume=resume,
|
||||
bottle,
|
||||
remote_control=False,
|
||||
resume=resume,
|
||||
provider_template=provider_template,
|
||||
)
|
||||
except BaseException:
|
||||
stdscr.refresh()
|
||||
@@ -1049,14 +1072,21 @@ def _attach_in_tmux(
|
||||
auto-attach after a stop) leave it False so the operator
|
||||
stays in the dashboard pane."""
|
||||
if resume:
|
||||
provider_template = getattr(bottle, "agent_provider_template", "claude")
|
||||
# `--continue` exits non-zero when no prior session
|
||||
# exists (agent spun up but never typed at). Wrap with a
|
||||
# shell-level fallback so the pane lands in a fresh
|
||||
# claude instead of crashing.
|
||||
claude_argv = _build_resume_argv_with_fallback(bottle)
|
||||
claude_argv = _build_resume_argv_with_fallback(
|
||||
bottle, provider_template=provider_template,
|
||||
)
|
||||
else:
|
||||
provider_template = getattr(bottle, "agent_provider_template", "claude")
|
||||
claude_argv = bottle.claude_argv(
|
||||
_claude_runtime_args(resume=False),
|
||||
_claude_runtime_args(
|
||||
resume=False,
|
||||
provider_template=provider_template,
|
||||
),
|
||||
)
|
||||
pane_id = _ensure_right_pane(tmux_state, claude_argv)
|
||||
if pane_id is None:
|
||||
@@ -1208,8 +1238,14 @@ def _new_agent_flow(
|
||||
# Foreground handoff: claude owns the terminal until exit,
|
||||
# then we restore curses.
|
||||
try:
|
||||
exit_code = attach_claude(bottle, remote_control=False)
|
||||
capture_session_state(identity, exit_code)
|
||||
provider_template = getattr(plan, "agent_provider_template", "claude")
|
||||
exit_code = attach_claude(
|
||||
bottle,
|
||||
remote_control=False,
|
||||
provider_template=provider_template,
|
||||
)
|
||||
if provider_template == "claude":
|
||||
capture_session_state(identity, exit_code)
|
||||
finally:
|
||||
stdscr.refresh()
|
||||
return f"[{plan.slug}] claude session ended (exit {exit_code})"
|
||||
|
||||
@@ -18,6 +18,7 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from ..agent_provider import runtime_for
|
||||
from ..backend import (
|
||||
Bottle,
|
||||
BottleSpec,
|
||||
@@ -114,6 +115,7 @@ def prepare_with_preflight(
|
||||
|
||||
def attach_claude(
|
||||
bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
|
||||
provider_template: str = "claude",
|
||||
) -> int:
|
||||
"""Run claude inside `bottle` as an interactive session. Blocks
|
||||
until the session ends; returns the claude process's exit code.
|
||||
@@ -129,17 +131,16 @@ def attach_claude(
|
||||
dashboard, which calls it from inside a `curses.endwin → … →
|
||||
stdscr.refresh()` handoff so the curses surface gets out of the
|
||||
terminal's way while claude has it."""
|
||||
runtime = runtime_for(provider_template)
|
||||
info(
|
||||
"attaching interactive claude session "
|
||||
f"attaching interactive {provider_template} session "
|
||||
"(Ctrl-D or 'exit' to leave; container will be removed)"
|
||||
)
|
||||
claude_args = ["--dangerously-skip-permissions"]
|
||||
claude_args = list(runtime.bypass_args)
|
||||
if remote_control:
|
||||
claude_args.append("--remote-control")
|
||||
claude_args.extend(runtime.remote_control_args)
|
||||
if resume:
|
||||
# `--continue` jumps straight to the most recent session
|
||||
# without showing the picker `--resume` would surface.
|
||||
claude_args.append("--continue")
|
||||
claude_args.extend(runtime.resume_args)
|
||||
return bottle.exec_claude(claude_args, tty=True)
|
||||
|
||||
|
||||
@@ -217,7 +218,12 @@ def _launch_bottle(
|
||||
|
||||
backend = get_bottle_backend(backend_name)
|
||||
with backend.launch(plan) as bottle:
|
||||
exit_code = attach_claude(bottle, remote_control=remote_control)
|
||||
provider_template = getattr(plan, "agent_provider_template", "claude")
|
||||
exit_code = attach_claude(
|
||||
bottle,
|
||||
remote_control=remote_control,
|
||||
provider_template=provider_template,
|
||||
)
|
||||
info(
|
||||
f"session ended (exit {exit_code}); "
|
||||
f"container {bottle.name} will be removed"
|
||||
@@ -230,7 +236,8 @@ def _launch_bottle(
|
||||
# way. snapshot_transcript is best-effort so the
|
||||
# capability-block path's prior snapshot isn't clobbered
|
||||
# when the container is already gone.
|
||||
capture_session_state(identity, exit_code)
|
||||
if provider_template == "claude":
|
||||
capture_session_state(identity, exit_code)
|
||||
return 0
|
||||
finally:
|
||||
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
|
||||
|
||||
@@ -48,6 +48,7 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Mapping, cast
|
||||
|
||||
from .agent_provider import PROVIDER_TEMPLATES
|
||||
from .log import die, warn
|
||||
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
||||
|
||||
@@ -180,6 +181,7 @@ EGRESS_AUTH_SCHEMES = ("Bearer", "token")
|
||||
# special happens on the agent side.
|
||||
EGRESS_ROLES = frozenset({
|
||||
"claude_code_oauth",
|
||||
"codex_auth",
|
||||
})
|
||||
|
||||
# Singleton roles may appear on at most one route per bottle.
|
||||
@@ -188,8 +190,55 @@ EGRESS_ROLES = frozenset({
|
||||
# ambiguous for any future role-aware logic.
|
||||
EGRESS_SINGLETON_ROLES = frozenset({
|
||||
"claude_code_oauth",
|
||||
"codex_auth",
|
||||
})
|
||||
|
||||
PROVIDER_EGRESS_ROLES = {
|
||||
"claude": frozenset({"claude_code_oauth"}),
|
||||
"codex": frozenset({"codex_auth"}),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentProvider:
|
||||
"""Provider/template for the agent process inside a bottle.
|
||||
|
||||
`template` selects a built-in launch/runtime contract. `dockerfile`
|
||||
optionally points at a custom agent-image Dockerfile while leaving
|
||||
claude-bottle's sidecar infrastructure intact.
|
||||
"""
|
||||
|
||||
template: str = "claude"
|
||||
dockerfile: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, bottle_name: str, raw: object) -> "AgentProvider":
|
||||
d = _as_json_object(raw, f"bottle '{bottle_name}' agent_provider")
|
||||
for k in d:
|
||||
if k not in {"template", "dockerfile"}:
|
||||
die(
|
||||
f"bottle '{bottle_name}' agent_provider has unknown key {k!r}; "
|
||||
f"allowed: template, dockerfile"
|
||||
)
|
||||
template = d.get("template", "claude")
|
||||
if not isinstance(template, str) or not template:
|
||||
die(
|
||||
f"bottle '{bottle_name}' agent_provider.template must be a "
|
||||
f"non-empty string"
|
||||
)
|
||||
if template not in PROVIDER_TEMPLATES:
|
||||
die(
|
||||
f"bottle '{bottle_name}' agent_provider.template {template!r} "
|
||||
f"is not one of {', '.join(sorted(PROVIDER_TEMPLATES))}"
|
||||
)
|
||||
dockerfile = d.get("dockerfile", "")
|
||||
if not isinstance(dockerfile, str):
|
||||
die(
|
||||
f"bottle '{bottle_name}' agent_provider.dockerfile must be a "
|
||||
f"string (was {type(dockerfile).__name__})"
|
||||
)
|
||||
return cls(template=template, dockerfile=dockerfile)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitUser:
|
||||
@@ -428,7 +477,9 @@ class EgressConfig:
|
||||
routes: tuple[EgressRoute, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, bottle_name: str, raw: object) -> "EgressConfig":
|
||||
def from_dict(
|
||||
cls, bottle_name: str, raw: object, *, provider_template: str = "claude",
|
||||
) -> "EgressConfig":
|
||||
d = _as_json_object(raw, f"bottle '{bottle_name}' egress")
|
||||
routes_raw = d.get("routes")
|
||||
routes: tuple[EgressRoute, ...] = ()
|
||||
@@ -443,7 +494,9 @@ class EgressConfig:
|
||||
EgressRoute.from_dict(bottle_name, i, entry)
|
||||
for i, entry in enumerate(routes_list)
|
||||
)
|
||||
_validate_egress_routes(bottle_name, routes)
|
||||
_validate_egress_routes(
|
||||
bottle_name, routes, provider_template=provider_template,
|
||||
)
|
||||
for k in d:
|
||||
if k != "routes":
|
||||
die(
|
||||
@@ -456,6 +509,7 @@ class EgressConfig:
|
||||
@dataclass(frozen=True)
|
||||
class Bottle:
|
||||
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
||||
agent_provider: AgentProvider = field(default_factory=AgentProvider)
|
||||
git: tuple[GitEntry, ...] = ()
|
||||
# Per-bottle git identity (issue #86). Empty default — bottles
|
||||
# that don't set `git.user:` in the manifest skip the
|
||||
@@ -526,8 +580,17 @@ class Bottle:
|
||||
if git_raw is not None:
|
||||
git, git_user = _parse_git_config(name, git_raw)
|
||||
|
||||
agent_provider = (
|
||||
AgentProvider.from_dict(name, d["agent_provider"])
|
||||
if "agent_provider" in d
|
||||
else AgentProvider()
|
||||
)
|
||||
|
||||
egress = (
|
||||
EgressConfig.from_dict(name, d["egress"])
|
||||
EgressConfig.from_dict(
|
||||
name, d["egress"],
|
||||
provider_template=agent_provider.template,
|
||||
)
|
||||
if "egress" in d
|
||||
else EgressConfig()
|
||||
)
|
||||
@@ -540,8 +603,8 @@ class Bottle:
|
||||
)
|
||||
|
||||
return cls(
|
||||
env=env, git=git, git_user=git_user, egress=egress,
|
||||
supervise=supervise_raw,
|
||||
env=env, agent_provider=agent_provider, git=git,
|
||||
git_user=git_user, egress=egress, supervise=supervise_raw,
|
||||
)
|
||||
|
||||
|
||||
@@ -823,6 +886,8 @@ def _parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
|
||||
def _validate_egress_routes(
|
||||
bottle_name: str,
|
||||
routes: tuple[EgressRoute, ...],
|
||||
*,
|
||||
provider_template: str = "claude",
|
||||
) -> None:
|
||||
"""Cross-validation for `bottle.egress.routes`:
|
||||
|
||||
@@ -854,6 +919,16 @@ def _validate_egress_routes(
|
||||
f"routes with role {role!r} (hosts: {hosts}); this role drives a "
|
||||
f"single launch-step side effect — pick one."
|
||||
)
|
||||
allowed_roles = PROVIDER_EGRESS_ROLES[provider_template]
|
||||
for route in routes:
|
||||
for role in route.Role:
|
||||
if role not in allowed_roles:
|
||||
die(
|
||||
f"bottle '{bottle_name}' egress route for host "
|
||||
f"{route.Host!r} has role {role!r}, but provider "
|
||||
f"{provider_template!r} only accepts roles "
|
||||
f"{', '.join(sorted(allowed_roles)) or '(none)'}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None:
|
||||
@@ -881,7 +956,7 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
|
||||
# sets dies with a "did you mean" pointer — typos shouldn't silently
|
||||
# ghost into an empty config.
|
||||
_BOTTLE_KEYS = frozenset(
|
||||
{"env", "extends", "git", "egress", "supervise"}
|
||||
{"env", "extends", "agent_provider", "git", "egress", "supervise"}
|
||||
)
|
||||
_AGENT_KEYS_REQUIRED = frozenset({"bottle"})
|
||||
_AGENT_KEYS_OPTIONAL = frozenset({"skills"})
|
||||
@@ -1056,12 +1131,22 @@ def _merge_bottles(
|
||||
# Presence-driven full-replace for the remaining list-valued +
|
||||
# scalar fields.
|
||||
merged_egress = child.egress if "egress" in child_raw else parent.egress
|
||||
merged_agent_provider = (
|
||||
child.agent_provider
|
||||
if "agent_provider" in child_raw
|
||||
else parent.agent_provider
|
||||
)
|
||||
merged_supervise = (
|
||||
child.supervise if "supervise" in child_raw else parent.supervise
|
||||
)
|
||||
_validate_egress_routes(
|
||||
name, merged_egress.routes,
|
||||
provider_template=merged_agent_provider.template,
|
||||
)
|
||||
|
||||
return Bottle(
|
||||
env=merged_env,
|
||||
agent_provider=merged_agent_provider,
|
||||
git=merged_git,
|
||||
git_user=merged_git_user,
|
||||
egress=merged_egress,
|
||||
|
||||
Reference in New Issue
Block a user