Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ resolves each in a separate fork.
- `E2E_ANTHROPIC_MODEL`: Anthropic model for E2E tests (legacy E2E default: `claude-3-haiku-20240307`; baseline toolkit default: `claude-haiku-4-5` — the baseline judge uses structured outputs, which `claude-3-haiku-20240307` does not support)
- `E2E_JUDGE_MODEL`: Anthropic model for the baseline LLM judge (default: falls back to `E2E_ANTHROPIC_MODEL`; must support structured outputs)
- `E2E_TIMEOUT`: Per-turn response timeout in seconds for E2E tests (default: `120`; a slow test can add headroom with `@pytest.mark.timeout(extra=n)`)
- `DOCKER_TESTS_ENABLED`: Set to `true` to run `docker_build`-marked tests (e.g. `tests/docker/test_band_python_kit.py`), which shell out to a real `docker build`/`docker run` (default: disabled everywhere, including CI — CI runners do have a Docker daemon, unlike the nested-virtualization `sbx` tests, so this needs the same explicit opt-in as `E2E_TESTS_ENABLED` rather than a plain Docker-availability check)

Baseline lane scoping (see `tests/e2e/baseline/README.md`):

Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ With `pip`, use the same package spec:
pip install "band-sdk[langgraph]"
```

### Docker Sandboxes

Building an agent for [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/)?
The repository includes a `band-python-kit` base image with the Band SDK in an
isolated, read-only virtual environment, automatic sandbox proxy-CA trust
wiring, and support for both arm64 and x86_64.

See the [band-python-kit image guide](docker/band_python_kit/README.md) for
build options, framework extras, image layout, and direct Docker usage. The
declarative kit and published GHCR image are separate release deliverables.

---

## Quickstart
Expand Down
122 changes: 122 additions & 0 deletions docker/band_python_kit/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Fully-specific tag + manifest-list digest (multi-arch: resolves to the
# correct linux/amd64 or linux/arm64 image automatically). The tag makes the
# pin human-legible; the digest is what actually gets resolved — re-pin by
# passing --build-arg to bump either, but always keep both in sync (the tag
# is documentation, not an independent fallback).
ARG PYTHON_BASE_IMAGE=python:3.12.13-slim-trixie@sha256:c3d81d25b3154142b0b42eb1e61300024426268edeb5b5a26dd7ddf64d9daf28
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.9.13@sha256:f07d1bf7b1fb4b983eed2b31320e25a2a76625bdf83d5ff0208fe105d4d8d2f5
# Shared by both stages so the venv is built at the exact absolute path it
# will live at in the runtime image — see UV_PROJECT_ENVIRONMENT below.
ARG BAND_SDK_HOME=/opt/band

# Named stage so the pinned uv image can be referenced by a fixed alias below
# — COPY --from doesn't support ARG expansion directly.
FROM ${UV_IMAGE} AS uv

# ── Build stage ───────────────────────────────────────────────────────────
FROM ${PYTHON_BASE_IMAGE} AS builder
ARG BAND_SDK_HOME

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y --no-install-recommends \
git \
&& rm -rf /var/lib/apt/lists/*

# uv, copied as a pinned binary (no pip install).
COPY --from=uv /uv /uvx /usr/local/bin/

WORKDIR /build
COPY pyproject.toml uv.lock README.md ./
COPY src ./src

# Prefer HTTPS for GitHub URLs to avoid SSH key requirements in container builds.
RUN git config --global --add url."https://github.com/".insteadOf "git@github.com:" \
&& git config --global --add url."https://github.com/".insteadOf "ssh://git@github.com/"

# Strip host-specific [tool.uv.sources] overrides so uv resolves from the registry.
RUN sed -i '/^\[tool\.uv\.sources\]/,/^\[/{ /^\[tool\.uv\.sources\]/d; /^\[/!d; }' pyproject.toml
Comment on lines +37 to +38

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sed strip of [tool.uv.sources] is currently a no-op — no such section exists in the committed root pyproject.toml. Legitimate as a guard against a dev's local override leaking into the build, but reads as load-bearing when it isn't. Reword the comment to say "defensive guard; no-op against committed state," or drop it. Failure mode is safe (local source + stale lock → --locked fails loud).


# Core-only by default: this venv is baked once and never touched again.
# Optionally bake exactly one framework extra in at build time (e.g.
# `--build-arg SDK_EXTRA=langgraph`) — deliberately one, not a fixed v1 set,
# since some extras (e.g. crewai vs. parlant/pydantic_ai) conflict and can't
# resolve into the same venv. Any other framework is the customer's own
# venv, created at sandbox runtime, isolated from this one.
ARG SDK_EXTRA=""
# Build the venv directly at its final runtime path (not e.g. ./.venv, then
# relocated by COPY below): console-script shebangs bake in an absolute
# interpreter path at install time, so copying an already-built venv to a
# different path leaves every entry point (band-acp, band-trigger, ...)
# pointing at the deleted builder path.
ENV UV_PROJECT_ENVIRONMENT=${BAND_SDK_HOME}/venv
RUN if [ -n "$SDK_EXTRA" ]; then \
uv sync --locked --no-dev --no-editable --extra "$SDK_EXTRA"; \
else \
uv sync --locked --no-dev --no-editable; \
fi

# ── Runtime stage ─────────────────────────────────────────────────────────
FROM ${PYTHON_BASE_IMAGE}
ARG BAND_SDK_HOME

ENV DEBIAN_FRONTEND=noninteractive

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DEBIAN_FRONTEND=noninteractive set via ENV in the runtime stage leaks build config into the runtime image env; conventionally build-only (ARG or inline on apt-get).


# ca-certificates provides the system trust store that the sandbox's
# session-generated proxy CA is installed into at runtime. No proxy env vars
# are set here. SSL_CERT_FILE/REQUESTS_CA_BUNDLE point at that same system
# bundle (not a narrowed, kit-private CA file) so it's additive, not an
# override: httpx and requests otherwise trust only their vendored certifi
# bundle and never see certs update-ca-certificates adds at sandbox runtime.
# git + openssh-client: band.docker.repo_init (always in the baked venv, part
# of core band-sdk) shells out to git for clone/checkout/rev-parse and to
# ssh-keygen for SSH host-key verification — both are needed in the base
# runtime image because repo initialization runs directly inside it.
RUN apt-get update && apt-get install -y --no-install-recommends \
bash \
ca-certificates \
git \
openssh-client \
&& rm -rf /var/lib/apt/lists/*

ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt

# Immutable SDK venv, outside the customer's writable workspace ($HOME). Copied
# from the exact same absolute path it was built at (see UV_PROJECT_ENVIRONMENT
# in the builder stage) — console-script shebangs bake in an absolute
# interpreter path, so copying to a *different* path would leave every entry
# point (band-acp, band-trigger, ...) pointing at the deleted builder path.
ENV BAND_SDK_HOME=${BAND_SDK_HOME}
COPY --from=builder ${BAND_SDK_HOME}/venv ${BAND_SDK_HOME}/venv
# Same digest-pinned uv as the build stage, for the sandbox launcher's locked
# customer-venv sync (`uv sync --locked`) at runtime. Lives under the
# read-only SDK home and stays off PATH for the same reason as
# BAND_SDK_PYTHON: the customer's own tooling must never be shadowed.
COPY --from=uv /uv ${BAND_SDK_HOME}/bin/uv
RUN chmod -R a-w ${BAND_SDK_HOME}

# Fixed, absolute interpreter path for launching the SDK. Deliberately not
# added to PATH, so it can never shadow the customer's own venv at runtime.
ENV BAND_SDK_PYTHON=${BAND_SDK_HOME}/venv/bin/python
# Fixed, absolute path to the pinned uv binary; the launcher must use this
# and never download or upgrade uv at runtime.
ENV BAND_SDK_UV=${BAND_SDK_HOME}/bin/uv

# Fixed system path, deliberately NOT under ${BAND_SDK_HOME}: ENTRYPOINT's
# exec-form JSON array is one of the few Dockerfile instructions Docker does
# NOT substitute ARG/ENV into, so a literal path is unavoidable here — keep
# it independent of the configurable SDK home rather than letting a
# --build-arg BAND_SDK_HOME override silently break container startup.
COPY docker/band_python_kit/entrypoint.sh /usr/local/bin/band-entrypoint.sh
RUN chmod +x /usr/local/bin/band-entrypoint.sh

RUN useradd -m -u 1000 -s /bin/bash agent
ENV HOME=/home/agent
WORKDIR /home/agent

# Container starts as root: the entrypoint decodes the sandbox's per-session
# proxy CA (only root can update the system trust store), then drops to the
# non-root "agent" user via setpriv before handing off to CMD.
ENTRYPOINT ["/usr/local/bin/band-entrypoint.sh"]
CMD ["bash"]
113 changes: 113 additions & 0 deletions docker/band_python_kit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# band-python-kit base image

Base image for the Band Docker Sandboxes kit: a customer's existing framework
agent (LangGraph, CrewAI, Anthropic, etc.) boots inside a sandbox already
connected to Band. This image provides the immutable Band SDK layer; the
customer's own framework lives in a separate venv created at sandbox runtime
(not part of this image).

The publishing workflow is a separate release deliverable. This README covers
building and running the image directly.

## Build

Core SDK only, no framework extras:

```bash
docker build -f docker/band_python_kit/Dockerfile -t band-python-kit .
```

Bake in exactly one framework extra (e.g. for a kit variant dedicated to a
single framework):

```bash
docker build -f docker/band_python_kit/Dockerfile \
--build-arg SDK_EXTRA=langgraph \
-t band-python-kit:langgraph .
```

`SDK_EXTRA` accepts any single extra from `pyproject.toml`'s
`[project.optional-dependencies]` (`langgraph`, `anthropic`, `claude_sdk`,
`crewai`, `pydantic_ai`, ...). Only one at a time: some extras conflict and
cannot resolve into the same venv (`crewai` vs. `parlant`/`pydantic_ai` — see
the Dependency Conflicts section of the repo's `CLAUDE.md`). Any framework not
baked in here is installed into the customer's own venv at sandbox runtime,
isolated from this one.

Multi-arch (arm64 + x86_64):

```bash
docker buildx build --platform linux/amd64,linux/arm64 \
-f docker/band_python_kit/Dockerfile -t band-python-kit .
```

## Image layout

| Path / env var | What it is |
|---|---|
| `$BAND_SDK_HOME` (`/opt/band`) | Root of the baked SDK install. Read-only to every user, including root, after build (`chmod -R a-w`). |
| `$BAND_SDK_PYTHON` (`/opt/band/venv/bin/python`) | Fixed, absolute interpreter for the Band SDK. **Not on `PATH`** — the SDK venv can never shadow the customer's own venv or interpreter. Invoke the SDK only via this path. |
| `$BAND_SDK_UV` (`/opt/band/bin/uv`) | The build's digest-pinned `uv`, for the sandbox launcher's locked customer-venv sync at runtime. **Not on `PATH`**, read-only like the rest of `$BAND_SDK_HOME`; never downloaded or upgraded at runtime. |
| `agent` (uid 1000, `$HOME=/home/agent`) | The non-root user every process ends up running as. Matches the sandbox's `$HOME`-mounted workspace convention. |

## CA trust

Docker Sandboxes generates a per-session proxy CA and exposes it to the
container as base64 in `PROXY_CA_CERT_B64`. The entrypoint:

1. Decodes `PROXY_CA_CERT_B64` (if set) into
`/usr/local/share/ca-certificates/sandbox-proxy-ca.crt` and runs
`update-ca-certificates` — this needs root, which is why the container
starts as root before dropping privileges (see below).
2. Relies on `SSL_CERT_FILE`/`REQUESTS_CA_BUNDLE`, baked in as image `ENV`
and pointed at the system bundle
(`/etc/ssl/certs/ca-certificates.crt`) — this is what `httpx` needs,
since it trusts only its vendored `certifi` bundle by default and
ignores the system trust store otherwise. `websockets` already defaults
to `ssl.create_default_context()`, so it needs no extra wiring.

Both are additive (pointed at the *whole* system bundle, not a narrowed,
kit-private CA file) — never override these to trust only an internal CA;
that breaks the credential proxy's own TLS termination path.

If `PROXY_CA_CERT_B64` is unset (plain allowlisted egress, no credential
injection), the entrypoint skips the install step entirely and every real,
publicly-trusted upstream cert still verifies normally.

## Privilege drop

The container's default process starts as **root** — required for the CA
install step above, which only root can perform. The entrypoint
(`entrypoint.sh`) always ends by dropping to `agent` (uid 1000) via
`setpriv` before `exec`-ing the real command:

```bash
exec setpriv --reuid=agent --regid=agent --init-groups -- "$@"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setpriv assumed present, never explicitly installed (only bash/ca-certificates/git/openssh-client are). It ships in util-linux (Essential in Debian trixie-slim), so present today — but container startup hard-depends on it. Add explicit util-linux to the runtime apt-get for a base-bump-proof, legible dependency.

