From 09421f04d49e9bc12fd59a5c3f2b653a0514e577 Mon Sep 17 00:00:00 2001 From: QueryPlanner Date: Sat, 31 Oct 2026 14:55:41 +0530 Subject: [PATCH 1/3] feat: add multi-platform sandbox image - Publish a Blacki-owned sandbox image for AMD64 and ARM64 - Verify sandbox tools and pin deployments to the image digest --- .env.example | 4 +- .github/workflows/docker-publish.yml | 53 +++++++++- .github/workflows/sandbox-image.yml | 38 +++++++ deploy/sandbox-image/Dockerfile | 120 ++++++++++++++++++++++ deploy/sandbox-image/blacki-sandbox-start | 20 ++++ docs/architecture.md | 2 +- docs/base-infra/environment-variables.md | 11 +- scripts/sandbox-image-check.sh | 113 ++++++++++++++++++++ src/blacki/sandbox/config.py | 14 +-- tests/sandbox/test_config.py | 11 +- tests/test_deployment_contract.py | 25 +++++ 11 files changed, 396 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/sandbox-image.yml create mode 100644 deploy/sandbox-image/Dockerfile create mode 100644 deploy/sandbox-image/blacki-sandbox-start create mode 100644 scripts/sandbox-image-check.sh diff --git a/.env.example b/.env.example index 2f37e2f..299e7bb 100644 --- a/.env.example +++ b/.env.example @@ -212,7 +212,7 @@ TELEGRAM_ENABLED=false # 1. Install: uv pip install opensandbox-server # 2. Configure: uvx opensandbox-server init-config ~/.sandbox.toml --example docker # 3. Edit port: ~/.sandbox.toml: port = 9090 -# 4. Pre-pull image: docker pull ghcr.io/queryplanner/athena-sandbox:latest +# 4. Pre-pull image: docker pull ghcr.io/queryplanner/blacki-sandbox:latest # 5. Start server: uvx opensandbox-server # # Enable sandbox tools @@ -234,7 +234,7 @@ TELEGRAM_ENABLED=false # Docker image for sandboxes. The default image includes Chromium, a pinned # agent-browser, and agent-browser's bundled usage guides. The published image # is linux/amd64; build an arm64 equivalent for Apple Silicon sandbox hosts. -# SANDBOX_IMAGE=ghcr.io/queryplanner/athena-sandbox:latest +# SANDBOX_IMAGE=ghcr.io/queryplanner/blacki-sandbox:latest # # Security boundary: Blacki never copies model, repository, search, Telegram, or # application credentials into a general-purpose sandbox. SANDBOX_API_KEY is used diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 0769b0d..7882f74 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -13,6 +13,41 @@ jobs: quality: uses: ./.github/workflows/code-quality.yml + sandbox-test: + uses: ./.github/workflows/sandbox-image.yml + + sandbox-image: + needs: [quality, sandbox-test] + if: github.ref == 'refs/heads/main' && github.repository == 'QueryPlanner/blacki' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + image-digest: ${{ steps.sandbox-build.outputs.digest }} + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and publish sandbox image + id: sandbox-build + uses: docker/build-push-action@v5 + with: + context: deploy/sandbox-image + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/queryplanner/blacki-sandbox:latest + ghcr.io/queryplanner/blacki-sandbox:sha-${{ github.sha }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + # 2. Build and Push after Quality passes build: needs: quality @@ -74,7 +109,7 @@ jobs: cache-to: type=gha,mode=max deploy: - needs: build + needs: [build, sandbox-image] if: >- github.ref == 'refs/heads/main' && github.repository == 'QueryPlanner/blacki' @@ -87,6 +122,20 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + # The sandbox server is a separate host and cannot use Blacki's GHCR login. + # Check anonymous access before interrupting the running deployment. + - name: Verify public sandbox image platforms + env: + SANDBOX_IMAGE_DIGEST: ${{ needs.sandbox-image.outputs.image-digest }} + run: | + set -euo pipefail + DOCKER_CONFIG=$(mktemp -d) + export DOCKER_CONFIG + trap 'rmdir "$DOCKER_CONFIG" 2>/dev/null || true' EXIT + manifest=$(docker buildx imagetools inspect "ghcr.io/queryplanner/blacki-sandbox@$SANDBOX_IMAGE_DIGEST") + printf '%s\n' "$manifest" | grep -q 'Platform:.*linux/amd64' + printf '%s\n' "$manifest" | grep -q 'Platform:.*linux/arm64' + # Connect to Tailscale network for secure SSH access - name: Connect to Tailscale uses: tailscale/github-action@v2 @@ -119,6 +168,7 @@ jobs: BROWSER_USE_API_KEY: ${{ secrets.BROWSER_USE_API_KEY }} SANDBOX_ENABLED: ${{ secrets.SANDBOX_ENABLED }} SANDBOX_DOMAIN: ${{ secrets.SANDBOX_DOMAIN }} + SANDBOX_IMAGE: ghcr.io/queryplanner/blacki-sandbox@${{ needs.sandbox-image.outputs.image-digest }} R2_FILES_ENABLED: ${{ secrets.R2_FILES_ENABLED }} R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }} @@ -197,6 +247,7 @@ jobs: BROWSER_USE_API_KEY \ SANDBOX_ENABLED \ SANDBOX_DOMAIN \ + SANDBOX_IMAGE \ R2_FILES_ENABLED \ R2_ENDPOINT_URL \ R2_BUCKET_NAME \ diff --git a/.github/workflows/sandbox-image.yml b/.github/workflows/sandbox-image.yml new file mode 100644 index 0000000..0b607dc --- /dev/null +++ b/.github/workflows/sandbox-image.yml @@ -0,0 +1,38 @@ +name: Sandbox Image + +on: + pull_request: + paths: + - 'deploy/sandbox-image/**' + - 'scripts/sandbox-image-check.sh' + - '.github/workflows/sandbox-image.yml' + - '.github/workflows/docker-publish.yml' + workflow_call: + +permissions: + contents: read + +jobs: + test: + name: Test sandbox image (${{ matrix.platform }}) + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + platform: linux/amd64 + - runner: ubuntu-24.04-arm + platform: linux/arm64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Build sandbox image + uses: docker/build-push-action@v5 + with: + context: deploy/sandbox-image + platforms: ${{ matrix.platform }} + load: true + tags: blacki-sandbox:dev + - name: Test sandbox tools and startup + run: bash scripts/sandbox-image-check.sh blacki-sandbox:dev diff --git a/deploy/sandbox-image/Dockerfile b/deploy/sandbox-image/Dockerfile new file mode 100644 index 0000000..85e9ff1 --- /dev/null +++ b/deploy/sandbox-image/Dockerfile @@ -0,0 +1,120 @@ +# The image Blacki's per-session sandboxes run. +# +# OpenSandbox injects its own execd into every sandbox, so this image only +# carries the tools: Chromium and a pinned agent-browser for the +# agent_browser tool, Python with a Jupyter server for run_code, and everyday CLI +# tools and Python libraries for shell (scripts/sandbox-image-check.sh +# lists them). Build (x86_64 or arm64, matching the sandbox host): +# +# docker build -t blacki-sandbox:dev deploy/sandbox-image +# +# Adapted from QueryPlanner/athena commit a3ad9a5. + +FROM debian:13-slim + +ARG DEBIAN_FRONTEND=noninteractive + +# agent-browser moves fast; bump the version and both digests together. +# Digests are the release assets' sha256 as GitHub lists them. +ARG AGENT_BROWSER_VERSION=v0.38.1 +ARG AGENT_BROWSER_SHA256_AMD64=5100149a1903211c889de4e545bf36d90803740cea4f99aa22651649f9205ea1 +ARG AGENT_BROWSER_SHA256_ARM64=937b315ee0761e8a62f7950ddcfef9b3d3d8e8d5eb9c9d2bf9e23e5725664511 +# The usage guides `agent-browser skills get` prints ship only in the npm +# package, not in the release binaries. The digest is the tarball's sha512 +# in hex; npm lists it base64-encoded as dist.integrity +# (`npm view agent-browser@ dist.integrity`). +ARG AGENT_BROWSER_SKILLS_SHA512=939f050b3d3250e080368364322a897be1f6cbaafab1465acea5ec585f8c62ad62442e363e3b4b701a1a83a4924ce0ff151429a6f3c3bc4e470d8121725be787 +ARG TARGETARCH + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + chromium \ + curl \ + fonts-liberation \ + git \ + python3 \ + python3-pip \ + python3-venv \ + tini \ + ffmpeg \ + fd-find \ + file \ + jq \ + ripgrep \ + tree \ + unzip \ + xz-utils \ + zip \ + dnsutils \ + openssh-client \ + rsync \ + wget; \ + rm -rf /var/lib/apt/lists/* + +RUN set -eux; \ + arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \ + case "$arch" in \ + amd64) asset=agent-browser-linux-x64; sum="$AGENT_BROWSER_SHA256_AMD64" ;; \ + arm64) asset=agent-browser-linux-arm64; sum="$AGENT_BROWSER_SHA256_ARM64" ;; \ + *) echo "unsupported architecture: $arch" >&2; exit 1 ;; \ + esac; \ + curl -fsSL -o /usr/local/lib/agent-browser \ + "https://github.com/vercel-labs/agent-browser/releases/download/${AGENT_BROWSER_VERSION}/${asset}"; \ + echo "${sum} /usr/local/lib/agent-browser" | sha256sum -c -; \ + chmod 0755 /usr/local/lib/agent-browser + +# Only skill-data/ is kept from the npm tarball; the tarball is large because +# it bundles every platform's binary. +RUN set -eux; \ + version="${AGENT_BROWSER_VERSION#v}"; \ + curl -fsSL -o /tmp/agent-browser.tgz \ + "https://registry.npmjs.org/agent-browser/-/agent-browser-${version}.tgz"; \ + echo "${AGENT_BROWSER_SKILLS_SHA512} /tmp/agent-browser.tgz" | sha512sum -c -; \ + mkdir -p /usr/local/share/agent-browser; \ + tar -xzf /tmp/agent-browser.tgz -C /usr/local/share/agent-browser \ + --strip-components=1 package/skill-data; \ + rm /tmp/agent-browser.tgz + +# Jupyter in its own venv: execd's code interpreter (run_code) talks to it. +# The venv is first on PATH, so `pip install` in the shell works too (Debian +# refuses system-wide installs). +RUN set -eux; \ + python3 -m venv /opt/jupyter; \ + /opt/jupyter/bin/pip install --no-cache-dir jupyter-server==2.21.1 ipykernel==7.3.0 +ENV PATH=/opt/jupyter/bin:$PATH + +# Everyday Python tools and libraries, in the same venv so both the shell +# and run_code see them. Pinned: a rebuild should not pick up new versions. +# yt-dlp breaks as sites change; bump it deliberately. +RUN /opt/jupyter/bin/pip install --no-cache-dir \ + yt-dlp==2026.8.19 \ + requests==2.34.2 \ + pandas==3.0.6 \ + pillow==12.3.0 \ + beautifulsoup4==4.15.0 + +# Debian ships fd as fdfind; give it its usual name. +RUN ln -s /usr/bin/fdfind /usr/local/bin/fd + +# agent-browser uses the system Chromium. Sandboxes run as root, which +# Chromium only allows with --no-sandbox; the OpenSandbox container is the +# isolation boundary. A wrapper, so the settings hold however execd starts +# the command. +RUN printf '%s\n' \ + '#!/bin/sh' \ + 'export AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium' \ + 'export AGENT_BROWSER_SKILLS_DIR=/usr/local/share/agent-browser/skill-data' \ + 'export AGENT_BROWSER_ARGS="--no-sandbox,--disable-dev-shm-usage"' \ + 'exec /usr/local/lib/agent-browser "$@"' \ + > /usr/local/bin/agent-browser; \ + chmod 0755 /usr/local/bin/agent-browser + +COPY --chmod=0755 blacki-sandbox-start /usr/local/bin/blacki-sandbox-start + +WORKDIR /workspace + +# Blacki creates sandboxes with its own entrypoint, which runs +# blacki-sandbox-start when it exists; this default is for `docker run`. +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/blacki-sandbox-start"] diff --git a/deploy/sandbox-image/blacki-sandbox-start b/deploy/sandbox-image/blacki-sandbox-start new file mode 100644 index 0000000..1ba187d --- /dev/null +++ b/deploy/sandbox-image/blacki-sandbox-start @@ -0,0 +1,20 @@ +#!/bin/sh +# Blacki's sandbox entrypoint runs this when the image has it. +# +# Starts a Jupyter server on loopback for execd's code interpreter +# (Blacki's run_code tool), then keeps the sandbox alive. Blacki passes +# JUPYTER_HOST and a fresh JUPYTER_TOKEN per sandbox in its environment; +# without a token no server is started, rather than one anyone inside the +# sandbox could use unauthenticated. +set -eu + +if [ -n "${JUPYTER_TOKEN:-}" ]; then + host="${JUPYTER_HOST:-http://127.0.0.1:44771}" + port="${host##*:}" + /opt/jupyter/bin/jupyter-server \ + --ip=127.0.0.1 --port="${port:-44771}" --allow-root --no-browser \ + --ServerApp.root_dir=/workspace \ + >/tmp/jupyter.log 2>&1 & +fi + +exec tail -f /dev/null diff --git a/docs/architecture.md b/docs/architecture.md index dfa4d69..20b4554 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -183,7 +183,7 @@ Optional tools follow the project's cloud-first principle: - browser automation uses the dedicated `agent_browser(args)` tool inside the current session's OpenSandbox instance. Its argument array is shell-quoted, and commands time out after 90 seconds. The default - `ghcr.io/queryplanner/athena-sandbox:latest` image provides Chromium, a pinned + `ghcr.io/queryplanner/blacki-sandbox:latest` image provides Chromium, a pinned agent-browser, and agent-browser's bundled usage guides; the tool itself does not install dependencies. Run `agent_browser(args=["skills", "get", "core"])` for the usage guide, or `agent_browser(args=["--help"])` for the command list. diff --git a/docs/base-infra/environment-variables.md b/docs/base-infra/environment-variables.md index fba6c3d..505e154 100644 --- a/docs/base-infra/environment-variables.md +++ b/docs/base-infra/environment-variables.md @@ -208,13 +208,16 @@ If Qdrant Cloud values are present, the provider stores the vectors remotely. | `SANDBOX_TIMEOUT_MINUTES` | `30` | Sandbox lifetime | | `SANDBOX_MEMORY_LIMIT` | `1Gi` | Per-sandbox memory; Chromium needs headroom | | `SANDBOX_CPU_LIMIT` | `0.5` | Per-sandbox CPU setting | -| `SANDBOX_IMAGE` | `ghcr.io/queryplanner/athena-sandbox:latest` | Chromium + agent-browser sandbox image | +| `SANDBOX_IMAGE` | `ghcr.io/queryplanner/blacki-sandbox:latest` | Chromium + agent-browser sandbox image | The default sandbox image provides Chromium, a pinned agent-browser, and the bundled agent-browser usage guides. The `agent_browser` tool runs in this -sandbox only; it never runs on the Blacki host. The published image is -`linux/amd64`; on arm64 hosts, build an equivalent image or point -`SANDBOX_IMAGE` at an arm64 build. +sandbox only; it never runs on the Blacki host. The image is built for +`linux/amd64` and `linux/arm64`. The OpenSandbox server must be able to pull +the image from GHCR. Image changes apply when a new sandbox is created; +existing session sandboxes continue using their original image until replaced. +The production deployment pins the sandbox image to the digest built by that +deployment, while local installations use the default tag unless overridden. Running a local OpenSandbox server adds Docker and resource requirements beyond the Blacki golden path. diff --git a/scripts/sandbox-image-check.sh b/scripts/sandbox-image-check.sh new file mode 100644 index 0000000..bb6f08d --- /dev/null +++ b/scripts/sandbox-image-check.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Checks that every tool the sandbox image promises actually runs in it. +# +# scripts/sandbox-image-check.sh [IMAGE] # default blacki-sandbox:dev +# +# Build first: docker build -t blacki-sandbox:dev deploy/sandbox-image +set -euo pipefail + +image="${1:-blacki-sandbox:dev}" + +# One command per tool; each must exit 0. +checks=( + "agent-browser --version" + "agent-browser skills get core | grep -q 'name: core'" + "chromium --version" + "git --version" + "curl --version" + "python3 --version" + "jupyter-server --version" + "ffmpeg -version" + "yt-dlp --version" + "fd --version" + "file --version" + "jq --version" + "rg --version" + "tree --version" + "unzip -v" + "xz --version" + "zip -v" + "dig -v" + "ssh -V" + "rsync --version" + "wget --version" + "python3 -c 'import requests, pandas, PIL, bs4'" +) + +failed=0 +for check in "${checks[@]}"; do + if docker run --rm --entrypoint /bin/sh "$image" -c "$check" >/dev/null 2>&1; then + echo "ok $check" + else + echo "FAIL $check" + failed=1 + fi +done + +if docker run --rm --entrypoint /bin/sh "$image" -c \ + "agent-browser open 'data:text/html,Blacki sandbox' >/dev/null && \ + agent-browser get title | grep -q 'Blacki sandbox' && agent-browser close >/dev/null"; then + echo "ok browser opens a page" +else + echo "FAIL browser opens a page" + failed=1 +fi + +# Exercise the same startup command and Jupyter token used by Blacki. +container=$(docker run -d --entrypoint blacki-sandbox-start \ + -e JUPYTER_TOKEN=sandbox-image-test "$image") +trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT +if docker exec "$container" python3 -c ' +import time +import urllib.request + +request = urllib.request.Request( + "http://127.0.0.1:44771/api", headers={"Authorization": "token sandbox-image-test"} +) +for attempt in range(30): + try: + with urllib.request.urlopen(request, timeout=2) as response: + assert response.status == 200 + break + except OSError: + if attempt == 29: + raise + time.sleep(1) +'; then + echo "ok authenticated Jupyter starts" +else + echo "FAIL authenticated Jupyter starts" + failed=1 +fi + +if docker exec "$container" python3 -c ' +from jupyter_client import KernelManager + +manager = KernelManager() +manager.start_kernel() +client = manager.client() +client.start_channels() +try: + client.wait_for_ready(timeout=30) + request_id = client.execute("print(6 * 7)") + while True: + message = client.get_iopub_msg(timeout=30) + if message["parent_header"].get("msg_id") != request_id: + continue + if message["msg_type"] == "stream" and message["content"]["text"].strip() == "42": + break + if message["msg_type"] == "error": + raise RuntimeError(message["content"]) + if message["msg_type"] == "status" and message["content"]["execution_state"] == "idle": + raise RuntimeError("Kernel did not produce the expected result") +finally: + client.stop_channels() + manager.shutdown_kernel(now=True) +'; then + echo "ok Jupyter executes Python" +else + echo "FAIL Jupyter executes Python" + failed=1 +fi + +exit "$failed" diff --git a/src/blacki/sandbox/config.py b/src/blacki/sandbox/config.py index e5825e0..0700559 100644 --- a/src/blacki/sandbox/config.py +++ b/src/blacki/sandbox/config.py @@ -9,6 +9,8 @@ from pydantic import BaseModel, Field, field_validator +DEFAULT_SANDBOX_IMAGE: Final[str] = "ghcr.io/queryplanner/blacki-sandbox:latest" + class SandboxConfig(BaseModel): """Configuration for OpenSandbox sandbox operations. @@ -36,14 +38,16 @@ class SandboxConfig(BaseModel): memory_limit: str = Field(default="1Gi", description="Memory limit per sandbox") cpu_limit: str = Field(default="0.5", description="CPU limit per sandbox") image: str = Field( - default="ghcr.io/queryplanner/athena-sandbox:latest", + default=DEFAULT_SANDBOX_IMAGE, description="Docker image for sandboxes", ) entrypoint: list[str] = Field( default=[ "/bin/sh", "-c", - "if command -v athena-sandbox-start >/dev/null 2>&1; then " + "if command -v blacki-sandbox-start >/dev/null 2>&1; then " + "exec blacki-sandbox-start; " + "elif command -v athena-sandbox-start >/dev/null 2>&1; then " "exec athena-sandbox-start; " "elif [ -x /opt/opensandbox/code-interpreter.sh ]; then " "exec /opt/opensandbox/code-interpreter.sh; " @@ -89,7 +93,7 @@ def load_sandbox_config() -> SandboxConfig: SANDBOX_TIMEOUT_MINUTES: Sandbox TTL in minutes (default: 30) SANDBOX_MEMORY_LIMIT: Memory limit per sandbox (default: 1Gi) SANDBOX_CPU_LIMIT: CPU limit per sandbox (default: 0.5) - SANDBOX_IMAGE: Docker image for sandboxes (default: the shared + SANDBOX_IMAGE: Docker image for sandboxes (default: the Blacki Chromium/agent-browser image) Returns: @@ -105,9 +109,7 @@ def load_sandbox_config() -> SandboxConfig: timeout_minutes=int(os.getenv("SANDBOX_TIMEOUT_MINUTES", "30").strip()), memory_limit=os.getenv("SANDBOX_MEMORY_LIMIT", "1Gi").strip(), cpu_limit=os.getenv("SANDBOX_CPU_LIMIT", "0.5").strip(), - image=os.getenv( - "SANDBOX_IMAGE", "ghcr.io/queryplanner/athena-sandbox:latest" - ).strip(), + image=os.getenv("SANDBOX_IMAGE", DEFAULT_SANDBOX_IMAGE).strip(), ) diff --git a/tests/sandbox/test_config.py b/tests/sandbox/test_config.py index 6a094b7..fc718e2 100644 --- a/tests/sandbox/test_config.py +++ b/tests/sandbox/test_config.py @@ -26,8 +26,9 @@ def test_default_values(self) -> None: assert config.timeout_minutes == 30 assert config.memory_limit == "1Gi" assert config.cpu_limit == "0.5" - assert config.image == "ghcr.io/queryplanner/athena-sandbox:latest" + assert config.image == "ghcr.io/queryplanner/blacki-sandbox:latest" entrypoint = config.entrypoint[-1] + assert "blacki-sandbox-start" in entrypoint assert "athena-sandbox-start" in entrypoint assert "/opt/opensandbox/code-interpreter.sh" in entrypoint @@ -89,6 +90,14 @@ def test_timeout_minutes_bounds(self) -> None: class TestLoadSandboxConfig: """Tests for load_sandbox_config.""" + def test_default_image_without_override( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("SANDBOX_IMAGE", raising=False) + assert ( + load_sandbox_config().image == "ghcr.io/queryplanner/blacki-sandbox:latest" + ) + def test_load_from_environment(self) -> None: """Test loading config from environment variables.""" env = { diff --git a/tests/test_deployment_contract.py b/tests/test_deployment_contract.py index d8cef8d..50fc76b 100644 --- a/tests/test_deployment_contract.py +++ b/tests/test_deployment_contract.py @@ -510,6 +510,31 @@ def test_production_deployment_serializes_host_bind_secret() -> None: assert 'HOST_BIND_IP="$DEPLOY_HOST_BIND_IP"' in deploy_script +def test_production_deployment_uses_published_sandbox_digest() -> None: + """Deployments must use the image digest produced by their own run.""" + jobs = _load_yaml(".github/workflows/docker-publish.yml")["jobs"] + assert "sandbox-test" in jobs["sandbox-image"]["needs"] + assert "sandbox-image" in jobs["deploy"]["needs"] + assert jobs["sandbox-image"]["outputs"]["image-digest"] == ( + "${{ steps.sandbox-build.outputs.digest }}" + ) + steps = jobs["deploy"]["steps"] + verify = next( + s for s in steps if s["name"] == "Verify public sandbox image platforms" + ) + deploy = next(s for s in steps if s["name"] == "Deploy to Server via Tailscale") + assert steps.index(verify) < steps.index(deploy) + assert verify["env"]["SANDBOX_IMAGE_DIGEST"] == ( + "${{ needs.sandbox-image.outputs.image-digest }}" + ) + assert deploy["env"]["SANDBOX_IMAGE"] == ( + "ghcr.io/queryplanner/blacki-sandbox@" + "${{ needs.sandbox-image.outputs.image-digest }}" + ) + writer = deploy["run"].split("python3 scripts/write_compose_env.py", 1)[1] + assert "SANDBOX_IMAGE" in writer.split("printf '%s' \"$GH_TOKEN\"", 1)[0].split() + + def test_production_deployment_serializes_telegram_access_code() -> None: """The Telegram access-control secret must reach the remote Compose environment.""" workflow = _load_yaml(".github/workflows/docker-publish.yml") From ccbbee9b79016120b5e9b60ca774b8650979487b Mon Sep 17 00:00:00 2001 From: QueryPlanner Date: Sat, 31 Oct 2026 15:10:35 +0530 Subject: [PATCH 2/3] ci: stop sandbox image builds on PRs - Run sandbox image checks only during publishing - Verify the reusable workflow does not run on pull requests --- .github/workflows/sandbox-image.yml | 8 +------- tests/test_deployment_contract.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sandbox-image.yml b/.github/workflows/sandbox-image.yml index 0b607dc..1f0b2e9 100644 --- a/.github/workflows/sandbox-image.yml +++ b/.github/workflows/sandbox-image.yml @@ -1,12 +1,6 @@ name: Sandbox Image -on: - pull_request: - paths: - - 'deploy/sandbox-image/**' - - 'scripts/sandbox-image-check.sh' - - '.github/workflows/sandbox-image.yml' - - '.github/workflows/docker-publish.yml' +"on": workflow_call: permissions: diff --git a/tests/test_deployment_contract.py b/tests/test_deployment_contract.py index 50fc76b..7aa7cf0 100644 --- a/tests/test_deployment_contract.py +++ b/tests/test_deployment_contract.py @@ -535,6 +535,16 @@ def test_production_deployment_uses_published_sandbox_digest() -> None: assert "SANDBOX_IMAGE" in writer.split("printf '%s' \"$GH_TOKEN\"", 1)[0].split() +def test_sandbox_image_checks_run_only_when_called() -> None: + """Image checks must not start separately for pull requests.""" + image_workflow = _load_yaml(".github/workflows/sandbox-image.yml") + assert set(image_workflow["on"]) == {"workflow_call"} + publish_jobs = _load_yaml(".github/workflows/docker-publish.yml")["jobs"] + assert publish_jobs["sandbox-test"]["uses"] == ( + "./.github/workflows/sandbox-image.yml" + ) + + def test_production_deployment_serializes_telegram_access_code() -> None: """The Telegram access-control secret must reach the remote Compose environment.""" workflow = _load_yaml(".github/workflows/docker-publish.yml") From 9578ffb34bd80e83f54c2d0b5c5d8f58a9aa8225 Mon Sep 17 00:00:00 2001 From: QueryPlanner Date: Sat, 31 Oct 2026 15:34:58 +0530 Subject: [PATCH 3/3] refactor: separate sandbox image publishing - Remove image publishing from the deployment workflow - Keep the sandbox image build outside the repository --- .env.example | 4 +- .github/workflows/docker-publish.yml | 53 +--------- .github/workflows/sandbox-image.yml | 32 ------ deploy/sandbox-image/Dockerfile | 120 ---------------------- deploy/sandbox-image/blacki-sandbox-start | 20 ---- docs/architecture.md | 2 +- docs/base-infra/environment-variables.md | 11 +- scripts/sandbox-image-check.sh | 113 -------------------- tests/test_deployment_contract.py | 35 ------- 9 files changed, 8 insertions(+), 382 deletions(-) delete mode 100644 .github/workflows/sandbox-image.yml delete mode 100644 deploy/sandbox-image/Dockerfile delete mode 100644 deploy/sandbox-image/blacki-sandbox-start delete mode 100644 scripts/sandbox-image-check.sh diff --git a/.env.example b/.env.example index 299e7bb..2f37e2f 100644 --- a/.env.example +++ b/.env.example @@ -212,7 +212,7 @@ TELEGRAM_ENABLED=false # 1. Install: uv pip install opensandbox-server # 2. Configure: uvx opensandbox-server init-config ~/.sandbox.toml --example docker # 3. Edit port: ~/.sandbox.toml: port = 9090 -# 4. Pre-pull image: docker pull ghcr.io/queryplanner/blacki-sandbox:latest +# 4. Pre-pull image: docker pull ghcr.io/queryplanner/athena-sandbox:latest # 5. Start server: uvx opensandbox-server # # Enable sandbox tools @@ -234,7 +234,7 @@ TELEGRAM_ENABLED=false # Docker image for sandboxes. The default image includes Chromium, a pinned # agent-browser, and agent-browser's bundled usage guides. The published image # is linux/amd64; build an arm64 equivalent for Apple Silicon sandbox hosts. -# SANDBOX_IMAGE=ghcr.io/queryplanner/blacki-sandbox:latest +# SANDBOX_IMAGE=ghcr.io/queryplanner/athena-sandbox:latest # # Security boundary: Blacki never copies model, repository, search, Telegram, or # application credentials into a general-purpose sandbox. SANDBOX_API_KEY is used diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7882f74..0769b0d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -13,41 +13,6 @@ jobs: quality: uses: ./.github/workflows/code-quality.yml - sandbox-test: - uses: ./.github/workflows/sandbox-image.yml - - sandbox-image: - needs: [quality, sandbox-test] - if: github.ref == 'refs/heads/main' && github.repository == 'QueryPlanner/blacki' - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - outputs: - image-digest: ${{ steps.sandbox-build.outputs.digest }} - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and publish sandbox image - id: sandbox-build - uses: docker/build-push-action@v5 - with: - context: deploy/sandbox-image - platforms: linux/amd64,linux/arm64 - push: true - tags: | - ghcr.io/queryplanner/blacki-sandbox:latest - ghcr.io/queryplanner/blacki-sandbox:sha-${{ github.sha }} - labels: | - org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} - org.opencontainers.image.revision=${{ github.sha }} - # 2. Build and Push after Quality passes build: needs: quality @@ -109,7 +74,7 @@ jobs: cache-to: type=gha,mode=max deploy: - needs: [build, sandbox-image] + needs: build if: >- github.ref == 'refs/heads/main' && github.repository == 'QueryPlanner/blacki' @@ -122,20 +87,6 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - # The sandbox server is a separate host and cannot use Blacki's GHCR login. - # Check anonymous access before interrupting the running deployment. - - name: Verify public sandbox image platforms - env: - SANDBOX_IMAGE_DIGEST: ${{ needs.sandbox-image.outputs.image-digest }} - run: | - set -euo pipefail - DOCKER_CONFIG=$(mktemp -d) - export DOCKER_CONFIG - trap 'rmdir "$DOCKER_CONFIG" 2>/dev/null || true' EXIT - manifest=$(docker buildx imagetools inspect "ghcr.io/queryplanner/blacki-sandbox@$SANDBOX_IMAGE_DIGEST") - printf '%s\n' "$manifest" | grep -q 'Platform:.*linux/amd64' - printf '%s\n' "$manifest" | grep -q 'Platform:.*linux/arm64' - # Connect to Tailscale network for secure SSH access - name: Connect to Tailscale uses: tailscale/github-action@v2 @@ -168,7 +119,6 @@ jobs: BROWSER_USE_API_KEY: ${{ secrets.BROWSER_USE_API_KEY }} SANDBOX_ENABLED: ${{ secrets.SANDBOX_ENABLED }} SANDBOX_DOMAIN: ${{ secrets.SANDBOX_DOMAIN }} - SANDBOX_IMAGE: ghcr.io/queryplanner/blacki-sandbox@${{ needs.sandbox-image.outputs.image-digest }} R2_FILES_ENABLED: ${{ secrets.R2_FILES_ENABLED }} R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }} @@ -247,7 +197,6 @@ jobs: BROWSER_USE_API_KEY \ SANDBOX_ENABLED \ SANDBOX_DOMAIN \ - SANDBOX_IMAGE \ R2_FILES_ENABLED \ R2_ENDPOINT_URL \ R2_BUCKET_NAME \ diff --git a/.github/workflows/sandbox-image.yml b/.github/workflows/sandbox-image.yml deleted file mode 100644 index 1f0b2e9..0000000 --- a/.github/workflows/sandbox-image.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Sandbox Image - -"on": - workflow_call: - -permissions: - contents: read - -jobs: - test: - name: Test sandbox image (${{ matrix.platform }}) - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-24.04 - platform: linux/amd64 - - runner: ubuntu-24.04-arm - platform: linux/arm64 - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - - name: Build sandbox image - uses: docker/build-push-action@v5 - with: - context: deploy/sandbox-image - platforms: ${{ matrix.platform }} - load: true - tags: blacki-sandbox:dev - - name: Test sandbox tools and startup - run: bash scripts/sandbox-image-check.sh blacki-sandbox:dev diff --git a/deploy/sandbox-image/Dockerfile b/deploy/sandbox-image/Dockerfile deleted file mode 100644 index 85e9ff1..0000000 --- a/deploy/sandbox-image/Dockerfile +++ /dev/null @@ -1,120 +0,0 @@ -# The image Blacki's per-session sandboxes run. -# -# OpenSandbox injects its own execd into every sandbox, so this image only -# carries the tools: Chromium and a pinned agent-browser for the -# agent_browser tool, Python with a Jupyter server for run_code, and everyday CLI -# tools and Python libraries for shell (scripts/sandbox-image-check.sh -# lists them). Build (x86_64 or arm64, matching the sandbox host): -# -# docker build -t blacki-sandbox:dev deploy/sandbox-image -# -# Adapted from QueryPlanner/athena commit a3ad9a5. - -FROM debian:13-slim - -ARG DEBIAN_FRONTEND=noninteractive - -# agent-browser moves fast; bump the version and both digests together. -# Digests are the release assets' sha256 as GitHub lists them. -ARG AGENT_BROWSER_VERSION=v0.38.1 -ARG AGENT_BROWSER_SHA256_AMD64=5100149a1903211c889de4e545bf36d90803740cea4f99aa22651649f9205ea1 -ARG AGENT_BROWSER_SHA256_ARM64=937b315ee0761e8a62f7950ddcfef9b3d3d8e8d5eb9c9d2bf9e23e5725664511 -# The usage guides `agent-browser skills get` prints ship only in the npm -# package, not in the release binaries. The digest is the tarball's sha512 -# in hex; npm lists it base64-encoded as dist.integrity -# (`npm view agent-browser@ dist.integrity`). -ARG AGENT_BROWSER_SKILLS_SHA512=939f050b3d3250e080368364322a897be1f6cbaafab1465acea5ec585f8c62ad62442e363e3b4b701a1a83a4924ce0ff151429a6f3c3bc4e470d8121725be787 -ARG TARGETARCH - -RUN set -eux; \ - apt-get update; \ - apt-get install -y --no-install-recommends \ - ca-certificates \ - chromium \ - curl \ - fonts-liberation \ - git \ - python3 \ - python3-pip \ - python3-venv \ - tini \ - ffmpeg \ - fd-find \ - file \ - jq \ - ripgrep \ - tree \ - unzip \ - xz-utils \ - zip \ - dnsutils \ - openssh-client \ - rsync \ - wget; \ - rm -rf /var/lib/apt/lists/* - -RUN set -eux; \ - arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \ - case "$arch" in \ - amd64) asset=agent-browser-linux-x64; sum="$AGENT_BROWSER_SHA256_AMD64" ;; \ - arm64) asset=agent-browser-linux-arm64; sum="$AGENT_BROWSER_SHA256_ARM64" ;; \ - *) echo "unsupported architecture: $arch" >&2; exit 1 ;; \ - esac; \ - curl -fsSL -o /usr/local/lib/agent-browser \ - "https://github.com/vercel-labs/agent-browser/releases/download/${AGENT_BROWSER_VERSION}/${asset}"; \ - echo "${sum} /usr/local/lib/agent-browser" | sha256sum -c -; \ - chmod 0755 /usr/local/lib/agent-browser - -# Only skill-data/ is kept from the npm tarball; the tarball is large because -# it bundles every platform's binary. -RUN set -eux; \ - version="${AGENT_BROWSER_VERSION#v}"; \ - curl -fsSL -o /tmp/agent-browser.tgz \ - "https://registry.npmjs.org/agent-browser/-/agent-browser-${version}.tgz"; \ - echo "${AGENT_BROWSER_SKILLS_SHA512} /tmp/agent-browser.tgz" | sha512sum -c -; \ - mkdir -p /usr/local/share/agent-browser; \ - tar -xzf /tmp/agent-browser.tgz -C /usr/local/share/agent-browser \ - --strip-components=1 package/skill-data; \ - rm /tmp/agent-browser.tgz - -# Jupyter in its own venv: execd's code interpreter (run_code) talks to it. -# The venv is first on PATH, so `pip install` in the shell works too (Debian -# refuses system-wide installs). -RUN set -eux; \ - python3 -m venv /opt/jupyter; \ - /opt/jupyter/bin/pip install --no-cache-dir jupyter-server==2.21.1 ipykernel==7.3.0 -ENV PATH=/opt/jupyter/bin:$PATH - -# Everyday Python tools and libraries, in the same venv so both the shell -# and run_code see them. Pinned: a rebuild should not pick up new versions. -# yt-dlp breaks as sites change; bump it deliberately. -RUN /opt/jupyter/bin/pip install --no-cache-dir \ - yt-dlp==2026.8.19 \ - requests==2.34.2 \ - pandas==3.0.6 \ - pillow==12.3.0 \ - beautifulsoup4==4.15.0 - -# Debian ships fd as fdfind; give it its usual name. -RUN ln -s /usr/bin/fdfind /usr/local/bin/fd - -# agent-browser uses the system Chromium. Sandboxes run as root, which -# Chromium only allows with --no-sandbox; the OpenSandbox container is the -# isolation boundary. A wrapper, so the settings hold however execd starts -# the command. -RUN printf '%s\n' \ - '#!/bin/sh' \ - 'export AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium' \ - 'export AGENT_BROWSER_SKILLS_DIR=/usr/local/share/agent-browser/skill-data' \ - 'export AGENT_BROWSER_ARGS="--no-sandbox,--disable-dev-shm-usage"' \ - 'exec /usr/local/lib/agent-browser "$@"' \ - > /usr/local/bin/agent-browser; \ - chmod 0755 /usr/local/bin/agent-browser - -COPY --chmod=0755 blacki-sandbox-start /usr/local/bin/blacki-sandbox-start - -WORKDIR /workspace - -# Blacki creates sandboxes with its own entrypoint, which runs -# blacki-sandbox-start when it exists; this default is for `docker run`. -ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/blacki-sandbox-start"] diff --git a/deploy/sandbox-image/blacki-sandbox-start b/deploy/sandbox-image/blacki-sandbox-start deleted file mode 100644 index 1ba187d..0000000 --- a/deploy/sandbox-image/blacki-sandbox-start +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/sh -# Blacki's sandbox entrypoint runs this when the image has it. -# -# Starts a Jupyter server on loopback for execd's code interpreter -# (Blacki's run_code tool), then keeps the sandbox alive. Blacki passes -# JUPYTER_HOST and a fresh JUPYTER_TOKEN per sandbox in its environment; -# without a token no server is started, rather than one anyone inside the -# sandbox could use unauthenticated. -set -eu - -if [ -n "${JUPYTER_TOKEN:-}" ]; then - host="${JUPYTER_HOST:-http://127.0.0.1:44771}" - port="${host##*:}" - /opt/jupyter/bin/jupyter-server \ - --ip=127.0.0.1 --port="${port:-44771}" --allow-root --no-browser \ - --ServerApp.root_dir=/workspace \ - >/tmp/jupyter.log 2>&1 & -fi - -exec tail -f /dev/null diff --git a/docs/architecture.md b/docs/architecture.md index 20b4554..dfa4d69 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -183,7 +183,7 @@ Optional tools follow the project's cloud-first principle: - browser automation uses the dedicated `agent_browser(args)` tool inside the current session's OpenSandbox instance. Its argument array is shell-quoted, and commands time out after 90 seconds. The default - `ghcr.io/queryplanner/blacki-sandbox:latest` image provides Chromium, a pinned + `ghcr.io/queryplanner/athena-sandbox:latest` image provides Chromium, a pinned agent-browser, and agent-browser's bundled usage guides; the tool itself does not install dependencies. Run `agent_browser(args=["skills", "get", "core"])` for the usage guide, or `agent_browser(args=["--help"])` for the command list. diff --git a/docs/base-infra/environment-variables.md b/docs/base-infra/environment-variables.md index 505e154..fba6c3d 100644 --- a/docs/base-infra/environment-variables.md +++ b/docs/base-infra/environment-variables.md @@ -208,16 +208,13 @@ If Qdrant Cloud values are present, the provider stores the vectors remotely. | `SANDBOX_TIMEOUT_MINUTES` | `30` | Sandbox lifetime | | `SANDBOX_MEMORY_LIMIT` | `1Gi` | Per-sandbox memory; Chromium needs headroom | | `SANDBOX_CPU_LIMIT` | `0.5` | Per-sandbox CPU setting | -| `SANDBOX_IMAGE` | `ghcr.io/queryplanner/blacki-sandbox:latest` | Chromium + agent-browser sandbox image | +| `SANDBOX_IMAGE` | `ghcr.io/queryplanner/athena-sandbox:latest` | Chromium + agent-browser sandbox image | The default sandbox image provides Chromium, a pinned agent-browser, and the bundled agent-browser usage guides. The `agent_browser` tool runs in this -sandbox only; it never runs on the Blacki host. The image is built for -`linux/amd64` and `linux/arm64`. The OpenSandbox server must be able to pull -the image from GHCR. Image changes apply when a new sandbox is created; -existing session sandboxes continue using their original image until replaced. -The production deployment pins the sandbox image to the digest built by that -deployment, while local installations use the default tag unless overridden. +sandbox only; it never runs on the Blacki host. The published image is +`linux/amd64`; on arm64 hosts, build an equivalent image or point +`SANDBOX_IMAGE` at an arm64 build. Running a local OpenSandbox server adds Docker and resource requirements beyond the Blacki golden path. diff --git a/scripts/sandbox-image-check.sh b/scripts/sandbox-image-check.sh deleted file mode 100644 index bb6f08d..0000000 --- a/scripts/sandbox-image-check.sh +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env bash -# Checks that every tool the sandbox image promises actually runs in it. -# -# scripts/sandbox-image-check.sh [IMAGE] # default blacki-sandbox:dev -# -# Build first: docker build -t blacki-sandbox:dev deploy/sandbox-image -set -euo pipefail - -image="${1:-blacki-sandbox:dev}" - -# One command per tool; each must exit 0. -checks=( - "agent-browser --version" - "agent-browser skills get core | grep -q 'name: core'" - "chromium --version" - "git --version" - "curl --version" - "python3 --version" - "jupyter-server --version" - "ffmpeg -version" - "yt-dlp --version" - "fd --version" - "file --version" - "jq --version" - "rg --version" - "tree --version" - "unzip -v" - "xz --version" - "zip -v" - "dig -v" - "ssh -V" - "rsync --version" - "wget --version" - "python3 -c 'import requests, pandas, PIL, bs4'" -) - -failed=0 -for check in "${checks[@]}"; do - if docker run --rm --entrypoint /bin/sh "$image" -c "$check" >/dev/null 2>&1; then - echo "ok $check" - else - echo "FAIL $check" - failed=1 - fi -done - -if docker run --rm --entrypoint /bin/sh "$image" -c \ - "agent-browser open 'data:text/html,Blacki sandbox' >/dev/null && \ - agent-browser get title | grep -q 'Blacki sandbox' && agent-browser close >/dev/null"; then - echo "ok browser opens a page" -else - echo "FAIL browser opens a page" - failed=1 -fi - -# Exercise the same startup command and Jupyter token used by Blacki. -container=$(docker run -d --entrypoint blacki-sandbox-start \ - -e JUPYTER_TOKEN=sandbox-image-test "$image") -trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT -if docker exec "$container" python3 -c ' -import time -import urllib.request - -request = urllib.request.Request( - "http://127.0.0.1:44771/api", headers={"Authorization": "token sandbox-image-test"} -) -for attempt in range(30): - try: - with urllib.request.urlopen(request, timeout=2) as response: - assert response.status == 200 - break - except OSError: - if attempt == 29: - raise - time.sleep(1) -'; then - echo "ok authenticated Jupyter starts" -else - echo "FAIL authenticated Jupyter starts" - failed=1 -fi - -if docker exec "$container" python3 -c ' -from jupyter_client import KernelManager - -manager = KernelManager() -manager.start_kernel() -client = manager.client() -client.start_channels() -try: - client.wait_for_ready(timeout=30) - request_id = client.execute("print(6 * 7)") - while True: - message = client.get_iopub_msg(timeout=30) - if message["parent_header"].get("msg_id") != request_id: - continue - if message["msg_type"] == "stream" and message["content"]["text"].strip() == "42": - break - if message["msg_type"] == "error": - raise RuntimeError(message["content"]) - if message["msg_type"] == "status" and message["content"]["execution_state"] == "idle": - raise RuntimeError("Kernel did not produce the expected result") -finally: - client.stop_channels() - manager.shutdown_kernel(now=True) -'; then - echo "ok Jupyter executes Python" -else - echo "FAIL Jupyter executes Python" - failed=1 -fi - -exit "$failed" diff --git a/tests/test_deployment_contract.py b/tests/test_deployment_contract.py index 7aa7cf0..d8cef8d 100644 --- a/tests/test_deployment_contract.py +++ b/tests/test_deployment_contract.py @@ -510,41 +510,6 @@ def test_production_deployment_serializes_host_bind_secret() -> None: assert 'HOST_BIND_IP="$DEPLOY_HOST_BIND_IP"' in deploy_script -def test_production_deployment_uses_published_sandbox_digest() -> None: - """Deployments must use the image digest produced by their own run.""" - jobs = _load_yaml(".github/workflows/docker-publish.yml")["jobs"] - assert "sandbox-test" in jobs["sandbox-image"]["needs"] - assert "sandbox-image" in jobs["deploy"]["needs"] - assert jobs["sandbox-image"]["outputs"]["image-digest"] == ( - "${{ steps.sandbox-build.outputs.digest }}" - ) - steps = jobs["deploy"]["steps"] - verify = next( - s for s in steps if s["name"] == "Verify public sandbox image platforms" - ) - deploy = next(s for s in steps if s["name"] == "Deploy to Server via Tailscale") - assert steps.index(verify) < steps.index(deploy) - assert verify["env"]["SANDBOX_IMAGE_DIGEST"] == ( - "${{ needs.sandbox-image.outputs.image-digest }}" - ) - assert deploy["env"]["SANDBOX_IMAGE"] == ( - "ghcr.io/queryplanner/blacki-sandbox@" - "${{ needs.sandbox-image.outputs.image-digest }}" - ) - writer = deploy["run"].split("python3 scripts/write_compose_env.py", 1)[1] - assert "SANDBOX_IMAGE" in writer.split("printf '%s' \"$GH_TOKEN\"", 1)[0].split() - - -def test_sandbox_image_checks_run_only_when_called() -> None: - """Image checks must not start separately for pull requests.""" - image_workflow = _load_yaml(".github/workflows/sandbox-image.yml") - assert set(image_workflow["on"]) == {"workflow_call"} - publish_jobs = _load_yaml(".github/workflows/docker-publish.yml")["jobs"] - assert publish_jobs["sandbox-test"]["uses"] == ( - "./.github/workflows/sandbox-image.yml" - ) - - def test_production_deployment_serializes_telegram_access_code() -> None: """The Telegram access-control secret must reach the remote Compose environment.""" workflow = _load_yaml(".github/workflows/docker-publish.yml")