Files
bot-bottle/docs/prds/0053-user-provider-plugins.md
didericis 7ebddf7792
prd-number / assign-numbers (push) Successful in 20s
ci(prd): assign sequential numbers to new PRDs
prd-new-user-provider-plugins → 0053-user-provider-plugins
prd-new-named-labelled-agents → 0054-named-labelled-agents

Both PRDs ship with their implementations so Status flips Draft → Active.
Manual fix: the prd-number workflow did not fire on these merges.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 14:23:56 -04:00

12 KiB

PRD 0053: User-defined agent provider plugins

  • Status: Active
  • 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:
    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

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:

Path.home() / ".bot-bottle" / "contrib" / template / "Dockerfile"

Per-bottle overrides and manifest dockerfile: fields continue to take precedence.

Provisioning methods on AgentProvider

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:

provider.provision_ca(bottle, plan)
provider.provision_git(bottle, plan)

Manifest validation change

In manifest_agent.AgentProvider.from_dict, remove the hard rejection:

# 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:

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.pyget_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)