# PRD 0004: Split out provisioners

- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-11

## Summary

Break `bot_bottle/backend/docker/backend.py` (664 lines) apart by
moving the four provisioner methods — `provision_prompt`,
`provision_skills`, `provision_ssh`, `provision_git` — out of
`DockerBottleBackend` into their own modules under
`bot_bottle/backend/docker/provision/`. The abstract base in
`bot_bottle/backend/__init__.py` keeps the same four-method
contract; only the Docker implementation changes shape.

## Problem

`DockerBottleBackend` is doing too much in one file. After PRD 0003
landed, the class owns:

- `prepare` — name resolution, validation, scratch file writes
- `launch` — image build, network creation, sidecar lifecycle,
  `docker run`, teardown
- `_run_agent_container` — argv assembly + name-conflict retry
- `provision_prompt` / `provision_skills` / `provision_ssh` /
  `provision_git` — four host→container copy paths
- `prepare_cleanup` / `cleanup` / `list_active` — orphan handling

The provisioners are the largest single chunk. `provision_ssh` alone
is ~150 lines because it sets up a root-staged keyring, an in-container
`ssh-agent`, and a `socat` forwarder so node (uid 1000) can talk to a
root-owned agent socket without ptrace access. That logic is
self-contained — it touches the container via `docker exec` and
`docker cp` and reads from `BottlePlan` — but it sits in the same file
as image build and cleanup, which makes the file hard to scan and
invites unrelated changes to land in the same diff.

The provisioners are also the most likely place for new backends to
diverge. A future fly.io backend would not run `ssh-agent` in a
sidecar this way; an Apple `container` backend might. Pulling each
provisioner into its own module makes the per-backend variation a
file boundary, not a method boundary inside a god-class.

## Goals / Success Criteria

The feature works when all of the following are observable:

- `cli.py start` produces a byte-identical container topology, env,
  skills layout, SSH config, and `.git` copy as before the split. No
  user-visible behavior change.
- `DockerBottleBackend` in `backend.py` is under ~350 lines, with the
  four provisioner methods reduced to thin dispatchers that delegate
  to the per-provisioner modules.
- The full test suite passes unchanged (unit + integration + canary).

The feature is **done** when all of the following ship:

- A new `bot_bottle/backend/docker/provision/` subpackage exists
  with one module per provisioner: `prompt.py`, `skills.py`, `ssh.py`,
  `git.py`. Each exports a single top-level function taking
  `(plan: DockerBottlePlan, target: str)` and returning the same type
  the current method returns (`str | None` for prompt, `None` for the
  others).
- `DockerBottleBackend.provision_prompt` / `provision_skills` /
  `provision_ssh` / `provision_git` each become one-line delegations
  to the new module functions.
- The abstract `BottleBackend.provision_*` signatures in
  `bot_bottle/backend/__init__.py` are unchanged. The
  `BottleBackend.provision` orchestration in the base class is
  unchanged.
- No top-level CLI code or other backend gains a direct import of the
  provisioner modules — the only call site is
  `DockerBottleBackend.provision_*`.

## Non-goals

- No change to *what* the provisioners do. The SSH provisioning's
  root-keyring + ssh-agent + socat-bridge design stays exactly as it
  is. The skills `docker cp <src>/. <dst>/` pattern stays. The
  `.git` copy stays gated on `spec.copy_cwd` + cwd having a `.git`.
- No replacement of `launch`'s ad-hoc `state: dict[str, str]`
  teardown with `contextlib.ExitStack`. That cleanup is worthwhile
  but is a separate change.
- No deduplication of the two name-conflict retry loops (one in
  `prepare`, one in `_run_agent_container`).
- No removal of the `os.environ["CLAUDE_CODE_OAUTH_TOKEN"]` mutation
  in `_run_agent_container`. That's a parent-process side effect
  worth fixing, but it's outside the provisioner split.
- No new abstract base class for provisioners (no `Provisioner` ABC).
  The four functions stay module-level; the abstract surface is the
  four methods on `BottleBackend`. Introducing a `Provisioner` type
  would be premature with one backend.
- No change to the `BottleBackend.provision_*` method names or
  signatures. Callers continue to invoke them on the backend
  instance.

