diff --git a/.github/workflows/parsar-daemon-release.yml b/.github/workflows/parsar-daemon-release.yml index b4b738a..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/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile -# 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/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile` - 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 index 4357140..8770c65 100644 --- a/.github/workflows/sandbox-image-release.yml +++ b/.github/workflows/sandbox-image-release.yml @@ -1,49 +1,61 @@ -# Build + push the agent_daemon sandbox image to GitHub Container Registry. +# Build + push the Parsar sandbox images to GHCR. # -# 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/**. +# 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 # -# 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. +# 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 (when re-enabled): -# - Push to main when files under infra/sandbox/** change -# - Tag push matching `sandbox-v*` (release tags) -# - Manual workflow_dispatch +# 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: -# - `latest` on main pushes -# - `` short-SHA tag on every build -# - `` on tag pushes (e.g. `sandbox-v0.0.1`) +# 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 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. +# 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 image release +name: sandbox images 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*" + 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: - inputs: - parsar_daemon_version: - description: "parsar-daemon release tag to bake into the image (blank = latest)" - required: false - default: "" permissions: contents: read @@ -52,22 +64,49 @@ permissions: 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,,}/parsar-sandbox" >> "$GITHUB_OUTPUT" + 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 @@ -79,24 +118,23 @@ jobs: 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=pr type=ref,event=tag type=sha,format=short type=raw,value=latest,enable={{is_default_branch}} - - name: Build and push + - name: Build image 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 + 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 }} - 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 + 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 80212f0..93d378e 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 -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..3c3d5b2 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): - # docker build -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . + # 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 -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..0a2c291 100644 --- a/docs/deploy/lan-deploy.md +++ b/docs/deploy/lan-deploy.md @@ -207,25 +207,31 @@ 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): 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 -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` 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: @@ -334,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 ``` @@ -374,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) | @@ -425,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/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/docs/spec-memory-module.md b/docs/spec-memory-module.md index 0073c42..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/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile` +File: `infra/sandbox/Dockerfile` 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` 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` (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/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 deleted file mode 100644 index 2863a38..0000000 --- a/infra/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile +++ /dev/null @@ -1,151 +0,0 @@ -# Parsar agent_daemon sandbox template — base image w/ Claude Code CLI + parsar-daemon preinstalled -# -# Builds an E2B sandbox image where: -# - Claude Code CLI is installed under /usr/local/bin/claude (Anthropic's installer) -# - 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 + OpenCode are pre-baked under -# /opt/parsar/hooks/{claude,opencode}/. 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 npm / 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 . -# -# 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.24-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 - -# --- 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 - -# --- 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 + 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. -# -# 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 -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/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 }, -}; 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 b/infra/sandbox/Dockerfile new file mode 100644 index 0000000..6ffc1aa --- /dev/null +++ b/infra/sandbox/Dockerfile @@ -0,0 +1,137 @@ +# Parsar sandbox image — one Dockerfile, two deployment targets selected +# by BASE_IMAGE: +# +# 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 +# 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 +# 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 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) +# --build-arg PI_VERSION= pin pi CLI (default: 0.80.6) + +############################################################################### +# 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 +ARG BASE_IMAGE=ubuntu:22.04 + +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 + +# 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 + +############################################################################### +# Stage 2: runtime — the actual sandbox image. +############################################################################### +FROM ${BASE_IMAGE} +ARG TARGETARCH=amd64 + +# --- 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/* + +# --- 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. 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 \ + && python3 --version \ + && uvx --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 +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/sandbox/hooks/claude /opt/parsar/hooks/claude +RUN chmod -R a+rx /opt/parsar/hooks + +# --- 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/Dockerfile.local b/infra/sandbox/Dockerfile.local deleted file mode 100644 index 5f5a28c..0000000 --- a/infra/sandbox/Dockerfile.local +++ /dev/null @@ -1,108 +0,0 @@ -# Parsar sandbox image — local Docker build variant. -# -# 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 -# -# 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): -# docker build -f infra/sandbox/Dockerfile.local -t parsar-sandbox:local . - -############################################################################### -# 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. -############################################################################### -ARG GO_VERSION=1.25-bookworm - -FROM golang:${GO_VERSION} AS parsar-builder -WORKDIR /src -COPY go.mod go.sum go.work ./ -COPY go.work.sum* ./ -RUN go mod download -COPY apps ./apps -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 ubuntu:22.04 - -# --- Base tools --- -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) --- -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 --- -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 - -# --- 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 - -# --- Pi CLI (via npm) --- -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 -RUN chmod -R a+rx /opt/parsar/hooks - -# --- 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/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/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/infra/sandbox/scripts/install-agents.sh b/infra/sandbox/scripts/install-agents.sh new file mode 100755 index 0000000..39bae00 --- /dev/null +++ b/infra/sandbox/scripts/install-agents.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Installs every agent CLI a Parsar sandbox image needs: Node 22 (for npm- +# 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): +# 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/install.sh b/install.sh index 5105939..7fe6066 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 -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 diff --git a/server/internal/connector/agentdaemon/sandbox_seed.go b/server/internal/connector/agentdaemon/sandbox_seed.go index d18a7ee..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/e2b-templates/parsar-daemon-claudecode/e2b.Dockerfile. Keeping +// infra/sandbox/Dockerfile. Keeping // them as constants here means a Dockerfile move forces an update on // the Go side too. const (