Commit Graph

21 Commits

Author SHA1 Message Date
didericis a786ca3391 refactor(util): split private helpers off DockerBottleBackend
test / run tests/run_tests.py (pull_request) Successful in 14s
New file claude_bottle/backend/util.py for cross-backend host-side
helpers:
  host_skill_dir(name) — resolves $HOME/.claude/skills/<name>

docker/util.py gains:
  docker_exec_root(container, argv) — `docker exec -u 0` wrapper used
    by SSH provisioning

DockerBottleBackend drops the two methods that wrapped these
(`_host_skill_dir`, `_docker_exec_root`) — they had no instance state
and just lived on the class for organizational reasons. Call sites
now use the imported functions directly.
2026-05-11 14:09:55 -04:00
didericis 1269edf311 refactor(pipelock): PipelockProxy.prepare takes a Bottle, not (manifest, name)
test / run tests/run_tests.py (pull_request) Successful in 14s
Matches the allowlist-resolution helpers' shape: the caller resolves
the bottle once and passes it in. Signature drops from
(manifest, bottle_name, slug, yaml_path) to (bottle, slug, yaml_path).

DockerBottleBackend.prepare_proxy uses manifest.bottle_for(agent_name)
to get the bottle directly. Tests pass fixture.bottles[name].

prepare's docstring also explains what `slug` is: the lowercased,
hyphen-normalized agent identifier used as the suffix in every
per-agent resource name (agent container, pipelock container, the
internal/egress networks). It's stored on the plan so start can
derive the sidecar's container name.

Top-level pipelock.py drops the Manifest import — no longer used.
2026-05-11 14:05:48 -04:00
didericis 1b3254bf37 refactor(pipelock): move PIPELOCK_IMAGE and PIPELOCK_PORT to docker/pipelock.py
test / run tests/run_tests.py (pull_request) Successful in 14s
Both constants were already only used by Docker-specific code (the
sidecar boot, the proxy_url/host_port naming helpers, the image
contract test). Move them next to DockerPipelockProxy.

Top-level pipelock.py drops the 'os' import along with the constants;
the two test files that pulled PIPELOCK_IMAGE retarget at the new
location.
2026-05-11 13:59:43 -04:00
didericis b49281800a refactor(pipelock): move Docker-specific naming helpers to docker/pipelock.py
test / run tests/run_tests.py (pull_request) Successful in 16s
The three slug-based naming helpers were nominally on pipelock.py but
each assumed a Docker container topology (the container name is
'claude-bottle-pipelock-<slug>', the proxy URL uses that container
name). Move them next to DockerPipelockProxy:

  pipelock_container_name  -> claude-bottle-pipelock-<slug>
  pipelock_proxy_url       -> http://<container>:<port>
  pipelock_proxy_host_port -> <container>:<port>

backend.py imports them directly from .pipelock; the orphan-cleanup
test imports container_name from the same place.
2026-05-11 13:57:18 -04:00
didericis edd8b444a6 refactor(pipelock): split sidecar lifecycle into DockerPipelockProxy
test / run tests/run_tests.py (pull_request) Successful in 18s
PipelockProxy becomes an ABC with the platform-agnostic
prepare/_build_pipelock_yaml as concrete methods and start/stop as
abstract. Docker-specific sidecar lifecycle moves to a new sibling
file:

  claude_bottle/backend/docker/pipelock.py
    DockerPipelockProxy(PipelockProxy) — implements start (docker
    create/cp/network connect/start) and stop (docker inspect/rm -f).

