PRD 0050: Move provider-specific agent logic into contrib #180

Merged
didericis merged 7 commits from prd-0050-agent-provider-contrib into main 2026-06-03 21:38:14 -04:00
33 changed files with 1677 additions and 1106 deletions
+107 -134
View File
@@ -3,18 +3,32 @@
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.
Per PRD 0050 the per-provider implementations live under
`bot_bottle/contrib/<template>/agent_provider.py`. This module exposes:
- `AgentProvider` (ABC) — the contract each plugin implements.
- `get_provider(template)` — lazy-imported registry; the analogue
of `bot_bottle/deploy_key_provisioner.get_provisioner`.
- `AgentProvisionPlan` (+ helper dataclasses) — declarative shape
each provider produces and the backends consume unchanged.
- `agent_provision_plan` / `runtime_for` — thin wrappers around the
registry kept so existing callers keep working without per-call
edits.
"""
from __future__ import annotations
import json
import os
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from typing import TYPE_CHECKING, Literal
from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file
from .egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
from .egress import EgressRoute
if TYPE_CHECKING:
from .backend import Bottle, BottlePlan
PROVIDER_CLAUDE = "claude"
1
@@ -96,35 +110,88 @@ class AgentProvisionPlan:
provisioned_env: dict[str, str] = field(default_factory=dict)
_REPO_ROOT = Path(__file__).resolve().parent.parent
class AgentProvider(ABC):
"""Per-template plugin: produces the provision plan and applies
the provider-specific in-guest setup steps (skills, prompt, the
declarative `dirs`/`files`/`pre_copy`/`verify` apply loop, and
supervise MCP registration). Concrete subclasses live under
`bot_bottle/contrib/<template>/agent_provider.py`."""
@property
@abstractmethod
def runtime(self) -> AgentProviderRuntime:
"""The static command / image / prompt-mode table for this
template."""
@abstractmethod
def provision_plan(
self,
*,
dockerfile: str,
state_dir: Path,
guest_home: str,
guest_env: dict[str, str] | None = None,
auth_token: str = "",
forward_host_credentials: bool = False,
host_env: dict[str, str] | None = None,
trusted_project_path: str = "",
) -> AgentProvisionPlan:
"""Build the declarative AgentProvisionPlan for one launch.
Backends call this during `prepare` and consume the result as
before."""
@abstractmethod
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
"""Copy each of the agent's named skills from the host into
the guest. No-op when the agent has no skills. The in-guest
layout is provider-specific (claude-code's
`~/.claude/skills/` today; future providers may differ)."""
@abstractmethod
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
"""Copy the prompt file into the guest, fix ownership/mode,
and return the in-guest path iff the agent has a non-empty
prompt (drives the `--append-system-prompt-file` flag).
The file is copied either way so the path always exists."""
@abstractmethod
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
"""Apply the provider's declarative
`dirs`/`pre_copy`/`files`/`verify` steps from
`plan.agent_provision`. Was called `provision_provider_auth`
on `BottleBackend` before PRD 0050."""
@abstractmethod
def provision_supervise_mcp(
self,
plan: "BottlePlan",
bottle: "Bottle",
supervise_url: str,
) -> None:
"""Register the per-bottle supervise sidecar as an MCP server
in the provider's in-guest config. Called by the backend after
the supervise sidecar is reachable. No-op when
`plan.supervise_plan is None`."""
_RUNTIMES = {
PROVIDER_CLAUDE: AgentProviderRuntime(
template=PROVIDER_CLAUDE,
command="claude",
image="bot-bottle-claude:latest",
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
prompt_mode="append_file",
bypass_args=("--dangerously-skip-permissions",),
resume_args=("--continue",),
remote_control_args=("--remote-control",),
),
PROVIDER_CODEX: AgentProviderRuntime(
template=PROVIDER_CODEX,
command="codex",
image="bot-bottle-codex:latest",
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
prompt_mode="read_prompt_file",
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
resume_args=("resume", "--last"),
remote_control_args=(),
),
}
def get_provider(template: str) -> AgentProvider:
"""Resolve a provider template name to its plugin instance.
Lazy-imports the contrib module so importing this module doesn't
pull provider-specific code paths in. Mirrors the contrib
convention PRD 0048 established for deploy key provisioners."""
if template == PROVIDER_CLAUDE:
from .contrib.claude.agent_provider import ClaudeAgentProvider
return ClaudeAgentProvider()
if template == PROVIDER_CODEX:
from .contrib.codex.agent_provider import CodexAgentProvider
return CodexAgentProvider()
raise ValueError(f"unknown agent provider template: {template!r}")
def runtime_for(template: str) -> AgentProviderRuntime:
return _RUNTIMES[template]
return get_provider(template).runtime
def agent_provision_plan(
@@ -132,118 +199,24 @@ def agent_provision_plan(
template: str,
dockerfile: str,
state_dir: Path,
guest_home: str = "/home/node",
guest_home: str,
guest_env: dict[str, str] | None = None,
auth_token: str = "",
forward_host_credentials: bool = False,
host_env: dict[str, str] | None = None,
trusted_project_path: str = "",
) -> AgentProvisionPlan:
runtime = runtime_for(template)
resolved_guest_env = dict(guest_env or {})
trusted_path = trusted_project_path or guest_home
env_vars: dict[str, str] = {}
provisioned_env: dict[str, str] = {}
dirs: list[AgentProvisionDir] = []
files: list[AgentProvisionFile] = []
pre_copy: list[AgentProvisionCommand] = []
verify: list[AgentProvisionCommand] = []
egress_routes: list[EgressRoute] = []
hidden_env_names: frozenset[str] = frozenset()
if template == PROVIDER_CODEX:
env_vars["CODEX_CA_CERTIFICATE"] = "/etc/ssl/certs/ca-certificates.crt"
auth_dir = resolved_guest_env.get("CODEX_HOME", f"{guest_home}/.codex")
if forward_host_credentials:
env_vars["CODEX_HOME"] = auth_dir
dirs.append(AgentProvisionDir(auth_dir))
config_path = f"{auth_dir}/config.toml"
config_file = state_dir / "codex-config.toml"
toml_path = trusted_path.replace("\\", "\\\\").replace('"', '\\"')
config_file.write_text(
f'[projects."{toml_path}"]\n'
'trust_level = "trusted"\n'
)
config_file.chmod(0o600)
files.append(AgentProvisionFile(config_file, config_path))
for host in CODEX_HOST_CREDENTIAL_HOSTS:
egress_routes.append(EgressRoute(
host=host,
auth_scheme="Bearer" if forward_host_credentials else "",
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials else "",
tls_passthrough=True,
))
if forward_host_credentials:
_host_env = host_env or dict(os.environ)
provisioned_env[CODEX_HOST_CREDENTIAL_TOKEN_REF] = codex_host_access_token(
_host_env,
)
auth_file = state_dir / "codex-auth.json"
write_codex_dummy_auth_file(auth_file, _host_env)
files.append(AgentProvisionFile(auth_file, f"{auth_dir}/auth.json"))
pre_copy.append(AgentProvisionCommand((
"find", auth_dir,
"-maxdepth", "1",
"-type", "f",
"(",
"-name", "*.sqlite",
"-o", "-name", "*.sqlite-*",
"-o", "-name", "*.codex-repair-*.bak",
")",
"-delete",
), "codex host credentials: could not reset runtime db files"))
verify.append(AgentProvisionCommand((
"runuser", "-u", "node", "--",
"env",
f"HOME={guest_home}",
f"CODEX_HOME={auth_dir}",
"codex", "login", "status",
), (
"codex host credentials: dummy auth was copied into the "
"guest, but Codex did not accept it"
)))
if template == PROVIDER_CLAUDE:
env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"
env_vars["DISABLE_ERROR_REPORTING"] = "1"
claude_config = state_dir / "claude.json"
claude_projects = {
guest_home: {"hasTrustDialogAccepted": True},
}
claude_projects[trusted_path] = {"hasTrustDialogAccepted": True}
claude_config.write_text(json.dumps({
"hasCompletedOnboarding": True,
"theme": "dark",
"bypassPermissionsModeAccepted": True,
"projects": claude_projects,
}, indent=2) + "\n")
claude_config.chmod(0o600)
files.append(AgentProvisionFile(claude_config, f"{guest_home}/.claude.json"))
egress_routes.append(EgressRoute(
host="api.anthropic.com",
auth_scheme="Bearer" if auth_token else "",
token_ref=auth_token,
tls_passthrough=True,
))
if auth_token:
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
return AgentProvisionPlan(
template=template,
command=runtime.command,
prompt_mode=runtime.prompt_mode,
image=runtime.image,
"""Back-compat shim — `prepare` callers stay the same; the work
now lives on the provider plugin."""
return get_provider(template).provision_plan(
dockerfile=dockerfile,
env_vars=env_vars,
guest_env=resolved_guest_env,
dirs=tuple(dirs),
files=tuple(files),
pre_copy=tuple(pre_copy),
verify=tuple(verify),
egress_routes=tuple(egress_routes),
hidden_env_names=hidden_env_names,
provisioned_env=provisioned_env,
state_dir=state_dir,
guest_home=guest_home,
guest_env=guest_env,
auth_token=auth_token,
forward_host_credentials=forward_host_credentials,
host_env=host_env,
trusted_project_path=trusted_project_path,
)
+30 -33
View File
@@ -39,7 +39,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan
from ..agent_provider import AgentProvisionPlan, get_provider
from ..egress import EgressPlan
from ..git_gate import GitGatePlan
from ..log import die, info
@@ -76,6 +76,7 @@ class BottlePlan(ABC):
spec: BottleSpec
stage_dir: Path
guest_home: str
git_gate_plan: GitGatePlan
egress_plan: EgressPlan
supervise_plan: SupervisePlan | None
@@ -320,24 +321,33 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
to decide whether to add provider-specific prompt args to the
agent's argv.
Default orchestration: ca → prompt → skills → workspace → git →
supervise. CA install runs first so the agent's trust store
is rebuilt before anything inside the agent makes a TLS call.
Subclasses typically don't override this; they implement the
sub-methods below.
Default orchestration: ca → prompt → provider apply → skills
→ workspace → git → supervise-mcp. CA install runs first so
the agent's trust store is rebuilt before anything inside the
agent makes a TLS call.
Per PRD 0050 the per-provider steps (prompt, skills,
declarative provision-plan apply, supervise MCP registration)
live on the `AgentProvider` plugin. The backend only owns the
steps that are about backend infrastructure (CA, workspace,
git) and surfaces the supervise sidecar URL its launch step
knows about via `supervise_mcp_url`.
PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc,
~/.gitconfig insteadOf, tea config) are gone. Egress-proxy is
on the agent's HTTP_PROXY path so every tool that respects
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
intercepted without per-tool reconfiguration."""
provider = get_provider(plan.agent_provision.template)
self.provision_ca(plan, bottle)
prompt_path = self.provision_prompt(plan, bottle)
self.provision_provider_auth(plan, bottle)
self.provision_skills(plan, bottle)
prompt_path = provider.provision_prompt(plan, bottle)
provider.provision(plan, bottle)
provider.provision_skills(plan, bottle)
self.provision_workspace(plan, bottle)
self.provision_git(plan, bottle)
self.provision_supervise(plan, bottle)
provider.provision_supervise_mcp(
plan, bottle, self.supervise_mcp_url(plan),
)
return prompt_path
def provision_ca(self, plan: PlanT, bottle: "Bottle") -> None:
@@ -349,23 +359,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
backend overrides to docker-cp the cert in and run
`update-ca-certificates`."""
def provision_provider_auth(self, plan: PlanT, bottle: "Bottle") -> None:
"""Install non-secret provider auth marker files into the agent
home when a provider needs them to select the right auth mode.
The default is no-op."""
@abstractmethod
def provision_prompt(self, plan: PlanT, bottle: "Bottle") -> str | None:
"""Copy the prompt file into the running bottle. Returns the
in-container path iff the agent has a non-empty prompt;
callers use the return value to decide whether to add
provider-specific prompt args to the agent's argv."""
@abstractmethod
def provision_skills(self, plan: PlanT, bottle: "Bottle") -> None:
"""Copy the agent's named skills from the host into the
running bottle. No-op when the agent has no skills."""
def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None:
"""Copy the operator workspace into the running bottle when
the backend cannot bake it into the agent image. Default is
@@ -376,12 +369,16 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""Copy the host's cwd `.git` directory into the running
bottle if the user requested --cwd. No-op otherwise."""
def provision_supervise(self, plan: PlanT, bottle: "Bottle") -> None:
"""Write the in-bottle Claude Code MCP config so the agent
discovers the per-bottle supervise sidecar (PRD 0013).
No-op when bottle.supervise is False or the backend doesn't
support the supervise sidecar yet. The Docker backend
overrides."""
def supervise_mcp_url(self, plan: PlanT) -> str:
"""Return the agent-side URL of the per-bottle supervise
sidecar, or "" when this bottle has no sidecar. The provider
plugin's `provision_supervise_mcp` uses it to register the
MCP entry inside the guest.
Default returns "" so backends without supervise support
don't have to implement it. Docker and smolmachines override."""
del plan
return ""
@abstractmethod
def prepare_cleanup(self) -> CleanupT:
+14 -15
View File
@@ -9,6 +9,12 @@ This module is a thin façade. The real work lives in four siblings:
The base class's `prepare` template runs cross-backend host-side
validation before calling `_resolve_plan` here.
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
the declarative provision-plan apply, supervise MCP registration)
live on the `AgentProvider` plugin under `bot_bottle/contrib/`. The
Docker backend only owns the steps that are about backend
infrastructure: CA install and git copy-in.
"""
from __future__ import annotations
@@ -18,6 +24,7 @@ from contextlib import contextmanager
from pathlib import Path
from typing import Generator, Sequence
from ...supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec
from . import cleanup as _cleanup
from . import enumerate as _enumerate
@@ -28,10 +35,6 @@ from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan
from .provision import ca as _ca
from .provision import git as _git
from .provision import prompt as _prompt
from .provision import provider_auth as _provider_auth
from .provision import skills as _skills
from .provision import supervise as _supervise_prov
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
@@ -60,20 +63,16 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
def provision_ca(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
_ca.provision_ca(plan, bottle)
def provision_prompt(self, plan: DockerBottlePlan, bottle: Bottle) -> str | None:
return _prompt.provision_prompt(plan, bottle)
def provision_provider_auth(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
_provider_auth.provision_provider_auth(plan, bottle)
def provision_skills(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
_skills.provision_skills(plan, bottle)
def provision_git(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
_git.provision_git(plan, bottle)
def provision_supervise(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
_supervise_prov.provision_supervise(plan, bottle)
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
"""Docker bottles reach the supervise sidecar via the
compose-network alias `supervise:9100`. No per-bottle URL
plumbing needed; the alias resolves inside the bridge."""
if plan.supervise_plan is None:
return ""
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
return _cleanup.prepare_cleanup()
+2 -1
View File
@@ -63,7 +63,7 @@ def resolve_plan(
bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider
provider_runtime = runtime_for(provider.template)
guest_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
guest_home = "/home/node"
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
# PRD 0016 follow-up: identity, not bare slug. A fresh `start`
@@ -233,6 +233,7 @@ def resolve_plan(
return DockerBottlePlan(
spec=spec,
stage_dir=stage_dir,
guest_home=guest_home,
slug=slug,
container_name=container_name,
container_name_pinned=container_name_pinned,
@@ -1,8 +1,11 @@
"""Per-provisioner modules for the Docker backend.
"""Backend-infrastructure provisioners for the Docker backend.
Each module exports one top-level function:
provision_<thing>(plan: DockerBottlePlan, bottle: Bottle) -> ...
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
declarative provision-plan apply, supervise MCP registration) live on
the `AgentProvider` plugin under `bot_bottle/contrib/`. The modules
left in this subpackage handle only the steps that are
backend-specific:
`DockerBottleBackend.provision_*` methods delegate to these. The
abstract `BottleBackend.provision_*` surface is unchanged; this
subpackage exists only to keep `backend.py` from being a god-file."""
- ca.py — install per-bottle CA bundle into the guest trust store
- git.py — copy host cwd `.git` into the guest when --cwd is used
"""
+1 -3
View File
@@ -18,7 +18,6 @@ Three concerns, all about git in the agent:
from __future__ import annotations
import os
import shlex
from ....git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
@@ -58,8 +57,7 @@ def _provision_git_gate_config(plan: DockerBottlePlan, bottle: Bottle) -> None:
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if not manifest_bottle.git:
return
didericis marked this conversation as resolved Outdated
Outdated
Review

You were right — workspace_plan was the only place guest_home lived, and reading it through plan.workspace_plan.guest_home was the wrong layer. Hoisted in 21a46b97d8: guest_home: str is now a field on the base BottlePlan dataclass, populated by each backend's prepare step. The contrib providers, this gitconfig path, and the smolmachines _guest_home() helper all read plan.guest_home directly.

You were right — workspace_plan was the only place `guest_home` lived, and reading it through `plan.workspace_plan.guest_home` was the wrong layer. Hoisted in 21a46b97d8f35734fa23165ee2b94d1268ddb8d3: `guest_home: str` is now a field on the base `BottlePlan` dataclass, populated by each backend's `prepare` step. The contrib providers, this gitconfig path, and the smolmachines `_guest_home()` helper all read `plan.guest_home` directly.
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
container_gitconfig = f"{container_home}/.gitconfig"
container_gitconfig = f"{plan.guest_home}/.gitconfig"
didericis marked this conversation as resolved Outdated
Outdated
Review

/home/node should be coming from the bottle plan... isn't this already there somewhere/don't we already need this to create the workspace? Regardless, it should be somewhere higher up in the plan.

`/home/node` should be coming from the bottle plan... isn't this already there somewhere/don't we already need this to create the workspace? Regardless, it should be somewhere higher up in the plan.
content = git_gate_render_gitconfig(manifest_bottle.git, GIT_GATE_HOSTNAME)
config_file = plan.stage_dir / "agent_gitconfig"
@@ -1,33 +0,0 @@
"""Copy the agent prompt into a running Docker bottle.
The prompt file is always copied (so the in-container path always
exists) but `--append-system-prompt-file` only fires when the agent
actually has a prompt — the return value signals which case."""
from __future__ import annotations
import os
from ... import Bottle
from ..bottle_plan import DockerBottlePlan
def provision_prompt(plan: DockerBottlePlan, bottle: Bottle) -> str | None:
"""Copy the prompt file into the container, fix ownership/mode.
Returns the in-container path if the agent has a non-empty
prompt (drives --append-system-prompt-file), else None. The
file is copied either way so the path always exists."""
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
in_container_prompt_path = f"{container_home}/.bot-bottle-prompt.txt"
bottle.cp_in(str(plan.prompt_file), in_container_prompt_path)
# `docker cp` preserves host UID; re-own/mode as root so node
# can read its own mode-600 prompt regardless of host UID.
bottle.exec(
f"chown node:node {in_container_prompt_path} && "
f"chmod 600 {in_container_prompt_path}",
user="root",
)
agent = plan.spec.manifest.agents[plan.spec.agent_name]
return in_container_prompt_path if agent.prompt else None
@@ -1,35 +0,0 @@
"""Provision non-secret provider auth markers into a Docker bottle."""
from __future__ import annotations
import shlex
from ....log import die
from ... import Bottle
from ..bottle_plan import DockerBottlePlan
def provision_provider_auth(plan: DockerBottlePlan, bottle: Bottle) -> None:
"""Apply provider-owned guest setup through the bottle's exec / cp_in."""
provision = plan.agent_provision
for d in provision.dirs:
_exec(bottle, f"mkdir -p {shlex.quote(d.guest_path)}", d.guest_path)
_exec(bottle, f"chown {shlex.quote(d.owner)} {shlex.quote(d.guest_path)}", d.guest_path)
_exec(bottle, f"chmod {shlex.quote(d.mode)} {shlex.quote(d.guest_path)}", d.guest_path)
for command in provision.pre_copy:
_exec(bottle, shlex.join(command.argv), command.error)
for f in provision.files:
bottle.cp_in(str(f.host_path), f.guest_path)
_exec(bottle, f"chown {shlex.quote(f.owner)} {shlex.quote(f.guest_path)}", f.guest_path)
_exec(bottle, f"chmod {shlex.quote(f.mode)} {shlex.quote(f.guest_path)}", f.guest_path)
for command in provision.verify:
_exec(bottle, shlex.join(command.argv), command.error)
def _exec(bottle: Bottle, script: str, error: str) -> None:
result = bottle.exec(script, user="root")
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
if detail:
detail = f": {detail}"
die(f"agent provider provisioning: {error}{detail}")
@@ -1,44 +0,0 @@
"""Copy host-side skill directories into a running Docker bottle.
Skills are validated on the host before launch by the base class's
`BottleBackend._validate_skills` (called from `prepare`); this module
assumes that validation has already run. A skill disappearing between
validation and copy still dies loudly rather than silently producing
a partial container."""
from __future__ import annotations
import os
from ....log import die, info
from ...util import host_skill_dir
from ... import Bottle
from ..bottle_plan import DockerBottlePlan
def provision_skills(plan: DockerBottlePlan, bottle: Bottle) -> None:
"""Copy each of the agent's named skills from the host's
~/.claude/skills/<name>/ into the container's equivalent path.
For each skill: ensure parent dir, wipe any prior copy, then
`cp_in <host>/. <container>:<dst>/` so the contents are
copied into a freshly-created destination dir. No-op when the
agent has no skills."""
agent = plan.spec.manifest.agents[plan.spec.agent_name]
if not agent.skills:
return
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
skills_dir = os.environ.get(
"BOT_BOTTLE_CONTAINER_SKILLS_DIR", f"{container_home}/.claude/skills"
)
bottle.exec(f"mkdir -p {skills_dir}", user="node")
for n in agent.skills:
src = host_skill_dir(n)
if not os.path.isdir(src):
die(f"skill '{n}' disappeared from host between validation and copy at {src}.")
dst = f"{skills_dir}/{n}"
info(f"copying skill {n} into {bottle.name}:{dst}")
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="node")
bottle.cp_in(f"{src}/.", f"{dst}/")
@@ -1,59 +0,0 @@
"""Supervise sidecar provisioning inside a running Docker bottle
(PRD 0013).
Registers the per-bottle supervise sidecar as an HTTP MCP server in
the agent's claude-code config so the agent discovers the three
stuck-recovery MCP tools (cred-proxy-block, pipelock-block,
capability-block) at startup.
Uses `claude mcp add` rather than writing JSON directly. claude-code
owns the on-disk config format (`~/.claude.json` `mcpServers` shape,
field names, scope semantics) and changes it between versions; the
official command handles whatever the installed version expects.
No-op when bottle.supervise is False — bottles that haven't opted
into the supervise sidecar shouldn't get an MCP entry pointing at a
sidecar that isn't running.
"""
from __future__ import annotations
from ....log import info, warn
from ....supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
from ... import Bottle
from ..bottle_plan import DockerBottlePlan
_SUPERVISE_MCP_NAME = "supervise"
def supervise_mcp_url() -> str:
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
def provision_supervise(plan: DockerBottlePlan, bottle: Bottle) -> None:
"""Run `claude mcp add` inside the agent container to register
the supervise sidecar in claude-code's user config. No-op when
bottle.supervise is False.
Failure is logged but not fatal: the bottle still works (you
just can't call supervise tools from the agent until the entry
is added manually). The operator sees the warning at launch."""
if plan.supervise_plan is None:
return
url = supervise_mcp_url()
info(f"registering supervise MCP server in agent claude config → {url}")
r = bottle.exec(
f"claude mcp add --scope user --transport http {_SUPERVISE_MCP_NAME} {url}",
user="node",
)
if r.returncode != 0:
warn(
f"`claude mcp add supervise` failed (exit {r.returncode}): "
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
f"register manually with: "
f"claude mcp add --scope user --transport http supervise {url}"
)
__all__ = ["provision_supervise", "supervise_mcp_url"]
+13 -24
View File
@@ -1,5 +1,11 @@
"""SmolmachinesBottleBackend — the smolmachines implementation of
BottleBackend (PRD 0023)."""
BottleBackend (PRD 0023).
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
the declarative provision-plan apply, supervise MCP registration)
live on the `AgentProvider` plugin under `bot_bottle/contrib/`. The
smolmachines backend only owns the steps that are about backend
infrastructure: CA install (no-op for now), workspace, git copy-in."""
from __future__ import annotations
@@ -18,10 +24,6 @@ from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
from .bottle_plan import SmolmachinesBottlePlan
from .provision import ca as _ca
from .provision import git as _git
from .provision import prompt as _prompt
from .provision import provider_auth as _provider_auth
from .provision import skills as _skills
from .provision import supervise as _supervise
from .provision import workspace as _workspace
@@ -58,21 +60,6 @@ class SmolmachinesBottleBackend(
) -> None:
_ca.provision_ca(plan, bottle)
def provision_prompt(
self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> str | None:
return _prompt.provision_prompt(plan, bottle)
def provision_provider_auth(
self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None:
_provider_auth.provision_provider_auth(plan, bottle)
def provision_skills(
self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None:
_skills.provision_skills(plan, bottle)
def provision_workspace(
self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None:
@@ -83,10 +70,12 @@ class SmolmachinesBottleBackend(
) -> None:
_git.provision_git(plan, bottle)
def provision_supervise(
self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None:
_supervise.provision_supervise(plan, bottle)
def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str:
"""The smolmachines guest reaches the supervise sidecar via a
host-published random port the launch step pinned earlier
(`http://<loopback_ip>:<random_port>/`). `agent_supervise_url`
on the plan is "" when the bottle has no sidecar."""
return plan.agent_supervise_url
def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan:
return _cleanup.prepare_cleanup()
+2 -1
View File
@@ -61,7 +61,7 @@ def resolve_plan(
bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider
provider_runtime = runtime_for(provider.template)
guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", "/home/node")
guest_home = "/home/node"
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
slug = spec.identity or bottle_identity(spec.agent_name)
@@ -172,6 +172,7 @@ def resolve_plan(
return SmolmachinesBottlePlan(
spec=spec,
stage_dir=stage_dir,
guest_home=guest_home,
slug=slug,
bundle_subnet=subnet,
bundle_gateway=gateway,
@@ -1,14 +1,12 @@
"""Provisioning helpers for the smolmachines backend (PRD 0023
chunk 4).
"""Backend-infrastructure provisioners for the smolmachines backend.
Each method maps onto one of `BottleBackend`'s `provision_*`
overrides. They run after the VM is up + the bundle is reachable
and copy host-side state (prompt, skills, .git, CA cert,
supervise MCP config) into the guest via `smolvm machine cp` /
`smolvm machine exec`.
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
declarative provision-plan apply, supervise MCP registration) live on
the `AgentProvider` plugin under `bot_bottle/contrib/`. The modules
left in this subpackage handle only the steps that are
backend-specific:
Chunk 4a ships `provision_prompt` and `provision_skills` the
two that don't depend on agent-image tooling (claude-code,
update-ca-certificates) beyond `cp` and `mkdir`. provision_ca /
provision_git / provision_supervise land once the agent-image
gap is solved."""
- ca.py install per-bottle CA bundle into the guest trust store
- git.py copy host cwd `.git` into the guest when --cwd is used
- workspace.py copy the operator workspace into the guest
"""
@@ -36,17 +36,6 @@ from ... import Bottle
from ..bottle_plan import SmolmachinesBottlePlan
# `node` is the agent user from the repo Dockerfile. Override via
# BOT_BOTTLE_GUEST_HOME mirrors the docker backend's
# BOT_BOTTLE_CONTAINER_HOME knob — same purpose, different
# transport.
_DEFAULT_GUEST_HOME = "/home/node"
def _guest_home() -> str:
return os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
def provision_git(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
"""Set up git inside the guest. Runs all three subcases; each
no-ops when its condition isn't met."""
@@ -95,7 +84,7 @@ def _provision_git_gate_config(
manifest_bottle.git, plan.agent_git_gate_host, scheme="http",
)
guest_gitconfig = f"{_guest_home()}/.gitconfig"
guest_gitconfig = f"{plan.guest_home}/.gitconfig"
# Stage the file under the plan's stage_dir so cp_in
# has a stable host path. The plan's stage_dir is cleaned up
# by start.py's session-end teardown.
@@ -1,44 +0,0 @@
"""Copy the agent prompt into a running smolmachines bottle.
The prompt file is always copied (so the in-guest path always
exists) but `--append-system-prompt-file` only fires when the
agent actually has a prompt the return value signals which
case, mirroring the docker backend's contract.
cp_in lands files as root inside the VM; the claude
process runs as `node`, so we chown + chmod the prompt after the
copy. Same flow as the docker backend's provision_prompt."""
from __future__ import annotations
import os
from ... import Bottle
from ..bottle_plan import SmolmachinesBottlePlan
# `node` is the agent user from the repo Dockerfile.
# BOT_BOTTLE_GUEST_HOME mirrors the docker backend's
# BOT_BOTTLE_CONTAINER_HOME knob.
_DEFAULT_GUEST_HOME = "/home/node"
def provision_prompt(plan: SmolmachinesBottlePlan, bottle: Bottle) -> str | None:
"""Copy the prompt file into the running smolvm guest, fix
ownership/mode. Returns the in-guest path if the agent has a
non-empty prompt (drives --append-system-prompt-file), else
None. The file is copied either way so the path always
exists mirrors the docker backend's behavior."""
guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
in_guest_prompt_path = f"{guest_home}/.bot-bottle-prompt.txt"
bottle.cp_in(str(plan.prompt_file), in_guest_prompt_path)
# cp_in lands as root, source's 0o600 mode is preserved —
# node can't read its own prompt without these two.
bottle.exec(
f"chown node:node {in_guest_prompt_path} && chmod 600 {in_guest_prompt_path}",
user="root",
)
agent = plan.spec.manifest.agents[plan.spec.agent_name]
return in_guest_prompt_path if agent.prompt else None
@@ -1,35 +0,0 @@
"""Provision non-secret provider auth markers into a smolmachines bottle."""
from __future__ import annotations
import shlex
from ....log import die
from ... import Bottle
from ..bottle_plan import SmolmachinesBottlePlan
def provision_provider_auth(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
"""Apply provider-owned guest setup through the bottle's exec / cp_in."""
provision = plan.agent_provision
for d in provision.dirs:
_exec(bottle, f"mkdir -p {shlex.quote(d.guest_path)}", f"could not create {d.guest_path}")
_exec(bottle, f"chown {shlex.quote(d.owner)} {shlex.quote(d.guest_path)}", f"could not chown {d.guest_path}")
_exec(bottle, f"chmod {shlex.quote(d.mode)} {shlex.quote(d.guest_path)}", f"could not chmod {d.guest_path}")
for command in provision.pre_copy:
_exec(bottle, shlex.join(command.argv), command.error)
for f in provision.files:
bottle.cp_in(str(f.host_path), f.guest_path)
_exec(bottle, f"chown {shlex.quote(f.owner)} {shlex.quote(f.guest_path)}", f"could not chown {f.guest_path}")
_exec(bottle, f"chmod {shlex.quote(f.mode)} {shlex.quote(f.guest_path)}", f"could not chmod {f.guest_path}")
for command in provision.verify:
_exec(bottle, shlex.join(command.argv), command.error)
def _exec(bottle: Bottle, script: str, error: str) -> None:
result = bottle.exec(script, user="root")
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
if detail:
detail = f": {detail}"
die(f"agent provider provisioning: {error}{detail}")
@@ -1,63 +0,0 @@
"""Copy host-side skill directories into a running smolmachines
bottle.
Skills are validated on the host before launch by
`BottleBackend._validate_skills`; this module assumes that
validation has already run. A skill that disappears between
validation and copy still dies loudly rather than silently
producing a partial guest."""
from __future__ import annotations
import os
from ....log import die, info
from ...util import host_skill_dir
from ... import Bottle
from ..bottle_plan import SmolmachinesBottlePlan
# In-guest path mirrors the docker backend's claude-skills
# convention (~/.claude/skills/<name>/) under the node user's
# home — same path as the real bot-bottle image's
# /home/node/.claude/skills (pre-created in the Dockerfile).
_DEFAULT_SKILLS_DIR = "/home/node/.claude/skills"
def provision_skills(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
"""Copy each of the agent's named skills from the host's
~/.claude/skills/<name>/ into the guest's equivalent path.
For each skill: `mkdir -p` the destination, cp_in the host
source dir over, then chown the result to node:node so the
agent can read it. No-op when the agent has no skills.
cp_in on a directory copies recursively; unlike docker cp's
trailing-slash convention, smolvm doesn't need the `/.` suffix
dance.
cp_in lands files as root inside the VM, so we chown each
skill tree over to node:node after the copy same pattern as
the docker backend's provision_prompt."""
agent = plan.spec.manifest.agents[plan.spec.agent_name]
if not agent.skills:
return
skills_dir = os.environ.get(
"BOT_BOTTLE_GUEST_SKILLS_DIR", _DEFAULT_SKILLS_DIR,
)
bottle.exec(f"mkdir -p {skills_dir}", user="root")
for name in agent.skills:
src = host_skill_dir(name)
if not os.path.isdir(src):
die(
f"skill {name!r} disappeared from host between "
f"validation and copy at {src}."
)
dst = f"{skills_dir}/{name}"
info(f"copying skill {name} into {bottle.name}:{dst}")
# Wipe any prior copy so re-runs don't accumulate.
bottle.exec(f"rm -rf {dst}", user="root")
bottle.cp_in(src, dst)
bottle.exec(f"chown -R node:node {dst}", user="root")
@@ -1,58 +0,0 @@
"""Supervise sidecar provisioning inside a running smolmachines
bottle (PRD 0023 chunk 4d; PRD 0013 supervise plane).
Registers the per-bottle supervise sidecar as an HTTP MCP server
in the agent's claude-code config so the agent discovers the
stuck-recovery MCP tools (pipelock-block, capability-block) at
startup.
Mirrors `backend.docker.provision.supervise` same `claude mcp
add` call, just dispatched via bottle.exec instead of
`docker exec`, and against `<bundle_ip>:<port>` instead of the
short `supervise` alias (no DNS in the TSI-allowlisted guest)."""
from __future__ import annotations
from ....log import info, warn
from ... import Bottle
from ..bottle_plan import SmolmachinesBottlePlan
_SUPERVISE_MCP_NAME = "supervise"
def provision_supervise(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
"""Run `claude mcp add` inside the guest to register the
supervise sidecar in claude-code's user config. No-op when
bottle.supervise is False.
The URL is the agent-side endpoint launch.py populated after
bundle bringup `http://127.0.0.1:<host port>/` rather than
the bundle's docker bridge IP, because that bridge isn't
reachable from the smolvm guest on macOS.
Failure is logged but not fatal: the bottle still works (you
just can't call supervise tools from the agent until the entry
is added manually). The operator sees the warning at launch."""
if plan.supervise_plan is None:
return
url = plan.agent_supervise_url
info(f"registering supervise MCP server in agent claude config → {url}")
# `claude mcp add --scope user` writes to ~/.claude.json. Run
# as node so the config lands in /home/node/.claude.json.
# SmolmachinesBottle.exec sets HOME and USER automatically
# for the requested user.
r = bottle.exec(
f"claude mcp add --scope user --transport http {_SUPERVISE_MCP_NAME} {url}",
user="node",
)
if r.returncode != 0:
warn(
f"`claude mcp add supervise` failed (exit {r.returncode}): "
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
f"register manually with: "
f"claude mcp add --scope user --transport http supervise {url}"
)
__all__ = ["provision_supervise"]
+226
View File
@@ -0,0 +1,226 @@
"""Claude agent provider plugin (PRD 0050, contrib).
The Claude-specific behavior previously inlined under
`agent_provider.agent_provision_plan` (claude.json trust marker,
api.anthropic.com egress route, OAuth-token placeholder), plus
the `claude mcp add` invocation that registers the supervise
sidecar in claude-code's user config (PRD 0013)."""
from __future__ import annotations
import json
import os
import shlex
from pathlib import Path
from typing import TYPE_CHECKING
from ...agent_provider import (
AgentProvider,
AgentProviderRuntime,
AgentProvisionFile,
AgentProvisionPlan,
)
from ...egress import EgressRoute
from ...log import die, info, warn
if TYPE_CHECKING:
from ...backend import Bottle, BottlePlan
_REPO_ROOT = Path(__file__).resolve().parents[3]
_SUPERVISE_MCP_NAME = "supervise"
def _skills_dir(guest_home: str) -> str:
return f"{guest_home}/.claude/skills"
def _prompt_path(guest_home: str) -> str:
return f"{guest_home}/.bot-bottle-prompt.txt"
_RUNTIME = AgentProviderRuntime(
template="claude",
command="claude",
image="bot-bottle-claude:latest",
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
prompt_mode="append_file",
bypass_args=("--dangerously-skip-permissions",),
resume_args=("--continue",),
remote_control_args=("--remote-control",),
)
class ClaudeAgentProvider(AgentProvider):
@property
def runtime(self) -> AgentProviderRuntime:
return _RUNTIME
def provision_plan(
self,
*,
dockerfile: str,
state_dir: Path,
guest_home: str,
guest_env: dict[str, str] | None = None,
auth_token: str = "",
forward_host_credentials: bool = False,
host_env: dict[str, str] | None = None,
trusted_project_path: str = "",
) -> AgentProvisionPlan:
del forward_host_credentials, host_env # Codex-only knobs
resolved_guest_env = dict(guest_env or {})
trusted_path = trusted_project_path or guest_home
env_vars: dict[str, str] = {
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
"DISABLE_ERROR_REPORTING": "1",
}
claude_config = state_dir / "claude.json"
claude_projects = {guest_home: {"hasTrustDialogAccepted": True}}
claude_projects[trusted_path] = {"hasTrustDialogAccepted": True}
claude_config.write_text(json.dumps({
"hasCompletedOnboarding": True,
"theme": "dark",
"bypassPermissionsModeAccepted": True,
"projects": claude_projects,
}, indent=2) + "\n")
claude_config.chmod(0o600)
files = (
AgentProvisionFile(claude_config, f"{guest_home}/.claude.json"),
)
egress_routes = (EgressRoute(
host="api.anthropic.com",
auth_scheme="Bearer" if auth_token else "",
token_ref=auth_token,
tls_passthrough=True,
),)
hidden_env_names: frozenset[str] = frozenset()
if auth_token:
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
return AgentProvisionPlan(
template=_RUNTIME.template,
command=_RUNTIME.command,
prompt_mode=_RUNTIME.prompt_mode,
image=_RUNTIME.image,
dockerfile=dockerfile,
env_vars=env_vars,
guest_env=resolved_guest_env,
files=files,
egress_routes=egress_routes,
hidden_env_names=hidden_env_names,
)
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
"""Copy each named skill tree from `~/.claude/skills/<name>/`
on the host into the guest's claude-code skills dir. No-op
when the agent has no skills."""
from ...backend.util import host_skill_dir
agent = plan.spec.manifest.agents[plan.spec.agent_name]
if not agent.skills:
return
skills_dir = _skills_dir(plan.guest_home)
bottle.exec(f"mkdir -p {skills_dir}", user="root")
for name in agent.skills:
src = host_skill_dir(name)
if not os.path.isdir(src):
die(
f"skill {name!r} disappeared from host between "
f"validation and copy at {src}."
)
dst = f"{skills_dir}/{name}"
info(f"copying skill {name} into {bottle.name}:{dst}")
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="root")
bottle.cp_in(f"{src}/.", f"{dst}/")
bottle.exec(f"chown -R node:node {dst}", user="root")
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
"""Copy the prompt file into the guest, fix ownership/mode.
Returns the in-guest path iff the agent has a non-empty
prompt (drives `--append-system-prompt-file`); the file is
copied either way so the path always exists."""
prompt_path = _prompt_path(plan.guest_home)
bottle.cp_in(str(plan.prompt_file), prompt_path)
bottle.exec(
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
user="root",
)
agent = plan.spec.manifest.agents[plan.spec.agent_name]
return prompt_path if agent.prompt else None
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
"""Apply the claude-side declarative provision steps from
`plan.agent_provision` today that's the `claude.json`
trust-marker file. Hot-replace this with a richer flow as
claude-code's harness shape evolves."""
provision = plan.agent_provision
for d in provision.dirs:
path = shlex.quote(d.guest_path)
_exec(bottle, f"mkdir -p {path}", f"could not create {d.guest_path}")
_exec(
bottle,
f"chown {shlex.quote(d.owner)} {path}",
f"could not chown {d.guest_path}",
)
_exec(
bottle,
f"chmod {shlex.quote(d.mode)} {path}",
f"could not chmod {d.guest_path}",
)
for command in provision.pre_copy:
_exec(bottle, shlex.join(command.argv), command.error)
for f in provision.files:
bottle.cp_in(str(f.host_path), f.guest_path)
path = shlex.quote(f.guest_path)
_exec(
bottle,
f"chown {shlex.quote(f.owner)} {path}",
f"could not chown {f.guest_path}",
)
_exec(
bottle,
f"chmod {shlex.quote(f.mode)} {path}",
f"could not chmod {f.guest_path}",
)
for command in provision.verify:
_exec(bottle, shlex.join(command.argv), command.error)
def provision_supervise_mcp(
self,
plan: "BottlePlan",
bottle: "Bottle",
supervise_url: str,
) -> None:
"""Run `claude mcp add` inside the agent guest to register the
supervise sidecar in claude-code's user config (~/.claude.json).
Failure is logged but not fatal the bottle still works without
the entry; the operator can register it manually."""
if plan.supervise_plan is None:
return
info(f"registering supervise MCP server in agent claude config → {supervise_url}")
r = bottle.exec(
f"claude mcp add --scope user --transport http "
f"{_SUPERVISE_MCP_NAME} {supervise_url}",
user="node",
)
if r.returncode != 0:
warn(
f"`claude mcp add supervise` failed (exit {r.returncode}): "
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
f"register manually with: "
f"claude mcp add --scope user --transport http supervise {supervise_url}"
)
def _exec(bottle: "Bottle", script: str, error: str) -> None:
result = bottle.exec(script, user="root")
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
if detail:
detail = f": {detail}"
die(f"agent provider provisioning: {error}{detail}")
+271
View File
@@ -0,0 +1,271 @@
"""Codex agent provider plugin (PRD 0050, contrib).
The Codex-specific behavior previously inlined under
`agent_provider.agent_provision_plan` (config.toml trust marker,
chatgpt.com / api.openai.com egress routes, optional host-credential
forwarding with dummy-auth.json + verify), plus the `codex mcp add`
invocation that registers the supervise sidecar in Codex's
~/.codex/config.toml (PRD 0050)."""
from __future__ import annotations
import os
import shlex
from pathlib import Path
from typing import TYPE_CHECKING
from ...agent_provider import (
CODEX_HOST_CREDENTIAL_HOSTS,
AgentProvider,
AgentProviderRuntime,
AgentProvisionCommand,
AgentProvisionDir,
AgentProvisionFile,
AgentProvisionPlan,
)
from ...codex_auth import codex_host_access_token, write_codex_dummy_auth_file
from ...egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
from ...log import die, info, warn
if TYPE_CHECKING:
from ...backend import Bottle, BottlePlan
_REPO_ROOT = Path(__file__).resolve().parents[3]
_SUPERVISE_MCP_NAME = "supervise"
def _skills_dir(guest_home: str) -> str:
# Codex agents still read skills from the claude-code convention
# (~/.claude/skills/) — the bot-bottle-codex image follows the
# same layout. If Codex grows native skill discovery later,
# change here.
return f"{guest_home}/.claude/skills"
def _prompt_path(guest_home: str) -> str:
return f"{guest_home}/.bot-bottle-prompt.txt"
_RUNTIME = AgentProviderRuntime(
template="codex",
command="codex",
image="bot-bottle-codex:latest",
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
prompt_mode="read_prompt_file",
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
resume_args=("resume", "--last"),
remote_control_args=(),
)
class CodexAgentProvider(AgentProvider):
@property
def runtime(self) -> AgentProviderRuntime:
return _RUNTIME
def provision_plan(
self,
*,
dockerfile: str,
state_dir: Path,
guest_home: str,
guest_env: dict[str, str] | None = None,
auth_token: str = "",
forward_host_credentials: bool = False,
host_env: dict[str, str] | None = None,
trusted_project_path: str = "",
) -> AgentProvisionPlan:
del auth_token # Claude-only knob
resolved_guest_env = dict(guest_env or {})
trusted_path = trusted_project_path or guest_home
env_vars: dict[str, str] = {
"CODEX_CA_CERTIFICATE": "/etc/ssl/certs/ca-certificates.crt",
}
auth_dir = resolved_guest_env.get("CODEX_HOME", f"{guest_home}/.codex")
if forward_host_credentials:
env_vars["CODEX_HOME"] = auth_dir
dirs = [AgentProvisionDir(auth_dir)]
files: list[AgentProvisionFile] = []
pre_copy: list[AgentProvisionCommand] = []
verify: list[AgentProvisionCommand] = []
provisioned_env: dict[str, str] = {}
config_path = f"{auth_dir}/config.toml"
config_file = state_dir / "codex-config.toml"
toml_path = trusted_path.replace("\\", "\\\\").replace('"', '\\"')
config_file.write_text(
f'[projects."{toml_path}"]\n'
'trust_level = "trusted"\n'
)
config_file.chmod(0o600)
files.append(AgentProvisionFile(config_file, config_path))
egress_routes: list[EgressRoute] = []
for host in CODEX_HOST_CREDENTIAL_HOSTS:
egress_routes.append(EgressRoute(
host=host,
auth_scheme="Bearer" if forward_host_credentials else "",
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials else "",
tls_passthrough=True,
))
if forward_host_credentials:
_host_env = host_env or dict(os.environ)
provisioned_env[CODEX_HOST_CREDENTIAL_TOKEN_REF] = (
codex_host_access_token(_host_env)
)
auth_file = state_dir / "codex-auth.json"
write_codex_dummy_auth_file(auth_file, _host_env)
files.append(AgentProvisionFile(auth_file, f"{auth_dir}/auth.json"))
pre_copy.append(AgentProvisionCommand((
"find", auth_dir,
"-maxdepth", "1",
"-type", "f",
"(",
"-name", "*.sqlite",
"-o", "-name", "*.sqlite-*",
"-o", "-name", "*.codex-repair-*.bak",
")",
"-delete",
), "codex host credentials: could not reset runtime db files"))
verify.append(AgentProvisionCommand((
"runuser", "-u", "node", "--",
"env",
f"HOME={guest_home}",
f"CODEX_HOME={auth_dir}",
"codex", "login", "status",
), (
"codex host credentials: dummy auth was copied into the "
"guest, but Codex did not accept it"
)))
return AgentProvisionPlan(
template=_RUNTIME.template,
command=_RUNTIME.command,
prompt_mode=_RUNTIME.prompt_mode,
image=_RUNTIME.image,
dockerfile=dockerfile,
env_vars=env_vars,
guest_env=resolved_guest_env,
dirs=tuple(dirs),
files=tuple(files),
pre_copy=tuple(pre_copy),
verify=tuple(verify),
egress_routes=tuple(egress_routes),
provisioned_env=provisioned_env,
)
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
"""Copy each named skill tree from `~/.claude/skills/<name>/`
on the host into the guest. No-op when the agent has no
skills."""
from ...backend.util import host_skill_dir
agent = plan.spec.manifest.agents[plan.spec.agent_name]
if not agent.skills:
return
skills_dir = _skills_dir(plan.guest_home)
bottle.exec(f"mkdir -p {skills_dir}", user="root")
for name in agent.skills:
src = host_skill_dir(name)
if not os.path.isdir(src):
die(
f"skill {name!r} disappeared from host between "
f"validation and copy at {src}."
)
dst = f"{skills_dir}/{name}"
info(f"copying skill {name} into {bottle.name}:{dst}")
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="root")
bottle.cp_in(f"{src}/.", f"{dst}/")
bottle.exec(f"chown -R node:node {dst}", user="root")
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
"""Copy the prompt file into the guest, fix ownership/mode.
Codex reads it via the agent's `Read and follow the
instructions in <path>.` bootstrap (see `prompt_args`); the
file is copied either way so the path always exists."""
prompt_path = _prompt_path(plan.guest_home)
bottle.cp_in(str(plan.prompt_file), prompt_path)
bottle.exec(
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
user="root",
)
agent = plan.spec.manifest.agents[plan.spec.agent_name]
return prompt_path if agent.prompt else None
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
"""Apply the codex-side declarative provision steps from
`plan.agent_provision`: the `~/.codex/` dir + config.toml
trust marker, plus the dummy-auth.json drop + `codex login
status` verify when host-credential forwarding is on."""
provision = plan.agent_provision
for d in provision.dirs:
path = shlex.quote(d.guest_path)
_exec(bottle, f"mkdir -p {path}", f"could not create {d.guest_path}")
_exec(
bottle,
f"chown {shlex.quote(d.owner)} {path}",
f"could not chown {d.guest_path}",
)
_exec(
bottle,
f"chmod {shlex.quote(d.mode)} {path}",
f"could not chmod {d.guest_path}",
)
for command in provision.pre_copy:
_exec(bottle, shlex.join(command.argv), command.error)
for f in provision.files:
bottle.cp_in(str(f.host_path), f.guest_path)
path = shlex.quote(f.guest_path)
_exec(
bottle,
f"chown {shlex.quote(f.owner)} {path}",
f"could not chown {f.guest_path}",
)
_exec(
bottle,
f"chmod {shlex.quote(f.mode)} {path}",
f"could not chmod {f.guest_path}",
)
for command in provision.verify:
_exec(bottle, shlex.join(command.argv), command.error)
def provision_supervise_mcp(
self,
plan: "BottlePlan",
bottle: "Bottle",
supervise_url: str,
) -> None:
"""Run `codex mcp add` inside the agent guest to register the
supervise sidecar in Codex's user config (~/.codex/config.toml).
Mirrors the Claude provider's `claude mcp add` flow — failure
is logged but not fatal."""
if plan.supervise_plan is None:
return
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
r = bottle.exec(
f"codex mcp add --transport http "
f"{_SUPERVISE_MCP_NAME} {supervise_url}",
user="node",
)
if r.returncode != 0:
warn(
f"`codex mcp add supervise` failed (exit {r.returncode}): "
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
f"register manually with: "
f"codex mcp add --transport http supervise {supervise_url}"
)
def _exec(bottle: "Bottle", script: str, error: str) -> None:
result = bottle.exec(script, user="root")
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
if detail:
detail = f": {detail}"
die(f"agent provider provisioning: {error}{detail}")
+401
View File
@@ -0,0 +1,401 @@
# PRD 0050: Move provider-specific agent logic into contrib
- **Status:** Active
- **Author:** claude
- **Created:** 2026-06-03
- **Issue:** #177
## Summary
The agent provider module (`bot_bottle/agent_provider.py`) hard-codes
the Claude- and Codex-specific provisioning rules — auth file shapes,
trust-dialog markers, egress routes, dummy-auth dance, env vars — in a
single `if template == "codex": ... if template == "claude": ...`
chain (lines 154230 today). Other pieces of provider behavior live in
each backend's `provision/` directory (`provision_skills`,
`provision_prompt`, `provision_provider_auth`, `provision_supervise`),
duplicated once per backend, even though almost none of what they do
is actually backend-specific.
This PRD reshapes the agent provider into a proper plugin boundary.
The two existing providers (Claude, Codex) move out of `agent_provider`
into `bot_bottle/contrib/claude/` and `bot_bottle/contrib/codex/`
the same `contrib/` layout PRD 0048 established for the Gitea
deploy-key provisioner. The four provisioner methods backends
currently duplicate move into the provider plugin itself; the backend
keeps only the bottle-side primitives (`cp_in`, `exec`) the plugin
calls through. MCP server registration becomes a first-class part of
the provider contract so Codex finally gets the supervise sidecar
wired in alongside Claude.
The shipping artifact is two new provider plugins under `contrib/`, a
narrower `AgentProvider` ABC in `bot_bottle/agent_provider.py`, four
fewer provisioner hooks on `BottleBackend`, and a supervise-MCP entry
visible from the Codex agent at launch.
## Problem
Three concrete pains, all downstream of the provider abstraction not
being where the work happens:
1. **Adding a third provider is a five-file edit.** A hypothetical
Gemini or Aider provider has to: (a) add a branch in
`agent_provision_plan`, (b) add a runtime entry in `_RUNTIMES`,
(c) thread a `prompt_mode` enum value, (d) potentially extend
`provision_provider_auth` per backend, (e) wire MCP registration
into both `backend/docker/provision/supervise.py` and
`backend/smolmachines/provision/supervise.py`. Nothing about that
spread is load-bearing; it's leftover from when there was one
provider.
2. **MCP server registration is Claude-only.** Both
`backend/docker/provision/supervise.py` and
`backend/smolmachines/provision/supervise.py` run `claude mcp add`
verbatim. Codex bottles silently get no MCP entry — the sidecar
is running, the routes are open, but the agent can't see the
tools because nothing wrote them into Codex's TOML config. Today
this is a latent gap. The provider plugin is the only layer that
knows how a given agent discovers MCP servers, so that's where
the registration belongs.
3. **`provision_skills` / `provision_prompt` / `provision_provider_auth`
are duplicated between backends.** Each backend has its own
~50-line copy. The differences are entirely about which path the
backend uses for `cp_in` and what user it `chown`s to. Same
business logic, two implementations, two test surfaces, two
places to update when the rules change.
The agent_provider module is the right home for all of this. It already
owns the `AgentProvisionPlan` (the declarative description of what
needs to land in the guest); extending it to own the imperative
"actually land it" step is the natural next move. Putting
provider-specific code under `contrib/` mirrors the convention PRD 0048
established and keeps the core package provider-agnostic.
## Goals / Success Criteria
1. `bot_bottle/agent_provider.py` contains no Claude- or
Codex-specific branches. The Claude and Codex template strings
themselves still live in the core module (they're the public
manifest values), but everything keyed off them moves out.
2. `bot_bottle/contrib/claude/agent_provider.py` and
`bot_bottle/contrib/codex/agent_provider.py` exist and contain
the provider-specific behavior previously in lines 154230 of
`agent_provider.py`. Each is reachable from the core registry via
a lazy import (the same pattern PRD 0048 used for
`GiteaDeployKeyProvisioner`).
3. `AgentProvider` is an ABC (or protocol) with at minimum:
- `provision_plan(...) -> AgentProvisionPlan` — what the existing
`agent_provision_plan` produces today, scoped to one provider.
- `provision_skills(bottle, plan)` — copy host skills into the guest.
- `provision_prompt(bottle, plan)` — copy the prompt file, return
the in-guest path (or None).
- `provision_supervise_mcp(bottle, plan, supervise_url)` — register
the supervise sidecar in the provider's MCP config. No-op when
the bottle has no supervise sidecar.
- The Claude implementation runs `claude mcp add`. The Codex
implementation writes the corresponding entry into
`~/.codex/config.toml`'s `[mcp_servers.supervise]` table.
4. `BottleBackend` loses the four abstract methods being moved
(`provision_skills`, `provision_prompt`, `provision_provider_auth`,
`provision_supervise`). `BottleBackend.provision_in_bottle` calls
the provider plugin directly via the bottle and plan it already
has. `provision_ca`, `provision_workspace`, and `provision_git`
stay on the backend — they're backend infrastructure, not
provider behavior.
5. `bot_bottle/backend/docker/provision/{skills,prompt,provider_auth,
supervise}.py` and `bot_bottle/backend/smolmachines/provision/{skills,
prompt,provider_auth,supervise}.py` are deleted. The
backend-specific provisioners that remain (`ca`, `git`,
`workspace`) stay.
6. A Codex bottle launched with `--supervise` shows the
supervise MCP server entry in its Codex config and can call
supervise tools from inside the bottle (egress-block,
pipelock-block, capability-block).
7. Existing tests for the moved logic move with the code:
provider-specific tests under `tests/unit/test_contrib_claude_*.py`
and `tests/unit/test_contrib_codex_*.py`, mirroring
`tests/unit/test_contrib_gitea_deploy_key.py`.
8. PRD 0050's Status flips Draft → Active in the same commit that
removes the last `if template == "claude"` branch from
`agent_provider.py`.
## Non-goals
- **A third agent provider.** This PRD reshapes the boundary so a
third provider is cheap to add. It does not add one.
- **Changing the manifest surface.** The `agent.provider`
manifest field still takes `"claude"` or `"codex"`. The set of
valid strings is unchanged.
- **Changing `AgentProvisionPlan`'s shape.** The dataclasses
(`AgentProvisionDir`, `AgentProvisionFile`, `AgentProvisionCommand`,
`AgentProvisionPlan` itself) stay in the core module and keep their
current fields. Provider plugins produce the same plan shape; only
the producer moves.
- **Changing the supervise sidecar protocol or the supervise tool
surface.** PRDs 00130016 stay Active. What changes is how the
agent discovers the sidecar's MCP endpoint, not what it does once
connected.
- **Per-skill provider differences.** A Codex agent and a Claude
agent see the same `~/.claude/skills/<name>/` tree today (Codex
reads it via its own skills mechanism). This PRD does not change
that — `provision_skills` lands the same content for both.
- **Removing the `prompt_args` helper from `agent_provider.py`.** It
stays at module scope; it's already a pure dispatch on `prompt_mode`
and has no Claude/Codex `if` chain to extract.
- **`provision_provider_auth` migration.** The issue notes this method
is "probably not needed anymore" once each provider owns its own
provisioning. After the move, the work that
`provision_provider_auth` did (apply `dirs` / `files` / `pre_copy` /
`verify` from the plan) becomes a shared helper the per-provider
`provision_skills` / `provision_prompt` calls dispatch through —
or, more likely, a single `provision(bottle)` entry point on the
provider. The hook is removed from `BottleBackend`; whether the
underlying loop lives on `AgentProvider` as a default
implementation or as a free function in `contrib/_apply.py` is
decided at implementation time, not in this PRD.
## Scope
### In scope
- New `AgentProvider` ABC in `bot_bottle/agent_provider.py` with the
five methods listed under Goal 3. Existing `agent_provision_plan`
becomes `AgentProvider.provision_plan`.
- New `bot_bottle/contrib/claude/__init__.py`,
`bot_bottle/contrib/claude/agent_provider.py`,
`bot_bottle/contrib/codex/__init__.py`,
`bot_bottle/contrib/codex/agent_provider.py`. Each defines a
`ClaudeAgentProvider` / `CodexAgentProvider` class.
- A `get_provider(template) -> AgentProvider` registry in
`bot_bottle/agent_provider.py`, lazy-imported from `contrib/`,
mirroring `get_provisioner(provider, ...)` in
`bot_bottle/deploy_key_provisioner.py`.
- Backend changes:
- `BottleBackend.provision_in_bottle` resolves the provider once
and calls `provider.provision_skills(bottle, plan)`,
`provider.provision_prompt(bottle, plan)`, and
`provider.provision_supervise_mcp(bottle, plan, url)` in place
of the current four abstract hooks.
- `BottleBackend.provision_skills`, `provision_prompt`,
`provision_provider_auth`, `provision_supervise` are removed.
- Docker and smolmachines backends remove their corresponding
`provision_*` implementations and the
`backend/<name>/provision/{skills,prompt,provider_auth,
supervise}.py` modules.
- Codex MCP wiring: `CodexAgentProvider.provision_supervise_mcp`
writes a `[mcp_servers.supervise]` block into
`~/.codex/config.toml` pointing at the same agent-side supervise
URL the Claude provider uses. The file already exists from the
trust-dialog step; the MCP entry is appended (or the file is
rewritten in a single shot, whichever's simpler).
- Tests migrate. Backend tests that targeted the four moved
provisioners are rewritten against the provider plugin, with one
test file per provider mirroring `tests/unit/test_contrib_gitea_*.py`.
### Out of scope
- Adding a manifest field for "extra MCP servers the agent should
see". The supervise sidecar is the only MCP server provisioned
today, and the issue's "Add mcp server configuring into agent
provision" line is about the supervise sidecar specifically. A
general-purpose user-declared MCP list is a follow-up if and when
the need surfaces.
- Refactoring `AgentProvisionPlan`'s dataclasses. They stay byte-
for-byte the same so the diff is purely "who owns the producer".
- A `BottleBackend.provision_provider_auth` shim during transition.
The hook is removed in one cut; the only caller is the backend
itself, no manifest consumers reference it.
- Renaming `agent_provider.py``agent_providers/`. The module
still has core dataclasses + the ABC + the registry; it's a single
file's worth of code.
## Proposed design
### Module shape after the cut
```
bot_bottle/agent_provider.py
PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_TEMPLATES
PromptMode (Literal)
AgentProvisionDir, AgentProvisionFile, AgentProvisionCommand,
AgentProvisionPlan (dataclasses, unchanged)
AgentProviderRuntime (dataclass — template/command/image/etc.)
AgentProvider (ABC)
.runtime() -> AgentProviderRuntime
.provision_plan(state_dir, ..., trusted_project_path, ...) -> AgentProvisionPlan
.provision_skills(bottle, plan) -> None
.provision_prompt(bottle, plan) -> str | None
.provision_supervise_mcp(bottle, plan, supervise_url) -> None
get_provider(template: str) -> AgentProvider # lazy-imports contrib
prompt_args(prompt_mode, prompt_path, *, argv) # unchanged
bot_bottle/contrib/claude/agent_provider.py
ClaudeAgentProvider(AgentProvider)
_RUNTIME = AgentProviderRuntime(template="claude", ...)
.provision_plan(...) # owns the lines-204230 chunk
.provision_skills(...) # was backend/<name>/provision/skills.py
.provision_prompt(...) # was backend/<name>/provision/prompt.py
.provision_supervise_mcp(...)# was backend/<name>/provision/supervise.py
bot_bottle/contrib/codex/agent_provider.py
CodexAgentProvider(AgentProvider)
_RUNTIME = AgentProviderRuntime(template="codex", ...)
.provision_plan(...) # owns the lines-154204 chunk
.provision_skills(...) # same as Claude impl, factored to shared helper
.provision_prompt(...) # same as Claude impl, factored to shared helper
.provision_supervise_mcp(...)# writes [mcp_servers.supervise] to config.toml
```
The skills / prompt / provider-auth-apply implementations are 99%
identical across providers — `cp_in` then `chown` / `chmod`. They are
extracted to small free functions in
`bot_bottle/contrib/_provision_apply.py` (or kept as default
implementations on `AgentProvider` if every concrete subclass would
just call them). Picked at implementation time; both options match
PRD 0048's contrib convention. The visible contract is that
provisioning lives on the provider plugin.
### MCP registration for Codex
Codex reads MCP servers from `~/.codex/config.toml` (or whatever
`CODEX_HOME/config.toml` resolves to). The provider already writes
this file once during `provision_plan` to set the project trust
level. `CodexAgentProvider.provision_supervise_mcp` extends the
existing write: same path, append a `[mcp_servers.supervise]` table
pointing at the agent-side supervise URL.
Two implementation routes worth flagging:
- **Option A:** Pre-bake the MCP entry in the same config-write that
happens during `provision_plan`, before bottle launch. Simpler;
the supervise URL has to be known at plan time, which means
`provision_plan` needs the supervise URL (or a sentinel that means
"fill this in"). The smolmachines backend already plumbs
`agent_supervise_url` through to its provision_supervise step, so
the value is available.
- **Option B:** Append at bottle-launch time via a `bottle.exec`
that writes to the file inside the guest, matching the
`claude mcp add` flow. Slower but uniform with how
`ClaudeAgentProvider.provision_supervise_mcp` works.
Option B is the symmetric choice and the one this PRD assumes.
The implementer can switch to A if Option B turns out to need a
TOML-merge primitive the codebase doesn't already have.
### Backend after the cut
```python
class BottleBackend:
def provision_in_bottle(self, plan, bottle, supervise_url):
provider = get_provider(plan.spec.manifest.agents[
plan.spec.agent_name].provider)
self.provision_ca(plan, bottle)
prompt_path = provider.provision_prompt(bottle, plan)
provider.provision_skills(bottle, plan)
self.provision_workspace(plan, bottle)
self.provision_git(plan, bottle)
provider.provision_supervise_mcp(bottle, plan, supervise_url)
return prompt_path
```
`supervise_url` is the existing per-backend "where does the agent
reach the sidecar from inside the guest" value. The Docker backend
passes `http://supervise:<port>/`; smolmachines passes the
`http://127.0.0.1:<port>/` it already computed. The backend's only
remaining provider-touching duty is "tell the provider what the
sidecar URL is".
### Registry
```python
# bot_bottle/agent_provider.py
def get_provider(template: str) -> AgentProvider:
if template == PROVIDER_CLAUDE:
from bot_bottle.contrib.claude.agent_provider import (
ClaudeAgentProvider,
)
return ClaudeAgentProvider()
if template == PROVIDER_CODEX:
from bot_bottle.contrib.codex.agent_provider import (
CodexAgentProvider,
)
return CodexAgentProvider()
raise ValueError(f"unknown agent provider template: {template!r}")
```
Lazy imports keep core import-time graph small and match PRD 0048.
## Implementation chunks
Each chunk is one commit on the PR; the PR ships as one cut.
1. **Lift `AgentProvider` ABC + registry.** Add the ABC and
`get_provider` next to the existing `agent_provision_plan`
function. Have `agent_provision_plan` delegate to
`get_provider(template).provision_plan(...)` so callers keep
working through the transition.
2. **Move provider-specific `provision_plan` content into
contrib.** Create `contrib/claude/` and `contrib/codex/`. The
Claude and Codex branches of `agent_provision_plan` move into
the respective provider classes. The shared scaffolding
(initial dict setup, final `AgentProvisionPlan(...)` return)
stays in the ABC as a template method or moves into each
subclass — whichever needs less indirection.
3. **Move backend provisioners onto the provider.** Add
`provision_skills`, `provision_prompt`, `provision_supervise_mcp`
to `AgentProvider` (with a shared apply helper for skills /
prompt). Update `BottleBackend.provision_in_bottle` to call them.
Delete the four backend hook methods and the eight
`backend/<name>/provision/{skills,prompt,provider_auth,supervise}.py`
modules.
4. **Add Codex MCP support.** Implement
`CodexAgentProvider.provision_supervise_mcp` against
`~/.codex/config.toml`. Add a unit test that runs the method
against an in-memory FakeBottle and asserts the
`[mcp_servers.supervise]` block is present.
5. **Migrate tests.** Per-backend tests for the moved
provisioners turn into per-provider tests under
`tests/unit/test_contrib_claude_*.py` and
`tests/unit/test_contrib_codex_*.py`. Keep one integration-style
test per backend that confirms `provision_in_bottle` still
reaches every step.
6. **Activate.** Flip Status: Draft → Active in this PRD; close
#177 on merge.
## Open questions (resolved)
1. **`codex mcp add` exists.** Implementation calls
`codex mcp add --transport http supervise <url>` as `node`
symmetric with `claude mcp add` (no `--scope user`; Codex writes
`~/.codex/config.toml` by default). Failure logs a warning; the
bottle still works without the entry.
2. **Each provider owns its apply steps end-to-end.** The base
ABC declares `provision_skills` / `provision_prompt` /
`provision` as abstract; each concrete provider implements its
own copy loop. No shared `_provision_apply.py`. The apply
sequences look similar today, but Claude and Codex harnesses
diverge over time (codex already grew a dummy-auth dance + a
`codex login status` verify with no Claude analogue) and the
"shared because both happen to call cp_in then chown" coupling
would just rot. Duplication is intentional.
3. **Env knobs removed.** `BOT_BOTTLE_CONTAINER_HOME`,
`BOT_BOTTLE_GUEST_HOME`, `BOT_BOTTLE_CONTAINER_SKILLS_DIR`, and
`BOT_BOTTLE_GUEST_SKILLS_DIR` are gone; `/home/node` is hardcoded
everywhere it was read. The values were effectively constants;
the knobs added surface area for no real flexibility.
## References
- Issue
[#177](https://gitea.dideric.is/didericis/bot-bottle/issues/177)
— the request: move provider logic into contrib, add MCP
configuration to agent provision, rename provision_supervise →
provision_supervise_mcp, ensure Codex gets MCP provisioned.
- PRD 0013 — supervise plane foundation (defines the MCP-discoverable
block-remediation tools this PRD makes available to Codex).
- PRD 0048 — SSH deploy key provisioning (the `contrib/` convention
this PRD follows).
- Current source:
[agent_provider.py L154-L230](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/bot_bottle/agent_provider.py#L154-L230)
— the provider-specific block this PRD relocates to contrib.
+10
View File
@@ -27,6 +27,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_plan_declares_home_state(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan(
guest_home="/home/node",
template="codex",
dockerfile="/tmp/Dockerfile.codex",
state_dir=Path(tmp),
@@ -51,6 +52,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_trusts_requested_project_path(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
agent_provision_plan(
guest_home="/home/node",
template="codex",
dockerfile="",
state_dir=Path(tmp),
@@ -68,6 +70,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
"tokens": {"access_token": _jwt(2000000000)},
}))
plan = agent_provision_plan(
guest_home="/home/node",
template="codex",
dockerfile="",
state_dir=Path(tmp),
@@ -87,6 +90,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_claude_with_auth_token_injects_provider_route_and_placeholder(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan(
guest_home="/home/node",
template="claude",
dockerfile="/tmp/Dockerfile.claude",
state_dir=Path(tmp),
@@ -109,6 +113,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_claude_trusts_requested_project_path(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
agent_provision_plan(
guest_home="/home/node",
template="claude",
dockerfile="",
state_dir=Path(tmp),
@@ -127,6 +132,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
"tokens": {"access_token": _jwt(2000000000)},
}))
plan = agent_provision_plan(
guest_home="/home/node",
template="codex",
dockerfile="",
state_dir=Path(tmp),
@@ -143,6 +149,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan(
guest_home="/home/node",
template="codex",
dockerfile="",
state_dir=Path(tmp),
@@ -160,6 +167,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_claude_without_auth_token_has_passthrough_egress_route(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan(
guest_home="/home/node",
template="claude",
dockerfile="",
state_dir=Path(tmp),
@@ -183,6 +191,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
"tokens": {"access_token": access},
}))
plan = agent_provision_plan(
guest_home="/home/node",
template="codex",
dockerfile="",
state_dir=Path(tmp),
@@ -197,6 +206,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_without_forward_host_credentials_has_empty_provisioned_env(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan(
guest_home="/home/node",
template="codex",
dockerfile="",
state_dir=Path(tmp),
+1
View File
@@ -164,6 +164,7 @@ def _plan(
spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
return DockerBottlePlan(
guest_home="/home/node",
spec=spec,
stage_dir=STAGE,
slug=SLUG,
+303
View File
@@ -0,0 +1,303 @@
"""Unit: ClaudeAgentProvider provisioning (PRD 0050, contrib/claude).
Each provider owns its own in-guest provisioning end-to-end
skills copy, prompt copy, declarative dirs/files/pre_copy/verify
apply, and supervise MCP registration. The Claude / Codex paths
intentionally don't share a helper module: harness changes on
either side are expected to diverge the implementations."""
from __future__ import annotations
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.agent_provider import (
AgentProvisionCommand,
AgentProvisionDir,
AgentProvisionFile,
AgentProvisionPlan,
)
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.contrib.claude.agent_provider import ClaudeAgentProvider
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
_URL = "http://supervise:9100/"
def _make_bottle(exec_result: ExecResult | None = None) -> MagicMock:
bottle = MagicMock(spec=Bottle)
bottle.name = "bot-bottle-demo-abc12"
bottle.exec.return_value = (
exec_result if exec_result is not None
else ExecResult(returncode=0, stdout="", stderr="")
)
return bottle
def _exec_scripts(bottle: MagicMock) -> list[str]:
return [c.args[0] for c in bottle.exec.call_args_list]
def _plan(
*,
agent_prompt: str = "",
skills: list[str] | None = None,
agent_provision: AgentProvisionPlan | None = None,
supervise: bool = False,
) -> DockerBottlePlan:
bottle_json: dict = {"agent_provider": {"template": "claude"}}
if supervise:
bottle_json["supervise"] = True
manifest = Manifest.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {
"demo": {
"skills": list(skills or []),
"prompt": agent_prompt,
"bottle": "dev",
},
},
})
spec = BottleSpec(
manifest=manifest, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x",
)
supervise_plan = None
if supervise:
supervise_plan = SupervisePlan(
slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
current_config_dir=Path("/tmp/current-config"),
)
return DockerBottlePlan(
guest_home="/home/node",
spec=spec,
stage_dir=Path("/tmp/stage"),
slug="demo-abc12",
container_name="bot-bottle-demo-abc12",
container_name_pinned=False,
image="bot-bottle-claude:latest",
derived_image="",
runtime_image="bot-bottle-claude:latest",
dockerfile_path="",
env_file=Path("/tmp/agent.env"),
forwarded_env={},
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
proxy_plan=PipelockProxyPlan(
yaml_path=Path("/tmp/pipelock.yaml"), slug="demo-abc12",
),
git_gate_plan=GitGatePlan(
slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
hook_script=Path("/tmp/git-gate-hook"),
access_hook_script=Path("/tmp/git-gate-access-hook"),
upstreams=(),
),
egress_plan=EgressPlan(
slug="demo-abc12",
routes_path=Path("/tmp/routes.yaml"),
routes=(),
token_env_map={},
),
supervise_plan=supervise_plan,
use_runsc=False,
agent_provision=agent_provision or AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file",
image="", dockerfile="", guest_env={},
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
)
class TestClaudeProvisionPrompt(unittest.TestCase):
def test_cp_uses_bottle_cp_in(self):
bottle = _make_bottle()
ClaudeAgentProvider().provision_prompt(_plan(), bottle)
bottle.cp_in.assert_called_once_with(
"/tmp/state/demo-abc12/agent/prompt.txt",
"/home/node/.bot-bottle-prompt.txt",
)
def test_returns_path_when_agent_has_prompt(self):
bottle = _make_bottle()
r = ClaudeAgentProvider().provision_prompt(
_plan(agent_prompt="You are helpful."), bottle,
)
self.assertEqual("/home/node/.bot-bottle-prompt.txt", r)
def test_returns_none_when_agent_has_no_prompt(self):
bottle = _make_bottle()
r = ClaudeAgentProvider().provision_prompt(_plan(agent_prompt=""), bottle)
self.assertIsNone(r)
bottle.cp_in.assert_called_once()
def test_chowns_to_node_after_copy(self):
bottle = _make_bottle()
ClaudeAgentProvider().provision_prompt(_plan(), bottle)
scripts = _exec_scripts(bottle)
self.assertTrue(
any("chown node:node" in s
and "/home/node/.bot-bottle-prompt.txt" in s
for s in scripts)
)
self.assertTrue(
any("chmod 600" in s
and "/home/node/.bot-bottle-prompt.txt" in s
for s in scripts)
)
class TestClaudeProvisionSkills(unittest.TestCase):
def test_noop_when_agent_has_no_skills(self):
bottle = _make_bottle()
ClaudeAgentProvider().provision_skills(_plan(skills=[]), bottle)
bottle.cp_in.assert_not_called()
bottle.exec.assert_not_called()
def test_mkdir_plus_cp_per_skill(self):
bottle = _make_bottle()
with patch(
"bot_bottle.backend.util.host_skill_dir",
side_effect=lambda n: f"/host/skills/{n}",
), patch(
"bot_bottle.contrib.claude.agent_provider.os.path.isdir",
return_value=True,
):
ClaudeAgentProvider().provision_skills(
_plan(skills=["init-prd", "verify"]), bottle,
)
scripts = _exec_scripts(bottle)
self.assertTrue(
any("mkdir -p" in s and "/home/node/.claude/skills" in s
for s in scripts)
)
cp_targets = {c.args[1] for c in bottle.cp_in.call_args_list}
self.assertEqual({
"/home/node/.claude/skills/init-prd/",
"/home/node/.claude/skills/verify/",
}, cp_targets)
self.assertEqual(
2, sum(1 for s in scripts if "chown -R node:node" in s),
)
def test_missing_skill_dies(self):
bottle = _make_bottle()
with patch(
"bot_bottle.backend.util.host_skill_dir",
side_effect=lambda n: f"/host/skills/{n}",
), patch(
"bot_bottle.contrib.claude.agent_provider.os.path.isdir",
return_value=False,
):
with self.assertRaises(SystemExit):
ClaudeAgentProvider().provision_skills(
_plan(skills=["init-prd"]), bottle,
)
class TestClaudeProvision(unittest.TestCase):
"""The declarative dirs/files/pre_copy/verify apply loop for
the claude.json trust marker."""
def test_noop_on_empty_provision_plan(self):
bottle = _make_bottle()
ClaudeAgentProvider().provision(_plan(), bottle)
bottle.cp_in.assert_not_called()
bottle.exec.assert_not_called()
def test_copies_files_and_chowns(self):
provision = AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file",
image="", dockerfile="", guest_env={},
files=(AgentProvisionFile(
Path("/tmp/claude.json"), "/home/node/.claude.json",
),),
)
bottle = _make_bottle()
ClaudeAgentProvider().provision(
_plan(agent_provision=provision), bottle,
)
bottle.cp_in.assert_called_once_with(
"/tmp/claude.json", "/home/node/.claude.json",
)
scripts = _exec_scripts(bottle)
self.assertTrue(
any("chown" in s and "/home/node/.claude.json" in s for s in scripts)
)
self.assertTrue(
any("chmod" in s and "/home/node/.claude.json" in s for s in scripts)
)
def test_dies_when_file_chown_fails(self):
provision = AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file",
image="", dockerfile="", guest_env={},
files=(AgentProvisionFile(
Path("/tmp/claude.json"), "/home/node/.claude.json",
),),
)
bottle = _make_bottle(
exec_result=ExecResult(1, "", "chown: no such file\n"),
)
with self.assertRaises(SystemExit):
ClaudeAgentProvider().provision(
_plan(agent_provision=provision), bottle,
)
def test_runs_verify_commands(self):
provision = AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file",
image="", dockerfile="", guest_env={},
verify=(AgentProvisionCommand(
("/usr/bin/true",), "verify failed",
),),
)
bottle = _make_bottle()
ClaudeAgentProvider().provision(
_plan(agent_provision=provision), bottle,
)
scripts = _exec_scripts(bottle)
self.assertTrue(any("/usr/bin/true" in s for s in scripts))
class TestClaudeSuperviseMcp(unittest.TestCase):
def test_noop_when_supervise_disabled(self):
bottle = _make_bottle()
ClaudeAgentProvider().provision_supervise_mcp(
_plan(supervise=False), bottle, _URL,
)
bottle.exec.assert_not_called()
def test_runs_claude_mcp_add_as_node(self):
bottle = _make_bottle()
ClaudeAgentProvider().provision_supervise_mcp(
_plan(supervise=True), bottle, _URL,
)
bottle.exec.assert_called_once()
script = bottle.exec.call_args.args[0]
self.assertEqual("node", bottle.exec.call_args.kwargs.get("user"))
self.assertIn("claude mcp add", script)
self.assertIn("--scope user", script)
self.assertIn("--transport http", script)
self.assertIn("supervise", script)
self.assertIn(_URL, script)
def test_logs_warning_on_failure_but_does_not_raise(self):
bottle = _make_bottle(
exec_result=ExecResult(returncode=1, stdout="", stderr="boom"),
)
ClaudeAgentProvider().provision_supervise_mcp(
_plan(supervise=True), bottle, _URL,
)
if __name__ == "__main__":
unittest.main()
+271
View File
@@ -0,0 +1,271 @@
"""Unit: CodexAgentProvider provisioning (PRD 0050, contrib/codex).
The Codex provider owns its own skills / prompt / provision /
supervise-mcp end-to-end symmetric with the claude provider but
not sharing a helper module, since codex's apply steps include
the dummy-auth dance and a `codex login status` verify that have
no claude equivalent."""
from __future__ import annotations
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.agent_provider import (
AgentProvisionCommand,
AgentProvisionDir,
AgentProvisionFile,
AgentProvisionPlan,
)
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.contrib.codex.agent_provider import CodexAgentProvider
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
_URL = "http://supervise:9100/"
def _make_bottle(exec_result: ExecResult | None = None) -> MagicMock:
bottle = MagicMock(spec=Bottle)
bottle.name = "bot-bottle-demo-abc12"
bottle.exec.return_value = (
exec_result if exec_result is not None
else ExecResult(returncode=0, stdout="", stderr="")
)
return bottle
def _exec_scripts(bottle: MagicMock) -> list[str]:
return [c.args[0] for c in bottle.exec.call_args_list]
def _plan(
*,
agent_prompt: str = "",
skills: list[str] | None = None,
agent_provision: AgentProvisionPlan | None = None,
supervise: bool = False,
) -> DockerBottlePlan:
bottle_json: dict = {"agent_provider": {"template": "codex"}}
if supervise:
bottle_json["supervise"] = True
manifest = Manifest.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {
"demo": {
"skills": list(skills or []),
"prompt": agent_prompt,
"bottle": "dev",
},
},
})
spec = BottleSpec(
manifest=manifest, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x",
)
supervise_plan = None
if supervise:
supervise_plan = SupervisePlan(
slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
current_config_dir=Path("/tmp/current-config"),
)
return DockerBottlePlan(
guest_home="/home/node",
spec=spec,
stage_dir=Path("/tmp/stage"),
slug="demo-abc12",
container_name="bot-bottle-demo-abc12",
container_name_pinned=False,
image="bot-bottle-codex:latest",
derived_image="",
runtime_image="bot-bottle-codex:latest",
dockerfile_path="",
env_file=Path("/tmp/agent.env"),
forwarded_env={},
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
proxy_plan=PipelockProxyPlan(
yaml_path=Path("/tmp/pipelock.yaml"), slug="demo-abc12",
),
git_gate_plan=GitGatePlan(
slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
hook_script=Path("/tmp/git-gate-hook"),
access_hook_script=Path("/tmp/git-gate-access-hook"),
upstreams=(),
),
egress_plan=EgressPlan(
slug="demo-abc12",
routes_path=Path("/tmp/routes.yaml"),
routes=(),
token_env_map={},
),
supervise_plan=supervise_plan,
use_runsc=False,
agent_provision=agent_provision or AgentProvisionPlan(
template="codex", command="codex", prompt_mode="read_prompt_file",
image="", dockerfile="", guest_env={},
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
)
class TestCodexProvisionPrompt(unittest.TestCase):
def test_cp_uses_bottle_cp_in_and_chowns(self):
bottle = _make_bottle()
r = CodexAgentProvider().provision_prompt(
_plan(agent_prompt="hello"), bottle,
)
self.assertEqual("/home/node/.bot-bottle-prompt.txt", r)
bottle.cp_in.assert_called_once_with(
"/tmp/state/demo-abc12/agent/prompt.txt",
"/home/node/.bot-bottle-prompt.txt",
)
scripts = _exec_scripts(bottle)
self.assertTrue(
any("chown node:node" in s
and "/home/node/.bot-bottle-prompt.txt" in s
for s in scripts)
)
def test_returns_none_when_agent_has_no_prompt(self):
bottle = _make_bottle()
r = CodexAgentProvider().provision_prompt(_plan(agent_prompt=""), bottle)
self.assertIsNone(r)
bottle.cp_in.assert_called_once()
class TestCodexProvisionSkills(unittest.TestCase):
def test_noop_when_agent_has_no_skills(self):
bottle = _make_bottle()
CodexAgentProvider().provision_skills(_plan(skills=[]), bottle)
bottle.cp_in.assert_not_called()
bottle.exec.assert_not_called()
def test_mkdir_plus_cp_per_skill(self):
bottle = _make_bottle()
with patch(
"bot_bottle.backend.util.host_skill_dir",
side_effect=lambda n: f"/host/skills/{n}",
), patch(
"bot_bottle.contrib.codex.agent_provider.os.path.isdir",
return_value=True,
):
CodexAgentProvider().provision_skills(
_plan(skills=["init-prd"]), bottle,
)
scripts = _exec_scripts(bottle)
self.assertTrue(
any("mkdir -p" in s and "/home/node/.claude/skills" in s
for s in scripts)
)
bottle.cp_in.assert_called_once()
self.assertEqual(
"/home/node/.claude/skills/init-prd/",
bottle.cp_in.call_args.args[1],
)
class TestCodexProvision(unittest.TestCase):
"""Codex's declarative provision step: ~/.codex/ dir + config.toml
+ (optional) dummy-auth.json + `codex login status` verify."""
def test_creates_dir_and_copies_config(self):
provision = AgentProvisionPlan(
template="codex", command="codex",
prompt_mode="read_prompt_file",
image="", dockerfile="", guest_env={},
dirs=(AgentProvisionDir("/home/node/.codex"),),
files=(AgentProvisionFile(
Path("/tmp/codex-config.toml"),
"/home/node/.codex/config.toml",
),),
)
bottle = _make_bottle()
CodexAgentProvider().provision(
_plan(agent_provision=provision), bottle,
)
bottle.cp_in.assert_called_once_with(
"/tmp/codex-config.toml",
"/home/node/.codex/config.toml",
)
scripts = _exec_scripts(bottle)
self.assertTrue(any("mkdir -p" in s and "/home/node/.codex" in s for s in scripts))
self.assertTrue(any("chown" in s and "/home/node/.codex/config.toml" in s for s in scripts))
self.assertTrue(any("chmod" in s and "/home/node/.codex/config.toml" in s for s in scripts))
def test_runs_pre_copy_then_verify(self):
provision = AgentProvisionPlan(
template="codex", command="codex",
prompt_mode="read_prompt_file",
image="", dockerfile="", guest_env={},
pre_copy=(AgentProvisionCommand(
("find", "/home/node/.codex", "-name", "*.sqlite", "-delete"),
"could not reset runtime db files",
),),
verify=(AgentProvisionCommand(
("runuser", "-u", "node", "--", "codex", "login", "status"),
"codex rejected the dummy auth",
),),
)
bottle = _make_bottle()
CodexAgentProvider().provision(
_plan(agent_provision=provision), bottle,
)
scripts = _exec_scripts(bottle)
self.assertTrue(any("find" in s and "-delete" in s for s in scripts))
self.assertTrue(any("runuser" in s and "codex login status" in s for s in scripts))
def test_dies_when_dir_creation_fails(self):
provision = AgentProvisionPlan(
template="codex", command="codex",
prompt_mode="read_prompt_file",
image="", dockerfile="", guest_env={},
dirs=(AgentProvisionDir("/home/node/.codex"),),
)
bottle = _make_bottle(exec_result=ExecResult(1, "", "mkdir: nope\n"))
with self.assertRaises(SystemExit):
CodexAgentProvider().provision(
_plan(agent_provision=provision), bottle,
)
class TestCodexSuperviseMcp(unittest.TestCase):
def test_noop_when_supervise_disabled(self):
bottle = _make_bottle()
CodexAgentProvider().provision_supervise_mcp(
_plan(supervise=False), bottle, _URL,
)
bottle.exec.assert_not_called()
def test_runs_codex_mcp_add_as_node(self):
bottle = _make_bottle()
CodexAgentProvider().provision_supervise_mcp(
_plan(supervise=True), bottle, _URL,
)
bottle.exec.assert_called_once()
script = bottle.exec.call_args.args[0]
self.assertEqual("node", bottle.exec.call_args.kwargs.get("user"))
self.assertIn("codex mcp add", script)
self.assertIn("--transport http", script)
self.assertIn("supervise", script)
self.assertIn(_URL, script)
def test_logs_warning_on_failure_but_does_not_raise(self):
bottle = _make_bottle(
exec_result=ExecResult(returncode=1, stdout="", stderr="boom"),
)
CodexAgentProvider().provision_supervise_mcp(
_plan(supervise=True), bottle, _URL,
)
if __name__ == "__main__":
unittest.main()
@@ -44,6 +44,7 @@ def _plan(tmp: str) -> DockerBottlePlan:
identity="test-teardown-00001",
)
return DockerBottlePlan(
guest_home="/home/node",
spec=spec,
stage_dir=stage,
git_gate_plan=GitGatePlan(
@@ -40,6 +40,7 @@ def _plan(*, git_user: dict | None = None,
copy_cwd=copy_cwd, user_cwd=user_cwd,
)
return DockerBottlePlan(
guest_home="/home/node",
spec=spec,
stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12",
@@ -1,173 +0,0 @@
"""Unit: docker provider auth marker provisioning."""
from __future__ import annotations
import unittest
from pathlib import Path
from unittest.mock import MagicMock
from bot_bottle.agent_provider import (
AgentProvisionDir,
AgentProvisionFile,
AgentProvisionPlan,
)
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.docker.provision import provider_auth as _provider_auth
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.workspace import workspace_plan
def _plan(
*,
codex_auth_file: Path | None = None,
agent_provider_template: str = "codex",
) -> DockerBottlePlan:
manifest = Manifest.from_json_obj({
"bottles": {"dev": {"agent_provider": {"template": "codex"}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
spec = BottleSpec(
manifest=manifest,
agent_name="demo",
copy_cwd=False,
user_cwd="/tmp/x",
)
return DockerBottlePlan(
spec=spec,
stage_dir=Path("/tmp/stage"),
slug="demo-abc12",
container_name="bot-bottle-demo-abc12",
container_name_pinned=False,
image="bot-bottle-codex:latest",
derived_image="",
runtime_image="bot-bottle-codex:latest",
dockerfile_path="",
env_file=Path("/tmp/agent.env"),
forwarded_env={},
prompt_file=Path("/tmp/prompt.txt"),
proxy_plan=PipelockProxyPlan(
yaml_path=Path("/tmp/pipelock.yaml"),
slug="demo-abc12",
),
git_gate_plan=GitGatePlan(
slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
hook_script=Path("/tmp/git-gate-hook"),
access_hook_script=Path("/tmp/git-gate-access-hook"),
upstreams=(),
),
egress_plan=EgressPlan(
slug="demo-abc12",
routes_path=Path("/tmp/routes.yaml"),
routes=(),
token_env_map={},
),
supervise_plan=None,
use_runsc=False,
agent_provision=_agent_provision(
agent_provider_template, codex_auth_file=codex_auth_file,
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
)
def _agent_provision(
template: str, *, codex_auth_file: Path | None = None,
) -> AgentProvisionPlan:
if template != "codex":
return AgentProvisionPlan(
template=template,
command=template,
prompt_mode="append_file",
image="",
dockerfile="",
guest_env={},
)
files = [
AgentProvisionFile(
Path("/tmp/codex-config.toml"),
"/home/node/.codex/config.toml",
),
]
if codex_auth_file is not None:
files.append(AgentProvisionFile(
codex_auth_file,
"/home/node/.codex/auth.json",
))
return AgentProvisionPlan(
template="codex",
command="codex",
prompt_mode="read_prompt_file",
image="bot-bottle-codex:latest",
dockerfile="",
guest_env={},
dirs=(AgentProvisionDir("/home/node/.codex"),),
files=tuple(files),
)
def _make_bottle(name: str = "bot-bottle-demo-abc12") -> MagicMock:
bottle = MagicMock(spec=Bottle)
bottle.name = name
bottle.exec.return_value = ExecResult(returncode=0, stdout="", stderr="")
return bottle
class TestProvisionProviderAuth(unittest.TestCase):
def test_noop_for_non_codex_provider(self):
bottle = _make_bottle()
_provider_auth.provision_provider_auth(
_plan(agent_provider_template="claude"), bottle,
)
self.assertEqual(0, bottle.cp_in.call_count)
self.assertEqual(0, bottle.exec.call_count)
def test_codex_provider_trusts_launch_dir_without_auth_file(self):
bottle = _make_bottle()
_provider_auth.provision_provider_auth(_plan(), bottle)
scripts = [c.args[0] for c in bottle.exec.call_args_list]
self.assertTrue(
any("mkdir -p" in s and "/home/node/.codex" in s for s in scripts)
)
cp_calls = [c.args for c in bottle.cp_in.call_args_list]
self.assertIn(
("/tmp/codex-config.toml", "/home/node/.codex/config.toml"),
cp_calls,
)
self.assertTrue(
any("chown" in s and "/home/node/.codex/config.toml" in s for s in scripts)
)
self.assertTrue(
any("chmod" in s and "/home/node/.codex/config.toml" in s for s in scripts)
)
def test_copies_dummy_auth_json_to_codex_home(self):
bottle = _make_bottle()
_provider_auth.provision_provider_auth(
_plan(codex_auth_file=Path("/tmp/codex-auth.json")),
bottle,
)
cp_calls = [c.args for c in bottle.cp_in.call_args_list]
self.assertIn(
("/tmp/codex-config.toml", "/home/node/.codex/config.toml"),
cp_calls,
)
self.assertIn(
("/tmp/codex-auth.json", "/home/node/.codex/auth.json"),
cp_calls,
)
scripts = [c.args[0] for c in bottle.exec.call_args_list]
self.assertTrue(
any("chown" in s and "/home/node/.codex/auth.json" in s for s in scripts)
)
self.assertTrue(
any("chmod" in s and "/home/node/.codex/auth.json" in s for s in scripts)
)
if __name__ == "__main__":
unittest.main()
+2
View File
@@ -103,6 +103,7 @@ def _proxy_plan(tmp: str) -> PipelockProxyPlan:
def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
stage = Path(tmp)
return DockerBottlePlan(
guest_home="/home/node",
spec=spec,
stage_dir=stage,
git_gate_plan=_git_gate_plan(tmp),
@@ -128,6 +129,7 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
stage = Path(tmp)
return SmolmachinesBottlePlan(
guest_home="/home/node",
spec=spec,
stage_dir=stage,
git_gate_plan=_git_gate_plan(tmp),
-30
View File
@@ -1,30 +0,0 @@
"""Unit: supervise MCP provisioning (PRD 0013 follow-up).
The real provisioning runs `claude mcp add` inside the agent
container exercised by the existing supervise integration test
chain once the agent container is brought up. Here we just cover
the URL computation so a regression in SUPERVISE_HOSTNAME / PORT
plumbing surfaces in unit CI."""
import unittest
from bot_bottle.backend.docker.provision.supervise import supervise_mcp_url
from bot_bottle.supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
class TestSuperviseMcpUrl(unittest.TestCase):
def test_url_matches_sidecar_constants(self):
self.assertEqual(
f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/",
supervise_mcp_url(),
)
def test_url_is_http_not_https(self):
# The agent dials the sidecar on the internal docker network;
# no TLS termination, no CA trust juggling. If this ever
# needs HTTPS, the sidecar's listener side has to change too.
self.assertTrue(supervise_mcp_url().startswith("http://"))
if __name__ == "__main__":
unittest.main()
+1 -291
View File
@@ -26,10 +26,6 @@ from bot_bottle.backend.smolmachines.bottle_plan import (
from bot_bottle.backend.smolmachines.provision import (
ca as _ca,
git as _git,
prompt as _prompt,
provider_auth as _provider_auth,
skills as _skills,
supervise as _supervise,
workspace as _workspace,
)
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
@@ -124,6 +120,7 @@ def _plan(
current_config_dir=Path("/tmp/current-config"),
)
return SmolmachinesBottlePlan(
guest_home="/home/node",
spec=spec,
stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12",
@@ -223,257 +220,6 @@ def _agent_provision(
)
class TestProvisionPrompt(unittest.TestCase):
def test_cp_uses_bottle_cp_in(self):
bottle = _make_bottle()
_prompt.provision_prompt(_plan(), bottle)
bottle.cp_in.assert_called_once_with(
"/tmp/state/demo-abc12/agent/prompt.txt",
"/home/node/.bot-bottle-prompt.txt",
)
def test_returns_path_when_agent_has_prompt(self):
bottle = _make_bottle()
r = _prompt.provision_prompt(
_plan(agent_prompt="You are a helpful assistant."),
bottle,
)
self.assertEqual("/home/node/.bot-bottle-prompt.txt", r)
def test_returns_none_when_agent_has_no_prompt(self):
# The file is still copied (path-must-exist contract);
# only the return value differs.
bottle = _make_bottle()
r = _prompt.provision_prompt(_plan(agent_prompt=""), bottle)
self.assertIsNone(r)
bottle.cp_in.assert_called_once()
def test_chowns_to_node_after_copy(self):
# cp_in lands as root; without the chown, the node user
# can't read its own mode-600 prompt.
bottle = _make_bottle()
_prompt.provision_prompt(_plan(), bottle)
scripts = _exec_scripts(bottle)
self.assertTrue(
any("chown node:node" in s and "/home/node/.bot-bottle-prompt.txt" in s
for s in scripts)
)
self.assertTrue(
any("chmod 600" in s and "/home/node/.bot-bottle-prompt.txt" in s
for s in scripts)
)
class TestProvisionProviderAuth(unittest.TestCase):
def test_noop_for_non_codex_provider(self):
bottle = _make_bottle()
_provider_auth.provision_provider_auth(_plan(), bottle)
self.assertEqual(0, bottle.cp_in.call_count)
self.assertEqual(0, bottle.exec.call_count)
def test_codex_provider_trusts_launch_dir_without_auth_file(self):
bottle = _make_bottle()
_provider_auth.provision_provider_auth(
_plan(agent_provider_template="codex"),
bottle,
)
bottle.cp_in.assert_called_once_with(
"/tmp/codex-config.toml",
"/home/node/.codex/config.toml",
)
scripts = _exec_scripts(bottle)
self.assertTrue(any("mkdir -p" in s and "/home/node/.codex" in s for s in scripts))
self.assertTrue(
any("chown" in s and "node:node" in s and "/home/node/.codex/config.toml" in s
for s in scripts)
)
self.assertTrue(
any("chmod" in s and "600" in s and "/home/node/.codex/config.toml" in s
for s in scripts)
)
def test_copies_dummy_auth_json_to_codex_home(self):
bottle = _make_bottle()
_provider_auth.provision_provider_auth(
_plan(
agent_provider_template="codex",
codex_auth_file=Path("/tmp/codex-auth.json"),
),
bottle,
)
cp_calls = [c.args for c in bottle.cp_in.call_args_list]
self.assertIn(
("/tmp/codex-config.toml", "/home/node/.codex/config.toml"),
cp_calls,
)
self.assertIn(
("/tmp/codex-auth.json", "/home/node/.codex/auth.json"),
cp_calls,
)
scripts = _exec_scripts(bottle)
self.assertTrue(any("mkdir -p" in s and "/home/node/.codex" in s for s in scripts))
self.assertTrue(
any("chown" in s and "node:node" in s and s.rstrip().endswith("/home/node/.codex")
for s in scripts)
)
self.assertTrue(
any("chmod" in s and "700" in s and s.rstrip().endswith("/home/node/.codex")
for s in scripts)
)
# The pre_copy `find ... -delete` script should be present
# (shlex.join properly quotes the `(`/`)`/`*.sqlite`).
self.assertTrue(
any("find" in s and "-delete" in s and "*.sqlite" in s for s in scripts)
)
self.assertTrue(
any("chown" in s and "/home/node/.codex/auth.json" in s for s in scripts)
)
self.assertTrue(
any("chmod" in s and "600" in s and "/home/node/.codex/auth.json" in s
for s in scripts)
)
# Verify command runs `codex login status` via runuser node.
self.assertTrue(
any("runuser" in s and "codex login status" in s for s in scripts)
)
def test_honors_codex_home_from_guest_env(self):
bottle = _make_bottle()
_provider_auth.provision_provider_auth(
_plan(
agent_provider_template="codex",
codex_auth_file=Path("/tmp/codex-auth.json"),
guest_env={"CODEX_HOME": "/run/codex-home"},
),
bottle,
)
cp_calls = [c.args for c in bottle.cp_in.call_args_list]
self.assertIn(
("/tmp/codex-config.toml", "/run/codex-home/config.toml"),
cp_calls,
)
self.assertIn(
("/tmp/codex-auth.json", "/run/codex-home/auth.json"),
cp_calls,
)
scripts = _exec_scripts(bottle)
self.assertTrue(
any("runuser" in s and "CODEX_HOME=/run/codex-home" in s and "codex login status" in s
for s in scripts)
)
def test_dies_when_codex_home_cannot_be_created(self):
bottle = _make_bottle(
exec_result=ExecResult(1, "", "mkdir: nope\n"),
)
with self.assertRaises(SystemExit):
_provider_auth.provision_provider_auth(
_plan(
agent_provider_template="codex",
codex_auth_file=Path("/tmp/codex-auth.json"),
),
bottle,
)
self.assertEqual(0, bottle.cp_in.call_count)
self.assertEqual(1, bottle.exec.call_count)
def test_dies_when_codex_rejects_dummy_auth(self):
# CODEX_HOME setup ok, but codex login status fails (last exec).
bottle = _make_bottle()
bottle.exec.side_effect = [
ExecResult(0, "", ""), # mkdir CODEX_HOME
ExecResult(0, "", ""), # chown CODEX_HOME
ExecResult(0, "", ""), # chmod CODEX_HOME
ExecResult(0, "", ""), # find ... -delete (pre_copy)
ExecResult(0, "", ""), # chown config.toml
ExecResult(0, "", ""), # chmod config.toml
ExecResult(0, "", ""), # chown auth.json
ExecResult(0, "", ""), # chmod auth.json
ExecResult(1, "Not logged in\n", ""), # login status (verify)
]
with self.assertRaises(SystemExit):
_provider_auth.provision_provider_auth(
_plan(
agent_provider_template="codex",
codex_auth_file=Path("/tmp/codex-auth.json"),
),
bottle,
)
class TestProvisionSkills(unittest.TestCase):
def _patch_host_skill_dir(self, returns: dict[str, str]):
return patch(
"bot_bottle.backend.smolmachines.provision.skills.host_skill_dir",
side_effect=lambda n: returns.get(n, f"/nope/{n}"),
)
def test_no_op_when_agent_has_no_skills(self):
bottle = _make_bottle()
_skills.provision_skills(_plan(skills=[]), bottle)
self.assertEqual(0, bottle.cp_in.call_count)
self.assertEqual(0, bottle.exec.call_count)
def test_mkdir_plus_cp_per_skill(self):
bottle = _make_bottle()
with self._patch_host_skill_dir({
"init-prd": "/host/skills/init-prd",
"verify": "/host/skills/verify",
}), patch(
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
return_value=True,
):
_skills.provision_skills(
_plan(skills=["init-prd", "verify"]),
bottle,
)
# mkdir skills_dir once + (rm -rf + chown) per skill = 5 exec calls.
self.assertEqual(5, bottle.exec.call_count)
scripts = _exec_scripts(bottle)
self.assertTrue(
any("mkdir -p" in s and "/home/node/.claude/skills" in s for s in scripts)
)
# Two cp calls, one per skill, into the per-skill subdir.
self.assertEqual(2, bottle.cp_in.call_count)
cp_targets = {call.args[1] for call in bottle.cp_in.call_args_list}
self.assertEqual(
{
"/home/node/.claude/skills/init-prd",
"/home/node/.claude/skills/verify",
},
cp_targets,
)
# Each skill gets a chown -R node:node so claude can read it.
chown_scripts = [s for s in scripts if "chown -R node:node" in s]
self.assertEqual(2, len(chown_scripts))
def test_skills_dir_overridable_via_env(self):
import os
bottle = _make_bottle()
with self._patch_host_skill_dir({"init-prd": "/host/skills/init-prd"}), \
patch(
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
return_value=True,
), \
patch.dict(os.environ, {"BOT_BOTTLE_GUEST_SKILLS_DIR": "/home/node/.claude/skills"}):
_skills.provision_skills(_plan(skills=["init-prd"]), bottle)
self.assertEqual(
"/home/node/.claude/skills/init-prd",
bottle.cp_in.call_args.args[1],
)
def test_missing_skill_dies(self):
bottle = _make_bottle()
with self._patch_host_skill_dir({"init-prd": "/host/skills/init-prd"}), \
patch(
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
return_value=False,
):
with self.assertRaises(SystemExit):
_skills.provision_skills(_plan(skills=["init-prd"]), bottle)
def _write_self_signed_cert(path: Path) -> None:
"""Drop a real self-signed PEM at `path` so provision_ca's
fingerprint computation (PEM_cert_to_DER_cert + sha256) has
@@ -774,41 +520,5 @@ class TestProvisionWorkspace(unittest.TestCase):
)
class TestProvisionSupervise(unittest.TestCase):
def test_noop_when_supervise_not_enabled(self):
bottle = _make_bottle()
_supervise.provision_supervise(_plan(), bottle)
bottle.exec.assert_not_called()
def test_calls_claude_mcp_add_when_supervise_enabled(self):
plan = _plan(
supervise=True,
agent_supervise_url="http://127.0.0.1:9100/",
)
bottle = _make_bottle()
_supervise.provision_supervise(plan, bottle)
bottle.exec.assert_called_once()
script = bottle.exec.call_args.args[0]
user = bottle.exec.call_args.kwargs.get("user")
self.assertEqual("node", user)
# SmolmachinesBottle.exec(user="node") handles uid switch +
# HOME setup automatically — the script itself is just the
# claude command.
self.assertIn("claude mcp add", script)
self.assertIn("--scope user", script)
self.assertIn("--transport http", script)
self.assertIn("supervise", script)
self.assertIn("http://127.0.0.1:9100/", script)
def test_non_zero_exit_logs_warning_but_does_not_raise(self):
plan = _plan(supervise=True)
bottle = _make_bottle(
exec_result=ExecResult(returncode=1, stdout="", stderr="boom"),
)
# No raise — the bottle still works without the MCP
# entry, so we log and move on.
_supervise.provision_supervise(plan, bottle)
if __name__ == "__main__":
unittest.main()