## Scope

### In scope

- New `bot_bottle/backend/docker/provision/` subpackage with
  `__init__.py`, `prompt.py`, `skills.py`, `ssh.py`, `git.py`.
- Moving the four method bodies out of
  `DockerBottleBackend` into the new modules verbatim, adjusting only
  what's needed to make them free functions: `self` becomes implicit
  via the `plan` argument; private helpers move with their primary
  caller; imports of `docker_mod`, `network_mod`, `pipelock`,
  `expand_tilde`, etc. follow them.
- Reducing the `provision_*` methods on `DockerBottleBackend` to
  one-line delegations.
- Updating any tests that monkeypatch
  `DockerBottleBackend.provision_*` to monkeypatch the new module
  functions instead (if any do — most existing tests don't reach
  into provisioning).

### Out of scope

- `prepare`, `launch`, `_run_agent_container`, `prepare_cleanup`,
  `cleanup`, `list_active`. These stay in `backend.py`.
- The `validate_skills` and `validate_ssh_entries` host-side
  validation methods. They run from `prepare` (before the y/N), not
  from `provision`, so they belong with `prepare` and stay on the
  class.
- Any change to the abstract `BottleBackend` base in
  `backend/__init__.py`.
- Cross-backend reuse of provisioner code. There's no second backend
  yet; designing for one before it exists would be premature.

## Proposed Design

### New layout

```
bot_bottle/backend/docker/
  backend.py                # DockerBottleBackend (slimmer)
  bottle.py
  bottle_plan.py
  bottle_cleanup_plan.py
  network.py
  pipelock.py
  util.py
  provision/
    __init__.py             # empty; explicit imports per module
    prompt.py               # provision_prompt(plan, target) -> str | None
    skills.py               # provision_skills(plan, target) -> None
    ssh.py                  # provision_ssh(plan, target) -> None
    git.py                  # provision_git(plan, target) -> None
```

### Function signatures

Each module exports one top-level function with the same shape:

```python
# prompt.py
def provision_prompt(plan: DockerBottlePlan, target: str) -> str | None: ...

# skills.py
def provision_skills(plan: DockerBottlePlan, target: str) -> None: ...

# ssh.py
def provision_ssh(plan: DockerBottlePlan, target: str) -> None: ...

# git.py
def provision_git(plan: DockerBottlePlan, target: str) -> None: ...
```

`target` is the resolved container name (same value the current
methods receive). The functions are free functions, not methods, so
they don't accept `self`.

### Delegation on the backend

`DockerBottleBackend.provision_*` shrinks to:

```python
from .provision import prompt as _prompt
from .provision import skills as _skills
from .provision import ssh as _ssh
from .provision import git as _git

class DockerBottleBackend(BottleBackend):
    ...
    def provision_prompt(self, plan: BottlePlan, target: str) -> str | None:
        assert isinstance(plan, DockerBottlePlan)
        return _prompt.provision_prompt(plan, target)

    def provision_skills(self, plan: BottlePlan, target: str) -> None:
        assert isinstance(plan, DockerBottlePlan)
        _skills.provision_skills(plan, target)

    # ...same for ssh, git
```

The `isinstance` assert stays on the method (the abstract base passes
`BottlePlan`, not `DockerBottlePlan`) so the module functions can
take the concrete type and skip re-checking.

### Existing code touched

- **`bot_bottle/backend/docker/backend.py`** — four method
  bodies move out; method definitions stay as one-line delegations.
  Imports for `pipelock_proxy_host_port`, `expand_tilde`, etc., that
  are only used by the moved bodies migrate with them.
- **`bot_bottle/backend/docker/__init__.py`** — no change. The
  public surface (`DockerBottleBackend`) is unchanged.
- **`bot_bottle/backend/__init__.py`** — no change.
- **`tests/`** — no expected change. Existing tests exercise the
  backend via `DockerBottleBackend` or the CLI surface; they don't
  reach into provisioners directly. Verify after the move and only
  update if a test breaks.

### Data model changes

None.

### External dependencies

None new.

## References

- PRD 0003 (`docs/prds/0003-bottle-backend-abstraction.md`) —
  establishes the four-method provisioner contract being preserved
  here.