DockerBottleBackend._proxy is now a DockerPipelockProxy instance.
Tests that previously instantiated PipelockProxy() directly switch to
DockerPipelockProxy() (the base is no longer constructable).
2026-05-11 13:53:45 -04:00
didericis 25e67137f2 refactor(pipelock): allowlist-resolution helpers take a Bottle, not (manifest, name)
test / run tests/run_tests.py (pull_request) Successful in 17s
Every function in the 'Allowlist resolution' section was doing
`manifest.bottles[bottle_name].X` as its first move. Push the lookup
to the caller and have each helper take a resolved Bottle:

  pipelock_bottle_allowlist
  pipelock_bottle_ssh_hostnames
  pipelock_bottle_ssh_trusted_domains
  pipelock_bottle_ssh_ip_cidrs
  pipelock_effective_allowlist
  pipelock_allowlist_summary

PipelockProxy._build_pipelock_yaml resolves bottle once at the top
and passes it through; DockerBottleBackend.prepare already had the
bottle in scope and now uses it directly. Tests pass the resolved
bottle from each fixture.
2026-05-11 13:44:58 -04:00
didericis ff962d2893 refactor(pipelock): start/stop become methods on PipelockProxy
test / run tests/run_tests.py (pull_request) Successful in 31s
ProxyPlan -> PipelockProxyPlan, with two additional fields populated
in launch: internal_network, egress_network (default ""). prepare
fills yaml_path + slug; launch uses dataclasses.replace to populate
the networks before calling start.

pipelock_start -> PipelockProxy.start(plan). Reads yaml_path, slug,
internal_network, egress_network off the plan. Returns the resolved
container name.

pipelock_stop -> PipelockProxy.stop(proxy_target). Takes the resolved
container name directly (the value that start returned); no longer
needs to know about slugs or naming conventions.

Backend launch passes the running container name (state["pipelock"])
to stop. Test for stop's idempotency uses pipelock_container_name to
construct the proxy_target.
2026-05-11 10:57:07 -04:00
didericis c2cdb7777d refactor(pipelock): prepare_proxy returns a ProxyPlan
Add a frozen ProxyPlan dataclass to pipelock.py (currently one field:
yaml_path; kept as a class so future proxy-level state has a home).

  - prepare_proxy(spec, stage_dir) now returns pipelock.ProxyPlan
    instead of a raw Path.
  - DockerBottlePlan replaces pipelock_yaml_path + pipelock_yaml_filename
    with a single proxy: ProxyPlan field.
  - launch reads plan.proxy.yaml_path.parent / .name when calling
    pipelock_start. Eventually pipelock_start should just take a Path
    but that's a separate change.
2026-05-11 01:27:03 -04:00
didericis 1b8d3bbb94 refactor(docker): prepare_proxy takes stage_dir and owns the yaml path
test / run tests/run_tests.py (pull_request) Successful in 15s
prepare_proxy(spec, stage_dir) -> Path now decides where the
pipelock yaml lives inside stage_dir (currently 'pipelock.yaml'),
writes it via PipelockProxy.prepare, and returns the resolved path.
The caller (prepare) drops its 'pipelock_yaml_filename' /
'pipelock_yaml = stage_dir / filename' setup and just consumes the
returned Path; the plan's pipelock_yaml_filename is derived from
.name on the path.
2026-05-11 01:22:26 -04:00
didericis 30ead9102a refactor(pipelock): introduce PipelockProxy class housing the yaml body
test / run tests/run_tests.py (pull_request) Successful in 14s
The YAML generation now lives on PipelockProxy.prepare(manifest,
bottle_name, yaml_path) in claude_bottle/pipelock.py. The class is the
natural home for any future proxy-level state.

DockerBottleBackend keeps an instance as a class attribute
(_proxy = PipelockProxy()) and its prepare_proxy becomes a thin
delegation. A future backend that wants a different egress proxy
(or none) plugs in its own strategy.

Tests retarget at the new home — PipelockProxy.prepare gets the
content-shape assertions; the sidecar smoke test uses the class
directly too. Same coverage.
2026-05-11 01:18:53 -04:00
didericis 11f17d7927 refactor(docker): inline pipelock_write_yaml body into prepare_proxy
test / run tests/run_tests.py (pull_request) Successful in 16s
The yaml generation logic moves wholesale onto DockerBottleBackend
where it's used. pipelock_write_yaml is deleted; pipelock.py keeps
the allowlist resolution helpers (still called by prepare_proxy and
by pipelock_allowlist_summary).

