From efb3af4a93bcffc2524c8bfd2916b1964fabfe3c Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 7 Jun 2026 10:39:58 -0400 Subject: [PATCH] feat(agent-provider): user plugin discovery, Dockerfile cascade, and provider-owned ca/git provisioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _load_user_plugin: loads AgentProvider subclass from ~/.bot-bottle/contrib//agent_provider.py; get_provider() checks there first before falling back to built-ins - Add Dockerfile cascade to docker prepare: per-bottle override → manifest dockerfile → user plugin Dockerfile → provider default - Move provision_ca and provision_git from backend-specific provision/ modules to AgentProvider ABC as overridable defaults; delete docker/provision/ca.py, docker/provision/git.py, smolmachines/provision/ca.py, smolmachines/provision/git.py - Add git_gate_insteadof_host/scheme properties to BottlePlan base; SmolmachinesBottlePlan overrides them to return agent_git_gate_host and "http" so provision_git works correctly on both backends - Move SIGKILL retry from smolmachines provision/ca.py into SmolmachinesBottle.exec via _exec_raw helper — all exec calls on smolmachines now transparently retry once on exit 137 - Relax manifest_agent template validation to allow user-defined template names; keep auth_token/forward_host_credentials guards for built-in-only features - Update tests: rewrite test_docker_provision_git_user and test_smolmachines_provision to call provider methods directly; add TestSmolmachinesBottleExec for SIGKILL retry coverage Co-Authored-By: Claude Sonnet 4.6 --- bot_bottle/agent_provider.py | 127 ++++++++++++++++- bot_bottle/backend/__init__.py | 32 ++--- bot_bottle/backend/docker/backend.py | 10 -- bot_bottle/backend/docker/prepare.py | 11 +- .../backend/docker/provision/__init__.py | 11 +- bot_bottle/backend/docker/provision/ca.py | 40 ------ bot_bottle/backend/docker/provision/git.py | 106 -------------- bot_bottle/backend/smolmachines/backend.py | 12 -- bot_bottle/backend/smolmachines/bottle.py | 20 ++- .../backend/smolmachines/bottle_plan.py | 8 ++ .../smolmachines/provision/__init__.py | 11 +- .../backend/smolmachines/provision/ca.py | 90 ------------ .../backend/smolmachines/provision/git.py | 133 ------------------ bot_bottle/manifest_agent.py | 17 ++- tests/unit/test_docker_provision_git_user.py | 52 ++++--- tests/unit/test_smolmachines_provision.py | 117 ++++++++++----- 16 files changed, 320 insertions(+), 477 deletions(-) delete mode 100644 bot_bottle/backend/docker/provision/ca.py delete mode 100644 bot_bottle/backend/docker/provision/git.py delete mode 100644 bot_bottle/backend/smolmachines/provision/ca.py delete mode 100644 bot_bottle/backend/smolmachines/provision/git.py diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index eeb52c4..dcc79b2 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -19,6 +19,10 @@ Per PRD 0050 the per-provider implementations live under from __future__ import annotations +import importlib.util +import os +import shlex +import tempfile from abc import ABC, abstractmethod from dataclasses import dataclass, field from pathlib import Path @@ -174,13 +178,130 @@ class AgentProvider(ABC): the supervise sidecar is reachable. No-op when `plan.supervise_plan is None`.""" + def provision_ca(self, bottle: "Bottle", plan: "BottlePlan") -> None: + """Install the egress MITM CA into the agent's trust store. + + Default: Debian-style — cp the cert to the standard source path, + run update-ca-certificates, log the fingerprint. Override for + non-Debian base images or non-standard trust mechanisms.""" + from .backend.util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert + from .log import die + cert_host_path, label = select_ca_cert(plan.egress_plan) + bottle.cp_in(str(cert_host_path), AGENT_CA_PATH) + r = bottle.exec( + f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates", + user="root", + ) + if r.returncode != 0: + die( + f"update-ca-certificates failed (exit {r.returncode}): " + f"stdout={(r.stdout or '').strip()!r} " + f"stderr={(r.stderr or '').strip()!r}" + ) + log_ca_fingerprint(cert_host_path, label) + + def provision_git(self, bottle: "Bottle", plan: "BottlePlan") -> None: + """Configure git inside the agent container. + + Default: Debian/node — copies .git when --cwd is set, writes the + git-gate insteadOf gitconfig, sets user.name/email as node. + Override for images that run as a different user or use a + non-standard home directory.""" + from .log import info + workspace = plan.workspace_plan + if workspace.enabled and workspace.copy_git and workspace.has_host_git_dir: + guest_workspace_git = f"{workspace.guest_path}/.git" + host_git = str(workspace.host_path / ".git") + info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}") + bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root") + bottle.cp_in(host_git, guest_workspace_git) + bottle.exec( + f"chown -R {shlex.quote(workspace.owner)} " + f"{shlex.quote(guest_workspace_git)}", + user="root", + ) + + manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) + if manifest_bottle.git: + from .git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig + gate_host = getattr(plan, "git_gate_insteadof_host", GIT_GATE_HOSTNAME) + gate_scheme = getattr(plan, "git_gate_insteadof_scheme", "git") + content = git_gate_render_gitconfig( + manifest_bottle.git, gate_host, scheme=gate_scheme, + ) + guest_gitconfig = f"{plan.guest_home}/.gitconfig" + with tempfile.NamedTemporaryFile( + "w", dir=str(plan.stage_dir), prefix="gitconfig.", delete=False, + ) as f: + f.write(content) + config_file = Path(f.name) + os.chmod(config_file, 0o600) + info( + f"writing {guest_gitconfig} with " + f"{len(manifest_bottle.git)} insteadOf rule(s)" + ) + bottle.cp_in(str(config_file), guest_gitconfig) + bottle.exec( + f"chown node:node {shlex.quote(guest_gitconfig)} && " + f"chmod 644 {shlex.quote(guest_gitconfig)}", + user="root", + ) + + gu = manifest_bottle.git_user + if not gu.is_empty(): + if gu.name: + info(f"git config --global user.name = {gu.name!r}") + bottle.exec( + f"git config --global user.name {shlex.quote(gu.name)}", + user="node", + ) + if gu.email: + info(f"git config --global user.email = {gu.email!r}") + bottle.exec( + f"git config --global user.email {shlex.quote(gu.email)}", + user="node", + ) + + +def _load_user_plugin(template: str) -> AgentProvider | None: + """Check ~/.bot-bottle/contrib/