Commit Graph

8 Commits

Author SHA1 Message Date
didericis cefdc8c6e9 feat(launch): switch start to docker compose project per bottle
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m5s
PRD 0018 chunk 3. Each instance is now one `docker compose` project:

  - launch.py renders the compose spec via chunk-1's
    bottle_plan_to_compose, writes it to state/<slug>/docker-compose.yml,
    `docker compose up -d`s, and (on teardown) dumps
    `docker compose logs --no-color --timestamps` to
    state/<slug>/compose.log before `docker compose down`.
  - Networks are pre-created (`docker network create --internal` +
    user-defined bridge) so pipelock yaml can know the internal CIDR
    before compose-up. Compose references them with `external: true`;
    the launch step's ExitStack still owns network removal.
  - Agent still runs `sleep infinity`; claude reaches it via
    `docker exec -it` exactly like before (per the PRD's resolved
    TTY question).
  - metadata.json grows a `compose_project` field so dashboard /
    cleanup tooling can derive compose invocations without
    re-deriving the slug.

Security follow-ups from chunk-2 review:

  (b) CA private keys: pipelock + egress ca-key.pem land at 0o600
      explicitly. The mitmproxy cert+key concat stays 0o644 because
      the egress container's uid-1000 user reads it through the
      bind mount; parent dir at 0o700 still restricts host-side
      reach.
  (c) Apply atomicity: egress_apply + pipelock_apply switch from
      `docker cp` to host-side write-temp-then-rename on the
      bind-mount source. POSIX rename is atomic on the same
      filesystem, so a sidecar SIGHUP racing the apply can't see
      a half-written routes.yaml / pipelock.yaml.

Per-sidecar Docker{Sidecar}.start/stop methods stay in place — the
integration test suite drives them directly to validate each image
in isolation, which is still useful. launch.py no longer calls
them; a follow-up chunk can prune if the integration tests move to
the compose lifecycle.

git-gate entrypoint's chmod 600 on the keyfile + known_hosts now
tolerates EROFS (`|| true`) — the host SSH key is already 0600
(SSH refuses to load otherwise), so the inside-container chmod
was already a no-op in the docker-cp path and now just needs to
not error on the read-only bind mount.

422 unit tests pass; supervise integration test passes; end-to-end
`./cli.py start implementer` brings up the project, attaches,
captures full merged logs on teardown, and reaps all containers +
networks.
2026-05-25 23:16:40 -04:00
didericis cd82a48399 refactor(state): write prepare-time scratch files under state/<slug>/
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m5s
PRD 0018 chunk 2. Each sidecar's prepare-time output (pipelock yaml +
CAs, egress routes.yaml + CAs, git-gate entrypoint + hooks, supervise
current-config, agent env + prompt) now lands in
~/.claude-bottle/state/<slug>/<service>/ instead of an ephemeral
mktemp dir. The state subdirs become the stable bind-mount sources
that chunk 3's docker compose project will reference.

The SDK launch path is unchanged — `docker cp` still copies from the
plan-held paths into containers, just from new locations. start.py's
session-end cleanup is now in `finally`, which also reaps state dirs
left behind by dry-run / preflight-N / prepare-exception paths
(previously only the post-launch path settled state).
2026-05-25 22:53:47 -04:00
didericis 1e5b0dcfca refactor: rename egress-proxy → egress everywhere
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m10s
The manifest key is `egress:` now; finish the rename so the rest of
the codebase matches. Files (Dockerfile.egress, claude_bottle/egress.py
etc.), classes (Egress, EgressConfig, EgressRoute, EgressPlan,
DockerEgress), constants (EGRESS_HOSTNAME, EGRESS_ROUTES, ...),
container name prefix (claude-bottle-egress-*), docker network alias
(egress), the introspection host (_egress.local), the MCP tool IDs
(egress-block, list-egress-routes), and the preflight label all drop
the `-proxy` suffix.
2026-05-25 21:59:47 -04:00
didericis 3be70eb07a feat(supervise): list-egress-proxy-routes MCP tool, defaults on egress-proxy
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m7s
Reshape the allowlist topology so the egress-proxy is the bottle's
single allowlist surface, and replace the agent-side
routes/allowlist file mounts with a live MCP tool.

