From 39f3989ddfb03d8b159a70dbb3f763c76de19106 Mon Sep 17 00:00:00 2001 From: yuanhe Date: Sun, 12 Jul 2026 00:51:35 +0800 Subject: [PATCH] Simplify runtime providers --- .env.example | 2 - .github/workflows/sandbox-image-release.yml | 46 +- Dockerfile | 8 +- INSTALL.md | 11 +- .../runtime/RuntimeStatusBanner.tsx | 1 - apps/web/src/i18n/locales/en-US/admin.json | 112 ++--- apps/web/src/i18n/locales/zh-CN/admin.json | 134 +++--- apps/web/src/lib/api-runtime.ts | 23 +- .../web/src/pages/admin/CreateAgentDialog.tsx | 51 +- apps/web/src/pages/admin/RuntimePage.tsx | 44 +- .../runtimes/LocalDeviceRuntimesPanel.tsx | 39 +- docker-compose.yml | 33 +- docs/deploy/lan-deploy.md | 446 ------------------ docs/deploy/shared-feishu-app.md | 2 +- docs/openapi/openapi.yaml | 45 +- infra/sandbox/Dockerfile | 24 +- install.sh | 21 +- scripts/with-dev-env.sh | 2 - server/cmd/server/main.go | 64 ++- server/cmd/server/sandbox_docker.go | 250 ---------- server/cmd/server/sandbox_docker_test.go | 199 -------- server/internal/dev/runtime_status.go | 80 +++- server/internal/dev/runtime_status_test.go | 48 +- server/internal/sandbox/docker/client.go | 213 --------- .../sandbox/docker/client_integration_test.go | 87 ---- server/internal/sandbox/docker/client_test.go | 394 ---------------- 26 files changed, 412 insertions(+), 1967 deletions(-) delete mode 100644 docs/deploy/lan-deploy.md delete mode 100644 server/cmd/server/sandbox_docker.go delete mode 100644 server/cmd/server/sandbox_docker_test.go delete mode 100644 server/internal/sandbox/docker/client.go delete mode 100644 server/internal/sandbox/docker/client_integration_test.go delete mode 100644 server/internal/sandbox/docker/client_test.go diff --git a/.env.example b/.env.example index af2720b..bc7fd41 100644 --- a/.env.example +++ b/.env.example @@ -62,8 +62,6 @@ PARSAR_PG_PORT=15432 PARSAR_HOST_IP= # Set to parsar:local after running: make docker-build PARSAR_IMAGE=parsar PARSAR_IMAGE_TAG=local PARSAR_SERVER_IMAGE=parsar:local -# Run: stat -c '%g' /var/run/docker.sock (Linux) or stat -f '%g' (macOS) -DOCKER_GID=999 # ----------------------------------------------------------------------------- # Feishu Bot (optional — see docs/deploy/lan-deploy.md) diff --git a/.github/workflows/sandbox-image-release.yml b/.github/workflows/sandbox-image-release.yml index 9aa2b5b..c542636 100644 --- a/.github/workflows/sandbox-image-release.yml +++ b/.github/workflows/sandbox-image-release.yml @@ -1,11 +1,7 @@ -# Build + push the Parsar sandbox images to GHCR. +# Build + push the Parsar E2B-compatible sandbox image 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-sandbox-e2b e2b.app SaaS template, amd64 only +# Published image: +# - parsar-sandbox-e2b E2B-compatible template, amd64 only # # Both compile parsar-daemon + the parsar CLI from source in the shared # Dockerfile's builder stage — neither depends on a published @@ -19,7 +15,6 @@ # - Manual workflow_dispatch -> build + push # # Output: -# ghcr.io//parsar-sandbox: # ghcr.io//parsar-sandbox-e2b: # - latest on default-branch pushes # - on every build @@ -29,7 +24,7 @@ # `images:` field, or just build infra/sandbox/Dockerfile locally and # never run this workflow. -name: sandbox images +name: e2b sandbox image on: push: @@ -64,29 +59,10 @@ 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-sandbox-e2b - 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 @@ -94,16 +70,12 @@ jobs: 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 + echo "value=linux/amd64" >> "$GITHUB_OUTPUT" - name: Compute lowercase GHCR image name id: image shell: bash - run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/${{ matrix.image_name }}" >> "$GITHUB_OUTPUT" + run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/parsar-sandbox-e2b" >> "$GITHUB_OUTPUT" - name: Log in to GitHub Container Registry if: github.event_name != 'pull_request' @@ -132,9 +104,9 @@ jobs: file: infra/sandbox/Dockerfile platforms: ${{ steps.platforms.outputs.value }} build-args: | - BASE_IMAGE=${{ matrix.base_image }} + BASE_IMAGE=e2bdev/base:latest 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 }} + cache-from: type=gha,scope=sandbox-e2b + cache-to: type=gha,mode=max,scope=sandbox-e2b diff --git a/Dockerfile b/Dockerfile index 08da053..c6feac8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ # - parsar-server (default CMD, serves the API and the SPA) # - parsar-migrate (goose migration runner) # - parsar-bootstrap (first-owner provisioning CLI) +# - parsar-daemon (manual daemon runtime) # - /app/web/dist (Vite build output, served via PARSAR_WEB_DIST) # - /app/migrations (SQL migrations, picked up via PARSAR_MIGRATIONS_DIR) # @@ -66,7 +67,7 @@ COPY packages ./packages RUN pnpm --filter @parsar/web build ############################################################################### -# Stage 2: go-builder — compile the three Go binaries. +# Stage 2: go-builder — compile the Go binaries. # # CGO is off so the binaries are statically linked and can run on a # minimal runtime. trimpath strips build-host file paths from the @@ -88,7 +89,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ COPY internal ./internal COPY server ./server -# Build all three binaries in one RUN so the layer represents one +# Build server-side binaries in one RUN so the layer represents one # logical "compile" operation. -s -w strips debug info / symbol table; # combined they shave ~25% off binary size with no runtime cost. RUN --mount=type=cache,target=/go/pkg/mod \ @@ -123,6 +124,8 @@ RUN --mount=type=cache,target=/go/pkg/mod \ ./apps/parsar-daemon/cmd/parsar-daemon; \ done +RUN cp "/out/daemon/parsar-daemon-linux-${TARGETARCH}" /out/parsar-daemon + ############################################################################### # Stage 3: runtime — debian-slim with a non-root user. # @@ -160,6 +163,7 @@ RUN set -eux; \ COPY --from=go-builder /out/parsar-server /usr/local/bin/parsar-server COPY --from=go-builder /out/parsar-migrate /usr/local/bin/parsar-migrate COPY --from=go-builder /out/parsar-bootstrap /usr/local/bin/parsar-bootstrap +COPY --from=go-builder /out/parsar-daemon /usr/local/bin/parsar-daemon # Per-platform parsar-daemon binaries. RegisterParsarDaemonDownloadRoute # serves these (from PARSAR_DAEMON_BINARY_DIR) to the one-line connect diff --git a/INSTALL.md b/INSTALL.md index 9319e67..dc5c398 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -22,14 +22,9 @@ user is the administrator. ## Requirements - 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. The - sandbox image (`ghcr.io/minimax-ai-dev/parsar-sandbox:latest`) is pulled - automatically — build your own instead with: - ```bash - docker build -f infra/sandbox/Dockerfile -t parsar-sandbox:local . - PARSAR_SANDBOX_IMAGE=parsar-sandbox:local ./install.sh - ``` +- `parsar-server` does not need host container-engine privileges. Local + execution uses `manual_daemon`; managed isolation uses an E2B-compatible + provider. ## What The Installer Does diff --git a/apps/web/src/components/runtime/RuntimeStatusBanner.tsx b/apps/web/src/components/runtime/RuntimeStatusBanner.tsx index d63e526..1cd11fc 100644 --- a/apps/web/src/components/runtime/RuntimeStatusBanner.tsx +++ b/apps/web/src/components/runtime/RuntimeStatusBanner.tsx @@ -79,7 +79,6 @@ function describeStatus( available: boolean sandbox_agent_count: number profile: string - configured_by?: string credential_masked?: string | null }, ): BannerKeys { diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index af89953..9c906c4 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -1335,28 +1335,28 @@ }, "renewedToast": "Renewed until {{expiresAt}}", "provisionError": "Provisioning failed", - "provisioningHint": "Sandbox is being provisioned. A warm start usually takes 10-30 seconds; the first local Docker startup can take longer while the sandbox image is pulled.", + "provisioningHint": "Cloud (E2B) is being provisioned. A warm start usually takes 10-30 seconds.", "preparing": { - "title": "Preparing sandbox", - "body": "Starting the sandbox and waiting for parsar-daemon to pair.", - "coldStartBody": "A warm start usually takes 10-30 seconds. The first local Docker startup can take longer while the sandbox image is pulled.", + "title": "Preparing Cloud (E2B)", + "body": "Starting the cloud environment and waiting for parsar-daemon to pair.", + "coldStartBody": "A warm start usually takes 10-30 seconds.", "runtimeId": "Runtime id", "started": "Started", "steps": { "prepareImage": "Prepare image", - "prepareImageDetail": "Checking the sandbox image and startup settings.", - "pullImage": "Pull sandbox image", - "pullImageDetail": "If this image is not cached locally, Docker may be downloading it now.", - "pullImageSlowDetail": "Still waiting. First pull can take several minutes on a slow network.", - "startContainer": "Start container", - "startContainerDetail": "Create the isolated sandbox container after the image is ready.", + "prepareImageDetail": "Checking the E2B template and startup settings.", + "pullImage": "Prepare template", + "pullImageDetail": "The cloud provider is preparing the runtime template.", + "pullImageSlowDetail": "Still waiting. Cloud provisioning can take longer on a cold start.", + "startContainer": "Start environment", + "startContainerDetail": "Create the isolated cloud environment after the template is ready.", "pairDaemon": "Pair daemon", - "pairDaemonDetail": "Wait for parsar-daemon inside the sandbox to connect back." + "pairDaemonDetail": "Wait for parsar-daemon inside the cloud environment to connect back." } }, "startupTimedOut": { - "title": "Sandbox startup timed out", - "body": "The sandbox did not pair before the startup token expired. This often happens when the first local Docker startup is still pulling the sandbox image." + "title": "Cloud startup timed out", + "body": "The cloud environment did not pair before the startup token expired. Retry provisioning from the Agent detail." }, "confirm": { "kill": { @@ -1399,8 +1399,7 @@ "executionMode": "Execution mode", "agentEngine": "Agent engine", "device": "Local device", - "sandboxSize": "Sandbox size", - "sandboxImage": "Sandbox image", + "sandboxSize": "Cloud size", "workDir": "Working directory (optional)" }, "placeholders": { @@ -1425,13 +1424,10 @@ "workDirAbsolute": "Working directory must be an absolute path (starting with /)." }, "sandboxSize": { - "hint": "Changes take effect after the current sandbox is released.", + "hint": "Changes take effect after the current cloud environment is released.", "standard": "Standard (2 vCPU / 4 GiB)", "xl": "Large (4 vCPU / 8 GiB)" }, - "sandboxImage": { - "hint": "The server pulls this container image when the Agent's sandbox starts for the first time." - }, "emptyModel": { "title": "No usable Model yet", "description": "Go to Models to onboard one. The fields you typed here will be carried over so you can come back.", @@ -1442,7 +1438,7 @@ "description": "Configure a Connector first so the Agent knows where to run.", "cta": "Configure Connector" }, - "runtimeHint": "Runtime is part of an agent's identity and cannot be changed after creation. To switch, recreate the agent. Cloud Sandbox requires the deployment to have cloud runtime enabled; open-source/self-hosted deployments may also need a workspace runtime credential.", + "runtimeHint": "Runtime is part of an agent's identity and cannot be changed after creation. To switch, recreate the agent. Cloud (E2B) needs an E2B-compatible provider; Local device uses the default local daemon or a paired device.", "runtimeLocked": "Runtime is set at creation and cannot be changed", "noTagsAdmin": "This workspace has no reusable capability tags yet.", "noTagsMember": "This workspace has no reusable capability tags yet.", @@ -1503,12 +1499,12 @@ "modelProtocolMismatch": "{{engine}} can't use this model's protocol", "selected": "Selected", "devicePicker": { - "hint": "Pick a connected local daemon. Cloud isolation does not need a device; Parsar lazy-starts a sandbox daemon on first run." + "hint": "Pick the default local daemon or another connected device. Cloud (E2B) does not need a local device." }, "workDir": { "placeholder": "/Users/me/code/my-app", "hintLocal": "Directory the agent runs in on your local device. Leave blank to use a per-conversation scratch dir. Created if it does not exist.", - "hintSandbox": "Directory inside the sandbox container (e.g. /workspace/my-app). Leave blank to use a per-conversation scratch dir. Created if it does not exist." + "hintSandbox": "Directory inside the Cloud (E2B) environment (e.g. /workspace/my-app). Leave blank to use a per-conversation scratch dir. Created if it does not exist." } }, "pendingCapability": { @@ -1527,17 +1523,17 @@ }, "execution": { "sandbox": { - "title": "Cloud isolation", - "description": "Parsar prepares an isolated sandbox in ~10s.", + "title": "Cloud (E2B)", + "description": "Run in an E2B-compatible cloud environment.", "poolHint": "Sandbox pool: {{idle}} available", "poolEmpty": "Sandbox pool is empty — cold start required after creation" }, "localDevice": { "title": "Local device", - "description": "Use a paired local device for local code." + "description": "Use the default local daemon or another paired device." }, "external": { - "title": "External Agent", + "title": "HTTP Agent", "description": "Route to a self-managed HTTP Agent service." } }, @@ -2260,7 +2256,7 @@ }, "credential": { "title": "E2B API Key", - "description": "Agents using Cloud Sandbox use this key. Once saved, only the masked form is shown.", + "description": "Agents using Cloud (E2B) use this key. Once saved, only the masked form is shown.", "state": { "hasCredential": "Saved", "noCredential": "Not entered yet." @@ -2286,7 +2282,7 @@ }, "delete": { "title": "Delete E2B API Key?", - "description": "Agents using Cloud Sandbox cannot start until a new key is entered.", + "description": "Agents using Cloud (E2B) cannot start until a new key is entered.", "confirm": "Confirm delete" }, "error": { @@ -2299,13 +2295,13 @@ "cloudReadyManaged": "Runtime is managed by Parsar — no setup required.", "cloudReadyOps": "Cloud runtime ready · configured by your administrator. Contact ops to adjust quotas.", "cloudOff": "No cloud credential registered for this workspace", - "cloudOffHint": "Agents can still run locally or in external environments. To use cloud isolation, register a credential in the Workspace credential card below.", + "cloudOffHint": "Agents can still run on Local device. To use Cloud (E2B), register a credential in the Workspace credential card below.", "cloudOffActionDocs": "Open deployment docs", "cloudMisconfigured": "Cloud runtime configuration is incomplete — check the workspace credential or contact the operator.", "unreachable": "Cannot reach the Parsar backend; runtime status is unknown.", "retry": "Retry", - "cloudRunnerUnavailable": "Cloud Sandbox is not enabled", - "cloudRunnerUnavailableHint": "This deployment has not enabled Cloud Sandbox yet. Contact the deployment administrator." + "cloudRunnerUnavailable": "Cloud (E2B) is not enabled", + "cloudRunnerUnavailableHint": "This deployment has not enabled Cloud (E2B) yet. Contact the deployment administrator." }, "list": { "summary": "{{count}} runtime instance(s)", @@ -2342,7 +2338,7 @@ }, "localOss": { "title": "No cloud credential registered for this workspace", - "description": "Agents can still run locally or externally. To run an Agent inside an isolated cloud environment, register the sandbox provider's API key in the Workspace credential card above.", + "description": "Agents can still run on Local device. To run an Agent inside an isolated Cloud (E2B) environment, register the provider API key in the Workspace credential card above.", "afterSnippet": "Once registered, every runtime=sandbox Agent in this workspace shares the credential. Full setup at ", "docsLink": "deployment docs", "e2bSignup": "No E2B account yet? Create one at E2B" @@ -2350,12 +2346,12 @@ "localManaged": { "title": "Cloud runtime is not enabled", "description": "This workspace currently has no cloud runtime quota. Contact the Parsar team or your workspace owner to enable it.", - "fallback": "Agents can still run locally / externally — open Agent detail → Runtime tab and pick any option other than \"Follow workspace\"." + "fallback": "Agents can still run on Local device." }, "localSelfhost": { "title": "No workspace runtime credential yet", "description": "This private deployment is running in local mode. Workspace admins can register a cloud sandbox credential in the \"Workspace credential\" card above; if your company centrally manages credentials, ask ops or IT for help.", - "afterSnippet": "Until a credential is registered, Agents only run locally or in external environments." + "afterSnippet": "Until a credential is registered, Agents only run on Local device." }, "misconfiguredOss": { "title": "Cloud runtime configuration is incomplete", @@ -2479,16 +2475,16 @@ }, "providers": { "sandbox": { - "title": "Cloud Sandbox", - "description": "Run Agents in isolated cloud environments for stronger isolation and credential governance." + "title": "Cloud (E2B)", + "description": "Run Agents in E2B-compatible cloud environments for stronger isolation and credential governance." }, "localDevice": { "title": "Local Device", - "description": "Connect team-owned machines to Parsar so Agents can use local CLIs, workdirs, and BYO credentials.", + "description": "Use the default local daemon, or connect team-owned machines so Agents can use local CLIs, workdirs, and BYO credentials.", "badge": "Paired device" }, "external": { - "title": "External Agent", + "title": "HTTP Agent", "description": "Attach an existing Agent service to Parsar from the Agent configuration.", "badge": "Per Agent" }, @@ -2506,21 +2502,21 @@ }, "deploymentOff": { "label": "Not enabled", - "body": "Cloud Sandbox is not enabled for this deployment yet. No workspace setup is needed until it is enabled.", - "bodyWithCredential": "The E2B API Key is saved, but Cloud Sandbox is not enabled for this deployment yet." + "body": "Cloud (E2B) is not enabled for this deployment yet. No workspace setup is needed until it is enabled.", + "bodyWithCredential": "The E2B API Key is saved, but Cloud (E2B) is not enabled for this deployment yet." }, "notConfigured": { "label": "Not configured", - "body": "Enter an E2B API Key before Agents can use Cloud Sandbox." + "body": "Enter an E2B API Key before Agents can use Cloud (E2B)." }, "ready": { "label": "Ready", - "body": "Cloud Sandbox is ready. {{count}} Agent(s) currently use Cloud Sandbox." + "body": "Cloud (E2B) is ready. {{count}} Agent(s) currently use Cloud (E2B)." }, "error": { "label": "Error", - "body": "Cloud Sandbox is temporarily unavailable. Check workspace configuration and validate again.", - "managedBody": "Cloud Sandbox is managed by the deployment but is temporarily unavailable. Contact the deployment administrator to check the sandbox service.", + "body": "Cloud (E2B) is temporarily unavailable. Check workspace configuration and validate again.", + "managedBody": "Cloud (E2B) is managed by the deployment but is temporarily unavailable. Contact the deployment administrator to check the provider.", "bodyWithCredential": "{{value}} is saved, but connection validation did not pass. Re-enter the E2B API Key and validate again." }, "unknown": { @@ -2532,7 +2528,7 @@ "stats": { "deployment": "Deployment", "workspace": "Workspace", - "agents": "Agents using Cloud Sandbox", + "agents": "Agents using Cloud (E2B)", "enabled": "Enabled", "disabled": "Disabled", "configured": "Configured", @@ -2543,26 +2539,26 @@ "body": "This workspace does not need its own E2B API Key." }, "instances": { - "title": "Active Cloud Sandboxes", - "emptyTitle": "No active Cloud Sandbox yet", - "emptyBody": "Agents using Cloud Sandbox start an isolated environment on their next message and appear here." + "title": "Active Cloud (E2B) Environments", + "emptyTitle": "No active Cloud (E2B) environment yet", + "emptyBody": "Agents using Cloud (E2B) start an isolated environment on their next message and appear here." }, "daemonRuntimes": { - "title": "Sandbox Daemons", - "description": "parsar-daemon processes running inside Cloud Sandboxes. They belong to Cloud Sandbox, not Local Device.", + "title": "Cloud Daemons", + "description": "parsar-daemon processes running inside Cloud (E2B) environments. They belong to Cloud (E2B), not Local Device.", "status": { "preparing": "Preparing", - "preparingDetail": "Starting the sandbox and waiting for parsar-daemon to pair. First local Docker startup may include pulling the sandbox image.", + "preparingDetail": "Starting the cloud environment and waiting for parsar-daemon to pair.", "timedOut": "Startup timed out", - "timedOutDetail": "The sandbox did not pair before the startup token expired. Retry provisioning from the Agent detail." + "timedOutDetail": "The cloud environment did not pair before the startup token expired. Retry provisioning from the Agent detail." }, "errors": { - "loadFailed": "Failed to load Sandbox Daemons" + "loadFailed": "Failed to load Cloud Daemons" }, "table": { "runtime": "Runtime", "agent": "Agent", - "kind": "Sandbox kind", + "kind": "Cloud runtime", "agentEngines": "Agent engines", "status": "Status", "heartbeat": "Last heartbeat" @@ -2570,11 +2566,17 @@ } }, "external": { - "body": "External Agents do not need workspace-level setup. Create or edit an Agent, choose External Agent, then fill in its endpoint and auth method." + "body": "HTTP agents do not need workspace-level setup. Configure the endpoint and auth method from the Agent configuration." }, "agentDaemon": { "intro": "Local devices connect back to Parsar through parsar-daemon so Agents can use Claude Code / OpenCode CLIs on that machine.", "empty": "No local device is connected yet. Generate a connection command from the button on the right.", + "defaultDevice": { + "title": "Default local daemon", + "statusReady": "Started", + "statusPending": "Starting", + "description": "Parsar starts a local daemon with the server install. Use this default local path first; pair extra devices only when Agents need another machine." + }, "errors": { "loadFailed": "Failed to load Local Devices" }, diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index ea0086d..d980391 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -1090,9 +1090,9 @@ "runtime": { "label": "运行环境", "local": "本机连接", - "sandbox": "云端隔离 (推荐)", + "sandbox": "Cloud (E2B) (推荐)", "localHint": "Agent 直接在本机进程运行,与当前机器共享文件系统。", - "sandboxHint": "Agent 在云端独立沙盒里跑,互不影响、不接触你本地。", + "sandboxHint": "Agent 在 Cloud (E2B) 独立环境里跑,互不影响、不接触你本地。", "followWorkspace": "跟随工作区默认 (推荐)" }, "loadError": { @@ -1335,33 +1335,33 @@ }, "renewedToast": "已续期到 {{expiresAt}}", "provisionError": "分配失败", - "provisioningHint": "沙盒正在准备中。热启动通常需要 10-30 秒;本地 Docker 首次启动可能需要先拉取沙盒镜像,因此会更久。", + "provisioningHint": "Cloud (E2B) 正在准备中。热启动通常需要 10-30 秒。", "preparing": { - "title": "正在准备沙盒", - "body": "正在启动沙盒,并等待 parsar-daemon 完成配对。", - "coldStartBody": "热启动通常需要 10-30 秒。本地 Docker 首次启动可能需要先拉取沙盒镜像,因此会更久。", + "title": "正在准备 Cloud (E2B)", + "body": "正在启动云端环境,并等待 parsar-daemon 完成配对。", + "coldStartBody": "热启动通常需要 10-30 秒。", "runtimeId": "Runtime id", "started": "开始于", "steps": { "prepareImage": "准备镜像", - "prepareImageDetail": "检查沙盒镜像和启动参数。", - "pullImage": "拉取沙盒镜像", - "pullImageDetail": "如果本机还没有缓存该镜像,Docker 现在可能正在下载。", - "pullImageSlowDetail": "仍在等待。首次拉取在慢网络下可能需要几分钟。", - "startContainer": "启动容器", - "startContainerDetail": "镜像就绪后创建隔离的沙盒容器。", + "prepareImageDetail": "检查 E2B template 和启动参数。", + "pullImage": "准备 template", + "pullImageDetail": "云端 provider 正在准备 runtime template。", + "pullImageSlowDetail": "仍在等待。冷启动时云端分配可能更久。", + "startContainer": "启动环境", + "startContainerDetail": "template 就绪后创建隔离云端环境。", "pairDaemon": "配对 daemon", - "pairDaemonDetail": "等待沙盒内的 parsar-daemon 反向连接。" + "pairDaemonDetail": "等待云端环境里的 parsar-daemon 反向连接。" } }, "startupTimedOut": { - "title": "沙盒启动超时", - "body": "沙盒没有在启动 token 过期前完成配对。常见原因是本地 Docker 首次启动仍在拉取沙盒镜像。" + "title": "Cloud 启动超时", + "body": "云端环境没有在启动 token 过期前完成配对。请到 Agent 详情页重试分配。" }, "confirm": { "kill": { "title": "确定 Kill 这个 sandbox 吗?", - "description": "与该 Agent 绑定的长期 E2B sandbox 会立刻销毁。当前 sandbox 里正在做的事会丢失;下一次 prompt 会从基础模板冷启一个新 sandbox(约 10-20 秒)。如果你最近改过 Agent 的沙盒规格,Kill 后下次冷启会切到新规格。", + "description": "与该 Agent 绑定的长期 E2B sandbox 会立刻销毁。当前 sandbox 里正在做的事会丢失;下一次 prompt 会从基础模板冷启一个新 sandbox(约 10-20 秒)。如果你最近改过 Cloud size,Kill 后下次冷启会切到新规格。", "confirmLabel": "Kill sandbox" }, "rebuild": { @@ -1399,8 +1399,7 @@ "executionMode": "执行方式", "agentEngine": "Agent 引擎", "device": "本地设备", - "sandboxSize": "沙盒规格", - "sandboxImage": "沙盒镜像", + "sandboxSize": "云端规格", "workDir": "工作目录(可选)" }, "placeholders": { @@ -1425,13 +1424,10 @@ "workDirAbsolute": "工作目录必须是绝对路径(以 / 开头)。" }, "sandboxSize": { - "hint": "改完规格后,等当前沙盒被释放才会切换到新规格。", + "hint": "改完规格后,等当前云端环境释放后才会切换到新规格。", "standard": "标准 (2 vCPU / 4 GiB)", "xl": "大规格 (4 vCPU / 8 GiB)" }, - "sandboxImage": { - "hint": "Agent 首次启动沙盒时,服务端会拉取这个容器镜像。" - }, "emptyModel": { "title": "还没有可用 Model", "description": "先去 Models 页接入一个模型;当前已填内容会带过去,配完后可以回到新建 Agent。", @@ -1442,7 +1438,7 @@ "description": "先配置一个 Connector,Agent 才知道在哪里运行。", "cta": "去配置 Connector" }, - "runtimeHint": "运行环境是 Agent 身份的一部分,创建后不可修改。要换运行方式请新建 Agent。选「云端隔离」需要部署已启用云端沙盒;开源/自托管场景可能需要工作区运行凭证。", + "runtimeHint": "运行环境是 Agent 身份的一部分,创建后不可修改。要换运行方式请新建 Agent。Cloud (E2B) 需要 E2B-compatible provider;Local device 使用默认本地 daemon 或已配对设备。", "runtimeLocked": "运行环境创建时确定,不可修改", "noTagsAdmin": "当前工作区还没有可复用的能力标签。", "noTagsMember": "当前工作区还没有可复用的能力标签。", @@ -1503,12 +1499,12 @@ "modelProtocolMismatch": "{{engine}} 无法使用该模型的协议", "selected": "已选择", "devicePicker": { - "hint": "选择一台已连接的本地 daemon。云端隔离不需要选择设备,会在首次运行时自动拉起 sandbox daemon。" + "hint": "选择默认本地 daemon 或另一台已连接设备。Cloud (E2B) 不需要选择本地设备。" }, "workDir": { "placeholder": "/Users/me/code/my-app", "hintLocal": "Agent 在你这台本地设备上运行时的目录。留空则每个会话各用一个临时目录。路径不存在时自动创建。", - "hintSandbox": "Agent 在沙盒容器内运行时的目录(如 /workspace/my-app)。留空则每个会话各用一个临时目录。路径不存在时自动创建。" + "hintSandbox": "Agent 在 Cloud (E2B) 环境内运行时的目录(如 /workspace/my-app)。留空则每个会话各用一个临时目录。路径不存在时自动创建。" } }, "pendingCapability": { @@ -1527,17 +1523,17 @@ }, "execution": { "sandbox": { - "title": "云端隔离", - "description": "Parsar 自动准备隔离沙箱,约 10 秒就绪。", + "title": "Cloud (E2B)", + "description": "在 E2B-compatible 云端环境中运行。", "poolHint": "沙盒池:{{idle}} 个可用", "poolEmpty": "沙盒池已空,创建后需要等待冷启动" }, "localDevice": { - "title": "本地设备", - "description": "使用已配对的本地设备,贴近本机代码。" + "title": "Local device", + "description": "使用默认本地 daemon 或另一台已配对设备。" }, "external": { - "title": "外部 Agent", + "title": "HTTP Agent", "description": "接入企业自管 HTTP Agent 服务。" } }, @@ -1680,7 +1676,7 @@ }, "empty": { "modelKeys": "还没有模型 API Key。通常从 Models 页创建 Provider 时自动生成。", - "runtimeKeys": "还没有运行环境凭据。需要云端隔离运行时再配置。" + "runtimeKeys": "还没有运行环境凭据。需要 Cloud (E2B) 运行时再配置。" }, "error": { "load": { @@ -2251,7 +2247,7 @@ "placeholder": "本机运行始终可用 · Agent 实例聚合视图建设中。" }, "sandbox": { - "title": "云端隔离运行", + "title": "Cloud (E2B) 运行", "healthy": "凭证已就绪,{{count}} 个 Agent 选择云端运行。", "notConfigured": "未录入工作区凭证;标记为云端运行的 Agent 暂时无法启动。", "misconfigured": "已录入凭证但无法连通,请重置凭证或联系部署方。", @@ -2260,7 +2256,7 @@ }, "credential": { "title": "E2B API Key", - "description": "Agent 选择云端沙盒时会使用这条 Key。保存后只展示遮蔽形式。", + "description": "Agent 选择 Cloud (E2B) 时会使用这条 Key。保存后只展示遮蔽形式。", "state": { "hasCredential": "已保存", "noCredential": "尚未录入。" @@ -2286,7 +2282,7 @@ }, "delete": { "title": "删除 E2B API Key?", - "description": "删除后,选择云端沙盒的 Agent 在重新录入前无法启动。", + "description": "删除后,选择 Cloud (E2B) 的 Agent 在重新录入前无法启动。", "confirm": "确认删除" }, "error": { @@ -2295,17 +2291,17 @@ }, "status": { "cloudReady": "云端运行已就绪", - "cloudReadyHint": "当前工作区可让 Agent 运行在云端隔离环境中。", + "cloudReadyHint": "当前工作区可让 Agent 运行在 Cloud (E2B) 环境中。", "cloudReadyManaged": "运行环境由 Parsar 托管,无需配置。", "cloudReadyOps": "云端运行已就绪 · 由管理员配置。联系系统管理员调整额度。", "cloudOff": "本工作区尚未录入云端凭证", - "cloudOffHint": "Agent 仍可走本机或外部环境运行;如需让 Agent 走云端隔离,请在下方「工作区凭证」录入。", + "cloudOffHint": "Agent 仍可走 Local device 运行;如需使用 Cloud (E2B),请在下方「工作区凭证」录入。", "cloudOffActionDocs": "查看部署文档", "cloudMisconfigured": "云端运行配置不完整 — 请检查工作区凭证或联系部署方。", "unreachable": "无法连接到 Parsar 后端,无法判断运行环境状态。", "retry": "重试", - "cloudRunnerUnavailable": "云端沙盒暂未启用", - "cloudRunnerUnavailableHint": "当前部署还没有启用云端沙盒。请联系部署管理员开启后再使用。" + "cloudRunnerUnavailable": "Cloud (E2B) 暂未启用", + "cloudRunnerUnavailableHint": "当前部署还没有启用 Cloud (E2B)。请联系部署管理员开启后再使用。" }, "list": { "summary": "{{count}} 个运行实例", @@ -2335,14 +2331,14 @@ }, "empty": { "newDeployment": { - "title": "还没有正在运行的云端沙盒", - "description": "选择云端沙盒的 Agent 收到消息后,会自动启动隔离环境并出现在这里。", + "title": "还没有正在运行的 Cloud (E2B) 环境", + "description": "选择 Cloud (E2B) 的 Agent 收到消息后,会自动启动隔离环境并出现在这里。", "tryCta": "想立刻验证配置已通?点击下方按钮,会临时启动一个测试环境、运行健康检查,然后自动回收。不会占用任何 Agent。", "tryCtaInline": "验证连接" }, "localOss": { "title": "本工作区尚未录入云端凭证", - "description": "Agent 仍可走本机或外部环境运行。如需让 Agent 在云端隔离环境运行,请在上方「工作区凭证」录入 sandbox 提供方 API key。", + "description": "Agent 仍可走 Local device 运行。如需让 Agent 在 Cloud (E2B) 环境运行,请在上方「工作区凭证」录入 provider API key。", "afterSnippet": "录入完成后,本工作区里所有 runtime=sandbox 的 Agent 会共用这条凭证。完整说明见 ", "docsLink": "部署文档", "e2bSignup": "还没有 E2B account?在 E2B 创建一个" @@ -2350,12 +2346,12 @@ "localManaged": { "title": "云端运行未启用", "description": "你的工作区当前没有云端运行环境配额。请联系 Parsar 团队或你的 workspace owner 开启。", - "fallback": "仍可使用本机/外部 Agent 运行模式 —— 进入 Agent 详情 → 运行环境 tab 选择「跟随工作区」之外的选项。" + "fallback": "Agent 仍可走 Local device 运行。" }, "localSelfhost": { "title": "本工作区尚未录入云端凭证", "description": "你的私有部署当前在本地模式运行。工作区管理员可以在上方的「工作区凭证」卡片录入云端 sandbox 凭证;如果公司统一管理凭证,请联系 ops 或 IT 管理员协助。", - "afterSnippet": "录入凭证前,Agent 只能在本地或外部环境运行。" + "afterSnippet": "录入凭证前,Agent 只能在 Local device 运行。" }, "misconfiguredOss": { "title": "云端运行配置不完整", @@ -2393,7 +2389,7 @@ "placeholderDescription": "完整详情视图(连通性测试、概览、技术细节)将在后续 PR 中实现。", "placeholderBody": "详情页建设中 — Agent ID: {{id}}", "title": "{{agentName}} 的运行实例", - "subtitle": "云端隔离环境 · 由 Agent {{agentName}} 的下次消息按需启用,超过空闲阈值自动回收。", + "subtitle": "Cloud (E2B) 环境 · 由 Agent {{agentName}} 的下次消息按需启用,超过空闲阈值自动回收。", "sections": { "overview": "概览", "connectivity": "连通性测试结果", @@ -2479,16 +2475,16 @@ }, "providers": { "sandbox": { - "title": "云端沙盒", - "description": "Agent 在隔离云环境中运行,适合需要稳定隔离和统一凭据治理的任务。" + "title": "Cloud (E2B)", + "description": "Agent 在 E2B-compatible 云端环境中运行,适合需要稳定隔离和统一凭据治理的任务。" }, "localDevice": { - "title": "本地设备", - "description": "把团队自己的机器接入 Parsar,让 Agent 使用本机 CLI、代码目录和 BYO 凭据。", + "title": "Local device", + "description": "使用默认本地 daemon,或把团队自己的机器接入 Parsar,让 Agent 使用本机 CLI、代码目录和 BYO 凭据。", "badge": "设备配对" }, "external": { - "title": "外部 Agent", + "title": "HTTP Agent", "description": "把已有 Agent 服务接入 Parsar,由具体 Agent 配置入口地址。", "badge": "按 Agent 配置" }, @@ -2506,21 +2502,21 @@ }, "deploymentOff": { "label": "部署未启用", - "body": "当前部署还没有启用云端沙盒。这里不需要继续填写,启用后再配置工作区的 E2B API Key。", - "bodyWithCredential": "E2B API Key 已保存;当前部署还没有启用云端沙盒。启用后,选择云端沙盒的 Agent 才能启动隔离环境。" + "body": "当前部署还没有启用 Cloud (E2B)。这里不需要继续填写,启用后再配置工作区的 E2B API Key。", + "bodyWithCredential": "E2B API Key 已保存;当前部署还没有启用 Cloud (E2B)。启用后,选择 Cloud (E2B) 的 Agent 才能启动隔离环境。" }, "notConfigured": { "label": "工作区未配置", - "body": "录入 E2B API Key 后,Agent 才能选择云端沙盒运行。" + "body": "录入 E2B API Key 后,Agent 才能选择 Cloud (E2B) 运行。" }, "ready": { "label": "已就绪", - "body": "云端沙盒已就绪。当前有 {{count}} 个 Agent 选择云端沙盒。" + "body": "Cloud (E2B) 已就绪。当前有 {{count}} 个 Agent 选择 Cloud (E2B)。" }, "error": { "label": "异常", - "body": "云端沙盒暂时不可用。请检查工作区配置后再验证。", - "managedBody": "云端沙盒由部署方托管,但当前暂不可用。请联系部署管理员检查沙盒服务。", + "body": "Cloud (E2B) 暂时不可用。请检查工作区配置后再验证。", + "managedBody": "Cloud (E2B) 由部署方托管,但当前暂不可用。请联系部署管理员检查 provider。", "bodyWithCredential": "已保存 {{value}},但当前连接校验没有通过。可以重新录入 E2B API Key 后再验证。" }, "unknown": { @@ -2532,7 +2528,7 @@ "stats": { "deployment": "部署状态", "workspace": "工作区配置", - "agents": "选择云端沙盒的 Agent", + "agents": "选择 Cloud (E2B) 的 Agent", "enabled": "已启用", "disabled": "未启用", "configured": "已配置", @@ -2543,26 +2539,26 @@ "body": "本工作区不需要单独录入 E2B API Key。" }, "instances": { - "title": "正在运行的云端沙盒", - "emptyTitle": "还没有正在运行的云端沙盒", - "emptyBody": "选择云端沙盒的 Agent 收到消息后,会自动启动隔离环境并出现在这里。" + "title": "正在运行的 Cloud (E2B) 环境", + "emptyTitle": "还没有正在运行的 Cloud (E2B) 环境", + "emptyBody": "选择 Cloud (E2B) 的 Agent 收到消息后,会自动启动隔离环境并出现在这里。" }, "daemonRuntimes": { - "title": "沙盒内 Daemon", - "description": "运行在云端沙盒里的 parsar-daemon 进程。它们属于云端沙盒,不是本地设备。", + "title": "Cloud Daemon", + "description": "运行在 Cloud (E2B) 环境里的 parsar-daemon 进程。它们属于 Cloud (E2B),不是 Local device。", "status": { "preparing": "准备中", - "preparingDetail": "正在启动沙盒,并等待 parsar-daemon 完成配对。本地 Docker 首次启动可能需要先拉取沙盒镜像。", + "preparingDetail": "正在启动云端环境,并等待 parsar-daemon 完成配对。", "timedOut": "启动超时", - "timedOutDetail": "沙盒没有在启动 token 过期前完成配对。请到 Agent 详情页重试分配。" + "timedOutDetail": "云端环境没有在启动 token 过期前完成配对。请到 Agent 详情页重试分配。" }, "errors": { - "loadFailed": "无法加载沙盒内 Daemon" + "loadFailed": "无法加载 Cloud Daemon" }, "table": { "runtime": "Runtime", "agent": "Agent", - "kind": "沙盒类型", + "kind": "云端运行环境", "agentEngines": "Agent 引擎", "status": "状态", "heartbeat": "最后心跳" @@ -2570,11 +2566,17 @@ } }, "external": { - "body": "外部 Agent 不需要工作区级配置。创建或编辑 Agent 时选择外部 Agent,并填写服务入口与认证方式即可。" + "body": "HTTP agent 不需要工作区级配置;在 Agent 配置里填写服务入口和认证方式即可。" }, "agentDaemon": { "intro": "本地设备通过 parsar-daemon 反向连接 Parsar,让 Agent 使用这台机器上的 Claude Code / OpenCode 等 CLI。", "empty": "尚未接入任何本地设备。点击右上角按钮生成连接命令。", + "defaultDevice": { + "title": "默认本地 daemon", + "statusReady": "已启动", + "statusPending": "启动中", + "description": "Parsar 安装 server 时会启动一个本地 daemon。优先使用这个默认本地路径;只有 Agent 需要另一台机器时,再额外配对设备。" + }, "errors": { "loadFailed": "无法加载本地设备列表" }, diff --git a/apps/web/src/lib/api-runtime.ts b/apps/web/src/lib/api-runtime.ts index af354dd..649155c 100644 --- a/apps/web/src/lib/api-runtime.ts +++ b/apps/web/src/lib/api-runtime.ts @@ -16,14 +16,26 @@ import { ApiError, apiRequest, noUnreachableRetry } from "./api-client" * matrix branch. * - profile: deployment profile ("oss" / "managed" / * "selfhost") - * - configured_by: legacy field; "ops" when server has - * PARSAR_OPENCODE_RUNNER set. Informational only - * (env no longer drives runtime selection). + * - providers: normalized runtime providers. Product-wise this is + * manual_daemon plus optional e2b_compatible. */ export type RuntimeMode = "sandbox" | "local" export type RuntimeProfile = "oss" | "managed" | "selfhost" -export type RuntimeConfiguredBy = "ops" | "self" +export type RuntimeProviderID = "manual_daemon" | "e2b_compatible" + +export interface RuntimeProviderStatus { + id: RuntimeProviderID + label: string + kind: "manual" | "managed" + configured: boolean + available: boolean + recommended?: boolean + requires?: string[] + missing?: string[] + message?: string + action?: "pair_daemon" | "configure_e2b" +} export interface RuntimeStatus { has_credential: boolean @@ -31,8 +43,7 @@ export interface RuntimeStatus { available: boolean sandbox_agent_count: number profile: RuntimeProfile - configured_by?: RuntimeConfiguredBy - sandbox_image?: string + providers: RuntimeProviderStatus[] } const KEY_RUNTIME_STATUS = (workspaceID: string) => diff --git a/apps/web/src/pages/admin/CreateAgentDialog.tsx b/apps/web/src/pages/admin/CreateAgentDialog.tsx index e45edb8..ebabf17 100644 --- a/apps/web/src/pages/admin/CreateAgentDialog.tsx +++ b/apps/web/src/pages/admin/CreateAgentDialog.tsx @@ -1,7 +1,7 @@ import { Fragment, forwardRef, useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from "react" import { useTranslation } from "react-i18next" import { useQueryClient } from "@tanstack/react-query" -import { AlertTriangle, ArrowUpRight, Bot, Check, ChevronDown, Cloud, Cpu, Eye, EyeOff, Laptop, Network, Search, Server, Sparkles } from "lucide-react" +import { AlertTriangle, ArrowUpRight, Bot, Check, ChevronDown, Cloud, Cpu, Eye, EyeOff, Laptop, Search, Server, Sparkles } from "lucide-react" import { Badge } from "../../components/ui/badge" import { Button } from "../../components/ui/button" @@ -20,7 +20,6 @@ import { ApiError } from "../../lib/api-client" import { useCapabilitiesQuery, aggregateRequiredCredentials, aggregateRequiredCredentialsByID, useCapabilityVersionsQuery, useAgentCapabilitiesQuery, useEnableAgentCapabilityMutation } from "../../lib/api-capabilities" import { CredentialCheckPanel } from "../../components/admin/CredentialCheckPanel" import { useSecrets } from "../../lib/api-secrets" -import { useRuntimeStatus } from "../../lib/api-runtime" import type { UpdateAgentProfileRequest } from "../../lib/api-agents" import type { AgentInlineNewSecret, @@ -252,7 +251,6 @@ export function CreateAgentDialog({ onOpenChange, onSubmit, }: CreateAgentDialogProps) { - const runtimeStatus = useRuntimeStatus(open ? workspaceID : null) const { t } = useTranslation("admin") const { t: tc } = useTranslation("common") const queryClient = useQueryClient() @@ -1022,7 +1020,7 @@ export function CreateAgentDialog({ {showExecutionChoices ? ( <> -
+
} title={t("agents.execution.sandbox.title")} @@ -1037,16 +1035,6 @@ export function CreateAgentDialog({ selected={executionMode === "local_device"} onSelect={() => setExecutionMode("local_device")} /> - {mode === "create" && ( - } - title={t("agents.execution.external.title")} - description={t("agents.execution.external.description")} - selected={executionMode === "external"} - onSelect={() => setExecutionMode("external")} - disabled - /> - )}
{connector === "agent_daemon" && ( @@ -1081,29 +1069,20 @@ export function CreateAgentDialog({ )} {connector === "agent_daemon" && executionMode === "sandbox" && ( -
- + setSandboxSize(e.target.value === "xl" ? "xl" : "standard")} - disabled={pending} - className="h-9 rounded-md border border-line bg-surface px-3 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-line-strong disabled:cursor-not-allowed disabled:bg-surface-subtle" - > - - - - - {runtimeStatus.data?.sandbox_image ? ( - - - {runtimeStatus.data.sandbox_image} - - - ) : null} -
+ + + + )} ) : ( diff --git a/apps/web/src/pages/admin/RuntimePage.tsx b/apps/web/src/pages/admin/RuntimePage.tsx index 6d8fd04..3341aa5 100644 --- a/apps/web/src/pages/admin/RuntimePage.tsx +++ b/apps/web/src/pages/admin/RuntimePage.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useState } from "react" import { useTranslation } from "react-i18next" import type { TFunction } from "i18next" -import { Cloud, PlugZap, Skull, Zap } from "lucide-react" +import { Cloud, Skull, Zap } from "lucide-react" import { AdminLayout } from "../../components/layout/AdminLayout" import { PageHeader } from "../../components/layout/PageHeader" @@ -54,7 +54,7 @@ import { useMyWorkspaces } from "../../lib/api-workspaces" import { useNow } from "../../lib/use-now" import { useWorkspaceId } from "../../lib/workspace" -type RuntimeTab = "sandbox" | "local_device" | "external" +type RuntimeTab = "sandbox" | "local_device" type CloudState = "loading" | "notConfigured" | "ready" | "error" | "unknown" type SortKey = "last_active" | "created_at" | "agent" @@ -242,13 +242,7 @@ export function RuntimePage() { /> )} - {tab === "local_device" && ( -
- -
- )} - - {tab === "external" && } + {tab === "local_device" && }
@@ -879,13 +873,6 @@ function RuntimeTabs({ tab, onChange }: { tab: RuntimeTab; onChange: (next: Runt > {t("runtime.providers.localDevice.title", { defaultValue: "Local Device" })} - onChange("external")} - testId="runtime-tab-external" - > - {t("runtime.providers.external.title")} - ) @@ -937,27 +924,6 @@ function CloudStateBadge({ state }: { state: CloudState }) { ) } -function ExternalAgentPanel() { - const { t } = useTranslation("admin") - return ( -
-
-
- -
-
-

- {t("runtime.providers.external.title")} -

-

- {t("runtime.external.body")} -

-
-
-
- ) -} - function ConfirmBulkKillDialog({ open, count, diff --git a/apps/web/src/pages/admin/runtimes/LocalDeviceRuntimesPanel.tsx b/apps/web/src/pages/admin/runtimes/LocalDeviceRuntimesPanel.tsx index e99cf55..8805971 100644 --- a/apps/web/src/pages/admin/runtimes/LocalDeviceRuntimesPanel.tsx +++ b/apps/web/src/pages/admin/runtimes/LocalDeviceRuntimesPanel.tsx @@ -1,7 +1,7 @@ import { useState } from "react" import { useTranslation } from "react-i18next" import type { TFunction } from "i18next" -import { Loader2, ShieldAlert, Trash2 } from "lucide-react" +import { Laptop, Loader2, ShieldAlert, Trash2 } from "lucide-react" import { Badge } from "../../../components/ui/badge" import { Button } from "../../../components/ui/button" @@ -32,6 +32,7 @@ import { type Runtime, type SupportedAgentKind, } from "../../../lib/api-runtimes" +import { useRuntimeStatus } from "../../../lib/api-runtime" import { useWorkspaceId } from "../../../lib/workspace" export function LocalDeviceRuntimesPanel() { @@ -43,6 +44,7 @@ export function LocalDeviceRuntimesPanel() { function LocalDeviceRuntimesPanelInner({ workspaceID }: { workspaceID: string }) { const { t } = useTranslation("admin") + const statusQ = useRuntimeStatus(workspaceID) const listQ = useWorkspaceRuntimes(workspaceID, "agent_daemon") const deleteMut = useDeleteRuntime(workspaceID) const [pairOpen, setPairOpen] = useState(false) @@ -71,8 +73,43 @@ function LocalDeviceRuntimesPanelInner({ workspaceID }: { workspaceID: string }) const runtimes = (listQ.data ?? []).filter( (r) => isLocalDeviceRuntime(r) && r.liveness !== "pending_pairing", ) + const manualProvider = statusQ.data?.providers.find((provider) => provider.id === "manual_daemon") return (
+
+
+
+
+ +
+
+
+

+ {t("runtime.agentDaemon.defaultDevice.title", { + defaultValue: "Default local daemon", + })} +

+ + {manualProvider?.available === false + ? t("runtime.agentDaemon.defaultDevice.statusPending", { + defaultValue: "Starting", + }) + : t("runtime.agentDaemon.defaultDevice.statusReady", { + defaultValue: "Started", + })} + +
+

+ {t("runtime.agentDaemon.defaultDevice.description", { + defaultValue: + "Parsar starts a local daemon with the server install. Use this default local path first; pair extra devices only when Agents need another machine.", + })} +

+
+
+
+
+

{t("runtime.agentDaemon.intro", { diff --git a/docker-compose.yml b/docker-compose.yml index 1b51c1e..ee32f0c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,22 +18,20 @@ # - Local build fallback (before the tag is published, or to test your # own changes): # make docker-build PARSAR_IMAGE=parsar PARSAR_IMAGE_TAG=local -# PARSAR_SERVER_IMAGE=parsar:local docker compose up +# PARSAR_SERVER_IMAGE=parsar:local +# docker compose up # # Override knobs (all optional — defaults make `up` work with no .env): # PARSAR_PROJECT_NAME compose project/network prefix (default: parsar) # PARSAR_SERVER_IMAGE server image ref (default: GHCR latest) -# PARSAR_SANDBOX_IMAGE sandbox image ref (default: GHCR latest) # PARSAR_LOCAL_PORT host port for the web UI (default: 18080) # PARSAR_PG_PORT host port for Postgres (default: 15432) # PARSAR_BIND_ADDR host bind address (default: 0.0.0.0) # PARSAR_PG_DATA_DIR host path for Postgres data (default: named volume) # PARSAR_DATA_DIR host path for server data (default: named volume) -# Explicit project name so the auto-created network is always parsar_default -# regardless of the clone directory name. The sandbox provider hard-codes this -# network below (AGENT_DAEMON_SANDBOX_DOCKER_NETWORK); mismatched names would -# leave every acquired sandbox unable to reach parsar-server. +# Explicit project name so container names and the compose network stay stable +# across install directories. name: ${PARSAR_PROJECT_NAME:-parsar} networks: @@ -154,31 +152,8 @@ services: PARSAR_DISCORD_GATEWAY: "${PARSAR_DISCORD_GATEWAY:-}" 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 + Codex + Pi + parsar-daemon. The server calls docker - # to start / exec / stop sandbox containers via the mounted - # /var/run/docker.sock below. Defaults to the GHCR-published image - # (infra/sandbox/Dockerfile, built + pushed by - # .github/workflows/sandbox-image-release.yml). Build your own - # instead with: - # docker build -f infra/sandbox/Dockerfile -t parsar-sandbox:local . - # PARSAR_SANDBOX_IMAGE=parsar-sandbox:local - # 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_NETWORK: "${PARSAR_PROJECT_NAME:-parsar}_default" - AGENT_DAEMON_SANDBOX_SERVER_URL: "http://parsar-server:8080" - group_add: - - "${DOCKER_GID:-999}" volumes: - ${PARSAR_DATA_DIR:-parsar-local-data}:/var/lib/parsar - # Docker sandbox — the server calls `docker` against the host - # daemon to acquire/release sandbox containers. The GID mapping - # above (DOCKER_GID) grants the parsar user rights on the socket. - - /var/run/docker.sock:/var/run/docker.sock - - ${PARSAR_DOCKER_BIN:-/usr/bin/docker}:/usr/bin/docker:ro # The image's baked-in HEALTHCHECK probes /healthz with `wget --spider` # (an HTTP HEAD), but /healthz is GET-only and answers HEAD with 405 — # so the default probe reports the container "unhealthy" even though it diff --git a/docs/deploy/lan-deploy.md b/docs/deploy/lan-deploy.md deleted file mode 100644 index 0a2c291..0000000 --- a/docs/deploy/lan-deploy.md +++ /dev/null @@ -1,446 +0,0 @@ -# Parsar LAN deployment guide — full service for multiple users - -> **Audience:** teams that want to deploy Parsar on a developer machine / internal server so that **multiple people** on the LAN can access it through the browser, chat with @Bot in Feishu groups, and pair devices. -> **How this differs from INSTALL.md:** INSTALL.md targets 127.0.0.1 single-user + mock login; this document targets 0.0.0.0 binding + real Feishu OAuth + Feishu Bot + cloud sandbox + LAN reachability. -> **Assumptions:** one Linux machine (also works on macOS), Docker + Docker Compose v2, internet access. - ---- - -## ⚠️ Trust boundary and security assumptions - -This document assumes **the network the deploy machine sits on is trusted** -(family / small-team office LAN, single-user dev machine behind VPN). This -compose is **not suitable** for multi-tenant LANs (school / big-corp networks, -public Wi-Fi) or for direct public-internet exposure. - -Reasons: - -- **The Docker socket is mounted into the server container** (`/var/run/docker.sock`) so it can spin up sandbox containers on demand. This is equivalent to giving the server root-level access to the host; any RCE inside the server becomes host compromise. -- **HTTP without TLS by default**: `PARSAR_COOKIE_SECURE=false` + 0.0.0.0 binding means anyone sniffing the same subnet can capture session cookies. -- **PARSAR_MASTER_KEY is the encryption root for every workspace credential** (Feishu / Slack / Discord bots): leaking it exposes every bot secret in the database in plaintext. - -For production / multi-tenant / public-internet deployments, switch to the -K8s + envd e2b-sandbox path (out of scope for this document). This -document's target is "get it running on a trusted LAN to validate -functionality". - ---- - -## Overview - -``` -clone the repo → create a Feishu app → prepare .env → build images (server + sandbox) -→ docker compose up → first user registers via web form (Owner) → create Agent -→ pair device / @Bot in Feishu groups -``` - -After deployment, the capability matrix: - -| Capability | Description | -|---|---| -| Web admin | Multiple users log in with Feishu, manage Agents / Devices / Workspaces | -| Device pairing | LAN users pair their local Claude Code / Codex with a single command | -| Cloud sandbox (Docker) | Agents auto-run in Docker containers with Claude Code + Codex built in | -| Feishu Bot | Group @Bot / DM Bot triggers Agent runs; results reply into Feishu | - ---- - -## 1. Prerequisites - -```bash -docker compose version # v2.x+ -docker info # confirm daemon is running -``` - -- Confirm the machine's **LAN IP** (referred to as `YOUR_IP` below): - ```bash - hostname -I | awk '{print $1}' - ``` -- Confirm port `18080` (or whichever port you pick) is free and open on the firewall. -- If this machine needs an HTTP proxy for internet access, note the proxy address (referred to as `YOUR_PROXY` below). -- Confirm the Docker socket GID (Linux only; macOS Docker Desktop has no host-side dockerd): - ```bash - # Linux - stat -c '%g' /var/run/docker.sock # usually 999 or the docker group - # macOS: skip this step, keep DOCKER_GID=999 default - ``` - ---- - -## 2. Feishu Open Platform configuration - -> One Feishu app plays two roles: OAuth login and Bot chat. If your team uses Lark (overseas), the process is identical — swap the domain to `open.larksuite.com`. - -### 2.1 Create the app - -1. Log in to the [Feishu Open Platform](https://open.feishu.cn) → create a **custom app**. -2. On the **Credentials & Basic Info** page, note: - - `App ID` (looks like `cli_xxxxxxxxxx`) - - `App Secret` - - `Verification Token` - - The Bot `Open ID` (looks like `ou_xxxxxxxx`; visible after enabling the Bot capability) - -### 2.2 Configure the redirect URL - -**Security Settings** → **Redirect URL**, add: -``` -http://YOUR_IP:18080/api/v1/auth/feishu/callback -``` - -### 2.3 Request permissions (scopes) - -**Permission Management** → request the following scopes and get admin -approval: - -| Scope | Purpose | -|---|---| -| `contact:user.base:readonly` | Read basic user info (login) | -| `contact:user.email:readonly` | Read user email (login) | -| `im:message` | Receive IM message events (Bot) | -| `im:message.group_at_msg:readonly` | Receive group @Bot messages (Bot) | -| `im:message.p2p_msg:readonly` | Receive DM messages (Bot) | -| `im:message:send_as_bot` | Send messages as the Bot (Bot) | -| `im:chat:readonly` | Read chat info (Bot outbound needs it) | - -### 2.4 Enable the Bot capability - -**App Capabilities → Bot** → click **Enable**. - -> Without the Bot capability enabled, the Bot cannot be added to a group and no @Bot messages reach you. This is the most commonly missed step. - -### 2.5 Publish a version - -**Version Management & Release** → create and publish a version → get admin -approval. **Scopes do not take effect until approved.** - ---- - -## 3. Clone the repo - -```bash -git clone parsar -cd parsar -``` - ---- - -## 4. Prepare the `.env` file - -Create `.env` in the project root (it is in `.gitignore` and will not be -committed): - -```bash -cp .env.example .env -``` - -Edit `.env` and fill in: - -```bash -# ---- Feishu OAuth + Bot ---- -PARSAR_FEISHU_MOCK=false -PARSAR_FEISHU_APP_ID=cli_xxxxxxxxxx # App ID from §2.1 -PARSAR_FEISHU_APP_SECRET=xxxxxxxx # App Secret from §2.1 -PARSAR_FEISHU_REDIRECT_URI=http://YOUR_IP:18080/api/v1/auth/feishu/callback -PARSAR_FEISHU_VERIFICATION_TOKEN=xxxxxxxx # Verification Token from §2.1 -PARSAR_FEISHU_DEFAULT_BOT_OPEN_ID=ou_xxxxxx # Bot Open ID from §2.1 - -# ---- Security ---- -# PARSAR_MASTER_KEY encrypts every Bot credential in the DB. There is no -# auto-generation — parsar-server fatal-exits at startup if this is empty -# in production profile. Generate it before the first `up`: -# echo "PARSAR_MASTER_KEY=$(openssl rand -hex 32)" >> .env -# Once set, DO NOT change it: any Bot credentials already stored become -# undecryptable and every Bot must be re-bound. -PARSAR_MASTER_KEY= -PARSAR_COOKIE_SECURE=false # must be false on HTTP; true when behind an HTTPS reverse proxy - -# ---- Network ---- -PARSAR_HOST_IP=YOUR_IP # your LAN IP; empty defaults to 127.0.0.1 (single machine) -PARSAR_LOCAL_PORT=18080 -PARSAR_PG_PORT=15432 - -# ---- Image ---- -PARSAR_SERVER_IMAGE=parsar:local - -# ---- Docker sandbox ---- -# Linux: stat -c '%g' /var/run/docker.sock (usually 999 or docker) -# macOS Docker Desktop: no host-side dockerd; the socket goes through a vsock proxy — leave 999 (group_add is a no-op) -DOCKER_GID=999 - -# ---- Proxy (optional; only if this machine needs a proxy to reach the internet) ---- -# HTTP_PROXY=http://your-proxy:port -# HTTPS_PROXY=http://your-proxy:port -``` - -> **PARSAR_FEISHU_DEFAULT_BOT_OPEN_ID is required.** Without it, group @Bot messages are silently skipped — the server has no way to identify which entry in the mention list is the Bot itself and drops every group message. DMs are not affected. - ---- - -## 5. Build the images - -You need two images: the **server image** (the service itself) and the -**sandbox image** (the container Agents run in). - -### 5.1 Build the server image - -```bash -# With proxy (read from .env): -source .env -sudo docker build \ - -t parsar:local \ - --build-arg http_proxy="$HTTP_PROXY" \ - --build-arg https_proxy="$HTTPS_PROXY" \ - . - -# Without proxy: -sudo docker build -t parsar:local . -``` - -Build takes ~5–10 minutes. Verify: - -```bash -sudo docker run --rm --entrypoint ls parsar:local /usr/local/share/parsar/daemon -# Expected: parsar-daemon-darwin-amd64 parsar-daemon-darwin-arm64 -# parsar-daemon-linux-amd64 parsar-daemon-linux-arm64 -``` - -### 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 + 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 \ - -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 -t parsar-sandbox:local . - -# On Apple Silicon (arm64): -sudo docker build --platform linux/arm64 -f infra/sandbox/Dockerfile -t parsar-sandbox:local . -``` - -> `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: - -```bash -sudo docker run --rm --entrypoint /bin/sh parsar-sandbox:local \ - -c "claude --version && parsar-daemon version && codex --version" -# Expected: all three version strings print cleanly -``` - ---- - -## 6. Bring up the service stack - -```bash -sudo docker compose -f docker-compose.yml up -d -``` - -**Startup order (automatic):** -1. `postgres` — PostgreSQL 16, wait for healthcheck. -2. `parsar-server` — runs `parsar-migrate` then starts serving; binds `0.0.0.0:18080`; Feishu WebSocket inbound + outbound worker start automatically. - -> **If you left `PARSAR_MASTER_KEY` blank in step 4, startup will fatal-exit.** -> `parsar-server` refuses to start in production profile without a master key — it does not generate one for you. **Generate it yourself** (`echo "PARSAR_MASTER_KEY=$(openssl rand -hex 32)" >> .env`) **before** running `up -d`. -> Migrations re-run (as a fast no-op) on every restart, so this is safe to run `up -d` again after fixing `.env`. - -**Verify:** - -```bash -# Container status -sudo docker compose -f docker-compose.yml ps - -# Health checks -curl -s http://YOUR_IP:18080/healthz # 200 -curl -s http://YOUR_IP:18080/readyz # 200 - -# Bootstrap status -curl -s http://YOUR_IP:18080/api/v1/bootstrap/status -# First run: {"needed":true,"has_owners":false,...} - -# Feishu Bot connection confirmation -sudo docker logs parsar-local-server 2>&1 | grep "feishu.*inbound.*ready" -# Expected: feishu websocket inbound client ready -``` - ---- - -## 7. First login — Register the Owner - -1. Open `http://YOUR_IP:18080` in your browser. -2. The system detects no owner exists and shows the **registration form**. -3. Fill in your name, email, password, and workspace name → submit. -4. You are now the **Workspace Owner**. - ---- - -## 8. Create an Agent and verify - -### 8.1 Create the Agent in the web UI - -1. After login, go to the admin UI → **Agents** → **New Agent**. -2. Pick connector type `agent_daemon`; daemon mode `sandbox`. -3. On save the server auto-starts a Docker sandbox container for the Agent (~10 seconds). - -### 8.2 Bind the Feishu Bot - -1. Go to the Agent detail page → **Connector** tab → **Feishu Bot binding** card. -2. Pick **"Default Bot"** → Save. - -### 8.3 Group-chat verification - -1. In Feishu, add the Bot to a group → **@Bot** with a message. -2. Parsar receives the message → triggers an Agent run → Bot replies in the group. - -### 8.4 DM verification - -1. In Feishu, search the Bot name directly → send a DM. -2. Bot replies. - -### 8.5 Device-pairing verification - -1. Web UI → **Device management** → **Pair new device** → enter a device name → **Generate connect command**. -2. Paste and run it in a terminal on your machine. After a few seconds the device status becomes **online**. - -> The machine you are pairing must have at least one Agent CLI (`claude` / `opencode` / `codex`) installed and logged in. - ---- - -## 9. Operations - -### View logs - -```bash -sudo docker logs -f parsar-local-server # server logs (includes migration output on startup) -sudo docker logs parsar-local-postgres # DB logs -``` - -### Stop / clean up - -```bash -sudo docker compose -f docker-compose.yml down # stop, keep data -sudo docker compose -f docker-compose.yml down -v # also delete data volumes -``` - -### Upgrade - -```bash -git pull -sudo docker build -t parsar:local . -sudo docker build -f infra/sandbox/Dockerfile -t parsar-sandbox:local . -sudo docker compose -f docker-compose.yml up -d --force-recreate -``` - -### Change port - -Edit `PARSAR_LOCAL_PORT` in `.env` and **also update**: -- the port in `PARSAR_FEISHU_REDIRECT_URI` -- the redirect URL configured on the Feishu Open Platform - -Then `sudo docker compose -f docker-compose.yml up -d --force-recreate`. - ---- - -## Network proxy - -If the deploy machine needs an HTTP proxy to reach the internet (Feishu API, -dependency downloads), uncomment and fill the proxy address in `.env`: - -```bash -HTTP_PROXY=http://your-proxy:port -HTTPS_PROXY=http://your-proxy:port -``` - -`docker-compose.yml` reads these variables and passes them to the -container. Leave them blank on machines that do not need a proxy. - -You must also pass the proxy args at image-build time (see the `--build-arg` -snippets in §5) because `docker build` does not read `.env`. - ---- - -## Troubleshooting - -| Symptom | Cause | Fix | -|---|---|---| -| Server fatal-exits with `secret.master_key is required in production` | `.env` has an empty `PARSAR_MASTER_KEY` | Generate one yourself (`echo "PARSAR_MASTER_KEY=$(openssl rand -hex 32)" >> .env`) → `up -d` again | -| 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` (§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) | -| Server crash-loops with `owner URL not resolvable` | `PARSAR_AGENT_DAEMON_OWNER_URL` missing | Confirm compose has `PARSAR_AGENT_DAEMON_OWNER_URL: "http://parsar-server:8080"` | -| Docker build times out on `go mod download` | Machine cannot reach the internet | Add `--build-arg http_proxy=...` `--build-arg https_proxy=...` at build time | -| Server unhealthy but actually reachable | The built-in HEALTHCHECK uses HEAD; `/healthz` only accepts GET | Confirm compose overrides the healthcheck to GET (defaulted) | - ---- - -## Architecture overview - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Deploy machine (YOUR_IP) │ -│ │ -│ ┌──────────────┐ ┌──────────────────────────────────────┐ │ -│ │PostgreSQL │ │parsar-server │ │ -│ │:5432 │ │migrate on startup, then: │ │ -│ │data vol │ │SPA + API + WS │ │ -│ └──────────────┘ │Feishu WS inbound + outbound worker │ │ -│ │Docker sandbox mgr │ │ -│ └──────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────┐ │ -│ │sandbox container (on demand) │ │ -│ │Claude Code + Codex + parsar-daemon │ │ -│ └──────────────────────────────────────┘ │ -│ │ -│ Listens on 0.0.0.0:18080 │ -└─────────────────────────────────────────────────────────────────────┘ - -LAN / Feishu clients reach parsar-server on port 18080: - - User browser ── HTTP ──────→ parsar-server (web UI) - - User device ── WebSocket ─→ parsar-server (device pairing) - - Feishu group/DM ── Feishu WS ─→ parsar-server (bot) -``` - ---- - -## TL;DR - -```bash -git clone parsar && cd parsar - -# 1. Configure .env -cp .env.example .env -vim .env # fill in Feishu credentials + master key + PARSAR_HOST_IP + Bot Open ID - -# 2. Build images -sudo docker build -t parsar: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 - -# 4. Verify -curl http://YOUR_IP:18080/healthz # 200 -curl http://YOUR_IP:18080/readyz # 200 - -# 5. Browser http://YOUR_IP:18080 → register first user (Owner) -# → create Agent (sandbox mode) → bind default Bot -# → @Bot in a Feishu group / pair a device → verified -``` diff --git a/docs/deploy/shared-feishu-app.md b/docs/deploy/shared-feishu-app.md index 4fa9d76..dbe9744 100644 --- a/docs/deploy/shared-feishu-app.md +++ b/docs/deploy/shared-feishu-app.md @@ -68,7 +68,7 @@ Pre-configured allowlist: | Scenario | `PARSAR_PUBLIC_URL` | `PARSAR_FEISHU_REDIRECT_URI` | |---|---|---| -| Local docker compose default | `http://localhost:8080` | `http://localhost:8080/api/v1/auth/feishu/callback` | +| Local compose default | `http://localhost:8080` | `http://localhost:8080/api/v1/auth/feishu/callback` | | Local, custom port | `http://localhost:` | `http://localhost:/api/v1/auth/feishu/callback` | | Tunneled / temporary public URL | `` | Same + `/api/v1/auth/feishu/callback` | diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index b123939..dd91aa0 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -641,6 +641,33 @@ definitions: updated_at: type: string type: object + dev.RuntimeProviderStatus: + properties: + action: + type: string + available: + type: boolean + configured: + type: boolean + id: + type: string + kind: + type: string + label: + type: string + message: + type: string + missing: + items: + type: string + type: array + recommended: + type: boolean + requires: + items: + type: string + type: array + type: object dev.runtimeStatusResponse: properties: available: @@ -649,11 +676,6 @@ definitions: Only meaningful after credential registration in oss/selfhost; in managed profile no workspace credential is expected. type: boolean - configured_by: - description: |- - ConfiguredBy is "ops" when PARSAR_OPENCODE_RUNNER was set at - server boot. Informational only — admin UI badge. - type: string credential_masked: description: |- CredentialMasked is the redacted form (e.g. "e2b_•••••...xyz") @@ -671,16 +693,19 @@ definitions: deployment operator provides cloud sandbox credentials and workspaces don't need to register an E2B key. type: string + providers: + description: |- + Providers is the normalized runtime connection surface. Product-wise + there are only two runtime provider families: manual_daemon and + e2b_compatible. A server-managed local daemon is still manual_daemon. + items: + $ref: '#/definitions/dev.RuntimeProviderStatus' + type: array sandbox_agent_count: description: |- SandboxAgentCount is the number of agents in this workspace whose declared runtime is 'sandbox'. type: integer - sandbox_image: - description: |- - SandboxImage is the operator-configured container image used for - docker-backed sandbox agents. Empty for non-docker providers. - type: string type: object dev.sandboxConnectivityCheck: properties: diff --git a/infra/sandbox/Dockerfile b/infra/sandbox/Dockerfile index f0aec77..694986d 100644 --- a/infra/sandbox/Dockerfile +++ b/infra/sandbox/Dockerfile @@ -1,27 +1,11 @@ -# 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. +# Parsar E2B-compatible sandbox image. # # docker build -f infra/sandbox/Dockerfile \ # --build-arg BASE_IMAGE=e2bdev/base:latest -t parsar-sandbox-e2b . -# e2b.app SaaS template. amd64 only. After build, the SandboxProvider -# only needs to: Sandbox.Create("parsar-sandbox-e2b", {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). +# After build, the provider creates the template, runs +# `parsar-daemon connect --device-name -b` with URL/token in env, +# and waits for the gateway to see that device connect. # # Contents: # - Claude Code CLI + Codex CLI + Pi CLI (installed by diff --git a/install.sh b/install.sh index b1a4a40..1cb52bb 100755 --- a/install.sh +++ b/install.sh @@ -3,7 +3,6 @@ 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" usage() { cat <<'EOF' @@ -20,11 +19,6 @@ 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 - To use your own build instead: - docker build -f infra/sandbox/Dockerfile -t parsar-sandbox:local . - --sandbox-image 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 @@ -35,8 +29,8 @@ Options: Environment variables with the same names are also honored: PARSAR_HOME, PARSAR_COMPOSE_FILE, PARSAR_SERVER_IMAGE, - PARSAR_SANDBOX_IMAGE, PARSAR_LOCAL_PORT, PARSAR_PG_PORT, - PARSAR_BIND_ADDR, PARSAR_PUBLIC_URL, PARSAR_PROJECT_NAME. + PARSAR_LOCAL_PORT, PARSAR_PG_PORT, PARSAR_BIND_ADDR, + PARSAR_PUBLIC_URL, PARSAR_PROJECT_NAME. EOF } @@ -152,7 +146,6 @@ wait_for_health() { home_arg="${PARSAR_HOME:-$HOME/.parsar}" compose_arg="${PARSAR_COMPOSE_FILE:-}" server_image="${PARSAR_SERVER_IMAGE:-$DEFAULT_SERVER_IMAGE}" -sandbox_image="${PARSAR_SANDBOX_IMAGE:-$DEFAULT_SANDBOX_IMAGE}" local_port="${PARSAR_LOCAL_PORT:-18080}" pg_port="${PARSAR_PG_PORT:-15432}" bind_addr="${PARSAR_BIND_ADDR:-127.0.0.1}" @@ -165,7 +158,6 @@ while [ "$#" -gt 0 ]; do --home) home_arg="${2:?missing value for --home}"; shift 2 ;; --compose-file) compose_arg="${2:?missing value for --compose-file}"; shift 2 ;; --image) server_image="${2:?missing value for --image}"; shift 2 ;; - --sandbox-image) sandbox_image="${2:?missing value for --sandbox-image}"; shift 2 ;; --port) local_port="${2:?missing value for --port}"; shift 2 ;; --pg-port) pg_port="${2:?missing value for --pg-port}"; shift 2 ;; --bind) bind_addr="${2:?missing value for --bind}"; shift 2 ;; @@ -197,12 +189,6 @@ fi detect_docker -docker_bin="$(command -v docker || true)" -docker_gid="999" -if [ -S /var/run/docker.sock ] && command -v stat >/dev/null 2>&1; then - docker_gid="$(stat -c '%g' /var/run/docker.sock 2>/dev/null || printf '999')" -fi - umask 077 mkdir -p "$parsar_home" "$parsar_home/postgres" "$parsar_home/data" @@ -216,7 +202,6 @@ fetch_compose set_env "PARSAR_HOME" "$parsar_home" "$env_file" set_env "PARSAR_PROJECT_NAME" "$project_name" "$env_file" set_env "PARSAR_SERVER_IMAGE" "$server_image" "$env_file" -set_env "PARSAR_SANDBOX_IMAGE" "$sandbox_image" "$env_file" set_env "PARSAR_LOCAL_PORT" "$local_port" "$env_file" set_env "PARSAR_PG_PORT" "$pg_port" "$env_file" set_env "PARSAR_BIND_ADDR" "$bind_addr" "$env_file" @@ -226,8 +211,6 @@ ensure_env "PARSAR_PG_PASSWORD" "$(random_hex 24)" "$env_file" ensure_env "PARSAR_MASTER_KEY" "$(random_hex 32)" "$env_file" set_env "PARSAR_PG_DATA_DIR" "$parsar_home/postgres" "$env_file" set_env "PARSAR_DATA_DIR" "$parsar_home/data" "$env_file" -set_env "PARSAR_DOCKER_BIN" "${docker_bin:-/usr/bin/docker}" "$env_file" -set_env "DOCKER_GID" "$docker_gid" "$env_file" log "Using $compose_file" log "Wrote $env_file" diff --git a/scripts/with-dev-env.sh b/scripts/with-dev-env.sh index 7ff8f37..a84cf6b 100755 --- a/scripts/with-dev-env.sh +++ b/scripts/with-dev-env.sh @@ -8,7 +8,5 @@ source "$ROOT_DIR/scripts/dev-env.sh" export DATABASE_URL="${DATABASE_URL:-$(parsar_dev_database_url)}" export PARSAR_MIGRATIONS_DIR="${PARSAR_MIGRATIONS_DIR:-$ROOT_DIR/server/migrations}" -export AGENT_DAEMON_SANDBOX_BACKEND="${AGENT_DAEMON_SANDBOX_BACKEND:-docker}" -export AGENT_DAEMON_SANDBOX_DOCKER_IMAGE="${AGENT_DAEMON_SANDBOX_DOCKER_IMAGE:-${PARSAR_SANDBOX_IMAGE:-ghcr.io/minimax-ai-dev/parsar-sandbox:latest}}" exec "$@" diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index f65ab0d..ce3b15c 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -411,7 +411,7 @@ func main() { }) agentDaemonRegistry := agentdaemongateway.NewRegistry() agentDaemonAuth := agentdaemongateway.NewAuthenticator(dbStore) - publicWSURL := resolveAgentDaemonPublicWSURL(envLookup, cfg) + publicWSURL := buildAgentDaemonWSURL(cfg) agentDaemonPodID := resolveAgentDaemonOwnerPodID(envLookup) agentDaemonOwnerURL, err := resolveAgentDaemonOwnerURL(envLookup, cfg) if err != nil { @@ -586,17 +586,14 @@ func main() { log.Bg().Info("streaming dispatch hook wired") } - // Runtime status banner: ConfiguredByOps marks "operator set - // PARSAR_OPENCODE_RUNNER explicitly" and is retained only for - // the legacy admin badge query; it no longer drives runtime - // selection. + // Runtime status banner: expose the two runtime provider families only: + // manual_daemon and e2b_compatible. runtimeProfile := resolveRuntimeProfile(envLookup, managedSandboxProviderWired) runtimeStatusDeps := dev.RuntimeStatusDeps{ - SettingsStore: dbStore, - SandboxProber: runtimeStatusProber, - Profile: runtimeProfile, - ConfiguredByOps: strings.TrimSpace(envLookup("PARSAR_OPENCODE_RUNNER")) != "", - SandboxImage: configuredDockerSandboxImage(envLookup), + SettingsStore: dbStore, + SandboxProber: runtimeStatusProber, + Profile: runtimeProfile, + Providers: buildRuntimeProviderStatuses(envLookup, managedSandboxProviderWired), } log.Bg().Info("runtime status profile configured", "profile", runtimeProfile, @@ -1384,6 +1381,47 @@ func (configuredSandboxProber) Ping(ctx context.Context) error { return ctx.Err() } +func buildRuntimeProviderStatuses(env func(string) string, e2bProviderWired bool) []dev.RuntimeProviderStatus { + if env == nil { + env = os.Getenv + } + providers := []dev.RuntimeProviderStatus{ + { + ID: "manual_daemon", + Label: "Manual daemon", + Kind: "manual", + Configured: true, + Available: true, + Recommended: true, + Requires: []string{"parsar-daemon"}, + Action: "pair_daemon", + Message: "Default runtime path. The server-managed local daemon and user-installed daemons both use this provider.", + }, + } + + template := strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_TEMPLATE")) + apiKey := strings.TrimSpace(env("PARSAR_E2B_API_KEY")) + missing := make([]string, 0, 2) + if template == "" { + missing = append(missing, "AGENT_DAEMON_SANDBOX_TEMPLATE") + } + if apiKey == "" { + missing = append(missing, "workspace_runtime_credential") + } + providers = append(providers, dev.RuntimeProviderStatus{ + ID: "e2b_compatible", + Label: "E2B compatible", + Kind: "managed", + Configured: template != "" && (apiKey != "" || e2bProviderWired), + Available: e2bProviderWired, + Requires: []string{"AGENT_DAEMON_SANDBOX_TEMPLATE", "workspace_runtime_credential"}, + Missing: missing, + Action: "configure_e2b", + Message: "Optional managed sandbox provider for isolated cloud execution.", + }) + return providers +} + // buildAgentDaemonSandboxProvider wires the lazy-create SandboxProvider // for the agent_daemon connector. Returns nil when sandbox mode is not // configured (caller falls back to NoopSandboxProvider). @@ -1405,12 +1443,6 @@ func buildAgentDaemonSandboxProvider( if env == nil { env = os.Getenv } - // Local-docker backend short-circuit: when AGENT_DAEMON_SANDBOX_BACKEND - // is "docker" this returns a container-backed provider and we skip the - // e2b-specific API-key/CA/pod-IP wiring below entirely. - if p := buildDockerAgentDaemonSandboxProvider(env, cfg, dbStore, registry, binder, selfPodID); p != nil { - return p - } template := strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_TEMPLATE")) if template == "" { return nil diff --git a/server/cmd/server/sandbox_docker.go b/server/cmd/server/sandbox_docker.go deleted file mode 100644 index cff5cdf..0000000 --- a/server/cmd/server/sandbox_docker.go +++ /dev/null @@ -1,250 +0,0 @@ -package main - -import ( - "net/url" - "os" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - agentdaemonbinding "github.com/MiniMax-AI-Dev/parsar/server/internal/agentdaemon/binding" - agentdaemongateway "github.com/MiniMax-AI-Dev/parsar/server/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/server/internal/config" - connagentdaemon "github.com/MiniMax-AI-Dev/parsar/server/internal/connector/agentdaemon" - dockersandbox "github.com/MiniMax-AI-Dev/parsar/server/internal/sandbox/docker" - "github.com/MiniMax-AI-Dev/parsar/server/internal/store" -) - -// dockerDialBackURL rewrites a loopback ServerURL so a sandbox container -// can reach the host-run server. A daemon inside the container cannot dial -// 127.0.0.1/localhost/::1 (that's the container itself); Docker exposes the -// host as host.docker.internal. Returns the rewritten URL and whether the -// host-gateway mapping is needed (non-loopback URLs pass through untouched). -func dockerDialBackURL(serverURL string) (string, bool) { - u, err := url.Parse(strings.TrimSpace(serverURL)) - if err != nil || u.Host == "" { - return serverURL, false - } - host := u.Hostname() - if host != "127.0.0.1" && host != "localhost" && host != "::1" { - return serverURL, false - } - newHost := "host.docker.internal" - if port := u.Port(); port != "" { - newHost += ":" + port - } - u.Host = newHost - return u.String(), true -} - -// resolveAgentDaemonPublicWSURL returns the ws:// URL the daemon dials after -// bootstrap. It starts from the scheme-swapped PublicURL -// (buildAgentDaemonWSURL) and, when the local-docker sandbox backend is active -// without a user-defined docker network, applies the same loopback → -// host.docker.internal rewrite used for the bootstrap ServerURL. Without it a -// container daemon dials 127.0.0.1 — itself — never reaching the host server, -// so the run stays unassigned. A configured network makes the server reachable -// by service name, so the loopback URL is left intact (mirrors the gating in -// buildDockerAgentDaemonSandboxProvider). -func resolveAgentDaemonPublicWSURL(env func(string) string, cfg config.Config) string { - if env == nil { - env = os.Getenv - } - if serverURL := strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_SERVER_URL")); serverURL != "" { - return agentDaemonWSURLFromBase(serverURL) - } - base := buildAgentDaemonWSURL(cfg) - if strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_BACKEND")) != "docker" { - return base - } - if strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_DOCKER_NETWORK")) != "" { - return base - } - rewritten, _ := dockerDialBackURL(base) - return rewritten -} - -func agentDaemonWSURLFromBase(base string) string { - const path = "/agent-daemon/ws" - raw := strings.TrimRight(strings.TrimSpace(base), "/") - parsed, err := url.Parse(raw) - if err != nil || parsed.Host == "" { - return "ws://" + raw + path - } - switch strings.ToLower(parsed.Scheme) { - case "https", "wss": - parsed.Scheme = "wss" - default: - parsed.Scheme = "ws" - } - parsed.Path = strings.TrimRight(parsed.Path, "/") + path - return parsed.String() -} - -// buildDockerAgentDaemonSandboxProvider wires a local-docker-backed -// SandboxProvider for the agent_daemon connector. Returns nil when the -// docker backend is not requested (caller falls back to the e2b builder, -// then NoopSandboxProvider). -// -// Env vars: -// - AGENT_DAEMON_SANDBOX_BACKEND — must equal "docker" to select this. -// - AGENT_DAEMON_SANDBOX_DOCKER_IMAGE — local image tag to run. -// - AGENT_DAEMON_SANDBOX_DOCKER_NETWORK — optional docker network to join -// (use the compose network when the server runs as a compose service). -// - AGENT_DAEMON_SANDBOX_DOCKER_MEMORY / _CPUS — optional global -// `docker run` resource caps for every sandbox size. Unset = -// size-specific defaults below. Set to 0/unlimited/none to remove the cap. -// - AGENT_DAEMON_SANDBOX_DOCKER_STANDARD_MEMORY / _STANDARD_CPUS and -// _XL_MEMORY / _XL_CPUS — optional per-size overrides. -// - AGENT_DAEMON_SANDBOX_DOCKER_PIDS_LIMIT — optional pids cap; unset = no -// cap (docker default). -func buildDockerAgentDaemonSandboxProvider( - env func(string) string, - cfg config.Config, - dbStore *store.Store, - registry *agentdaemongateway.Registry, - binder agentdaemonbinding.Binder, - selfPodID string, -) connagentdaemon.SandboxProvider { - if env == nil { - env = os.Getenv - } - if strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_BACKEND")) != "docker" { - return nil - } - image := strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_DOCKER_IMAGE")) - if image == "" { - log.Bg().Warn("agent_daemon docker sandbox disabled: AGENT_DAEMON_SANDBOX_BACKEND=docker but AGENT_DAEMON_SANDBOX_DOCKER_IMAGE is empty") - return nil - } - - publicURL := strings.TrimSpace(cfg.Server.PublicURL) - if publicURL == "" { - publicURL = "http://127.0.0.1:18080" - } - network := strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_DOCKER_NETWORK")) - serverURL, hostGateway := dockerDialBackURL(publicURL) - // When joined to a user-defined docker network the server is reachable - // by service name, so the loopback rewrite/host-gateway is unnecessary. - if network != "" { - serverURL = publicURL - hostGateway = false - } - if override := strings.TrimRight(strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_SERVER_URL")), "/"); override != "" { - serverURL = override - hostGateway = false - } - - client := dockerClientFromEnv(env, image, network, hostGateway) - provider, err := connagentdaemon.NewE2BSandboxProvider(connagentdaemon.E2BProviderConfig{ - Client: client, - Store: dbStore, - Registry: registry, - Binder: binder, - Bindings: dbStore, - Template: image, - Templates: map[string]string{"standard": image, "xl": image}, - DefaultSize: "standard", - ServerURL: serverURL, - OwnerChecker: dbStore, - SelfPodID: selfPodID, - Log: log.Bg(), - }) - if err != nil { - log.Bg().Warn("agent_daemon docker sandbox provider init failed; docker backend disabled", "error", err) - return nil - } - log.Bg().Info("agent_daemon docker sandbox provider wired", - "image", image, - "network", network, - "server_url", serverURL, - "host_gateway", hostGateway, - "memory", client.Memory, - "cpus", client.CPUs, - "pids_limit", client.PidsLimit) - return provider -} - -func configuredDockerSandboxImage(env func(string) string) string { - if env == nil { - env = os.Getenv - } - if strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_BACKEND")) != "docker" { - return "" - } - return strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_DOCKER_IMAGE")) -} - -// Built-in sandbox caps used when the operator sets no override. PidsLimit has -// no default — a low pids cap breaks parallel builds (`make -j`, `go test ./...`). -const ( - defaultDockerStandardMemory = "4g" - defaultDockerStandardCPUs = "2" - defaultDockerXLMemory = "8g" - defaultDockerXLCPUs = "4" -) - -// dockerClientFromEnv builds the docker sandbox client, resolving the -// AGENT_DAEMON_SANDBOX_DOCKER_{MEMORY,CPUS,PIDS_LIMIT} caps: memory and cpus -// fall back to the built-in default when unset, pids stays off; see -// resolveDockerLimit for the 0/unlimited escape hatch. -func dockerClientFromEnv(env func(string) string, image, network string, hostGateway bool) *dockersandbox.Client { - standardLimits, xlLimits := dockerLimitsFromEnv(env) - return &dockersandbox.Client{ - Image: image, - Network: network, - HostGateway: hostGateway, - Memory: standardLimits.Memory, - CPUs: standardLimits.CPUs, - PidsLimit: standardLimits.PidsLimit, - LimitsBySize: map[string]dockersandbox.ResourceLimits{"standard": standardLimits, "xl": xlLimits}, - } -} - -func dockerLimitsFromEnv(env func(string) string) (standard dockersandbox.ResourceLimits, xl dockersandbox.ResourceLimits) { - globalMemory := strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_DOCKER_MEMORY")) - globalCPUs := strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_DOCKER_CPUS")) - pidsLimit := resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_PIDS_LIMIT"), "") - - standardMemoryDefault := defaultDockerStandardMemory - xlMemoryDefault := defaultDockerXLMemory - if globalMemory != "" { - resolved := resolveDockerLimit(globalMemory, "") - standardMemoryDefault = resolved - xlMemoryDefault = resolved - } - standardCPUsDefault := defaultDockerStandardCPUs - xlCPUsDefault := defaultDockerXLCPUs - if globalCPUs != "" { - resolved := resolveDockerLimit(globalCPUs, "") - standardCPUsDefault = resolved - xlCPUsDefault = resolved - } - - standard = dockersandbox.ResourceLimits{ - Memory: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_STANDARD_MEMORY"), standardMemoryDefault), - CPUs: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_STANDARD_CPUS"), standardCPUsDefault), - PidsLimit: pidsLimit, - } - xl = dockersandbox.ResourceLimits{ - Memory: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_XL_MEMORY"), xlMemoryDefault), - CPUs: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_XL_CPUS"), xlCPUsDefault), - PidsLimit: pidsLimit, - } - return standard, xl -} - -// resolveDockerLimit resolves one resource cap: empty env → built-in default; -// "0"/"unlimited"/"none" (case-insensitive) → "" so Create omits the flag -// (docker's unbounded default); otherwise the literal value, passed through -// unvalidated (a bad value fails `docker run`, which Create surfaces). -func resolveDockerLimit(raw, def string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return def - } - switch strings.ToLower(raw) { - case "0", "unlimited", "none": - return "" - } - return raw -} diff --git a/server/cmd/server/sandbox_docker_test.go b/server/cmd/server/sandbox_docker_test.go deleted file mode 100644 index 4b000cc..0000000 --- a/server/cmd/server/sandbox_docker_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package main - -import ( - "testing" - - "github.com/MiniMax-AI-Dev/parsar/server/internal/config" -) - -func TestDockerDialBackURLRewritesLoopback(t *testing.T) { - cases := map[string]struct { - wantURL string - wantGateway bool - }{ - "http://127.0.0.1:18080": {"http://host.docker.internal:18080", true}, - "http://localhost:18080": {"http://host.docker.internal:18080", true}, - "http://[::1]:8080": {"http://host.docker.internal:8080", true}, - "http://parsar-server:8080": {"http://parsar-server:8080", false}, - "https://public.example.com": {"https://public.example.com", false}, - } - for in, want := range cases { - gotURL, gotGateway := dockerDialBackURL(in) - if gotURL != want.wantURL || gotGateway != want.wantGateway { - t.Fatalf("dockerDialBackURL(%q) = (%q, %v), want (%q, %v)", - in, gotURL, gotGateway, want.wantURL, want.wantGateway) - } - } -} - -func TestDockerClientFromEnvReadsResourceLimits(t *testing.T) { - env := func(k string) string { - return map[string]string{ - "AGENT_DAEMON_SANDBOX_DOCKER_MEMORY": "2g", - "AGENT_DAEMON_SANDBOX_DOCKER_CPUS": "1.5", - "AGENT_DAEMON_SANDBOX_DOCKER_PIDS_LIMIT": "512", - }[k] - } - c := dockerClientFromEnv(env, "img", "net", true) - if c.Image != "img" || c.Network != "net" || !c.HostGateway { - t.Fatalf("base fields not wired: %+v", c) - } - if c.Memory != "2g" || c.CPUs != "1.5" || c.PidsLimit != "512" { - t.Fatalf("resource limits not wired: %+v", c) - } - if c.LimitsBySize["standard"].Memory != "2g" || c.LimitsBySize["xl"].Memory != "2g" { - t.Fatalf("global memory override should apply to every size: %+v", c.LimitsBySize) - } -} - -func TestDockerClientFromEnvAppliesBuiltInDefaults(t *testing.T) { - // With the env unset the operator gets the smaller advertised standard size. - // PidsLimit stays unset: a low pids cap is a classic build-breaker - // (make -j, go test ./...). - c := dockerClientFromEnv(func(string) string { return "" }, "img", "", false) - if c.CPUs != "2" || c.Memory != "4g" { - t.Fatalf("expected default 2 CPU / 4g, got cpus=%q memory=%q", c.CPUs, c.Memory) - } - if xl := c.LimitsBySize["xl"]; xl.CPUs != "4" || xl.Memory != "8g" { - t.Fatalf("expected xl default 4 CPU / 8g, got %+v", xl) - } - if c.PidsLimit != "" { - t.Fatalf("expected pids-limit unset by default, got %q", c.PidsLimit) - } -} - -func TestDockerClientFromEnvReadsPerSizeOverrides(t *testing.T) { - env := func(k string) string { - return map[string]string{ - "AGENT_DAEMON_SANDBOX_DOCKER_STANDARD_MEMORY": "6g", - "AGENT_DAEMON_SANDBOX_DOCKER_STANDARD_CPUS": "3", - "AGENT_DAEMON_SANDBOX_DOCKER_XL_MEMORY": "64g", - "AGENT_DAEMON_SANDBOX_DOCKER_XL_CPUS": "6", - }[k] - } - c := dockerClientFromEnv(env, "img", "", false) - if got := c.LimitsBySize["standard"]; got.Memory != "6g" || got.CPUs != "3" { - t.Fatalf("standard override not wired: %+v", got) - } - if got := c.LimitsBySize["xl"]; got.Memory != "64g" || got.CPUs != "6" { - t.Fatalf("xl override not wired: %+v", got) - } -} - -func TestDockerClientFromEnvEscapeHatchDisablesDefault(t *testing.T) { - // An operator who wants docker's unbounded default back sets the env to - // 0/unlimited/none (case-insensitive, trimmed); the flag is then omitted - // rather than falling back to the built-in cap. - for _, off := range []string{"0", "unlimited", "none", "UNLIMITED", " None "} { - env := func(k string) string { - switch k { - case "AGENT_DAEMON_SANDBOX_DOCKER_MEMORY", "AGENT_DAEMON_SANDBOX_DOCKER_CPUS": - return off - } - return "" - } - c := dockerClientFromEnv(env, "img", "", false) - if c.Memory != "" || c.CPUs != "" { - t.Fatalf("escape hatch %q: expected limits omitted, got cpus=%q memory=%q", off, c.CPUs, c.Memory) - } - } -} - -func dockerBackendEnv(overrides map[string]string) func(string) string { - return func(k string) string { - if v, ok := overrides[k]; ok { - return v - } - return "" - } -} - -func TestResolveAgentDaemonPublicWSURLRewritesLoopbackForDockerBackend(t *testing.T) { - // The in-container daemon dials PublicWSURL after bootstrap. With the local - // docker backend and no user-defined network, a loopback host must be - // rewritten to host.docker.internal (mirroring the bootstrap ServerURL - // rewrite) or the container would dial itself and stay unassigned. - var cfg config.Config - cfg.Server.PublicURL = "http://127.0.0.1:18090" - env := dockerBackendEnv(map[string]string{"AGENT_DAEMON_SANDBOX_BACKEND": "docker"}) - - got := resolveAgentDaemonPublicWSURL(env, cfg) - if want := "ws://host.docker.internal:18090/agent-daemon/ws"; got != want { - t.Fatalf("resolveAgentDaemonPublicWSURL = %q, want %q", got, want) - } -} - -func TestResolveAgentDaemonPublicWSURLKeepsLoopbackWhenDockerNetworkSet(t *testing.T) { - // A user-defined docker network makes the server reachable by service name, - // so the loopback rewrite/host-gateway is unnecessary — leave PublicWSURL - // untouched (mirrors buildDockerAgentDaemonSandboxProvider). - var cfg config.Config - cfg.Server.PublicURL = "http://127.0.0.1:18090" - env := dockerBackendEnv(map[string]string{ - "AGENT_DAEMON_SANDBOX_BACKEND": "docker", - "AGENT_DAEMON_SANDBOX_DOCKER_NETWORK": "parsar_default", - }) - - got := resolveAgentDaemonPublicWSURL(env, cfg) - if want := "ws://127.0.0.1:18090/agent-daemon/ws"; got != want { - t.Fatalf("resolveAgentDaemonPublicWSURL = %q, want %q", got, want) - } -} - -func TestResolveAgentDaemonPublicWSURLUsesSandboxServerURL(t *testing.T) { - var cfg config.Config - cfg.Server.PublicURL = "http://127.0.0.1:18090" - env := dockerBackendEnv(map[string]string{ - "AGENT_DAEMON_SANDBOX_BACKEND": "docker", - "AGENT_DAEMON_SANDBOX_DOCKER_NETWORK": "parsar_default", - "AGENT_DAEMON_SANDBOX_SERVER_URL": "http://parsar-server:8080", - }) - - got := resolveAgentDaemonPublicWSURL(env, cfg) - if want := "ws://parsar-server:8080/agent-daemon/ws"; got != want { - t.Fatalf("resolveAgentDaemonPublicWSURL = %q, want %q", got, want) - } -} - -func TestResolveAgentDaemonPublicWSURLLeavesNonLoopbackUntouched(t *testing.T) { - // A real public host is reachable from inside the container as-is; only the - // scheme swap from buildAgentDaemonWSURL applies. - var cfg config.Config - cfg.Server.PublicURL = "https://parsar.example.com" - env := dockerBackendEnv(map[string]string{"AGENT_DAEMON_SANDBOX_BACKEND": "docker"}) - - got := resolveAgentDaemonPublicWSURL(env, cfg) - if want := "wss://parsar.example.com/agent-daemon/ws"; got != want { - t.Fatalf("resolveAgentDaemonPublicWSURL = %q, want %q", got, want) - } -} - -func TestResolveAgentDaemonPublicWSURLNoRewriteWhenBackendNotDocker(t *testing.T) { - // Without the docker backend the rewrite must not fire: e2b/other backends - // keep the plain scheme-swapped PublicURL from buildAgentDaemonWSURL. - var cfg config.Config - cfg.Server.PublicURL = "http://127.0.0.1:18090" - - got := resolveAgentDaemonPublicWSURL(dockerBackendEnv(nil), cfg) - if want := "ws://127.0.0.1:18090/agent-daemon/ws"; got != want { - t.Fatalf("resolveAgentDaemonPublicWSURL = %q, want %q", got, want) - } -} - -func TestConfiguredDockerSandboxImage(t *testing.T) { - env := dockerBackendEnv(map[string]string{ - "AGENT_DAEMON_SANDBOX_BACKEND": "docker", - "AGENT_DAEMON_SANDBOX_DOCKER_IMAGE": "example/sandbox:test", - }) - if got := configuredDockerSandboxImage(env); got != "example/sandbox:test" { - t.Fatalf("configuredDockerSandboxImage() = %q, want example/sandbox:test", got) - } - - env = dockerBackendEnv(map[string]string{ - "AGENT_DAEMON_SANDBOX_BACKEND": "e2b", - "AGENT_DAEMON_SANDBOX_DOCKER_IMAGE": "example/sandbox:test", - }) - if got := configuredDockerSandboxImage(env); got != "" { - t.Fatalf("configuredDockerSandboxImage() = %q for non-docker backend, want empty", got) - } -} diff --git a/server/internal/dev/runtime_status.go b/server/internal/dev/runtime_status.go index a0953b0..ae77d51 100644 --- a/server/internal/dev/runtime_status.go +++ b/server/internal/dev/runtime_status.go @@ -25,12 +25,11 @@ type SandboxLivenessProber interface { } type RuntimeStatusDeps struct { - SettingsStore RuntimeSettingsStore - SandboxProber SandboxLivenessProber - Profile string - ConfiguredByOps bool - SandboxImage string - PingTimeout time.Duration + SettingsStore RuntimeSettingsStore + SandboxProber SandboxLivenessProber + Profile string + PingTimeout time.Duration + Providers []RuntimeProviderStatus } type RuntimeSettingsStore interface { @@ -63,13 +62,23 @@ type runtimeStatusResponse struct { // workspaces don't need to register an E2B key. Profile string `json:"profile"` - // ConfiguredBy is "ops" when PARSAR_OPENCODE_RUNNER was set at - // server boot. Informational only — admin UI badge. - ConfiguredBy string `json:"configured_by,omitempty"` + // Providers is the normalized runtime connection surface. Product-wise + // there are only two runtime provider families: manual_daemon and + // e2b_compatible. A server-managed local daemon is still manual_daemon. + Providers []RuntimeProviderStatus `json:"providers"` +} - // SandboxImage is the operator-configured container image used for - // docker-backed sandbox agents. Empty for non-docker providers. - SandboxImage string `json:"sandbox_image,omitempty"` +type RuntimeProviderStatus struct { + ID string `json:"id"` + Label string `json:"label"` + Kind string `json:"kind"` + Configured bool `json:"configured"` + Available bool `json:"available"` + Recommended bool `json:"recommended,omitempty"` + Requires []string `json:"requires,omitempty"` + Missing []string `json:"missing,omitempty"` + Message string `json:"message,omitempty"` + Action string `json:"action,omitempty"` } // runtimeStatus returns the workspace runtime status. 503 when no @@ -125,14 +134,11 @@ func runtimeStatus(deps RuntimeStatusDeps) http.HandlerFunc { Available: available, SandboxAgentCount: settings.SandboxAgentCount, Profile: profile, - SandboxImage: strings.TrimSpace(deps.SandboxImage), + Providers: runtimeProvidersForResponse(deps.Providers, hasCredential, profile, available), } if masked := strings.TrimSpace(settings.RuntimeCredentialMasked); masked != "" { resp.CredentialMasked = &masked } - if deps.ConfiguredByOps { - resp.ConfiguredBy = "ops" - } writeJSON(w, http.StatusOK, resp) } } @@ -162,3 +168,45 @@ func computeSandboxReachable(ctx context.Context, prober SandboxLivenessProber, } return true } + +func runtimeProvidersForResponse(providers []RuntimeProviderStatus, hasCredential bool, profile string, sandboxAvailable bool) []RuntimeProviderStatus { + out := make([]RuntimeProviderStatus, 0, len(providers)) + for _, p := range providers { + cp := p + cp.Requires = append([]string(nil), p.Requires...) + cp.Missing = append([]string(nil), p.Missing...) + switch cp.ID { + case "manual_daemon": + cp.Configured = true + if cp.Action == "" { + cp.Action = "pair_daemon" + } + if cp.Message == "" { + cp.Message = "Install or start parsar-daemon and pair it with this workspace." + } + case "e2b_compatible": + if normalizeRuntimeProfile(profile) == "managed" { + cp.Available = sandboxAvailable + cp.Configured = cp.Configured || cp.Available + cp.Missing = nil + break + } + if hasCredential { + cp.Missing = removeString(cp.Missing, "workspace_runtime_credential") + } + cp.Available = cp.Configured && hasCredential && sandboxAvailable + } + out = append(out, cp) + } + return out +} + +func removeString(items []string, value string) []string { + out := items[:0] + for _, item := range items { + if item != value { + out = append(out, item) + } + } + return out +} diff --git a/server/internal/dev/runtime_status_test.go b/server/internal/dev/runtime_status_test.go index b83c78b..7592101 100644 --- a/server/internal/dev/runtime_status_test.go +++ b/server/internal/dev/runtime_status_test.go @@ -149,7 +149,6 @@ func TestNormalizeRuntimeProfile(t *testing.T) { // TestRuntimeStatusNoCredential — fresh workspace, no E2B credential // recorded. Response: has_credential=false, available=false (prober // is not even invoked when no credential), sandbox_agent_count=0. -// configured_by absent. func TestRuntimeStatusNoCredential(t *testing.T) { prober := &fakeSandboxProber{} r := newRuntimeStatusTestRouter(RuntimeStatusDeps{ @@ -182,18 +181,32 @@ func TestRuntimeStatusNoCredential(t *testing.T) { if got := resp["profile"]; got != "oss" { t.Errorf("profile: want oss, got %v", got) } - if _, present := resp["configured_by"]; present { - t.Errorf("configured_by should be OMITTED when ConfiguredByOps=false, got %v", resp["configured_by"]) - } if prober.callCount() != 0 { t.Errorf("prober must NOT be invoked when has_credential=false (saves a probe), got %d calls", prober.callCount()) } } -func TestRuntimeStatusIncludesSandboxImage(t *testing.T) { +func TestRuntimeStatusIncludesProviders(t *testing.T) { r := newRuntimeStatusTestRouter(RuntimeStatusDeps{ SettingsStore: fakeRuntimeSettingsStore{settings: store.WorkspaceRuntimeSettingsRead{}}, - SandboxImage: "ghcr.io/example/sandbox:test", + Providers: []RuntimeProviderStatus{ + { + ID: "manual_daemon", + Label: "Manual daemon", + Kind: "manual", + Configured: true, + Available: true, + Recommended: true, + }, + { + ID: "e2b_compatible", + Label: "E2B compatible", + Kind: "managed", + Requires: []string{"AGENT_DAEMON_SANDBOX_TEMPLATE", "workspace_runtime_credential"}, + Missing: []string{"workspace_runtime_credential"}, + Available: false, + }, + }, }) rec := httptest.NewRecorder() @@ -206,8 +219,23 @@ func TestRuntimeStatusIncludesSandboxImage(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } - if got := resp["sandbox_image"]; got != "ghcr.io/example/sandbox:test" { - t.Errorf("sandbox_image: want configured image, got %v", got) + providers, ok := resp["providers"].([]any) + if !ok || len(providers) != 2 { + t.Fatalf("providers: got %#v", resp["providers"]) + } + manual, _ := providers[0].(map[string]any) + if got := manual["id"]; got != "manual_daemon" { + t.Errorf("manual provider id: got %v", got) + } + if got := manual["available"]; got != true { + t.Errorf("manual provider available: got %v", got) + } + e2b, _ := providers[1].(map[string]any) + if got := e2b["id"]; got != "e2b_compatible" { + t.Errorf("e2b provider id: got %v", got) + } + if got := e2b["available"]; got != false { + t.Errorf("e2b provider available without credential: got %v", got) } } @@ -363,7 +391,6 @@ func TestRuntimeStatusHasCredentialNoProber(t *testing.T) { SettingsStore: fakeRuntimeSettingsStore{settings: store.WorkspaceRuntimeSettingsRead{ RuntimeCredentialSecretID: "00000000-0000-0000-0000-000000000123", }}, - ConfiguredByOps: true, }) rec := httptest.NewRecorder() @@ -379,9 +406,6 @@ func TestRuntimeStatusHasCredentialNoProber(t *testing.T) { if got := resp["available"]; got != false { t.Errorf("available: want false, got %v", got) } - if got := resp["configured_by"]; got != "ops" { - t.Errorf("configured_by: want ops, got %v", got) - } } // TestRuntimeStatusUnwired — router built without WithRuntimeStatus diff --git a/server/internal/sandbox/docker/client.go b/server/internal/sandbox/docker/client.go deleted file mode 100644 index caa414e..0000000 --- a/server/internal/sandbox/docker/client.go +++ /dev/null @@ -1,213 +0,0 @@ -package docker - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "os/exec" - "strconv" - "strings" - "time" - - "github.com/MiniMax-AI-Dev/parsar/server/internal/sandbox/e2b" -) - -// syntheticTTL is the fake expiry GetInfo reports. Local docker containers -// have no control-plane TTL; the provider only uses EndAt as optional -// status metadata, so any comfortably-future value is fine. -const syntheticTTL = 30 * 24 * time.Hour - -type execResult struct { - Stdout string - Stderr string - ExitCode int -} - -// runnerFunc is the injection seam: production wires an os/exec-backed -// runner, tests wire a fake so no real docker daemon is required. -type runnerFunc func(ctx context.Context, name string, args []string, stdin io.Reader) (execResult, error) - -type Client struct { - Image string - Network string - HostGateway bool - // Optional container resource limits, passed straight to `docker run` - // when non-empty (empty = flag omitted, docker's default). A malformed - // value makes `docker run` exit non-zero, which Create surfaces as an - // error, so no pre-validation is needed here. - Memory string // --memory, e.g. "2g" - CPUs string // --cpus, e.g. "1.5" - PidsLimit string // --pids-limit, e.g. "512" - LimitsBySize map[string]ResourceLimits - runner runnerFunc -} - -type ResourceLimits struct { - Memory string - CPUs string - PidsLimit string -} - -func (c *Client) Create(ctx context.Context, input e2b.CreateInput) (e2b.Sandbox, error) { - args := []string{"run", "-d", "--entrypoint", "sleep"} - if strings.TrimSpace(c.Network) != "" { - args = append(args, "--network", c.Network) - } - if c.HostGateway { - args = append(args, "--add-host", "host.docker.internal:host-gateway") - } - limits := c.limitsFor(input) - if m := strings.TrimSpace(limits.Memory); m != "" { - args = append(args, "--memory", m) - } - if cpus := strings.TrimSpace(limits.CPUs); cpus != "" { - args = append(args, "--cpus", cpus) - } - if pids := strings.TrimSpace(limits.PidsLimit); pids != "" { - args = append(args, "--pids-limit", pids) - } - for k, v := range input.Metadata { - args = append(args, "--label", k+"="+v) - } - for k, v := range input.Env { - args = append(args, "-e", k+"="+v) - } - args = append(args, c.Image, "infinity") - - res, err := c.runnerOrDefault()(ctx, "docker", args, nil) - if err != nil { - return e2b.Sandbox{}, err - } - // Create is a control-plane verb: unlike RunCommand, a non-zero docker - // exit means the operation failed, not a payload. Surface it (with - // stderr) instead of returning an empty-id sandbox that reads as success. - if res.ExitCode != 0 { - return e2b.Sandbox{}, fmt.Errorf("dockersandbox: docker run failed (exit %d): %s", res.ExitCode, strings.TrimSpace(res.Stderr)) - } - id := strings.TrimSpace(res.Stdout) - if id == "" { - return e2b.Sandbox{}, fmt.Errorf("dockersandbox: docker run returned no container id (stderr: %s)", strings.TrimSpace(res.Stderr)) - } - return e2b.Sandbox{SandboxID: id}, nil -} - -func (c *Client) limitsFor(input e2b.CreateInput) ResourceLimits { - size := strings.TrimSpace(input.Metadata["parsar.sandbox_size"]) - if size != "" && c.LimitsBySize != nil { - if limits, ok := c.LimitsBySize[size]; ok { - return limits - } - } - return ResourceLimits{ - Memory: c.Memory, - CPUs: c.CPUs, - PidsLimit: c.PidsLimit, - } -} - -// RunCommand execs into the container and returns the command's exit code as -// Status (Create/Kill treat non-zero as failure; here it's the payload). Note -// a docker-level launch failure — e.g. the container is gone — surfaces as -// docker exec's own 125/126/127, indistinguishable from the command exiting -// with those codes; callers see it as a failed command with docker's stderr. -func (c *Client) RunCommand(ctx context.Context, input e2b.RunCommandInput) (e2b.CommandResult, error) { - args := []string{"exec"} - if cwd := strings.TrimSpace(input.CWD); cwd != "" { - args = append(args, "-w", cwd) - } - user := strings.TrimSpace(input.User) - if user == "" { - user = "root" - } - args = append(args, "-u", user) - for k, v := range input.Env { - args = append(args, "-e", k+"="+v) - } - args = append(args, input.Sandbox.SandboxID, "/bin/bash", "-l", "-c", input.Command) - - res, err := c.runnerOrDefault()(ctx, "docker", args, nil) - if err != nil { - return e2b.CommandResult{}, err - } - return e2b.CommandResult{ - Stdout: res.Stdout, - Stderr: res.Stderr, - Status: strconv.Itoa(res.ExitCode), - Exited: true, - }, nil -} - -func (c *Client) Kill(ctx context.Context, sandboxID string) error { - sandboxID = strings.TrimSpace(sandboxID) - if sandboxID == "" { - return errors.New("dockersandbox: sandbox id is empty") - } - res, err := c.runnerOrDefault()(ctx, "docker", []string{"rm", "-f", sandboxID}, nil) - if err != nil { - return err - } - if res.ExitCode != 0 { - // Removing an already-gone container is idempotent success; any other - // non-zero exit (daemon unreachable, permission denied) must surface - // so Release/Reap don't record a leaked container as killed. - if strings.Contains(res.Stderr, "No such container") { - return nil - } - return fmt.Errorf("dockersandbox: docker rm -f %s failed (exit %d): %s", sandboxID, res.ExitCode, strings.TrimSpace(res.Stderr)) - } - return nil -} - -func (c *Client) Renew(_ context.Context, _ string, _ int) error { - return nil -} - -// GetInfo returns synthetic metadata: local containers have no control-plane, -// so State is always "running" and EndAt a fixed future TTL. It is NOT a -// liveness probe — a crashed container still reports "running". The provider -// only consumes EndAt (optional status metadata), so this is sufficient. -func (c *Client) GetInfo(_ context.Context, sandboxID string) (e2b.SandboxRuntimeInfo, error) { - now := time.Now() - return e2b.SandboxRuntimeInfo{ - SandboxID: strings.TrimSpace(sandboxID), - StartedAt: now, - EndAt: now.Add(syntheticTTL), - State: "running", - }, nil -} - -func (c *Client) runnerOrDefault() runnerFunc { - if c.runner != nil { - return c.runner - } - return osExecRun -} - -// osExecRun runs a local process. A non-zero exit is a normal result -// (ExitCode set, err nil) so RunCommand can report it as Status; only a -// launch failure or context cancellation returns a non-nil error. -func osExecRun(ctx context.Context, name string, args []string, stdin io.Reader) (execResult, error) { - cmd := exec.CommandContext(ctx, name, args...) - if stdin != nil { - cmd.Stdin = stdin - } - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err := cmd.Run() - res := execResult{Stdout: stdout.String(), Stderr: stderr.String()} - if err != nil { - if ctx.Err() != nil { - return res, ctx.Err() - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - res.ExitCode = exitErr.ExitCode() - return res, nil - } - return res, err - } - return res, nil -} diff --git a/server/internal/sandbox/docker/client_integration_test.go b/server/internal/sandbox/docker/client_integration_test.go deleted file mode 100644 index bf8624a..0000000 --- a/server/internal/sandbox/docker/client_integration_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package docker - -import ( - "context" - "os" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/server/internal/sandbox/e2b" -) - -// TestIntegrationRealDockerLifecycle drives the real os/exec runner against a -// live docker daemon. Skipped unless PARSAR_DOCKER_IT=1 (and the -// parsar-sandbox:local image exists) so `go test ./...` stays hermetic. -// -// Run: PARSAR_DOCKER_IT=1 go test ./server/internal/sandbox/docker/ -run Integration -v -func TestIntegrationRealDockerLifecycle(t *testing.T) { - if os.Getenv("PARSAR_DOCKER_IT") != "1" { - t.Skip("set PARSAR_DOCKER_IT=1 to run the real-docker integration test") - } - image := os.Getenv("PARSAR_DOCKER_IT_IMAGE") - if image == "" { - image = "parsar-sandbox:local" - } - - client := &Client{Image: image} - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - sb, err := client.Create(ctx, e2b.CreateInput{ - Env: map[string]string{"FOO": "bar"}, - Metadata: map[string]string{"parsar.test": "it"}, - }) - if err != nil { - t.Fatalf("create: %v", err) - } - if strings.TrimSpace(sb.SandboxID) == "" { - t.Fatalf("expected non-empty container id") - } - t.Logf("created container %s", sb.SandboxID) - - defer func() { - if err := client.Kill(context.Background(), sb.SandboxID); err != nil { - t.Errorf("kill: %v", err) - } - out, _ := osExecRun(context.Background(), "docker", - []string{"ps", "-a", "--filter", "id=" + sb.SandboxID, "--format", "{{.ID}}"}, nil) - if strings.TrimSpace(out.Stdout) != "" { - t.Errorf("expected container removed, still present: %q", out.Stdout) - } - }() - - res, err := client.RunCommand(ctx, e2b.RunCommandInput{ - Sandbox: sb, - Command: "echo hi-$FOO", - CWD: "/workspace", - Env: map[string]string{"FOO": "bar"}, - }) - if err != nil { - t.Fatalf("run echo: %v", err) - } - if res.Status != "0" || !res.Exited { - t.Fatalf("expected exit 0, got status=%q exited=%v stderr=%q", res.Status, res.Exited, res.Stderr) - } - if strings.TrimSpace(res.Stdout) != "hi-bar" { - t.Fatalf("expected 'hi-bar', got %q", res.Stdout) - } - - // Non-zero exit must surface as Status, not a Go error. - res, err = client.RunCommand(ctx, e2b.RunCommandInput{Sandbox: sb, Command: "exit 7"}) - if err != nil { - t.Fatalf("run exit7 returned error, want nil: %v", err) - } - if res.Status != "7" { - t.Fatalf("expected status 7, got %q", res.Status) - } - - res, err = client.RunCommand(ctx, e2b.RunCommandInput{Sandbox: sb, Command: "parsar-daemon version"}) - if err != nil { - t.Fatalf("run daemon version: %v", err) - } - if res.Status != "0" { - t.Fatalf("expected daemon version exit 0, got %q stderr=%q", res.Status, res.Stderr) - } - t.Logf("parsar-daemon version -> %q", strings.TrimSpace(res.Stdout)) -} diff --git a/server/internal/sandbox/docker/client_test.go b/server/internal/sandbox/docker/client_test.go deleted file mode 100644 index 7fb1c23..0000000 --- a/server/internal/sandbox/docker/client_test.go +++ /dev/null @@ -1,394 +0,0 @@ -package docker - -import ( - "context" - "io" - "slices" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/server/internal/sandbox/e2b" -) - -type recordedCall struct { - Name string - Args []string - Stdin string -} - -// fakeRunner records calls and returns canned output so unit tests never -// touch a real docker daemon. -type fakeRunner struct { - calls []recordedCall - handler func(call recordedCall) (execResult, error) -} - -func (f *fakeRunner) run(_ context.Context, name string, args []string, stdin io.Reader) (execResult, error) { - var stdinStr string - if stdin != nil { - b, _ := io.ReadAll(stdin) - stdinStr = string(b) - } - call := recordedCall{Name: name, Args: args, Stdin: stdinStr} - f.calls = append(f.calls, call) - if f.handler != nil { - return f.handler(call) - } - return execResult{}, nil -} - -func containsArg(args []string, want string) bool { - return slices.Contains(args, want) -} - -func TestCreateRunsDockerRunAndReturnsContainerID(t *testing.T) { - fake := &fakeRunner{ - handler: func(recordedCall) (execResult, error) { - // docker run -d prints the full container ID on stdout. - return execResult{Stdout: "abc123def456\n"}, nil - }, - } - client := &Client{Image: "parsar-sandbox:local", runner: fake.run} - - sb, err := client.Create(context.Background(), e2b.CreateInput{}) - if err != nil { - t.Fatalf("create: %v", err) - } - if sb.SandboxID != "abc123def456" { - t.Fatalf("expected container id as sandbox id, got %q", sb.SandboxID) - } - if len(fake.calls) != 1 { - t.Fatalf("expected exactly 1 docker call, got %d", len(fake.calls)) - } - call := fake.calls[0] - if call.Name != "docker" { - t.Fatalf("expected docker binary, got %q", call.Name) - } - if !containsArg(call.Args, "run") || !containsArg(call.Args, "-d") { - t.Fatalf("expected `docker run -d`, got args %v", call.Args) - } - if !containsArg(call.Args, "parsar-sandbox:local") { - t.Fatalf("expected image in args, got %v", call.Args) - } -} - -func TestCreateKeepsContainerAliveViaEntrypointOverride(t *testing.T) { - fake := &fakeRunner{handler: func(recordedCall) (execResult, error) { - return execResult{Stdout: "cid\n"}, nil - }} - client := &Client{Image: "img", runner: fake.run} - - if _, err := client.Create(context.Background(), e2b.CreateInput{}); err != nil { - t.Fatalf("create: %v", err) - } - call := fake.calls[0] - // The container must outlive `docker run` so later `docker exec` works; - // the e2b base image's default entrypoint is envd, which we bypass - // locally in favour of exec, so we override it with a keepalive. - if !containsArg(call.Args, "--entrypoint") { - t.Fatalf("expected --entrypoint override, got %v", call.Args) - } - if !strings.Contains(strings.Join(call.Args, " "), "sleep") { - t.Fatalf("expected keepalive command, got %v", call.Args) - } -} - -func TestCreateAppliesNetworkHostGatewayEnvAndLabels(t *testing.T) { - var got recordedCall - fake := &fakeRunner{handler: func(call recordedCall) (execResult, error) { - got = call - return execResult{Stdout: "cid\n"}, nil - }} - client := &Client{ - Image: "img", - Network: "parsar_default", - HostGateway: true, - runner: fake.run, - } - _, err := client.Create(context.Background(), e2b.CreateInput{ - Env: map[string]string{"FOO": "bar"}, - Metadata: map[string]string{"workspace": "ws1"}, - }) - if err != nil { - t.Fatalf("create: %v", err) - } - joined := strings.Join(got.Args, " ") - if !strings.Contains(joined, "--network parsar_default") { - t.Fatalf("expected --network, got %v", got.Args) - } - if !strings.Contains(joined, "--add-host host.docker.internal:host-gateway") { - t.Fatalf("expected host-gateway mapping, got %v", got.Args) - } - if !strings.Contains(joined, "-e FOO=bar") { - t.Fatalf("expected env passthrough, got %v", got.Args) - } - if !strings.Contains(joined, "--label workspace=ws1") { - t.Fatalf("expected metadata label, got %v", got.Args) - } -} - -func TestCreateAppliesSizeSpecificResourceLimits(t *testing.T) { - var got recordedCall - fake := &fakeRunner{handler: func(call recordedCall) (execResult, error) { - got = call - return execResult{Stdout: "cid\n"}, nil - }} - client := &Client{ - Image: "img", - Memory: "4g", - CPUs: "2", - runner: fake.run, - LimitsBySize: map[string]ResourceLimits{ - "xl": {Memory: "8g", CPUs: "4"}, - }, - } - _, err := client.Create(context.Background(), e2b.CreateInput{ - Metadata: map[string]string{"parsar.sandbox_size": "xl"}, - }) - if err != nil { - t.Fatalf("create: %v", err) - } - joined := strings.Join(got.Args, " ") - if !strings.Contains(joined, "--memory 8g") || !strings.Contains(joined, "--cpus 4") { - t.Fatalf("expected xl resource limits, got %v", got.Args) - } -} - -func TestKillRemovesContainer(t *testing.T) { - fake := &fakeRunner{} - client := &Client{Image: "img", runner: fake.run} - - if err := client.Kill(context.Background(), "cid42"); err != nil { - t.Fatalf("kill: %v", err) - } - call := fake.calls[0] - if !containsArg(call.Args, "rm") || !containsArg(call.Args, "-f") || !containsArg(call.Args, "cid42") { - t.Fatalf("expected `docker rm -f cid42`, got %v", call.Args) - } -} - -func TestKillEmptyIDIsError(t *testing.T) { - fake := &fakeRunner{} - client := &Client{Image: "img", runner: fake.run} - if err := client.Kill(context.Background(), " "); err == nil { - t.Fatalf("expected error for empty sandbox id") - } - if len(fake.calls) != 0 { - t.Fatalf("expected no docker call for empty id, got %v", fake.calls) - } -} - -func TestRenewIsNoOp(t *testing.T) { - fake := &fakeRunner{} - client := &Client{Image: "img", runner: fake.run} - if err := client.Renew(context.Background(), "cid", 60); err != nil { - t.Fatalf("renew: %v", err) - } - // Local containers have no control-plane TTL, so Renew must not shell - // out — it exists only to satisfy the E2BClient interface. - if len(fake.calls) != 0 { - t.Fatalf("expected renew to be a no-op, got calls %v", fake.calls) - } -} - -func TestGetInfoReturnsSyntheticFutureExpiry(t *testing.T) { - fake := &fakeRunner{} - client := &Client{Image: "img", runner: fake.run} - before := time.Now() - info, err := client.GetInfo(context.Background(), "cid99") - if err != nil { - t.Fatalf("getinfo: %v", err) - } - if info.SandboxID != "cid99" { - t.Fatalf("expected sandbox id echoed, got %q", info.SandboxID) - } - if !info.EndAt.After(before) { - t.Fatalf("expected future EndAt, got %v", info.EndAt) - } - if info.State != "running" { - t.Fatalf("expected running state, got %q", info.State) - } -} - -func TestOSExecRunCapturesStdoutAndZeroExit(t *testing.T) { - res, err := osExecRun(context.Background(), "sh", []string{"-c", "printf hello"}, nil) - if err != nil { - t.Fatalf("run: %v", err) - } - if res.Stdout != "hello" { - t.Fatalf("stdout=%q", res.Stdout) - } - if res.ExitCode != 0 { - t.Fatalf("exit=%d, want 0", res.ExitCode) - } -} - -func TestOSExecRunCapturesNonZeroExitWithoutError(t *testing.T) { - // A command exiting non-zero is a normal result, not a runner failure: - // RunCommand must surface it as CommandResult.Status, so err stays nil. - res, err := osExecRun(context.Background(), "sh", []string{"-c", "printf oops >&2; exit 7"}, nil) - if err != nil { - t.Fatalf("expected nil err for clean non-zero exit, got %v", err) - } - if res.ExitCode != 7 { - t.Fatalf("exit=%d, want 7", res.ExitCode) - } - if res.Stderr != "oops" { - t.Fatalf("stderr=%q", res.Stderr) - } -} - -func TestNilRunnerFallsBackToOSExec(t *testing.T) { - if (&Client{}).runnerOrDefault() == nil { - t.Fatalf("expected non-nil default runner when none injected") - } -} - -func TestRunCommandExecsBashWrapperAndReturnsExitStatus(t *testing.T) { - fake := &fakeRunner{ - handler: func(recordedCall) (execResult, error) { - return execResult{Stdout: "hi\n", ExitCode: 0}, nil - }, - } - client := &Client{Image: "img", runner: fake.run} - - res, err := client.RunCommand(context.Background(), e2b.RunCommandInput{ - Sandbox: e2b.Sandbox{SandboxID: "cid789"}, - Command: "echo hi", - }) - if err != nil { - t.Fatalf("run command: %v", err) - } - if res.Stdout != "hi\n" { - t.Fatalf("expected stdout hi, got %q", res.Stdout) - } - if !res.Exited || res.Status != "0" { - t.Fatalf("expected exited=true status=0, got exited=%v status=%q", res.Exited, res.Status) - } - if len(fake.calls) != 1 { - t.Fatalf("expected 1 call, got %d", len(fake.calls)) - } - call := fake.calls[0] - if !containsArg(call.Args, "exec") { - t.Fatalf("expected `docker exec`, got %v", call.Args) - } - if !containsArg(call.Args, "cid789") { - t.Fatalf("expected container id in args, got %v", call.Args) - } - // The command must run through a login bash so PATH + profile match e2b's - // `/bin/bash -l -c` contract that seed/connect scripts rely on. - joined := strings.Join(call.Args, " ") - if !strings.Contains(joined, "/bin/bash -l -c") { - t.Fatalf("expected bash login wrapper, got %v", call.Args) - } - if call.Args[len(call.Args)-1] != "echo hi" { - t.Fatalf("expected raw command as final arg, got %q", call.Args[len(call.Args)-1]) - } -} - -func TestCreateReturnsErrorOnNonZeroExit(t *testing.T) { - // `docker run` failing (image missing, daemon down, bad --network) exits - // non-zero with an empty stdout. Create is a control-plane verb, so this - // must be a Go error — not a Sandbox{SandboxID:""} reported as success — - // and the error must carry docker's stderr for diagnosis. - fake := &fakeRunner{handler: func(recordedCall) (execResult, error) { - return execResult{ExitCode: 125, Stderr: "Unable to find image 'missing:tag' locally\nno such image"}, nil - }} - client := &Client{Image: "missing:tag", runner: fake.run} - - sb, err := client.Create(context.Background(), e2b.CreateInput{}) - if err == nil { - t.Fatalf("expected error on non-zero docker run exit, got sandbox %+v", sb) - } - if !strings.Contains(err.Error(), "no such image") { - t.Fatalf("expected docker stderr in error, got %q", err.Error()) - } -} - -func TestCreateReturnsErrorOnEmptyContainerID(t *testing.T) { - // A zero exit but blank stdout still yields no usable container id; the - // provider must not proceed with an empty SandboxID. - fake := &fakeRunner{handler: func(recordedCall) (execResult, error) { - return execResult{ExitCode: 0, Stdout: " \n"}, nil - }} - client := &Client{Image: "img", runner: fake.run} - - if sb, err := client.Create(context.Background(), e2b.CreateInput{}); err == nil { - t.Fatalf("expected error on empty container id, got sandbox %+v", sb) - } -} - -func TestKillReturnsErrorOnNonZeroExit(t *testing.T) { - // A real `docker rm -f` failure (e.g. daemon unreachable) must surface so - // Release/Reap don't record a leaked container as successfully killed. - fake := &fakeRunner{handler: func(recordedCall) (execResult, error) { - return execResult{ExitCode: 1, Stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock"}, nil - }} - client := &Client{Image: "img", runner: fake.run} - - err := client.Kill(context.Background(), "cid42") - if err == nil { - t.Fatalf("expected error on non-zero docker rm exit") - } - if !strings.Contains(err.Error(), "Cannot connect to the Docker daemon") { - t.Fatalf("expected docker stderr in error, got %q", err.Error()) - } -} - -func TestKillToleratesMissingContainer(t *testing.T) { - // Kill must stay idempotent: removing an already-gone container is a - // success, not an error, so retries/Reap don't wedge on it. - fake := &fakeRunner{handler: func(recordedCall) (execResult, error) { - return execResult{ExitCode: 1, Stderr: "Error: No such container: cid42"}, nil - }} - client := &Client{Image: "img", runner: fake.run} - - if err := client.Kill(context.Background(), "cid42"); err != nil { - t.Fatalf("expected nil error when container already gone, got %v", err) - } -} - -func TestCreateAppliesResourceLimitsWhenSet(t *testing.T) { - var got recordedCall - fake := &fakeRunner{handler: func(call recordedCall) (execResult, error) { - got = call - return execResult{Stdout: "cid\n"}, nil - }} - client := &Client{ - Image: "img", - Memory: "2g", - CPUs: "1.5", - PidsLimit: "512", - runner: fake.run, - } - if _, err := client.Create(context.Background(), e2b.CreateInput{}); err != nil { - t.Fatalf("create: %v", err) - } - joined := strings.Join(got.Args, " ") - for _, want := range []string{"--memory 2g", "--cpus 1.5", "--pids-limit 512"} { - if !strings.Contains(joined, want) { - t.Fatalf("expected %q in args, got %v", want, got.Args) - } - } -} - -func TestCreateOmitsResourceLimitsWhenUnset(t *testing.T) { - // Defaults must be byte-for-byte unchanged: no limit flags unless the - // operator opts in via env. - var got recordedCall - fake := &fakeRunner{handler: func(call recordedCall) (execResult, error) { - got = call - return execResult{Stdout: "cid\n"}, nil - }} - client := &Client{Image: "img", runner: fake.run} - if _, err := client.Create(context.Background(), e2b.CreateInput{}); err != nil { - t.Fatalf("create: %v", err) - } - joined := strings.Join(got.Args, " ") - for _, unwanted := range []string{"--memory", "--cpus", "--pids-limit"} { - if strings.Contains(joined, unwanted) { - t.Fatalf("expected no %q flag when unset, got %v", unwanted, got.Args) - } - } -}