Commit Graph

96 Commits

Author SHA1 Message Date
didericis 30d92bef48 docs: drop ssh from README/example, supersede PRD 0007 (PRD 0009)
test / unit (pull_request) Successful in 13s
test / integration (pull_request) Successful in 21s
- README architecture diagram drops the socat/ssh image box and
  the agent's ~/.ssh/config; the prose-bullets section drops the
  ssh image; the manifest example swaps `ssh:` for `git:` so
  someone copy-pasting it picks up the new shape.
- claude-bottle.example.json: `default` bottle's `"ssh": []` is
  gone (now just an empty bottle); the gitea-dev example already
  uses `git:` since the ExtraHosts work.
- PRD 0007 carries a "Superseded by PRD 0009" header at the top
  with a one-paragraph block explaining why; the file stays so
  the rationale of the prior design is still in-tree.
- git_gate.py: drop the now-stale shadow-route mention from a
  docstring (the validator went away in the manifest layer).
2026-05-12 23:57:50 -04:00
didericis 3d66ad2a86 feat(ssh-gate)!: remove ssh-gate sidecar and provisioner (PRD 0009)
Delete claude_bottle/ssh_gate.py, the DockerSSHGate sidecar,
and the provision_ssh provisioner (~/.ssh/config + ssh-agent
wiring). Unwire the gate from the abstract BottleBackend
(provision orchestration drops the ssh step,
_validate_ssh_entries goes away) and from the Docker backend
(prepare/launch lose the `gate` kwarg, bottle_plan drops the
gate_plan field, dry-run JSON drops the ssh_hosts / ssh_gate
keys, y/N preflight drops the ssh-hosts block). cli/info now
prints declared git remotes instead of ssh hosts. pipelock's
docstring picks up the git-gate framing now that there's no
PRD-0007 boundary to call out.

BREAKING (dry-run JSON): the `ssh_hosts` and `ssh_gate` keys
are gone from `start --dry-run --format=json`. Consumers should
read `git_remotes` / `git_gate` instead.
2026-05-12 23:49:58 -04:00
didericis c403d137b6 feat(manifest)!: remove SshEntry and bottle.ssh (PRD 0009)
Drop the SshEntry dataclass, the Bottle.ssh field, the shadow-
route validator, and the SSH-only _opt_port helper. A legacy
bottle.ssh key now parse-fails with a one-line hint pointing at
bottle.git (PRD 0008), which is the replacement.

BREAKING: manifests carrying bottle.ssh will not load. Migration
is per-entry: drop the ssh entry, add a git entry with a Name +
full Upstream URL + IdentityFile.
2026-05-12 23:41:09 -04:00
didericis 102e29ee77 feat(git-gate): plumb ExtraHosts through to docker --add-host
GitGateUpstream carries each entry's extra_hosts; a new
git_gate_aggregate_extra_hosts() merges them into one map for the
gate container's /etc/hosts. Same host -> same IP is harmless
duplication; same host -> different IPs is a manifest bug
(/etc/hosts is per-container, not per-upstream) and dies with
the conflicting upstream names.

DockerGitGate.start passes one --add-host host:ip per merged
entry on docker create. Empty map (the default) emits no flags
and is a no-op for bottles that don't need DNS overrides.
2026-05-12 23:18:46 -04:00
didericis 4c6610e222 feat(manifest): add ExtraHosts to bottle.git entries
Optional `ExtraHosts: { hostname: ip }` map per git entry. The
docker backend will surface these to the gate sidecar via
--add-host so the gate can resolve upstreams whose default
container DNS doesn't point at the reachable IP (e.g.
Tailscale-only hosts with a public DNS A record pointed
elsewhere). The agent-side insteadOf rewrite still keys off
the original hostname, so the manifest's Upstream URL stays
human-readable.
2026-05-12 23:18:46 -04:00
didericis f9d9e9cf33 test(git-gate): bidirectional mirror round-trip
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 34s
A pair of integration tests against a real sshd-based "upstream"
sibling container that prove every operation through the gate is
observably equivalent to the same operation against the upstream:

  - test_clone_and_refetch_reflect_upstream: clone via gate
    returns the upstream's current commit; an out-of-band commit
    on the upstream shows up via the gate on the next ls-remote.
  - test_push_through_gate_lands_on_upstream: a clean push routed
    through the gate lands on the upstream's bare repo.