The pipelock_start error message that referenced "pipelock_write_yaml
must run first" now says "backend.prepare_proxy must run first."

tests/test_pipelock_yaml.py rewritten to drive DockerBottleBackend().
prepare_proxy(spec, yaml_path); test_pipelock_sidecar_smoke.py call
site updated similarly. Same coverage at the new location.
2026-05-11 01:04:47 -04:00
didericis 8457869dcd refactor(util): move expand_tilde to top-level claude_bottle/util.py
test / run tests/run_tests.py (pull_request) Successful in 21s
_expand_tilde was a path-string helper on DockerBottleBackend but
nothing about it was Docker-specific — any future backend reading
host paths from manifest entries would want it. Lift to
claude_bottle/util.py (sibling of log.py / manifest.py) as a public
expand_tilde() function. Docker-specific primitives stay in
claude_bottle/backend/docker/util.py.
2026-05-11 00:52:33 -04:00
didericis 6298d33c31 refactor(docker): absorb claude_bottle/ssh.py into DockerBottleBackend
test / run tests/run_tests.py (pull_request) Successful in 17s
Mirrors the skills.py absorb. ssh_validate_entries -> validate_ssh_entries
(called from prepare); ssh_setup body -> provision_ssh; the
_docker_exec_root and _expand_tilde helpers become private methods on
the backend.

The detailed isolation-model docstring from the old module moves to
provision_ssh, where the code lives now. ssh.py deleted.
2026-05-11 00:49:05 -04:00
didericis c9fe23a043 refactor(docker): absorb claude_bottle/skills.py into DockerBottleBackend
test / run tests/run_tests.py (pull_request) Successful in 18s
The whole module folds into two methods on the backend:

  validate_skills(skills)  — called from prepare; fails loudly when
                              a named skill is missing on the host so
                              the user doesn't get a y/N for a plan
                              that's already known to break.
  _host_skill_dir(name)    — private helper used by both
                              validate_skills and provision_skills.

skills.py is deleted; the four prior functions (host_skill_dir,
host_skill_exists, require_host_skill, skills_validate_all) collapse
into the two above without losing the pre-y/N validation.
2026-05-11 00:44:34 -04:00
didericis d45d4fec8a refactor(docker): inline skills_copy_into into provision_skills
test / run tests/run_tests.py (pull_request) Successful in 14s
The copy logic was Docker-specific (docker exec mkdir / rm -rf,
docker cp); it had no reason to live in a top-level skills module.
Pull the body into DockerBottleBackend.provision_skills.

skills.py keeps the host-side discovery + validation
(host_skill_dir, host_skill_exists, require_host_skill,
skills_validate_all). The orphaned CONTAINER_HOME /
CONTAINER_SKILLS_DIR constants and the now-unused subprocess + info
imports are removed.
2026-05-11 00:38:25 -04:00
didericis 054dc09b38 refactor(backend): make provision_* abstract; provision lives on the base
test / run tests/run_tests.py (pull_request) Successful in 14s
Template Method pattern. BottleBackend.provision is now concrete and
orchestrates four abstract sub-methods:

  provision_prompt  -> str | None    (only one with a meaningful return)
  provision_skills  -> None
  provision_ssh     -> None
  provision_git     -> None

Each is self-gating: skills/ssh/git short-circuit on empty inputs;
prompt always copies (the path must exist) and returns None when the
agent has no prompt content.

DockerBottleBackend drops its own `provision` (inherited from the
base) and now just implements the four sub-methods. Each sub-method
takes `plan: BottlePlan` (matching the abstract) and asserts
isinstance to narrow to DockerBottlePlan internally, same pattern as
`launch`.

