From 665d97e0ea2550cd35462d8fa21f535ab82a4ed7 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 3 Jun 2026 21:21:42 +0000 Subject: [PATCH] refactor(backend): move per-provider provisioning onto AgentProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BottleBackend.provision now resolves the provider plugin from the plan and dispatches prompt / skills / declarative-apply / supervise-mcp through it. The four hooks the docker + smolmachines backends used to override (provision_skills, provision_prompt, provision_provider_auth, provision_supervise) are gone — the duplicated 50-line implementations under backend/{docker,smolmachines}/provision/{skills,prompt, provider_auth,supervise}.py are deleted. Each backend gains a small supervise_mcp_url(plan) override so the provider plugin can run `claude mcp add` / `codex mcp add` against the right URL: docker returns http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/ on the compose network alias; smolmachines returns plan.agent_supervise_url which launch.py already pins to a host-loopback port. Removes tests/unit/test_provision_supervise.py — the URL it asserted on now lives on the backend, with no equivalent standalone surface to test against (it's covered by the broader plan / launch integration tests). --- bot_bottle/backend/__init__.py | 62 +++++++++--------- bot_bottle/backend/docker/backend.py | 29 +++++---- .../backend/docker/provision/__init__.py | 15 +++-- bot_bottle/backend/docker/provision/prompt.py | 33 ---------- .../backend/docker/provision/provider_auth.py | 35 ----------- bot_bottle/backend/docker/provision/skills.py | 44 ------------- .../backend/docker/provision/supervise.py | 59 ----------------- bot_bottle/backend/smolmachines/backend.py | 37 ++++------- .../smolmachines/provision/__init__.py | 22 +++---- .../backend/smolmachines/provision/prompt.py | 44 ------------- .../smolmachines/provision/provider_auth.py | 35 ----------- .../backend/smolmachines/provision/skills.py | 63 ------------------- .../smolmachines/provision/supervise.py | 58 ----------------- tests/unit/test_provision_supervise.py | 30 --------- 14 files changed, 75 insertions(+), 491 deletions(-) delete mode 100644 bot_bottle/backend/docker/provision/prompt.py delete mode 100644 bot_bottle/backend/docker/provision/provider_auth.py delete mode 100644 bot_bottle/backend/docker/provision/skills.py delete mode 100644 bot_bottle/backend/docker/provision/supervise.py delete mode 100644 bot_bottle/backend/smolmachines/provision/prompt.py delete mode 100644 bot_bottle/backend/smolmachines/provision/provider_auth.py delete mode 100644 bot_bottle/backend/smolmachines/provision/skills.py delete mode 100644 bot_bottle/backend/smolmachines/provision/supervise.py delete mode 100644 tests/unit/test_provision_supervise.py diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index 6a50ed4..0af4a84 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -39,7 +39,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Generic, Sequence, TypeVar -from ..agent_provider import AgentProvisionPlan +from ..agent_provider import AgentProvisionPlan, get_provider from ..egress import EgressPlan from ..git_gate import GitGatePlan from ..log import die, info @@ -320,24 +320,33 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): to decide whether to add provider-specific prompt args to the agent's argv. - Default orchestration: ca → prompt → skills → workspace → git → - supervise. CA install runs first so the agent's trust store - is rebuilt before anything inside the agent makes a TLS call. - Subclasses typically don't override this; they implement the - sub-methods below. + Default orchestration: ca → prompt → provider apply → skills + → workspace → git → supervise-mcp. CA install runs first so + the agent's trust store is rebuilt before anything inside the + agent makes a TLS call. + + Per PRD 0050 the per-provider steps (prompt, skills, + declarative provision-plan apply, supervise MCP registration) + live on the `AgentProvider` plugin. The backend only owns the + steps that are about backend infrastructure (CA, workspace, + git) and surfaces the supervise sidecar URL its launch step + knows about via `supervise_mcp_url`. PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc, ~/.gitconfig insteadOf, tea config) are gone. Egress-proxy is on the agent's HTTP_PROXY path so every tool that respects HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is intercepted without per-tool reconfiguration.""" + provider = get_provider(plan.agent_provision.template) self.provision_ca(plan, bottle) - prompt_path = self.provision_prompt(plan, bottle) - self.provision_provider_auth(plan, bottle) - self.provision_skills(plan, bottle) + prompt_path = provider.provision_prompt(plan, bottle) + provider.provision(plan, bottle) + provider.provision_skills(plan, bottle) self.provision_workspace(plan, bottle) self.provision_git(plan, bottle) - self.provision_supervise(plan, bottle) + provider.provision_supervise_mcp( + plan, bottle, self.supervise_mcp_url(plan), + ) return prompt_path def provision_ca(self, plan: PlanT, bottle: "Bottle") -> None: @@ -349,23 +358,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): backend overrides to docker-cp the cert in and run `update-ca-certificates`.""" - def provision_provider_auth(self, plan: PlanT, bottle: "Bottle") -> None: - """Install non-secret provider auth marker files into the agent - home when a provider needs them to select the right auth mode. - The default is no-op.""" - - @abstractmethod - def provision_prompt(self, plan: PlanT, bottle: "Bottle") -> str | None: - """Copy the prompt file into the running bottle. Returns the - in-container path iff the agent has a non-empty prompt; - callers use the return value to decide whether to add - provider-specific prompt args to the agent's argv.""" - - @abstractmethod - def provision_skills(self, plan: PlanT, bottle: "Bottle") -> None: - """Copy the agent's named skills from the host into the - running bottle. No-op when the agent has no skills.""" - def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None: """Copy the operator workspace into the running bottle when the backend cannot bake it into the agent image. Default is @@ -376,12 +368,16 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): """Copy the host's cwd `.git` directory into the running bottle if the user requested --cwd. No-op otherwise.""" - def provision_supervise(self, plan: PlanT, bottle: "Bottle") -> None: - """Write the in-bottle Claude Code MCP config so the agent - discovers the per-bottle supervise sidecar (PRD 0013). - No-op when bottle.supervise is False or the backend doesn't - support the supervise sidecar yet. The Docker backend - overrides.""" + def supervise_mcp_url(self, plan: PlanT) -> str: + """Return the agent-side URL of the per-bottle supervise + sidecar, or "" when this bottle has no sidecar. The provider + plugin's `provision_supervise_mcp` uses it to register the + MCP entry inside the guest. + + Default returns "" so backends without supervise support + don't have to implement it. Docker and smolmachines override.""" + del plan + return "" @abstractmethod def prepare_cleanup(self) -> CleanupT: diff --git a/bot_bottle/backend/docker/backend.py b/bot_bottle/backend/docker/backend.py index e502de2..539369b 100644 --- a/bot_bottle/backend/docker/backend.py +++ b/bot_bottle/backend/docker/backend.py @@ -9,6 +9,12 @@ This module is a thin façade. The real work lives in four siblings: The base class's `prepare` template runs cross-backend host-side validation before calling `_resolve_plan` here. + +Per PRD 0050 the per-provider provisioning steps (prompt, skills, +the declarative provision-plan apply, supervise MCP registration) +live on the `AgentProvider` plugin under `bot_bottle/contrib/`. The +Docker backend only owns the steps that are about backend +infrastructure: CA install and git copy-in. """ from __future__ import annotations @@ -18,6 +24,7 @@ from contextlib import contextmanager from pathlib import Path from typing import Generator, Sequence +from ...supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec from . import cleanup as _cleanup from . import enumerate as _enumerate @@ -28,10 +35,6 @@ from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan from .provision import ca as _ca from .provision import git as _git -from .provision import prompt as _prompt -from .provision import provider_auth as _provider_auth -from .provision import skills as _skills -from .provision import supervise as _supervise_prov class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]): @@ -60,20 +63,16 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup def provision_ca(self, plan: DockerBottlePlan, bottle: Bottle) -> None: _ca.provision_ca(plan, bottle) - def provision_prompt(self, plan: DockerBottlePlan, bottle: Bottle) -> str | None: - return _prompt.provision_prompt(plan, bottle) - - def provision_provider_auth(self, plan: DockerBottlePlan, bottle: Bottle) -> None: - _provider_auth.provision_provider_auth(plan, bottle) - - def provision_skills(self, plan: DockerBottlePlan, bottle: Bottle) -> None: - _skills.provision_skills(plan, bottle) - def provision_git(self, plan: DockerBottlePlan, bottle: Bottle) -> None: _git.provision_git(plan, bottle) - def provision_supervise(self, plan: DockerBottlePlan, bottle: Bottle) -> None: - _supervise_prov.provision_supervise(plan, bottle) + def supervise_mcp_url(self, plan: DockerBottlePlan) -> str: + """Docker bottles reach the supervise sidecar via the + compose-network alias `supervise:9100`. No per-bottle URL + plumbing needed; the alias resolves inside the bridge.""" + if plan.supervise_plan is None: + return "" + return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/" def prepare_cleanup(self) -> DockerBottleCleanupPlan: return _cleanup.prepare_cleanup() diff --git a/bot_bottle/backend/docker/provision/__init__.py b/bot_bottle/backend/docker/provision/__init__.py index 2e87ef9..0c62fa1 100644 --- a/bot_bottle/backend/docker/provision/__init__.py +++ b/bot_bottle/backend/docker/provision/__init__.py @@ -1,8 +1,11 @@ -"""Per-provisioner modules for the Docker backend. +"""Backend-infrastructure provisioners for the Docker backend. -Each module exports one top-level function: - provision_(plan: DockerBottlePlan, bottle: Bottle) -> ... +Per PRD 0050 the per-provider provisioning steps (prompt, skills, +declarative provision-plan apply, supervise MCP registration) live on +the `AgentProvider` plugin under `bot_bottle/contrib/`. The modules +left in this subpackage handle only the steps that are +backend-specific: -`DockerBottleBackend.provision_*` methods delegate to these. The -abstract `BottleBackend.provision_*` surface is unchanged; this -subpackage exists only to keep `backend.py` from being a god-file.""" + - ca.py — install per-bottle CA bundle into the guest trust store + - git.py — copy host cwd `.git` into the guest when --cwd is used +""" diff --git a/bot_bottle/backend/docker/provision/prompt.py b/bot_bottle/backend/docker/provision/prompt.py deleted file mode 100644 index c0395f4..0000000 --- a/bot_bottle/backend/docker/provision/prompt.py +++ /dev/null @@ -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 diff --git a/bot_bottle/backend/docker/provision/provider_auth.py b/bot_bottle/backend/docker/provision/provider_auth.py deleted file mode 100644 index 0068e36..0000000 --- a/bot_bottle/backend/docker/provision/provider_auth.py +++ /dev/null @@ -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}") diff --git a/bot_bottle/backend/docker/provision/skills.py b/bot_bottle/backend/docker/provision/skills.py deleted file mode 100644 index 82f09e5..0000000 --- a/bot_bottle/backend/docker/provision/skills.py +++ /dev/null @@ -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// into the container's equivalent path. - For each skill: ensure parent dir, wipe any prior copy, then - `cp_in /. :/` 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}/") diff --git a/bot_bottle/backend/docker/provision/supervise.py b/bot_bottle/backend/docker/provision/supervise.py deleted file mode 100644 index 71b48cd..0000000 --- a/bot_bottle/backend/docker/provision/supervise.py +++ /dev/null @@ -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"] diff --git a/bot_bottle/backend/smolmachines/backend.py b/bot_bottle/backend/smolmachines/backend.py index bdf1270..e29f1c3 100644 --- a/bot_bottle/backend/smolmachines/backend.py +++ b/bot_bottle/backend/smolmachines/backend.py @@ -1,5 +1,11 @@ """SmolmachinesBottleBackend — the smolmachines implementation of -BottleBackend (PRD 0023).""" +BottleBackend (PRD 0023). + +Per PRD 0050 the per-provider provisioning steps (prompt, skills, +the declarative provision-plan apply, supervise MCP registration) +live on the `AgentProvider` plugin under `bot_bottle/contrib/`. The +smolmachines backend only owns the steps that are about backend +infrastructure: CA install (no-op for now), workspace, git copy-in.""" from __future__ import annotations @@ -18,10 +24,6 @@ from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan from .bottle_plan import SmolmachinesBottlePlan from .provision import ca as _ca from .provision import git as _git -from .provision import prompt as _prompt -from .provision import provider_auth as _provider_auth -from .provision import skills as _skills -from .provision import supervise as _supervise from .provision import workspace as _workspace @@ -58,21 +60,6 @@ class SmolmachinesBottleBackend( ) -> None: _ca.provision_ca(plan, bottle) - def provision_prompt( - self, plan: SmolmachinesBottlePlan, bottle: Bottle - ) -> str | None: - return _prompt.provision_prompt(plan, bottle) - - def provision_provider_auth( - self, plan: SmolmachinesBottlePlan, bottle: Bottle - ) -> None: - _provider_auth.provision_provider_auth(plan, bottle) - - def provision_skills( - self, plan: SmolmachinesBottlePlan, bottle: Bottle - ) -> None: - _skills.provision_skills(plan, bottle) - def provision_workspace( self, plan: SmolmachinesBottlePlan, bottle: Bottle ) -> None: @@ -83,10 +70,12 @@ class SmolmachinesBottleBackend( ) -> None: _git.provision_git(plan, bottle) - def provision_supervise( - self, plan: SmolmachinesBottlePlan, bottle: Bottle - ) -> None: - _supervise.provision_supervise(plan, bottle) + def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str: + """The smolmachines guest reaches the supervise sidecar via a + host-published random port the launch step pinned earlier + (`http://:/`). `agent_supervise_url` + on the plan is "" when the bottle has no sidecar.""" + return plan.agent_supervise_url def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan: return _cleanup.prepare_cleanup() diff --git a/bot_bottle/backend/smolmachines/provision/__init__.py b/bot_bottle/backend/smolmachines/provision/__init__.py index cc202c0..97d5288 100644 --- a/bot_bottle/backend/smolmachines/provision/__init__.py +++ b/bot_bottle/backend/smolmachines/provision/__init__.py @@ -1,14 +1,12 @@ -"""Provisioning helpers for the smolmachines backend (PRD 0023 -chunk 4). +"""Backend-infrastructure provisioners for the smolmachines backend. -Each method maps onto one of `BottleBackend`'s `provision_*` -overrides. They run after the VM is up + the bundle is reachable -and copy host-side state (prompt, skills, .git, CA cert, -supervise MCP config) into the guest via `smolvm machine cp` / -`smolvm machine exec`. +Per PRD 0050 the per-provider provisioning steps (prompt, skills, +declarative provision-plan apply, supervise MCP registration) live on +the `AgentProvider` plugin under `bot_bottle/contrib/`. The modules +left in this subpackage handle only the steps that are +backend-specific: -Chunk 4a ships `provision_prompt` and `provision_skills` — the -two that don't depend on agent-image tooling (claude-code, -update-ca-certificates) beyond `cp` and `mkdir`. provision_ca / -provision_git / provision_supervise land once the agent-image -gap is solved.""" + - ca.py — install per-bottle CA bundle into the guest trust store + - git.py — copy host cwd `.git` into the guest when --cwd is used + - workspace.py — copy the operator workspace into the guest +""" diff --git a/bot_bottle/backend/smolmachines/provision/prompt.py b/bot_bottle/backend/smolmachines/provision/prompt.py deleted file mode 100644 index 7ab24f3..0000000 --- a/bot_bottle/backend/smolmachines/provision/prompt.py +++ /dev/null @@ -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 diff --git a/bot_bottle/backend/smolmachines/provision/provider_auth.py b/bot_bottle/backend/smolmachines/provision/provider_auth.py deleted file mode 100644 index 426bed4..0000000 --- a/bot_bottle/backend/smolmachines/provision/provider_auth.py +++ /dev/null @@ -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}") diff --git a/bot_bottle/backend/smolmachines/provision/skills.py b/bot_bottle/backend/smolmachines/provision/skills.py deleted file mode 100644 index 7f21625..0000000 --- a/bot_bottle/backend/smolmachines/provision/skills.py +++ /dev/null @@ -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//) 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// 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") diff --git a/bot_bottle/backend/smolmachines/provision/supervise.py b/bot_bottle/backend/smolmachines/provision/supervise.py deleted file mode 100644 index 58929a3..0000000 --- a/bot_bottle/backend/smolmachines/provision/supervise.py +++ /dev/null @@ -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 `:` 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:/` 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"] diff --git a/tests/unit/test_provision_supervise.py b/tests/unit/test_provision_supervise.py deleted file mode 100644 index da7e2b1..0000000 --- a/tests/unit/test_provision_supervise.py +++ /dev/null @@ -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()