PRD: User-defined agent provider plugins #190

Merged
didericis merged 9 commits from prd-0052-user-provider-plugins into main 2026-06-07 11:51:51 -04:00
16 changed files with 320 additions and 477 deletions
Showing only changes of commit efb3af4a93 - Show all commits
+124 -3
View File
@@ -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/<template>/agent_provider.py for a
user-defined AgentProvider subclass. Returns an instance if found,
None if the plugin directory doesn't exist, raises ValueError if
the file exists but exports no AgentProvider subclass."""
plugin_path = (
Path.home() / ".bot-bottle" / "contrib" / template / "agent_provider.py"
)
if not plugin_path.exists():
return None
spec = importlib.util.spec_from_file_location(
f"_user_contrib_{template}.agent_provider", plugin_path
)
if spec is None or spec.loader is None:
raise ValueError(f"user plugin at {plugin_path} could not be loaded")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[union-attr]
for obj in vars(mod).values():
if (
isinstance(obj, type)
and issubclass(obj, AgentProvider)
and obj is not AgentProvider
):
return obj()
raise ValueError(
f"user plugin at {plugin_path} defines no AgentProvider subclass"
)
def get_provider(template: str) -> AgentProvider:
"""Resolve a provider template name to its plugin instance.
Lazy-imports the contrib module so importing this module doesn't
pull provider-specific code paths in. Mirrors the contrib
convention PRD 0048 established for deploy key provisioners."""
Checks ~/.bot-bottle/contrib/<template>/agent_provider.py first so
users can shadow a built-in for local testing. Falls through to the
built-in registry; raises ValueError for unknown names with no
matching user plugin."""
user_plugin = _load_user_plugin(template)
if user_plugin is not None:
return user_plugin
if template == PROVIDER_CLAUDE:
from .contrib.claude.agent_provider import ClaudeAgentProvider
return ClaudeAgentProvider()
+16 -16
View File
@@ -78,6 +78,20 @@ class BottlePlan(ABC):
stage_dir: Path
guest_home: str
git_gate_plan: GitGatePlan
@property
def git_gate_insteadof_host(self) -> str:
"""Host (and optional port) used in git-gate insteadOf URLs.
Docker uses the compose-network DNS alias; smolmachines
overrides with a loopback IP:port since TSI has no DNS."""
return "git-gate"
@property
def git_gate_insteadof_scheme(self) -> str:
"""URL scheme for git-gate insteadOf rewrites. 'git' for
Docker (git daemon); 'http' for smolmachines (HTTP proxy
over a published host port)."""
return "git"
egress_plan: EgressPlan
supervise_plan: SupervisePlan | None
agent_provision: AgentProvisionPlan
@@ -339,36 +353,22 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
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)
provider.provision_ca(bottle, plan)
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)
provider.provision_git(bottle, plan)
provider.provision_supervise_mcp(
plan, bottle, self.supervise_mcp_url(plan),
)
return prompt_path
def provision_ca(self, plan: PlanT, bottle: "Bottle") -> None:
"""Install the per-bottle CA into the agent's trust store so
the agent trusts the bumped CONNECT cert egress presents.
Default impl is a no-op so
backends that don't yet support TLS interception (every backend
except Docker today) aren't forced to implement it. The Docker
backend overrides to docker-cp the cert in and run
`update-ca-certificates`."""
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
no-op for backends like Docker that handle this before launch."""
@abstractmethod
def provision_git(self, plan: PlanT, bottle: "Bottle") -> None:
"""Copy the host's cwd `.git` directory into the running
bottle if the user requested --cwd. No-op otherwise."""
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
-10
View File
@@ -33,10 +33,6 @@ from . import prepare as _prepare
from .bottle import DockerBottle
from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan
from .provision import ca as _ca
from .provision import git as _git
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
"""Docker backend implementation. Selected by BOT_BOTTLE_BACKEND
(default)."""
@@ -60,12 +56,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
with _launch.launch(plan, provision=self.provision) as bottle:
yield bottle
def provision_ca(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
_ca.provision_ca(plan, bottle)
def provision_git(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
_git.provision_git(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
+10 -1
View File
@@ -15,7 +15,7 @@ from datetime import datetime, timezone
from dataclasses import replace
from pathlib import Path
from ...agent_provider import agent_provision_plan, runtime_for
from ...agent_provider import PROVIDER_TEMPLATES, agent_provision_plan, runtime_for
from ...egress import Egress
from ...env import ResolvedEnv, resolve_env
from ...git_gate import GitGate
@@ -100,6 +100,15 @@ def resolve_plan(
elif provider_runtime.dockerfile:
image_default = provider_runtime.image
dockerfile_path = provider_runtime.dockerfile
elif provider.template not in PROVIDER_TEMPLATES:
user_dockerfile = (
Path.home() / ".bot-bottle" / "contrib" / provider.template / "Dockerfile"
)
if user_dockerfile.is_file():
image_default = f"bot-bottle-{provider.template}:{slug}"
dockerfile_path = str(user_dockerfile)
else:
image_default = provider_runtime.image
else:
image_default = provider_runtime.image
image = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
@@ -2,10 +2,11 @@
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:
the `AgentProvider` plugin under `bot_bottle/contrib/`. CA and git
provisioning also moved to the AgentProvider ABC (with Debian/node
defaults); user plugins override them for non-standard images.
- 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
No modules remain in this subpackage the directory is kept so that
existing imports of `from .provision import ...` don't need updating
if new backend-specific provisioners are added later.
"""
-40
View File
@@ -1,40 +0,0 @@
"""Install the per-bottle egress MITM CA into the agent container's
trust store.
By the time this provisioner runs, `egress_tls_init` has generated
the egress CA and the path is re-bound into `plan.egress_plan`.
Cert lands on Debian's standard source path
(`/usr/local/share/ca-certificates/`); `update-ca-certificates`
rebuilds `/etc/ssl/certs/ca-certificates.crt`, which is what curl,
Python `ssl`, and OpenSSL-based tools all read by default. The env
trio set on the agent's `docker run` covers Node
(`NODE_EXTRA_CA_CERTS`) and Python `requests` /
`SSL_CERT_FILE`-honoring libraries that don't load the system
bundle.
The fingerprint is computed via stdlib (`ssl.PEM_cert_to_DER_cert`
+ `hashlib.sha256`) and logged once to stderr. The private key
stays on the host (under `stage_dir`) until teardown wipes the
stage dir; nothing in the agent ever sees it."""
from __future__ import annotations
from ... import Bottle
from ...util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert
from ..bottle_plan import DockerBottlePlan
def provision_ca(plan: DockerBottlePlan, bottle: Bottle) -> None:
"""Copy the agent-facing CA cert into the agent, rebuild the
trust bundle, emit a one-line fingerprint log. Called from
`BottleBackend.provision` after the agent container is up."""
cert_host_path, label = select_ca_cert(plan.egress_plan)
bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
bottle.exec(
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates",
user="root",
)
log_ca_fingerprint(cert_host_path, label)
-106
View File
@@ -1,106 +0,0 @@
"""Git provisioning inside a running Docker bottle.
Three concerns, all about git in the agent:
1. If --cwd was passed AND the host cwd has a .git, copy that .git
into the planned guest workspace so the agent operates on the
user's repo.
2. If the bottle declares `git` entries (PRD 0008), write a
~/.gitconfig with insteadOf rules so every git operation
against a declared upstream (push, fetch, clone, pull,
ls-remote) transparently hits the per-agent git-gate. The
gate mirrors the upstream in both directions, so URL
rewriting is symmetric.
3. If the bottle declares `git.user` (issue #86), set
`git config --global user.{name,email}` inside the bottle so
the agent's commits are attributed to that identity.
"""
from __future__ import annotations
import shlex
from ....git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
from ....log import info
from ... import Bottle
from ..bottle_plan import DockerBottlePlan
def provision_git(plan: DockerBottlePlan, bottle: Bottle) -> None:
"""Set up git inside the bottle. Runs all three subcases; each
no-ops when its condition isn't met."""
_provision_cwd_git(plan, bottle)
_provision_git_gate_config(plan, bottle)
_provision_git_user(plan, bottle)
def _provision_cwd_git(plan: DockerBottlePlan, bottle: Bottle) -> None:
"""If --cwd was set and the host cwd has a .git directory, copy
it into /home/node/workspace/.git and fix ownership. No-op
otherwise."""
workspace = plan.workspace_plan
if not (workspace.enabled and workspace.copy_git and workspace.has_host_git_dir):
return
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.cp_in(host_git, guest_workspace_git)
bottle.exec(
f"chown -R {shlex.quote(workspace.owner)} {shlex.quote(guest_workspace_git)}",
user="root",
)
def _provision_git_gate_config(plan: DockerBottlePlan, bottle: Bottle) -> None:
"""Write ~/.gitconfig in the bottle with the git-gate
insteadOf rules. No-op when the bottle has no `git` entries."""
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if not manifest_bottle.git:
return
container_gitconfig = f"{plan.guest_home}/.gitconfig"
content = git_gate_render_gitconfig(manifest_bottle.git, GIT_GATE_HOSTNAME)
config_file = plan.stage_dir / "agent_gitconfig"
config_file.write_text(content)
config_file.chmod(0o600)
info(f"writing {container_gitconfig} with {len(manifest_bottle.git)} insteadOf rule(s)")
bottle.cp_in(str(config_file), container_gitconfig)
bottle.exec(
f"chown node:node {shlex.quote(container_gitconfig)} && "
f"chmod 644 {shlex.quote(container_gitconfig)}",
user="root",
)
def _provision_git_user(plan: DockerBottlePlan, bottle: Bottle) -> None:
"""Apply `git config --global user.{name,email}` inside the
bottle so the agent's commits are attributed to the operator-
chosen identity instead of the agent image's default
(which is no user git would refuse to commit at all
until the agent ran its own `git config`).
Runs as the `node` user so `--global` lands in
`/home/node/.gitconfig` (matching the existing
`_provision_git_gate_config` write location). No-op when the
bottle didn't declare `git.user`.
Each field set independently name-only or email-only
configs only run the `git config` line for the field
present."""
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
gu = manifest_bottle.git_user
if gu.is_empty():
return
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",
)
@@ -22,8 +22,6 @@ from . import smolvm as _smolvm
from .bottle import SmolmachinesBottle
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 workspace as _workspace
@@ -55,21 +53,11 @@ class SmolmachinesBottleBackend(
with _launch.launch(plan, provision=self.provision) as bottle:
yield bottle
def provision_ca(
self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None:
_ca.provision_ca(plan, bottle)
def provision_workspace(
self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None:
_workspace.provision_workspace(plan, bottle)
def provision_git(
self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None:
_git.provision_git(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
+17 -3
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import subprocess
import sys
import time
from typing import Mapping, cast
from ...agent_provider import PromptMode, prompt_args
@@ -131,6 +132,11 @@ class SmolmachinesBottle(Bottle):
self.agent_argv(argv, tty=tty), check=False,
).returncode
# smolvm/libkrun can SIGKILL an otherwise-normal exec during
# early-VM provisioning. Retry once after a short settle so
# callers (provision_ca, etc.) don't have to handle it themselves.
_SIGKILL_EXIT = 128 + 9
def exec(self, script: str, *, user: str = "node") -> ExecResult:
"""Run a POSIX shell script as `user` (default `node`) and
capture the result. Matches the docker backend's `exec`,
@@ -141,14 +147,22 @@ class SmolmachinesBottle(Bottle):
`runuser -u <user> -- env ... /bin/sh -c <script>` switches UID
without invoking a login shell, then sets HOME / USER and the
bottle env in the child process."""
bottle env in the child process.
Retries once on SIGKILL (exit 137) libkrun occasionally
kills short-lived execs during VM bring-up."""
r = self._exec_raw(script, user=user)
if r.returncode == self._SIGKILL_EXIT:
time.sleep(1.0)
r = self._exec_raw(script, user=user)
return r
def _exec_raw(self, script: str, *, user: str = "node") -> ExecResult:
argv = [
"--", "runuser", "-u", user, "--",
"env", *_env_assignments_for(user, self._guest_env),
"/bin/sh", "-c", script,
]
# Call smolvm directly because this path needs the host-side
# subprocess capture shape used by the Docker backend.
r = subprocess.run(
["smolvm", "machine", "exec", "--name", self.name] + argv,
capture_output=True, text=True, check=False,
@@ -82,6 +82,14 @@ class SmolmachinesBottlePlan(BottlePlan):
agent_git_gate_host: str = ""
agent_supervise_url: str = ""
@property
def git_gate_insteadof_host(self) -> str:
return self.agent_git_gate_host
@property
def git_gate_insteadof_scheme(self) -> str:
return "http"
@property
def agent_command(self) -> str:
return self.agent_provision.command
@@ -2,11 +2,12 @@
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:
the `AgentProvider` plugin under `bot_bottle/contrib/`. CA and git
provisioning also moved to the AgentProvider ABC (with Debian/node
defaults); user plugins override them for non-standard images.
The module left in this subpackage handles the remaining backend-
specific step:
- 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
"""
@@ -1,90 +0,0 @@
"""Install the per-bottle egress MITM CA into the smolmachines
guest's trust store (PRD 0023 chunk 4d).
Mirrors `backend.docker.provision.ca`: copy the egress CA to
Debian's `/usr/local/share/ca-certificates/` path,
`update-ca-certificates` to rebuild the trust bundle, and log the
fingerprint once.
`smolvm machine exec` runs commands as root in the VM (no `-u`
flag exists; the VM init is root), so we don't need the explicit
`-u 0` the docker backend uses on its `docker exec` calls."""
from __future__ import annotations
import time
from ....log import die
from ...util import (
AGENT_CA_BUNDLE,
AGENT_CA_PATH,
log_ca_fingerprint,
select_ca_cert,
)
from ... import Bottle, ExecResult
from ..bottle_plan import SmolmachinesBottlePlan
_SIGKILL_EXIT = 128 + 9
def provision_ca(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
"""Copy the agent-facing CA cert into the guest, rebuild the
trust bundle, emit a one-line fingerprint log. Called from
`BottleBackend.provision` after the smolvm guest is up."""
cert_host_path, label = select_ca_cert(plan.egress_plan)
bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
# Mode 0644 — readable to non-root tools in the guest.
# update-ca-certificates rebuilds the bundle at AGENT_CA_BUNDLE,
# which is what curl / Python ssl / OpenSSL-based tools read by
# default. The env trio (NODE_EXTRA_CA_CERTS / SSL_CERT_FILE /
# REQUESTS_CA_BUNDLE) on the guest_env covers Node + Python
# `requests` / libraries that don't load the system bundle.
#
r = _install_ca(bottle)
if r.returncode == _SIGKILL_EXIT:
# smolvm/libkrun can SIGKILL an otherwise-normal exec
# during early-VM provisioning. `update-ca-certificates`
# is idempotent, so retry the same install once after a
# short settle delay before treating it as fatal.
time.sleep(1.0)
r = _install_ca(bottle)
if r.returncode != 0:
# update-ca-certificates not adding our cert is fatal —
# claude-code's TLS handshake against the egress-MITM'd
# api.anthropic.com would fail downstream. Bail early
# with what we can see (output is captured so we can
# surface it).
die(
f"update-ca-certificates didn't add the agent CA "
f"(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 _install_ca(bottle: Bottle) -> ExecResult:
# chown + chmod + update-ca-certificates + bundle
# verification run in one exec so we only pay one
# round trip; the `&&` chaining surfaces the first failure
# as the return code. The verify check is more stable than
# requiring "1 added" in stdout: a retry after a
# partially-completed first run may legitimately report "0
# added" while the cert is already installed.
return bottle.exec(
f"chown root:root {AGENT_CA_PATH} && "
f"chmod 644 {AGENT_CA_PATH} && "
f"update-ca-certificates && "
f"openssl verify -CAfile {AGENT_CA_BUNDLE} {AGENT_CA_PATH}",
user="root",
)
# Re-exported for the launch/provision_ca caller + tests. The path
# constants live in the shared `backend.util` (Debian's
# `update-ca-certificates` layout is the same in both backends).
__all__ = ["AGENT_CA_BUNDLE", "AGENT_CA_PATH", "provision_ca"]
@@ -1,133 +0,0 @@
"""Git provisioning inside a running smolmachines bottle
(PRD 0023 chunk 4d).
Three concerns, all about git in the agent:
1. If --cwd was passed AND the host cwd has a .git, copy that
.git into the planned guest workspace so the agent operates on
the user's repo.
2. If the bottle declares `git` entries (PRD 0008), write a
~/.gitconfig with insteadOf rules so every git operation
against a declared upstream transparently hits the per-bottle
git-gate. The gate mirrors the upstream in both directions,
so URL rewriting is symmetric.
3. If the bottle declares `git.user` (issue #86), set
`git config --global user.{name,email}` inside the guest so
the agent's commits are attributed to that identity.
Differs from `backend.docker.provision.git` in one address detail:
the TSI-allowlisted guest can only reach the bundle's pinned IP
(no DNS resolver in the /32 allowlist), so the insteadOf URLs
are `http://<bundle_ip>:<port>/<name>.git` rather than the
docker backend's `git://git-gate/<name>.git`. The render itself
is the shared `git_gate_render_gitconfig` on the platform-neutral
git_gate module."""
from __future__ import annotations
import os
import shlex
import tempfile
from pathlib import Path
from ....git_gate import git_gate_render_gitconfig
from ....log import info
from ... import Bottle
from ..bottle_plan import SmolmachinesBottlePlan
def provision_git(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
"""Set up git inside the guest. Runs all three subcases; each
no-ops when its condition isn't met."""
_provision_cwd_git(plan, bottle)
_provision_git_gate_config(plan, bottle)
_provision_git_user(plan, bottle)
def _provision_cwd_git(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
"""If --cwd was set and the host cwd has a .git directory, copy
it into <guest_home>/workspace/.git and fix ownership. No-op
otherwise."""
workspace = plan.workspace_plan
if not (workspace.enabled and workspace.copy_git and workspace.has_host_git_dir):
return
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}")
# mkdir -p the workspace dir so cp_in lands the .git
# directly there even on first-time bottles.
bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root")
bottle.cp_in(host_git, guest_workspace_git)
# cp_in lands files as root; the agent runs as node so
# the workspace tree must be chowned over.
bottle.exec(
f"chown -R {shlex.quote(workspace.owner)} {shlex.quote(guest_workspace_git)}",
user="root",
)
def _provision_git_gate_config(
plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None:
"""Write ~/.gitconfig in the guest with the git-gate insteadOf
rules. No-op when the bottle has no `git` entries."""
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if not manifest_bottle.git:
return
# `<loopback alias>:<host port>` form: the bundle's git-gate
# HTTP port is published on host loopback at launch time so
# the smolvm guest (which can only reach macOS networking via
# TSI, not the docker bridge IP) can dial it. launch.py
# populates `plan.agent_git_gate_host` after bundle bringup.
content = git_gate_render_gitconfig(
manifest_bottle.git, plan.agent_git_gate_host, scheme="http",
)
guest_gitconfig = f"{plan.guest_home}/.gitconfig"
# Stage the file under the plan's stage_dir so cp_in
# has a stable host path. The plan's stage_dir is cleaned up
# by start.py's session-end teardown.
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 {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",
)
def _provision_git_user(
plan: SmolmachinesBottlePlan, bottle: Bottle,
) -> None:
"""Apply `git config --global user.{name,email}` inside the
guest as the node user so --global lands in the same
`/home/node/.gitconfig` that `_provision_git_gate_config`
writes to. No-op when the bottle didn't declare `git.user`.
SmolmachinesBottle.exec(user="node") automatically sets
HOME=/home/node so --global writes to /home/node/.gitconfig."""
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
gu = manifest_bottle.git_user
if gu.is_empty():
return
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",
)
+12 -5
View File
@@ -49,11 +49,6 @@ class AgentProvider:
f"bottle '{bottle_name}' agent_provider.template must be a "
f"non-empty string"
)
if template not in PROVIDER_TEMPLATES:
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.template {template!r} "
f"is not one of {', '.join(sorted(PROVIDER_TEMPLATES))}"
)
dockerfile = d.get("dockerfile", "")
if not isinstance(dockerfile, str):
raise ManifestError(
@@ -66,6 +61,12 @@ class AgentProvider:
f"bottle '{bottle_name}' agent_provider.auth_token must be a "
f"string (was {type(auth_token).__name__})"
)
if auth_token and template not in PROVIDER_TEMPLATES:
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.auth_token is only "
f"supported for built-in templates "
f"({', '.join(sorted(PROVIDER_TEMPLATES))})"
)
if auth_token and template != "claude":
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.auth_token is only "
@@ -77,6 +78,12 @@ class AgentProvider:
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
f"must be a boolean (was {type(forward_host_credentials).__name__})"
)
if forward_host_credentials and template not in PROVIDER_TEMPLATES:
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
f"is only supported for built-in templates "
f"({', '.join(sorted(PROVIDER_TEMPLATES))})"
)
if forward_host_credentials and template != "codex":
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
+36 -16
View File
@@ -1,10 +1,9 @@
"""Unit: docker backend `_provision_git_user` (issue #86).
"""Unit: AgentProvider.provision_git — git-user and cwd .git copy (issue #86).
Mocks `bottle.exec` / `bottle.cp_in` and asserts on the script
strings and user parameter. The cwd + git-gate passes are covered
indirectly by the existing integration-shaped tests in
test_smolmachines_provision; this file targets just the git_user
pass."""
Mocks bottle.exec / bottle.cp_in and asserts on the dispatched script
shape. provision_git is now a method on AgentProvider (default impl);
the internal passes (_provision_cwd_git, _provision_git_gate_config,
_provision_git_user) are no longer exposed as separate helpers."""
from __future__ import annotations
@@ -13,16 +12,39 @@ import unittest
from pathlib import Path
from unittest.mock import MagicMock
from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.agent_provider import (
AgentProvider,
AgentProvisionPlan,
AgentProviderRuntime,
)
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.docker.provision import git as _git
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.workspace import workspace_plan
class _Provider(AgentProvider):
"""Minimal concrete subclass for testing the default provision_git."""
@property
def runtime(self) -> AgentProviderRuntime:
return AgentProviderRuntime(
template="test", command="test", image="", dockerfile="",
prompt_mode="append_file", bypass_args=(), resume_args=(),
remote_control_args=(),
)
def provision_plan(self, **kwargs): # type: ignore[override]
raise NotImplementedError
def provision_skills(self, plan, bottle): ... # type: ignore[override]
def provision_prompt(self, plan, bottle): ... # type: ignore[override]
def provision(self, plan, bottle): ... # type: ignore[override]
def provision_supervise_mcp(self, plan, bottle, supervise_url): ... # type: ignore[override]
_PROVIDER = _Provider()
def _plan(*, git_user: dict | None = None, # type: ignore
copy_cwd: bool = False,
user_cwd: str = "/tmp/x",
@@ -87,8 +109,6 @@ def _make_bottle(name: str = "bot-bottle-demo-abc12") -> MagicMock:
def _git_config_exec_calls(bottle: MagicMock) -> list[tuple[str, str]]:
"""Filter bottle.exec calls to git-config invocations.
Returns list of (script, user) tuples."""
out = []
for c in bottle.exec.call_args_list:
script = c.args[0] if c.args else c.kwargs.get("script", "")
@@ -108,7 +128,7 @@ class TestProvisionGitUser(unittest.TestCase):
def test_noop_when_no_git_user(self):
bottle = _make_bottle()
_git._provision_git_user(_plan(stage_dir=self.stage), bottle)
_PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage))
self.assertEqual([], _git_config_exec_calls(bottle))
def test_copies_cwd_git_to_workspace_plan_path(self):
@@ -116,7 +136,7 @@ class TestProvisionGitUser(unittest.TestCase):
(cwd / ".git").mkdir(parents=True)
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
bottle = _make_bottle()
_git._provision_cwd_git(plan, bottle)
_PROVIDER.provision_git(bottle, plan)
bottle.cp_in.assert_called_once_with(
f"{cwd}/.git",
@@ -125,10 +145,10 @@ class TestProvisionGitUser(unittest.TestCase):
chown_calls = [
c for c in bottle.exec.call_args_list
if "chown" in (c.args[0] if c.args else "")
and "/home/node/workspace/.git" in (c.args[0] if c.args else "")
]
self.assertEqual(1, len(chown_calls))
self.assertIn("node:node", chown_calls[0].args[0])
self.assertIn("/home/node/workspace/.git", chown_calls[0].args[0])
def test_sets_name_and_email(self):
plan = _plan(
@@ -136,7 +156,7 @@ class TestProvisionGitUser(unittest.TestCase):
stage_dir=self.stage,
)
bottle = _make_bottle()
_git._provision_git_user(plan, bottle)
_PROVIDER.provision_git(bottle, plan)
calls = _git_config_exec_calls(bottle)
self.assertEqual(2, len(calls))
for script, user in calls:
@@ -150,7 +170,7 @@ class TestProvisionGitUser(unittest.TestCase):
def test_name_only_sets_only_name(self):
plan = _plan(git_user={"name": "Bot"}, stage_dir=self.stage)
bottle = _make_bottle()
_git._provision_git_user(plan, bottle)
_PROVIDER.provision_git(bottle, plan)
calls = _git_config_exec_calls(bottle)
self.assertEqual(1, len(calls))
self.assertIn("user.name", calls[0][0])
@@ -161,7 +181,7 @@ class TestProvisionGitUser(unittest.TestCase):
git_user={"email": "bot@example.com"}, stage_dir=self.stage,
)
bottle = _make_bottle()
_git._provision_git_user(plan, bottle)
_PROVIDER.provision_git(bottle, plan)
calls = _git_config_exec_calls(bottle)
self.assertEqual(1, len(calls))
self.assertIn("user.email", calls[0][0])
+85 -32
View File
@@ -14,21 +14,23 @@ from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.agent_provider import (
AgentProvider,
AgentProviderRuntime,
AgentProvisionCommand,
AgentProvisionDir,
AgentProvisionFile,
AgentProvisionPlan,
)
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
from bot_bottle.backend.smolmachines.bottle_plan import (
SmolmachinesBottlePlan,
)
from bot_bottle.backend.smolmachines.provision import (
ca as _ca,
git as _git,
workspace as _workspace,
)
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.backend.util import AGENT_CA_PATH
from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import GitEntry, Manifest
@@ -36,6 +38,26 @@ from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
class _Provider(AgentProvider):
"""Minimal concrete subclass for testing the default provision_ca/provision_git."""
@property
def runtime(self) -> AgentProviderRuntime:
return AgentProviderRuntime(
template="test", command="test", image="", dockerfile="",
prompt_mode="append_file", bypass_args=(), resume_args=(),
remote_control_args=(),
)
def provision_plan(self, **kwargs): # type: ignore[override]
raise NotImplementedError
def provision_skills(self, plan, bottle): ... # type: ignore[override]
def provision_prompt(self, plan, bottle): ... # type: ignore[override]
def provision(self, plan, bottle): ... # type: ignore[override]
def provision_supervise_mcp(self, plan, bottle, supervise_url): ... # type: ignore[override]
_PROVIDER = _Provider()
def _make_bottle(
name: str = "bot-bottle-demo-abc12",
exec_result: ExecResult | None = None,
@@ -252,10 +274,10 @@ class TestProvisionCA(unittest.TestCase):
def test_egress_ca_always_installed(self):
plan = _plan(egress_ca_path=self.egress_ca)
bottle = _make_bottle(exec_result=self._UPDATE_OK)
_ca.provision_ca(plan, bottle)
_PROVIDER.provision_ca(bottle, plan)
bottle.cp_in.assert_called_once_with(
str(self.egress_ca),
_ca.AGENT_CA_PATH,
AGENT_CA_PATH,
)
bottle.exec.assert_called_once()
script = bottle.exec.call_args.args[0]
@@ -263,28 +285,59 @@ class TestProvisionCA(unittest.TestCase):
self.assertIn("update-ca-certificates", script)
self.assertEqual("root", bottle.exec.call_args.kwargs.get("user"))
def test_retries_smolvm_sigkill_during_update_ca(self):
plan = _plan(egress_ca_path=self.egress_ca)
killed = ExecResult(
returncode=137,
stdout="Updating certificates in /etc/ssl/certs...\n",
stderr="",
)
bottle = _make_bottle()
bottle.exec.side_effect = [killed, self._UPDATE_OK]
with patch(
"bot_bottle.backend.smolmachines.provision.ca.time.sleep"
) as sleep:
_ca.provision_ca(plan, bottle)
self.assertEqual(2, bottle.exec.call_count)
sleep.assert_called_once_with(1.0)
def test_dies_when_egress_cert_missing(self):
plan = _plan(egress_ca_path=self.tmp / "does-not-exist.pem")
bottle = _make_bottle()
with self.assertRaises(SystemExit):
_ca.provision_ca(plan, bottle)
_PROVIDER.provision_ca(bottle, plan)
class TestSmolmachinesBottleExec(unittest.TestCase):
"""SmolmachinesBottle.exec retries once on SIGKILL (exit 137)."""
_SIGKILL = subprocess.CompletedProcess(
args=[], returncode=137, stdout="", stderr="",
)
_SUCCESS = subprocess.CompletedProcess(
args=[], returncode=0, stdout="done", stderr="",
)
def test_retries_on_sigkill(self):
bottle = SmolmachinesBottle("test-machine")
with patch(
"bot_bottle.backend.smolmachines.bottle.subprocess.run",
side_effect=[self._SIGKILL, self._SUCCESS],
) as mock_run, patch(
"bot_bottle.backend.smolmachines.bottle.time.sleep"
) as mock_sleep:
result = bottle.exec("echo hi")
self.assertEqual(0, result.returncode)
self.assertEqual(2, mock_run.call_count)
mock_sleep.assert_called_once_with(1.0)
def test_no_retry_on_success(self):
bottle = SmolmachinesBottle("test-machine")
with patch(
"bot_bottle.backend.smolmachines.bottle.subprocess.run",
return_value=self._SUCCESS,
) as mock_run:
result = bottle.exec("echo hi")
self.assertEqual(0, result.returncode)
self.assertEqual(1, mock_run.call_count)
def test_no_retry_on_other_error(self):
fail = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="err")
bottle = SmolmachinesBottle("test-machine")
with patch(
"bot_bottle.backend.smolmachines.bottle.subprocess.run",
return_value=fail,
) as mock_run:
result = bottle.exec("bad-cmd")
self.assertEqual(1, result.returncode)
self.assertEqual(1, mock_run.call_count)
class TestProvisionGit(unittest.TestCase):
@@ -301,20 +354,20 @@ class TestProvisionGit(unittest.TestCase):
def test_noop_when_no_cwd_and_no_git_entries(self):
bottle = _make_bottle()
_git.provision_git(_plan(stage_dir=self.stage), bottle)
_PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage))
bottle.cp_in.assert_not_called()
bottle.exec.assert_not_called()
def test_copies_cwd_git_when_copy_cwd_and_git_present(self):
# Stage a fake host .git dir under user_cwd so the path-
# check in _provision_cwd_git fires.
# check in provision_git fires.
cwd = self.stage / "cwd"
(cwd / ".git").mkdir(parents=True)
plan = _plan(
copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage,
)
bottle = _make_bottle()
_git.provision_git(plan, bottle)
_PROVIDER.provision_git(bottle, plan)
bottle.cp_in.assert_called_once_with(
f"{cwd}/.git",
"/home/node/workspace/.git",
@@ -330,7 +383,7 @@ class TestProvisionGit(unittest.TestCase):
def test_skips_cwd_when_copy_cwd_false(self):
plan = _plan(copy_cwd=False, stage_dir=self.stage)
bottle = _make_bottle()
_git.provision_git(plan, bottle)
_PROVIDER.provision_git(bottle, plan)
bottle.cp_in.assert_not_called()
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
@@ -348,7 +401,7 @@ class TestProvisionGit(unittest.TestCase):
agent_git_gate_host="127.0.0.1:9418",
)
bottle = _make_bottle()
_git.provision_git(plan, bottle)
_PROVIDER.provision_git(bottle, plan)
# The staged gitconfig path is whatever NamedTemporaryFile
# picked; we read its contents.
cp_call = bottle.cp_in.call_args
@@ -392,7 +445,7 @@ class TestBundleLaunchSpec(unittest.TestCase):
class TestProvisionGitUser(unittest.TestCase):
"""`_provision_git_user` runs `git config --global` inside the
"""`provision_git` runs `git config --global` inside the
guest as the node user. SmolmachinesBottle.exec sets HOME and
USER automatically for the requested user, so --global lands
in /home/node/.gitconfig. No-op when the bottle didn't declare
@@ -411,7 +464,7 @@ class TestProvisionGitUser(unittest.TestCase):
def test_noop_when_no_git_user(self):
bottle = _make_bottle()
_git._provision_git_user(_plan(), bottle)
_PROVIDER.provision_git(bottle, _plan())
self.assertEqual([], self._git_config_calls(bottle))
def test_sets_name_and_email_as_node(self):
@@ -420,7 +473,7 @@ class TestProvisionGitUser(unittest.TestCase):
"email": "eric@dideric.is",
})
bottle = _make_bottle()
_git._provision_git_user(plan, bottle)
_PROVIDER.provision_git(bottle, plan)
calls = self._git_config_calls(bottle)
self.assertEqual(2, len(calls))
# Both run as node so SmolmachinesBottle.exec sets HOME=/home/node
@@ -436,7 +489,7 @@ class TestProvisionGitUser(unittest.TestCase):
def test_name_only(self):
plan = _plan(git_user={"name": "Bot"})
bottle = _make_bottle()
_git._provision_git_user(plan, bottle)
_PROVIDER.provision_git(bottle, plan)
calls = self._git_config_calls(bottle)
self.assertEqual(1, len(calls))
self.assertIn("user.name", calls[0][0])
@@ -445,7 +498,7 @@ class TestProvisionGitUser(unittest.TestCase):
def test_email_only(self):
plan = _plan(git_user={"email": "bot@example.com"})
bottle = _make_bottle()
_git._provision_git_user(plan, bottle)
_PROVIDER.provision_git(bottle, plan)
calls = self._git_config_calls(bottle)
self.assertEqual(1, len(calls))
self.assertIn("user.email", calls[0][0])