From 718a94e77d08dbb9905f5e96cc034846d4859ad3 Mon Sep 17 00:00:00 2001 From: yuanhe Date: Sat, 11 Jul 2026 15:40:23 +0800 Subject: [PATCH 1/4] Consolidate sandbox images to e2b + local docker, drop K8s SandboxSet path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support scope decision: only e2b.app SaaS templates and the local docker-backed sandbox provider are supported going forward. The self-hosted K8s SandboxSet path is dropped — it required its own OpenKruise CRD infrastructure, duplicated most of the e2b.app template, and its publish pipeline had been silently broken since the initial public release (envd COPY failure, caught while investigating why ghcr.io/minimax-ai-dev/parsar-sandbox never published). - Delete infra/sandbox/parsar-daemon-claudecode/{Dockerfile,sandboxset.yaml}, infra/sandbox/parsar-daemon-claudecode-xl/sandboxset.yaml, .github/workflows/sandbox-image-release.yml, and docs/deploy/sandbox-setup.md (entirely K8s-specific). - Consolidate the two local-docker sandbox Dockerfiles (amd64 infra/sandbox/Dockerfile.local + arm64 infra/e2b-templates/parsar-sandbox-local/local.Dockerfile, which had drifted — the arm64 variant was missing OpenCode/Codex/Pi) into one multi-arch Dockerfile using TARGETARCH, deleting the arm64-only variant and its companion build.sh staging script. - The consolidated Dockerfile compiles parsar-daemon + the parsar CLI from source in its own Go builder stage, removing the prior dependency on the parsar:local server image being built first. - Drop OpenCode from the local sandbox image (Claude Code + Codex + Pi only) to keep it minimal; final image is 1.21GB vs 2.08GB before. - docker-compose.yml / install.sh: AGENT_DAEMON_SANDBOX_DOCKER_IMAGE / DEFAULT_SANDBOX_IMAGE now default to parsar-sandbox:local instead of the never-published GHCR image — this was the actual root cause of "sandbox does not start" after a fresh ./install.sh. Verified: full docker build succeeds (amd64; arm64 download URLs confirmed to exist but not build-tested, no arm64 emulation available here), and `docker run -d --entrypoint sleep parsar-sandbox:local infinity` + `docker exec ... bash -l -c` (the exact invocation server/internal/sandbox/docker/client.go uses) confirms claude/codex/ pi/parsar-daemon/parsar all resolve on PATH. Follow-up not included here: server/internal/sandbox/e2b/podip.go (K8s in-cluster pod IP resolution) is now dead code now that the self-hosted K8s path is gone; left in place since removing it means touching server Go code beyond the infra/Dockerfile scope of this change. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/sandbox-image-release.yml | 102 ------- INSTALL.md | 6 + docker-compose.yml | 12 +- docs/deploy/lan-deploy.md | 14 +- docs/deploy/sandbox-setup.md | 255 ------------------ .../parsar-sandbox-local/.gitignore | 2 - .../parsar-sandbox-local/build.sh | 45 ---- .../parsar-sandbox-local/local.Dockerfile | 40 --- infra/sandbox/Dockerfile.local | 133 ++++++--- .../sandboxset.yaml | 65 ----- .../parsar-daemon-claudecode/Dockerfile | 183 ------------- .../parsar-daemon-claudecode/sandboxset.yaml | 68 ----- install.sh | 8 +- 13 files changed, 123 insertions(+), 810 deletions(-) delete mode 100644 .github/workflows/sandbox-image-release.yml delete mode 100644 docs/deploy/sandbox-setup.md delete mode 100644 infra/e2b-templates/parsar-sandbox-local/.gitignore delete mode 100755 infra/e2b-templates/parsar-sandbox-local/build.sh delete mode 100644 infra/e2b-templates/parsar-sandbox-local/local.Dockerfile delete mode 100644 infra/sandbox/parsar-daemon-claudecode-xl/sandboxset.yaml delete mode 100644 infra/sandbox/parsar-daemon-claudecode/Dockerfile delete mode 100644 infra/sandbox/parsar-daemon-claudecode/sandboxset.yaml diff --git a/.github/workflows/sandbox-image-release.yml b/.github/workflows/sandbox-image-release.yml deleted file mode 100644 index 4357140..0000000 --- a/.github/workflows/sandbox-image-release.yml +++ /dev/null @@ -1,102 +0,0 @@ -# Build + push the agent_daemon sandbox image to GitHub Container Registry. -# -# GATED: auto-triggers (push / tag) are intentionally disabled. The -# Dockerfile copies envd out of `e2bdev/base:latest` -# (`COPY --from=e2bdev/base:latest /usr/bin/envd ...`), but upstream -# removed envd from that image, so the build currently fails with -# `"/usr/bin/envd": not found`. Until the Dockerfile sources envd from a -# working location, this workflow only runs on manual workflow_dispatch -# so it cannot red-X every push that touches infra/sandbox/**. -# -# To re-enable automatic builds: fix the envd COPY in -# infra/sandbox/parsar-daemon-claudecode/Dockerfile, then restore the -# `push:` trigger block commented out under `on:` below. -# -# Triggers (when re-enabled): -# - Push to main when files under infra/sandbox/** change -# - Tag push matching `sandbox-v*` (release tags) -# - Manual workflow_dispatch -# -# Output: ghcr.io//parsar-sandbox: -# - `latest` on main pushes -# - `` short-SHA tag on every build -# - `` on tag pushes (e.g. `sandbox-v0.0.1`) -# -# Operators using their own registry can copy this workflow into a fork -# and swap the `images:` field below. The sandboxset.yaml manifests -# point at `ghcr.io//parsar-sandbox:latest` by default. - -name: sandbox image release - -on: - # Auto-triggers disabled until the envd COPY in the Dockerfile is - # fixed (see header). Uncomment to restore automatic builds: - # push: - # branches: [main] - # paths: - # - "infra/sandbox/parsar-daemon-claudecode/**" - # - ".github/workflows/sandbox-image-release.yml" - # tags: - # - "sandbox-v*" - workflow_dispatch: - inputs: - parsar_daemon_version: - description: "parsar-daemon release tag to bake into the image (blank = latest)" - required: false - default: "" - -permissions: - contents: read - packages: write - -jobs: - build-and-push: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Compute lowercase GHCR image name - id: image - shell: bash - run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/parsar-sandbox" >> "$GITHUB_OUTPUT" - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Compute image metadata - id: meta - uses: docker/metadata-action@v6 - with: - images: ${{ steps.image.outputs.name }} - # latest on main, sha for every push, release tag on tag push - tags: | - type=ref,event=branch - type=ref,event=tag - type=sha,format=short - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push - uses: docker/build-push-action@v7 - with: - context: infra/sandbox/parsar-daemon-claudecode - file: infra/sandbox/parsar-daemon-claudecode/Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: | - PARSAR_DAEMON_REPO=${{ github.repository }} - PARSAR_DAEMON_VERSION=${{ github.event.inputs.parsar_daemon_version }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/INSTALL.md b/INSTALL.md index 80212f0..7348e5a 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -24,6 +24,12 @@ user is the administrator. - Docker Engine with Docker Compose v2. - Linux host with access to `/var/run/docker.sock`. The local compose stack enables Docker-managed agent sandboxes and mounts the Docker socket. +- To actually run Agents in sandbox mode, build the sandbox image first — + there is no published default: + ```bash + docker build -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . + ``` + Everything else in the stack works without it. ## What The Installer Does diff --git a/docker-compose.yml b/docker-compose.yml index e5a8f46..049cfc0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -154,16 +154,18 @@ services: PARSAR_DISCORD_APP_ID: "${PARSAR_DISCORD_APP_ID:-}" PARSAR_DISCORD_BOT_TOKEN: "${PARSAR_DISCORD_BOT_TOKEN:-}" # Docker sandbox — Agent runs inside a sandboxed container with - # Claude Code + OpenCode + Codex + Pi + parsar-daemon. The server - # calls docker to start / exec / stop sandbox containers via the - # mounted /var/run/docker.sock below. Build the sandbox image - # first (README's 5.2 section): + # Claude Code + Codex + Pi + parsar-daemon. The server calls docker + # to start / exec / stop sandbox containers via the mounted + # /var/run/docker.sock below. There is no published image for this — + # build one locally first (README's 5.2 section): # docker build -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . + # Until you do, sandbox-mode Agent runs fail with "no such image"; + # everything else in this stack works fine without it. # The network below is pinned to `parsar_default` — the top-level # `name: parsar` + `networks:` block at the head of this file # guarantees that alias regardless of the clone directory. AGENT_DAEMON_SANDBOX_BACKEND: "docker" - AGENT_DAEMON_SANDBOX_DOCKER_IMAGE: "${PARSAR_SANDBOX_IMAGE:-ghcr.io/minimax-ai-dev/parsar-sandbox:latest}" + AGENT_DAEMON_SANDBOX_DOCKER_IMAGE: "${PARSAR_SANDBOX_IMAGE:-parsar-sandbox:local}" AGENT_DAEMON_SANDBOX_DOCKER_NETWORK: "${PARSAR_PROJECT_NAME:-parsar}_default" AGENT_DAEMON_SANDBOX_SERVER_URL: "http://parsar-server:8080" group_add: diff --git a/docs/deploy/lan-deploy.md b/docs/deploy/lan-deploy.md index af4b675..32ccc0e 100644 --- a/docs/deploy/lan-deploy.md +++ b/docs/deploy/lan-deploy.md @@ -207,7 +207,9 @@ sudo docker run --rm --entrypoint ls parsar:local /usr/local/share/parsar/daemon ### 5.2 Build the sandbox image The sandbox image is the container Agents start when running in -Docker-sandbox mode; it contains Claude Code + Codex + parsar-daemon. +Docker-sandbox mode; it contains Claude Code + Codex + Pi CLI + +parsar-daemon. It builds parsar-daemon and the parsar CLI from source in +its own Go builder stage — independent of 5.1, order doesn't matter. ```bash # With proxy (read from .env): @@ -221,11 +223,15 @@ sudo docker build \ # Without proxy: sudo docker build -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . + +# On Apple Silicon (arm64): +sudo docker build --platform linux/arm64 -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . ``` -> `Dockerfile.local` copies the daemon binary from the server image and -> downloads the Claude Code CLI from a CDN — no GitHub Release required. -> **You must complete 5.1 before running 5.2.** +> `Dockerfile.local` downloads the Claude Code + Codex CLIs from their +> respective CDN/release endpoints and compiles parsar-daemon + the +> parsar CLI from this repo's source — no GitHub Release, no dependency +> on the server image (5.1) being built first. Verify: diff --git a/docs/deploy/sandbox-setup.md b/docs/deploy/sandbox-setup.md deleted file mode 100644 index e0fde09..0000000 --- a/docs/deploy/sandbox-setup.md +++ /dev/null @@ -1,255 +0,0 @@ -# Parsar Sandbox deployment (Kubernetes SandboxSet) - -> Self-hosted cloud sandbox for the agent_daemon connector, backed by the -> [agents.kruise.io](https://openkruise.io/) SandboxSet CRD. Each pod -> ships envd (e2b-compatible API) + Claude Code CLI + parsar-daemon, allocated -> on demand by the Parsar server's E2B-compatible client. - -This page is only needed when you want **platform-managed cloud sandboxes** -for the agent_daemon connector. Self-hosted deployments that just need -local-laptop-paired daemons can skip this entirely. - ---- - -## 0. Architecture - -``` -User sends a message → Parsar Server - ↓ - SandboxProvider.Acquire() - ↓ - E2B-compatible API (envd in pod) - ↓ - Allocate a pod from the warm pool - ↓ - Pod runs: parsar-daemon connect -b - ↓ - parsar-daemon WebSocket dials Server back - ↓ - Server delivers prompt_request → daemon runs - Claude Code → streams output → client -``` - -The warm pool is maintained by the SandboxSet controller; allocation -happens at prompt time, not pod startup, so a cold queue runs ~3-5s -instead of the 30s+ that on-demand pod creation would cost. - ---- - -## 1. Prerequisites - -| Item | Notes | -|------|-------| -| A Kubernetes cluster with [OpenKruise Agents CRDs](https://openkruise.io/docs/installation) installed | Provides the `SandboxSet` resource type | -| A container registry you control (or use `ghcr.io` via the bundled workflow) | The sandbox image is built and pushed by `.github/workflows/sandbox-image-release.yml` to `ghcr.io//parsar-sandbox` | -| A published `parsar-daemon` release | `.github/workflows/parsar-daemon-release.yml` produces GitHub Releases; the sandbox Dockerfile pulls the binary at build time | -| Parsar Server reachable from the sandbox pods | `PARSAR_PUBLIC_URL` must be resolvable inside the cluster — parsar-daemon dials back to it over WebSocket | - ---- - -## 2. Build the sandbox image - -The default path uses GitHub Actions: - -1. Fork or own a copy of this repo on GitHub. -2. Push to `main` (or tag `sandbox-v*`) — `.github/workflows/sandbox-image-release.yml` builds + pushes - `ghcr.io//parsar-sandbox:latest` (and `:sha-`, `:`). - -To build locally instead: - -```bash -cd infra/sandbox/parsar-daemon-claudecode -docker build --platform linux/amd64 \ - --build-arg PARSAR_DAEMON_REPO=/parsar \ - -t ghcr.io//parsar-sandbox:dev \ - . -docker push ghcr.io//parsar-sandbox:dev -``` - -Sanity-check the image (envd MUST be present, otherwise pods will -CrashLoopBackOff on startup): - -```bash -docker run --rm --platform linux/amd64 \ - ghcr.io//parsar-sandbox:dev \ - /bin/sh -c 'which envd && envd --version && claude --version && parsar-daemon version' -``` - -Image contents: - -- `ubuntu:22.04` base -- `/usr/bin/envd` — e2b sandbox daemon (copied from `e2bdev/base:latest`) -- `/usr/local/bin/claude` — Claude Code CLI -- `/usr/local/bin/parsar-daemon` — parsar-daemon static binary (from GitHub Releases) -- `/usr/local/bin/codex` — OpenAI Codex CLI -- `node 20 + python3 + uv` — MCP server runtimes -- `IS_SANDBOX=1` env - ---- - -## 3. Apply the SandboxSet - -The bundled manifests default to `ghcr.io//parsar-sandbox:latest` -— substitute your registry path before applying. The repo ships two pool -sizes: - -| File | Pool | Per-pod resources | -|------|------|-------------------| -| `infra/sandbox/parsar-daemon-claudecode/sandboxset.yaml` | Standard (`parsar-daemon`) | 4 vCPU, 8 GiB | -| `infra/sandbox/parsar-daemon-claudecode-xl/sandboxset.yaml` | XL (`parsar-daemon-xl`) | 4 vCPU, 50 GiB | - -Apply: - -```bash -# Replace in the image: field first, then: -kubectl apply -f infra/sandbox/parsar-daemon-claudecode/sandboxset.yaml -kubectl apply -f infra/sandbox/parsar-daemon-claudecode-xl/sandboxset.yaml # optional - -# Pool status: -kubectl -n sandbox-work get sandboxset parsar-daemon -kubectl -n sandbox-work get pods | grep parsar-daemon -``` - -Key SandboxSet fields: - -| Field | Default | Notes | -|-------|---------|-------| -| `metadata.name` | `parsar-daemon` (or `-xl`) | Also the template ID the Parsar server uses | -| `spec.replicas` | `10` / `2` | Warm pool size; cold queue starts ~3-5s, beyond pool fills new pods on demand | -| `image` | `ghcr.io//parsar-sandbox:latest` | Bump when re-tagging | -| `resources` | 4 vCPU / 8 GiB (XL: 50 GiB) | Per-pod reservation | - ---- - -## 4. Wire the Parsar Server - -Set these env vars on the Parsar Server process: - -| Env | Value | Purpose | -|-----|-------|---------| -| `PARSAR_E2B_API_BASE_URL` | The cluster-internal URL of your envd gateway | Where the server's E2B client dials | -| `PARSAR_E2B_API_KEY` | Your gateway's API key | Auth for the above | -| `AGENT_DAEMON_SANDBOX_TEMPLATE` | `parsar-daemon` | Matches `metadata.name` in `sandboxset.yaml` | -| `AGENT_DAEMON_SANDBOX_TEMPLATE_XL` | `parsar-daemon-xl` | Optional; agents requesting `sandbox_size: "xl"` route here, otherwise fall back to the standard template | - -Restart the server. The startup log should contain: - -``` -agent_daemon connector registered -agent_daemon gateway mounted -``` - ---- - -## 5. Smoke-test the chain - -### Automated (production flow) - -1. Create an Agent in the web UI with `daemon_mode: sandbox`. -2. Send a message to that Agent. -3. Server should: mint a pairing token → allocate a pod → daemon - dials back via WebSocket → first response within 5-30s. - -### Manual probe - -```bash -# Confirm the pool is warm -kubectl -n sandbox-work get pods | grep parsar-daemon - -# Exec into a pod -kubectl -n sandbox-work exec -it -c sandbox -- /bin/bash - -# Inside the pod: -claude --version -parsar-daemon version - -# Manually drive a connection (you need a pairing token from the -# admin shell first) -PARSAR_DAEMON_CONNECT_URL=https://parsar.example.com \ -PARSAR_DAEMON_CONNECT_TOKEN= \ -parsar-daemon connect --device-name probe-sandbox -b - -# Watch the log -tail -F ~/.parsar/parsar-daemon/default/connect.log -``` - ---- - -## 6. Rolling image updates - -```bash -# 1. Tag a new image (locally or via the workflow on a `sandbox-v*` tag). -docker build --platform linux/amd64 \ - -t ghcr.io//parsar-sandbox:v0.0.X \ - infra/sandbox/parsar-daemon-claudecode/ -docker push ghcr.io//parsar-sandbox:v0.0.X - -# 2. Bump the `image:` field in sandboxset.yaml. - -# 3. APPLY — never `delete`. The SandboxSet controller rolls warm-pool -# pods automatically (maxUnavailable: 20% by default). Claimed pods -# keep the old image until their natural TTL releases them. -kubectl apply -f infra/sandbox/parsar-daemon-claudecode/sandboxset.yaml - -# 4. Watch the roll out -kubectl -n sandbox-work get sandboxset parsar-daemon -w -``` - -### ⚠️ Do NOT `delete sandboxset` or `delete pod ` - -`delete sandboxset` removes every pod, including ones running live user -runs — they disconnect mid-prompt. The controller will respawn the pool, -but the in-flight user sees a hard sandbox failure. - -To list which pods are currently claimed: - -```bash -kubectl -n sandbox-work get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.labels.agents\.kruise\.io/sandbox-claimed}{"\n"}{end}' \ - | grep parsar-daemon -``` - ---- - -## 7. Troubleshooting - -| Symptom | Likely cause | Investigation | -|---------|--------------|---------------| -| `availableReplicas: 0`, pods CrashLoopBackOff | envd binary missing from image | `kubectl describe pod ` — look for `exec: "envd": executable file not found in $PATH`. Re-build the image; the Dockerfile already copies envd from `e2bdev/base:latest`, regression usually means someone removed that COPY line. | -| Pods Pending forever | Cluster lacks resources | `kubectl describe pod ` → Events | -| Pod Running but envd port closed | envd failed to start | `kubectl exec` into pod, run `envd --version`, check `/proc/1/log` | -| daemon starts but cannot dial server | Network / `PARSAR_PUBLIC_URL` unreachable from pod | `kubectl exec` into pod, `curl -v $PARSAR_PUBLIC_URL/healthz` | -| 401/403 on dial | pairing token expired or signed by a different vault | Server log grep `authenticate`; rotate the pairing token | -| Claude does not respond | Claude CLI not installed or model not configured | Pod-side `claude --version`; check the Agent's model binding | -| `sandbox acquire: context deadline exceeded` (server) | Warm pool empty | `kubectl get sandboxset parsar-daemon` — `availableReplicas` should be > 0; if not, walk back through the rows above | - ---- - -## 8. Tuning timeouts - -| Parameter | Default | Notes | -|-----------|---------|-------| -| `SandboxAcquireTimeout` | 45s | Total cold-start budget | -| `SandboxConnectTimeout` | 30s | Wait for daemon WebSocket dial-in | -| `SandboxDefaultTTL` | 30 days | Long-running sandbox lifetime | -| `SandboxIdleReapThreshold` | 30 days | Idle reaper backstop; e2b TTL also applies | - ---- - -## File reference - -``` -infra/sandbox/parsar-daemon-claudecode/ - Dockerfile # Sandbox image definition - sandboxset.yaml # Standard SandboxSet (4 vCPU / 8 GiB pool) - -infra/sandbox/parsar-daemon-claudecode-xl/ - sandboxset.yaml # XL SandboxSet (4 vCPU / 50 GiB pool) - -infra/e2b-templates/parsar-daemon-claudecode/ - e2b.Dockerfile # e2b.app template (non-Kubernetes path) - hooks/ # Claude / OpenCode hook scripts - -.github/workflows/ - sandbox-image-release.yml # ghcr.io build + push - parsar-daemon-release.yml # GitHub Releases for parsar-daemon binaries -``` diff --git a/infra/e2b-templates/parsar-sandbox-local/.gitignore b/infra/e2b-templates/parsar-sandbox-local/.gitignore deleted file mode 100644 index 281c73f..0000000 --- a/infra/e2b-templates/parsar-sandbox-local/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Build staging dir assembled by build.sh (compiled binaries + copied hooks). -.build/ diff --git a/infra/e2b-templates/parsar-sandbox-local/build.sh b/infra/e2b-templates/parsar-sandbox-local/build.sh deleted file mode 100755 index 085d7ef..0000000 --- a/infra/e2b-templates/parsar-sandbox-local/build.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -# -# Build the local docker sandbox image (parsar-sandbox:local) for the -# docker-backed SandboxProvider (AGENT_DAEMON_SANDBOX_BACKEND=docker). -# -# Compiles the parsar + parsar-daemon CLIs as static linux/arm64 binaries -# (the container runs linux/aarch64 under Docker Desktop on Apple Silicon), -# stages them alongside the shared claude/opencode hooks, then builds the -# image from local.Dockerfile. -# -# Usage: infra/e2b-templates/parsar-sandbox-local/build.sh -# Override the tag: PARSAR_SANDBOX_LOCAL_IMAGE=foo:bar build.sh -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" -BUILD_DIR="${SCRIPT_DIR}/.build" -HOOKS_SRC="${SCRIPT_DIR}/../parsar-daemon-claudecode/hooks" -IMAGE="${PARSAR_SANDBOX_LOCAL_IMAGE:-parsar-sandbox:local}" -TARGET_ARCH="${PARSAR_SANDBOX_LOCAL_ARCH:-arm64}" - -echo "[build] repo root: ${REPO_ROOT}" -echo "[build] image tag: ${IMAGE} (linux/${TARGET_ARCH})" - -rm -rf "${BUILD_DIR}" -mkdir -p "${BUILD_DIR}" - -echo "[build] compiling parsar-daemon + parsar (static linux/${TARGET_ARCH})" -( cd "${REPO_ROOT}" && CGO_ENABLED=0 GOOS=linux GOARCH="${TARGET_ARCH}" GOFLAGS=-trimpath \ - go build -ldflags='-s -w' -o "${BUILD_DIR}/parsar-daemon" ./apps/parsar-daemon/cmd/parsar-daemon ) -( cd "${REPO_ROOT}" && CGO_ENABLED=0 GOOS=linux GOARCH="${TARGET_ARCH}" GOFLAGS=-trimpath \ - go build -ldflags='-s -w' -o "${BUILD_DIR}/parsar" ./apps/parsar/cmd/parsar ) - -echo "[build] staging hooks from ${HOOKS_SRC}" -cp -R "${HOOKS_SRC}" "${BUILD_DIR}/hooks" - -echo "[build] docker build" -docker build --platform "linux/${TARGET_ARCH}" \ - -f "${SCRIPT_DIR}/local.Dockerfile" \ - -t "${IMAGE}" \ - "${BUILD_DIR}" - -echo "[build] done: ${IMAGE}" -echo "[build] run the server with:" -echo " AGENT_DAEMON_SANDBOX_BACKEND=docker AGENT_DAEMON_SANDBOX_DOCKER_IMAGE=${IMAGE} " diff --git a/infra/e2b-templates/parsar-sandbox-local/local.Dockerfile b/infra/e2b-templates/parsar-sandbox-local/local.Dockerfile deleted file mode 100644 index 8b0bfb2..0000000 --- a/infra/e2b-templates/parsar-sandbox-local/local.Dockerfile +++ /dev/null @@ -1,40 +0,0 @@ -# Local-only sandbox image for the docker-backed SandboxProvider. -# -# Unlike the sibling ../parsar-daemon-claudecode/e2b.Dockerfile (amd64, -# parsar-daemon pulled from GitHub Releases), this copies host-built -# linux/arm64 binaries so it runs natively under Docker Desktop on Apple -# Silicon. The binaries and hooks are staged into the build context by -# build.sh — do NOT `docker build` this file directly; run build.sh. -# -# Wired via: AGENT_DAEMON_SANDBOX_BACKEND=docker -# AGENT_DAEMON_SANDBOX_DOCKER_IMAGE=parsar-sandbox:local -FROM e2bdev/base:latest - -RUN apt-get update -y \ - && apt-get install -y --no-install-recommends curl ca-certificates jq \ - && rm -rf /var/lib/apt/lists/* - -# Claude Code CLI — the daemon's connect preflight refuses to register -# unless at least one supported agent CLI is on PATH. -RUN curl -fsSL https://claude.ai/install.sh | bash \ - && CLAUDE_BIN="$(command -v claude || true)" \ - && if [ -z "$CLAUDE_BIN" ] && [ -x /root/.local/bin/claude ]; then CLAUDE_BIN=/root/.local/bin/claude; fi \ - && if [ -z "$CLAUDE_BIN" ]; then echo "claude install failed: no binary on PATH"; exit 1; fi \ - && ln -sf "$CLAUDE_BIN" /usr/local/bin/claude \ - && /usr/local/bin/claude --version - -COPY parsar-daemon /usr/local/bin/parsar-daemon -COPY parsar /usr/local/bin/parsar -RUN chmod +x /usr/local/bin/parsar-daemon /usr/local/bin/parsar \ - && /usr/local/bin/parsar-daemon version \ - && /usr/local/bin/parsar --version - -COPY hooks /opt/parsar/hooks -RUN chmod -R a+rx /opt/parsar/hooks - -ENV IS_SANDBOX=1 -ENV DISABLE_TELEMETRY=1 -ENV CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 - -RUN mkdir -p /workspace -WORKDIR /workspace diff --git a/infra/sandbox/Dockerfile.local b/infra/sandbox/Dockerfile.local index 5f5a28c..87c89ba 100644 --- a/infra/sandbox/Dockerfile.local +++ b/infra/sandbox/Dockerfile.local @@ -1,34 +1,64 @@ -# Parsar sandbox image — local Docker build variant. +# Parsar local docker sandbox image — minimal, multi-arch (amd64 + arm64). # -# Differences from the K8s/e2b Dockerfile: -# - No envd (local docker sandbox uses `docker exec`, not envd API) -# - parsar-daemon copied from the parsar:local server image (no GitHub Release) -# - parsar CLI compiled in a Go builder stage (hooks need `parsar inject`) -# - Claude Code from downloads.claude.ai; OpenCode / Codex / Pi via npm +# For AGENT_DAEMON_SANDBOX_BACKEND=docker, the sandbox provider does +# `docker run -d --entrypoint sleep infinity` then drives +# everything else via `docker exec ... bash -c ` +# (server/internal/sandbox/docker/client.go). No envd, no e2b API — the +# image just needs the agent CLIs + parsar-daemon on PATH and a shell. +# +# Contents: +# - Claude Code CLI + Codex CLI + Pi CLI +# - parsar-daemon + parsar CLI (spec/memory hook injection), both +# compiled from source in the builder stage below — no dependency +# on the parsar:local server image being built first +# - Claude Code hook scripts (session-start / user-prompt-submit) +# - Node 20 + Python 3 + uv for MCP server runtimes +# +# Deliberately NOT included: OpenCode. Add it back (`npm install -g +# opencode-ai`) if a local sandbox needs to run agent_kind=opencode. # # All Agent CLI installs are FAIL-LOUD: if any of them fails, the build # aborts. A silently missing CLI would only surface at run time when a # user creates an Agent with the corresponding agent_kind and the sandbox # reports "command not found". # -# Build (from repo root): +# Build (from repo root; buildx cross-compiles the Go binaries natively +# and downloads the right CLI binary for TARGETARCH): # docker build -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . +# docker build --platform linux/arm64 -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . +# +# Build args: +# --build-arg GO_VERSION= builder Go image (default: matches repo go.work) +# --build-arg CLAUDE_CODE_VERSION= pin claude code (default: latest) +# --build-arg CODEX_VERSION= pin codex-rs (default: 0.141.0) ############################################################################### -# Stage 1: parsar-builder — compile the parsar CLI from apps/parsar/cmd/parsar. -# Hook scripts call `parsar inject snapshot` / `parsar inject incremental` -# to fetch spec+memory from the server — without this binary, hooks fail open -# and the agent boots without workspace context. +# Stage 1: go-builder — compile parsar-daemon + parsar CLI for TARGETARCH. +# --platform=$BUILDPLATFORM keeps the Go toolchain itself running natively +# (no QEMU) while GOARCH=$TARGETARCH cross-compiles the output binaries. ############################################################################### ARG GO_VERSION=1.25-bookworm -FROM golang:${GO_VERSION} AS parsar-builder +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS go-builder +ARG TARGETARCH WORKDIR /src + +# Module graph first, source second — keeps `go mod download` cacheable. COPY go.mod go.sum go.work ./ COPY go.work.sum* ./ RUN go mod download + +# parsar-daemon depends on the repo-root internal/ packages (single +# go.work module graph, no separate go.mod under apps/). +COPY internal ./internal COPY apps ./apps -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOFLAGS=-trimpath \ + +# Hook scripts call `parsar inject snapshot/incremental` to fetch +# spec+memory from the server — without this binary, hooks fail open and +# the agent boots without workspace context. +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} GOFLAGS=-trimpath \ + go build -ldflags="-s -w" -o /out/parsar-daemon ./apps/parsar-daemon/cmd/parsar-daemon +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} GOFLAGS=-trimpath \ go build -ldflags="-s -w -X github.com/MiniMax-AI-Dev/parsar/apps/parsar/internal/cli.Version=sandbox" \ -o /out/parsar ./apps/parsar/cmd/parsar @@ -36,14 +66,21 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOFLAGS=-trimpath \ # Stage 2: runtime — the actual sandbox image. ############################################################################### FROM ubuntu:22.04 +ARG TARGETARCH # --- Base tools --- +# curl/ca-certificates for downloads, jq for JSON, git for repo ops, +# ripgrep for Claude Code's code search. RUN apt-get update -y \ && apt-get install -y --no-install-recommends \ curl ca-certificates jq git ripgrep \ && rm -rf /var/lib/apt/lists/* # --- MCP server runtimes (Node + Python + uv) --- +# Most community MCP servers are launched via `npx ` or `uvx +# `; without these pre-installed, --mcp-config entries fail with +# `spawn npx/uvx ENOENT` after capability resolution already logged +# success — a silent runtime failure with no UI signal. RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && apt-get install -y --no-install-recommends nodejs python3 \ && rm -rf /var/lib/apt/lists/* \ @@ -56,6 +93,11 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && uvx --version # --- Claude Code CLI --- +# Pulled from Anthropic's downloads CDN, not claude.ai/install.sh — the +# latter sits behind a bot challenge that 403s non-browser User-Agents. +# +# To pin (e.g. for a bisect): +# docker build --build-arg CLAUDE_CODE_VERSION=2.1.179 ... ARG CLAUDE_CODE_VERSION="" RUN set -e; \ DOWNLOAD_BASE="https://downloads.claude.ai/claude-code-releases"; \ @@ -64,39 +106,54 @@ RUN set -e; \ VERSION="$(curl -fsSL "$DOWNLOAD_BASE/latest")"; \ [ -n "$VERSION" ] || { echo "claude install: failed to resolve latest version" >&2; exit 1; }; \ fi; \ - echo "Installing claude code ${VERSION}"; \ + case "$TARGETARCH" in \ + amd64) CLAUDE_ARCH=linux-x64 ;; \ + arm64) CLAUDE_ARCH=linux-arm64 ;; \ + *) echo "claude install: unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ + esac; \ + echo "Installing claude code ${VERSION} (${CLAUDE_ARCH})"; \ mkdir -p /root/.local/bin; \ - curl -fsSL "$DOWNLOAD_BASE/${VERSION}/linux-x64/claude" -o /root/.local/bin/claude; \ + curl -fsSL "$DOWNLOAD_BASE/${VERSION}/${CLAUDE_ARCH}/claude" -o /root/.local/bin/claude; \ chmod +x /root/.local/bin/claude; \ ln -sf /root/.local/bin/claude /usr/local/bin/claude; \ /usr/local/bin/claude --version -# --- OpenCode CLI (via npm, Node already installed above) --- -# Fail the build if the install breaks — a silently missing CLI would -# only surface when a user creates an agent_kind=opencode Agent and the -# sandbox reports "command not found" at runtime. -RUN npm install -g opencode-ai && opencode --version - -# --- parsar-daemon from the server image --- -COPY --from=parsar:local /usr/local/share/parsar/daemon/parsar-daemon-linux-amd64 /usr/local/bin/parsar-daemon -RUN chmod +x /usr/local/bin/parsar-daemon && /usr/local/bin/parsar-daemon version - -# --- parsar CLI (spec/memory client for hook scripts) --- -COPY --from=parsar-builder /out/parsar /usr/local/bin/parsar -RUN chmod +x /usr/local/bin/parsar && /usr/local/bin/parsar --version - -# --- Codex CLI (via npm, Node already installed above) --- -RUN npm install -g @openai/codex && codex --version +# --- OpenAI Codex CLI (codex-rs) --- +# linux-musl builds are statically linked, no glibc dependency added. +# Pinned by default for reproducible builds; bump deliberately. +# +# Override: docker build --build-arg CODEX_VERSION=0.142.0 ... +ARG CODEX_VERSION="0.141.0" +RUN set -e; \ + case "$TARGETARCH" in \ + amd64) CODEX_ARCH=x86_64-unknown-linux-musl ;; \ + arm64) CODEX_ARCH=aarch64-unknown-linux-musl ;; \ + *) echo "codex install: unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ + esac; \ + echo "Installing codex ${CODEX_VERSION} (${CODEX_ARCH})"; \ + curl -fsSL \ + "https://github.com/openai/codex/releases/download/rust-v${CODEX_VERSION}/codex-${CODEX_ARCH}.tar.gz" \ + -o /tmp/codex.tar.gz; \ + tar -xzf /tmp/codex.tar.gz -C /tmp/; \ + install -m 0755 "/tmp/codex-${CODEX_ARCH}" /usr/local/bin/codex; \ + rm -rf /tmp/codex*; \ + /usr/local/bin/codex --version -# --- Pi CLI (via npm) --- +# --- Pi CLI (via npm, Node already installed above) --- RUN npm install -g @earendil-works/pi-coding-agent && pi --version -# --- Hook scripts (Claude Code + OpenCode) --- -# Pre-baked under /opt/parsar/hooks/. Claude hooks are Python, OpenCode -# hooks are JS. Both call `parsar inject snapshot/incremental` at runtime. -# The seed step (sandbox_seed.go) writes platform-specific config that -# POINTS at these scripts — they are not loaded directly by convention. -COPY infra/e2b-templates/parsar-daemon-claudecode/hooks /opt/parsar/hooks +# --- parsar-daemon + parsar CLI (compiled in the go-builder stage) --- +COPY --from=go-builder /out/parsar-daemon /usr/local/bin/parsar-daemon +COPY --from=go-builder /out/parsar /usr/local/bin/parsar +RUN chmod +x /usr/local/bin/parsar-daemon /usr/local/bin/parsar \ + && /usr/local/bin/parsar-daemon version \ + && /usr/local/bin/parsar --version + +# --- Hook scripts (Claude Code) --- +# Pre-baked under /opt/parsar/hooks/claude/. Hook scripts call `parsar +# inject snapshot/incremental` at runtime. settings.json pointing at +# these scripts is written per-sandbox at seed time, not baked here. +COPY infra/e2b-templates/parsar-daemon-claudecode/hooks/claude /opt/parsar/hooks/claude RUN chmod -R a+rx /opt/parsar/hooks # --- Sandbox environment --- diff --git a/infra/sandbox/parsar-daemon-claudecode-xl/sandboxset.yaml b/infra/sandbox/parsar-daemon-claudecode-xl/sandboxset.yaml deleted file mode 100644 index fa6a4a0..0000000 --- a/infra/sandbox/parsar-daemon-claudecode-xl/sandboxset.yaml +++ /dev/null @@ -1,65 +0,0 @@ -# Parsar agent_daemon SandboxSet — XL variant (4 vCPU / 50 GiB). -# -# Companion pool to the default `parsar-daemon` SandboxSet -# (4 vCPU / 8 GiB). Agents whose project_agents.config.sandbox_size = "xl" -# are routed to this pool by the server's sandbox provider -# (server/internal/connector/agentdaemon/sandbox_provider.go). -# -# Same container image as the standard pool — only resources differ. -# Both images are baked from infra/sandbox/parsar-daemon-claudecode/Dockerfile. -# -# Usage: -# kubectl apply -f sandboxset.yaml -# -# # Check pool status: -# kubectl -n sandbox-work get sandboxset parsar-daemon-xl -# kubectl -n sandbox-work get pods -l sandboxset=parsar-daemon-xl - -apiVersion: agents.kruise.io/v1alpha1 -kind: SandboxSet -metadata: - name: parsar-daemon-xl - namespace: sandbox-work -spec: - # Warm pool size. XL is expensive (50 GiB reservation per pod) so we - # keep this small — 2 pods means the first two concurrent XL agents - # get a hot pod, the third triggers the controller to spawn a fresh - # pod (still much faster than scaling from 0). - # - # Cluster reservation: 2 × (4 vCPU + 50 GiB) = 8 vCPU + 100 GiB - # regardless of whether the pods are in use. - replicas: 2 - template: - spec: - containers: - - name: sandbox - # envd is the e2b sandbox daemon. See the standard pool's - # sandboxset.yaml for details. - command: ["envd", "-port", "49983"] - env: - - name: GODEBUG - value: multipathtcp=0 - - name: ENVD_DIR - value: /mnt/envd - # Keep in sync with the standard SandboxSet - # (parsar-daemon-claudecode/sandboxset.yaml) — both pools always - # run the same image, only resource limits differ. - image: ghcr.io//parsar-sandbox:latest - resources: - requests: - cpu: 4 - memory: 50Gi - limits: - cpu: 4 - memory: 50Gi - ports: - - name: envd - containerPort: 49983 - startupProbe: - failureThreshold: 20 - tcpSocket: - port: 49983 - initialDelaySeconds: 1 - periodSeconds: 2 - timeoutSeconds: 1 - terminationGracePeriodSeconds: 1 diff --git a/infra/sandbox/parsar-daemon-claudecode/Dockerfile b/infra/sandbox/parsar-daemon-claudecode/Dockerfile deleted file mode 100644 index cde3580..0000000 --- a/infra/sandbox/parsar-daemon-claudecode/Dockerfile +++ /dev/null @@ -1,183 +0,0 @@ -# Parsar agent_daemon sandbox image — envd + Claude Code CLI + parsar-daemon -# -# Contents: -# - envd (e2b sandbox daemon) → /usr/bin/envd -# - Claude Code CLI → /usr/local/bin/claude -# - parsar-daemon binary → /usr/local/bin/parsar-daemon -# - MCP server runtimes → Node 20 (npx) + Python 3 + uv (uvx) -# - Basic utilities → curl, jq, git, ripgrep -# -# NOTE: envd MUST be baked into the image. The container Command -# (["envd", "-port", "49983"]) fails with "executable file not found in -# $PATH" → CrashLoopBackOff otherwise. We pull envd from the public -# `e2bdev/base` image instead of relying on cluster-side init injection -# (some SandboxSet controllers do not honour `should-init-envd`). -# -# Build: -# docker build -t ghcr.io//parsar-sandbox: . -# -# Push: -# docker push ghcr.io//parsar-sandbox: -# -# CI (.github/workflows/sandbox-image-release.yml) builds and pushes the -# image to ghcr.io on every change under infra/sandbox/. Operators who -# want their own registry should override the `image:` field in -# sandboxset.yaml + the `` placeholder above. -# -# Build args: -# --build-arg PARSAR_DAEMON_REPO=/ override parsar-daemon source repo -# --build-arg PARSAR_DAEMON_VERSION= pin parsar-daemon to a specific release -# --build-arg CLAUDE_CODE_VERSION= pin claude-code (default: latest) -# --build-arg CODEX_VERSION= pin codex-rs (default: 0.141.0) - -FROM ubuntu:22.04 - -# --- envd binary (e2b sandbox daemon) --- -# Bake envd into the image so the SandboxSet container Command resolves. -# Pulled from the public e2b base image. -COPY --from=e2bdev/base:latest --chmod=755 /usr/bin/envd /usr/bin/envd - -# --- Base tools --- -# curl/ca-certificates for downloads, jq for JSON, git for repo ops, -# ripgrep for Claude Code's code search, plus basic coreutils the -# Claude Code subprocess may shell out to. -RUN apt-get update -y \ - && apt-get install -y --no-install-recommends \ - curl ca-certificates jq git ripgrep \ - && rm -rf /var/lib/apt/lists/* - -# --- MCP server runtimes (Node + Python + uv) --- -# Most community MCP servers in canonical_spec.mcp.servers[].command are -# launched via `npx ` (Node ecosystem) or `uvx ` (Python -# ecosystem). Without these runtimes pre-installed, Claude Code's -# --mcp-config tempfile entries fail with `spawn npx ENOENT` / `spawn uvx -# ENOENT` AFTER the server-side capability resolution has already logged -# "mcp_server_count=1" — silent runtime failure with no UI signal. -# Pre-bake both runtimes so any canonical MCP binding "just works" once -# the user supplies the credential. -# -# Choices: -# - Node 20 LTS via NodeSource: distro package (apt's nodejs) on -# ubuntu:22.04 is 12.x — too old for most @modelcontextprotocol/* -# packages. -# - python3 from distro: needed because some uvx-launched MCP servers -# pull in Python deps that link against the system Python at runtime. -# - uv installed via the official `astral.sh/uv/install.sh` script -# (NOT via pip): pip on Ubuntu 22.04 is 22.0.2 which does not yet -# accept `--break-system-packages`, and even if it did, the uv folks -# explicitly recommend their installer script over pip because it -# drops a single static binary in /root/.local/bin (no Python -# interpreter coupling). Symlink uv + uvx into /usr/local/bin so -# `exec.LookPath("uvx")` from Claude Code's MCP spawner resolves. -# -# Size impact: ~150MB (Node ~90MB + Python tooling + uv ~60MB). Acceptable -# trade for eliminating the entire silent-failure class. -RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ - && apt-get install -y --no-install-recommends nodejs python3 \ - && rm -rf /var/lib/apt/lists/* \ - && curl -LsSf https://astral.sh/uv/install.sh | sh \ - && ln -sf /root/.local/bin/uv /usr/local/bin/uv \ - && ln -sf /root/.local/bin/uvx /usr/local/bin/uvx \ - && node --version \ - && npx --version \ - && python3 --version \ - && uvx --version - -# --- Claude Code CLI --- -# Pulled directly from Anthropic's `downloads.claude.ai/claude-code-releases` -# CDN. We used to call `https://claude.ai/install.sh | bash` but that URL -# now sits behind Cloudflare's managed bot challenge (returns 403 for any -# non-browser User-Agent), so curl from `docker build` fails: -# -# curl: (22) The requested URL returned error: 403 -# -# The CDN endpoint that install.sh hits internally is NOT challenged, so -# we replicate its three steps inline: -# 1. GET /latest → resolves to a version string (or use -# the explicit CLAUDE_CODE_VERSION arg -# to pin, e.g. for a bisect) -# 2. GET //linux-x64/claude → downloads the static binary -# 3. chmod + place on PATH -# -# To pin (e.g. for a bisect): -# docker build --build-arg CLAUDE_CODE_VERSION=2.1.179 ... -ARG CLAUDE_CODE_VERSION="" -RUN set -e; \ - DOWNLOAD_BASE="https://downloads.claude.ai/claude-code-releases"; \ - VERSION="${CLAUDE_CODE_VERSION}"; \ - if [ -z "$VERSION" ]; then \ - VERSION="$(curl -fsSL "$DOWNLOAD_BASE/latest")"; \ - [ -n "$VERSION" ] || { echo "claude install: failed to resolve latest version" >&2; exit 1; }; \ - fi; \ - echo "Installing claude code ${VERSION}"; \ - mkdir -p /root/.local/bin; \ - curl -fsSL "$DOWNLOAD_BASE/${VERSION}/linux-x64/claude" -o /root/.local/bin/claude; \ - chmod +x /root/.local/bin/claude; \ - ln -sf /root/.local/bin/claude /usr/local/bin/claude; \ - /usr/local/bin/claude --version - -# --- parsar-daemon static binary --- -# Pulled from GitHub Releases. The repo and version are build args so -# forks can point at their own release stream. -# -# PARSAR_DAEMON_REPO=/ default `MiniMax-AI-Dev/parsar` -# PARSAR_DAEMON_VERSION= default resolves `releases/latest` -# (format: `parsar-daemon-v0.1.0` per the -# release workflow). -ARG PARSAR_DAEMON_REPO=MiniMax-AI-Dev/parsar -ARG PARSAR_DAEMON_VERSION="" -RUN set -e; \ - REPO="${PARSAR_DAEMON_REPO}"; \ - VERSION="${PARSAR_DAEMON_VERSION}"; \ - if [ -z "$VERSION" ]; then \ - LATEST_URL="$(curl -fsILo /dev/null -w '%{url_effective}' \ - "https://github.com/${REPO}/releases/latest")"; \ - VERSION="${LATEST_URL##*/tag/}"; \ - [ -n "$VERSION" ] && [ "$VERSION" != "$LATEST_URL" ] \ - || { echo "Failed to resolve latest parsar-daemon release from ${REPO}" >&2; exit 1; }; \ - fi; \ - BARE_VERSION="${VERSION#parsar-daemon-}"; \ - BINARY="parsar-daemon-${BARE_VERSION}-linux-amd64"; \ - echo "Installing parsar-daemon ${VERSION} (${BINARY}) from github.com/${REPO}"; \ - curl -fsSL \ - "https://github.com/${REPO}/releases/download/${VERSION}/${BINARY}" \ - -o /usr/local/bin/parsar-daemon \ - && chmod +x /usr/local/bin/parsar-daemon \ - && /usr/local/bin/parsar-daemon version - -# --- OpenAI Codex CLI (codex-rs) --- -# Downloaded directly from the openai/codex GitHub release tarball. The -# linux-musl build is statically linked, so no glibc / libc++ dependency -# is added to the image. -# -# Default pinned to keep image builds reproducible: an empty CODEX_VERSION -# would otherwise resolve the GitHub `latest` tag at build time, which -# drifts whenever upstream cuts a release. Bump this when you want the -# sandbox to pick up a new codex-rs (and bisect with --build-arg if you -# need to confirm a regression came from the pin bump itself). -# -# Override: docker build --build-arg CODEX_VERSION=0.142.0 ... -ARG CODEX_VERSION="0.141.0" -RUN set -e; \ - VERSION="${CODEX_VERSION}"; \ - if [ -z "$VERSION" ]; then \ - VERSION="$(curl -fsSL https://api.github.com/repos/openai/codex/releases/latest \ - | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"rust-v\([^"]*\)".*/\1/p' | head -1)"; \ - [ -n "$VERSION" ] || { echo "Failed to resolve latest codex version" >&2; exit 1; }; \ - fi; \ - echo "Installing codex ${VERSION}"; \ - curl -fsSL \ - "https://github.com/openai/codex/releases/download/rust-v${VERSION}/codex-x86_64-unknown-linux-musl.tar.gz" \ - -o /tmp/codex.tar.gz; \ - tar -xzf /tmp/codex.tar.gz -C /tmp/; \ - install -m 0755 /tmp/codex-x86_64-unknown-linux-musl /usr/local/bin/codex; \ - rm -rf /tmp/codex*; \ - /usr/local/bin/codex --version - -# --- Sandbox environment --- -ENV IS_SANDBOX=1 -ENV DISABLE_TELEMETRY=1 -ENV CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 - -RUN mkdir -p /workspace -WORKDIR /workspace diff --git a/infra/sandbox/parsar-daemon-claudecode/sandboxset.yaml b/infra/sandbox/parsar-daemon-claudecode/sandboxset.yaml deleted file mode 100644 index b87d757..0000000 --- a/infra/sandbox/parsar-daemon-claudecode/sandboxset.yaml +++ /dev/null @@ -1,68 +0,0 @@ -# Parsar agent_daemon SandboxSet — K8s warm pool for Claude Code sandboxes. -# -# The SandboxSet controller (agents.kruise.io) maintains a warm pool of -# sandbox pods; the Parsar server's E2B-compatible API allocates from -# this pool at prompt time. -# -# Defaults below point at the open-source ghcr.io image stream produced -# by .github/workflows/sandbox-image-release.yml; swap `` for -# your GitHub Container Registry namespace, or replace the image entirely -# with your own registry path. -# -# Usage: -# kubectl apply -f sandboxset.yaml -# -# # Update image: -# kubectl apply -f sandboxset.yaml -# -# # Check pool status: -# kubectl -n sandbox-work get sandboxset parsar-daemon -# kubectl -n sandbox-work get pods -l sandboxset=parsar-daemon - -apiVersion: agents.kruise.io/v1alpha1 -kind: SandboxSet -metadata: - name: parsar-daemon - namespace: sandbox-work -spec: - # Warm pool size. Each pod consumes 4 CPU + 8Gi, so pool size × resources - # = total cluster reservation. - replicas: 10 - template: - spec: - containers: - - name: sandbox - # envd is the e2b sandbox daemon. It listens on port 49983 and - # provides the API for running commands, reading/writing files, - # etc. The Dockerfile bakes envd into /usr/bin/envd so the - # container Command resolves without cluster-side injection. - command: ["envd", "-port", "49983"] - env: - - name: GODEBUG - value: multipathtcp=0 - - name: ENVD_DIR - value: /mnt/envd - # Pinned to the ghcr.io image stream produced by - # .github/workflows/sandbox-image-release.yml. Replace - # `` with your GitHub org/user, or override entirely - # if you push to your own registry. Bump the tag when rolling - # out a new image. - image: ghcr.io//parsar-sandbox:latest - resources: - requests: - cpu: 4 - memory: 8Gi - limits: - cpu: 4 - memory: 8Gi - ports: - - name: envd - containerPort: 49983 - startupProbe: - failureThreshold: 20 - tcpSocket: - port: 49983 - initialDelaySeconds: 1 - periodSeconds: 2 - timeoutSeconds: 1 - terminationGracePeriodSeconds: 1 diff --git a/install.sh b/install.sh index 5105939..0617cc5 100755 --- a/install.sh +++ b/install.sh @@ -3,7 +3,7 @@ set -euo pipefail DEFAULT_COMPOSE_URL="https://raw.githubusercontent.com/MiniMax-AI-Dev/parsar/main/docker-compose.yml" DEFAULT_SERVER_IMAGE="ghcr.io/minimax-ai-dev/parsar-server:latest" -DEFAULT_SANDBOX_IMAGE="ghcr.io/minimax-ai-dev/parsar-sandbox:latest" +DEFAULT_SANDBOX_IMAGE="parsar-sandbox:local" usage() { cat <<'EOF' @@ -20,8 +20,10 @@ Options: when present, otherwise download the published template. --image IMAGE parsar-server image. Default: ghcr.io/minimax-ai-dev/parsar-server:latest - --sandbox-image IMAGE Docker sandbox image. - Default: ghcr.io/minimax-ai-dev/parsar-sandbox:latest + --sandbox-image IMAGE Docker sandbox image. There is no published + default — build one first: + docker build -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . + Default: parsar-sandbox:local --port PORT Web UI host port. Default: 18080 --pg-port PORT Postgres host port. Default: 15432 --bind ADDR Web UI bind address. Default: 127.0.0.1 From 5fdb2cd93480394cb8869204230755376e10071c Mon Sep 17 00:00:00 2001 From: yuanhe Date: Sat, 11 Jul 2026 15:46:37 +0800 Subject: [PATCH 2/4] Drop the standalone OpenCode e2b template, keep one e2b template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit infra/e2b-templates now has a single template (parsar-daemon-claudecode/) instead of two. Deletes the standalone opencode/e2b.Dockerfile (ran opencode serve directly, no parsar-daemon) and the OpenCode hook scripts baked into the parsar-daemon-claudecode template's hooks/ dir, narrowing that template's COPY to hooks/claude only. Scope explicitly limited to the e2b template/image layer: OpenCode remains a fully supported agent_kind everywhere else (DB schema, API routes, web frontend, and the local parsar-daemon runtime at apps/parsar-daemon/internal/agent/opencode/ are all untouched — that support spans ~35 server files plus a standalone npm package, too large to fold into this change). The only user-visible effect: an Agent configured with agent_kind=opencode + daemon_mode=sandbox on the e2b backend has no image to run against anymore. Local-daemon-mode OpenCode and every other agent_kind are unaffected. docs/spec-memory-module.md still describes OpenCode's hook design referencing the now-deleted infra/e2b-templates/.../hooks/opencode/ path — left as-is since that doc covers the still-supported OpenCode architecture broadly, not just the e2b template. Co-Authored-By: Claude Sonnet 5 --- infra/e2b-templates/opencode/e2b.Dockerfile | 77 --------- .../parsar-daemon-claudecode/e2b.Dockerfile | 20 ++- .../hooks/opencode/per-turn-injection.js | 147 ------------------ .../hooks/opencode/session-injection.js | 142 ----------------- 4 files changed, 9 insertions(+), 377 deletions(-) delete mode 100644 infra/e2b-templates/opencode/e2b.Dockerfile delete mode 100644 infra/e2b-templates/parsar-daemon-claudecode/hooks/opencode/per-turn-injection.js delete mode 100644 infra/e2b-templates/parsar-daemon-claudecode/hooks/opencode/session-injection.js diff --git a/infra/e2b-templates/opencode/e2b.Dockerfile b/infra/e2b-templates/opencode/e2b.Dockerfile deleted file mode 100644 index 475ab69..0000000 --- a/infra/e2b-templates/opencode/e2b.Dockerfile +++ /dev/null @@ -1,77 +0,0 @@ -# Parsar opencode sandbox template — base image w/ opencode + provider adapters preinstalled -# -# Builds an E2B sandbox image where: -# - opencode CLI is installed globally via npm (using its native nvm bin/ symlink) -# - the 4 provider adapters Parsar ships are pre-resolved in npm cache: -# @ai-sdk/anthropic, @ai-sdk/openai, @ai-sdk/google, @ai-sdk/openai-compatible -# - Node 22 LTS is on PATH (opencode requires modern Node) -# - jq + curl are present for opencode.json prep -# -# After build, Parsar BuildSandboxRunner only needs to: -# 1. write opencode.json into the sandbox (per-run model + API key) -# 2. spawn `opencode serve --hostname 0.0.0.0 --port 4096` -# No npm install at spawn time → boot ~3-5s instead of 30s+. -# -# CRITICAL: opencode-ai npm package ships its Linux bundle as -# `lib/node_modules/opencode-ai/bin/opencode.exe` (yes, even on Linux — -# Bun-compiled single-file binary that npm names with .exe historically). -# npm itself creates the correct unsuffixed shim at -# `/opencode`. We must NOT hand-roll a symlink to the .exe file -# directly — we must point at npm's auto-generated shim, OR put the npm -# nvm bin dir on PATH and let `which opencode` resolve naturally. -# We do BOTH defensively so `opencode` resolves regardless of $PATH -# pruning by any caller / sandbox runtime. - -FROM e2bdev/base:latest - -# --- Base tools (curl + jq for opencode.json prep) --- -RUN apt-get update -y \ - && apt-get install -y --no-install-recommends curl ca-certificates jq \ - && rm -rf /var/lib/apt/lists/* - -# --- Node 22 LTS via nvm --- -ENV NVM_DIR=/usr/local/nvm -RUN mkdir -p "$NVM_DIR" \ - && curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash \ - && . "$NVM_DIR/nvm.sh" \ - && nvm install 22 \ - && nvm alias default 22 \ - && NODE_BIN="$(dirname "$(nvm which 22)")" \ - && ln -sf "$NODE_BIN/node" /usr/local/bin/node \ - && ln -sf "$NODE_BIN/npm" /usr/local/bin/npm \ - && ln -sf "$NODE_BIN/npx" /usr/local/bin/npx \ - && echo "NODE_BIN=$NODE_BIN" > /etc/profile.d/node_bin.sh - -# --- opencode CLI + provider adapters + registered MCP tools --- -# -# Pre-installed globally into the v22 node_modules so `npx -y ` -# at sandbox spawn finds them without a network round-trip (saves -# ~20s on cold starts). -# -# Registered MCP tools that get pre-warmed here MUST mirror the -# server-side tool registry in server/internal/tool/registry.go: -# - github_mcp → @modelcontextprotocol/server-github -# When the registry adds a new tool, append the package here and -# rebuild + re-publish the template (semver-bump the tag, e.g. -# parsar-opencode-base:v0.3-). -RUN . "$NVM_DIR/nvm.sh" && nvm use default \ - && npm install -g \ - opencode-ai \ - @ai-sdk/anthropic \ - @ai-sdk/openai \ - @ai-sdk/google \ - @ai-sdk/openai-compatible \ - @modelcontextprotocol/server-github \ - && NODE_BIN="$(dirname "$(nvm which 22)")" \ - && ls -la "$NODE_BIN/opencode" \ - && ln -sf "$NODE_BIN/opencode" /usr/local/bin/opencode \ - && /usr/local/bin/opencode --version - -# --- workspace + PATH including the nvm v22 bin dir --- -WORKDIR /home/user -# Hardcode nvm v22 bin path into PATH so callers that override PATH still get -# opencode + node + npm. Use a glob expansion at runtime via /etc/profile.d -# would not help non-login shells; baking the v22.x path in is acceptable -# because `nvm install 22` pins to the current latest 22.x — if 22.x bumps -# we rebuild the template anyway. -ENV PATH=/usr/local/bin:/usr/local/nvm/versions/node/v22.22.3/bin:${PATH} diff --git a/infra/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile b/infra/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile index 2863a38..76afbbc 100644 --- a/infra/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile +++ b/infra/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile @@ -6,8 +6,8 @@ # - parsar CLI (spec/memory client) is installed under /usr/local/bin/parsar, compiled # on-the-fly in Stage 1 from apps/parsar/cmd/parsar. Multi-stage keeps the Go toolchain # out of the runtime image (final image only contains the static binary). -# - Hook scripts for Claude Code + OpenCode are pre-baked under -# /opt/parsar/hooks/{claude,opencode}/. Platform settings files +# - Hook scripts for Claude Code are pre-baked under +# /opt/parsar/hooks/claude/. Platform settings files # (~/.claude/settings.json etc.) are NOT pre-baked — server's sandbox # provider writes them at runtime via e2b Exec because they carry # runtime-specific values. @@ -118,16 +118,14 @@ COPY --from=parsar-builder /out/parsar /usr/local/bin/parsar RUN chmod +x /usr/local/bin/parsar \ && /usr/local/bin/parsar --version -# --- Hook scripts (Claude Code + OpenCode) --- -# Pre-baked under /opt/parsar/hooks/. The Claude Python hooks must be -# executable since Claude Code invokes them directly as commands. OpenCode -# plugins are loaded by `node` so executable-bit doesn't matter, but we -# set it anyway for uniformity. +# --- Hook scripts (Claude Code) --- +# Pre-baked under /opt/parsar/hooks/claude/. The Python hooks must be +# executable since Claude Code invokes them directly as commands. # -# Platform settings.json / opencode config that POINTS at these scripts is -# NOT baked here — the server's sandbox provider writes those at runtime -# (carries PARSAR_* env vars + sandbox-specific values). -COPY infra/e2b-templates/parsar-daemon-claudecode/hooks /opt/parsar/hooks +# settings.json that POINTS at these scripts is NOT baked here — the +# server's sandbox provider writes it at runtime (carries PARSAR_* env +# vars + sandbox-specific values). +COPY infra/e2b-templates/parsar-daemon-claudecode/hooks/claude /opt/parsar/hooks/claude RUN chmod -R a+rx /opt/parsar/hooks # --- Sandbox identity --- diff --git a/infra/e2b-templates/parsar-daemon-claudecode/hooks/opencode/per-turn-injection.js b/infra/e2b-templates/parsar-daemon-claudecode/hooks/opencode/per-turn-injection.js deleted file mode 100644 index 135ad88..0000000 --- a/infra/e2b-templates/parsar-daemon-claudecode/hooks/opencode/per-turn-injection.js +++ /dev/null @@ -1,147 +0,0 @@ -// OpenCode plugin — per-turn memory delta injector. -// -// Mirrors hooks/claude/user-prompt-submit.py: on each user message, -// read /tmp/parsar-cursor, ask `parsar inject incremental` for memories -// created after that point, advance the cursor, and return the delta. -// -// FAIL-OPEN: failures log to stderr + /tmp/parsar-hook-failures.log and -// return "" so the turn proceeds. -// -// Missing cursor → silent no-op (expected when SessionStart itself -// failed-open). Per-turn pulling the full history retroactively would -// defeat the point. - -const { spawn } = require("node:child_process"); -const { promises: fs } = require("node:fs"); - -const CURSOR_PATH = "/tmp/parsar-cursor"; -const FAILURE_LOG = "/tmp/parsar-hook-failures.log"; -const SUBPROCESS_TIMEOUT_MS = 5_000; -const HOOK_LABEL = "UserPromptSubmit"; - -// Same regex as the Python side so a malformed cursor (partial write -// from a crashed SessionStart) is caught before invoking parsar. -const RFC3339_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; - -function nowRfc3339() { - return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); -} - -async function logFailure(stage, detail) { - const line = `[${nowRfc3339()}] ${HOOK_LABEL} ${stage}: ${detail}\n`; - try { - process.stderr.write(line); - } catch (_e) { - /* drop */ - } - try { - await fs.appendFile(FAILURE_LOG, line, "utf-8"); - } catch (_e) { - /* drop */ - } -} - -async function readCursor() { - let raw; - try { - raw = (await fs.readFile(CURSOR_PATH, "utf-8")).trim(); - } catch (err) { - if (err && err.code === "ENOENT") { - return null; - } - await logFailure("read-cursor", String(err)); - return null; - } - if (!RFC3339_RE.test(raw)) { - await logFailure("cursor-malformed", raw.slice(0, 120)); - return null; - } - return raw; -} - -function runIncremental(since) { - return new Promise((resolve, reject) => { - let settled = false; - const child = spawn("parsar", ["inject", "incremental", "--since", since], { - stdio: ["ignore", "pipe", "pipe"], - }); - const stdoutChunks = []; - const stderrChunks = []; - const finish = (fn, arg) => { - if (settled) return; - settled = true; - clearTimeout(timer); - fn(arg); - }; - const timer = setTimeout(() => { - try { - child.kill("SIGKILL"); - } catch (_e) { - /* drop */ - } - finish(reject, `incremental-timeout: ${SUBPROCESS_TIMEOUT_MS}ms`); - }, SUBPROCESS_TIMEOUT_MS); - child.stdout.on("data", (b) => stdoutChunks.push(b)); - child.stderr.on("data", (b) => stderrChunks.push(b)); - child.on("error", (err) => { - finish(reject, `parsar-spawn-error: ${err && err.message}`); - }); - child.on("close", (code) => { - if (code !== 0) { - const errText = Buffer.concat(stderrChunks).toString("utf-8").trim(); - finish(reject, `parsar-exit ${code}: ${errText.slice(0, 500)}`); - return; - } - try { - const text = Buffer.concat(stdoutChunks).toString("utf-8"); - finish(resolve, JSON.parse(text)); - } catch (err) { - finish(reject, `incremental-parse: ${err && err.message}`); - } - }); - }); -} - -/** Returns the incremental text to prepend to this turn (possibly - * empty). Cursor is advanced BEFORE the fetch — same rationale as the - * Python hook: avoid double-pulling the same window if the call - * takes longer than the user's typing speed. */ -async function injectPerTurn() { - const since = await readCursor(); - if (since == null) { - return ""; - } - - try { - await fs.writeFile(CURSOR_PATH, nowRfc3339(), "utf-8"); - } catch (err) { - await logFailure("write-cursor", String(err)); - // Worst case: the next turn re-pulls the same window. Still - // better than failing the whole hook over a cursor write. - } - - let bundle; - try { - bundle = await runIncremental(since); - } catch (detail) { - await logFailure("incremental-error", String(detail)); - return ""; - } - - const delta = bundle && bundle.incremental_memory; - return typeof delta === "string" ? delta.trim() : ""; -} - -// Hook event names (onChatMessage / onUserPromptSubmit) are scaffold -// pending OpenCode contract confirmation. -module.exports = { - default: async function parsarPerTurnInjectionPlugin(_ctx) { - return { - name: "parsar.per-turn-injection", - onChatMessage: injectPerTurn, - onUserPromptSubmit: injectPerTurn, - }; - }, - injectPerTurn, - __internal: { runIncremental, readCursor, nowRfc3339 }, -}; diff --git a/infra/e2b-templates/parsar-daemon-claudecode/hooks/opencode/session-injection.js b/infra/e2b-templates/parsar-daemon-claudecode/hooks/opencode/session-injection.js deleted file mode 100644 index ae13523..0000000 --- a/infra/e2b-templates/parsar-daemon-claudecode/hooks/opencode/session-injection.js +++ /dev/null @@ -1,142 +0,0 @@ -// OpenCode plugin — SessionStart spec+memory injector. -// -// Mirrors hooks/claude/session-start.py for OpenCode: on the first -// chat.message of a session, run `parsar inject snapshot`, prepend the -// spec/memory bundle as system context, and drop /tmp/parsar-cursor so -// per-turn-injection.js knows the delta baseline. -// -// FAIL-OPEN: any failure logs to stderr + /tmp/parsar-hook-failures.log -// and returns "" so the session boots whether or not parsar succeeded. -// -// SCAFFOLD: OpenCode's plugin lifecycle hook names aren't pinned — -// see the default export at the bottom; integration step decides -// which event the runtime emits. - -const { spawn } = require("node:child_process"); -const { promises: fs } = require("node:fs"); - -const CURSOR_PATH = "/tmp/parsar-cursor"; -const FAILURE_LOG = "/tmp/parsar-hook-failures.log"; -const SUBPROCESS_TIMEOUT_MS = 5_000; -const HOOK_LABEL = "SessionStart"; - -/** UTC RFC3339 with trailing Z — matches parsar CLI / server format. */ -function nowRfc3339() { - return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); -} - -/** Best-effort log: never throws. Failure-to-log is silently swallowed - * because the alternative would crash the hook on disk-full sandboxes. */ -async function logFailure(stage, detail) { - const line = `[${nowRfc3339()}] ${HOOK_LABEL} ${stage}: ${detail}\n`; - try { - process.stderr.write(line); - } catch (_e) { - /* drop */ - } - try { - await fs.appendFile(FAILURE_LOG, line, "utf-8"); - } catch (_e) { - /* drop */ - } -} - -/** Promise-wrapped `parsar inject snapshot` with a hard timeout. Resolves - * the parsed JSON bundle; rejects with a stage/detail string callers - * convert to fail-open. */ -function runSnapshot() { - return new Promise((resolve, reject) => { - let settled = false; - const child = spawn("parsar", ["inject", "snapshot"], { - stdio: ["ignore", "pipe", "pipe"], - }); - const stdoutChunks = []; - const stderrChunks = []; - const finish = (fn, arg) => { - if (settled) return; - settled = true; - clearTimeout(timer); - fn(arg); - }; - const timer = setTimeout(() => { - try { - child.kill("SIGKILL"); - } catch (_e) { - /* drop */ - } - finish(reject, `snapshot-timeout: ${SUBPROCESS_TIMEOUT_MS}ms`); - }, SUBPROCESS_TIMEOUT_MS); - child.stdout.on("data", (b) => stdoutChunks.push(b)); - child.stderr.on("data", (b) => stderrChunks.push(b)); - child.on("error", (err) => { - finish(reject, `parsar-spawn-error: ${err && err.message}`); - }); - child.on("close", (code) => { - if (code !== 0) { - const errText = Buffer.concat(stderrChunks).toString("utf-8").trim(); - finish(reject, `parsar-exit ${code}: ${errText.slice(0, 500)}`); - return; - } - try { - const text = Buffer.concat(stdoutChunks).toString("utf-8"); - finish(resolve, JSON.parse(text)); - } catch (err) { - finish(reject, `snapshot-parse: ${err && err.message}`); - } - }); - }); -} - -/** Stitch the three blocks in the same order as the Claude hook so the - * agent sees identical context across platforms. */ -function renderContext(bundle) { - const parts = []; - for (const key of ["spec_block", "memory_block", "memory_write_guide"]) { - const value = bundle && bundle[key]; - if (typeof value === "string" && value.trim()) { - parts.push(value.trim()); - } - } - return parts.join("\n\n"); -} - -/** Returns a context string (possibly empty) and writes the cursor - * file as a side effect. Callers treat the return value as opaque - * text to prepend to the system prompt. */ -async function injectSessionStart() { - const cursorTs = nowRfc3339(); - // Write cursor first: even if the snapshot fetch fails, the per-turn - // hook has a sane baseline and won't retroactively dump history. - try { - await fs.writeFile(CURSOR_PATH, cursorTs, "utf-8"); - } catch (err) { - await logFailure("write-cursor", String(err)); - } - - let bundle; - try { - bundle = await runSnapshot(); - } catch (detail) { - await logFailure("snapshot-error", String(detail)); - return ""; - } - return renderContext(bundle); -} - -// OpenCode plugin convention: export a factory as default. The hook -// name guesses (onChatStart/onSessionStart) are scaffold pending -// OpenCode contract confirmation. -module.exports = { - default: async function parsarSessionInjectionPlugin(_ctx) { - return { - name: "parsar.session-injection", - onChatStart: injectSessionStart, - onSessionStart: injectSessionStart, - }; - }, - // Exported for direct call by the connector / AGENTS.md generator - // that wants to reuse this rendering without booting a real plugin. - injectSessionStart, - renderContext, - __internal: { runSnapshot, nowRfc3339 }, -}; From 864d5c6309299a06261ace746123f3b6825f8678 Mon Sep 17 00:00:00 2001 From: yuanhe Date: Sat, 11 Jul 2026 16:04:19 +0800 Subject: [PATCH 3/4] Unify infra/e2b-templates into infra/sandbox/, one shared install script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit infra/ now has a single directory for everything sandbox-image-related instead of two top-level dirs that each reached across into the other (Dockerfile.e2b pulling scripts from infra/sandbox/scripts/, Dockerfile.local pulling hooks from infra/e2b-templates/.../hooks/): infra/sandbox/ Dockerfile.local (was already here) Dockerfile.e2b (moved from infra/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile) scripts/install-agents.sh (was 4 separate install-*.sh, now 1) hooks/claude/*.py (moved from infra/e2b-templates/.../hooks/claude/) - Collapsed install-claude-code.sh / install-codex.sh / install-node.sh / install-pi.sh into one install-agents.sh (arch + version knobs via positional arg + env vars). Both Dockerfiles now COPY + RUN the same single script instead of four. - Backfilled Dockerfile.e2b with Codex + Pi (previously Claude-Code-only, inconsistent with Dockerfile.local) and swapped its Claude Code install from the claude.ai/install.sh bot-challenge-prone path to the CDN method Dockerfile.local already used. - Fixed a real bug installing Pi found while backfilling: Node 20 is not enough — @earendil-works/pi-coding-agent pins engines.node >=22.19.0 and fails at *runtime* on 20.x (TypeError: webidl.util.markAsUncloneable is not a function, from undici's Node-22-only Cache API), not just an npm EBADENGINE warning. Bumped the shared script to Node 22 and pinned pi to 0.80.6 for reproducibility (matching the existing Codex/Claude pin pattern). - Fixed a second bug the Node bump surfaced on Dockerfile.e2b only: e2bdev/base:latest ships its own /usr/local/bin/node (v20.9.0) baked into the image. nodesource's .deb actually installs to /usr/bin/node, but /usr/local/bin wins on PATH, so every npm-installed CLI was silently running on the stale bundled Node. install-agents.sh now force-relinks /usr/local/bin/{node,npm,npx} to the dpkg-installed /usr/bin/ binaries after install (hardcoded path, not `command -v` — that still resolves the stale shadow). - Updated the few remaining path references: .github/workflows/ parsar-daemon-release.yml, server/internal/connector/agentdaemon/ sandbox_seed.go (comment only), docs/spec-memory-module.md. Verified: both Dockerfile.local and Dockerfile.e2b build clean end to end; `docker run --rm bash -c 'node/claude/codex/pi --version'` confirms all four CLIs resolve to the intended pinned versions on both images (Node 22.23.1, Claude 2.1.207, Codex 0.141.0, Pi 0.80.6). Co-Authored-By: Claude Sonnet 5 --- .github/workflows/parsar-daemon-release.yml | 4 +- docs/spec-memory-module.md | 16 ++-- .../e2b.Dockerfile => sandbox/Dockerfile.e2b} | 39 ++++---- infra/sandbox/Dockerfile.local | 87 +++++------------- .../hooks/claude/session-start.py | 0 .../hooks/claude/user-prompt-submit.py | 0 infra/sandbox/scripts/install-agents.sh | 90 +++++++++++++++++++ .../connector/agentdaemon/sandbox_seed.go | 2 +- 8 files changed, 145 insertions(+), 93 deletions(-) rename infra/{e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile => sandbox/Dockerfile.e2b} (85%) rename infra/{e2b-templates/parsar-daemon-claudecode => sandbox}/hooks/claude/session-start.py (100%) rename infra/{e2b-templates/parsar-daemon-claudecode => sandbox}/hooks/claude/user-prompt-submit.py (100%) create mode 100755 infra/sandbox/scripts/install-agents.sh diff --git a/.github/workflows/parsar-daemon-release.yml b/.github/workflows/parsar-daemon-release.yml index b4b738a..5e9881d 100644 --- a/.github/workflows/parsar-daemon-release.yml +++ b/.github/workflows/parsar-daemon-release.yml @@ -10,7 +10,7 @@ # implementation for that OS. # # The same artifacts are consumed by the sandbox image build (the e2b -# Dockerfile at infra/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile +# Dockerfile at infra/sandbox/Dockerfile.e2b # pins PARSAR_DAEMON_VERSION and curls the matching linux/amd64 binary from # the deployment's download mirror). When cutting a sandbox template # rebuild, bump the tag here first, wait for this workflow to publish, @@ -149,7 +149,7 @@ jobs: ## Install (sandbox template) Bump `PARSAR_DAEMON_VERSION` in - `infra/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile` + `infra/sandbox/Dockerfile.e2b` to this release, then rebuild the e2b template. ## Install (local laptop) diff --git a/docs/spec-memory-module.md b/docs/spec-memory-module.md index 0073c42..9c07b19 100644 --- a/docs/spec-memory-module.md +++ b/docs/spec-memory-module.md @@ -410,7 +410,7 @@ ValidateRunnerToken(ctx, token) (RuntimeIdentity, error) ### 3.4 Dockerfile changes -File: `infra/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile` +File: `infra/sandbox/Dockerfile.e2b` New content (after the existing parsar-daemon install section): ```dockerfile @@ -419,14 +419,14 @@ ARG PARSAR_VERSION=... RUN curl -fSL "$GITLAB_URL/parsar-$PARSAR_VERSION-linux-amd64" -o /usr/local/bin/parsar \ && chmod +x /usr/local/bin/parsar -# Hook scripts (COPYed from the build context; source at infra/e2b-templates/parsar-daemon-claudecode/hooks/) +# Hook scripts (COPYed from the build context; source at infra/sandbox/hooks/) COPY hooks/claude /opt/parsar/hooks/claude COPY hooks/opencode /opt/parsar/hooks/opencode RUN chmod +x /opt/parsar/hooks/claude/*.py ``` Hook script sources go under -`infra/e2b-templates/parsar-daemon-claudecode/hooks/{claude,opencode}/`. +`infra/sandbox/hooks/{claude,opencode}/`. **`.claude/settings.json` / opencode config are not seeded into the image**; instead, after the sandbox starts, the server calls e2b Exec to @@ -548,9 +548,9 @@ Put the helper `seedPlatformConfig` in `sandbox_provider.go` or a new 19. Cross-compile (linux/amd64 + linux/arm64), publish to GitLab artifact. ### Phase 7: sandbox image & hooks -20. Modify `infra/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile` to install the `parsar` binary + COPY the hook script directory. -21. Create `infra/e2b-templates/parsar-daemon-claudecode/hooks/claude/{session-start.py, user-prompt-submit.py}`. -22. Create `infra/e2b-templates/parsar-daemon-claudecode/hooks/opencode/{session-injection.js, per-turn-injection.js}`. +20. Modify `infra/sandbox/Dockerfile.e2b` to install the `parsar` binary + COPY the hook script directory. +21. Create `infra/sandbox/hooks/claude/{session-start.py, user-prompt-submit.py}`. +22. Create `infra/sandbox/hooks/opencode/{session-injection.js, per-turn-injection.js}`. 23. Rewrite the three-platform hooks uniformly to "call `parsar inject ...` for data" (injection logic centralizes in Go). ### Phase 8: sandbox lifecycle changes @@ -585,8 +585,8 @@ depends on 4. | Sandbox lifecycle | `server/internal/connector/agentdaemon/sandbox_provider.go` (change; add seedPlatformConfig) | | Proto (no change) | `internal/agentdaemon/proto/outbound.go` (already supports the AgentOptions map) | | Audit hookup (no schema change) | `server/internal/audit/postgres.go:24-50` current `PostgresSink.Write()` | -| Dockerfile | `infra/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile` (change) | -| Hook scripts | `infra/e2b-templates/parsar-daemon-claudecode/hooks/{claude,opencode}/` (new) | +| Dockerfile | `infra/sandbox/Dockerfile.e2b` (change) | +| Hook scripts | `infra/sandbox/hooks/{claude,opencode}/` (new) | | CLI source | `cmd/parsar/` (new) | | UI | Depends on the frontend layout; typically `web/src/pages/workspace/spec/` etc. (mirror the existing workspace settings page) | diff --git a/infra/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile b/infra/sandbox/Dockerfile.e2b similarity index 85% rename from infra/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile rename to infra/sandbox/Dockerfile.e2b index 76afbbc..ddcd40a 100644 --- a/infra/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile +++ b/infra/sandbox/Dockerfile.e2b @@ -1,7 +1,11 @@ -# Parsar agent_daemon sandbox template — base image w/ Claude Code CLI + parsar-daemon preinstalled +# Parsar agent_daemon sandbox template — base image w/ Claude Code + Codex + +# Pi CLIs + parsar-daemon preinstalled # # Builds an E2B sandbox image where: -# - Claude Code CLI is installed under /usr/local/bin/claude (Anthropic's installer) +# - Claude Code + Codex + Pi CLIs are installed under /usr/local/bin/ +# (install logic shared with infra/sandbox/Dockerfile.local via +# infra/sandbox/scripts/install-agents.sh — edit there, not inline +# here, so the two images can't drift apart) # - parsar-daemon static linux/amd64 binary is installed under /usr/local/bin/parsar-daemon # - parsar CLI (spec/memory client) is installed under /usr/local/bin/parsar, compiled # on-the-fly in Stage 1 from apps/parsar/cmd/parsar. Multi-stage keeps the Go toolchain @@ -21,11 +25,10 @@ # 1. Sandbox.Create("parsar-daemon-claudecode", { timeout }) # 2. RunCommand: parsar-daemon connect --device-name -b (URL/token via env) # 3. Wait for the gateway to see deviceId dial in (gateway.Registry.WaitForDevice) -# No npm / curl at spawn time → boot ~3-5s instead of 30s+. +# No curl at spawn time → boot ~3-5s instead of 30s+. # # Build (from REPO ROOT, not this directory — Stage 1 needs apps/parsar source): -# docker build -f infra/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile \ -# -t parsar-daemon-claudecode . +# docker build -f infra/sandbox/Dockerfile.e2b -t parsar-daemon-claudecode . # # CRITICAL: parsar-daemon ships as a static Go binary, downloaded from GitHub # Releases at build time. CI publishes new binaries on every release tag @@ -45,7 +48,7 @@ # Only apps/ is COPY'd (parsar only imports apps/parsar/internal/cli) so server- # side changes don't bust this layer's cache. ############################################################################### -ARG GO_VERSION=1.24-bookworm +ARG GO_VERSION=1.25-bookworm FROM golang:${GO_VERSION} AS parsar-builder WORKDIR /src @@ -67,23 +70,23 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOFLAGS=-trimpath \ # Stage 2: runtime — the actual sandbox image. ############################################################################### FROM e2bdev/base:latest +ARG TARGETARCH=amd64 # --- Base tools (curl + ca-certs for downloads, jq optional for debugging) --- RUN apt-get update -y \ && apt-get install -y --no-install-recommends curl ca-certificates jq \ && rm -rf /var/lib/apt/lists/* -# --- Claude Code CLI --- -# Anthropic's installer drops claude into /root/.local/bin by default; symlink -# it into /usr/local/bin so the daemon's exec.LookPath("claude") works without -# a PATH override. The `claude --version` sanity-check at the end fails the -# build loudly if the installer ever changes layout. -RUN curl -fsSL https://claude.ai/install.sh | bash \ - && CLAUDE_BIN="$(command -v claude || true)" \ - && if [ -z "$CLAUDE_BIN" ] && [ -x /root/.local/bin/claude ]; then CLAUDE_BIN=/root/.local/bin/claude; fi \ - && if [ -z "$CLAUDE_BIN" ]; then echo "claude install failed: no binary on PATH"; exit 1; fi \ - && ln -sf "$CLAUDE_BIN" /usr/local/bin/claude \ - && /usr/local/bin/claude --version +# --- Agent CLIs (Node + Claude Code + Codex + Pi) --- +# See infra/sandbox/scripts/install-agents.sh for the install logic and +# version-pin build args. +ARG CLAUDE_CODE_VERSION="" +ARG CODEX_VERSION="0.141.0" +ARG PI_VERSION="0.80.6" +COPY infra/sandbox/scripts/install-agents.sh /tmp/install-agents.sh +RUN CLAUDE_CODE_VERSION="$CLAUDE_CODE_VERSION" CODEX_VERSION="$CODEX_VERSION" PI_VERSION="$PI_VERSION" \ + /tmp/install-agents.sh "$TARGETARCH" \ + && rm -f /tmp/install-agents.sh # --- parsar-daemon static binary --- # Pulled from GitHub Releases (default `MiniMax-AI-Dev/parsar`). CI publishes @@ -125,7 +128,7 @@ RUN chmod +x /usr/local/bin/parsar \ # settings.json that POINTS at these scripts is NOT baked here — the # server's sandbox provider writes it at runtime (carries PARSAR_* env # vars + sandbox-specific values). -COPY infra/e2b-templates/parsar-daemon-claudecode/hooks/claude /opt/parsar/hooks/claude +COPY infra/sandbox/hooks/claude /opt/parsar/hooks/claude RUN chmod -R a+rx /opt/parsar/hooks # --- Sandbox identity --- diff --git a/infra/sandbox/Dockerfile.local b/infra/sandbox/Dockerfile.local index 87c89ba..985fb61 100644 --- a/infra/sandbox/Dockerfile.local +++ b/infra/sandbox/Dockerfile.local @@ -7,21 +7,19 @@ # image just needs the agent CLIs + parsar-daemon on PATH and a shell. # # Contents: -# - Claude Code CLI + Codex CLI + Pi CLI +# - Claude Code CLI + Codex CLI + Pi CLI (installed by the shared +# infra/sandbox/scripts/install-agents.sh — also used by +# Dockerfile.e2b, so the two images can't drift apart again) # - parsar-daemon + parsar CLI (spec/memory hook injection), both # compiled from source in the builder stage below — no dependency # on the parsar:local server image being built first # - Claude Code hook scripts (session-start / user-prompt-submit) -# - Node 20 + Python 3 + uv for MCP server runtimes +# - Python 3 + uv for MCP server runtimes (Node comes from +# install-agents.sh) # # Deliberately NOT included: OpenCode. Add it back (`npm install -g # opencode-ai`) if a local sandbox needs to run agent_kind=opencode. # -# All Agent CLI installs are FAIL-LOUD: if any of them fails, the build -# aborts. A silently missing CLI would only surface at run time when a -# user creates an Agent with the corresponding agent_kind and the sandbox -# reports "command not found". -# # Build (from repo root; buildx cross-compiles the Go binaries natively # and downloads the right CLI binary for TARGETARCH): # docker build -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . @@ -31,6 +29,7 @@ # --build-arg GO_VERSION= builder Go image (default: matches repo go.work) # --build-arg CLAUDE_CODE_VERSION= pin claude code (default: latest) # --build-arg CODEX_VERSION= pin codex-rs (default: 0.141.0) +# --build-arg PI_VERSION= pin pi CLI (default: 0.80.6) ############################################################################### # Stage 1: go-builder — compile parsar-daemon + parsar CLI for TARGETARCH. @@ -76,72 +75,32 @@ RUN apt-get update -y \ curl ca-certificates jq git ripgrep \ && rm -rf /var/lib/apt/lists/* -# --- MCP server runtimes (Node + Python + uv) --- +# --- Agent CLIs (Node + Claude Code + Codex + Pi) --- +# See infra/sandbox/scripts/install-agents.sh for the install logic and +# version-pin build args. +ARG CLAUDE_CODE_VERSION="" +ARG CODEX_VERSION="0.141.0" +ARG PI_VERSION="0.80.6" +COPY infra/sandbox/scripts/install-agents.sh /tmp/install-agents.sh +RUN CLAUDE_CODE_VERSION="$CLAUDE_CODE_VERSION" CODEX_VERSION="$CODEX_VERSION" PI_VERSION="$PI_VERSION" \ + /tmp/install-agents.sh "$TARGETARCH" \ + && rm -f /tmp/install-agents.sh + +# --- Python 3 + uv (MCP server runtimes) --- # Most community MCP servers are launched via `npx ` or `uvx # `; without these pre-installed, --mcp-config entries fail with # `spawn npx/uvx ENOENT` after capability resolution already logged -# success — a silent runtime failure with no UI signal. -RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ - && apt-get install -y --no-install-recommends nodejs python3 \ +# success — a silent runtime failure with no UI signal. npx already +# resolves from install-agents.sh's Node install. +RUN apt-get update -y \ + && apt-get install -y --no-install-recommends python3 \ && rm -rf /var/lib/apt/lists/* \ && curl -LsSf https://astral.sh/uv/install.sh | sh \ && ln -sf /root/.local/bin/uv /usr/local/bin/uv \ && ln -sf /root/.local/bin/uvx /usr/local/bin/uvx \ - && node --version \ - && npx --version \ && python3 --version \ && uvx --version -# --- Claude Code CLI --- -# Pulled from Anthropic's downloads CDN, not claude.ai/install.sh — the -# latter sits behind a bot challenge that 403s non-browser User-Agents. -# -# To pin (e.g. for a bisect): -# docker build --build-arg CLAUDE_CODE_VERSION=2.1.179 ... -ARG CLAUDE_CODE_VERSION="" -RUN set -e; \ - DOWNLOAD_BASE="https://downloads.claude.ai/claude-code-releases"; \ - VERSION="${CLAUDE_CODE_VERSION}"; \ - if [ -z "$VERSION" ]; then \ - VERSION="$(curl -fsSL "$DOWNLOAD_BASE/latest")"; \ - [ -n "$VERSION" ] || { echo "claude install: failed to resolve latest version" >&2; exit 1; }; \ - fi; \ - case "$TARGETARCH" in \ - amd64) CLAUDE_ARCH=linux-x64 ;; \ - arm64) CLAUDE_ARCH=linux-arm64 ;; \ - *) echo "claude install: unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ - esac; \ - echo "Installing claude code ${VERSION} (${CLAUDE_ARCH})"; \ - mkdir -p /root/.local/bin; \ - curl -fsSL "$DOWNLOAD_BASE/${VERSION}/${CLAUDE_ARCH}/claude" -o /root/.local/bin/claude; \ - chmod +x /root/.local/bin/claude; \ - ln -sf /root/.local/bin/claude /usr/local/bin/claude; \ - /usr/local/bin/claude --version - -# --- OpenAI Codex CLI (codex-rs) --- -# linux-musl builds are statically linked, no glibc dependency added. -# Pinned by default for reproducible builds; bump deliberately. -# -# Override: docker build --build-arg CODEX_VERSION=0.142.0 ... -ARG CODEX_VERSION="0.141.0" -RUN set -e; \ - case "$TARGETARCH" in \ - amd64) CODEX_ARCH=x86_64-unknown-linux-musl ;; \ - arm64) CODEX_ARCH=aarch64-unknown-linux-musl ;; \ - *) echo "codex install: unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ - esac; \ - echo "Installing codex ${CODEX_VERSION} (${CODEX_ARCH})"; \ - curl -fsSL \ - "https://github.com/openai/codex/releases/download/rust-v${CODEX_VERSION}/codex-${CODEX_ARCH}.tar.gz" \ - -o /tmp/codex.tar.gz; \ - tar -xzf /tmp/codex.tar.gz -C /tmp/; \ - install -m 0755 "/tmp/codex-${CODEX_ARCH}" /usr/local/bin/codex; \ - rm -rf /tmp/codex*; \ - /usr/local/bin/codex --version - -# --- Pi CLI (via npm, Node already installed above) --- -RUN npm install -g @earendil-works/pi-coding-agent && pi --version - # --- parsar-daemon + parsar CLI (compiled in the go-builder stage) --- COPY --from=go-builder /out/parsar-daemon /usr/local/bin/parsar-daemon COPY --from=go-builder /out/parsar /usr/local/bin/parsar @@ -153,7 +112,7 @@ RUN chmod +x /usr/local/bin/parsar-daemon /usr/local/bin/parsar \ # Pre-baked under /opt/parsar/hooks/claude/. Hook scripts call `parsar # inject snapshot/incremental` at runtime. settings.json pointing at # these scripts is written per-sandbox at seed time, not baked here. -COPY infra/e2b-templates/parsar-daemon-claudecode/hooks/claude /opt/parsar/hooks/claude +COPY infra/sandbox/hooks/claude /opt/parsar/hooks/claude RUN chmod -R a+rx /opt/parsar/hooks # --- Sandbox environment --- diff --git a/infra/e2b-templates/parsar-daemon-claudecode/hooks/claude/session-start.py b/infra/sandbox/hooks/claude/session-start.py similarity index 100% rename from infra/e2b-templates/parsar-daemon-claudecode/hooks/claude/session-start.py rename to infra/sandbox/hooks/claude/session-start.py diff --git a/infra/e2b-templates/parsar-daemon-claudecode/hooks/claude/user-prompt-submit.py b/infra/sandbox/hooks/claude/user-prompt-submit.py similarity index 100% rename from infra/e2b-templates/parsar-daemon-claudecode/hooks/claude/user-prompt-submit.py rename to infra/sandbox/hooks/claude/user-prompt-submit.py diff --git a/infra/sandbox/scripts/install-agents.sh b/infra/sandbox/scripts/install-agents.sh new file mode 100755 index 0000000..7ea9ae8 --- /dev/null +++ b/infra/sandbox/scripts/install-agents.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Installs every agent CLI a Parsar sandbox image needs: Node 22 (for npm- +# based installs), Claude Code, Codex, and Pi. Shared by every sandbox +# Dockerfile (infra/sandbox/Dockerfile.local + infra/sandbox/Dockerfile.e2b) +# so the two images can't drift apart again — they already had once (an +# old arm64-only local variant was missing Codex/Pi that the amd64 one +# had). Edit here, not inline in a Dockerfile. +# +# Usage: install-agents.sh +# Version overrides (env, all optional): +# CLAUDE_CODE_VERSION default: latest +# CODEX_VERSION default: 0.141.0 +# PI_VERSION default: 0.80.6 +# +# All installs are FAIL-LOUD: `set -e` + a `--version` sanity check after +# each one. A silently missing CLI would only surface at run time when a +# user creates an Agent with the corresponding agent_kind and the sandbox +# reports "command not found". +set -euo pipefail + +TARGETARCH="${1:?install-agents.sh: TARGETARCH required (amd64|arm64)}" +CLAUDE_CODE_VERSION="${CLAUDE_CODE_VERSION:-}" +CODEX_VERSION="${CODEX_VERSION:-0.141.0}" +PI_VERSION="${PI_VERSION:-0.80.6}" + +case "$TARGETARCH" in + amd64) CLAUDE_ARCH=linux-x64 CODEX_ARCH=x86_64-unknown-linux-musl ;; + arm64) CLAUDE_ARCH=linux-arm64 CODEX_ARCH=aarch64-unknown-linux-musl ;; + *) echo "install-agents: unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; +esac + +# --- Node 22 LTS via NodeSource --- +# Distro nodejs packages on ubuntu:22.04 / debian:12 are 12.x-18.x — too +# old for most current npm packages. Node 22, not 20: @earendil-works/ +# pi-coding-agent pins engines.node >=22.19.0 and fails at *runtime* (not +# just an npm EBADENGINE warning) on 20.x — TypeError: +# webidl.util.markAsUncloneable is not a function, from undici's +# Node-22-only Cache API surface. +# +# Force-relink /usr/local/bin: e2bdev/base ships its own /usr/local/bin/ +# node (v20.9.0) baked into the image, and /usr/local/bin wins over the +# nodesource package's /usr/bin/node on PATH. `command -v node` after +# install still resolves the stale /usr/local/bin one (that's the whole +# problem), so this points directly at dpkg's actual install path rather +# than trusting PATH resolution — without this every npm-installed CLI +# silently runs on the stale bundled Node instead of the one we just +# installed. +echo "install-agents: installing Node 22" +curl -fsSL https://deb.nodesource.com/setup_22.x | bash - +apt-get install -y --no-install-recommends nodejs +rm -rf /var/lib/apt/lists/* +ln -sf /usr/bin/node /usr/local/bin/node +ln -sf /usr/bin/npm /usr/local/bin/npm +ln -sf /usr/bin/npx /usr/local/bin/npx +hash -r +node --version +npx --version + +# --- Claude Code CLI --- +# Pulled from Anthropic's downloads CDN, not claude.ai/install.sh — the +# latter sits behind a bot challenge that 403s non-browser User-Agents. +echo "install-agents: installing claude code" +DOWNLOAD_BASE="https://downloads.claude.ai/claude-code-releases" +VERSION="$CLAUDE_CODE_VERSION" +if [ -z "$VERSION" ]; then + VERSION="$(curl -fsSL "$DOWNLOAD_BASE/latest")" + [ -n "$VERSION" ] || { echo "claude install: failed to resolve latest version" >&2; exit 1; } +fi +mkdir -p /root/.local/bin +curl -fsSL "$DOWNLOAD_BASE/${VERSION}/${CLAUDE_ARCH}/claude" -o /root/.local/bin/claude +chmod +x /root/.local/bin/claude +ln -sf /root/.local/bin/claude /usr/local/bin/claude +/usr/local/bin/claude --version + +# --- OpenAI Codex CLI (codex-rs) --- +# linux-musl builds are statically linked, no glibc dependency added. +# Pinned by default for reproducible builds; bump deliberately. +echo "install-agents: installing codex ${CODEX_VERSION}" +curl -fsSL \ + "https://github.com/openai/codex/releases/download/rust-v${CODEX_VERSION}/codex-${CODEX_ARCH}.tar.gz" \ + -o /tmp/codex.tar.gz +tar -xzf /tmp/codex.tar.gz -C /tmp/ +install -m 0755 "/tmp/codex-${CODEX_ARCH}" /usr/local/bin/codex +rm -rf /tmp/codex* +/usr/local/bin/codex --version + +# --- Pi CLI (via npm) --- +echo "install-agents: installing pi ${PI_VERSION}" +npm install -g "@earendil-works/pi-coding-agent@${PI_VERSION}" +pi --version diff --git a/server/internal/connector/agentdaemon/sandbox_seed.go b/server/internal/connector/agentdaemon/sandbox_seed.go index d18a7ee..cda54a2 100644 --- a/server/internal/connector/agentdaemon/sandbox_seed.go +++ b/server/internal/connector/agentdaemon/sandbox_seed.go @@ -35,7 +35,7 @@ const ( ) // In-image absolute paths to the hook scripts baked by -// infra/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile. Keeping +// infra/sandbox/Dockerfile.e2b. Keeping // them as constants here means a Dockerfile move forces an update on // the Go side too. const ( From f3a5d7a85ec96a73141ac669cf31b73e6608698b Mon Sep 17 00:00:00 2001 From: yuanhe Date: Sat, 11 Jul 2026 16:32:49 +0800 Subject: [PATCH 4/4] Merge sandbox Dockerfiles into one file, add CI publish workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapses infra/sandbox/Dockerfile.local + Dockerfile.e2b into a single infra/sandbox/Dockerfile, selecting the local-docker vs e2b.app target via --build-arg BASE_IMAGE (defaults to ubuntu:22.04; pass e2bdev/base:latest for the e2b template). The two files had shrunk to a ~2-line real diff (base image + a TARGETARCH default) after the prior commit's cleanup — not worth maintaining as two files when Docker's ARG-before-FROM substitution covers it natively. Verified both build targets still produce byte-identical images (full cache hit against the prior two-file build). - docker build -f infra/sandbox/Dockerfile -t parsar-sandbox:local . - docker build -f infra/sandbox/Dockerfile --build-arg BASE_IMAGE=e2bdev/base:latest -t parsar-daemon-claudecode . Updated every remaining reference to the old two-file paths: docker-compose.yml, install.sh, INSTALL.md, docs/deploy/lan-deploy.md, docs/spec-memory-module.md, .github/workflows/parsar-daemon-release.yml (also fixed stale comments there claiming the sandbox image consumes its published releases — it compiles parsar-daemon from source now, those releases are only for the device-pairing connect command), server/internal/connector/agentdaemon/sandbox_seed.go (comment only). Added .github/workflows/sandbox-image-release.yml: a matrix build off the single Dockerfile publishing both ghcr.io//parsar-sandbox (multi-arch amd64+arm64) and ghcr.io//parsar-daemon-claudecode (amd64 only) to GHCR, mirroring parsar-server-release.yml's trigger conventions (PR = build only, push to main = :latest + :sha, sandbox-v* tag = release, manual dispatch). actionlint clean; locally simulated both matrix legs' exact build invocations successfully. Verified end to end: rebuilt parsar-sandbox:local, recreated parsar-server against it, confirmed the startup log reports the correct wired image, then started a real container on the parsar_default compose network with the exact invocation server/internal/sandbox/docker/client.go uses and confirmed it can curl parsar-server:8080/healthz and all four agent CLIs resolve. Found but did not fix (pre-existing, unrelated to this change): deploy/compose/compose.selfhost.yml and deploy/compose/.env.example set PARSAR_E2B_TEMPLATE_ID / PARSAR_E2B_TIMEOUT_SECONDS, but server/cmd/server/main.go actually reads AGENT_DAEMON_SANDBOX_TEMPLATE / AGENT_DAEMON_SANDBOX_TEMPLATE_XL — the self-host e2b sandbox path can never enable itself with those files as shipped. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/parsar-daemon-release.yml | 26 +-- .github/workflows/sandbox-image-release.yml | 140 ++++++++++++++++ INSTALL.md | 2 +- docker-compose.yml | 2 +- docs/deploy/lan-deploy.md | 14 +- docs/spec-memory-module.md | 6 +- .../sandbox/{Dockerfile.local => Dockerfile} | 51 +++--- infra/sandbox/Dockerfile.e2b | 152 ------------------ infra/sandbox/scripts/install-agents.sh | 9 +- install.sh | 2 +- .../connector/agentdaemon/sandbox_seed.go | 2 +- 11 files changed, 204 insertions(+), 202 deletions(-) create mode 100644 .github/workflows/sandbox-image-release.yml rename infra/sandbox/{Dockerfile.local => Dockerfile} (71%) delete mode 100644 infra/sandbox/Dockerfile.e2b diff --git a/.github/workflows/parsar-daemon-release.yml b/.github/workflows/parsar-daemon-release.yml index 5e9881d..da2ca0a 100644 --- a/.github/workflows/parsar-daemon-release.yml +++ b/.github/workflows/parsar-daemon-release.yml @@ -9,12 +9,13 @@ # project_daemon_platform_scope decision and the absence of a fork # implementation for that OS. # -# The same artifacts are consumed by the sandbox image build (the e2b -# Dockerfile at infra/sandbox/Dockerfile.e2b -# pins PARSAR_DAEMON_VERSION and curls the matching linux/amd64 binary from -# the deployment's download mirror). When cutting a sandbox template -# rebuild, bump the tag here first, wait for this workflow to publish, -# then trigger the template rebuild against the new version. +# These artifacts back the device-pairing "one-line connect command" +# (scripts/install-parsar-daemon.sh / server/internal/api/ +# install_parsar_daemon.sh pull from here). The sandbox image build +# (infra/sandbox/Dockerfile) does NOT consume +# this release — it compiles parsar-daemon from source in its own +# builder stage, so sandbox templates always match the checked-out +# commit and don't need a release cut first. name: parsar-daemon release @@ -68,8 +69,9 @@ jobs: id: ver run: | # Strip the `parsar-daemon-` prefix from the tag so the binary - # filename matches the e2b Dockerfile's PARSAR_DAEMON_VERSION arg - # (which is bare semver like `v0.1.0` or `0.1.0`). + # filename matches what scripts/install-parsar-daemon.sh / + # server/internal/api/install_parsar_daemon.sh expect (bare + # semver like `v0.1.0` or `0.1.0`). if [[ "${GITHUB_REF}" == refs/tags/parsar-daemon-* ]]; then VERSION="${GITHUB_REF#refs/tags/parsar-daemon-}" else @@ -147,10 +149,10 @@ jobs: body: | parsar-daemon binaries for macOS + Linux (amd64 / arm64). - ## Install (sandbox template) - Bump `PARSAR_DAEMON_VERSION` in - `infra/sandbox/Dockerfile.e2b` - to this release, then rebuild the e2b template. + The sandbox image (infra/sandbox/Dockerfile) compiles + parsar-daemon from source and doesn't consume this release — + these binaries are for the device-pairing "one-line connect + command" only. ## Install (local laptop) ```sh diff --git a/.github/workflows/sandbox-image-release.yml b/.github/workflows/sandbox-image-release.yml new file mode 100644 index 0000000..8770c65 --- /dev/null +++ b/.github/workflows/sandbox-image-release.yml @@ -0,0 +1,140 @@ +# Build + push the Parsar sandbox images to GHCR. +# +# One Dockerfile (infra/sandbox/Dockerfile), two published images selected +# by --build-arg BASE_IMAGE: +# - parsar-sandbox local docker-backed sandbox provider +# (AGENT_DAEMON_SANDBOX_BACKEND=docker), +# multi-arch (amd64 + arm64) +# - parsar-daemon-claudecode e2b.app SaaS template, amd64 only +# +# Both compile parsar-daemon + the parsar CLI from source in the shared +# Dockerfile's builder stage — neither depends on a published +# parsar-daemon release existing first (see parsar-daemon-release.yml, +# which is a separate artifact for the device-pairing connect command). +# +# Triggers: +# - Pull request touching sandbox inputs -> build only, no push +# - Push to main touching sandbox inputs -> :latest + : +# - Tag push matching `sandbox-v*` -> : +# - Manual workflow_dispatch -> build + push +# +# Output: +# ghcr.io//parsar-sandbox: +# ghcr.io//parsar-daemon-claudecode: +# - latest on default-branch pushes +# - on every build +# - on tag pushes (e.g. sandbox-v0.1.0) +# +# Operators using their own registry: copy this into a fork and swap the +# `images:` field, or just build infra/sandbox/Dockerfile locally and +# never run this workflow. + +name: sandbox images + +on: + push: + branches: [main] + paths: + - "infra/sandbox/**" + - "internal/**" + - "apps/parsar/**" + - "apps/parsar-daemon/**" + - "go.mod" + - "go.sum" + - "go.work" + - ".github/workflows/sandbox-image-release.yml" + tags: + - "sandbox-v*" + pull_request: + paths: + - "infra/sandbox/**" + - "internal/**" + - "apps/parsar/**" + - "apps/parsar-daemon/**" + - "go.mod" + - "go.sum" + - "go.work" + - ".github/workflows/sandbox-image-release.yml" + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - variant: local + image_name: parsar-sandbox + base_image: ubuntu:22.04 + platforms_pr: linux/amd64 + platforms_publish: linux/amd64,linux/arm64 + - variant: e2b + image_name: parsar-daemon-claudecode + base_image: e2bdev/base:latest + # e2b.app sandboxes are amd64 only — no point publishing arm64. + platforms_pr: linux/amd64 + platforms_publish: linux/amd64 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up QEMU + if: github.event_name != 'pull_request' && matrix.variant == 'local' + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Choose build platforms + id: platforms + shell: bash + run: | + if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then + echo "value=${{ matrix.platforms_pr }}" >> "$GITHUB_OUTPUT" + else + echo "value=${{ matrix.platforms_publish }}" >> "$GITHUB_OUTPUT" + fi + + - name: Compute lowercase GHCR image name + id: image + shell: bash + run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/${{ matrix.image_name }}" >> "$GITHUB_OUTPUT" + + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute image metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ steps.image.outputs.name }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=ref,event=tag + type=sha,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build image + uses: docker/build-push-action@v7 + with: + context: . + file: infra/sandbox/Dockerfile + platforms: ${{ steps.platforms.outputs.value }} + build-args: | + BASE_IMAGE=${{ matrix.base_image }} + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=sandbox-${{ matrix.variant }} + cache-to: type=gha,mode=max,scope=sandbox-${{ matrix.variant }} diff --git a/INSTALL.md b/INSTALL.md index 7348e5a..93d378e 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -27,7 +27,7 @@ user is the administrator. - To actually run Agents in sandbox mode, build the sandbox image first — there is no published default: ```bash - docker build -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . + docker build -f infra/sandbox/Dockerfile -t parsar-sandbox:local . ``` Everything else in the stack works without it. diff --git a/docker-compose.yml b/docker-compose.yml index 049cfc0..3c3d5b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -158,7 +158,7 @@ services: # to start / exec / stop sandbox containers via the mounted # /var/run/docker.sock below. There is no published image for this — # build one locally first (README's 5.2 section): - # docker build -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . + # docker build -f infra/sandbox/Dockerfile -t parsar-sandbox:local . # Until you do, sandbox-mode Agent runs fail with "no such image"; # everything else in this stack works fine without it. # The network below is pinned to `parsar_default` — the top-level diff --git a/docs/deploy/lan-deploy.md b/docs/deploy/lan-deploy.md index 32ccc0e..0a2c291 100644 --- a/docs/deploy/lan-deploy.md +++ b/docs/deploy/lan-deploy.md @@ -215,20 +215,20 @@ its own Go builder stage — independent of 5.1, order doesn't matter. # With proxy (read from .env): source .env sudo docker build \ - -f infra/sandbox/Dockerfile.local \ + -f infra/sandbox/Dockerfile \ -t parsar-sandbox:local \ --build-arg http_proxy="$HTTP_PROXY" \ --build-arg https_proxy="$HTTPS_PROXY" \ . # Without proxy: -sudo docker build -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . +sudo docker build -f infra/sandbox/Dockerfile -t parsar-sandbox:local . # On Apple Silicon (arm64): -sudo docker build --platform linux/arm64 -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . +sudo docker build --platform linux/arm64 -f infra/sandbox/Dockerfile -t parsar-sandbox:local . ``` -> `Dockerfile.local` downloads the Claude Code + Codex CLIs from their +> `Dockerfile` downloads the Claude Code + Codex CLIs from their > respective CDN/release endpoints and compiles parsar-daemon + the > parsar CLI from this repo's source — no GitHub Release, no dependency > on the server image (5.1) being built first. @@ -340,7 +340,7 @@ sudo docker compose -f docker-compose.yml down -v # also delete data volumes ```bash git pull sudo docker build -t parsar:local . -sudo docker build -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . +sudo docker build -f infra/sandbox/Dockerfile -t parsar-sandbox:local . sudo docker compose -f docker-compose.yml up -d --force-recreate ``` @@ -380,7 +380,7 @@ snippets in §5) because `docker build` does not read `.env`. | Feishu login reports `redirect_uri mismatch` | `.env`'s `REDIRECT_URI` does not match the Feishu console | Keep both sides completely identical (scheme, IP, port, path) | | Other machines cannot reach 18080 | Firewall blocking it | `sudo ufw allow 18080/tcp` or the equivalent firewall rule | | Device pairing downloads daemon and hits **404** | The GHCR image has no embedded daemon | Build the server image locally (§5.1) | -| Agent reports **"no runtime yet — ask an admin to rebuild it"** | The sandbox image lacks the Agent CLI | Rebuild the sandbox image via `Dockerfile.local` (§5.2), then click Rebuild in the UI | +| Agent reports **"no runtime yet — ask an admin to rebuild it"** | The sandbox image lacks the Agent CLI | Rebuild the sandbox image via `Dockerfile` (§5.2), then click Rebuild in the UI | | Sandbox comes up but the daemon cannot reach the server | Compose project name is not `parsar`, so network names do not match | Use the repo's `docker-compose.yml` (has `name: parsar` pinned at the top); do not override with `-p ` | | Group @Bot **no response**, DM works fine | `PARSAR_FEISHU_DEFAULT_BOT_OPEN_ID` not set | Add the Bot's open_id to `.env` and restart the server | | Bot receives messages but does not reply | Outbound worker is not up | Grep server logs for `feishu outbound`; confirm `PARSAR_FEISHU_OUTBOUND=true` (compose already sets it) | @@ -431,7 +431,7 @@ vim .env # fill in Feishu credentials + master key + PARSAR_HOST_IP + Bot Open # 2. Build images sudo docker build -t parsar:local . -sudo docker build -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . +sudo docker build -f infra/sandbox/Dockerfile -t parsar-sandbox:local . # 3. Bring it up sudo docker compose -f docker-compose.yml up -d diff --git a/docs/spec-memory-module.md b/docs/spec-memory-module.md index 9c07b19..650405e 100644 --- a/docs/spec-memory-module.md +++ b/docs/spec-memory-module.md @@ -410,7 +410,7 @@ ValidateRunnerToken(ctx, token) (RuntimeIdentity, error) ### 3.4 Dockerfile changes -File: `infra/sandbox/Dockerfile.e2b` +File: `infra/sandbox/Dockerfile` New content (after the existing parsar-daemon install section): ```dockerfile @@ -548,7 +548,7 @@ Put the helper `seedPlatformConfig` in `sandbox_provider.go` or a new 19. Cross-compile (linux/amd64 + linux/arm64), publish to GitLab artifact. ### Phase 7: sandbox image & hooks -20. Modify `infra/sandbox/Dockerfile.e2b` to install the `parsar` binary + COPY the hook script directory. +20. Modify `infra/sandbox/Dockerfile` to install the `parsar` binary + COPY the hook script directory. 21. Create `infra/sandbox/hooks/claude/{session-start.py, user-prompt-submit.py}`. 22. Create `infra/sandbox/hooks/opencode/{session-injection.js, per-turn-injection.js}`. 23. Rewrite the three-platform hooks uniformly to "call `parsar inject ...` for data" (injection logic centralizes in Go). @@ -585,7 +585,7 @@ depends on 4. | Sandbox lifecycle | `server/internal/connector/agentdaemon/sandbox_provider.go` (change; add seedPlatformConfig) | | Proto (no change) | `internal/agentdaemon/proto/outbound.go` (already supports the AgentOptions map) | | Audit hookup (no schema change) | `server/internal/audit/postgres.go:24-50` current `PostgresSink.Write()` | -| Dockerfile | `infra/sandbox/Dockerfile.e2b` (change) | +| Dockerfile | `infra/sandbox/Dockerfile` (change) | | Hook scripts | `infra/sandbox/hooks/{claude,opencode}/` (new) | | CLI source | `cmd/parsar/` (new) | | UI | Depends on the frontend layout; typically `web/src/pages/workspace/spec/` etc. (mirror the existing workspace settings page) | diff --git a/infra/sandbox/Dockerfile.local b/infra/sandbox/Dockerfile similarity index 71% rename from infra/sandbox/Dockerfile.local rename to infra/sandbox/Dockerfile index 985fb61..6ffc1aa 100644 --- a/infra/sandbox/Dockerfile.local +++ b/infra/sandbox/Dockerfile @@ -1,31 +1,43 @@ -# Parsar local docker sandbox image — minimal, multi-arch (amd64 + arm64). +# Parsar sandbox image — one Dockerfile, two deployment targets selected +# by BASE_IMAGE: # -# For AGENT_DAEMON_SANDBOX_BACKEND=docker, the sandbox provider does -# `docker run -d --entrypoint sleep infinity` then drives -# everything else via `docker exec ... bash -c ` -# (server/internal/sandbox/docker/client.go). No envd, no e2b API — the -# image just needs the agent CLIs + parsar-daemon on PATH and a shell. +# docker build -f infra/sandbox/Dockerfile -t parsar-sandbox:local . +# Local docker sandbox (default). For AGENT_DAEMON_SANDBOX_BACKEND=docker, +# the sandbox provider does `docker run -d --entrypoint sleep +# infinity` then drives everything else via `docker exec ... bash -c +# ` (server/internal/sandbox/docker/client.go). No envd, no +# e2b API — just needs the agent CLIs + parsar-daemon on PATH + a +# shell. Multi-arch: add --platform linux/arm64 to cross-build. +# +# docker build -f infra/sandbox/Dockerfile \ +# --build-arg BASE_IMAGE=e2bdev/base:latest -t parsar-daemon-claudecode . +# e2b.app SaaS template. amd64 only. After build, the SandboxProvider +# only needs to: Sandbox.Create("parsar-daemon-claudecode", {timeout}) +# → RunCommand: parsar-daemon connect --device-name -b +# (URL/token via env) → wait for the gateway to see deviceId dial in. +# +# Everything below this point is identical for both targets — swapping +# BASE_IMAGE is the only thing that changes what gets built. Don't fork +# this into two files; that's what drifted before (an old arm64-only +# local variant missing Codex/Pi, and the e2b variant downloading +# parsar-daemon from GitHub Releases instead of compiling it like this +# one does). # # Contents: -# - Claude Code CLI + Codex CLI + Pi CLI (installed by the shared -# infra/sandbox/scripts/install-agents.sh — also used by -# Dockerfile.e2b, so the two images can't drift apart again) +# - Claude Code CLI + Codex CLI + Pi CLI (installed by +# infra/sandbox/scripts/install-agents.sh) # - parsar-daemon + parsar CLI (spec/memory hook injection), both -# compiled from source in the builder stage below — no dependency -# on the parsar:local server image being built first +# compiled from source in the builder stage below — no dependency on +# a published parsar-daemon release or the parsar:local server image # - Claude Code hook scripts (session-start / user-prompt-submit) # - Python 3 + uv for MCP server runtimes (Node comes from # install-agents.sh) # # Deliberately NOT included: OpenCode. Add it back (`npm install -g -# opencode-ai`) if a local sandbox needs to run agent_kind=opencode. -# -# Build (from repo root; buildx cross-compiles the Go binaries natively -# and downloads the right CLI binary for TARGETARCH): -# docker build -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . -# docker build --platform linux/arm64 -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . +# opencode-ai`) if a sandbox needs to run agent_kind=opencode. # # Build args: +# --build-arg BASE_IMAGE= ubuntu:22.04 (local) or e2bdev/base:latest (e2b) # --build-arg GO_VERSION= builder Go image (default: matches repo go.work) # --build-arg CLAUDE_CODE_VERSION= pin claude code (default: latest) # --build-arg CODEX_VERSION= pin codex-rs (default: 0.141.0) @@ -37,6 +49,7 @@ # (no QEMU) while GOARCH=$TARGETARCH cross-compiles the output binaries. ############################################################################### ARG GO_VERSION=1.25-bookworm +ARG BASE_IMAGE=ubuntu:22.04 FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS go-builder ARG TARGETARCH @@ -64,8 +77,8 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} GOFLAGS=-trimpath \ ############################################################################### # Stage 2: runtime — the actual sandbox image. ############################################################################### -FROM ubuntu:22.04 -ARG TARGETARCH +FROM ${BASE_IMAGE} +ARG TARGETARCH=amd64 # --- Base tools --- # curl/ca-certificates for downloads, jq for JSON, git for repo ops, diff --git a/infra/sandbox/Dockerfile.e2b b/infra/sandbox/Dockerfile.e2b deleted file mode 100644 index ddcd40a..0000000 --- a/infra/sandbox/Dockerfile.e2b +++ /dev/null @@ -1,152 +0,0 @@ -# Parsar agent_daemon sandbox template — base image w/ Claude Code + Codex + -# Pi CLIs + parsar-daemon preinstalled -# -# Builds an E2B sandbox image where: -# - Claude Code + Codex + Pi CLIs are installed under /usr/local/bin/ -# (install logic shared with infra/sandbox/Dockerfile.local via -# infra/sandbox/scripts/install-agents.sh — edit there, not inline -# here, so the two images can't drift apart) -# - parsar-daemon static linux/amd64 binary is installed under /usr/local/bin/parsar-daemon -# - parsar CLI (spec/memory client) is installed under /usr/local/bin/parsar, compiled -# on-the-fly in Stage 1 from apps/parsar/cmd/parsar. Multi-stage keeps the Go toolchain -# out of the runtime image (final image only contains the static binary). -# - Hook scripts for Claude Code are pre-baked under -# /opt/parsar/hooks/claude/. Platform settings files -# (~/.claude/settings.json etc.) are NOT pre-baked — server's sandbox -# provider writes them at runtime via e2b Exec because they carry -# runtime-specific values. -# - IS_SANDBOX=1 is exported so Claude Code accepts running as root in a container -# (per Anthropic's docs — without this Claude refuses to run --permission-mode -# bypassPermissions in non-interactive contexts) -# - /workspace is the default working directory; the server's sandbox provider -# drops repo state in here before issuing prompt_request frames. -# -# After build, the SandboxProvider only needs to: -# 1. Sandbox.Create("parsar-daemon-claudecode", { timeout }) -# 2. RunCommand: parsar-daemon connect --device-name -b (URL/token via env) -# 3. Wait for the gateway to see deviceId dial in (gateway.Registry.WaitForDevice) -# No curl at spawn time → boot ~3-5s instead of 30s+. -# -# Build (from REPO ROOT, not this directory — Stage 1 needs apps/parsar source): -# docker build -f infra/sandbox/Dockerfile.e2b -t parsar-daemon-claudecode . -# -# CRITICAL: parsar-daemon ships as a static Go binary, downloaded from GitHub -# Releases at build time. CI publishes new binaries on every release tag -# via .github/workflows/parsar-daemon-release.yml. When updating, pass -# --build-arg PARSAR_DAEMON_VERSION= matching the published -# release (e.g. `parsar-daemon-v0.1.0`). parsar CLI is compiled in Stage 1 below. - -############################################################################### -# Stage 1: parsar-builder — compile the parsar CLI from apps/parsar/cmd/parsar. -# -# Why multi-stage instead of curl-from-registry (the parsar-daemon pattern): -# the parsar CLI moves in lock-step with this repo (DTOs duplicate server-side -# shapes), so a registry artifact would need re-publishing on every PR -# touching apps/parsar or server/internal/api/specmem. Until a CI job exists -# to do that, compiling in-place keeps the build self-contained. -# -# Only apps/ is COPY'd (parsar only imports apps/parsar/internal/cli) so server- -# side changes don't bust this layer's cache. -############################################################################### -ARG GO_VERSION=1.25-bookworm - -FROM golang:${GO_VERSION} AS parsar-builder -WORKDIR /src - -# Module graph first, source second — keeps `go mod download` cacheable. -COPY go.mod go.sum go.work ./ -COPY go.work.sum* ./ -RUN go mod download - -COPY apps ./apps - -# Static linux/amd64 binary, debug info stripped (~25% smaller). trimpath -# scrubs build-host file paths so the binary leaks no operator info. -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOFLAGS=-trimpath \ - go build -ldflags="-s -w -X github.com/MiniMax-AI-Dev/parsar/apps/parsar/internal/cli.Version=sandbox" \ - -o /out/parsar ./apps/parsar/cmd/parsar - -############################################################################### -# Stage 2: runtime — the actual sandbox image. -############################################################################### -FROM e2bdev/base:latest -ARG TARGETARCH=amd64 - -# --- Base tools (curl + ca-certs for downloads, jq optional for debugging) --- -RUN apt-get update -y \ - && apt-get install -y --no-install-recommends curl ca-certificates jq \ - && rm -rf /var/lib/apt/lists/* - -# --- Agent CLIs (Node + Claude Code + Codex + Pi) --- -# See infra/sandbox/scripts/install-agents.sh for the install logic and -# version-pin build args. -ARG CLAUDE_CODE_VERSION="" -ARG CODEX_VERSION="0.141.0" -ARG PI_VERSION="0.80.6" -COPY infra/sandbox/scripts/install-agents.sh /tmp/install-agents.sh -RUN CLAUDE_CODE_VERSION="$CLAUDE_CODE_VERSION" CODEX_VERSION="$CODEX_VERSION" PI_VERSION="$PI_VERSION" \ - /tmp/install-agents.sh "$TARGETARCH" \ - && rm -f /tmp/install-agents.sh - -# --- parsar-daemon static binary --- -# Pulled from GitHub Releases (default `MiniMax-AI-Dev/parsar`). CI publishes -# binaries via .github/workflows/parsar-daemon-release.yml on tag push. -# Override the repo and version with --build-arg if your fork publishes -# its own release stream. -ARG PARSAR_DAEMON_REPO=MiniMax-AI-Dev/parsar -ARG PARSAR_DAEMON_VERSION="" -RUN set -e; \ - REPO="${PARSAR_DAEMON_REPO}"; \ - VERSION="${PARSAR_DAEMON_VERSION}"; \ - if [ -z "$VERSION" ]; then \ - LATEST_URL="$(curl -fsILo /dev/null -w '%{url_effective}' \ - "https://github.com/${REPO}/releases/latest")"; \ - VERSION="${LATEST_URL##*/tag/}"; \ - [ -n "$VERSION" ] && [ "$VERSION" != "$LATEST_URL" ] \ - || { echo "Failed to resolve latest parsar-daemon release from ${REPO}" >&2; exit 1; }; \ - fi; \ - BARE_VERSION="${VERSION#parsar-daemon-}"; \ - BINARY="parsar-daemon-${BARE_VERSION}-linux-amd64"; \ - curl -fsSL \ - "https://github.com/${REPO}/releases/download/${VERSION}/${BINARY}" \ - -o /usr/local/bin/parsar-daemon \ - && chmod +x /usr/local/bin/parsar-daemon \ - && /usr/local/bin/parsar-daemon version - -# --- parsar CLI (spec/memory client) --- -# Compiled in Stage 1; only the ~10MB static binary lands here. No Go -# toolchain in the runtime image. parsar --version exits non-zero on a broken -# binary so the build fails loudly if the COPY ever picks up the wrong file. -COPY --from=parsar-builder /out/parsar /usr/local/bin/parsar -RUN chmod +x /usr/local/bin/parsar \ - && /usr/local/bin/parsar --version - -# --- Hook scripts (Claude Code) --- -# Pre-baked under /opt/parsar/hooks/claude/. The Python hooks must be -# executable since Claude Code invokes them directly as commands. -# -# settings.json that POINTS at these scripts is NOT baked here — the -# server's sandbox provider writes it at runtime (carries PARSAR_* env -# vars + sandbox-specific values). -COPY infra/sandbox/hooks/claude /opt/parsar/hooks/claude -RUN chmod -R a+rx /opt/parsar/hooks - -# --- Sandbox identity --- -# IS_SANDBOX=1 lets the Claude Code CLI know it's running in a disposable -# container — required for bypassPermissions modes to engage. The daemon -# defaults to mode=default (web-driven approvals) but we set this anyway -# so the sandbox can run Bash-heavy workflows without re-prompting on every -# tool call when the user explicitly opts into a permissive mode. -ENV IS_SANDBOX=1 -# Disable Claude telemetry from inside the sandbox — the host already has -# its own opt-in policy for the server side, the sandbox should not add a -# second uncontrolled outbound stream. -ENV DISABLE_TELEMETRY=1 -ENV CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 - -# --- Workspace --- -# Server-side sandbox provider writes a working_directory into the -# binding metadata; the daemon honours it but defaults to /workspace -# when unset. mkdir -p so a fresh sandbox boot is always cd-able. -RUN mkdir -p /workspace -WORKDIR /workspace diff --git a/infra/sandbox/scripts/install-agents.sh b/infra/sandbox/scripts/install-agents.sh index 7ea9ae8..39bae00 100755 --- a/infra/sandbox/scripts/install-agents.sh +++ b/infra/sandbox/scripts/install-agents.sh @@ -1,10 +1,9 @@ #!/usr/bin/env bash # Installs every agent CLI a Parsar sandbox image needs: Node 22 (for npm- -# based installs), Claude Code, Codex, and Pi. Shared by every sandbox -# Dockerfile (infra/sandbox/Dockerfile.local + infra/sandbox/Dockerfile.e2b) -# so the two images can't drift apart again — they already had once (an -# old arm64-only local variant was missing Codex/Pi that the amd64 one -# had). Edit here, not inline in a Dockerfile. +# based installs), Claude Code, Codex, and Pi. Used by +# infra/sandbox/Dockerfile (both the local-docker and e2b.app build +# targets, selected by --build-arg BASE_IMAGE). Edit here, not inline in +# the Dockerfile. # # Usage: install-agents.sh # Version overrides (env, all optional): diff --git a/install.sh b/install.sh index 0617cc5..7fe6066 100755 --- a/install.sh +++ b/install.sh @@ -22,7 +22,7 @@ Options: Default: ghcr.io/minimax-ai-dev/parsar-server:latest --sandbox-image IMAGE Docker sandbox image. There is no published default — build one first: - docker build -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . + docker build -f infra/sandbox/Dockerfile -t parsar-sandbox:local . Default: parsar-sandbox:local --port PORT Web UI host port. Default: 18080 --pg-port PORT Postgres host port. Default: 15432 diff --git a/server/internal/connector/agentdaemon/sandbox_seed.go b/server/internal/connector/agentdaemon/sandbox_seed.go index cda54a2..b2bf205 100644 --- a/server/internal/connector/agentdaemon/sandbox_seed.go +++ b/server/internal/connector/agentdaemon/sandbox_seed.go @@ -35,7 +35,7 @@ const ( ) // In-image absolute paths to the hook scripts baked by -// infra/sandbox/Dockerfile.e2b. Keeping +// infra/sandbox/Dockerfile. Keeping // them as constants here means a Dockerfile move forces an update on // the Go side too. const (