Policy change (move defaults to egress-proxy):

  - `egress_proxy_routes_for_bottle(bottle)` now folds in
    DEFAULT_ALLOWLIST (the claude-code defaults) and
    `bottle.egress.allowlist` (user adds) as bare-pass routes (no
    auth, no path filter), on top of the bottle's
    `egress_proxy.routes`. Manifest routes win on host collision.
  - `pipelock_effective_allowlist(bottle)` mirrors egress-proxy's
    effective host set when egress-proxy is in use. Pipelock is
    no longer the bottle's primary allowlist authority; it
    enforces a downstream copy as defense-in-depth + does DLP body
    scanning.
  - Split out `egress_proxy_manifest_routes(bottle)` for callers
    that want just the manifest entries (tests, internal use).
  - DEFAULT_ALLOWLIST moves from `pipelock.py` to `egress_proxy.py`
    (pipelock re-imports for the no-egress-proxy fallback path).
  - Dropped the `egress-proxy` auto-allow on pipelock's allowlist
    — the agent never dials egress-proxy via the proxy mechanism;
    pipelock only sees upstream hostnames from egress-proxy's
    CONNECTs.

Introspection endpoint (existing mitmproxy feature):

  - Egress-proxy addon recognises requests to the magic host
    `_egress-proxy.local` and synthesizes responses via
    `flow.response = http.Response.make(...)` — no upstream
    connection, no allowlist enforcement on the magic host.
  - `GET /allowlist` returns the in-memory route table as JSON
    (host + path_allowlist + auth_scheme + token_env per route;
    no token VALUES).
  - Smoke-tested end-to-end against a real egress-proxy container.

MCP tool (existing supervise plumbing):

  - New `list-egress-proxy-routes` tool (no inputs, no operator
    approval). Handler fetches via egress-proxy's introspection
    endpoint using urllib's ProxyHandler against
    `EGRESS_PROXY_FORWARD_PROXY`. Returns the JSON payload as the
    tool's text content; `isError: true` if the proxy is
    unreachable.
  - `egress-proxy-block` description now points the agent at
    `list-egress-proxy-routes` instead of a staged file path.
  - `pipelock-block` description acknowledges the mirror — agents
    should prefer `egress-proxy-block` to add hosts; pipelock-block
    stays for the rare divergence case.

Drop agent-side file mounts:

  - Supervise's `current-config` dir staging no longer writes
    routes.yaml / allowlist. Only `Dockerfile` remains
    (capability-block still reads it from
    `/etc/claude-bottle/current-config/Dockerfile`).
  - `prepare.py` stops passing `routes_content` /
    `allowlist_content` to `supervise.prepare`.
  - `Supervise.prepare` signature simplified to one
    `dockerfile_content` kwarg.

Tests: 400 unit + integration pass. Added coverage for
defaults-folding (`TestRoutesForBottleFoldsDefaults`), the new
tool definition + handler, and the updated supervise.prepare
shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:23:01 -04:00
didericis 9dbd20398e feat(state): clean up per-bottle state on session end (except capability-block)
test / unit (pull_request) Successful in 19s
test / integration (pull_request) Successful in 1m35s
Previously every bottle launch left ~/.claude-bottle/state/<identity>/
behind forever — metadata.json on every run, plus per-bottle
Dockerfile + transcript snapshot on capability-block rebuilds. The
metadata accumulated debris across launches; the only state worth
keeping was the capability-block rebuild bundle.

Make cleanup the default; preserve only on capability-block.

- bottle_state.py: .preserve marker helpers (mark_preserved,
  is_preserved, clear_preserve_marker, preserve_marker_path) +
  cleanup_state(identity) that rm -rf's the per-bottle dir.
- capability_apply.apply_capability_change writes mark_preserved
  before teardown so cli.py's session-end cleanup keeps the dir.
- prepare.py clears any leftover marker at launch (start or resume),
  so a marker from a prior capability-block doesn't keep state
  alive past a subsequent normal session-end.
- cli/start.py runs the cleanup decision AFTER the launch context
  closes: if is_preserved → print resume hint; else cleanup_state.
  The resume hint moves out of the launch with-block (was previously
  printed unconditionally — would have misled the operator about
  whether state was actually kept).

