Partial revert of fa06a3a. The role + agent-side provisioner felt
overengineered: anthropic-base-url + npm-registry's only realistic
host values match the tool defaults, so the role tags drove no-op
dotfile writes most of the time. If non-default npm registry / tea
config is needed in a future bottle, we can ship it through a more
direct mechanism then.
What stays from fa06a3a:
- Universal HTTPS git-push block in the egress-proxy addon
(`is_git_push_request` in egress_proxy_addon_core, called from
the request hook before route matching; 403s git-receive-pack
regardless of route). This is the security backstop so git-gate
remains the only outbound write path; PR #29 keeps it.
What gets reverted:
- `Role` field on EgressProxyRoute (manifest + runtime).
- `EGRESS_PROXY_ROLES` + `EGRESS_PROXY_SINGLETON_ROLES` constants
and singleton-role validation.
- `backend/docker/provision/egress_proxy.py` (npmrc + tea config).
- `provision_egress_proxy` slot in `BottleBackend.provision`.
- `prepare.py`'s role-based ANTHROPIC_BASE_URL detection (back to
the token_ref="CLAUDE_CODE_OAUTH_TOKEN" auto-detect).
- Manifest + provisioner tests for the above.
355 unit + 24 integration tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related fixes on top of PR #29's chunk-2 cutover:
1. Universal HTTPS git-push block in the egress-proxy addon
(`is_git_push_request` in egress_proxy_addon_core, called from the
mitmproxy request hook before route matching). 403s any
`/git-receive-pack` or `info/refs?service=git-receive-pack` —
defense in depth so git-gate (PRD 0008) remains the only outbound
path for writes, gitleaks-scanned by its pre-receive. Replicates
cred-proxy's `is_git_push_request` behavior.
2. Restored agent-side role provisioner. Brings back `Role` on
EgressProxyRoute (manifest + runtime) with three roles —
`anthropic-base-url`, `npm-registry`, `tea-login`. Singleton
constraint on the first two carries over from cred-proxy.
`git-insteadof` is intentionally absent (option 1 above handles
the push-bypass concern, and the canonical-URL rewrite has no
function when egress-proxy is on HTTPS_PROXY).
The provisioner (`backend/docker/provision/egress_proxy.py`):
- `~/.npmrc` registry= the canonical upstream URL.
- `~/.config/tea/config.yml` logins[] entry per tea-login route.
- `ANTHROPIC_BASE_URL` env set in prepare.py based on the
anthropic-base-url role (was a token_ref="CLAUDE_CODE_OAUTH_TOKEN"
check in this PR's earlier draft — the role marker is cleaner
and matches the cred-proxy precedent the user wants kept).
All three dotfile values point at canonical upstream URLs; the
agent's HTTPS_PROXY=egress-proxy routes them through the proxy
automatically.
Tests: 11 new role-validation tests, 11 new provisioner-render tests,
the chunk-1 manifest fixture exercise role=anthropic-base-url. 400
tests pass (was 376).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hard cutover. cred-proxy is deleted; egress-proxy is now the agent's
HTTP_PROXY (when routes are declared) with pipelock on its outbound
leg. Two per-bottle CAs are minted: egress-proxy's (agent trust
store) and pipelock's (egress-proxy's outbound trust store).
Manifest:
- `bottle.cred_proxy` → hard error with a migration recipe.
- `bottle.egress_proxy` is the new shape (PRD 0017 chunk 1).
- CredProxy* types + role validators removed.
Wiring:
- launch.py: `egress_proxy_tls_init` mints the egress-proxy CA
(cert+key concat for mitmproxy + cert-only for agent trust);
`DockerEgressProxy.start` docker-cps both CAs in, sets
`HTTPS_PROXY=pipelock` + `EGRESS_PROXY_UPSTREAM_CA` so mitmdump
trusts pipelock's MITM. Agent's HTTP_PROXY points at
egress-proxy when routes exist, else falls back to pipelock
(no-routes bottles unchanged).
- prepare.py / backend.py: `cred_proxy` arg → `egress_proxy`;
sidecar-orphan probe + plan field + dashboard view all
renamed.
- provision_ca: selects the egress-proxy CA when present, else
pipelock's (filename renamed to claude-bottle-mitm-ca.crt).
- bottle.provision: cred-proxy dotfile rewrites (~/.npmrc,
~/.gitconfig insteadOf, tea config) are gone — HTTP_PROXY
catches everything respecting it.
Pipelock helpers:
- `pipelock_token_hosts` → `pipelock_route_hosts` (now reading
egress_proxy.routes).
- cred-proxy hostname auto-allow → egress-proxy hostname
auto-allow.
- Anthropic seed-phrase workaround now triggers when an
egress_proxy route targets api.anthropic.com (was based on the
cred-proxy `anthropic-base-url` role).
Dockerfile.egress-proxy:
- Entrypoint conditionally passes
`--set ssl_verify_upstream_trusted_ca=$EGRESS_PROXY_UPSTREAM_CA`
(via the `${VAR:+...}` shell expansion) so standalone runs without
a mounted pipelock CA still boot.
- mkdirs `/home/mitmproxy/.mitmproxy` ahead of `docker cp`.
Deleted: claude_bottle/{cred_proxy,cred_proxy_server}.py,
backend/docker/{cred_proxy,provision/cred_proxy}.py,
Dockerfile.cred-proxy, plus the corresponding unit + integration
tests. backend/docker/cred_proxy_apply.py stays as a stub for
chunk 3 to rewrite (its container-name + routes-path constants
are inlined so it survives without the deleted module).
Test changes:
- test_pipelock_allowlist rewritten against egress-proxy routes
+ the new `pipelock_route_hosts`.
- test_manifest_md_load + test_pipelock_yaml + test_yaml_subset
fixtures migrated to the `egress_proxy: { routes: [...] }`
shape.
- test_supervise_sidecar's round-trip test switched from
`dashboard.approve` to `dashboard.reject`: the approval-apply
path on cred-proxy-block proposals hits a deleted sidecar in
chunk 2's transitional state. Chunk 3 restores the approval
test once the remediation flow is retargeted at egress-proxy.
376 tests pass (was 427; net delta is removed cred-proxy tests).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lands the new egress-proxy artifact alongside cred-proxy. Chunk 2
wires the agent's HTTP_PROXY to it and removes cred-proxy.
- `Dockerfile.egress-proxy` — mitmproxy 11.1.3 base, COPY addon
files flat to /app, mkdir routes dir at /etc/egress-proxy/.
Digest pin deferred to chunk 2.
- `egress_proxy_addon_core.py` — pure-logic parse + decide
(host-importable; 21 unit tests).
- `egress_proxy_addon.py` — mitmproxy hook wrapper, container-only
(boot + SIGHUP reload, strip-Authorization + decide + 403/inject).
- `egress_proxy.py` — host helpers: manifest lift, routes.yaml
render (JSON content), token-env-map, Plan + abstract class.
- `backend/docker/egress_proxy.py` — `DockerEgressProxy` start/stop
mirroring `DockerCredProxy`; not yet called from launch.py.
- `manifest.py` — new `EgressProxyRoute` + `EgressProxyConfig` types
with the nested `auth: { scheme, token_ref }` block per PRD;
`bottle.egress_proxy` added to the bottle key set alongside
`cred_proxy` (chunk 2 hard-fails on the latter).
All 427 unit tests pass. Image builds; `docker run` boots mitmdump
and the addon loads routes from a mounted routes.yaml.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Earlier draft had `auth_scheme: "none"` as the unauthenticated
signal — awkward sentinel. Nest the two credential-injection
fields under an optional `auth` key instead. Presence of the key
= authenticated; absence = unauthenticated. Empty `auth: {}` is
an error (omission is what means "no auth").
Touches: scope bullet, manifest example, mitmproxy addon
description's auth-handling step. Two trailing `auth_scheme:
"none"` references kept as historical context for what the new
shape replaces.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Significant rewrite of PRD 0017 based on PR #25 design discussion.
Original draft proposed adding `path_allowlist` to the existing
cred-proxy. That bought opt-in path filtering for tools that
voluntarily routed through cred-proxy (Claude Code, git, npm) —
but raw `curl https://github.com/foo` from the agent goes to
HTTPS_PROXY=pipelock and bypasses cred-proxy entirely, so any
universal enforcement claim was a lie.
New design: replace cred-proxy with a mitmproxy-based egress-proxy
that becomes the agent's HTTP_PROXY/HTTPS_PROXY. Every agent
HTTP/HTTPS request flows through it before reaching pipelock.
Path-level allow/deny enforcement is universal because the proxy
is on every leg. The proxy also absorbs cred-proxy's credential
injection role (mitmproxy addon hooks request → strip + inject
Authorization).
Net sidecar count: unchanged. cred-proxy is replaced 1:1 by
egress-proxy. Pipelock stays as hostname allow + DLP downstream
of egress-proxy.
Decisions baked in per PR-#25 discussion:
- Tool: mitmproxy (designed for this; Python addons; well-maintained).
- CA custody: egress-proxy holds the per-bottle MITM CA key
(concentration accepted; documented in trust-domain section).
- Migration: hard cutover. Existing `bottle.cred_proxy.routes[]`
manifests fail-fast at load time with a pointer at this PRD.
Open questions retained for the implementation PRs: addon
distribution (bake vs mount), prefix-vs-glob match, double-strip
of Authorization between egress-proxy and pipelock, whether
pipelock keeps TLS interception or stays hostname-only post-cutover,
performance under two-MITM-hops.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extends cred-proxy to filter (not just route) paths, including for
unauthenticated upstreams via a new `auth_scheme: "none"` mode and
`path_allowlist` field per route. Pipelock keeps its hostname
allowlist + DLP role; cred-proxy adds path-level enforcement for
routes that opt in.
Motivated by PR #25's follow-up note in _apply_pipelock_url: pipelock
2.3.0's api_allowlist is hostname-only, so approving pipelock-block
opens the entire host. For shared platforms (github.com, gitlab.com,
public registries) operators usually want narrower-than-host
granularity.
Draft status; open questions on match semantics, allow-route-with-
empty-allowlist edge case, and the eventual MCP tool shape for
agent-proposed path additions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The "→ would allow host: api.github.com" framing added narration
where none was needed. Just render the host on its own line in
green — that's literally the text that gets appended to pipelock's
allowlist on approve, and the green color carries "what's about to
change". The URL (with path) is still right above for context.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the operator opens a pipelock-block proposal in the detail
view (Enter / 'v'), append a green-coloured line:
→ would allow host: api.github.com
so what's actually about to change is obvious at a glance. The
full failed URL stays above the new line (the path is operator
context — pipelock can't enforce it, just records intent).
- _detail_lines now returns (text, attr) tuples; pipelock-block
appends the host-extract line tagged with the green color pair.
- _detail_view threaded the green_attr through from the main loop
(matches the new-proposal highlight pattern from earlier in this
PR).
- Best-effort URL parsing; unparseable payloads skip the highlight
line rather than render a misleading blank host.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PR #25's pipelock-block tool sends a full URL and the supervisor
extracts just the hostname for pipelock's allowlist — pipelock
2.3.0's api_allowlist is hostname-only (verified by inspecting the
binary's strict preset). The path component is operator context,
not enforced.
Document the follow-up shape inline at the apply site so a future
reader looking at why we're throwing away the path lands on the
plan: adding `auth_scheme: none` + `path_allowlist` to cred-proxy,
and rewiring pipelock-block to propose cred-proxy routes instead
of pipelock hostnames. Multi-touch change, its own PRD.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reshape the pipelock-block MCP tool around what the agent actually
knows at the moment of failure (the URL pipelock just refused), not
what the operator needs (a full allowlist file).
Before: agent had to read /etc/claude-bottle/current-config/allowlist,
copy the whole file, append their host, send back. Lots of work,
easy to get wrong, and the operator's diff was noisy because the
proposal contained every host the agent saw — most of which weren't
the change.
After: agent calls
pipelock-block(failed_url="https://api.github.com/repos/foo/bar",
justification="...")
supervisor extracts api.github.com, fetches the running allowlist,
adds the host if not already present, applies the merged content.
Path is captured as operator context (the detail view labels it
"failed URL" instead of "proposed file") but isn't enforced —
pipelock's api_allowlist is hostname-only, so the path can't
become an allow rule.
- supervise_server: pipelock-block input schema gains `failed_url`
(replaces `allowlist`); validate_proposed_file checks for
http/https + hostname.
- PROPOSED_FILE_FIELD updated; tool description rewritten.
- dashboard._apply_pipelock_url: extract host, fetch current,
merge, apply.
- _proposed_payload_label: detail view renders "failed URL" for
pipelock-block, "proposed file" otherwise.
- Tests updated end-to-end; new url-host-merge + idempotent-merge +
invalid-url cases added.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When a new proposal lands in the dashboard's list, the operator
shouldn't have to compare the list to a mental snapshot to spot
what's new. Render newly-arrived proposals in green for the first
five seconds after they show up.
- _try_init_green: initialise a green color pair; returns 0 if the
terminal lacks color so the highlight degrades to no-op.
- _main_loop tracks first_seen[proposal_id] across refresh ticks,
pruning entries when a proposal leaves the queue.
- _render ORs green into the existing attr (composes with selection
reverse-video — terminal handles the mix).
Applies to all tool types (cred-proxy-block, pipelock-block,
capability-block). If a tool-specific highlight is wanted later,
filter on qp.proposal.tool in _is_recent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The main loop blocked on stdscr.getch() until the operator hit a
key — a tool call landing in the queue while the operator was just
watching wouldn't appear on the screen. The operator had to press
any key to trigger a re-render and see the new proposal.
Switch to stdscr.timeout(1000): getch returns -1 after 1s if no
key was pressed, and the loop re-renders with the latest
discover_pending() result. CPU cost is trivial; the loop body is
~one filesystem scan + curses draw per second.
Also restructure status_line lifecycle: was cleared right after
every render, which meant a timeout-driven re-render would wipe
the message ~1s after the operator's keystroke set it. Now
status_line is cleared only on actual key press, so messages
like "approved cred-proxy-block for [dev-xyz]" persist until the
operator does something else.
Detail view + prompt view are unchanged — they're modal, the
underlying proposal data doesn't move, and getstr can't tolerate
a re-render mid-input.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`/mcp` showed the supervise server as ✔ connected (initialize is
fast), but any actual tool call failed because the supervise
MCP design is long-poll — the sidecar holds the HTTP request open
until the operator approves in the dashboard (potentially minutes)
and only then returns the response.
Pipelock is a forward proxy with idle timeouts; it cut the long-
polled HTTPS-style request well before the operator could act, and
claude-code reported the tool as ✘ failed.
Fix: add `supervise` to the agent's NO_PROXY when bottle.supervise
is true. The supervise sidecar is on the bottle's internal network
with the `supervise` network-alias, so the agent can dial it
directly via docker DNS — no proxy, no idle timeout.
Body-scanning supervise traffic isn't critical because the operator
reviews every proposal in the TUI before approving. The earlier
pipelock allowlist auto-add for `supervise` stays as belt-and-
braces (handles any proxy-respecting client other than claude-code
that might dial supervise).
Existing bottles need a restart to pick up the new NO_PROXY value
(env can't be changed on a running container). The dashboard's
pipelock-edit workaround from PR #25 unblocks short-running tool
calls in the meantime but won't survive the pipelock idle timeout
on a long-polled call.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When PR #19 added the supervise sidecar (PRD 0013), I forgot to
mirror the cred-proxy auto-allow in pipelock_effective_allowlist.
The agent's HTTP_PROXY points at pipelock, so a request for
http://supervise:9100/ (the MCP endpoint claude-code dials) arrives
at pipelock as hostname `supervise` — and pipelock 403s it because
the host isn't in api_allowlist.
End-user symptom: even after `claude mcp add` registers the
supervise server, `/mcp` shows it as ✘ failed and the supervise
sidecar's docker logs are silent (request never gets through).
Mirror what cred-proxy already does: when bottle.supervise is True,
add SUPERVISE_HOSTNAME to the rendered pipelock allowlist. New tests
cover both the auto-add and the no-add-when-disabled invariants.
Existing bottles: the dashboard `pipelock edit <bottle>` verb (or
backend.docker.pipelock_apply.apply_allowlist_change) can apply
this fix to a running bottle without a relaunch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous provisioner wrote ~/.claude/settings.json with an
mcpServers entry — but claude-code doesn't read its mcpServers from
that path. Inside a bottle, /mcp showed "No MCP servers configured"
even though the sidecar was running.
Switch to the official `claude mcp add` command run via docker exec:
docker exec -u node <agent> \
claude mcp add --scope user --transport http supervise <url>
claude-code owns its config file format (~/.claude.json shape, key
names, scope semantics) and has changed it between versions. The
official command writes to the right place in the right shape for
whatever version is installed.
Failure is logged but not fatal — the bottle still works; you just
have to register the server manually with the command surfaced in
the warning. Worst case is a bad agent claude-code version, not a
bad bottle.
To fix an already-running bottle without restarting, the user can
run the same `docker exec` command directly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extends the preserve-on-capability-block design to also preserve
state on agent crash, and snapshots the transcript on every
teardown so any resume (crash or capability-block) gets a warm
claude session — not a cold start.
- capability_apply: rename _snapshot_transcript → snapshot_transcript
(public; reused below). No behavior change in the capability path.
- cli/start.py: capture bottle.exec_claude's exit code; while the
container is still alive (inside the launch context):
* always snapshot_transcript(identity)
* if exit_code != 0, mark_preserved(identity)
Then the existing _settle_state runs after teardown.
Now the preservation matrix is:
exit 0 (clean) → snapshot + cleanup state
exit ≠0 (crash, Ctrl-C) → snapshot + preserve + show resume hint
capability-block → (already snapshotted/preserved by apply
before teardown; this path is a no-op
because the container is already gone
by the time exec_claude returns)
snapshot_transcript is best-effort — capability-block's earlier
snapshot is not clobbered when the container is already torn down,
and a missing /home/node/.claude is a warn + skip.
Tested behavior: clean exit doesn't preserve, non-zero exit
(including SIGINT/130 and SIGKILL/137) preserves; empty identity
no-ops both helpers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`cli.py cleanup` already enumerated orphan containers + networks
and asked for confirmation before nuking them. Per-bottle state
under ~/.claude-bottle/state/ wasn't touched — accumulated forever,
including orphans from old code paths.
Add state to the cleanup flow with its own prompt: the trade-off is
different from containers (which are pure debris) because a state
dir may carry a resumable bottle (capability-block rebuild +
transcript snapshot) the operator still wants.
Output shows the resumable / orphan / rebuilt-Dockerfile / transcript /
preserve-marker flags for each state dir so the operator sees what
they'd lose. Both sections are skippable independently — answering
"n" to containers doesn't skip the state prompt.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
The project started life as bash scripts and got rewritten to Python
(documented in docs/research/bash-vs-python-vs-go.md). Several docs
still carried the old "bash-first" framing — misleading for anyone
reading them now (8.7k lines of Python vs. ~130 lines of bash, all
in scripts/demo*.sh).
- CLAUDE.md "What this is" + "Conventions": orchestrator is Python,
posture is stdlib-first.
- docs/prds/0010-cred-proxy.md, docs/research/manifest-format-and-
grouping.md: quoted CLAUDE.md's old wording — re-quote.
- docs/research/built-in-supervisor-design.md, landscape-containerized-
claude.md, agent-sandbox-landscape.md, pipelock-assessment.md,
network-egress-guard.md: drop "bash-first" claims about the project,
keep accurate descriptions of external tools' bash usage.
Leaves untouched: bash code-fence syntax in examples, README's
literal `bash scripts/demo.sh` invocation (the demo IS bash),
Claude Code's "Bash tool" references, IVIJL/devbox bash description
(that project actually is bash), and the bash-vs-python-vs-go
research note that records the rewrite decision.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The supervise sidecar (PRD 0013) has been serving MCP at
http://supervise:9100/ since it landed, but the in-bottle Claude
Code had no `.mcp.json` or settings pointing there — so the agent
couldn't actually call cred-proxy-block / pipelock-block /
capability-block as tools. To exercise the flow you had to curl
the sidecar from a sibling container.
This closes that last mile.
- claude_bottle/backend/docker/provision/supervise.py (new):
provision_supervise(plan, target) writes
~/.claude/settings.json into the running agent container with an
mcpServers.supervise entry of type http pointing at the
per-bottle sidecar. No-op when bottle.supervise is False.
- BottleBackend.provision orchestrator gains provision_supervise as
the last step (after CA, prompt, skills, git, cred-proxy). Default
impl is a no-op so non-Docker backends aren't forced to implement it.
- DockerBottleBackend wires it through to the new module.
- Test covers the rendered settings shape so a future regression in
the MCP entry format would surface in unit-level CI.
To test the full flow end-to-end now:
./cli.py start <agent> --cwd # agent's claude sees supervise
# agent calls cred-proxy-block via MCP
./cli.py dashboard # approve
./cli.py resume <identity> # restart with new capabilities
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
Phase 4 of PRD 0016. End-to-end test against real Docker:
- Stages a fake bottle: alpine:latest container named
claude-bottle-<slug> with a marker file at
/home/node/.claude/sessions.json, plus a fake supervise sidecar.
- Calls apply_capability_change with a new Dockerfile.
- Verifies: per-bottle Dockerfile written, agent + sidecars
removed, networks removed, transcript snapshot dir on host
contains the marker file (proving docker cp transferred bytes).
- Subsequent-apply test proves the per-bottle Dockerfile state
persists across rebuilds (before-diff uses the prior override,
not the repo Dockerfile).
- Teardown-idempotent test: apply against a never-started bottle
doesn't raise.
docker exec / cp / rm / network rm work fine across the docker
socket boundary, so this runs in DinD too — no act_runner skip
needed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 3 of PRD 0016. dashboard.approve() now dispatches to
apply_capability_change when the proposal is a capability-block:
cred-proxy-block → apply_routes_change
pipelock-block → apply_allowlist_change
capability-block → apply_capability_change (new in PRD 0016)
CapabilityApplyError joins the ApplyError tuple, so the TUI's key
handlers catch it the same way and surface failures in the status
line.
After a successful capability-block apply, dashboard archives the
proposal+response itself — the supervise sidecar was torn down by
apply_capability_change and can't archive its own queue file.
Without this, dashboard.discover_pending would keep surfacing the
resolved proposal forever.
No audit log for capability-block per PRD 0013 — its record lives
in the per-bottle Dockerfile state + transcript snapshot.
Tests stub apply_capability_change at the dashboard module level,
add TestCapabilityApplyWiring (call wiring, failure-keeps-pending,
no-audit invariant, archive-after-apply), and update TestApproveReject
to stub the capability path too so it stays docker-independent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 2 of PRD 0016. New module
claude_bottle/backend/docker/capability_apply.py:
- apply_capability_change(slug, new_dockerfile): snapshot transcript
→ push working tree → write per-bottle Dockerfile → teardown.
Returns (before, after) for the dashboard's audit/diff render.
- fetch_current_dockerfile(slug): per-bottle Dockerfile if set,
else the repo's Dockerfile.
- Internal helpers _snapshot_transcript, _push_working_tree are
best-effort (log + return on failure); _teardown_bottle is
idempotent (force-rm + network rm silently ignore missing names).
Fire-and-forget from the agent's perspective: by the time the
dashboard writes the response file the supervise sidecar is already
gone (it was torn down), so the agent's tool call connection drops
without receiving the response. The replacement agent (next manual
`cli.py start <agent>`) sees the new per-bottle Dockerfile and the
transcript snapshot for resume. v1 does not auto-relaunch.
Tests cover sequencing (snapshot → push → teardown order), the
per-bottle vs repo Dockerfile fallback chain, empty-input rejection,
and the per-bottle-Dockerfile write. The docker exec / cp / rm
plumbing is covered by the Phase 4 integration test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Adds PRD 0016, the heaviest of the three remediation engines in the
stuck-agent recovery flow (overview in PRD 0012, foundation in PRD
0013). Wires the capability block path: rebuild orchestrator,
state-preservation helper, capability-block end-to-end. On approval
the orchestrator tears down the bottle, builds from the new
Dockerfile, and starts a replacement on the same branch via
state-preservation.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 4 of PRD 0015. End-to-end test against real Docker:
- Brings up a real pipelock sidecar via the production
DockerPipelockProxy bring-up + pipelock_tls_init.
- Calls apply_allowlist_change to add a new host.
- Polls the live /etc/pipelock.yaml until the new host shows up
(bridging the docker-restart window).
- Verifies api_allowlist contains both old + new hosts and
tls_interception block is preserved.
- Smaller cases: invalid hostname raises, missing sidecar raises,
fetch_current_allowlist returns one-per-line format.
Skipped under GITEA_ACTIONS because pipelock_tls_init bind-mounts a
host path that doesn't share fs in the runner, matching the
existing pipelock smoke test's skip pattern.
Drive-by fix: fetch_current_yaml now uses `docker cp` (daemon-API
tarball copy) instead of `docker exec cat` because the pipelock
image is distroless and has no shell utilities.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 3 of PRD 0015. Adds the proactive `pipelock edit` path,
mirroring routes edit from PRD 0014:
- discover_pipelock_slugs() lists running pipelock sidecars.
- operator_edit_allowlist(slug, new) wraps apply_allowlist_change
and writes an audit entry tagged ACTION_OPERATOR_EDIT.
- New 'p' keybinding in the main TUI: discover slugs, prompt if
multiple, fetch current allowlist, open in $EDITOR, apply on
save.
- Extracts shared scaffolding into _operator_edit_flow used by
both routes-edit and pipelock-edit — DRY without sacrificing
the per-verb status-line copy.
- Footer updated.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 2 of PRD 0015. dashboard.approve() now dispatches on the
proposal's tool:
cred-proxy-block → apply_routes_change (from PRD 0014)
pipelock-block → apply_allowlist_change (new in PRD 0015)
capability-block → no-op (lands in PRD 0016)
PipelockApplyError joins CredProxyApplyError under the ApplyError
tuple the TUI catches: failures keep the proposal pending and the
status line surfaces the message; no response is written and no
audit entry is appended.
Tests: existing TestApproveReject stubs both apply paths; new
TestPipelockApplyWiring covers the call wiring, failure-propagation,
and real-diff-in-audit invariants.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 1 of PRD 0015. New module
claude_bottle/backend/docker/pipelock_apply.py:
- fetch_current_yaml(slug): docker exec cat of the live
/etc/pipelock.yaml.
- fetch_current_allowlist(slug): parses the yaml, extracts
api_allowlist, renders as one-per-line for the operator/agent.
- parse_allowlist_content / render_allowlist_content: one-per-line
with `#` comments + blank-line tolerance, conservative hostname
validation.
- apply_allowlist_change(slug, new): parses new hosts, fetches +
parses current yaml, swaps api_allowlist, re-renders via
pipelock_render_yaml, docker cp into sidecar, docker restart.
Returns (before, after) as one-per-line strings for the audit diff.
- PipelockApplyError: caller surfaces to operator without crashing
the dashboard.
v1 uses restart, not SIGHUP — pipelock has no in-process reload
hook; adding one is the PRD's open question. Restart drops in-flight
outbound calls and the agent retries pick up the restarted proxy.
Yaml roundtrip is covered by tests: parse(render(cfg)) preserves
all fields pipelock_render_yaml emits, including tls_interception
+ passthrough_domains.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds PRD 0015, the second remediation engine in the stuck-agent
recovery flow (overview in PRD 0012, foundation in PRD 0013). Wires
the pipelock block path with restart-based reload: supervisor writes
the new allowlist on approval and restarts pipelock, proactive
pipelock edit TUI verb, pipelock audit log filled in. SIGHUP reload
for pipelock is deferred to a follow-up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 5 of PRD 0014. End-to-end test against real Docker:
- Brings up a cred-proxy sidecar with route /a/ → unreachable
upstream (so 502 = route matched, 404 = no route).
- Calls apply_routes_change to swap to /b/ only.
- Polls until the route table flips: /a/ now 404s, /b/ now 502s.
- Separately verifies fetch_current_routes returns the live file,
apply with invalid JSON raises, and apply against a non-existent
sidecar raises.
No fake-upstream container needed: unreachable hostnames give the
502 signal directly. apply_routes_change uses docker exec / cp / kill
(not bind mounts), so this should work in docker-in-docker too —
no DinD skip needed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 4 of PRD 0014. Adds the proactive routes-edit path that
doesn't require a pending proposal:
- discover_cred_proxy_slugs() lists running cred-proxy sidecars by
parsing docker ps output. Returns [] when docker is unreachable
or not installed (no exception escapes).
- operator_edit_routes(slug, new_content) wraps apply_routes_change
and writes an audit entry tagged ACTION_OPERATOR_EDIT (so a
future reader can distinguish operator-initiated changes from
agent-proposal approvals in the log).
- New 'e' keybinding in the main TUI: discover slugs, prompt if
multiple (or use the only one directly), fetch current routes,
open in $EDITOR, apply on save. CredProxyApplyError lands in the
status line; the operator can retry.
Tests cover audit-entry shape, failure path, and docker-missing
recovery for slug discovery.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 3 of PRD 0014. dashboard.approve() now does the real
remediation for cred-proxy-block proposals:
- Calls apply_routes_change(slug, file_to_apply) which fetches the
current routes.json from the running sidecar, validates the new
JSON, docker cp's it in, and SIGHUPs the sidecar.
- Audit entry's diff is now the real before→after from the apply
return — not the empty-string placeholder 0013 wrote.
- On apply failure (CredProxyApplyError): no response file, no
audit entry. Proposal stays pending so the operator can fix the
input and retry. The TUI's key handlers catch the exception and
surface the message in the status line.
- pipelock-block + capability-block remain no-op approvals; their
remediation lands in PRDs 0015 + 0016 and the audit diff stays
empty until then.
- reject path unchanged: no apply, audit entry with empty diff.
Tests stub apply_routes_change at the dashboard module level so the
unit suite doesn't need a running sidecar; integration test in
Phase 5 covers the real docker exec/cp/SIGHUP plumbing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 2 of PRD 0014. New module
claude_bottle/backend/docker/cred_proxy_apply.py:
- fetch_current_routes(slug): docker exec cat of the live
routes.json from the running cred-proxy sidecar.
- validate_routes_json(content): syntactic check before SIGHUP so
failures keep the old routes live and surface a clearer error
than 'reload failed' in the sidecar logs.
- apply_routes_change(slug, new): fetch current → validate new →
write to temp → docker cp into sidecar → docker kill --signal HUP.
Returns (before, after) so the caller can render a real audit diff.
- CredProxyApplyError: caller surfaces to operator without crashing
the dashboard.
docker exec / cp / kill paths are covered by the integration test
in Phase 5; unit tests here cover the validator.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 1 of PRD 0014. Adds the in-sidecar SIGHUP signal handler that
re-reads routes.json + re-resolves tokens from env without dropping
in-flight connections:
- reload_routes(server, path, environ=...) does the atomic swap.
Returns (ok, message) so the caller can log/surface failures.
On failure (bad JSON, missing file) the server keeps serving the
old routes rather than dying — typos shouldn't crash the sidecar.
- install_sighup_handler wires SIGHUP → reload_routes. No-op on
platforms without SIGHUP (Windows).
- serve() now installs the handler at startup.
Atomicity: Python attribute reassignment is atomic, and the request
handler reads server.routes/tokens once at the top of _proxy() so
an in-flight request keeps the version it captured.
Tests cover successful reload, JSON-parse failure, and missing-file
failure (both verify the old routes survive).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds PRD 0014, the first end-to-end remediation engine in the
stuck-agent recovery flow (overview in PRD 0012, foundation in PRD
0013). Wires the cred-proxy block path: SIGHUP-based hot reload of
routes.json on cred-proxy, supervisor write-on-approval, proactive
routes edit TUI verb, cred-proxy audit log filled in.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The integration test test_tools_call_round_trips_through_queue
relies on a host bind-mount to share the queue dir between the
sidecar (writing proposals) and the test process (approving via
dashboard helpers). In the Gitea Actions runner the docker socket
forwards to the outer host's daemon, so bind-mount paths are
resolved against the outer host's fs — not the runner container's.
The sidecar writes its proposal where the test can't see it; the
test times out.
Add a one-shot probe that does docker run -v <tmp>:<container> and
checks both directions of fs visibility. Skip the round-trip test
when the probe fails. tools_list and the orphan-name test are
unaffected — they don't touch the queue.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>