Compare commits
7 Commits
pr-211
...
266013095e
| Author | SHA1 | Date | |
|---|---|---|---|
| 266013095e | |||
| df1091113c | |||
| df0f1ad980 | |||
| 970c5066d7 | |||
| 8c45016aa2 | |||
| 1443376268 | |||
| 2c6f248cda |
+107
-134
@@ -3,18 +3,32 @@
|
|||||||
The manifest owns the user-facing AgentProvider shape. This module is
|
The manifest owns the user-facing AgentProvider shape. This module is
|
||||||
the launch-time table that turns a provider template into an executable
|
the launch-time table that turns a provider template into an executable
|
||||||
command, default image, and prompt/auth behavior.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
from abc import ABC, abstractmethod
|
||||||
import os
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
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 EgressRoute
|
||||||
from .egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .backend import Bottle, BottlePlan
|
||||||
|
|
||||||
|
|
||||||
PROVIDER_CLAUDE = "claude"
|
PROVIDER_CLAUDE = "claude"
|
||||||
@@ -96,35 +110,88 @@ class AgentProvisionPlan:
|
|||||||
provisioned_env: dict[str, str] = field(default_factory=dict)
|
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 = {
|
def get_provider(template: str) -> AgentProvider:
|
||||||
PROVIDER_CLAUDE: AgentProviderRuntime(
|
"""Resolve a provider template name to its plugin instance.
|
||||||
template=PROVIDER_CLAUDE,
|
|
||||||
command="claude",
|
Lazy-imports the contrib module so importing this module doesn't
|
||||||
image="bot-bottle-claude:latest",
|
pull provider-specific code paths in. Mirrors the contrib
|
||||||
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
|
convention PRD 0048 established for deploy key provisioners."""
|
||||||
prompt_mode="append_file",
|
if template == PROVIDER_CLAUDE:
|
||||||
bypass_args=("--dangerously-skip-permissions",),
|
from .contrib.claude.agent_provider import ClaudeAgentProvider
|
||||||
resume_args=("--continue",),
|
return ClaudeAgentProvider()
|
||||||
remote_control_args=("--remote-control",),
|
if template == PROVIDER_CODEX:
|
||||||
),
|
from .contrib.codex.agent_provider import CodexAgentProvider
|
||||||
PROVIDER_CODEX: AgentProviderRuntime(
|
return CodexAgentProvider()
|
||||||
template=PROVIDER_CODEX,
|
raise ValueError(f"unknown agent provider template: {template!r}")
|
||||||
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 runtime_for(template: str) -> AgentProviderRuntime:
|
def runtime_for(template: str) -> AgentProviderRuntime:
|
||||||
return _RUNTIMES[template]
|
return get_provider(template).runtime
|
||||||
|
|
||||||
|
|
||||||
def agent_provision_plan(
|
def agent_provision_plan(
|
||||||
@@ -132,118 +199,24 @@ def agent_provision_plan(
|
|||||||
template: str,
|
template: str,
|
||||||
dockerfile: str,
|
dockerfile: str,
|
||||||
state_dir: Path,
|
state_dir: Path,
|
||||||
guest_home: str = "/home/node",
|
guest_home: str,
|
||||||
guest_env: dict[str, str] | None = None,
|
guest_env: dict[str, str] | None = None,
|
||||||
auth_token: str = "",
|
auth_token: str = "",
|
||||||
forward_host_credentials: bool = False,
|
forward_host_credentials: bool = False,
|
||||||
host_env: dict[str, str] | None = None,
|
host_env: dict[str, str] | None = None,
|
||||||
trusted_project_path: str = "",
|
trusted_project_path: str = "",
|
||||||
) -> AgentProvisionPlan:
|
) -> AgentProvisionPlan:
|
||||||
runtime = runtime_for(template)
|
"""Back-compat shim — `prepare` callers stay the same; the work
|
||||||
resolved_guest_env = dict(guest_env or {})
|
now lives on the provider plugin."""
|
||||||
trusted_path = trusted_project_path or guest_home
|
return get_provider(template).provision_plan(
|
||||||
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,
|
|
||||||
dockerfile=dockerfile,
|
dockerfile=dockerfile,
|
||||||
env_vars=env_vars,
|
state_dir=state_dir,
|
||||||
guest_env=resolved_guest_env,
|
guest_home=guest_home,
|
||||||
dirs=tuple(dirs),
|
guest_env=guest_env,
|
||||||
files=tuple(files),
|
auth_token=auth_token,
|
||||||
pre_copy=tuple(pre_copy),
|
forward_host_credentials=forward_host_credentials,
|
||||||
verify=tuple(verify),
|
host_env=host_env,
|
||||||
egress_routes=tuple(egress_routes),
|
trusted_project_path=trusted_project_path,
|
||||||
hidden_env_names=hidden_env_names,
|
|
||||||
provisioned_env=provisioned_env,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Generic, Sequence, TypeVar
|
from typing import Any, Generic, Sequence, TypeVar
|
||||||
|
|
||||||
from ..agent_provider import AgentProvisionPlan
|
from ..agent_provider import AgentProvisionPlan, get_provider
|
||||||
from ..egress import EgressPlan
|
from ..egress import EgressPlan
|
||||||
from ..git_gate import GitGatePlan
|
from ..git_gate import GitGatePlan
|
||||||
from ..log import die, info
|
from ..log import die, info
|
||||||
@@ -76,6 +76,7 @@ class BottlePlan(ABC):
|
|||||||
|
|
||||||
spec: BottleSpec
|
spec: BottleSpec
|
||||||
stage_dir: Path
|
stage_dir: Path
|
||||||
|
guest_home: str
|
||||||
git_gate_plan: GitGatePlan
|
git_gate_plan: GitGatePlan
|
||||||
egress_plan: EgressPlan
|
egress_plan: EgressPlan
|
||||||
supervise_plan: SupervisePlan | None
|
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
|
to decide whether to add provider-specific prompt args to the
|
||||||
agent's argv.
|
agent's argv.
|
||||||
|
|
||||||
Default orchestration: ca → prompt → skills → workspace → git →
|
Default orchestration: ca → prompt → provider apply → skills
|
||||||
supervise. CA install runs first so the agent's trust store
|
→ workspace → git → supervise-mcp. CA install runs first so
|
||||||
is rebuilt before anything inside the agent makes a TLS call.
|
the agent's trust store is rebuilt before anything inside the
|
||||||
Subclasses typically don't override this; they implement the
|
agent makes a TLS call.
|
||||||
sub-methods below.
|
|
||||||
|
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,
|
PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc,
|
||||||
~/.gitconfig insteadOf, tea config) are gone. Egress-proxy is
|
~/.gitconfig insteadOf, tea config) are gone. Egress-proxy is
|
||||||
on the agent's HTTP_PROXY path so every tool that respects
|
on the agent's HTTP_PROXY path so every tool that respects
|
||||||
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
|
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
|
||||||
intercepted without per-tool reconfiguration."""
|
intercepted without per-tool reconfiguration."""
|
||||||
|
provider = get_provider(plan.agent_provision.template)
|
||||||
self.provision_ca(plan, bottle)
|
self.provision_ca(plan, bottle)
|
||||||
prompt_path = self.provision_prompt(plan, bottle)
|
prompt_path = provider.provision_prompt(plan, bottle)
|
||||||
self.provision_provider_auth(plan, bottle)
|
provider.provision(plan, bottle)
|
||||||
self.provision_skills(plan, bottle)
|
provider.provision_skills(plan, bottle)
|
||||||
self.provision_workspace(plan, bottle)
|
self.provision_workspace(plan, bottle)
|
||||||
self.provision_git(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
|
return prompt_path
|
||||||
|
|
||||||
def provision_ca(self, plan: PlanT, bottle: "Bottle") -> None:
|
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
|
backend overrides to docker-cp the cert in and run
|
||||||
`update-ca-certificates`."""
|
`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:
|
def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None:
|
||||||
"""Copy the operator workspace into the running bottle when
|
"""Copy the operator workspace into the running bottle when
|
||||||
the backend cannot bake it into the agent image. Default is
|
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
|
"""Copy the host's cwd `.git` directory into the running
|
||||||
bottle if the user requested --cwd. No-op otherwise."""
|
bottle if the user requested --cwd. No-op otherwise."""
|
||||||
|
|
||||||
def provision_supervise(self, plan: PlanT, bottle: "Bottle") -> None:
|
def supervise_mcp_url(self, plan: PlanT) -> str:
|
||||||
"""Write the in-bottle Claude Code MCP config so the agent
|
"""Return the agent-side URL of the per-bottle supervise
|
||||||
discovers the per-bottle supervise sidecar (PRD 0013).
|
sidecar, or "" when this bottle has no sidecar. The provider
|
||||||
No-op when bottle.supervise is False or the backend doesn't
|
plugin's `provision_supervise_mcp` uses it to register the
|
||||||
support the supervise sidecar yet. The Docker backend
|
MCP entry inside the guest.
|
||||||
overrides."""
|
|
||||||
|
Default returns "" so backends without supervise support
|
||||||
|
don't have to implement it. Docker and smolmachines override."""
|
||||||
|
del plan
|
||||||
|
return ""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def prepare_cleanup(self) -> CleanupT:
|
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
|
The base class's `prepare` template runs cross-backend host-side
|
||||||
validation before calling `_resolve_plan` here.
|
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
|
from __future__ import annotations
|
||||||
@@ -18,6 +24,7 @@ from contextlib import contextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator, Sequence
|
from typing import Generator, Sequence
|
||||||
|
|
||||||
|
from ...supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
|
||||||
from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec
|
from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec
|
||||||
from . import cleanup as _cleanup
|
from . import cleanup as _cleanup
|
||||||
from . import enumerate as _enumerate
|
from . import enumerate as _enumerate
|
||||||
@@ -28,10 +35,6 @@ from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
|||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
from .provision import ca as _ca
|
from .provision import ca as _ca
|
||||||
from .provision import git as _git
|
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"]):
|
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
||||||
@@ -60,20 +63,16 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
def provision_ca(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
|
def provision_ca(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
_ca.provision_ca(plan, bottle)
|
_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:
|
def provision_git(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
_git.provision_git(plan, bottle)
|
_git.provision_git(plan, bottle)
|
||||||
|
|
||||||
def provision_supervise(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
|
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
|
||||||
_supervise_prov.provision_supervise(plan, bottle)
|
"""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:
|
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
|
||||||
return _cleanup.prepare_cleanup()
|
return _cleanup.prepare_cleanup()
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ def resolve_plan(
|
|||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle_for(spec.agent_name)
|
||||||
provider = bottle.agent_provider
|
provider = bottle.agent_provider
|
||||||
provider_runtime = runtime_for(provider.template)
|
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)
|
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
|
||||||
|
|
||||||
# PRD 0016 follow-up: identity, not bare slug. A fresh `start`
|
# PRD 0016 follow-up: identity, not bare slug. A fresh `start`
|
||||||
@@ -233,6 +233,7 @@ def resolve_plan(
|
|||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
|
guest_home=guest_home,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
container_name=container_name,
|
container_name=container_name,
|
||||||
container_name_pinned=container_name_pinned,
|
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:
|
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
|
||||||
provision_<thing>(plan: DockerBottlePlan, bottle: Bottle) -> ...
|
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
|
- ca.py — install per-bottle CA bundle into the guest trust store
|
||||||
abstract `BottleBackend.provision_*` surface is unchanged; this
|
- git.py — copy host cwd `.git` into the guest when --cwd is used
|
||||||
subpackage exists only to keep `backend.py` from being a god-file."""
|
"""
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ Three concerns, all about git in the agent:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
from ....git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
|
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)
|
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
if not manifest_bottle.git:
|
if not manifest_bottle.git:
|
||||||
return
|
return
|
||||||
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
container_gitconfig = f"{plan.guest_home}/.gitconfig"
|
||||||
container_gitconfig = f"{container_home}/.gitconfig"
|
|
||||||
|
|
||||||
content = git_gate_render_gitconfig(manifest_bottle.git, GIT_GATE_HOSTNAME)
|
content = git_gate_render_gitconfig(manifest_bottle.git, GIT_GATE_HOSTNAME)
|
||||||
config_file = plan.stage_dir / "agent_gitconfig"
|
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
|
"""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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -18,10 +24,6 @@ from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
|
|||||||
from .bottle_plan import SmolmachinesBottlePlan
|
from .bottle_plan import SmolmachinesBottlePlan
|
||||||
from .provision import ca as _ca
|
from .provision import ca as _ca
|
||||||
from .provision import git as _git
|
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
|
from .provision import workspace as _workspace
|
||||||
|
|
||||||
|
|
||||||
@@ -58,21 +60,6 @@ class SmolmachinesBottleBackend(
|
|||||||
) -> None:
|
) -> None:
|
||||||
_ca.provision_ca(plan, bottle)
|
_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(
|
def provision_workspace(
|
||||||
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -83,10 +70,12 @@ class SmolmachinesBottleBackend(
|
|||||||
) -> None:
|
) -> None:
|
||||||
_git.provision_git(plan, bottle)
|
_git.provision_git(plan, bottle)
|
||||||
|
|
||||||
def provision_supervise(
|
def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str:
|
||||||
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
"""The smolmachines guest reaches the supervise sidecar via a
|
||||||
) -> None:
|
host-published random port the launch step pinned earlier
|
||||||
_supervise.provision_supervise(plan, bottle)
|
(`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:
|
def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan:
|
||||||
return _cleanup.prepare_cleanup()
|
return _cleanup.prepare_cleanup()
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ def resolve_plan(
|
|||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle_for(spec.agent_name)
|
||||||
provider = bottle.agent_provider
|
provider = bottle.agent_provider
|
||||||
provider_runtime = runtime_for(provider.template)
|
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)
|
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
|
||||||
|
|
||||||
slug = spec.identity or bottle_identity(spec.agent_name)
|
slug = spec.identity or bottle_identity(spec.agent_name)
|
||||||
@@ -172,6 +172,7 @@ def resolve_plan(
|
|||||||
return SmolmachinesBottlePlan(
|
return SmolmachinesBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
|
guest_home=guest_home,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
bundle_subnet=subnet,
|
bundle_subnet=subnet,
|
||||||
bundle_gateway=gateway,
|
bundle_gateway=gateway,
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
"""Provisioning helpers for the smolmachines backend (PRD 0023
|
"""Backend-infrastructure provisioners for the smolmachines backend.
|
||||||
chunk 4).
|
|
||||||
|
|
||||||
Each method maps onto one of `BottleBackend`'s `provision_*`
|
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
|
||||||
overrides. They run after the VM is up + the bundle is reachable
|
declarative provision-plan apply, supervise MCP registration) live on
|
||||||
and copy host-side state (prompt, skills, .git, CA cert,
|
the `AgentProvider` plugin under `bot_bottle/contrib/`. The modules
|
||||||
supervise MCP config) into the guest via `smolvm machine cp` /
|
left in this subpackage handle only the steps that are
|
||||||
`smolvm machine exec`.
|
backend-specific:
|
||||||
|
|
||||||
Chunk 4a ships `provision_prompt` and `provision_skills` — the
|
- ca.py — install per-bottle CA bundle into the guest trust store
|
||||||
two that don't depend on agent-image tooling (claude-code,
|
- git.py — copy host cwd `.git` into the guest when --cwd is used
|
||||||
update-ca-certificates) beyond `cp` and `mkdir`. provision_ca /
|
- workspace.py — copy the operator workspace into the guest
|
||||||
provision_git / provision_supervise land once the agent-image
|
"""
|
||||||
gap is solved."""
|
|
||||||
|
|||||||
@@ -36,17 +36,6 @@ from ... import Bottle
|
|||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
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:
|
def provision_git(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Set up git inside the guest. Runs all three subcases; each
|
"""Set up git inside the guest. Runs all three subcases; each
|
||||||
no-ops when its condition isn't met."""
|
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",
|
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
|
# 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
|
# has a stable host path. The plan's stage_dir is cleaned up
|
||||||
# by start.py's session-end teardown.
|
# 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):
|
def test_codex_plan_declares_home_state(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="/tmp/Dockerfile.codex",
|
dockerfile="/tmp/Dockerfile.codex",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -51,6 +52,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
def test_codex_trusts_requested_project_path(self):
|
def test_codex_trusts_requested_project_path(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
agent_provision_plan(
|
agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -68,6 +70,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
"tokens": {"access_token": _jwt(2000000000)},
|
"tokens": {"access_token": _jwt(2000000000)},
|
||||||
}))
|
}))
|
||||||
plan = agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -87,6 +90,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
def test_claude_with_auth_token_injects_provider_route_and_placeholder(self):
|
def test_claude_with_auth_token_injects_provider_route_and_placeholder(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="claude",
|
template="claude",
|
||||||
dockerfile="/tmp/Dockerfile.claude",
|
dockerfile="/tmp/Dockerfile.claude",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -109,6 +113,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
def test_claude_trusts_requested_project_path(self):
|
def test_claude_trusts_requested_project_path(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
agent_provision_plan(
|
agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="claude",
|
template="claude",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -127,6 +132,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
"tokens": {"access_token": _jwt(2000000000)},
|
"tokens": {"access_token": _jwt(2000000000)},
|
||||||
}))
|
}))
|
||||||
plan = agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -143,6 +149,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self):
|
def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -160,6 +167,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
def test_claude_without_auth_token_has_passthrough_egress_route(self):
|
def test_claude_without_auth_token_has_passthrough_egress_route(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="claude",
|
template="claude",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -183,6 +191,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
"tokens": {"access_token": access},
|
"tokens": {"access_token": access},
|
||||||
}))
|
}))
|
||||||
plan = agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -197,6 +206,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
def test_codex_without_forward_host_credentials_has_empty_provisioned_env(self):
|
def test_codex_without_forward_host_credentials_has_empty_provisioned_env(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ def _plan(
|
|||||||
|
|
||||||
spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
|
spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
|
guest_home="/home/node",
|
||||||
spec=spec,
|
spec=spec,
|
||||||
stage_dir=STAGE,
|
stage_dir=STAGE,
|
||||||
slug=SLUG,
|
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",
|
identity="test-teardown-00001",
|
||||||
)
|
)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
|
guest_home="/home/node",
|
||||||
spec=spec,
|
spec=spec,
|
||||||
stage_dir=stage,
|
stage_dir=stage,
|
||||||
git_gate_plan=GitGatePlan(
|
git_gate_plan=GitGatePlan(
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ def _plan(*, git_user: dict | None = None,
|
|||||||
copy_cwd=copy_cwd, user_cwd=user_cwd,
|
copy_cwd=copy_cwd, user_cwd=user_cwd,
|
||||||
)
|
)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
|
guest_home="/home/node",
|
||||||
spec=spec,
|
spec=spec,
|
||||||
stage_dir=stage_dir or Path("/tmp/stage"),
|
stage_dir=stage_dir or Path("/tmp/stage"),
|
||||||
slug="demo-abc12",
|
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:
|
def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
|
||||||
stage = Path(tmp)
|
stage = Path(tmp)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
|
guest_home="/home/node",
|
||||||
spec=spec,
|
spec=spec,
|
||||||
stage_dir=stage,
|
stage_dir=stage,
|
||||||
git_gate_plan=_git_gate_plan(tmp),
|
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:
|
def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
|
||||||
stage = Path(tmp)
|
stage = Path(tmp)
|
||||||
return SmolmachinesBottlePlan(
|
return SmolmachinesBottlePlan(
|
||||||
|
guest_home="/home/node",
|
||||||
spec=spec,
|
spec=spec,
|
||||||
stage_dir=stage,
|
stage_dir=stage,
|
||||||
git_gate_plan=_git_gate_plan(tmp),
|
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 (
|
from bot_bottle.backend.smolmachines.provision import (
|
||||||
ca as _ca,
|
ca as _ca,
|
||||||
git as _git,
|
git as _git,
|
||||||
prompt as _prompt,
|
|
||||||
provider_auth as _provider_auth,
|
|
||||||
skills as _skills,
|
|
||||||
supervise as _supervise,
|
|
||||||
workspace as _workspace,
|
workspace as _workspace,
|
||||||
)
|
)
|
||||||
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
|
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
|
||||||
@@ -124,6 +120,7 @@ def _plan(
|
|||||||
current_config_dir=Path("/tmp/current-config"),
|
current_config_dir=Path("/tmp/current-config"),
|
||||||
)
|
)
|
||||||
return SmolmachinesBottlePlan(
|
return SmolmachinesBottlePlan(
|
||||||
|
guest_home="/home/node",
|
||||||
spec=spec,
|
spec=spec,
|
||||||
stage_dir=stage_dir or Path("/tmp/stage"),
|
stage_dir=stage_dir or Path("/tmp/stage"),
|
||||||
slug="demo-abc12",
|
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:
|
def _write_self_signed_cert(path: Path) -> None:
|
||||||
"""Drop a real self-signed PEM at `path` so provision_ca's
|
"""Drop a real self-signed PEM at `path` so provision_ca's
|
||||||
fingerprint computation (PEM_cert_to_DER_cert + sha256) has
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user