Future-proof: cli.py never persists state speculatively. If the
agent wants to be resumable, it has to go through capability-block.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 06:51:13 -04:00
didericis 4032e04a9c feat(bottle): random-suffix identity + cli.py resume <identity>
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m30s
Replaces the cwd-hash identity with a random 5-char base36 suffix
per launch, so two simultaneous `start <agent>` invocations against
the same cwd no longer collide on container names. Each launch is
its own bottle.

State carries metadata: every prepare step writes
~/.claude-bottle/state/<identity>/metadata.json with the
(agent_name, cwd, copy_cwd, started_at) the bottle was launched
with. The new `cli.py resume <identity>` reads this metadata and
re-launches a bottle pinned to the same identity — picking up the
per-bottle Dockerfile (from a prior capability-block apply) and
the transcript snapshot under the same state dir.

- bottle_state.py: bottle_identity(agent_name) drops the cwd param
  and gains a random suffix; BottleMetadata dataclass +
  read/write/metadata_path helpers.
- BottleSpec gains an optional identity field — resume sets it to
  pin the identity; start leaves it empty so prepare mints fresh.
- prepare.py: writes metadata at launch time; uses spec.identity if
  provided (resume) else bottle_identity(agent_name) (fresh start).
- start.py: extracted _launch_bottle from cmd_start so resume can
  share the launch core; prints `./cli.py resume <identity>` hint
  at session end.
- cli/resume.py (new): reads metadata, reconstructs BottleSpec
  with the recorded identity + cwd, delegates to _launch_bottle.
  Errors clearly when no state exists for the given identity.
- cli/__init__.py: registers `resume` in COMMANDS + usage.
- dashboard.py: capability-block approval status line now appends
  the `resume <identity>` hint so the operator can copy-paste the
  rebuild command without leaving the TUI.

Closes the rebuild loop in PRD 0016: agent calls capability-block →
operator approves → bottle torn down with state preserved → status
line shows resume command → operator runs it → replacement bottle
boots with the new Dockerfile and prior transcript.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 06:09:45 -04:00
didericis e996f72532 fix(bottle): identity-key all per-bottle resources by (agent, cwd)
test / unit (pull_request) Successful in 16s
test / integration (pull_request) Successful in 1m30s
The single point that computed `slug = slugify(agent_name)` in
prepare.py is now `slug = bottle_identity(agent_name, cwd)`. With
--cwd the identity has a sha256(resolved-cwd)[:12] suffix, so the
same agent against different projects gets distinct container
names, network names, queue dir, audit log paths, and per-bottle
state (Dockerfile + transcript). Without --cwd the identity is
just slugify(agent_name), unchanged from before — no-cwd bottles
look the same as today.

The downstream `slug` field on DockerBottlePlan keeps its name —
every module already threads it under "slug" and the value flowing
through is now the bottle's full identity. A comment in prepare.py
flags the change.

Fixes the bug surfaced in PR #22 review: running the same agent
against project-A's cwd then project-B's would silently share
project-A's per-bottle Dockerfile + transcript snapshot, container
name (forcing serialized runs), and queue/audit history.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 05:46:26 -04:00
didericis 02811e0417 feat(bottle): per-bottle Dockerfile state + image build hook (PRD 0016)
Phase 1 of PRD 0016. Lays the per-bottle state plumbing that
capability-block remediation will write into:

- claude_bottle/backend/docker/bottle_state.py: bottle_state_dir,
  per_bottle_dockerfile (read), write_per_bottle_dockerfile,
  per_bottle_image_tag (unique per slug), transcript_snapshot_dir.
  Stores under ~/.claude-bottle/state/<slug>/.
- prepare.py: when a per-bottle Dockerfile exists, use
  per_bottle_image_tag(slug) as the base image and pass the
  per-bottle Dockerfile path through DockerBottlePlan.dockerfile_path.
  --cwd still layers a derived image on top.
- launch.py: passes plan.dockerfile_path to build_image so the
  per-bottle Dockerfile is what docker build reads.
- DockerBottlePlan gains dockerfile_path field; print() surfaces it
  in the preflight summary so the operator can see at-a-glance that
  this bottle is running on a rebuilt image.

Phase 2 will write to write_per_bottle_dockerfile (capability-block
approval); Phase 3 wires it into the dashboard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 05:23:31 -04:00