The upstream container is a tiny inline-built alpine image with
openssh-server, a `git` user (passwd -u so sshd doesn't reject
the locked account), and a baked bare repo seeded with one
commit. Host keys are baked in at build so the test can pin
KnownHostKey on the manifest entry before the container starts.

While wiring this up the access-hook gained a one-shot HEAD
sync: `git init --bare` defaults HEAD to refs/heads/master, and
upstreams that use main would leave the bare repo's HEAD
unresolvable — clones came through but the working tree was
empty. The hook now does a `rev-parse --verify HEAD` check
after the first fetch and runs `ls-remote --symref` to repoint
HEAD if it doesn't resolve. One extra round-trip on first
fetch only.
2026-05-12 22:34:38 -04:00
didericis 824527497c feat(git-gate): rewrite both fetch and push via insteadOf
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 16s
The agent's ~/.gitconfig now uses insteadOf (not pushInsteadOf),
so every git operation against a declared upstream — push, fetch,
clone, pull, ls-remote — routes through the gate. Matches the
gate's now-bidirectional design: fetch is mirrored via the
access-hook, push is gated via gitleaks.
2026-05-12 21:38:44 -04:00
didericis fdd06c54d2 feat(git-gate): mirror fetch through access-hook (bidirectional)
test / unit (pull_request) Successful in 11s
test / integration (pull_request) Successful in 14s
The gate is now a transparent mirror, not push-only. Per-repo
init now runs `git remote add --mirror=fetch origin <url>` so a
later `git fetch origin` mirrors the upstream's full ref graph at
canonical paths. The pre-receive hook forwards accepted refs via
`git push origin` (renamed from upstream).

New: an access-hook script wired via `git daemon --access-hook`
runs `git fetch origin --prune` against the real upstream before
every upload-pack request (clone, fetch, pull, ls-remote). On
upstream error the hook exits non-zero — the agent's fetch fails
rather than the gate serving stale data.

The pre-existing smoke test (ls-remote against unreachable
upstream returns refs) had to invert: under the bidirectional
design any ls-remote success is necessarily a success against
the upstream, so the unreachable-upstream case now correctly
fails closed.
2026-05-12 21:37:04 -04:00
didericis f787edb861 feat(git-gate): wire DockerGitGate through prepare/launch/plan
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 14s
DockerBottleBackend now instantiates a DockerGitGate alongside
DockerPipelockProxy and DockerSSHGate; the prepare step lifts
bottle.git into a GitGatePlan stored on DockerBottlePlan, and
launch starts/stops the sidecar in the same ExitStack as the
other two (only when bottle.git is non-empty).

bottle_plan.print now surfaces git remotes and per-upstream gate
forwards in the y/N preflight; to_dict adds git_remotes and
git_gate keys to the dry-run JSON payload for CLI consumers.
PRD: docs/prds/0008-git-gate.md
2026-05-12 21:06:08 -04:00
didericis 509b1b61e2 feat(git-gate): provision ~/.gitconfig pushInsteadOf in the bottle
test / unit (pull_request) Successful in 16s
test / integration (pull_request) Successful in 14s
provision_git now does two things: copy the host cwd's .git (when
--cwd is set, existing behavior) and write ~/.gitconfig with
pushInsteadOf rules for each bottle.git entry. A 'git push <real
upstream URL>' from inside the agent transparently rewrites to
'git://<gate>/<name>.git' so the gate gets first crack at the
incoming refs.

