Commit Graph

13 Commits

Author SHA1 Message Date
didericis 8334f51268 feat(cred_proxy): wire DockerCredProxy through backend (PRD 0010)
- DockerBottleBackend instantiates DockerCredProxy alongside pipelock
  and git-gate; threads it through prepare and launch.
- DockerBottlePlan gains cred_proxy_plan; preflight rendering shows
  the declared kinds + TokenRefs and to_dict emits a cred_proxy
  array matching the routing table.
- prepare.py: when bottle.tokens has an anthropic entry, route the
  agent at the proxy via ANTHROPIC_BASE_URL, drop the agent-side
  CLAUDE_CODE_OAUTH_TOKEN forward (the token goes to the sidecar's
  environ instead, set a non-secret placeholder so claude-code's
  startup check passes), and default the telemetry-off env vars.
- launch.py: bring up the cred-proxy sidecar in ExitStack before the
  agent container so DNS resolution for `cred-proxy` succeeds on the
  agent's first call.
- backend/__init__.py: add provision_cred_proxy to the provision
  template (runs after provision_git so it can append to ~/.gitconfig).
- bottle_plan _view: env_names is derived from the forwarded_env dict,
  so the preflight reflects the PRD 0010 switch without ad-hoc
  branching on spec.forward_oauth_token.
2026-05-13 16:20:42 -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 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 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 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 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 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 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 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 054dc09b38 refactor(backend): make provision_* abstract; provision lives on the base
test / run tests/run_tests.py (pull_request) Successful in 14s
Template Method pattern. BottleBackend.provision is now concrete and
orchestrates four abstract sub-methods:

  provision_prompt  -> str | None    (only one with a meaningful return)
  provision_skills  -> None
  provision_ssh     -> None
  provision_git     -> None

Each is self-gating: skills/ssh/git short-circuit on empty inputs;
prompt always copies (the path must exist) and returns None when the
agent has no prompt content.

DockerBottleBackend drops its own `provision` (inherited from the
base) and now just implements the four sub-methods. Each sub-method
takes `plan: BottlePlan` (matching the abstract) and asserts
isinstance to narrow to DockerBottlePlan internally, same pattern as
`launch`.

A future fly.io backend implements the four sub-methods; provision
works for it unchanged.
2026-05-11 00:31:36 -04:00
didericis 133a7a39e7 refactor(backend): fold BottleProvisioner back into BottleBackend
test / run tests/run_tests.py (pull_request) Successful in 14s
BottleProvisioner had no independent identity — no state, only one
caller, never selected, never crossed a method boundary as data. It
was a method dressed up as a class. Reverting that turn:

  - BottleBackend gains an abstract provision(plan, target).
  - DockerBottleBackend.provision absorbs the body that lived on
    DockerBottleProvisioner.
  - backend/docker/provisioner.py deleted.
  - BottleProvisioner ABC removed from backend/__init__.py.
  - launch now calls self.provision(plan, container) directly.

Net: -1 file, -1 class, -1 ABC. Same behavior; tests pass.
2026-05-11 00:13:36 -04:00
didericis 7b5a798186 refactor(backend): introduce BottleProvisioner ABC + DockerBottleProvisioner
test / run tests/run_tests.py (pull_request) Successful in 17s
Lift the file-copying-into-the-running-container step out of
DockerBottleBackend._provision_container into its own class. The
backend now holds a DockerBottleProvisioner instance and delegates
the post-launch provisioning to it.

  - BottleProvisioner (abstract) in backend/__init__.py with a
    `provision(plan, target) -> str | None` method.
  - DockerBottleProvisioner (concrete) in backend/docker/provisioner.py
    inheriting from the base, narrowing plan to DockerBottlePlan via
    isinstance, and carrying the prompt/skills/SSH/.git copy logic
    unchanged.
  - DockerBottleBackend keeps a class-level DockerBottleProvisioner()
    and calls self._provisioner.provision(plan, container) from launch.
    _provision_container method removed.

No behavior change.
2026-05-11 00:04:12 -04:00
didericis 70a22fa210 refactor: rename platform abstraction to backend
test / run tests/run_tests.py (pull_request) Successful in 21s
Across the package:
  - claude_bottle/platform/         -> claude_bottle/backend/
  - platform/docker/platform.py     -> backend/docker/backend.py
  - class BottlePlatform            -> BottleBackend
  - class DockerBottlePlatform      -> DockerBottleBackend
  - get_bottle_platform()           -> get_bottle_backend()
  - env var CLAUDE_BOTTLE_PLATFORM  -> CLAUDE_BOTTLE_BACKEND
  - dict _PLATFORMS                 -> _BACKENDS

"Backend" is shorter and more established as the term for a
pluggable strategy-pattern implementation. "Platform" was vague
(could mean OS, hardware, cloud) and mildly redundant — Docker is
itself a platform.

The previous PRD section claiming "the Backend protocol was
rejected" referred to a low-level run/exec/cp/network_connect
protocol; the name was never the reason. The PRD is updated to
describe that rejected design by shape rather than by name.

The bottle/agent concepts and the manifest schema are unchanged.
2026-05-10 23:59:38 -04:00