Commit Graph

81 Commits

Author SHA1 Message Date
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
didericis 18d29fc23f refactor(bottles): two-phase cleanup parallel to prepare/launch
test / run tests/run_tests.py (pull_request) Successful in 13s
cmd_cleanup used to only sweep running containers via `docker ps`,
missing stopped pipelock sidecars and orphaned networks entirely. On
my host the new version surfaced ~10 stranded networks left behind by
SIGKILLed sessions — the kind of thing the old command implied it was
handling.

New shape, symmetric with start:
- BottleCleanupPlan (abstract, in bottles/__init__.py) with `print` +
  `empty` abstract members.
- DockerBottleCleanupPlan (concrete, in bottles/docker.py) carrying
  the resolved tuples of containers and networks.
- BottlePlatform gains abstract prepare_cleanup() + cleanup(plan).
  DockerBottlePlatform implements both:
    - prepare_cleanup: docker ps -a + docker network ls, both
      filtered to ^claude-bottle-, sorted for stable output.
    - cleanup: docker rm -f containers first (they hold the network
      attachment), then docker network rm.
- cmd_cleanup is now ~25 lines: prepare → print → y/N → cleanup.
2026-05-10 23:14:54 -04:00
didericis 4a45c267f3 refactor(cli): remove redundant 'build' command
test / run tests/run_tests.py (pull_request) Successful in 13s
DockerBottlePlatform.launch already runs 'docker build' on every
start, and the layer cache makes no-change rebuilds nearly free.
Anything 'cli.py build' did, 'cli.py start' does for you.
2026-05-10 23:05:24 -04:00
didericis 5f82044403 refactor(bottles): move _run_agent_container and _provision_container onto the platform class
test / run tests/run_tests.py (pull_request) Successful in 14s
They were the only remaining module-level helpers in docker.py besides
runsc_available(); promote them to private methods on DockerBottlePlatform
so all the Docker orchestration logic lives in one place.
2026-05-10 23:02:21 -04:00
didericis 7ab35a5e2a refactor(bottles): absorb prepare/launch fns into DockerBottlePlatform
test / run tests/run_tests.py (pull_request) Successful in 14s
The module-level prepare_docker_bottle and launch_docker_bottle were
trivial delegations from the platform class. Move their bodies onto
DockerBottlePlatform.prepare and .launch directly. The launch method
keeps the @contextmanager decorator; isinstance narrows BottlePlan
to DockerBottlePlan at the entry point.

Internal helpers (_run_agent_container, _provision_container,
runsc_available) stay at module scope — they're stateless and don't
benefit from self.

No behavior change. Tests pass; dry-run output unchanged.
2026-05-10 23:00:07 -04:00
didericis e22a96e511 refactor(bottles): BottlePlatform becomes ABC; DockerBottlePlatform in docker.py
test / run tests/run_tests.py (pull_request) Successful in 18s
Mirror the BottlePlan -> DockerBottlePlan hierarchy at the platform
layer. BottlePlatform is now an abstract base with abstract `prepare`
and `launch` methods; DockerBottlePlatform lives alongside the rest of
the Docker code in bottles/docker.py and supplies the concrete impls.

The registry in bottles/__init__.py now holds an instance of each
concrete platform class. Future per-platform state (region, api
token, cleanup primitives) has a natural home on the subclass rather
than being stitched onto a dataclass struct.

No behavior change. Tests pass; dry-run output unchanged.
2026-05-10 22:56:47 -04:00
didericis 2827d9b899 refactor(bottles): introduce BottlePlan base + move print onto plan
test / run tests/run_tests.py (pull_request) Successful in 19s
- Add BottlePlan (frozen dataclass + ABC) with spec, stage_dir, and an
  abstract `print(*, remote_control)` method.
- DockerBottlePlan now inherits from BottlePlan; spec/stage_dir come
  from the base, Docker-specific fields stay on the subclass.
- Move BottleSpec from bottles/docker.py to bottles/__init__.py so the
  cross-platform types live together. docker.py pulls them via
  `from . import ...`.
- Move show_plan from cli/start.py to `DockerBottlePlan.print`. Caller
  becomes `plan.print(remote_control=...)`. The CLI no longer reads
  any Docker-specific fields.
- BottlePlatform.prepare is now typed `Callable[..., BottlePlan]`.

