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>
Phase 5 of PRD 0013. End-to-end integration test against real Docker:
- Brings up the supervise sidecar on a per-bottle internal network.
- A curl-image "agent" on the same network does tools/list and gets
back the three PRD 0013 tool names over real MCP wire format.
- A tools/call round-trips through the queue: agent blocks on the
call, host watches the queue, dashboard.approve writes a Response,
agent receives the approval payload (status, notes) in MCP content.
- Documents the orphan-sidecar name-collision behavior so a future
auto-cleanup change can flip the assertion.
Skips if docker is unreachable, matching the existing integration
pattern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 4 of PRD 0013. Adds `claude-bottle dashboard` subcommand:
- discover_pending() walks ~/.claude-bottle/queue/* and gathers
pending proposals across all bottles, sorted FIFO by arrival.
- approve / approve-with-final-file / reject helpers write the
Response file the sidecar polls, and append an AuditEntry for
cred-proxy and pipelock tools. capability-block proposals don't
write to an audit log here (PRD 0016 captures via rebuild record).
- Stdlib-curses TUI: list view, detail view, $EDITOR shellout for
modify-then-approve, inline prompt for reject reason.
- `dashboard --once` dumps pending proposals to stdout without
bringing up curses — useful for scripted checks and tests.
For 0013 the audit entry's diff field is render_diff("", proposed)
because we don't yet have access to the live on-disk current file;
PRDs 0014 / 0015 fill in real before→after diffs once they own the
host-side config writes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 3 of PRD 0013. Wires the supervise sidecar into bottle launch:
- Manifest: bottle.supervise (bool, default False). Opt-in for v1 so
existing bottles are unchanged.
- supervise.py: adds SupervisePlan + abstract Supervise(ABC) with a
prepare template that stages the per-bottle queue dir on the host
and the current-config dir under stage_dir (routes.json + allowlist
+ Dockerfile). Stdlib-only so it still runs as the in-container
shared helper.
- backend/docker/supervise.py: DockerSupervise concrete start/stop.
No egress network (the sidecar doesn't make outbound calls); just
the bottle's internal network with network-alias "supervise" and a
bind-mount of the host queue dir at /run/supervise/queue.
- Prepare wires supervise.prepare into the DockerBottlePlan, derives
routes_content from cred_proxy_plan, allowlist_content from
pipelock_effective_allowlist, and dockerfile_content from the
repo's Dockerfile. supervise sidecar added to the orphan probe.
- Launch starts the supervise sidecar after pipelock + cred-proxy
but before the agent (so DNS resolution for `supervise` is up on
the agent's first tool call).
- Agent container gets a read-only bind-mount of the current-config
dir at /etc/claude-bottle/current-config when supervise is enabled.
- bottle_plan print + to_dict surface the supervise state.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 2 of PRD 0013. Adds the in-container MCP server:
- claude_bottle/supervise_server.py: minimal JSON-RPC over HTTP MCP
server. Handles initialize / notifications/initialized / tools/list /
tools/call. Each tools/call validates the proposed file syntactically,
writes a Proposal to the host-mounted queue, blocks waiting for a
Response, archives both files, returns the operator's {status, notes}
wrapped in MCP content.
- Three tool definitions with JSON Schema inputs: cred-proxy-block
(routes.json), pipelock-block (allowlist), capability-block
(Dockerfile).
- Dockerfile.supervise mirroring the cred-proxy pattern: same pinned
python:3.13-alpine, copies supervise.py + supervise_server.py into
/app, exposes port 9100.
Stdlib-only. Tests cover JSON-RPC parsing, per-tool validation, all
three handlers, the queue round-trip via a background responder
thread, and an end-to-end HTTP sanity check on a random port.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds PRD 0013, the shared foundation for the stuck-agent recovery flow
(overview in PRD 0012). Defines the MCP sidecar, the three tool
definitions, the proposal queue, the read-only current-config mount,
the minimal TUI, and the audit log format. Approval handlers are
deliberately no-ops; the actual remediations land in PRDs 0014, 0015,
and 0016.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures the rationale for placing the MCP server outside the agent
container. The bottle wall doesn't strictly require it (the operator
TUI is the actual gate), but pattern consistency, audit metadata
trust, connection lifecycle, future enforcement headroom, and
pipelock cleanliness all argue for sidecar placement.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the text-only /supervise/notify protocol with three MCP tools
the agent calls directly: cred-proxy-block, pipelock-block, and
capability-block. Each tool carries the agent's proposed config file
(routes.json, pipelock allowlist, or Dockerfile) plus a justification.
Adds a new MCP sidecar, a read-only current-config mount in the agent
container, and renames "capability gap" to "capability block" to match
the tool name. The text-only-vs-structured tradeoff is captured as an
Open question with pros/cons on both sides.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Introduces cred-proxy block, pipelock block, and capability gap as the
three named categories of stuck. Adds pipelock-edit support (restart-
based for v1) parallel to the existing cred-proxy routes-edit path,
plus a pipelock audit log. Broadens Goals to cover all three paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rewrites Scope, Proposed Design, Data model, and Open questions to
match the model where /supervise/notify is text-in/text-out, routes
edits + SIGHUP reload are supervisor-side tooling, and manifest
rebuilds are the heavy path. Adds the per-bottle routes-edit audit log.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The autonomous "review comment → respawn bottle with comment as
next prompt" loop is the one feature that opens a prompt-injection
vector the bottle wall can't close (a public commenter would get
to issue instructions inside the agent's perimeter on every
launch). The available mitigations — commenter allowlists,
prompt-injection regex screens, private-repo defaults — are all
soft. The durable defense is to keep the human between the
review comment and any next agent prompt.
So `supervise` is now strictly notify-only. The `auto_respawn`
manifest field, the "with auto_respawn: true" behavior paragraph,
and the matching trust-model edge case all go. The reasoning
stays in the "Where to be conservative" bullet so the decision
isn't re-litigated later.
The "Manifest" section now describes the per-file MD layout under
~/.claude-bottle/{bottles,agents}/, the filename-as-key convention,
the YAML subset constraints, and the trust boundary (bottles are
home-only by filesystem layout). Includes a working bottle example
with comments inside the frontmatter and a working agent example
showing the Markdown body as the system prompt.
Drops claude-bottle.example.json. The new examples/ tree —
examples/bottles/dev.md, examples/agents/implementer.md,
examples/agents/researcher.md — verifies the parser end-to-end via
Manifest.from_md_dirs(examples/, None).
Manifest.resolve walks $HOME/.claude-bottle/{bottles,agents}/ and
$CWD/.claude-bottle/agents/ instead of reading claude-bottle.json.
A bottles/ subdir under $CWD is logged as a warn and ignored —
the filesystem layout IS the trust boundary, no resolver check
needed.
If claude-bottle.json exists alongside no .claude-bottle/ dir at
either location, dies with a clear pointer at the README — the
manifest format changed and we don't silently fall back.
Manifest.from_md_dirs(home, cwd) is the programmatic entry point
tests use to build a Manifest from fixture directories without
touching os.environ. Manifest.from_json_obj is preserved for
tests that still want to build manifests in-memory.
Bottle / agent frontmatter goes through Bottle.from_dict /
Agent.from_dict — same validators as today's JSON path. Unknown
top-level frontmatter keys die with a "did you mean" pointer
listing accepted keys. Filenames that don't match [a-z][a-z0-9-]*
are skipped with a warn.
Agent files accept the Claude Code subagent passthrough fields
(name, description, model, color, memory) so the same file can
drop into ~/.claude/agents/ — claude-bottle ignores them at
launch but doesn't reject.
The dry-run integration test ships a real MD fixture tree now;
all 200 unit + 17 integration tests stay green.
claude_bottle/yaml_subset.py — stdlib-only, ~450 lines. Parses the
bounded shape claude-bottle's manifest files use:
- Block mappings (top-level + nested via indentation)
- Block lists (under a key, items can be scalars or block-style
mappings whose keys align with the rest after the dash)
- Inline lists `[a, b]` and inline dicts `{a: 1}` for one-level
leaves
- Quoted (single + double) and bare strings
- Scalars: string, int, true/false, null/~
Rejects, each with a clear pointer at the line number:
- `yes`/`no`/`on`/`off`/`Y`/`N`/`TRUE`/`FALSE` — only literal
`true` / `false` are bools (the Norway problem stays solved by
"quote your strings if they look like bools")
- Bare strings that look like dates / octals / hex / floats
- Anchors (`&`/`*`), aliases, YAML tags (`!!str`)
- Multi-line block scalars (`|`, `>`)
- Tabs in indentation
- Nested flow style (only one level allowed)
Public API:
parse_yaml_subset(text) -> dict[str, object]
Top level must be a mapping.
parse_frontmatter(text) -> (dict, body_text)
Strips `---` delimiters, parses content as YAML subset, returns
the verbatim body text after the closing fence.
46 unit tests covering every construct the real manifest files use
(the cred_proxy.routes structure, role-as-inline-list, nested
ExtraHosts dicts) plus every rejection case listed in PRD 0011.
claude-bottle has a single primary user today; an automated
JSON → MD migration tool is overkill. Hand-rewriting one file
is the migration cost. The resolver still dies with a pointer
at the README's manifest section if a stale claude-bottle.json
is found alongside no .claude-bottle/ directory, so the breaking
change isn't silent.
Drops: SC #6 (migration tool), the "Migration command" In Scope
sub-bullet, the migrate_manifest.py / cli wiring entries from
Existing code touched, the tests/integration/test_migrate_manifest.py
entry from Tests, the destructive-vs-additive open question.
Renumbers the remaining success criteria 6, 7 (formerly 7, 8).
Backward-compat section rewritten around hand-rewrite.
Specs the implementation chosen in the PR #16 closing comment:
per-file MD-with-YAML-frontmatter layout for both bottles and
agents, with a hand-rolled YAML subset parser (no PyYAML).
Layout:
- $HOME/.claude-bottle/bottles/<name>.md (home-only)
- $HOME/.claude-bottle/agents/<name>.md (home agents)
- $CWD/.claude-bottle/agents/<name>.md (repo-supplied agents)
The trust boundary that PRD-0011-v1 (closed PR #15) tried to
enforce in the resolver now falls out of filesystem layout —
$CWD/.claude-bottle/ has no bottles/ subdir, the loader doesn't
look there. Filesystem layout IS the enforcement.
Eight success criteria, including: stdlib-only (no new runtime
dep), idempotent migration command, agent files shaped close to
Claude Code's existing subagent spec so the same file can drop
into ~/.claude/agents/.
PRD-only; no implementation in this commit. PRD slot 0011 is
intentionally reused — the v1 file was never merged to main.
Captures the two open questions surfaced by PRD 0011: should bottles and agents stay grouped in one file or split per file, and should the format stay JSON or move to YAML / MD-with-frontmatter.
Recommends per-file MD-with-frontmatter (with agents shaped close to Claude Code's subagent spec so they can drop into ~/.claude/agents/ as a side effect), explicitly flags the PyYAML runtime dependency as a user-decision crossing the project's "low deps by default" line, and leaves several other choices (hidden dotdir vs visible, migration tooling) as open questions.
Companion to docs/prds/0011-cwd-manifest-trust-boundary.md (which solves the trust problem at the resolver layer); this doc explores a structural alternative that would make the boundary self-documenting on disk.
The previous diagram showed three parallel egress lanes — agent ↔
pipelock, agent ↔ git-gate, agent ↔ cred-proxy — each going off-box
independently. That was true of an earlier shape but is now wrong on
two counts:
1. cred-proxy's outbound HTTPS routes through pipelock (set when
the SSRF / CA-trust wiring landed). All cred-proxy upstream
bytes pass pipelock's allowlist + body scanner.
2. git-gate's SSH push/fetch is direct out the egress network and
has never gone through pipelock — pipelock is HTTP-only.
Reflect both: the diagram now collapses to one HTTP/HTTPS chokepoint
(pipelock) that the agent and cred-proxy share, plus a separate SSH
lane for git-gate. Prose paragraph above the diagram updated to call
out the "everything except SSH" framing explicitly.
Verified against the current code: HTTPS_PROXY=pipelock set on the
agent in launch.py and on cred-proxy in DockerCredProxy.start;
git-gate's create-args carry no proxy env vars.
claude-code sends Accept-Encoding: gzip, deflate, br on every
request. api.anthropic.com honors it and returns gzip-compressed
SSE responses. Pipelock 2.3.0 has no decompression path; its
response scanner fails closed with "blocked: compressed
sse_stream response cannot be scanned" — and that gate fires
even with response_scanning.enabled=false and sse_streaming
disabled. Verified empirically against the real pipelock image.
Cleanest fix that preserves DLP coverage end-to-end: have
cred-proxy ask upstream for uncompressed bytes. Strip the
agent's Accept-Encoding when building the upstream headers and
inject `Accept-Encoding: identity`. Upstream returns plaintext;
pipelock can scan; no 403.
Bandwidth cost is the gzip ratio one-way (cred-proxy ↔ upstream
through pipelock). For LLM SSE streams that's a few KB extra per
turn — trivial compared to the alternative of leaving
pipelock's response scanner blind.
The previous attempt added a `suppress: [{rule, path}]` entry. The
yaml validated and the entry showed up in the live pipelock's
config, but the BIP-39 detector kept firing — `suppress` only
silences alerts, not enforcement.
Reproduced the failure in isolation, probed three knobs against a
real pipelock with a canonical BIP-39 body
(`abandon abandon ... about`):
suppress: [{rule: "BIP-39 Seed Phrase", path: "/anthropic/**"}]
-> still 403
rules.disabled: ["dlp:BIP-39 Seed Phrase"]
-> still 403
seed_phrase_detection: { enabled: false }
-> 200 (forwarded)
Only the global toggle actually stops the block. Pipelock 2.3.0
has no per-path / per-host knob for this detector, so the
trade-off is: when the bottle declares an `anthropic-base-url`
route, BIP-39 detection comes off globally for that bottle. Every
other DLP pattern (gh*_, sk-ant-, AKIA, etc.) keeps firing — the
ones that actually map to claude-bottle's threat model.
Drops the `suppress:` emitter from pipelock_build_config /
pipelock_render_yaml; replaces with a `seed_phrase_detection:
{ enabled: false }` block driven by
`pipelock_seed_phrase_detection_enabled(bottle)`. Tests flip from
suppress-shape to seed_phrase shape. End-to-end probe through the
real pipelock image confirms BIP-39 bodies forward.
claude-code's chat bodies legitimately trip pipelock's BIP-39 seed-
phrase detector — any 12+ English words that pass the BIP-39
checksum match. The direct path to api.anthropic.com already sits
on tls_interception.passthrough_domains so no body scan runs
there, but the cred-proxy hop is plain HTTP through pipelock and
the body scanner fires.
Add an anthropic-route-specific suppress entry:
suppress:
- rule: "BIP-39 Seed Phrase"
path: "/anthropic/**"
Just this one detector, only on this one path. Every other DLP
pattern (AKIA, gh*_, sk-ant-, etc.) keeps firing — those are
unambiguous credential shapes with no legitimate reason to appear
in a chat completion. Other detectors that fire on natural
language can be added to the suppress list when/if they surface.
Wiring: pipelock_effective_suppress(bottle) computes the entries
from bottle.cred_proxy.routes; pipelock_build_config accepts them
and emits a `suppress:` block; pipelock_render_yaml renders it.
Probed schema with `pipelock check --config` to confirm the
{rule, path} shape; full yaml validates clean.
The agent's HTTP_PROXY points at pipelock, so a request to
http://cred-proxy:9099/... arrives at pipelock; pipelock resolves
the host, sees an RFC1918 address (the bottle's internal Docker
network sits in 172.x), and 403's "SSRF blocked: cred-proxy
resolves to internal IP 172.20.0.4". Bypassing pipelock entirely
would also remove its body scanner from the agent->cred-proxy leg
— we want to keep that DLP coverage.
Pipelock has `ssrf.ip_allowlist` for exactly this: CIDRs that
override the built-in internal-IP block while api_allowlist + body
scanning + tls_interception keep firing.
Wiring:
- `pipelock_build_config` accepts `ssrf_ip_allowlist`; when
non-empty, emits an `ssrf: { ip_allowlist: [...] }` block.
- `pipelock_render_yaml` renders that block.
- `PipelockProxyPlan` gains `internal_network_cidr`.
- New `network_inspect_cidr(name)` helper reads the Docker-assigned
subnet via `docker network inspect`.
- launch.py: after `network_create_internal`, inspect the CIDR,
re-render the yaml with `ssrf_ip_allowlist=(cidr,)`, overwrite
the file in place; `DockerPipelockProxy.start` then docker-cp's
the updated content. Prepare's initial render stays unchanged
(CIDR isn't known yet at prepare time).
The exception scope is the bottle's own internal network only —
agent ↔ pipelock / git-gate / cred-proxy. Body scanning still
applies to the bytes flowing through pipelock; pipelock just no
longer treats those internal IPs as exfil targets.
The agent's HTTP_PROXY env points at pipelock, so an
ANTHROPIC_BASE_URL like http://cred-proxy:9099/anthropic doesn't
short-circuit through Docker's embedded DNS — it gets forwarded
through pipelock, which then checks its api_allowlist for the
hostname `cred-proxy` and 403's because the name isn't there. The
agent surfaces the failure as "API Error: 403 blocked: domain not
in allowlist: cred-proxy" on Claude's first call.
Fix: pipelock_effective_allowlist auto-adds CRED_PROXY_HOSTNAME
when bottle.cred_proxy.routes is non-empty (i.e., when the
sidecar will actually be running and reachable).
Move CRED_PROXY_HOSTNAME from backend/docker/cred_proxy.py to the
backend-agnostic claude_bottle/cred_proxy.py so pipelock can
reference it without a layering violation; the docker concrete
imports it from the same place.
Removes the legacy `CLAUDE_BOTTLE_OAUTH_TOKEN` -> `CLAUDE_CODE_OAUTH_TOKEN`
forward in prepare.py. Bottles that need claude-code to authenticate
must declare a cred_proxy route with role: "anthropic-base-url" — there
is no fallback that hands the token to the agent directly.
Drops the now-dead BottleSpec.forward_oauth_token field, the CLI
setter that read CLAUDE_BOTTLE_OAUTH_TOKEN from the host env at
prepare time, and the forward_oauth_token=False arg in the six
pipelock integration tests.
PRD 0010 and README updated; the dev ~/claude-bottle.json gains an
anthropic-base-url route so the implementer/researcher agents keep
working.
BREAKING: bottles previously relying on the implicit OAuth forward
will now produce an agent environ without any Anthropic credential.
Verified with --dry-run: a bottle with no anthropic-base-url route
yields env_names: [] (no token at all); a bottle that declares the
route yields ANTHROPIC_BASE_URL plus a non-secret placeholder for
CLAUDE_CODE_OAUTH_TOKEN.
Two failure-clarity paper cuts from the cred-proxy debugging:
1. Every docker create / start / network-connect call on the three
sidecars (pipelock, git-gate, cred-proxy) was piping stderr to
DEVNULL. A stuck orphan from a previous run produced "failed to
create pipelock sidecar claude-bottle-pipelock-demo" with no
pointer at the real cause ("Conflict. The container name ... is
already in use ..."). Switch each call to capture_output=True and
include the stripped stderr in the die() message.
2. The agent container had a container_exists() probe in resolve_plan
that fails fast with a hint, but the sidecars (whose names are
deterministic from the slug) didn't. So an orphan caused launch()
to bail deep inside docker create. Add a probe in resolve_plan for
each sidecar this launch will actually try to create: pipelock
always; git-gate when bottle.git is non-empty; cred-proxy when
bottle.cred_proxy.routes is non-empty. Die with a "./cli.py
cleanup" pointer.
Smoke-tested with an orphaned pipelock-<slug> container — the new
probe fires with the expected hint before any sidecar build/start
work begins.
Three leftovers from the manifest refactor:
1. provision/cred_proxy.py:223 referenced u.kind == 'gitea' for the
tea login count — kind was removed from the runtime class, so any
bottle with a tea-login route raised AttributeError at provision
time. Switch to `'tea-login' in r.roles`.
2. The runtime class CredProxyUpstream is renamed to CredProxyRoute
(its data is a route on the proxy, not an "upstream"; the field
route.upstream is the upstream URL). Module's own naming now
aligns with manifest.CredProxyRoute and routes.json.
3. cred_proxy_upstreams_for_bottle -> cred_proxy_routes_for_bottle;
CredProxyPlan.upstreams -> CredProxyPlan.routes; local
`upstreams` collections become `routes`. Callers in
backend.py, launch.py, prepare.py, bottle_plan.py,
provision/cred_proxy.py, and tests updated.
Also strips lingering `bottle.tokens` references from docstrings
(pipelock.py, cred_proxy.py prepare(), manifest._parse_https_host,
test_pipelock_allowlist.py module doc) and removes dead helpers
from the integration test (the _bottle helper used a tokens field
that no longer parses).
Replace bottle.tokens (with Kind enum and hardcoded per-kind
route/auth tables) with bottle.cred_proxy.routes — each route
declares its own path, upstream, auth_scheme, token_ref, and
optional role[]. The manifest is now the source of truth for the
proxy's runtime route table; adding an upstream is a manifest edit,
not a code change.
Agent-side rewrites move from per-kind dispatch to per-role tags
on routes:
anthropic-base-url -> set ANTHROPIC_BASE_URL=<proxy><path>
npm-registry -> write ~/.npmrc registry=
git-insteadof -> write ~/.gitconfig [url] insteadOf, keyed
off route.upstream (suppressed when
bottle.git brokers the same host)
tea-login -> add a ~/.config/tea/config.yml login
Roles are a list (string accepted as sugar). A gitea route
typically carries ["git-insteadof", "tea-login"]. Singleton roles
(anthropic-base-url, npm-registry) appear on at most one route.
token_env slots are assigned per distinct TokenRef in declaration
order — two routes sharing a token_ref (e.g. github API + git
endpoints) share a slot.
Drops: TOKEN_KINDS, _KIND_ROUTES, _KIND_AUTH_SCHEME, _TOKEN_DEFAULT_HOST,
cred_proxy_route_path_for_gitea, the kind field on CredProxyUpstream,
and the kind-based hardcoding in pipelock_token_hosts (now derives
from route.UpstreamHost).
Legacy bottle.tokens manifests now die with a hint pointing at
bottle.cred_proxy.routes + this PRD. Tests rewritten end-to-end.
Docs + example.json + the dev ~/claude-bottle.json updated to match.
Three coupled fixes that close a documented bypass of git-gate's
gitleaks pre-receive hook:
1. cred-proxy refuses git smart-HTTP push at runtime. Any path
ending in /git-receive-pack or /info/refs?service=git-receive-pack
returns 403 with a pointer at the bottle.git SSH path. Fetch
(upload-pack) is still allowed — the bypass we're closing is
push, where gitleaks is the load-bearing scanner. Hard guarantee.
2. The provisioner suppresses the cred-proxy `~/.gitconfig` insteadOf
rewrite for any host already declared in bottle.git. git-gate is
the canonical git path there; we don't write a competing rule
that would let `git clone https://<host>/...` succeed in ways
that confuse on push. Defense in depth — (1) is the hard guarantee.
3. cred-proxy routes its outbound HTTPS through pipelock. The
sidecar's environ now sets HTTPS_PROXY=<pipelock-url>, and the
image's entrypoint runs `update-ca-certificates` over the
per-bottle pipelock CA (docker cp'd into
/usr/local/share/ca-certificates/pipelock.crt before start) so
the proxy's HTTPS client trusts pipelock's bumped certs.
Consequence: pipelock's allowlist + body scanner now sit in the
cred-proxy egress path the same way they sit in front of direct
agent traffic. The cred-proxy upstream hosts (api.github.com,
github.com, gitea hosts, registry.npmjs.org) come OFF
pipelock's passthrough_domains. Only api.anthropic.com remains
on passthrough (LLM body content legitimately trips DLP).
PRD 0010 updated to reflect all three. Tests adjusted: the
"cred-proxy hosts go on passthrough" assertion in
test_pipelock_allowlist flips to "they don't", a new
TestIsGitPushRequest exercises the smart-HTTP refusal predicate,
and the gitconfig renderer tests cover the per-host suppression
matrix.
git-gate holds an SSH IdentityFile for push/fetch; cred-proxy holds
a PAT for HTTPS REST API calls. The two brokers are orthogonal —
the common dev setup names both on the same host (e.g. gitea.dideric.is
SSH for push, gitea.dideric.is PAT for `tea pr create`).
The original PRD 0010 wording called this a "configuration smell"
and rejected it at parse time. That was wrong; this drops the
overlap rejection from the validator and updates the PRD prose to
match. Tests flip from "rejection" to "coexistence" assertions.
- Architecture diagram gains the cred-proxy lane (agent talks plain
HTTP via bearer-auth-injection; sidecar talks HTTPS to the real
upstream with the manifest token).
- Adds a cred-proxy entry under the sidecar bullet list, with a
pointer to PRD 0010.
- Manifest example illustrates the `tokens` array on a bottle.
- Auth section notes that declaring an `anthropic` token routes
CLAUDE_BOTTLE_OAUTH_TOKEN through the sidecar instead of into
the agent's environ.
- claude-bottle.example.json gains an `agentic` bottle declaring
all four token kinds, plus a paired `agentic-helper` agent.
Drives DockerCredProxy.start through the production code path against
a fake upstream container running on the same egress network. The
"agent" is a curl container on the bottle's internal network — same
access topology the agent uses in production.
Covers PRD 0010 success criteria:
- SC3 (the request reaches upstream, header round-trip works)
- SC6 (inbound Authorization stripped; the proxy injects the
configured token even when the agent tries to smuggle one in)
- partial SC2 (cred-proxy reachable by the alias from the internal
network)
- 404 for unconfigured routes
Live-network tests against real Anthropic / GitHub / Gitea / npm
upstreams (SC4 and SC5 specifically) are deferred — the fake-upstream
shape covers the routing + header layer that's actually under test
here.
bottle.tokens declarations contribute their upstream hosts to both
pipelock's allowlist (so cred-proxy can reach them) and
passthrough_domains (so pipelock doesn't MITM the connection —
cred-proxy validates real upstream certs with the system CA bundle).
Mapping: anthropic -> api.anthropic.com (already on defaults);
github -> api.github.com + github.com; gitea -> the entry's host;
npm -> registry.npmjs.org.