```

Nothing meant to run as the sandbox's agent user ever executes as root —
verify with `docker top <container>`, not `docker exec ... whoami` (a fresh
`docker exec` process defaults to root regardless of what PID 1 dropped to).

## Run

```bash
docker run --rm band-python-kit bash -c '$BAND_SDK_PYTHON -c "import band; print(band.__version__)"'
```

With a proxy CA (mirrors what a real sandbox session sets):

```bash
docker run --rm -e PROXY_CA_CERT_B64="$(base64 -i proxy-ca.pem)" band-python-kit \
bash -c '$BAND_SDK_PYTHON -c "import httpx; print(httpx.get(\"https://your-injected-domain\").status_code)"'
```

## Out of scope here

- **Customer venv creation** — created at sandbox runtime from the
workspace's own dependency declaration, not part of this image.
- **`spec.yaml`** (`kind: sandbox`, `caps.network.allow`, credentials) — a
separate deliverable; this image is what it points at.
- **`sbx kit validate` / `sbx run --kit`** — requires the `spec.yaml` above
and a real Docker Sandboxes-capable machine; not runnable in CI (no
nested virtualization on GitHub-hosted runners).
21 changes: 21 additions & 0 deletions docker/band_python_kit/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail

# Deliberately thin: reading workspace config, spec.yaml-driven customer venv
# sync, and agent launch belong to the sandbox launcher, not this image. This
# stub only wires CA trust and validates the SDK venv before dropping to the
# non-root runtime user.

# The proxy CA is session-generated per sandbox, so it can only be installed
# at container start, not baked into the image. Runs while still root.
if [[ -n "${PROXY_CA_CERT_B64:-}" ]]; then
base64 -d <<<"${PROXY_CA_CERT_B64}" > /usr/local/share/ca-certificates/sandbox-proxy-ca.crt
update-ca-certificates >/dev/null
Comment on lines +12 to +13

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CA decode has no validation. Invalid base64 already fails loud under set -e; the silent path is a decode that succeeds but isn't PEM → update-ca-certificates skips it → trust silently not wired. Cheap guard: openssl x509 -noout -in and exit 1 on failure. Silent-untrusted is nasty to debug in a sandbox.

fi

if [[ ! -x "${BAND_SDK_PYTHON:-}" ]]; then
echo "[band-python-kit] ERROR: SDK interpreter not found at ${BAND_SDK_PYTHON:-<unset>}" >&2
exit 1
fi

exec setpriv --reuid=agent --regid=agent --init-groups -- "$@"
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ markers = [
"requires_api: marks tests as requiring real API credentials (skipped in CI)",
"e2e: marks tests as end-to-end (requires live services)",
"flaky_reason: baseline flakiness taxonomy stamp (kind + reason); see tests/e2e/baseline/flaky.py",
"docker_build: builds/runs a real Docker image via subprocess (requires a local Docker daemon; skipped unless DOCKER_TESTS_ENABLED=true, including in CI, which has Docker but shouldn't pay this cost by default)",
]

[tool.uv]
Expand All @@ -277,6 +278,8 @@ markers = [
required-environments = [
"sys_platform == 'darwin' and platform_machine == 'arm64'",
"sys_platform == 'linux' and platform_machine == 'x86_64'",
"sys_platform == 'linux' and platform_machine == 'aarch64'",
"sys_platform == 'win32' and platform_machine == 'AMD64'",
]

# crewai 1.14.3 conflicts with BOTH parlant and pydantic-ai:
Expand Down
23 changes: 17 additions & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,30 @@ def pytest_ignore_collect(collection_path: Path) -> bool | None:


def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
"""Skip e2e-marked tests unless explicitly enabled.
"""Skip e2e- and docker_build-marked tests unless explicitly enabled.

tests/e2e/ gates itself through its own conftest; this covers
e2e-marked tests living elsewhere (e.g. the codex ACP protocol
tests), which spawn real backends and must never ride a normal
unit run.

docker_build-marked tests shell out to a real `docker build`/`docker
run` — CI runners do have a Docker daemon (unlike the nested
virtualization sbx tests need), so a plain Docker-availability check
isn't enough to keep them off CI; they need the same explicit opt-in
as e2e tests.
"""
if os.environ.get("E2E_TESTS_ENABLED") == "true":
return
skip = pytest.mark.skip(reason="set E2E_TESTS_ENABLED=true to run e2e tests")
e2e_enabled = os.environ.get("E2E_TESTS_ENABLED") == "true"
docker_enabled = os.environ.get("DOCKER_TESTS_ENABLED") == "true"
skip_e2e = pytest.mark.skip(reason="set E2E_TESTS_ENABLED=true to run e2e tests")
skip_docker = pytest.mark.skip(
reason="set DOCKER_TESTS_ENABLED=true to run docker_build tests"
)
for item in items:
if item.get_closest_marker("e2e"):
item.add_marker(skip)
if not e2e_enabled and item.get_closest_marker("e2e"):
item.add_marker(skip_e2e)
if not docker_enabled and item.get_closest_marker("docker_build"):
item.add_marker(skip_docker)


@pytest.fixture(autouse=True)
Expand Down
Empty file added tests/docker/__init__.py
Empty file.
Loading