Commit Graph

105 Commits

Author SHA1 Message Date
didericis 52bb007b9e refactor(docker): move provision_ssh into provision/ssh.py 2026-05-11 19:43:12 -04:00
didericis 36d3e7f739 refactor(docker): move provision_skills into provision/skills.py 2026-05-11 19:41:32 -04:00
didericis 1b17b36988 refactor(docker): move provision_prompt into provision/prompt.py 2026-05-11 19:40:51 -04:00
didericis 45203e2cd6 docs(prd): add 0004 split out provisioners
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 13s
2026-05-11 19:36:39 -04:00
didericis b1001ba1b8 Merge pull request 'Refactor tests' (#6) from refactor-tests into main
test / unit (push) Successful in 15s
test / integration (push) Successful in 13s
2026-05-11 19:26:27 -04:00
didericis 7fb0b8488b test(pipelock): skip sidecar smoke under act_runner
test / unit (pull_request) Successful in 13s
test / integration (pull_request) Successful in 14s
The smoke test now drives the production prepare/start path, which
calls network_create_internal. Under Gitea act_runner the docker
socket mount topology makes `docker network create --internal` fail
(or be invisible across the host/job-container boundary) — the same
limitation that test_orphan_cleanup.test_create_and_remove already
skips for. Match that skip here so CI goes green; the test still
runs in environments with a direct docker daemon.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 19:24:34 -04:00
didericis f943e14891 refactor(pipelock): take stage_dir, derive yaml_path internally
test / unit (pull_request) Successful in 11s
test / integration (pull_request) Failing after 12s
PipelockProxy.prepare now accepts (bottle, slug, stage_dir) and derives
the yaml_path itself, so callers don't need to know the filename.
DockerBottleBackend.prepare_proxy becomes a one-line wrapper whose only
caller already has bottle and slug in scope, so it's inlined and
deleted.
2026-05-11 16:50:22 -04:00
didericis 479adc625a test(pipelock): collapse over-decomposed allowlist helper tests
test / unit (pull_request) Successful in 11s
test / integration (pull_request) Successful in 21s
The four lower-level helpers (pipelock_bottle_allowlist,
pipelock_bottle_ssh_hostnames, pipelock_bottle_ssh_ip_cidrs,
pipelock_bottle_ssh_trusted_domains) are one-line filters; testing
each in isolation duplicates coverage that pipelock_effective_allowlist
already provides end-to-end. The /32 CIDR suffix is the only behavior
beyond filtering, so it keeps a tiny dedicated test.

Drops the misplaced test_rejects_non_string_entry — that's manifest
validation, not allowlist resolution. Belongs in a manifest-validation
test file (which doesn't exist yet); leaving for a separate PR rather
than adding a one-branch sample here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:36:04 -04:00
didericis 757e76add7 test(cli): tighten and relocate --format=json validation test
Move the --format=json-requires-dry-run check out of the integration
suite (it doesn't need Docker — argparse fails before any backend
runs) and tighten the assertion: previously asserted only that exit
code was nonzero, so any unrelated breakage (manifest resolution
failure, bad agent name, etc.) silently passed. Now asserts stderr
contains the actual flag-conflict message.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:35:55 -04:00
didericis b97807ac71 docs(research): evaluate smolmachines as VM backend
test / run tests/run_tests.py (push) Successful in 16s
Compares smolmachines against the six subsystems in
agent-vm-isolation.md. smolmachines replaces the microVM runtime,
network attachment (libkrun TSI with built-in DNS-over-vsock filter),
vsock control plane, and Python lifecycle wrapper. Pipelock stays;
disk-image story shifts to OCI + writable overlay. Recommends adopting
smolmachines as the macOS VM backend after smoke-testing TSI
passthrough to a host-side pipelock.
2026-05-11 16:32:04 -04:00
didericis aba9a823ba docs(research): document macOS agent VM isolation approach
Transcript-style notes on running an agent in a hardware-isolated
microVM on macOS. Covers Virtualization.framework / vfkit / libkrun
choices, hardware-isolation guarantees, driving VMs from Python
(subprocess or PyObjC), pipelock as the egress proxy, vsock for the
control channel, and egress enforcement via
VZFileHandleNetworkDeviceAttachment + gvisor-tap-vsock.
2026-05-11 16:31:40 -04:00
didericis 8f5e07af7f test(pipelock): drive sidecar smoke through production prepare/start
test / unit (pull_request) Successful in 14s
test / integration (pull_request) Successful in 23s
The old smoke test hand-rolled the docker create/cp/start sequence in
parallel with what DockerPipelockProxy.start already does, so any
divergence in production code wouldn't trip it. Rewritten to call
.prepare and .start directly and probe /health from a sibling curl
container on the same internal network — same access topology the
agent container uses in production.

In-network probing means the test no longer depends on a published
port, so it can run under act_runner (where host-loopback port
publishing isn't reachable from the job container).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:23:43 -04:00
didericis beb0c9d58f feat(cli): add --format=json to start --dry-run for machine-readable plan
BottlePlan gains a to_dict method (abstract on the base, implemented
on DockerBottlePlan) returning a JSON-serializable view of the resolved
plan. `cli.py start --dry-run --format=json` prints it to stdout and
exits zero. --format=json without --dry-run is rejected — emitting JSON
during a real launch would race the y/N prompt.

The dry-run integration test now parses the JSON and asserts on
structured fields (agent, bottle, runtime, hosts sorted+deduped, etc.)
instead of regex-matching the human-readable preflight stdout. That
kills the magic-"8 hosts allowed" coupling — adding a new baked
default doesn't break the test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:23:24 -04:00
didericis 30b4f12288 refactor(pipelock): expose structured config; assert on dict in tests
Split pipelock config building from YAML rendering: pipelock_build_config
returns a dict, pipelock_render_yaml serializes it, and _build_pipelock_yaml
chains the two onto disk. Unchanged behavior — pipelock loads the same YAML.

The yaml test now asserts on the structured config dict, which is
robust to cosmetic YAML changes (key order, quoting). The two checks
that only make sense on the rendered output — file mode 0600 and
no-secret-leakage — stay against the on-disk content.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:23:12 -04:00
didericis 4462863d56 test: reorganize suite into unit/integration/canaries directories
Replace the hand-maintained INTEGRATION_NAMES classifier (and the
bespoke run_tests.py around it) with a directory-driven split:

  tests/unit/         unit tests, always run
  tests/integration/  Docker-dependent, skip cleanly without Docker
  tests/canaries/     upstream-regression checks, opt-in via
                      CLAUDE_BOTTLE_RUN_CANARIES=1

The pinned-pipelock-image check moves to the canary suite — it tests
upstream packaging, not our code, so it shouldn't gate every dev push.
A scheduled canaries.yml workflow runs it weekly.

The manifest-runtime tests collapse the four assertRaises cases for
distinct 'runtime' values into one subTest loop and drop the
error-message-wording assertions; the contract is "any value is
rejected", not "the error literally contains 'auto-detect'".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:23:02 -04:00
didericis 83fe5741f5 chore(test): open refactor-tests branch
test / run tests/run_tests.py (pull_request) Successful in 14s
Empty commit to seed the branch so a PR can be opened against main.
Actual test refactor work will follow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:01:54 -04:00
didericis 08159e1031 docs(research): survey AI-agent sandbox tools
test / run tests/run_tests.py (push) Successful in 19s
Compares claude-bottle to endo-familiar, litterbox, agent-safehouse,
matchlock, tilde.run, boxlite, microsandbox, and smolmachines. Covers
isolation primitive, locality, agent integration, network policy, and
maturity, and notes three borrowable ideas (per-use SSH confirmation,
in-flight secret injection, microVM backend) that fit the current
bash-first / local-Docker stance.
2026-05-11 15:56:23 -04:00
didericis 26bebc1eec Merge pull request 'PRD 0003: Bottle Backend abstraction' (#5) from add-bottle-factory-abstraction into main
test / run tests/run_tests.py (push) Successful in 17s
2026-05-11 14:49:42 -04:00
didericis f0b67a3e94 docs(prd): update PRD 0003 to reflect the shipped design
test / run tests/run_tests.py (pull_request) Successful in 14s
Renames the file and rewrites the body around what actually shipped:
class-based BottleBackend ABC (not a free create_docker_bottle
function), the two-phase prepare/launch split, the backend/docker/
subpackage layout, env.py reshaped into a backend-neutral ResolvedEnv,
and PipelockProxy split between top-level and backend/docker/.
2026-05-11 14:47:17 -04:00
didericis 656dc88d76 refactor(env): make env resolution backend-agnostic
test / run tests/run_tests.py (pull_request) Successful in 14s
resolve_env_into(...) becomes resolve_env(manifest, agent) -> ResolvedEnv
(forwarded names + literals). The docker backend now owns env-file /
argv serialization and the --env-file newline check. Also drops stray
Docker references from manifest.py, pipelock.py, util.py, and trims
the duplicated command list from cli.py's docstring (usage() in
claude_bottle/cli/__init__.py is now the only listing).
2026-05-11 14:39:44 -04:00
didericis 988c0bdad3 refactor(env): rename env_resolve.py -> env.py; env_resolve() -> resolve_env_into()
test / run tests/run_tests.py (pull_request) Successful in 13s
Module name aligns with the others (manifest, pipelock, network,
log) — nouns/noun-y, not verb phrases. The function name now reads
naturally at the call site: resolve_env_into(manifest, agent,
env_file, args_file).
2026-05-11 14:17:50 -04:00
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 c62b3204a8 refactor(util): move is_ipv4_literal out of pipelock.py into util.py
test / run tests/run_tests.py (pull_request) Successful in 25s
The classifier is a pure dotted-quad regex check — nothing
pipelock-specific about it. Pipelock now imports it from util.
test_pipelock_classify.py retargets at the new location.

Two manifest-accessor functions in pipelock.py
(pipelock_bottle_allowlist, pipelock_bottle_ssh_hostnames) look
generic but are 1-line wrappers used only internally; they stay
for now.
2026-05-11 13:37:31 -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 f344c8cd9d test(pipelock): cut low-value tests (naming + entrypoint/cmd inspection)
test / run tests/run_tests.py (pull_request) Successful in 14s
Drops 6 tests with no real coverage loss:

- tests/test_pipelock_naming.py — 4 tests asserting that f-string
  format helpers return their f-string. Shape locks, not behavior
  gates.
- tests/test_pipelock_image.py:test_entrypoint_contains_pipelock and
  :test_cmd_contains_run — Docker image metadata inspection. The
  remaining test_binary_runs already covers 'does the pinned image
  actually work,' which is the only scenario these were really
  guarding against.

31 tests -> 25.
2026-05-11 01:11:59 -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
didericis c79966731c refactor(docker): move network.py into platform/docker/
test / run tests/run_tests.py (pull_request) Successful in 14s
The Docker bridge / internal network primitives are Docker-specific;
they belong inside the Docker platform package alongside util.py and
the rest. Same logic the earlier top-level docker.py move followed.

Imports:
  - platform.py: `from ... import network as network_mod`
                 -> `from . import network as network_mod`
  - network.py: `from .log import ...` -> `from ...log import ...`
  - tests/test_orphan_cleanup.py: from claude_bottle.network
                 -> from claude_bottle.platform.docker.network
2026-05-10 23:40:58 -04:00
didericis 1d2c18eaae refactor(platform): rename claude_bottle/bottles -> claude_bottle/platform
test / run tests/run_tests.py (pull_request) Successful in 13s
'bottles' was the package name when it held a single Bottle Protocol;
since we added BottlePlatform / BottlePlan / BottleCleanupPlan and
made it the home of platform dispatch, 'platform' describes the
package better. The 'bottle' concept (and the manifest field) stays.

CLI imports update from ..bottles to ..platform; internal relative
imports inside the package survive the rename unchanged. Git
detected all 7 file renames.
2026-05-10 23:37:28 -04:00
didericis aaed390953 refactor(bottles): Bottle becomes an ABC; DockerBottle inherits
test / run tests/run_tests.py (pull_request) Successful in 14s
Bottle was the only Protocol in an otherwise-ABC family
(BottlePlan, BottleCleanupPlan, BottlePlatform are all ABCs).
Convert to an ABC with abstract exec_claude / cp_in / close,
matching the rest of the hierarchy.

Rename _DockerBottle -> DockerBottle: the underscore was a
default-Python-private instinct that doesn't match the sibling
plan classes (DockerBottlePlan, DockerBottleCleanupPlan), all of
which are equally "only constructed by the platform" and yet
public-by-name.

Re-export DockerBottle from claude_bottle.bottles.docker.
2026-05-10 23:32:33 -04:00
didericis d28f0e6d9b refactor(docker): split bottles/docker/__init__.py into sibling modules
test / run tests/run_tests.py (pull_request) Successful in 14s
The single docker/__init__.py grew to ~555 lines holding the platform,
its plan classes, the bottle handle, and the runsc probe. Split into:

  - util.py                : Docker subprocess primitives + runsc_available
  - bottle_plan.py         : DockerBottlePlan (+ its print method)
  - bottle_cleanup_plan.py : DockerBottleCleanupPlan
  - bottle.py              : _DockerBottle handle class
  - platform.py            : DockerBottlePlatform (the bulk)

docker/__init__.py becomes a thin re-export shim so existing imports
(claude_bottle.bottles.docker.DockerBottlePlatform, etc.) keep working.
2026-05-10 23:29:38 -04:00
didericis e20f8af05a refactor(bottles): make docker a package; absorb top-level docker.py
test / run tests/run_tests.py (pull_request) Successful in 14s
- claude_bottle/bottles/docker.py -> claude_bottle/bottles/docker/__init__.py
- claude_bottle/docker.py        -> claude_bottle/bottles/docker/util.py

The host-side Docker primitives (require_docker, slugify, image_exists,
container_exists, build_image, build_image_with_cwd) lived at the top
of the package as if cross-cutting, but they are Docker-specific.
Moving them into bottles/docker/util.py colocates them with the
platform that uses them and clears the top-level namespace.

Imports in bottles/docker/__init__.py shift to `from . import util as
docker_mod` so the existing call sites (`docker_mod.require_docker()`,
etc.) keep working unchanged.
2026-05-10 23:25:42 -04:00
didericis 47b882f634 refactor(bottles): move 'list active' onto DockerBottlePlatform
test / run tests/run_tests.py (pull_request) Successful in 14s
Add list_active() abstract method on BottlePlatform; DockerBottlePlatform
implements it with the existing docker ps logic. cli/list.py no longer
calls docker directly — it just dispatches the active branch to the
platform.
2026-05-10 23:19:22 -04:00