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>
This commit is contained in:
2026-05-12 11:18:43 -04:00
parent 5da2b47f72
commit 4864516b33
4 changed files with 269 additions and 3 deletions
+25 -2
View File
@@ -94,12 +94,26 @@ class BottleCleanupPlan(ABC):
short-circuit before showing the y/N."""
@dataclass(frozen=True)
class ExecResult:
"""Captured result of `Bottle.exec`. Backend-neutral: the Docker
impl populates it from a `subprocess.CompletedProcess`, but a
future fly/smolmachines backend could populate it from any source
that produces a returncode + captured streams."""
returncode: int
stdout: str
stderr: str
class Bottle(ABC):
"""Handle to a running bottle. Yielded by a backend's launch step.
`exec_claude` runs `claude` inside the bottle and blocks until the
session ends. `cp_in` copies a host path into the bottle. `close`
is an idempotent alias for context-manager teardown.
session ends. `exec` runs a POSIX shell script inside the bottle
and returns the captured result. `cp_in` copies a host path into
the bottle. `close` is an idempotent alias for context-manager
teardown.
"""
name: str
@@ -107,6 +121,14 @@ class Bottle(ABC):
@abstractmethod
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ...
@abstractmethod
def exec(self, script: str) -> ExecResult:
"""Run `script` as a POSIX shell script inside the bottle and
return the captured stdout/stderr/returncode. The bottle's
environment (including HTTPS_PROXY pointing at the pipelock
sidecar) is inherited by the child. Non-zero exit does not
raise — callers inspect `returncode` themselves."""
@abstractmethod
def cp_in(self, host_path: str, container_path: str) -> None: ...
@@ -270,5 +292,6 @@ __all__ = [
"BottleCleanupPlan",
"BottlePlan",
"BottleSpec",
"ExecResult",
"get_bottle_backend",
]
+18 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import subprocess
from typing import Callable
from .. import Bottle
from .. import Bottle, ExecResult
class DockerBottle(Bottle):
@@ -38,6 +38,23 @@ class DockerBottle(Bottle):
cmd.extend([self.name, "claude", *full_argv])
return subprocess.run(cmd, check=False).returncode
def exec(self, script: str) -> ExecResult:
# Pipe via stdin to `sh -s` so the caller never has to worry
# about quoting; the script source lands inside the container
# without crossing argv.
result = subprocess.run(
["docker", "exec", "-i", self.name, "sh", "-s"],
input=script,
capture_output=True,
text=True,
check=False,
)
return ExecResult(
returncode=result.returncode,
stdout=result.stdout,
stderr=result.stderr,
)
def cp_in(self, host_path: str, container_path: str) -> None:
subprocess.run(
["docker", "cp", host_path, f"{self.name}:{container_path}"],