PRD 0050: Move provider-specific agent logic into contrib #180
+107
-134
@@ -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"
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
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
didericis
commented
`/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"]
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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 154–230 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 154–230 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 0013–0016 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-204–230 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-154–204 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.
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user
You were right — workspace_plan was the only place
guest_homelived, and reading it throughplan.workspace_plan.guest_homewas the wrong layer. Hoisted in21a46b97d8:guest_home: stris now a field on the baseBottlePlandataclass, populated by each backend'spreparestep. The contrib providers, this gitconfig path, and the smolmachines_guest_home()helper all readplan.guest_homedirectly.