cmd_start drops ~46 more lines.
2026-05-10 22:49:57 -04:00
didericis 236c4fa50c refactor(bottles): rename DockerBottleSpec to BottleSpec
test / run tests/run_tests.py (pull_request) Successful in 13s
The spec is intent-only and platform-agnostic — only the plan carries
Docker-specific fields. Drop the 'Docker' prefix and re-export from
claude_bottle.bottles so callers see it as cross-platform.
2026-05-10 22:40:19 -04:00
didericis 4f16b3a9e1 refactor(bottles): split factory into prepare + launch phases
test / run tests/run_tests.py (pull_request) Successful in 15s
The Docker factory had absorbed live container ops but left the
host-side prep (image-name resolution, container-name collision
retry, pipelock yaml generation, env_resolve writes, host
validation) in cli/start.py. That kept ~half the Docker-specific
logic outside the abstraction.

Split the factory into two phases:

  prepare_docker_bottle(spec, stage_dir=...) -> DockerBottlePlan
      Resolves names, validates skills/SSH, writes scratch files.
      No Docker resources created yet.

  launch_docker_bottle(plan) -> ContextManager[Bottle]
      Builds image, creates networks, boots pipelock, runs the
      agent container, provisions files. Teardown on exit.

DockerBottleSpec shrinks to intent-only inputs (manifest, agent
name, --cwd flag, user_cwd, forward_oauth_token). The CLI no longer
references docker_mod, pipelock, skills, ssh, or env_resolve.

get_bottle_factory becomes get_bottle_platform returning a
BottlePlatform with .prepare and .launch — one selectable thing per
platform.

The Bottle handle now remembers the in-container prompt path and
adds --append-system-prompt-file to claude's argv when present, so
the CLI no longer needs to know the path.

cmd_start: ~148 lines down from 229. Tests pass; dry-run output
byte-identical.
2026-05-10 22:36:26 -04:00
didericis a284d85296 refactor(start): show_plan now takes DockerBottleSpec
test / run tests/run_tests.py (pull_request) Successful in 15s
2026-05-10 22:23:40 -04:00
didericis 7500ba230c refactor(start): extract show_plan from cmd_start
test / run tests/run_tests.py (pull_request) Successful in 15s
2026-05-10 22:20:33 -04:00
didericis d75cc9325f feat(bottles): implement bottle factory abstraction per PRD 0003
test / run tests/run_tests.py (pull_request) Successful in 16s
Introduce claude_bottle/bottles/ with a Bottle Protocol and a
get_bottle_factory() that dispatches on CLAUDE_BOTTLE_PLATFORM
(default "docker"). Move every Docker-specific subprocess.run call
from cli/start.py, plus the orchestration of build, networks, the
pipelock sidecar, container launch, and per-container provisioning
(prompt, skills, ssh, .git), into create_docker_bottle.

Drop bottles[].runtime from the manifest schema. Auto-detect whether
gVisor is registered with the daemon and pass --runtime=runsc when it
is; the preflight shows the resolved runtime so the choice is visible.
Manifests still carrying 'runtime' get a clear error pointing at the
auto-detect behavior, rather than silent ignore.

