From 08aadddd75a0557ce1a1918578d2ab05afbf9834 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 21 Jun 2026 01:37:18 +0200 Subject: [PATCH] ci(agent): build and test sandbox-agent images --- .github/workflows/12-check-unit-tests.yml | 71 +++++++++++++++ .github/workflows/42-railway-build.yml | 5 ++ .../guides/08-custom-agent-runner-images.mdx | 52 +++++++++++ .../docker-compose/ee/docker-compose.gh.yml | 36 ++++++++ hosting/docker-compose/ee/env.ee.gh.example | 18 ++-- .../docker-compose/oss/docker-compose.gh.yml | 36 ++++++++ hosting/docker-compose/oss/env.oss.gh.example | 18 ++-- .../agent/sandbox-images/daytona/README.md | 25 ++++++ .../sandbox-images/daytona/build_snapshot.py | 89 +++++++++++++++++++ 9 files changed, 338 insertions(+), 12 deletions(-) create mode 100644 docs/docs/self-host/guides/08-custom-agent-runner-images.mdx create mode 100644 services/agent/sandbox-images/daytona/README.md create mode 100644 services/agent/sandbox-images/daytona/build_snapshot.py diff --git a/.github/workflows/12-check-unit-tests.yml b/.github/workflows/12-check-unit-tests.yml index 157d81e470..c8bc699e65 100644 --- a/.github/workflows/12-check-unit-tests.yml +++ b/.github/workflows/12-check-unit-tests.yml @@ -301,3 +301,74 @@ jobs: files: services/oss/tests/results/junit.xml check_name: Application services Unit Test Results comment_mode: off + + run-services-node-unit-tests: + # The agent runner (services/agent) is a standalone Node/pnpm package, not part of the + # Python services suite above. It runs its own vitest unit tests plus a tsc typecheck gate. + # No "has_tests" guard on purpose: this suite is established, so a missing/empty suite must + # FAIL the job (vitest exits non-zero on no test files), not silently skip it. + if: | + github.event_name == 'workflow_dispatch' || + !github.event.pull_request.draft + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + contents: read + env: + AGENTA_LICENSE: oss + steps: + - uses: actions/checkout@v6 + + - name: Skip when package selection excludes services + if: github.event_name == 'workflow_dispatch' && !contains(fromJSON('["all","services-only"]'), inputs.packages) + run: exit 0 + + - name: Set up Node.js + if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages) + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Enable Corepack + if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages) + run: corepack enable + + - name: Cache pnpm store + if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages) + uses: actions/cache@v4 + with: + path: ~/.pnpm-store + key: ${{ runner.os }}-services-agent-pnpm-${{ hashFiles('services/agent/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-services-agent-pnpm- + + - name: Set up pnpm store + if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages) + working-directory: services/agent + run: pnpm config set store-dir ~/.pnpm-store + + - name: Install dependencies + if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages) + working-directory: services/agent + run: pnpm install --frozen-lockfile + + - name: Typecheck (tsc --noEmit, src + tests + config) + if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages) + working-directory: services/agent + run: pnpm run typecheck + + # The code-tool unit test spawns python3 and node end-to-end; both are preinstalled on + # ubuntu runners (node is also set up above), so no setup-python step is needed. + - name: Run agent runner unit tests + if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages) + working-directory: services/agent + run: pnpm run test:unit + + - name: Publish agent runner unit test results + if: always() && (github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages)) + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: services/agent/test-results/junit.xml + check_name: Agent Runner Unit Test Results + comment_mode: off diff --git a/.github/workflows/42-railway-build.yml b/.github/workflows/42-railway-build.yml index ba862d7cdb..0c16ac7ffb 100644 --- a/.github/workflows/42-railway-build.yml +++ b/.github/workflows/42-railway-build.yml @@ -109,6 +109,7 @@ jobs: - agenta-api - agenta-web - agenta-services + - agenta-sandbox-agent arch: - amd64 - arm64 @@ -127,6 +128,9 @@ jobs: - image_name: agenta-services context: . dockerfile: services/oss/docker/Dockerfile.gh + - image_name: agenta-sandbox-agent + context: services/agent + dockerfile: services/agent/docker/Dockerfile # Per-arch config (runner + platform string) - arch: amd64 runner: ubuntu-24.04 @@ -215,6 +219,7 @@ jobs: - agenta-api - agenta-web - agenta-services + - agenta-sandbox-agent steps: - name: Log in to GHCR uses: docker/login-action@v3 diff --git a/docs/docs/self-host/guides/08-custom-agent-runner-images.mdx b/docs/docs/self-host/guides/08-custom-agent-runner-images.mdx new file mode 100644 index 0000000000..7aea23981d --- /dev/null +++ b/docs/docs/self-host/guides/08-custom-agent-runner-images.mdx @@ -0,0 +1,52 @@ +--- +title: Custom Agent Runner Images +sidebar_label: Custom Runner Images +description: Build and configure custom sandbox-agent runner images for self-hosted Agenta +--- + +The default runner image is: + +```bash +ghcr.io/agenta-ai/agenta-sandbox-agent:latest +``` + +Build a custom image when you need extra system packages, custom certificates, or a pinned +runner build. + +## Build + +From the repository root: + +```bash +docker build \ + -f services/agent/docker/Dockerfile \ + -t ghcr.io//agenta-sandbox-agent: \ + services/agent +``` + +The published image does not bundle Claude Code. If a harness needs Claude Code, the runner +installs it from Anthropic at runtime. + +## Configure Compose + +```bash +AGENTA_SANDBOX_AGENT_IMAGE_NAME=/agenta-sandbox-agent +AGENTA_SANDBOX_AGENT_IMAGE_TAG= +``` + +## Configure Helm + +```yaml +agentRunner: + image: + repository: ghcr.io//agenta-sandbox-agent + tag: +``` + +## Configure Railway + +```bash +export AGENTA_SANDBOX_AGENT_IMAGE="ghcr.io//agenta-sandbox-agent:" +``` + +Then run the normal Railway deploy flow. diff --git a/hosting/docker-compose/ee/docker-compose.gh.yml b/hosting/docker-compose/ee/docker-compose.gh.yml index a74e799626..98ca1f234b 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.yml @@ -291,11 +291,17 @@ services: environment: - SCRIPT_NAME=/services - DOCKER_NETWORK_MODE=${DOCKER_NETWORK_MODE:-bridge} + - AGENTA_AGENT_RUNNER_URL=${AGENTA_AGENT_RUNNER_URL:-http://sandbox-agent:8765} + - AGENTA_AGENT_ENABLE_MCP=${AGENTA_AGENT_ENABLE_MCP:-false} # === NETWORK ============================================== # networks: - agenta-ee-gh-network extra_hosts: - "host.docker.internal:host-gateway" + # === ORCHESTRATION ======================================== # + depends_on: + sandbox-agent: + condition: service_healthy # === LABELS =============================================== # labels: - "traefik.http.routers.services.rule=PathPrefix(`/services/`)" @@ -308,6 +314,36 @@ services: # === LIFECYCLE ============================================ # restart: always + sandbox-agent: + # === IMAGE ================================================ # + image: ghcr.io/agenta-ai/${AGENTA_SANDBOX_AGENT_IMAGE_NAME:-internal-ee-agenta-sandbox-agent}:${AGENTA_SANDBOX_AGENT_IMAGE_TAG:-latest} + # === CONFIGURATION ======================================== # + environment: + PORT: "8765" + AGENTA_HOST: ${AGENTA_HOST:-} + AGENTA_API_KEY: ${AGENTA_API_KEY:-} + SANDBOX_AGENT_PROVIDER: ${SANDBOX_AGENT_PROVIDER:-local} + SANDBOX_AGENT_DAYTONA_API_KEY: ${SANDBOX_AGENT_DAYTONA_API_KEY:-} + SANDBOX_AGENT_DAYTONA_API_URL: ${SANDBOX_AGENT_DAYTONA_API_URL:-} + SANDBOX_AGENT_DAYTONA_TARGET: ${SANDBOX_AGENT_DAYTONA_TARGET:-} + SANDBOX_AGENT_DAYTONA_SNAPSHOT: ${SANDBOX_AGENT_DAYTONA_SNAPSHOT:-} + SANDBOX_AGENT_DAYTONA_IMAGE: ${SANDBOX_AGENT_DAYTONA_IMAGE:-} + SANDBOX_AGENT_DAYTONA_INSTALL_PI: ${SANDBOX_AGENT_DAYTONA_INSTALL_PI:-false} + # === NETWORK ============================================== # + networks: + - agenta-ee-gh-network + # === LABELS =============================================== # + labels: + - "traefik.enable=false" + # === LIFECYCLE ============================================ # + restart: always + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8765/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + postgres: # === IMAGE ================================================ # image: postgres:17 diff --git a/hosting/docker-compose/ee/env.ee.gh.example b/hosting/docker-compose/ee/env.ee.gh.example index c73338651d..06347883e0 100644 --- a/hosting/docker-compose/ee/env.ee.gh.example +++ b/hosting/docker-compose/ee/env.ee.gh.example @@ -114,12 +114,18 @@ AGENTA_CRYPT_KEY=replace-me # CRISP_WEBSITE_ID= # ================================================================== # -# daytona -# ================================================================== # -# DAYTONA_API_KEY= -# DAYTONA_API_URL=https://app.daytona.io/api -# DAYTONA_SNAPSHOT=daytona-small -# DAYTONA_TARGET=eu +# sandbox-agent +# ================================================================== # +# AGENTA_AGENT_RUNNER_URL=http://sandbox-agent:8765 +# AGENTA_AGENT_ENABLE_MCP=false +# AGENTA_SANDBOX_AGENT_IMAGE_NAME=internal-ee-agenta-sandbox-agent +# AGENTA_SANDBOX_AGENT_IMAGE_TAG=latest +# SANDBOX_AGENT_PROVIDER=local +# SANDBOX_AGENT_DAYTONA_API_KEY= +# SANDBOX_AGENT_DAYTONA_API_URL=https://app.daytona.io/api +# SANDBOX_AGENT_DAYTONA_TARGET=eu +# SANDBOX_AGENT_DAYTONA_SNAPSHOT= +# SANDBOX_AGENT_DAYTONA_IMAGE= # ================================================================== # # docker diff --git a/hosting/docker-compose/oss/docker-compose.gh.yml b/hosting/docker-compose/oss/docker-compose.gh.yml index d39ffb8643..369591c5fb 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.yml @@ -318,11 +318,17 @@ services: environment: - SCRIPT_NAME=/services - DOCKER_NETWORK_MODE=${DOCKER_NETWORK_MODE:-bridge} + - AGENTA_AGENT_RUNNER_URL=${AGENTA_AGENT_RUNNER_URL:-http://sandbox-agent:8765} + - AGENTA_AGENT_ENABLE_MCP=${AGENTA_AGENT_ENABLE_MCP:-false} # === NETWORK ============================================== # networks: - agenta-oss-gh-network extra_hosts: - "host.docker.internal:host-gateway" + # === ORCHESTRATION ======================================== # + depends_on: + sandbox-agent: + condition: service_healthy # === LABELS =============================================== # labels: - "traefik.http.routers.services.rule=PathPrefix(`/services/`)" @@ -335,6 +341,36 @@ services: # === LIFECYCLE ============================================ # restart: always + sandbox-agent: + # === IMAGE ================================================ # + image: ghcr.io/agenta-ai/${AGENTA_SANDBOX_AGENT_IMAGE_NAME:-agenta-sandbox-agent}:${AGENTA_SANDBOX_AGENT_IMAGE_TAG:-latest} + # === CONFIGURATION ======================================== # + environment: + PORT: "8765" + AGENTA_HOST: ${AGENTA_HOST:-} + AGENTA_API_KEY: ${AGENTA_API_KEY:-} + SANDBOX_AGENT_PROVIDER: ${SANDBOX_AGENT_PROVIDER:-local} + SANDBOX_AGENT_DAYTONA_API_KEY: ${SANDBOX_AGENT_DAYTONA_API_KEY:-} + SANDBOX_AGENT_DAYTONA_API_URL: ${SANDBOX_AGENT_DAYTONA_API_URL:-} + SANDBOX_AGENT_DAYTONA_TARGET: ${SANDBOX_AGENT_DAYTONA_TARGET:-} + SANDBOX_AGENT_DAYTONA_SNAPSHOT: ${SANDBOX_AGENT_DAYTONA_SNAPSHOT:-} + SANDBOX_AGENT_DAYTONA_IMAGE: ${SANDBOX_AGENT_DAYTONA_IMAGE:-} + SANDBOX_AGENT_DAYTONA_INSTALL_PI: ${SANDBOX_AGENT_DAYTONA_INSTALL_PI:-false} + # === NETWORK ============================================== # + networks: + - agenta-oss-gh-network + # === LABELS =============================================== # + labels: + - "traefik.enable=false" + # === LIFECYCLE ============================================ # + restart: always + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8765/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + postgres: # === IMAGE ================================================ # image: postgres:17 diff --git a/hosting/docker-compose/oss/env.oss.gh.example b/hosting/docker-compose/oss/env.oss.gh.example index 1c7c1c8cc8..b51b5350ad 100644 --- a/hosting/docker-compose/oss/env.oss.gh.example +++ b/hosting/docker-compose/oss/env.oss.gh.example @@ -114,12 +114,18 @@ AGENTA_CRYPT_KEY=replace-me # CRISP_WEBSITE_ID= # ================================================================== # -# daytona -# ================================================================== # -# DAYTONA_API_KEY= -# DAYTONA_API_URL=https://app.daytona.io/api -# DAYTONA_SNAPSHOT=daytona-small -# DAYTONA_TARGET=eu +# sandbox-agent +# ================================================================== # +# AGENTA_AGENT_RUNNER_URL=http://sandbox-agent:8765 +# AGENTA_AGENT_ENABLE_MCP=false +# AGENTA_SANDBOX_AGENT_IMAGE_NAME=agenta-sandbox-agent +# AGENTA_SANDBOX_AGENT_IMAGE_TAG=latest +# SANDBOX_AGENT_PROVIDER=local +# SANDBOX_AGENT_DAYTONA_API_KEY= +# SANDBOX_AGENT_DAYTONA_API_URL=https://app.daytona.io/api +# SANDBOX_AGENT_DAYTONA_TARGET=eu +# SANDBOX_AGENT_DAYTONA_SNAPSHOT= +# SANDBOX_AGENT_DAYTONA_IMAGE= # ================================================================== # # docker diff --git a/services/agent/sandbox-images/daytona/README.md b/services/agent/sandbox-images/daytona/README.md new file mode 100644 index 0000000000..f169dd7f9c --- /dev/null +++ b/services/agent/sandbox-images/daytona/README.md @@ -0,0 +1,25 @@ +# Daytona Sandbox Snapshot + +This folder contains the supported self-host recipe for building a Daytona snapshot for the +Agenta `sandbox-agent` runner path. + +We ship the recipe, not a built snapshot. The operator runs it in their own Daytona account: + +```bash +DAYTONA_API_KEY=... DAYTONA_TARGET=eu uv run build_snapshot.py --force +``` + +Configure the runner service with: + +```bash +SANDBOX_AGENT_PROVIDER=daytona +SANDBOX_AGENT_DAYTONA_SNAPSHOT=agenta-sandbox-pi +SANDBOX_AGENT_DAYTONA_INSTALL_PI=false +``` + +The recipe currently bases on the upstream full sandbox-agent image and adds the Pi CLI. +That image includes Claude Code. We do not distribute the resulting snapshot. Cloud builds its +own internal snapshot; self-hosters build their own. + +Keep credentials out of the image and snapshot. Provider keys and self-managed login paths are +runtime concerns. diff --git a/services/agent/sandbox-images/daytona/build_snapshot.py b/services/agent/sandbox-images/daytona/build_snapshot.py new file mode 100644 index 0000000000..0d7a148c26 --- /dev/null +++ b/services/agent/sandbox-images/daytona/build_snapshot.py @@ -0,0 +1,89 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["daytona"] +# /// +"""Build a Daytona snapshot for the Agenta sandbox-agent runner. + +Bakes the `pi` CLI into a sandbox-agent base image (which already ships the sandbox-agent +daemon, the Claude CLI, and CA certs) so Daytona runs don't pay a ~150s per-invoke +`npm install pi`. Set the runner service to use it: + + SANDBOX_AGENT_DAYTONA_SNAPSHOT=agenta-sandbox-pi + SANDBOX_AGENT_DAYTONA_INSTALL_PI=false + +Run: DAYTONA_API_KEY=... DAYTONA_TARGET=eu uv run build_snapshot.py [--force] + +Licensing (see services/agent/docker/README.md): + This script is the build recipe we ship, NOT a snapshot we distribute. Whoever + runs it builds the snapshot in their own Daytona account: Agenta Cloud builds + its own for internal use; self-hosters build their own. We never hand anyone a + Claude-containing image, so this is compliant even though the `-full` base bundles + Claude (Anthropic's Commercial Terms forbid us *distributing* Claude Code, not + building/using it). + + Cleaner-provenance follow-up (needs a live Daytona build to verify): base on a + daemon-only sandbox-agent image and install Claude from Anthropic at build (npm + `@anthropic-ai/claude-code` or `claude.ai/install.sh`), so the snapshot's Claude + comes straight from Anthropic instead of from a third party's bundled image. Pin + that only after confirming the daemon-only tag also ships the ACP adapters. +""" + +import sys +import time + +from daytona import ( + CreateSnapshotParams, + Daytona, + DaytonaConfig, + Image, + Resources, +) + +SNAPSHOT_NAME = "agenta-sandbox-pi" +SANDBOX_AGENT_IMAGE = "rivetdev/sandbox-agent:0.5.0-rc.2-full" +PI_PACKAGE = "@earendil-works/pi-coding-agent@0.79.4" + + +def main() -> None: + force = "--force" in sys.argv + daytona = Daytona(DaytonaConfig()) + + try: + existing = daytona.snapshot.get(SNAPSHOT_NAME) + except Exception: + existing = None + + if existing and not force: + print(f"snapshot '{SNAPSHOT_NAME}' already exists; pass --force to rebuild.") + return + if existing: + print(f"deleting existing snapshot '{SNAPSHOT_NAME}'...") + daytona.snapshot.delete(existing) + + # Base on the full sandbox-agent image (daemon + claude + certs) and add the pi CLI globally + # so it is on PATH for the sandbox user the daemon runs as. The image's default user + # is the non-root `sandbox`, so switch to root for the global install, then back. + image = Image.base(SANDBOX_AGENT_IMAGE).dockerfile_commands( + [ + "USER root", + f"RUN npm install -g --ignore-scripts {PI_PACKAGE}", + "RUN pi --version || true", + "USER sandbox", + ] + ) + + print(f"building snapshot '{SNAPSHOT_NAME}' from {SANDBOX_AGENT_IMAGE} (+ pi)...") + started = time.monotonic() + daytona.snapshot.create( + CreateSnapshotParams( + name=SNAPSHOT_NAME, + image=image, + resources=Resources(cpu=2, memory=4, disk=8), + ), + on_logs=print, + ) + print(f"\nsnapshot '{SNAPSHOT_NAME}' built in {time.monotonic() - started:.1f}s") + + +if __name__ == "__main__": + main()