pushInsteadOf (not insteadOf) keeps fetch on the original URL —
v1 of the git-gate is push-only scope per PRD 0008. The render
helper is exposed for testing without docker.
2026-05-12 21:01:00 -04:00
didericis 2d955a5512 feat(git-gate): add DockerGitGate sidecar lifecycle + image
test / unit (pull_request) Successful in 16s
test / integration (pull_request) Successful in 15s
Dockerfile.git-gate builds a small alpine image with git,
openssh-client, and gitleaks; the directory layout the entrypoint
and per-upstream cp's expect is pre-created in the image so docker
cp can target paths beneath /etc/git-gate and /git-gate/creds at
container-create time (cp doesn't create intermediate dirs).

DockerGitGate.start mirrors DockerSSHGate's shape: build, create,
cp the rendered entrypoint + hook + per-upstream identity files
(plus a known_hosts file synthesized from KnownHostKey when set),
attach the egress network, start. build_image gains an optional
dockerfile= argument so the gate can build from its own
Dockerfile in the shared context.

PRD: docs/prds/0008-git-gate.md
2026-05-12 20:58:51 -04:00
didericis 2fb90f2087 feat(git-gate): add platform-agnostic GitGate abstraction
test / unit (pull_request) Successful in 19s
test / integration (pull_request) Successful in 17s
Mirrors the SSHGate/PipelockProxy shape: a host-side prepare that
lifts bottle.git into a tuple of GitGateUpstreams and renders two
shell scripts under stage_dir — the gate's entrypoint (which
initializes a bare repo per upstream and execs git daemon
--enable=receive-pack) and the shared pre-receive hook
(gitleaks-scan, then forward each accepted ref to the real
upstream using the per-repo credential).

Failure in either hook phase aborts the push so the agent sees a
real rejection, not a silent success. KnownHostKey absence is
fail-closed: the hook refuses to forward without a pinned key
rather than TOFU-trusting the upstream from inside the gate.

PRD: docs/prds/0008-git-gate.md
2026-05-12 20:54:38 -04:00
didericis 5c5e9f817e feat(manifest): add bottle.git field for git-gate upstreams
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 15s
Each entry pairs a Name (local alias the gate exposes) with an
ssh:// Upstream URL, an IdentityFile the gate uses to push to
that upstream, and an optional KnownHostKey for upstream
host-key pinning. The Upstream URL is parsed at construction
into UpstreamUser/Host/Port/Path so downstream code doesn't
re-parse.

Two cross-validation rules: Names must be unique within a
bottle (each maps to a distinct bare repo), and no git entry's
(host, port) may overlap an ssh entry's (Hostname, Port) — the
same upstream reachable two ways would let a misbehaving agent
route around the gitleaks-bearing git-gate via the L4 ssh-gate.