Out of scope: cli/cleanup.py and cli/list.py still call docker
directly. They enumerate active bottles across the host, which is a
separate concern from "create a bottle" and is left for a follow-up
that introduces a list_active/cleanup primitive on the factory.
2026-05-10 22:15:05 -04:00
didericis d5c056f36e docs(prd): add 0003 bottle factory abstraction
test / run tests/run_tests.py (pull_request) Successful in 17s
2026-05-10 21:56:10 -04:00
didericis a39c7b1b7b Merge pull request 'refactor(manifest): convert to frozen dataclasses' (#4) from convert-manifest-to-dataclass into main
test / run tests/run_tests.py (push) Successful in 16s
2026-05-10 21:42:34 -04:00
didericis 9343f6f21d refactor(manifest): drop _json_type, use type(x).__name__ in error messages
test / run tests/run_tests.py (pull_request) Successful in 14s
The jq-style mapping (bool→"boolean", list→"array", None→"null", etc.)
existed only to match the original bash error wording. Not worth the
extra function; Python's native type names are clear enough.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 21:36:38 -04:00
didericis e9a3de49af fix(types): make manifest.py clean under pyright strict
test / run tests/run_tests.py (pull_request) Successful in 14s
- log.die() typed NoReturn so pyright knows it terminates control flow
  (was returning the unreachable Die instance type).
- manifest.py: raw inputs typed object (not Any) and narrowed via a new
  _as_json_object helper that validates str keys and returns
  dict[str, object]. Eliminates the Unknown cascade through .get()
  calls under strict.
- _from_dict classmethods renamed to from_dict so cross-class
  construction (Bottle.from_dict from Manifest.from_json_obj, etc.)
  doesn't trip reportPrivateUsage.
- _SUPPORTED_RUNTIMES typed tuple[Runtime, ...] so the membership
  check narrows runtime_raw to Literal["runc", "runsc"] and the
  # type: ignore[assignment] is no longer needed.
- Bottle.env uses a typed _empty_str_dict factory; bare dict resolves
  to dict[Unknown, Unknown] under strict.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 21:34:03 -04:00
didericis 1f36d53f7b refactor(manifest): convert TypedDict to frozen dataclasses
test / run tests/run_tests.py (pull_request) Successful in 14s
Replace the TypedDict + 14 manifest_* free functions with frozen
dataclasses (SshEntry, BottleEgress, Bottle, Agent, Manifest) carrying
their own validators and constructors. Call sites import Manifest and
chain attribute access; the manifest_* helpers and manifest_validate
are gone.

Behavior changes worth flagging:
- Agent.bottle is now required (was optional with a "(none)" fallback).
  Manifest.from_json_obj dies if any agent lacks a 'bottle' field or
  references an undefined bottle, where previously start.py raised the
  error lazily for the specific agent being launched.
- ssh.py now takes SshEntry instances; Host/IdentityFile shape checks
  moved upstream into Manifest construction, leaving only the IdentityFile
  filesystem-existence check in ssh_validate_entries.
- pipelock_bottle_allowlist's per-element string check is dropped — the
  Manifest validator enforces it at load.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 21:20:15 -04:00
didericis 36cb0c53bf refactor(manifest): add TypedDict schema and eager validation
test / run tests/run_tests.py (pull_request) Successful in 20s
Move schema checks out of per-access getters into a single
manifest_validate pass invoked by manifest_resolve. Getters can now
assume bottles/agents are well-typed dicts and every agent has a
defined bottle, so the .get(...) or {} chains collapse. Behavior
change: a bad runtime / shape error anywhere in the manifest now
fails at load instead of on the N-th read.

Intermediate step toward replacing TypedDict with a dataclass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 21:08:54 -04:00
didericis 7e0e256370 docs: add research note on polish priorities to close the maturity gap
test / run tests/run_tests.py (push) Successful in 21s
Captures the ranked list of changes that would move the project from
"works for me" toward the perceived maturity of comparable tools —
onboarding friction, error messages, distribution, versioning, schema
validation, starter library, docs site, cross-platform CI. Includes
effort estimates and an explicit "what polish is not" section so the
roadmap doesn't drift into feature work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 20:38:44 -04:00
didericis 79604fded7 docs: reposition README around scoped-agent wedge and note DoH
test / run tests/run_tests.py (push) Successful in 21s
Lead with what the project does for the user — scoped Claude Code
agents on self-hosted infrastructure with per-agent secret and egress
limits — instead of the 2024-coded "isolated container" framing.
Tagline, "Why claude-bottle?" intro, and goals list now name secret
minimization, egress allowlisting, and self-hosted operation as the
load-bearing properties.

Also adds a sentence to the security model noting that DoH is
blocked structurally by the existing egress allowlist, since the
bottle has no L3 path off-box except through pipelock's hostname-
allowlisted CONNECT proxy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 03:00:19 -04:00
didericis fe232744a6 docs: reframe security model around secret exposure and exfiltration
test / run tests/run_tests.py (push) Successful in 20s
Soften the "container is the boundary against reaching the host"
framing in favor of what the design actually leans on: granting each
bottle only the secrets it needs, and constraining where those
secrets can travel via pipelock. The container is one layer rather
than the load-bearing one.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 02:42:12 -04:00
didericis e1efc64862 docs: add research note on Apple container as an alternative backend
test / run tests/run_tests.py (push) Successful in 14s
Captures the surface area of the current Docker integration, how it
maps to Apple's `container` framework, the dominant networking risk
(pipelock multi-network attach), and the cost difference between a
faithful port and a simplified VM-firewall variant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 02:36:11 -04:00
didericis 1e6f254db5 docs: add research note comparing bash, Python, and Go for the CLI
test / run tests/run_tests.py (push) Successful in 14s
Captures the reasoning for staying on Python, the conditions under which
a Go rewrite would pay for itself, and why bash isn't viable at the
project's current size.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 02:34:40 -04:00
didericis 65d2ab9d5f docs: fill in copyright in LICENSE appendix
test / run tests/run_tests.py (push) Successful in 14s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 02:03:50 -04:00
didericis acbaffb98e docs: add Apache 2.0 LICENSE and link it from the README
test / run tests/run_tests.py (push) Successful in 15s
Drops the canonical Apache 2.0 text at repo root and adds a License
section to the README with the copyright line. Apache 2.0 picked over
MIT for the patent grant and contributor language, both of which
matter for a security-adjacent tool aiming at corporate-evaluable
adoption. Also tidies the manifest example's GIT_AUTHOR_NAME to use
the project's online handle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 02:02:42 -04:00