A future fly.io backend implements the four sub-methods; provision
works for it unchanged.
2026-05-11 00:31:36 -04:00
didericis 5d46d1bea4 refactor(docker): extract provision_skills to mirror the others
test / run tests/run_tests.py (pull_request) Successful in 15s
Four symmetric provision sub-methods now: provision_prompt,
provision_skills, provision_ssh, provision_git. Each self-gates with
an early return; provision is pure orchestration.
2026-05-11 00:26:10 -04:00
didericis 5a024259a6 refactor(docker): split provision into provision_prompt / _ssh / _git
test / run tests/run_tests.py (pull_request) Successful in 13s
provision now orchestrates three focused sub-methods. Each sub-method
self-gates: provision_ssh is a no-op when the bottle has no SSH
entries; provision_git is a no-op when --cwd was not set. The prompt
copy + chown always runs (so the path always exists in-container);
the return is gated on whether the agent has a non-empty prompt.
2026-05-11 00:20:22 -04:00
didericis 133a7a39e7 refactor(backend): fold BottleProvisioner back into BottleBackend
test / run tests/run_tests.py (pull_request) Successful in 14s
BottleProvisioner had no independent identity — no state, only one
caller, never selected, never crossed a method boundary as data. It
was a method dressed up as a class. Reverting that turn:

  - BottleBackend gains an abstract provision(plan, target).
  - DockerBottleBackend.provision absorbs the body that lived on
    DockerBottleProvisioner.
  - backend/docker/provisioner.py deleted.
  - BottleProvisioner ABC removed from backend/__init__.py.
  - launch now calls self.provision(plan, container) directly.

Net: -1 file, -1 class, -1 ABC. Same behavior; tests pass.
2026-05-11 00:13:36 -04:00
didericis 7b5a798186 refactor(backend): introduce BottleProvisioner ABC + DockerBottleProvisioner
test / run tests/run_tests.py (pull_request) Successful in 17s
Lift the file-copying-into-the-running-container step out of
DockerBottleBackend._provision_container into its own class. The
backend now holds a DockerBottleProvisioner instance and delegates
the post-launch provisioning to it.

  - BottleProvisioner (abstract) in backend/__init__.py with a
    `provision(plan, target) -> str | None` method.
  - DockerBottleProvisioner (concrete) in backend/docker/provisioner.py
    inheriting from the base, narrowing plan to DockerBottlePlan via
    isinstance, and carrying the prompt/skills/SSH/.git copy logic
    unchanged.
  - DockerBottleBackend keeps a class-level DockerBottleProvisioner()
    and calls self._provisioner.provision(plan, container) from launch.
    _provision_container method removed.

No behavior change.
2026-05-11 00:04:12 -04:00
didericis 70a22fa210 refactor: rename platform abstraction to backend
test / run tests/run_tests.py (pull_request) Successful in 21s
Across the package:
  - claude_bottle/platform/         -> claude_bottle/backend/
  - platform/docker/platform.py     -> backend/docker/backend.py
  - class BottlePlatform            -> BottleBackend
  - class DockerBottlePlatform      -> DockerBottleBackend
  - get_bottle_platform()           -> get_bottle_backend()
  - env var CLAUDE_BOTTLE_PLATFORM  -> CLAUDE_BOTTLE_BACKEND
  - dict _PLATFORMS                 -> _BACKENDS

"Backend" is shorter and more established as the term for a
pluggable strategy-pattern implementation. "Platform" was vague
(could mean OS, hardware, cloud) and mildly redundant — Docker is
itself a platform.

The previous PRD section claiming "the Backend protocol was
rejected" referred to a low-level run/exec/cp/network_connect
protocol; the name was never the reason. The PRD is updated to
describe that rejected design by shape rather than by name.

The bottle/agent concepts and the manifest schema are unchanged.
2026-05-10 23:59:38 -04:00