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
22 changed files with 603 additions and 529 deletions
-39
View File
@@ -1,39 +0,0 @@
# Block PRs that add prd-new-*.md files directly to main.
#
# prd-new-*.md files are placeholders — they must go through a PR so
# the post-merge prd-number workflow can assign a sequential number and
# rename the file. A direct push or a PR that slips through without
# triggering the check would leave an un-numbered PRD on main.
name: prd-check
on:
pull_request:
branches:
- main
paths:
- 'docs/prds/prd-new-*.md'
jobs:
no-prd-new-on-main:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fail if prd-new-*.md files are present in the diff
run: |
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
new_prds=$(git diff --name-only --diff-filter=A "$base" "$head" \
| grep -E '^docs/prds/prd-new-.+\.md$' || true)
if [ -n "$new_prds" ]; then
echo "ERROR: PRs to main must not add prd-new-*.md files directly."
echo "These files must be merged via a feature branch so the"
echo "prd-number workflow can assign a sequential number on merge:"
echo "$new_prds"
exit 1
fi
echo "OK: no prd-new-*.md files added in this PR."
+124 -3
View File
@@ -19,6 +19,10 @@ Per PRD 0050 the per-provider implementations live under
from __future__ import annotations from __future__ import annotations
import importlib.util
import os
import shlex
import tempfile
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -174,13 +178,130 @@ class AgentProvider(ABC):
the supervise sidecar is reachable. No-op when the supervise sidecar is reachable. No-op when
`plan.supervise_plan is None`.""" `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: def get_provider(template: str) -> AgentProvider:
"""Resolve a provider template name to its plugin instance. """Resolve a provider template name to its plugin instance.
Lazy-imports the contrib module so importing this module doesn't Checks ~/.bot-bottle/contrib/<template>/agent_provider.py first so
pull provider-specific code paths in. Mirrors the contrib users can shadow a built-in for local testing. Falls through to the
convention PRD 0048 established for deploy key provisioners.""" 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: if template == PROVIDER_CLAUDE:
from .contrib.claude.agent_provider import ClaudeAgentProvider from .contrib.claude.agent_provider import ClaudeAgentProvider
return ClaudeAgentProvider() return ClaudeAgentProvider()
+18 -18
View File
@@ -78,6 +78,20 @@ class BottlePlan(ABC):
stage_dir: Path stage_dir: Path
guest_home: str guest_home: str
git_gate_plan: GitGatePlan 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 egress_plan: EgressPlan
supervise_plan: SupervisePlan | None supervise_plan: SupervisePlan | None
agent_provision: AgentProvisionPlan agent_provision: AgentProvisionPlan
@@ -339,36 +353,22 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
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) provider = get_provider(plan.agent_provision.template)
self.provision_ca(plan, bottle) provider.provision_ca(bottle, plan)
prompt_path = provider.provision_prompt(plan, bottle) prompt_path = provider.provision_prompt(plan, bottle)
provider.provision(plan, bottle) provider.provision(plan, bottle)
provider.provision_skills(plan, bottle) provider.provision_skills(plan, bottle)
self.provision_workspace(plan, bottle) self.provision_workspace(plan, bottle)
self.provision_git(plan, bottle) provider.provision_git(bottle, plan)
provider.provision_supervise_mcp( provider.provision_supervise_mcp(
plan, bottle, self.supervise_mcp_url(plan), plan, bottle, self.supervise_mcp_url(plan),
) )
return prompt_path 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: 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
no-op for backends like Docker that handle this before launch.""" 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: def supervise_mcp_url(self, plan: PlanT) -> str:
"""Return the agent-side URL of the per-bottle supervise """Return the agent-side URL of the per-bottle supervise
sidecar, or "" when this bottle has no sidecar. The provider sidecar, or "" when this bottle has no sidecar. The provider
@@ -411,8 +411,8 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
# Import concrete backend classes AFTER the base types are defined, so # Import concrete backend classes AFTER the base types are defined, so
# each backend module can pull BottleSpec / BottlePlan / BottleBackend # each backend module can pull BottleSpec / BottlePlan / BottleBackend
# via `from . import ...` without hitting a partially-initialized module. # via `from . import ...` without hitting a partially-initialized module.
from .docker import DockerBottleBackend # noqa: E402 from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
from .smolmachines import SmolmachinesBottleBackend # noqa: E402 from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
# The dict is heterogeneous: each value is a BottleBackend specialized # The dict is heterogeneous: each value is a BottleBackend specialized
+1 -11
View File
@@ -25,7 +25,7 @@ from pathlib import Path
from typing import Generator, Sequence from typing import Generator, Sequence
from ...supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT from ...supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
from . import launch as _launch from . import launch as _launch
@@ -33,10 +33,6 @@ from . import prepare as _prepare
from .bottle import DockerBottle from .bottle import DockerBottle
from .bottle_cleanup_plan import DockerBottleCleanupPlan 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 git as _git
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]): class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
"""Docker backend implementation. Selected by BOT_BOTTLE_BACKEND """Docker backend implementation. Selected by BOT_BOTTLE_BACKEND
(default).""" (default)."""
@@ -60,12 +56,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
with _launch.launch(plan, provision=self.provision) as bottle: with _launch.launch(plan, provision=self.provision) as bottle:
yield 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: def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
"""Docker bottles reach the supervise sidecar via the """Docker bottles reach the supervise sidecar via the
compose-network alias `supervise:9100`. No per-bottle URL 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 dataclasses import replace
from pathlib import Path 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 ...egress import Egress
from ...env import ResolvedEnv, resolve_env from ...env import ResolvedEnv, resolve_env
from ...git_gate import GitGate from ...git_gate import GitGate
@@ -100,6 +100,15 @@ def resolve_plan(
elif provider_runtime.dockerfile: elif provider_runtime.dockerfile:
image_default = provider_runtime.image image_default = provider_runtime.image
dockerfile_path = provider_runtime.dockerfile 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: else:
image_default = provider_runtime.image image_default = provider_runtime.image
image = os.environ.get("BOT_BOTTLE_IMAGE", image_default) image = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
@@ -2,10 +2,11 @@
Per PRD 0050 the per-provider provisioning steps (prompt, skills, Per PRD 0050 the per-provider provisioning steps (prompt, skills,
declarative provision-plan apply, supervise MCP registration) live on declarative provision-plan apply, supervise MCP registration) live on
the `AgentProvider` plugin under `bot_bottle/contrib/`. The modules the `AgentProvider` plugin under `bot_bottle/contrib/`. CA and git
left in this subpackage handle only the steps that are provisioning also moved to the AgentProvider ABC (with Debian/node
backend-specific: defaults); user plugins override them for non-standard images.
- ca.py — install per-bottle CA bundle into the guest trust store No modules remain in this subpackage — the directory is kept so that
- git.py — copy host cwd `.git` into the guest when --cwd is used 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 import SmolmachinesBottle
from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan 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 git as _git
from .provision import workspace as _workspace from .provision import workspace as _workspace
@@ -55,21 +53,11 @@ class SmolmachinesBottleBackend(
with _launch.launch(plan, provision=self.provision) as bottle: with _launch.launch(plan, provision=self.provision) as bottle:
yield bottle yield bottle
def provision_ca(
self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None:
_ca.provision_ca(plan, bottle)
def provision_workspace( def provision_workspace(
self, plan: SmolmachinesBottlePlan, bottle: Bottle self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None: ) -> None:
_workspace.provision_workspace(plan, bottle) _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: def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str:
"""The smolmachines guest reaches the supervise sidecar via a """The smolmachines guest reaches the supervise sidecar via a
host-published random port the launch step pinned earlier host-published random port the launch step pinned earlier
+17 -3
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import subprocess import subprocess
import sys import sys
import time
from typing import Mapping, cast from typing import Mapping, cast
from ...agent_provider import PromptMode, prompt_args from ...agent_provider import PromptMode, prompt_args
@@ -131,6 +132,11 @@ class SmolmachinesBottle(Bottle):
self.agent_argv(argv, tty=tty), check=False, self.agent_argv(argv, tty=tty), check=False,
).returncode ).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: def exec(self, script: str, *, user: str = "node") -> ExecResult:
"""Run a POSIX shell script as `user` (default `node`) and """Run a POSIX shell script as `user` (default `node`) and
capture the result. Matches the docker backend's `exec`, 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 `runuser -u <user> -- env ... /bin/sh -c <script>` switches UID
without invoking a login shell, then sets HOME / USER and the 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 = [ argv = [
"--", "runuser", "-u", user, "--", "--", "runuser", "-u", user, "--",
"env", *_env_assignments_for(user, self._guest_env), "env", *_env_assignments_for(user, self._guest_env),
"/bin/sh", "-c", script, "/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( r = subprocess.run(
["smolvm", "machine", "exec", "--name", self.name] + argv, ["smolvm", "machine", "exec", "--name", self.name] + argv,
capture_output=True, text=True, check=False, capture_output=True, text=True, check=False,
@@ -82,6 +82,14 @@ class SmolmachinesBottlePlan(BottlePlan):
agent_git_gate_host: str = "" agent_git_gate_host: str = ""
agent_supervise_url: 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 @property
def agent_command(self) -> str: def agent_command(self) -> str:
return self.agent_provision.command return self.agent_provision.command
@@ -2,11 +2,12 @@
Per PRD 0050 the per-provider provisioning steps (prompt, skills, Per PRD 0050 the per-provider provisioning steps (prompt, skills,
declarative provision-plan apply, supervise MCP registration) live on declarative provision-plan apply, supervise MCP registration) live on
the `AgentProvider` plugin under `bot_bottle/contrib/`. The modules the `AgentProvider` plugin under `bot_bottle/contrib/`. CA and git
left in this subpackage handle only the steps that are provisioning also moved to the AgentProvider ABC (with Debian/node
backend-specific: 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 - 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",
)
@@ -68,8 +68,9 @@ def _read_winsize() -> tuple[int, int] | None:
- tmux respawn-pane: tmux sets all three to the pane's PTY. - tmux respawn-pane: tmux sets all three to the pane's PTY.
- non-TTY (someone piped stdin in tests): none are; the - non-TTY (someone piped stdin in tests): none are; the
sync just no-ops, which is the right behavior.""" sync just no-ops, which is the right behavior."""
for fd in (sys.stdin.fileno(), sys.stdout.fileno(), sys.stderr.fileno()): for stream in (sys.stdin, sys.stdout, sys.stderr):
try: try:
fd = stream.fileno()
data = fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\x00" * 8) data = fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\x00" * 8)
except OSError: except OSError:
continue continue
+1 -1
View File
@@ -23,7 +23,7 @@ from ...agent_provider import (
AgentProvisionFile, AgentProvisionFile,
AgentProvisionPlan, AgentProvisionPlan,
) )
from ...codex_auth import codex_host_access_token, write_codex_dummy_auth_file from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file
from ...egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute from ...egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
from ...log import die, info, warn from ...log import die, info, warn
@@ -15,8 +15,8 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
from .log import die from ...log import die
from .util import expand_tilde from ...util import expand_tilde
def codex_auth_path(host_env: dict[str, str] | None = None) -> Path: def codex_auth_path(host_env: dict[str, str] | None = None) -> Path:
+12 -5
View File
@@ -49,11 +49,6 @@ class AgentProvider:
f"bottle '{bottle_name}' agent_provider.template must be a " f"bottle '{bottle_name}' agent_provider.template must be a "
f"non-empty string" 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", "") dockerfile = d.get("dockerfile", "")
if not isinstance(dockerfile, str): if not isinstance(dockerfile, str):
raise ManifestError( raise ManifestError(
@@ -66,6 +61,12 @@ class AgentProvider:
f"bottle '{bottle_name}' agent_provider.auth_token must be a " f"bottle '{bottle_name}' agent_provider.auth_token must be a "
f"string (was {type(auth_token).__name__})" 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": if auth_token and template != "claude":
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' agent_provider.auth_token is only " 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"bottle '{bottle_name}' agent_provider.forward_host_credentials "
f"must be a boolean (was {type(forward_host_credentials).__name__})" 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": if forward_host_credentials and template != "codex":
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' agent_provider.forward_host_credentials " f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
+269
View File
@@ -0,0 +1,269 @@
# PRD prd-new: User-defined agent provider plugins
- **Status:** Draft
- **Author:** claude
- **Created:** 2026-06-04
## Summary
The `get_provider()` registry in `bot_bottle/agent_provider.py` is a closed list —
only `"claude"` and `"codex"` are valid templates, validated at manifest-load time and
again at launch. Users who want to run a different agent (Gemini, Aider, a custom
local model wrapper) cannot add a provider without forking the package.
This PRD opens the registry to user-defined plugins. A plugin placed at
`~/.bot-bottle/contrib/<name>/` is discovered and loaded at launch time. The manifest
accepts any non-empty template string that names a built-in or resolves to a user
plugin at that path.
Alongside discovery, this PRD moves CA and git provisioning out of the Docker backend
and into the `AgentProvider` ABC as overridable methods. The current standalone
`provision/ca.py` and `provision/git.py` files in the Docker backend are deleted;
their logic becomes the default implementations on the ABC. This lets exotic provider
images (different base OS, different user, non-standard trust mechanism) override
provisioning freely without the abstraction fighting them.
The preceding commit on this PR moves `codex_auth.py` from `bot_bottle/` into
`bot_bottle/contrib/codex/` — a clean-up that fits naturally here since this PR
also clarifies that `contrib/` is the per-provider home.
## Problem
Users building unconventional setups hit a hard wall: the template validation in
`manifest_agent.AgentProvider.from_dict` rejects any string not in `PROVIDER_TEMPLATES`.
There is no escape hatch short of editing bot-bottle's source.
PRD 0050 moved provider logic into `contrib/` specifically so a third provider would
be "cheap to add" — but "cheap" today still means a pull request against the bot-bottle
repo, not a drop-in file in the user's home directory. The filesystem layout is already
the right shape; the discovery step is missing.
Beyond discovery, the Docker backend's `provision_ca` and `provision_git` functions
bake in Debian-specific commands (`update-ca-certificates`) and a hardcoded container
user (`node`). A user plugin that runs as a different user, or on a different base OS,
silently gets the wrong provisioning with no way to correct it short of forking.
## Goals / Success Criteria
1. A user places `~/.bot-bottle/contrib/<name>/agent_provider.py` — a file that exports
a class inheriting `AgentProvider` — sets `agent_provider.template: <name>` in a
bottle's frontmatter, and launches a bottle using that provider with no changes to
the bot-bottle source.
2. The plugin directory may also contain a `Dockerfile` at
`~/.bot-bottle/contrib/<name>/Dockerfile`; the existing three-tier Dockerfile cascade
(per-bottle override → manifest `dockerfile:` field → provider default) uses this
path as the provider default for user plugins.
3. The manifest validator accepts any non-empty template string. Unknown templates that
resolve to no user plugin still raise a clear error, but at launch (via `get_provider`)
rather than at manifest-load time.
4. Built-in provider knobs (`auth_token` → claude only; `forward_host_credentials`
codex only) are guarded to built-in template names. Bottles using a user provider
may set neither knob.
5. `get_provider(template)` checks `~/.bot-bottle/contrib/<template>/agent_provider.py`
before the built-ins, so a user can shadow a built-in for local testing.
6. A clear `ValueError` is raised if the user plugin file exists but contains no
`AgentProvider` subclass.
7. `AgentProvider` gains `provision_ca(self, bottle, plan)` and
`provision_git(self, bottle, plan)` with default implementations that reproduce
current Docker/Debian/node behavior. Built-in providers inherit the defaults
unchanged. User plugins override either method when their image diverges.
8. `bot_bottle/backend/docker/provision/ca.py` and
`bot_bottle/backend/docker/provision/git.py` are deleted. The Docker backend base
class calls `provider.provision_ca(bottle, plan)` and
`provider.provision_git(bottle, plan)` directly.
## Non-goals
- Packaging or distributing user plugins as installable Python packages.
- A plugin registry, index, or discovery beyond the filesystem path convention.
- Adding a third built-in provider.
- Validating that user plugin images, Dockerfiles, or commands exist before launch
(same policy as built-ins).
- Sandboxing user plugin code — plugins run with full Python interpreter access.
- Per-provider opt-out of the egress sidecar or network provisioning (follow-on).
## Scope
### In scope
- `get_provider(template: str) -> AgentProvider` gains a `_load_user_plugin(template)`
step that checks `~/.bot-bottle/contrib/<template>/agent_provider.py` first, then
falls through to the built-in look-ups.
- `_load_user_plugin` uses `importlib.util.spec_from_file_location` to load the module
and returns the first `AgentProvider` subclass found in its `__dict__`. Raises
`ValueError` if the file exists but exports no subclass.
- The Dockerfile cascade in the Docker backend's `resolve_plan()` uses
`~/.bot-bottle/contrib/<template>/Dockerfile` as the provider default for user
plugins (the same slot currently occupied by `Dockerfile.claude` / `Dockerfile.codex`
for built-ins).
- `manifest_agent.AgentProvider.from_dict`: the `template not in PROVIDER_TEMPLATES`
check is removed; the two built-in-specific knob guards (`auth_token` → claude,
`forward_host_credentials` → codex) are tightened to `template in PROVIDER_TEMPLATES`
so they are skipped for user-defined names.
- `PROVIDER_TEMPLATES` remains in `agent_provider.py` as the set of built-in names for
use by tests and any enumeration callers.
- `AgentProvider` ABC gains:
```python
def provision_ca(self, bottle: Bottle, plan: BottlePlan) -> None: ...
def provision_git(self, bottle: Bottle, plan: BottlePlan) -> None: ...
```
Default implementations reproduce the current `provision/ca.py` and
`provision/git.py` logic exactly (Debian `update-ca-certificates`, `node` user,
`/home/node` home).
- `bot_bottle/backend/docker/provision/ca.py` and
`bot_bottle/backend/docker/provision/git.py` deleted. The Docker backend base
class substitutes direct calls to the provider methods.
- Unit tests for the discovery path:
- Plugin found and loaded → correct `AgentProvider` instance returned.
- Plugin file exists but exports no subclass → `ValueError`.
- Unknown template with no user plugin → `ValueError` from `get_provider`.
- Built-in template name still works normally even when no user plugin exists.
- Unit tests for the provisioning delegation:
- A provider subclass that overrides `provision_ca` has its override called.
- A provider subclass that overrides `provision_git` has its override called.
- One paragraph added to `README.md` under a new "Custom providers" section describing
the `~/.bot-bottle/contrib/<name>/` convention (both `agent_provider.py` and
`Dockerfile`), the `provision_ca` / `provision_git` override points, and pointing at
the existing contrib providers as reference implementations.
### Out of scope
- Hot-reloading plugins during a running session.
- Plugin versioning or dependency declaration.
- Changes to the smolmachines backend provisioning path.
## Proposed design
### Discovery in `get_provider`
```python
import importlib.util
def get_provider(template: str) -> AgentProvider:
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()
if template == PROVIDER_CODEX:
from .contrib.codex.agent_provider import CodexAgentProvider
return CodexAgentProvider()
raise ValueError(f"unknown agent provider template: {template!r}")
def _load_user_plugin(template: str) -> AgentProvider | None:
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"
)
```
### Dockerfile convention for user plugins
`resolve_plan()` in the Docker backend already has a three-tier cascade. For user
plugins the provider-default slot is filled by:
```python
Path.home() / ".bot-bottle" / "contrib" / template / "Dockerfile"
```
Per-bottle overrides and manifest `dockerfile:` fields continue to take precedence.
### Provisioning methods on `AgentProvider`
```python
class AgentProvider(ABC):
...
def provision_ca(self, bottle: Bottle, plan: BottlePlan) -> None:
"""Install the egress MITM CA into the agent container's trust store.
Override for non-Debian base images or non-standard trust mechanisms."""
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)
def provision_git(self, bottle: Bottle, plan: BottlePlan) -> None:
"""Configure git inside the agent container.
Override for images that run as a different user or use a non-standard home."""
_provision_cwd_git(plan, bottle)
_provision_git_gate_config(plan, bottle)
_provision_git_user(plan, bottle)
```
The Docker backend base class replaces the direct calls to the old standalone
functions with:
```python
provider.provision_ca(bottle, plan)
provider.provision_git(bottle, plan)
```
### Manifest validation change
In `manifest_agent.AgentProvider.from_dict`, remove the hard rejection:
```python
# Before
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))}"
)
# After — removed entirely; get_provider() raises at launch for unknown names
```
Guard the built-in knob checks with `template in PROVIDER_TEMPLATES`:
```python
if auth_token and template == "claude": # unchanged
...
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 ({', '.join(sorted(PROVIDER_TEMPLATES))})"
)
if forward_host_credentials and template == "codex": # unchanged
...
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"
)
```
## Open questions
1. **`BOT_BOTTLE_CONTRIB_DIR` env var.** Omitted for now — `~/.bot-bottle/contrib/`
is consistent with the rest of the user config layout. Revisit if the need surfaces.
## References
- PRD 0050 — agent provider contrib (established `contrib/` as the per-provider home)
- PRD 0048 — SSH deploy key provisioning (the `contrib/` convention)
- `bot_bottle/agent_provider.py``get_provider`, `PROVIDER_TEMPLATES`, `AgentProvider` ABC
- `bot_bottle/manifest_agent.py` — template validation that this PRD relaxes
- `bot_bottle/backend/docker/provision/ca.py` — current CA provisioner (to be deleted)
- `bot_bottle/backend/docker/provision/git.py` — current git provisioner (to be deleted)
+1 -1
View File
@@ -9,7 +9,7 @@ import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from bot_bottle.codex_auth import ( from bot_bottle.contrib.codex.codex_auth import (
codex_auth_path, codex_auth_path,
codex_dummy_auth_json, codex_dummy_auth_json,
codex_host_access_token, codex_host_access_token,
+37 -17
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 Mocks bottle.exec / bottle.cp_in and asserts on the dispatched script
strings and user parameter. The cwd + git-gate passes are covered shape. provision_git is now a method on AgentProvider (default impl);
indirectly by the existing integration-shaped tests in the internal passes (_provision_cwd_git, _provision_git_gate_config,
test_smolmachines_provision; this file targets just the git_user _provision_git_user) are no longer exposed as separate helpers."""
pass."""
from __future__ import annotations from __future__ import annotations
@@ -13,16 +12,39 @@ import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock 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 import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan 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.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.workspace import workspace_plan 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 def _plan(*, git_user: dict | None = None, # type: ignore
copy_cwd: bool = False, copy_cwd: bool = False,
user_cwd: str = "/tmp/x", 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]]: 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 = [] out = []
for c in bottle.exec.call_args_list: for c in bottle.exec.call_args_list:
script = c.args[0] if c.args else c.kwargs.get("script", "") script = c.args[0] if c.args else c.kwargs.get("script", "")
@@ -100,7 +120,7 @@ def _git_config_exec_calls(bottle: MagicMock) -> list[tuple[str, str]]:
class TestProvisionGitUser(unittest.TestCase): class TestProvisionGitUser(unittest.TestCase):
def setUp(self): def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git-user.") self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git-user.") # pylint: disable=consider-using-with
self.stage = Path(self._tmp.name) self.stage = Path(self._tmp.name)
def tearDown(self): def tearDown(self):
@@ -108,7 +128,7 @@ class TestProvisionGitUser(unittest.TestCase):
def test_noop_when_no_git_user(self): def test_noop_when_no_git_user(self):
bottle = _make_bottle() 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)) self.assertEqual([], _git_config_exec_calls(bottle))
def test_copies_cwd_git_to_workspace_plan_path(self): def test_copies_cwd_git_to_workspace_plan_path(self):
@@ -116,7 +136,7 @@ class TestProvisionGitUser(unittest.TestCase):
(cwd / ".git").mkdir(parents=True) (cwd / ".git").mkdir(parents=True)
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage) plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
bottle = _make_bottle() bottle = _make_bottle()
_git._provision_cwd_git(plan, bottle) _PROVIDER.provision_git(bottle, plan)
bottle.cp_in.assert_called_once_with( bottle.cp_in.assert_called_once_with(
f"{cwd}/.git", f"{cwd}/.git",
@@ -125,10 +145,10 @@ class TestProvisionGitUser(unittest.TestCase):
chown_calls = [ chown_calls = [
c for c in bottle.exec.call_args_list c for c in bottle.exec.call_args_list
if "chown" in (c.args[0] if c.args else "") 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.assertEqual(1, len(chown_calls))
self.assertIn("node:node", chown_calls[0].args[0]) 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): def test_sets_name_and_email(self):
plan = _plan( plan = _plan(
@@ -136,7 +156,7 @@ class TestProvisionGitUser(unittest.TestCase):
stage_dir=self.stage, stage_dir=self.stage,
) )
bottle = _make_bottle() bottle = _make_bottle()
_git._provision_git_user(plan, bottle) _PROVIDER.provision_git(bottle, plan)
calls = _git_config_exec_calls(bottle) calls = _git_config_exec_calls(bottle)
self.assertEqual(2, len(calls)) self.assertEqual(2, len(calls))
for script, user in calls: for script, user in calls:
@@ -150,7 +170,7 @@ class TestProvisionGitUser(unittest.TestCase):
def test_name_only_sets_only_name(self): def test_name_only_sets_only_name(self):
plan = _plan(git_user={"name": "Bot"}, stage_dir=self.stage) plan = _plan(git_user={"name": "Bot"}, stage_dir=self.stage)
bottle = _make_bottle() bottle = _make_bottle()
_git._provision_git_user(plan, bottle) _PROVIDER.provision_git(bottle, plan)
calls = _git_config_exec_calls(bottle) calls = _git_config_exec_calls(bottle)
self.assertEqual(1, len(calls)) self.assertEqual(1, len(calls))
self.assertIn("user.name", calls[0][0]) 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, git_user={"email": "bot@example.com"}, stage_dir=self.stage,
) )
bottle = _make_bottle() bottle = _make_bottle()
_git._provision_git_user(plan, bottle) _PROVIDER.provision_git(bottle, plan)
calls = _git_config_exec_calls(bottle) calls = _git_config_exec_calls(bottle)
self.assertEqual(1, len(calls)) self.assertEqual(1, len(calls))
self.assertIn("user.email", calls[0][0]) self.assertIn("user.email", calls[0][0])
+89 -36
View File
@@ -14,21 +14,23 @@ from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from bot_bottle.agent_provider import ( from bot_bottle.agent_provider import (
AgentProvider,
AgentProviderRuntime,
AgentProvisionCommand, AgentProvisionCommand,
AgentProvisionDir, AgentProvisionDir,
AgentProvisionFile, AgentProvisionFile,
AgentProvisionPlan, AgentProvisionPlan,
) )
from bot_bottle.backend import Bottle, BottleSpec, ExecResult from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
from bot_bottle.backend.smolmachines.bottle_plan import ( from bot_bottle.backend.smolmachines.bottle_plan import (
SmolmachinesBottlePlan, SmolmachinesBottlePlan,
) )
from bot_bottle.backend.smolmachines.provision import ( from bot_bottle.backend.smolmachines.provision import (
ca as _ca,
git as _git,
workspace as _workspace, workspace as _workspace,
) )
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.backend.util import AGENT_CA_PATH
from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import GitEntry, Manifest from bot_bottle.manifest import GitEntry, Manifest
@@ -36,6 +38,26 @@ from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan 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( def _make_bottle(
name: str = "bot-bottle-demo-abc12", name: str = "bot-bottle-demo-abc12",
exec_result: ExecResult | None = None, exec_result: ExecResult | None = None,
@@ -232,7 +254,7 @@ class TestProvisionCA(unittest.TestCase):
cp_in + exec in the right order.""" cp_in + exec in the right order."""
def setUp(self): def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.") self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.") # pylint: disable=consider-using-with
self.tmp = Path(self._tmp.name) self.tmp = Path(self._tmp.name)
self.egress_ca = self.tmp / "egress-ca.pem" self.egress_ca = self.tmp / "egress-ca.pem"
_write_self_signed_cert(self.egress_ca) _write_self_signed_cert(self.egress_ca)
@@ -252,10 +274,10 @@ class TestProvisionCA(unittest.TestCase):
def test_egress_ca_always_installed(self): def test_egress_ca_always_installed(self):
plan = _plan(egress_ca_path=self.egress_ca) plan = _plan(egress_ca_path=self.egress_ca)
bottle = _make_bottle(exec_result=self._UPDATE_OK) 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( bottle.cp_in.assert_called_once_with(
str(self.egress_ca), str(self.egress_ca),
_ca.AGENT_CA_PATH, AGENT_CA_PATH,
) )
bottle.exec.assert_called_once() bottle.exec.assert_called_once()
script = bottle.exec.call_args.args[0] script = bottle.exec.call_args.args[0]
@@ -263,28 +285,59 @@ class TestProvisionCA(unittest.TestCase):
self.assertIn("update-ca-certificates", script) self.assertIn("update-ca-certificates", script)
self.assertEqual("root", bottle.exec.call_args.kwargs.get("user")) 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): def test_dies_when_egress_cert_missing(self):
plan = _plan(egress_ca_path=self.tmp / "does-not-exist.pem") plan = _plan(egress_ca_path=self.tmp / "does-not-exist.pem")
bottle = _make_bottle() bottle = _make_bottle()
with self.assertRaises(SystemExit): 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): class TestProvisionGit(unittest.TestCase):
@@ -293,7 +346,7 @@ class TestProvisionGit(unittest.TestCase):
when its condition doesn't hold.""" when its condition doesn't hold."""
def setUp(self): def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git.") self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git.") # pylint: disable=consider-using-with
self.stage = Path(self._tmp.name) self.stage = Path(self._tmp.name)
def tearDown(self): def tearDown(self):
@@ -301,20 +354,20 @@ class TestProvisionGit(unittest.TestCase):
def test_noop_when_no_cwd_and_no_git_entries(self): def test_noop_when_no_cwd_and_no_git_entries(self):
bottle = _make_bottle() 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.cp_in.assert_not_called()
bottle.exec.assert_not_called() bottle.exec.assert_not_called()
def test_copies_cwd_git_when_copy_cwd_and_git_present(self): def test_copies_cwd_git_when_copy_cwd_and_git_present(self):
# Stage a fake host .git dir under user_cwd so the path- # 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 = self.stage / "cwd"
(cwd / ".git").mkdir(parents=True) (cwd / ".git").mkdir(parents=True)
plan = _plan( plan = _plan(
copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage, copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage,
) )
bottle = _make_bottle() bottle = _make_bottle()
_git.provision_git(plan, bottle) _PROVIDER.provision_git(bottle, plan)
bottle.cp_in.assert_called_once_with( bottle.cp_in.assert_called_once_with(
f"{cwd}/.git", f"{cwd}/.git",
"/home/node/workspace/.git", "/home/node/workspace/.git",
@@ -330,7 +383,7 @@ class TestProvisionGit(unittest.TestCase):
def test_skips_cwd_when_copy_cwd_false(self): def test_skips_cwd_when_copy_cwd_false(self):
plan = _plan(copy_cwd=False, stage_dir=self.stage) plan = _plan(copy_cwd=False, stage_dir=self.stage)
bottle = _make_bottle() bottle = _make_bottle()
_git.provision_git(plan, bottle) _PROVIDER.provision_git(bottle, plan)
bottle.cp_in.assert_not_called() bottle.cp_in.assert_not_called()
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self): def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
@@ -348,13 +401,13 @@ class TestProvisionGit(unittest.TestCase):
agent_git_gate_host="127.0.0.1:9418", agent_git_gate_host="127.0.0.1:9418",
) )
bottle = _make_bottle() bottle = _make_bottle()
_git.provision_git(plan, bottle) _PROVIDER.provision_git(bottle, plan)
# The staged gitconfig path is whatever NamedTemporaryFile # The staged gitconfig path is whatever NamedTemporaryFile
# picked; we read its contents. # picked; we read its contents.
cp_call = bottle.cp_in.call_args cp_call = bottle.cp_in.call_args
staged_path = Path(cp_call.args[0]) staged_path = Path(cp_call.args[0])
self.assertEqual(self.stage, staged_path.parent) self.assertEqual(self.stage, staged_path.parent)
content = staged_path.read_text() content = staged_path.read_text(encoding="utf-8")
self.assertIn( self.assertIn(
'[url "http://127.0.0.1:9418/bot-bottle.git"]', content, '[url "http://127.0.0.1:9418/bot-bottle.git"]', content,
) )
@@ -392,7 +445,7 @@ class TestBundleLaunchSpec(unittest.TestCase):
class TestProvisionGitUser(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 guest as the node user. SmolmachinesBottle.exec sets HOME and
USER automatically for the requested user, so --global lands USER automatically for the requested user, so --global lands
in /home/node/.gitconfig. No-op when the bottle didn't declare 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): def test_noop_when_no_git_user(self):
bottle = _make_bottle() bottle = _make_bottle()
_git._provision_git_user(_plan(), bottle) _PROVIDER.provision_git(bottle, _plan())
self.assertEqual([], self._git_config_calls(bottle)) self.assertEqual([], self._git_config_calls(bottle))
def test_sets_name_and_email_as_node(self): def test_sets_name_and_email_as_node(self):
@@ -420,7 +473,7 @@ class TestProvisionGitUser(unittest.TestCase):
"email": "eric@dideric.is", "email": "eric@dideric.is",
}) })
bottle = _make_bottle() bottle = _make_bottle()
_git._provision_git_user(plan, bottle) _PROVIDER.provision_git(bottle, plan)
calls = self._git_config_calls(bottle) calls = self._git_config_calls(bottle)
self.assertEqual(2, len(calls)) self.assertEqual(2, len(calls))
# Both run as node so SmolmachinesBottle.exec sets HOME=/home/node # Both run as node so SmolmachinesBottle.exec sets HOME=/home/node
@@ -436,7 +489,7 @@ class TestProvisionGitUser(unittest.TestCase):
def test_name_only(self): def test_name_only(self):
plan = _plan(git_user={"name": "Bot"}) plan = _plan(git_user={"name": "Bot"})
bottle = _make_bottle() bottle = _make_bottle()
_git._provision_git_user(plan, bottle) _PROVIDER.provision_git(bottle, plan)
calls = self._git_config_calls(bottle) calls = self._git_config_calls(bottle)
self.assertEqual(1, len(calls)) self.assertEqual(1, len(calls))
self.assertIn("user.name", calls[0][0]) self.assertIn("user.name", calls[0][0])
@@ -445,7 +498,7 @@ class TestProvisionGitUser(unittest.TestCase):
def test_email_only(self): def test_email_only(self):
plan = _plan(git_user={"email": "bot@example.com"}) plan = _plan(git_user={"email": "bot@example.com"})
bottle = _make_bottle() bottle = _make_bottle()
_git._provision_git_user(plan, bottle) _PROVIDER.provision_git(bottle, plan)
calls = self._git_config_calls(bottle) calls = self._git_config_calls(bottle)
self.assertEqual(1, len(calls)) self.assertEqual(1, len(calls))
self.assertIn("user.email", calls[0][0]) self.assertIn("user.email", calls[0][0])
@@ -454,7 +507,7 @@ class TestProvisionGitUser(unittest.TestCase):
class TestProvisionWorkspace(unittest.TestCase): class TestProvisionWorkspace(unittest.TestCase):
def setUp(self): def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-workspace.") self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-workspace.") # pylint: disable=consider-using-with
self.stage = Path(self._tmp.name) self.stage = Path(self._tmp.name)
def tearDown(self): def tearDown(self):