PRD: docs/prds/0008-git-gate.md
2026-05-12 18:48:14 -04:00
didericis 4f0cd0f782 fix(pipelock): passthrough api.anthropic.com so Claude auth/chat works
test / unit (push) Successful in 15s
test / integration (push) Successful in 15s
Pipelock's BIP-39 seed-phrase scanner fires on Anthropic Messages API
bodies because user-authored conversation text can hit 12 consecutive
BIP-39 dictionary words that pass the checksum, returning a 403
`blocked: request body contains secret: BIP-39 Seed Phrase` that the
Claude CLI surfaces as `Please run /login`. Pipelock's `suppress`
section only covers git/file findings, not the inline body scanner,
so the recommended treatment for LLM endpoints is
`tls_interception.passthrough_domains`: CONNECT is still allowlist-
gated, but the body is not MITM'd. The existing body-scan integration
test moves to `raw.githubusercontent.com` so it still pins TLS body
DLP on non-passthrough'd hosts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 17:55:05 -04:00
didericis a3d77cd015 fix(ssh-gate): listen on the upstream port so URL-supplied ports work
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 12s
Bug: git fetch failed with "connect to host
claude-bottle-ssh-gate-implementer port 30009: Connection refused".
OpenSSH treats a URL-supplied port (the user's remote was
ssh://git@gitea.dideric.is:30009/...) as overriding the
~/.ssh/config Port directive, so even though the config wrote
Port 30000 the agent dialed :30009 — where nothing was listening
because the gate had been assigned BASE_LISTEN_PORT + index.

Fix: the gate's listen port now equals the upstream port. Same
script, same socat, just port = entry.Port. Two entries on the
same upstream port are rejected at prepare time (the gate is one
container with a flat port space).

Re-smoked: probe nc github.com via the gate at :22, banner came
back as expected.

PRD 0007 updated to record the design refinement.
2026-05-12 16:19:07 -04:00
didericis 6130ea385f refactor(pipelock): drop bottle.ssh carve-outs
PRD 0007: SSH traffic now flows through the per-agent ssh-gate
sidecar, so pipelock should know nothing about bottle.ssh.

Removed:
- pipelock_bottle_ssh_hostnames, _trusted_domains, _ip_cidrs.
- The trusted_domains / ssrf blocks built from ssh entries.
- pipelock_proxy_host_port — its last caller (the ssh provisioner)
  is gone.
- is_ipv4_literal — only used to classify ssh hostnames into
  trusted_domains vs ssrf.ip_allowlist, both of which are gone.

api_allowlist now derives solely from baked-in defaults +
bottle.egress.allowlist. Tests updated to pin the new shape and
assert ssh hostnames do NOT leak into pipelock's config.
2026-05-12 16:08:26 -04:00
didericis ce948db0b7 feat(ssh-gate): retarget ssh provisioner at the new gate
PRD 0007: stop tunneling ssh through pipelock. Each Host block in
the agent's ~/.ssh/config now points at the gate container + the
per-entry listen port; HostKeyAlias preserves host-key validation
against the real upstream name, and CheckHostIP=no skips the
resolved-IP path (which would otherwise hit the gate's IP).
known_hosts collapses to a single entry per upstream keyed on the
alias.

The pipelock_proxy_host_port import is gone from this module; the
function itself becomes dead code and gets removed alongside the
broader pipelock SSH carve-outs in the next commit.
2026-05-12 16:05:22 -04:00
didericis 2533f8a00b feat(ssh-gate): wire gate into DockerBottlePlan, prepare, launch
PRD 0007: thread the DockerSSHGate through the bottle lifecycle.

- DockerBottlePlan gains gate_plan: SSHGatePlan.
- prepare.resolve_plan accepts a gate and renders its entrypoint
  script next to the pipelock yaml.
- launch.launch starts the gate sidecar after pipelock (so it's on
  the same internal + egress networks) and registers its stop in
  the ExitStack. Skipped when the bottle has no ssh entries.
- DockerBottleBackend instantiates DockerSSHGate alongside the
  pipelock proxy.
- bottle_plan.print + to_dict surface the upstream table so
  --dry-run shows the per-host listen-port mapping.

ssh_config provisioning still points at pipelock; that swap lands
in the next commit so this one stays a pure wiring change.
2026-05-12 16:03:55 -04:00
didericis c05d1ddcdb feat(ssh-gate): add DockerSSHGate sidecar lifecycle
PRD 0007: Docker-specific start/stop for the SSH egress gate.
Mirrors DockerPipelockProxy: docker create on the internal
network with /bin/sh entrypoint, docker cp the staged entrypoint
script in, attach to the egress network, docker start. Image is
alpine/socat pinned by digest — self-sufficient at boot so the
gate's agent-facing leg can stay on the --internal network.

Not yet wired into the bottle launch path; that lands next.
2026-05-12 15:57:56 -04:00
didericis f7fb691626 feat(ssh-gate): add abstract SSHGate + plan dataclass
First piece of PRD 0007: the per-agent SSH egress gate that will
let pipelock stop seeing SSH traffic. This commit only lands the
backend-agnostic surface — the SSHGate ABC, SSHGatePlan, the
listen-port assignment (BASE_LISTEN_PORT + index), and the
entrypoint-script renderer. Backend wiring lands in follow-up
commits.
2026-05-12 15:56:52 -04:00
didericis fb10c8dd8a feat(bottle-plan): render TLS interception in the dry-run preflight
Third step of PRD 0006. The preflight now surfaces the TLS-
intercept layer so the operator sees it before agreeing to launch.

- Text output: one new line under the egress summary
  ("tls intercept : pipelock (per-bottle ephemeral CA, generated
  at launch)").
- JSON output (--format=json contract): new
  egress.tls_interception: { enabled: true, ca_fingerprint: null }
  block. Fingerprint is always null at dry-run because the CA
  only exists after launch; real launches print it as a stderr
  log line from provision_ca.
- Pin the new shape in the dry-run integration test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:52:53 -04:00
didericis 86a9b499bc feat(provision): install pipelock CA into the agent + add curl
test / unit (pull_request) Successful in 16s
test / integration (pull_request) Successful in 15s
Second step of PRD 0006. With pipelock now doing the bumping, the
agent's TLS library has to trust pipelock's per-bottle CA — or
every CONNECT to api.anthropic.com is a self-signed-cert error.

- BottleBackend.provision gains a non-abstract `provision_ca`
  with a default no-op (so non-Docker backends aren't forced to
  implement TLS interception) and orchestrates
  ca → prompt → skills → ssh → git. CA install runs first so the
  agent's trust store is rebuilt before anything else in the
  agent makes a TLS call.

- New backend/docker/provision/ca.py: docker-cp's the CA cert
  into the agent at /usr/local/share/ca-certificates/...,
  `update-ca-certificates`, then emits a one-line stderr log
  with the SHA-256 fingerprint (stdlib `ssl` + `hashlib`; no
  subprocess for crypto). Module-level constants AGENT_CA_PATH
  and AGENT_CA_BUNDLE are imported by launch.py so the env
  trio set at docker run time matches the paths the provisioner
  writes.

- launch.py: rebinds `plan` after `dataclasses.replace`s on the
  pipelock proxy plan so provision_ca (which reads
  `plan.proxy_plan.ca_cert_host_path`) sees the populated CA
  paths. Three new -e flags on the agent's docker run for the
  NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / REQUESTS_CA_BUNDLE trio.

- Dockerfile: adds curl to the apt-get install line. curl
  natively respects HTTPS_PROXY and sends CONNECT directly —
  the agent doesn't need OS-level DNS for external hostnames
  (pipelock resolves them on its side of the bumped tunnel).
  This is the "simple HTTPS request" path the earlier turn
  needed and Node's stdlib https.request couldn't provide.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:50:20 -04:00
didericis 3755e66abe feat(pipelock): enable tls_interception with per-bottle ephemeral CA
First step of PRD 0006. Pipelock now does the CONNECT bumping that
PR #8's mitmproxy chain was supposed to provide — natively, in the
same single sidecar PRD 0001 wired up.

- claude_bottle/pipelock.py: pipelock_build_config grows optional
  ca_cert_path / ca_key_path kwargs. When both are passed the
  rendered YAML carries a `tls_interception: { enabled: true,
  ca_cert, ca_key }` block. PipelockProxy gains class-level
  CA_CERT_IN_CONTAINER / CA_KEY_IN_CONTAINER constants that
  subclasses set to wherever they place the CA inside the
  sidecar. PipelockProxyPlan gains ca_cert_host_path /
  ca_key_host_path fields (default empty Path() — sentinel for
  "not yet populated", filled by launch via dataclasses.replace).

- claude_bottle/backend/docker/pipelock.py: new
  pipelock_tls_init(stage_dir) helper runs `pipelock tls init`
  in a one-shot container against a host-mounted scratch dir.
  DockerPipelockProxy sets its class constants to
  /etc/pipelock-ca.pem and /etc/pipelock-ca-key.pem; .start
  docker-cp's the cert + key into those paths between
  `docker create` and `docker start`. Pipelock runs as root in
  its distroless image, so no chown is needed (verified).

- claude_bottle/backend/docker/launch.py: calls pipelock_tls_init
  between network creation and proxy.start. Prepare stays
  side-effect-free on docker; the one-shot ca-init container
  only runs on a real launch, not on `start --dry-run`.

- tests/unit/test_pipelock_yaml.py: new assertions that
  pipelock_build_config emits the tls_interception block only
  when both paths are supplied (and rejects a half-set pair),
  plus a test that the docker proxy's prepare plumbs the
  in-container paths through to the rendered YAML.

The end-to-end "bumping actually fires" assertion lands in
chunk 4 (HTTPS integration tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:45:36 -04:00
didericis 427ef96e3f feat(pipelock): enforce DLP body-scan hits by default
test / unit (push) Successful in 19s
test / integration (push) Failing after 21s
Adds bottle.egress.dlp_action ("block" | "warn", default block) and
wires it into pipelock as request_body_scanning.action. Pipelock's
own default is "warn", which previously meant claude-bottle detected
credential patterns in outbound bodies but forwarded the request
anyway.

The matching integration test posts a manifest env var shaped like
a GitHub PAT to api.anthropic.com via plain HTTP forward proxy so
pipelock can see the body. Pipelock answers 403 from its body-scan
layer instead of forwarding to the upstream.

Behavior change: bottles without an explicit egress.dlp_action now
block on body-scan hits. Set egress.dlp_action: "warn" to restore
the prior detect-only behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 11:39:25 -04:00
didericis 4864516b33 feat(bottle): add exec method to the bottle abstraction
test / unit (push) Successful in 11s
test / integration (push) Failing after 12s
Bottle.exec(script) -> ExecResult runs a POSIX shell script inside a
running bottle and returns captured stdout/stderr/returncode. The
Docker impl pipes the script via stdin to `docker exec -i ... sh -s`
so the source never crosses argv.

Two integration tests exercise it end-to-end through the pipelock
sidecar: a Node request to a non-allowlisted host (example.com)
returns 403 from pipelock; a Node CONNECT to an allowlisted host
(raw.githubusercontent.com) is tunneled with 200 Connection
Established. The 200/403 split on each verb is decided by pipelock
itself, isolating the allowlist decision from whatever the remote
might return.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 11:18:43 -04:00
didericis 5da2b47f72 refactor(docker): move force_remove_container into the docker util module
test / unit (push) Successful in 11s
test / integration (push) Failing after 11s
The helper is a thin subprocess wrapper over `container_exists` +
`docker rm -f`, so it belongs alongside the other docker primitives
in util.py rather than as a private in launch.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 10:58:05 -04:00
didericis 1546acad00 refactor(docker): split backend.py into prepare / launch / cleanup
test / unit (push) Successful in 11s
test / integration (push) Failing after 12s
Move the resolution, bring-up, and orphan-cleanup logic out of
backend.py into three topic-named modules. DockerBottleBackend becomes
a thin façade that wires the per-instance pipelock proxy and the
provision orchestrator into the free functions.

backend.py drops from ~360 to ~70 lines and each topic now reads
end-to-end in one place. Mirrors the existing provision/ split.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 10:56:22 -04:00
didericis 339d40f8c9 refactor(backend): lift host-side validation onto the base class
test / unit (push) Successful in 12s
test / integration (push) Failing after 10s
Make BottleBackend.prepare a template method that runs a cross-backend
_validate step (agent exists, named skills present on host, SSH
IdentityFiles resolve) and then delegates to a subclass-implemented
_resolve_plan for backend-specific resolution.

A future backend that overrides _resolve_plan can no longer forget to
validate skills or SSH keys; the validation runs unconditionally via
prepare. Backends with additional preconditions can override _validate
and chain via super().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 10:51:19 -04:00
didericis a23e89ef48 refactor(docker): make pipelock proxy a per-instance attribute
test / unit (push) Successful in 13s
test / integration (push) Failing after 12s
Avoids cross-instance state via class attribute; the proxy is now
constructed in __init__ alongside its owning backend.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 10:46:38 -04:00
didericis 5f29fd10e2 refactor(env): stop mutating os.environ in resolve_env
test / unit (push) Successful in 14s
test / integration (push) Failing after 13s
ResolvedEnv.forwarded now carries name->value pairs instead of names
whose values had been side-loaded into os.environ. The Docker backend
collects the dict (plus the renamed OAuth token) and passes it via
subprocess.run(env=...) so docker run -e NAME forwards by-name from
the child's environment, not the parent's.

Values are excluded from the dataclass repr (forwarded on ResolvedEnv,
forwarded_env on DockerBottlePlan) so accidental logging cannot leak
secret or interpolated values.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 10:37:01 -04:00
didericis 95a14bb8d2 style: pass explicit check= to every subprocess.run call
test / unit (push) Successful in 11s
test / integration (push) Failing after 11s
Silences pylint W1510 / ruff PLW1510 across the codebase. The choice
at each site reflects existing intent:

- check=True where the caller implicitly trusts success (docker ps /
  network ls returning stdout, docker build, exec chown/chmod inside
  provisioners).
- check=False where the caller inspects .returncode (race-retry on
  docker run, pipelock sidecar lifecycle, network plumbing, exec_claude
  propagating the session's exit code, best-effort cleanup paths).

No behavior change; check= defaults to False so the False sites are
semantically identical.
2026-05-12 10:13:56 -04:00
didericis 64a31a382b chore(types): add pyright strict config and fix resulting errors
test / unit (push) Successful in 11s
test / integration (push) Successful in 12s
Adds pyrightconfig.json (strict, Python 3.11) covering cli.py,
claude_bottle/, and tests/. Fixes the 49 strict-mode errors:

- Type DockerBottle.teardown as Callable[[], None].
- ResolvedEnv default_factory uses parameterized list[str] / dict[str, str].
- Erase BottleBackend generics at the registry boundary
  (BottleBackend[Any, Any]) since selection is runtime-driven and
  callers use the unparameterized interface.
- DockerBottleBackend.launch returns Generator[DockerBottle, None, None];
  @contextmanager now flags Iterator returns as deprecated.
- Sidestep cli.list submodule shadowing builtins.list in main()'s argv
  annotation via an aliased re-import in cli/__init__.py.
- Cast cfg[...] results in test_pipelock_yaml at the dict[str, object]
  boundary.
- Annotate write_fixture's fn parameter and _manifest_with_runtime's
  return type.
2026-05-12 10:03:48 -04:00
didericis ac634edcb6 refactor(docker): keep prepare side-effect-free, rename token in child env
test / unit (push) Successful in 16s
test / integration (push) Successful in 16s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 09:48:38 -04:00
didericis 250954c17d refactor(docker): share derive step between print and to_dict
test / unit (push) Successful in 11s
test / integration (push) Successful in 12s
DockerBottlePlan.print and .to_dict each pulled the same agent /
bottle / env_names / ssh_hosts / prompt-first-line out of the spec
before formatting. Extract a private _view() helper that returns a
small frozen _PlanView dataclass with those derived fields; both
methods consume it. Removes the duplicated derivation and the risk
that one renderer drifts from the other (the OAuth-name append in
particular existed twice).
2026-05-11 20:09:42 -04:00
didericis 62d2e36e5c refactor(docker): hand forwarded env names through the plan, not a file
Previously prepare wrote two on-disk artifacts that launch consumed:
agent.env (NAME=VALUE) and docker-args (paired -e\nNAME\n lines), with
launch parsing the second back into argv. Docker requires the literals
file on disk for --env-file, but the args-file round-trip was a pure
serialize/deserialize trip with hand-rolled line pairing logic.

Drop docker-args entirely. Pass forwarded names as a structured
tuple[str, ...] field on DockerBottlePlan; launch iterates it directly
to extend docker_args. _write_env_files becomes _write_env_file (only
the literals file remains).
2026-05-11 20:08:02 -04:00
didericis 42c2e8108e refactor(docker): share container-name candidate iterator
Both prepare-time probing and launch-time race-retry generated the
same `<base>, <base>-2, ..., <base>-N` sequence with their own copies
of the suffix arithmetic and the 99-cap. Extract the candidate stream
into docker/util.container_name_candidates and have both call sites
walk it; each keeps its own predicate (probe vs. retry).

Also bumps the cap into a named constant (MAX_CONTAINER_SUFFIX) so
the two error messages can't drift.
2026-05-11 20:06:09 -04:00
didericis c63d8e0f9d refactor(docker): forward OAuth token through resolved env
Previously _run_agent_container set os.environ["CLAUDE_CODE_OAUTH_TOKEN"]
deep inside the launch path and added a one-off `-e` pair to docker_args,
which was the only env var to bypass the resolved.forwarded flow used
for everything else.

Move the os.environ mutation + name registration into prepare, right
after resolve_env, so the OAuth token rides the same forwarded-by-name
mechanism as secrets and interpolated entries. _run_agent_container
loses the special case entirely.
2026-05-11 20:04:28 -04:00
didericis cbafbbec5a refactor(backend): make BottleBackend generic over its plan types
test / unit (push) Successful in 12s
test / integration (push) Successful in 12s
Parameterize BottleBackend over PlanT (bound to BottlePlan) and
CleanupT (bound to BottleCleanupPlan). DockerBottleBackend declares
itself BottleBackend[DockerBottlePlan, DockerBottleCleanupPlan], which
narrows every method's plan parameter to the concrete type and lets
the six `assert isinstance(plan, DockerBottlePlan)` lines on
launch/cleanup/provision_* go away.

The dict in get_bottle_backend keeps its unparameterized
BottleBackend element type so it can hold heterogeneous backend
specializations.
2026-05-11 20:02:56 -04:00
didericis 4fc0707760 refactor(docker): use ExitStack for launch teardown
Replace the manual state-dict + per-resource branching teardown in
DockerBottleBackend.launch with an ExitStack: each resource registers
its own cleanup callback at the moment it's created, and stack.close()
unwinds in LIFO order. The previous form had to hand-coordinate four
nullable slots and re-check existence for the container; ExitStack
encodes the same semantics declaratively.
2026-05-11 19:58:57 -04:00
didericis d12efc8ccf refactor(docker): move provision_git into provision/git.py
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 14s
2026-05-11 19:44:11 -04:00
didericis 52bb007b9e refactor(docker): move provision_ssh into provision/ssh.py 2026-05-11 19:43:12 -04:00
didericis 36d3e7f739 refactor(docker): move provision_skills into provision/skills.py 2026-05-11 19:41:32 -04:00
didericis 1b17b36988 refactor(docker): move provision_prompt into provision/prompt.py 2026-05-11 19:40:51 -04:00
didericis f943e14891 refactor(pipelock): take stage_dir, derive yaml_path internally
test / unit (pull_request) Successful in 11s
test / integration (pull_request) Failing after 12s
PipelockProxy.prepare now accepts (bottle, slug, stage_dir) and derives
the yaml_path itself, so callers don't need to know the filename.
DockerBottleBackend.prepare_proxy becomes a one-line wrapper whose only
caller already has bottle and slug in scope, so it's inlined and
deleted.
2026-05-11 16:50:22 -04:00
didericis beb0c9d58f feat(cli): add --format=json to start --dry-run for machine-readable plan
BottlePlan gains a to_dict method (abstract on the base, implemented
on DockerBottlePlan) returning a JSON-serializable view of the resolved
plan. `cli.py start --dry-run --format=json` prints it to stdout and
exits zero. --format=json without --dry-run is rejected — emitting JSON
during a real launch would race the y/N prompt.

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:23:12 -04:00
didericis 656dc88d76 refactor(env): make env resolution backend-agnostic
test / run tests/run_tests.py (pull_request) Successful in 14s
resolve_env_into(...) becomes resolve_env(manifest, agent) -> ResolvedEnv
(forwarded names + literals). The docker backend now owns env-file /
argv serialization and the --env-file newline check. Also drops stray
Docker references from manifest.py, pipelock.py, util.py, and trims
the duplicated command list from cli.py's docstring (usage() in
claude_bottle/cli/__init__.py is now the only listing).
2026-05-11 14:39:44 -04:00
didericis 988c0bdad3 refactor(env): rename env_resolve.py -> env.py; env_resolve() -> resolve_env_into()
test / run tests/run_tests.py (pull_request) Successful in 13s
Module name aligns with the others (manifest, pipelock, network,
log) — nouns/noun-y, not verb phrases. The function name now reads
naturally at the call site: resolve_env_into(manifest, agent,
env_file, args_file).
2026-05-11 14:17:50 -04:00
didericis a786ca3391 refactor(util): split private helpers off DockerBottleBackend
test / run tests/run_tests.py (pull_request) Successful in 14s
New file claude_bottle/backend/util.py for cross-backend host-side
helpers:
  host_skill_dir(name) — resolves $HOME/.claude/skills/<name>

docker/util.py gains:
  docker_exec_root(container, argv) — `docker exec -u 0` wrapper used
    by SSH provisioning

DockerBottleBackend drops the two methods that wrapped these
(`_host_skill_dir`, `_docker_exec_root`) — they had no instance state
and just lived on the class for organizational reasons. Call sites
now use the imported functions directly.
2026-05-11 14:09:55 -04:00
didericis 1269edf311 refactor(pipelock): PipelockProxy.prepare takes a Bottle, not (manifest, name)
test / run tests/run_tests.py (pull_request) Successful in 14s
Matches the allowlist-resolution helpers' shape: the caller resolves
the bottle once and passes it in. Signature drops from
(manifest, bottle_name, slug, yaml_path) to (bottle, slug, yaml_path).

DockerBottleBackend.prepare_proxy uses manifest.bottle_for(agent_name)
to get the bottle directly. Tests pass fixture.bottles[name].

prepare's docstring also explains what `slug` is: the lowercased,
hyphen-normalized agent identifier used as the suffix in every
per-agent resource name (agent container, pipelock container, the
internal/egress networks). It's stored on the plan so start can
derive the sidecar's container name.

Top-level pipelock.py drops the Manifest import — no longer used.
2026-05-11 14:05:48 -04:00