refactor(backend): move per-provider provisioning onto AgentProvider
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).
This commit is contained in:
@@ -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
|
||||||
@@ -320,24 +320,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 +358,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 +368,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()
|
||||||
|
|||||||
@@ -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."""
|
"""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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."""
|
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
@@ -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()
|
|
||||||
Reference in New Issue
Block a user