From 58c54156f1657869e577e1039a0082196504e2c0 Mon Sep 17 00:00:00 2001 From: Nicholas Kissel Date: Fri, 13 Mar 2026 12:30:03 -0700 Subject: [PATCH 1/2] Remove Download Foundry section from website (#248) Co-authored-by: Claude Opus 4.6 --- .../src/components/DownloadFoundry.tsx | 130 ------------------ .../packages/website/src/pages/index.astro | 4 +- 2 files changed, 2 insertions(+), 132 deletions(-) delete mode 100644 frontend/packages/website/src/components/DownloadFoundry.tsx diff --git a/frontend/packages/website/src/components/DownloadFoundry.tsx b/frontend/packages/website/src/components/DownloadFoundry.tsx deleted file mode 100644 index e9e9a14..0000000 --- a/frontend/packages/website/src/components/DownloadFoundry.tsx +++ /dev/null @@ -1,130 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { motion } from "framer-motion"; -import { Download, Monitor, ChevronDown } from "lucide-react"; - -const DOWNLOAD_BASE = "https://releases.rivet.dev/foundry/0.1.0"; - -type Platform = { - label: string; - arch: string; - filename: string; -}; - -const PLATFORMS: Platform[] = [ - { - label: "macOS (Apple Silicon)", - arch: "arm64", - filename: "Foundry_0.1.0_aarch64.dmg", - }, - { - label: "macOS (Intel)", - arch: "x64", - filename: "Foundry_0.1.0_x64.dmg", - }, -]; - -function detectPlatform(): Platform | null { - if (typeof navigator === "undefined") return null; - - const ua = navigator.userAgent.toLowerCase(); - if (!ua.includes("mac")) return null; - - // Apple Silicon detection: check for arm in platform or userAgentData - const isArm = navigator.platform === "MacIntel" && (navigator as any).userAgentData?.architecture === "arm"; - // Fallback: newer Safari/Chrome on Apple Silicon - const couldBeArm = navigator.platform === "MacIntel" && !ua.includes("intel"); - - if (isArm || couldBeArm) { - return PLATFORMS[0]; // Apple Silicon - } - return PLATFORMS[1]; // Intel -} - -export function DownloadFoundry() { - const [detected, setDetected] = useState(null); - const [showDropdown, setShowDropdown] = useState(false); - - useEffect(() => { - setDetected(detectPlatform()); - }, []); - - const primary = detected ?? PLATFORMS[0]; - const secondary = PLATFORMS.filter((p) => p !== primary); - - return ( -
-
-
- - Download Foundry - - - Run Foundry as a native desktop app. Manage workspaces, handoffs, and coding agents locally. - -
- - - {/* Primary download button */} - - - Download for {primary.label} - - - {/* Other platforms */} -
- - - {showDropdown && ( -
- {secondary.map((p) => ( - - {p.label} - - ))} -
- )} -
- - {/* Unsigned app note */} -

- macOS only. On first launch, right-click the app and select "Open" to bypass Gatekeeper. -

-
-
-
- ); -} diff --git a/frontend/packages/website/src/pages/index.astro b/frontend/packages/website/src/pages/index.astro index f6d8366..476485c 100644 --- a/frontend/packages/website/src/pages/index.astro +++ b/frontend/packages/website/src/pages/index.astro @@ -5,7 +5,7 @@ import { Hero } from "../components/Hero"; import { PainPoints } from "../components/PainPoints"; import { FeatureGrid } from "../components/FeatureGrid"; import { GetStarted } from "../components/GetStarted"; -import { DownloadFoundry } from "../components/DownloadFoundry"; + import { Inspector } from "../components/Inspector"; import { FAQ } from "../components/FAQ"; import { Footer } from "../components/Footer"; @@ -18,7 +18,7 @@ import { Footer } from "../components/Footer"; - + From 110e969f989e298457792d23f0908db3f3e9c10f Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 13 Mar 2026 18:31:55 -0700 Subject: [PATCH 2/2] Add full Docker image defaults, fix actor deadlocks, and improve dev experience - Add Dockerfile.full and --all flag to install-agent CLI for pre-built images - Centralize Docker image constant (FULL_IMAGE) pinned to 0.3.1-full - Remove examples/shared/Dockerfile{,.dev} and daytona snapshot example - Expand Docker docs with full runnable Dockerfile - Fix self-deadlock in createWorkbenchSession (fire-and-forget provisioning) - Audit and convert 12 task actions from wait:true to wait:false - Add bun --hot for dev backend hot reload - Remove --force from pnpm install in dev Dockerfile for faster startup - Add env_file support to compose.dev.yaml for automatic credential loading - Add mock frontend compose config and dev panel - Update CLAUDE.md with wait:true policy and dev environment setup Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release.yaml | 18 +- CLAUDE.md | 4 +- README.md | 5 +- docker/runtime/Dockerfile | 2 +- docker/runtime/Dockerfile.full | 162 ++++++++++ docs/cli.mdx | 12 +- docs/deploy/docker.mdx | 43 ++- docs/quickstart.mdx | 11 +- examples/daytona/package.json | 1 - examples/daytona/src/daytona-with-snapshot.ts | 39 --- examples/docker/src/index.ts | 19 +- examples/persist-memory/src/index.ts | 1 - examples/persist-postgres/src/index.ts | 1 - examples/persist-sqlite/src/index.ts | 1 - examples/shared/Dockerfile | 5 - examples/shared/Dockerfile.dev | 63 ---- examples/shared/src/docker.ts | 65 ++-- foundry/CLAUDE.md | 31 +- foundry/compose.dev.yaml | 3 + foundry/compose.mock.yaml | 32 ++ foundry/docker/backend.dev.Dockerfile | 2 +- .../packages/backend/src/actors/task/index.ts | 53 +-- .../backend/src/actors/task/workbench.ts | 7 +- foundry/packages/frontend/index.html | 3 - .../frontend/src/components/dev-panel.tsx | 301 ++++++++++++++++++ .../frontend/src/components/mock-layout.tsx | 3 + justfile | 16 +- scripts/release/docker.ts | 44 ++- server/packages/sandbox-agent/src/cli.rs | 140 ++++++-- 29 files changed, 804 insertions(+), 283 deletions(-) create mode 100644 docker/runtime/Dockerfile.full delete mode 100644 examples/daytona/src/daytona-with-snapshot.ts delete mode 100644 examples/shared/Dockerfile delete mode 100644 examples/shared/Dockerfile.dev create mode 100644 foundry/compose.mock.yaml create mode 100644 foundry/packages/frontend/src/components/dev-panel.tsx diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 102f612..34fb64a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -180,10 +180,20 @@ jobs: include: - platform: linux/arm64 runner: depot-ubuntu-24.04-arm-8 - arch_suffix: -arm64 + tag_suffix: -arm64 + dockerfile: docker/runtime/Dockerfile - platform: linux/amd64 runner: depot-ubuntu-24.04-8 - arch_suffix: -amd64 + tag_suffix: -amd64 + dockerfile: docker/runtime/Dockerfile + - platform: linux/arm64 + runner: depot-ubuntu-24.04-arm-8 + tag_suffix: -full-arm64 + dockerfile: docker/runtime/Dockerfile.full + - platform: linux/amd64 + runner: depot-ubuntu-24.04-8 + tag_suffix: -full-amd64 + dockerfile: docker/runtime/Dockerfile.full runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 @@ -205,8 +215,8 @@ jobs: with: context: . push: true - tags: rivetdev/sandbox-agent:${{ steps.vars.outputs.sha_short }}${{ matrix.arch_suffix }} - file: docker/runtime/Dockerfile + tags: rivetdev/sandbox-agent:${{ steps.vars.outputs.sha_short }}${{ matrix.tag_suffix }} + file: ${{ matrix.dockerfile }} platforms: ${{ matrix.platform }} build-args: | TARGETARCH=${{ contains(matrix.platform, 'arm64') && 'arm64' || 'amd64' }} diff --git a/CLAUDE.md b/CLAUDE.md index cbc0c18..26dfa28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ ## Docker Examples (Dev Testing) - When manually testing bleeding-edge (unreleased) versions of sandbox-agent in `examples/`, use `SANDBOX_AGENT_DEV=1` with the Docker-based examples. -- This triggers `examples/shared/Dockerfile.dev` which builds the server binary from local source and packages it into the Docker image. +- This triggers a local build of `docker/runtime/Dockerfile.full` which builds the server binary from local source and packages it into the Docker image. - Example: `SANDBOX_AGENT_DEV=1 pnpm --filter @sandbox-agent/example-mcp start` ## Install Version References @@ -152,7 +152,7 @@ - `.claude/commands/post-release-testing.md` - `examples/cloudflare/Dockerfile` - `examples/daytona/src/index.ts` - - `examples/daytona/src/daytona-with-snapshot.ts` + - `examples/shared/src/docker.ts` - `examples/docker/src/index.ts` - `examples/e2b/src/index.ts` - `examples/vercel/src/index.ts` diff --git a/README.md b/README.md index b84df8d..d4bfc61 100644 --- a/README.md +++ b/README.md @@ -143,10 +143,7 @@ sandbox-agent server --token "$SANDBOX_TOKEN" --host 127.0.0.1 --port 2468 Optional: preinstall agent binaries (no server required; they will be installed lazily on first use if you skip this): ```bash -sandbox-agent install-agent claude -sandbox-agent install-agent codex -sandbox-agent install-agent opencode -sandbox-agent install-agent amp +sandbox-agent install-agent --all ``` To disable auth locally: diff --git a/docker/runtime/Dockerfile b/docker/runtime/Dockerfile index 27b9560..bdd1a16 100644 --- a/docker/runtime/Dockerfile +++ b/docker/runtime/Dockerfile @@ -167,4 +167,4 @@ WORKDIR /home/sandbox EXPOSE 2468 ENTRYPOINT ["sandbox-agent"] -CMD ["--host", "0.0.0.0", "--port", "2468"] +CMD ["server", "--host", "0.0.0.0", "--port", "2468"] diff --git a/docker/runtime/Dockerfile.full b/docker/runtime/Dockerfile.full new file mode 100644 index 0000000..beb1664 --- /dev/null +++ b/docker/runtime/Dockerfile.full @@ -0,0 +1,162 @@ +# syntax=docker/dockerfile:1.10.0 + +# ============================================================================ +# Build inspector frontend +# ============================================================================ +FROM node:22-alpine AS inspector-build +WORKDIR /app +RUN npm install -g pnpm + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ +COPY sdks/cli-shared/package.json ./sdks/cli-shared/ +COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ +COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ +COPY sdks/react/package.json ./sdks/react/ +COPY sdks/typescript/package.json ./sdks/typescript/ + +RUN pnpm install --filter @sandbox-agent/inspector... + +COPY docs/openapi.json ./docs/ +COPY sdks/cli-shared ./sdks/cli-shared +COPY sdks/acp-http-client ./sdks/acp-http-client +COPY sdks/persist-indexeddb ./sdks/persist-indexeddb +COPY sdks/react ./sdks/react +COPY sdks/typescript ./sdks/typescript + +RUN cd sdks/cli-shared && pnpm exec tsup +RUN cd sdks/acp-http-client && pnpm exec tsup +RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup +RUN cd sdks/persist-indexeddb && pnpm exec tsup +RUN cd sdks/react && pnpm exec tsup + +COPY frontend/packages/inspector ./frontend/packages/inspector +RUN cd frontend/packages/inspector && pnpm exec vite build + +# ============================================================================ +# AMD64 Builder - Uses cross-tools musl toolchain +# ============================================================================ +FROM --platform=linux/amd64 rust:1.88.0 AS builder-amd64 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + musl-tools \ + musl-dev \ + llvm-14-dev \ + libclang-14-dev \ + clang-14 \ + libssl-dev \ + pkg-config \ + ca-certificates \ + g++ \ + g++-multilib \ + git \ + curl \ + wget && \ + rm -rf /var/lib/apt/lists/* + +RUN wget -q https://github.com/cross-tools/musl-cross/releases/latest/download/x86_64-unknown-linux-musl.tar.xz && \ + tar -xf x86_64-unknown-linux-musl.tar.xz -C /opt/ && \ + rm x86_64-unknown-linux-musl.tar.xz && \ + rustup target add x86_64-unknown-linux-musl + +ENV PATH="/opt/x86_64-unknown-linux-musl/bin:$PATH" \ + LIBCLANG_PATH=/usr/lib/llvm-14/lib \ + CLANG_PATH=/usr/bin/clang-14 \ + CC_x86_64_unknown_linux_musl=x86_64-unknown-linux-musl-gcc \ + CXX_x86_64_unknown_linux_musl=x86_64-unknown-linux-musl-g++ \ + AR_x86_64_unknown_linux_musl=x86_64-unknown-linux-musl-ar \ + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-unknown-linux-musl-gcc \ + CARGO_INCREMENTAL=0 \ + CARGO_NET_GIT_FETCH_WITH_CLI=true + +ENV SSL_VER=1.1.1w +RUN wget https://www.openssl.org/source/openssl-$SSL_VER.tar.gz && \ + tar -xzf openssl-$SSL_VER.tar.gz && \ + cd openssl-$SSL_VER && \ + ./Configure no-shared no-async --prefix=/musl --openssldir=/musl/ssl linux-x86_64 && \ + make -j$(nproc) && \ + make install_sw && \ + cd .. && \ + rm -rf openssl-$SSL_VER* + +ENV OPENSSL_DIR=/musl \ + OPENSSL_INCLUDE_DIR=/musl/include \ + OPENSSL_LIB_DIR=/musl/lib \ + PKG_CONFIG_ALLOW_CROSS=1 \ + RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-static-libgcc" + +WORKDIR /build +COPY . . + +COPY --from=inspector-build /app/frontend/packages/inspector/dist ./frontend/packages/inspector/dist + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/build/target \ + cargo build -p sandbox-agent --release --target x86_64-unknown-linux-musl && \ + cp target/x86_64-unknown-linux-musl/release/sandbox-agent /sandbox-agent + +# ============================================================================ +# ARM64 Builder - Uses Alpine with native musl +# ============================================================================ +FROM --platform=linux/arm64 rust:1.88-alpine AS builder-arm64 + +RUN apk add --no-cache \ + musl-dev \ + clang \ + llvm-dev \ + openssl-dev \ + openssl-libs-static \ + pkgconfig \ + git \ + curl \ + build-base + +RUN rustup target add aarch64-unknown-linux-musl + +ENV CARGO_INCREMENTAL=0 \ + CARGO_NET_GIT_FETCH_WITH_CLI=true \ + RUSTFLAGS="-C target-feature=+crt-static" + +WORKDIR /build +COPY . . + +COPY --from=inspector-build /app/frontend/packages/inspector/dist ./frontend/packages/inspector/dist + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/build/target \ + cargo build -p sandbox-agent --release --target aarch64-unknown-linux-musl && \ + cp target/aarch64-unknown-linux-musl/release/sandbox-agent /sandbox-agent + +# ============================================================================ +# Select the appropriate builder based on target architecture +# ============================================================================ +ARG TARGETARCH +FROM builder-${TARGETARCH} AS builder + +# Runtime stage - full image with all supported agents preinstalled +FROM node:22-bookworm-slim + +RUN apt-get update && apt-get install -y \ + bash \ + ca-certificates \ + curl \ + git && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent +RUN chmod +x /usr/local/bin/sandbox-agent + +RUN useradd -m -s /bin/bash sandbox +USER sandbox +WORKDIR /home/sandbox + +RUN sandbox-agent install-agent --all + +EXPOSE 2468 + +ENTRYPOINT ["sandbox-agent"] +CMD ["server", "--host", "0.0.0.0", "--port", "2468"] diff --git a/docs/cli.mdx b/docs/cli.mdx index 22b041d..a3cd839 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -39,20 +39,24 @@ Notes: ## install-agent -Install or reinstall a single agent. +Install or reinstall a single agent, or every supported agent with `--all`. ```bash -sandbox-agent install-agent [OPTIONS] +sandbox-agent install-agent [] [OPTIONS] ``` | Option | Description | |--------|-------------| +| `--all` | Install every supported agent | | `-r, --reinstall` | Force reinstall | -| `--agent-version ` | Override agent package version | -| `--agent-process-version ` | Override agent process version | +| `--agent-version ` | Override agent package version (conflicts with `--all`) | +| `--agent-process-version ` | Override agent process version (conflicts with `--all`) | + +Examples: ```bash sandbox-agent install-agent claude --reinstall +sandbox-agent install-agent --all ``` ## opencode (experimental) diff --git a/docs/deploy/docker.mdx b/docs/deploy/docker.mdx index 988382a..030ddc9 100644 --- a/docs/deploy/docker.mdx +++ b/docs/deploy/docker.mdx @@ -9,18 +9,18 @@ Docker is not recommended for production isolation of untrusted workloads. Use d ## Quick start -Run Sandbox Agent with agents pre-installed: +Run the published full image with all supported agents pre-installed: ```bash docker run --rm -p 3000:3000 \ -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ -e OPENAI_API_KEY="$OPENAI_API_KEY" \ - alpine:latest sh -c "\ - apk add --no-cache curl ca-certificates libstdc++ libgcc bash nodejs npm && \ - curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh && \ - sandbox-agent server --no-token --host 0.0.0.0 --port 3000" + rivetdev/sandbox-agent:0.3.1-full \ + server --no-token --host 0.0.0.0 --port 3000 ``` +The `0.3.1-full` tag pins the exact version. The moving `full` tag is also published for contributors who want the latest full image. + ## TypeScript with dockerode ```typescript @@ -31,14 +31,8 @@ const docker = new Docker(); const PORT = 3000; const container = await docker.createContainer({ - Image: "node:22-bookworm-slim", - Cmd: ["sh", "-c", [ - "apt-get update", - "DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6", - "rm -rf /var/lib/apt/lists/*", - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", - `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, - ].join(" && ")], + Image: "rivetdev/sandbox-agent:0.3.1-full", + Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", `${PORT}`], Env: [ `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`, `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`, @@ -60,6 +54,29 @@ const session = await sdk.createSession({ agent: "codex" }); await session.prompt([{ type: "text", text: "Summarize this repository." }]); ``` +## Building a custom image with everything preinstalled + +If you need to extend your own base image, install Sandbox Agent and preinstall every supported agent in one step: + +```dockerfile +FROM node:22-bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash ca-certificates curl git && \ + rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh && \ + sandbox-agent install-agent --all + +RUN useradd -m -s /bin/bash sandbox +USER sandbox +WORKDIR /home/sandbox + +EXPOSE 2468 +ENTRYPOINT ["sandbox-agent"] +CMD ["server", "--host", "0.0.0.0", "--port", "2468"] +``` + ## Building from source ```bash diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 0654e61..a6293fe 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -61,9 +61,11 @@ icon: "rocket" ```bash - docker run -e ANTHROPIC_API_KEY="sk-ant-..." \ + docker run -p 2468:2468 \ + -e ANTHROPIC_API_KEY="sk-ant-..." \ -e OPENAI_API_KEY="sk-..." \ - your-image + rivetdev/sandbox-agent:0.3.1-full \ + server --no-token --host 0.0.0.0 --port 2468 ``` @@ -215,10 +217,7 @@ icon: "rocket" To preinstall agents: ```bash - sandbox-agent install-agent claude - sandbox-agent install-agent codex - sandbox-agent install-agent opencode - sandbox-agent install-agent amp + sandbox-agent install-agent --all ``` If agents are not installed up front, they are lazily installed when creating a session. diff --git a/examples/daytona/package.json b/examples/daytona/package.json index f105bac..ba5b0ac 100644 --- a/examples/daytona/package.json +++ b/examples/daytona/package.json @@ -4,7 +4,6 @@ "type": "module", "scripts": { "start": "tsx src/index.ts", - "start:snapshot": "tsx src/daytona-with-snapshot.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/examples/daytona/src/daytona-with-snapshot.ts b/examples/daytona/src/daytona-with-snapshot.ts deleted file mode 100644 index 661d303..0000000 --- a/examples/daytona/src/daytona-with-snapshot.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Daytona, Image } from "@daytonaio/sdk"; -import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; - -const daytona = new Daytona(); - -const envVars: Record = {}; -if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; -if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY; - -// Build a custom image with sandbox-agent pre-installed (slower first run, faster subsequent runs) -const image = Image.base("ubuntu:22.04").runCommands( - "apt-get update && apt-get install -y curl ca-certificates", - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", -); - -console.log("Creating Daytona sandbox (first run builds the base image and may take a few minutes, subsequent runs are fast)..."); -const sandbox = await daytona.create({ envVars, image, autoStopInterval: 0 }, { timeout: 180 }); - -await sandbox.process.executeCommand("nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &"); - -const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; - -console.log("Connecting to server..."); -const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } }); -const sessionId = session.id; - -console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); -console.log(" Press Ctrl+C to stop."); - -const keepAlive = setInterval(() => {}, 60_000); -const cleanup = async () => { - clearInterval(keepAlive); - await sandbox.delete(60); - process.exit(0); -}; -process.once("SIGINT", cleanup); -process.once("SIGTERM", cleanup); diff --git a/examples/docker/src/index.ts b/examples/docker/src/index.ts index c6f29c2..74469f3 100644 --- a/examples/docker/src/index.ts +++ b/examples/docker/src/index.ts @@ -3,12 +3,13 @@ import fs from "node:fs"; import path from "node:path"; import { SandboxAgent } from "sandbox-agent"; import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; +import { FULL_IMAGE } from "@sandbox-agent/example-shared/docker"; -const IMAGE = "node:22-bookworm-slim"; +const IMAGE = FULL_IMAGE; const PORT = 3000; const agent = detectAgent(); const codexAuthPath = process.env.HOME ? path.join(process.env.HOME, ".codex", "auth.json") : null; -const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath) ? [`${codexAuthPath}:/root/.codex/auth.json:ro`] : []; +const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath) ? [`${codexAuthPath}:/home/sandbox/.codex/auth.json:ro`] : []; const docker = new Docker({ socketPath: "/var/run/docker.sock" }); @@ -28,17 +29,7 @@ try { console.log("Starting container..."); const container = await docker.createContainer({ Image: IMAGE, - Cmd: [ - "sh", - "-c", - [ - "apt-get update", - "DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6", - "rm -rf /var/lib/apt/lists/*", - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", - `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, - ].join(" && "), - ], + Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", `${PORT}`], Env: [ process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "", process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "", @@ -56,7 +47,7 @@ await container.start(); const baseUrl = `http://127.0.0.1:${PORT}`; const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent, sessionInit: { cwd: "/root", mcpServers: [] } }); +const session = await client.createSession({ agent, sessionInit: { cwd: "/home/sandbox", mcpServers: [] } }); const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); diff --git a/examples/persist-memory/src/index.ts b/examples/persist-memory/src/index.ts index e81ef06..2065a50 100644 --- a/examples/persist-memory/src/index.ts +++ b/examples/persist-memory/src/index.ts @@ -7,7 +7,6 @@ const persist = new InMemorySessionPersistDriver(); console.log("Starting sandbox..."); const sandbox = await startDockerSandbox({ port: 3000, - setupCommands: ["sandbox-agent install-agent claude", "sandbox-agent install-agent codex"], }); const sdk = await SandboxAgent.connect({ baseUrl: sandbox.baseUrl, persist }); diff --git a/examples/persist-postgres/src/index.ts b/examples/persist-postgres/src/index.ts index 5409705..73f9f04 100644 --- a/examples/persist-postgres/src/index.ts +++ b/examples/persist-postgres/src/index.ts @@ -66,7 +66,6 @@ try { console.log("Starting sandbox..."); const sandbox = await startDockerSandbox({ port: 3000, - setupCommands: ["sandbox-agent install-agent claude", "sandbox-agent install-agent codex"], }); const sdk = await SandboxAgent.connect({ baseUrl: sandbox.baseUrl, persist }); diff --git a/examples/persist-sqlite/src/index.ts b/examples/persist-sqlite/src/index.ts index 3b42550..d2c4ef2 100644 --- a/examples/persist-sqlite/src/index.ts +++ b/examples/persist-sqlite/src/index.ts @@ -8,7 +8,6 @@ const persist = new SQLiteSessionPersistDriver({ filename: "./sessions.db" }); console.log("Starting sandbox..."); const sandbox = await startDockerSandbox({ port: 3000, - setupCommands: ["sandbox-agent install-agent claude", "sandbox-agent install-agent codex"], }); const sdk = await SandboxAgent.connect({ baseUrl: sandbox.baseUrl, persist }); diff --git a/examples/shared/Dockerfile b/examples/shared/Dockerfile deleted file mode 100644 index 1a960d6..0000000 --- a/examples/shared/Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -FROM node:22-bookworm-slim -RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates > /dev/null 2>&1 && \ - rm -rf /var/lib/apt/lists/* && \ - npm install -g --silent @sandbox-agent/cli@latest && \ - sandbox-agent install-agent claude diff --git a/examples/shared/Dockerfile.dev b/examples/shared/Dockerfile.dev deleted file mode 100644 index 53a9922..0000000 --- a/examples/shared/Dockerfile.dev +++ /dev/null @@ -1,63 +0,0 @@ -FROM node:22-bookworm-slim AS frontend -RUN corepack enable && corepack prepare pnpm@latest --activate -WORKDIR /build - -# Copy workspace root config -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ - -# Copy packages needed for the inspector build chain: -# inspector -> sandbox-agent SDK -> acp-http-client, cli-shared, persist-indexeddb -COPY sdks/typescript/ sdks/typescript/ -COPY sdks/acp-http-client/ sdks/acp-http-client/ -COPY sdks/cli-shared/ sdks/cli-shared/ -COPY sdks/persist-indexeddb/ sdks/persist-indexeddb/ -COPY sdks/react/ sdks/react/ -COPY frontend/packages/inspector/ frontend/packages/inspector/ -COPY docs/openapi.json docs/ - -# Create stub package.json for workspace packages referenced in pnpm-workspace.yaml -# but not needed for the inspector build (avoids install errors). -RUN set -e; for dir in \ - sdks/cli sdks/gigacode \ - sdks/persist-postgres sdks/persist-sqlite sdks/persist-rivet \ - resources/agent-schemas resources/vercel-ai-sdk-schemas \ - scripts/release scripts/sandbox-testing \ - examples/shared examples/docker examples/e2b examples/vercel \ - examples/daytona examples/cloudflare examples/file-system \ - examples/mcp examples/mcp-custom-tool \ - examples/skills examples/skills-custom-tool \ - frontend/packages/website; do \ - mkdir -p "$dir"; \ - printf '{"name":"@stub/%s","private":true,"version":"0.0.0"}\n' "$(basename "$dir")" > "$dir/package.json"; \ - done; \ - for parent in sdks/cli/platforms sdks/gigacode/platforms; do \ - for plat in darwin-arm64 darwin-x64 linux-arm64 linux-x64 win32-x64; do \ - mkdir -p "$parent/$plat"; \ - printf '{"name":"@stub/%s-%s","private":true,"version":"0.0.0"}\n' "$(basename "$parent")" "$plat" > "$parent/$plat/package.json"; \ - done; \ - done - -RUN pnpm install --no-frozen-lockfile -ENV SKIP_OPENAPI_GEN=1 -RUN pnpm --filter sandbox-agent build && \ - pnpm --filter @sandbox-agent/inspector build - -FROM rust:1.88.0-bookworm AS builder -WORKDIR /build -COPY Cargo.toml Cargo.lock ./ -COPY server/ ./server/ -COPY gigacode/ ./gigacode/ -COPY resources/agent-schemas/artifacts/ ./resources/agent-schemas/artifacts/ -COPY scripts/agent-configs/ ./scripts/agent-configs/ -COPY --from=frontend /build/frontend/packages/inspector/dist/ ./frontend/packages/inspector/dist/ -RUN --mount=type=cache,target=/usr/local/cargo/registry \ - --mount=type=cache,target=/usr/local/cargo/git \ - --mount=type=cache,target=/build/target \ - cargo build -p sandbox-agent --release && \ - cp target/release/sandbox-agent /sandbox-agent - -FROM node:22-bookworm-slim -RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates > /dev/null 2>&1 && \ - rm -rf /var/lib/apt/lists/* -COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent -RUN sandbox-agent install-agent claude diff --git a/examples/shared/src/docker.ts b/examples/shared/src/docker.ts index 80c3916..2feca37 100644 --- a/examples/shared/src/docker.ts +++ b/examples/shared/src/docker.ts @@ -6,10 +6,10 @@ import { PassThrough } from "node:stream"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const EXAMPLE_IMAGE = "sandbox-agent-examples:latest"; -const EXAMPLE_IMAGE_DEV = "sandbox-agent-examples-dev:latest"; -const DOCKERFILE_DIR = path.resolve(__dirname, ".."); -const REPO_ROOT = path.resolve(DOCKERFILE_DIR, "../.."); +const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); + +/** Pre-built Docker image with all agents installed. */ +export const FULL_IMAGE = "rivetdev/sandbox-agent:0.3.1-full"; export interface DockerSandboxOptions { /** Container port used by sandbox-agent inside Docker. */ @@ -18,7 +18,7 @@ export interface DockerSandboxOptions { hostPort?: number; /** Additional shell commands to run before starting sandbox-agent. */ setupCommands?: string[]; - /** Docker image to use. Defaults to the pre-built sandbox-agent-examples image. */ + /** Docker image to use. Defaults to the pre-built full image. */ image?: string; } @@ -131,33 +131,31 @@ function stripAnsi(value: string): string { return value.replace(/[\u001B\u009B][[\]()#;?]*(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007|(?:\d{1,4}(?:;\d{0,4})*)?[0-9A-ORZcf-nqry=><])/g, ""); } -async function ensureExampleImage(_docker: Docker): Promise { - const dev = !!process.env.SANDBOX_AGENT_DEV; - const imageName = dev ? EXAMPLE_IMAGE_DEV : EXAMPLE_IMAGE; - - if (dev) { - console.log(" Building sandbox image from source (may take a while, only runs once)..."); +async function ensureImage(docker: Docker, image: string): Promise { + if (process.env.SANDBOX_AGENT_DEV) { + console.log(" Building sandbox image from source (may take a while)..."); try { - execFileSync("docker", ["build", "-t", imageName, "-f", path.join(DOCKERFILE_DIR, "Dockerfile.dev"), REPO_ROOT], { - stdio: ["ignore", "ignore", "pipe"], - }); - } catch (err: unknown) { - const stderr = err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr) : ""; - throw new Error(`Failed to build sandbox image: ${stderr}`); - } - } else { - console.log(" Building sandbox image (may take a while, only runs once)..."); - try { - execFileSync("docker", ["build", "-t", imageName, DOCKERFILE_DIR], { + execFileSync("docker", ["build", "-t", image, "-f", path.join(REPO_ROOT, "docker/runtime/Dockerfile.full"), REPO_ROOT], { stdio: ["ignore", "ignore", "pipe"], }); } catch (err: unknown) { const stderr = err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr) : ""; throw new Error(`Failed to build sandbox image: ${stderr}`); } + return; } - return imageName; + try { + await docker.getImage(image).inspect(); + } catch { + console.log(` Pulling ${image}...`); + await new Promise((resolve, reject) => { + docker.pull(image, (err: Error | null, stream: NodeJS.ReadableStream) => { + if (err) return reject(err); + docker.modem.followProgress(stream, (err: Error | null) => (err ? reject(err) : resolve())); + }); + }); + } } /** @@ -166,8 +164,7 @@ async function ensureExampleImage(_docker: Docker): Promise { */ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise { const { port, hostPort } = opts; - const useCustomImage = !!opts.image; - let image = opts.image ?? EXAMPLE_IMAGE; + const image = opts.image ?? FULL_IMAGE; // TODO: Replace setupCommands shell bootstrapping with native sandbox-agent exec API once available. const setupCommands = [...(opts.setupCommands ?? [])]; const credentialEnv = collectCredentialEnv(); @@ -197,27 +194,13 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise((resolve, reject) => { - docker.pull(image, (err: Error | null, stream: NodeJS.ReadableStream) => { - if (err) return reject(err); - docker.modem.followProgress(stream, (err: Error | null) => (err ? reject(err) : resolve())); - }); - }); - } - } else { - image = await ensureExampleImage(docker); - } + await ensureImage(docker, image); const bootCommands = [...setupCommands, `sandbox-agent server --no-token --host 0.0.0.0 --port ${port}`]; const container = await docker.createContainer({ Image: image, - WorkingDir: "/root", + WorkingDir: "/home/sandbox", Cmd: ["sh", "-c", bootCommands.join(" && ")], Env: [...Object.entries(credentialEnv).map(([key, value]) => `${key}=${value}`), ...Object.entries(bootstrapEnv).map(([key, value]) => `${key}=${value}`)], ExposedPorts: { [`${port}/tcp`]: {} }, diff --git a/foundry/CLAUDE.md b/foundry/CLAUDE.md index 074514f..d0fc8cc 100644 --- a/foundry/CLAUDE.md +++ b/foundry/CLAUDE.md @@ -31,16 +31,26 @@ Use `pnpm` workspaces and Turborepo. - Foundry is the canonical name for this product tree. Do not introduce or preserve legacy pre-Foundry naming in code, docs, commands, or runtime paths. - Install deps: `pnpm install` - Full active-workspace validation: `pnpm -w typecheck`, `pnpm -w build`, `pnpm -w test` -- Start the full dev stack: `just foundry-dev` +- Start the full dev stack (real backend + frontend): `just foundry-dev` — frontend on **port 4173**, backend on **port 7741** (Docker via `compose.dev.yaml`) +- Start the mock frontend stack (no backend): `just foundry-mock` — mock frontend on **port 4174** (Docker via `compose.mock.yaml`) - Start the local production-build preview stack: `just foundry-preview` - Start only the backend locally: `just foundry-backend-start` - Start only the frontend locally: `pnpm --filter @sandbox-agent/foundry-frontend dev` -- Start the frontend against the mock workbench client: `FOUNDRY_FRONTEND_CLIENT_MODE=mock pnpm --filter @sandbox-agent/foundry-frontend dev` +- Start the mock frontend locally (no Docker): `just foundry-dev-mock` — mock frontend on **port 4174** +- Dev and mock stacks can run simultaneously on different ports (4173 and 4174). - Stop the compose dev stack: `just foundry-dev-down` -- Tail compose logs: `just foundry-dev-logs` +- Tail compose dev logs: `just foundry-dev-logs` +- Stop the mock stack: `just foundry-mock-down` +- Tail mock logs: `just foundry-mock-logs` - Stop the preview stack: `just foundry-preview-down` - Tail preview logs: `just foundry-preview-logs` +## Dev Environment Setup + +- `compose.dev.yaml` loads `foundry/.env` (optional) for credentials needed by the backend (GitHub OAuth, Stripe, Daytona, API keys, etc.). +- The canonical source for these credentials is `~/misc/the-foundry.env`. If `foundry/.env` does not exist, copy it: `cp ~/misc/the-foundry.env foundry/.env` +- `foundry/.env` is gitignored and must never be committed. + ## Railway Logs - Production Foundry Railway logs can be read from a linked workspace with `railway logs --deployment --lines 200` or `railway logs --deployment --lines 200`. @@ -65,6 +75,17 @@ Use `pnpm` workspaces and Turborepo. - When asked for screenshots, capture all relevant affected screens and modal states, not just a single viewport. Include empty, populated, success, and blocked/error states when they are part of the changed flow. - If a screenshot catches a transition frame, blank modal, or otherwise misleading state, retake it before reporting it. +## UI System + +- Foundry's base UI system is `BaseUI` with `Styletron`, plus Foundry-specific theme/tokens on top. Treat that as the default UI foundation. +- The full `BaseUI` reference for available components and guidance on animations, customization, composition, and forms is at `https://base-ui.com/llms.txt`. +- Prefer existing `BaseUI` components and composition patterns whenever possible instead of building custom controls from scratch. +- Reuse the established Foundry theme/token layer for colors, typography, spacing, and surfaces instead of introducing ad hoc visual values. +- If the same UI pattern is shared with the Inspector or other consumers, prefer extracting or reusing it through `@sandbox-agent/react` rather than duplicating it in Foundry. +- If a requested UI cannot be implemented cleanly with an existing `BaseUI` component, stop and ask the user whether they are sure they want to diverge from the system. +- In that case, recommend the closest existing `BaseUI` components or compositions that could satisfy the need before proposing custom UI work. +- Only introduce custom UI primitives when `BaseUI` and existing Foundry patterns are not sufficient, or when the user explicitly confirms they want the divergence. + ## Runtime Policy - Runtime is Bun-native. @@ -122,7 +143,9 @@ For all Rivet/RivetKit implementation: - Do not build blocking flows that wait on external systems to become ready or complete. Prefer push-based progression driven by actor messages, events, webhooks, or queue/workflow state changes. - Use workflows/background commands for any repo sync, sandbox provisioning, agent install, branch restack/rebase, or other multi-step external work. Do not keep user-facing actions/requests open while that work runs. - `send` policy: always `await` the `send(...)` call itself so enqueue failures surface immediately, but default to `wait: false`. -- Only use `send(..., { wait: true })` for short, bounded mutations that should finish quickly and do not depend on external readiness, polling actors, provider setup, repo/network I/O, or long-running queue drains. +- Only use `send(..., { wait: true })` for short, bounded local mutations (e.g. a DB write that returns a result the caller needs). Never use `wait: true` for operations that depend on external readiness, polling actors, provider setup, repo/network I/O, sandbox sessions, GitHub API calls, or long-running queue drains. +- Never self-send with `wait: true` from inside a workflow handler — the workflow processes one message at a time, so the handler would deadlock waiting for the new message to be dequeued. +- When an action is void-returning and triggers external work, use `wait: false` and let the UI react to state changes pushed by the workflow. - Request/action contract: wait only until the minimum resource needed for the client's next step exists. Example: task creation may wait for task actor creation/identity, but not for sandbox provisioning or session bootstrap. - Read paths must not force refresh/sync work inline. Serve the latest cached projection, mark staleness explicitly, and trigger background refresh separately when needed. - If a workflow needs to resume after some external work completes, model that as workflow state plus follow-up messages/events instead of holding the original request open. diff --git a/foundry/compose.dev.yaml b/foundry/compose.dev.yaml index 01c6934..464835a 100644 --- a/foundry/compose.dev.yaml +++ b/foundry/compose.dev.yaml @@ -7,6 +7,9 @@ services: dockerfile: foundry/docker/backend.dev.Dockerfile image: foundry-backend-dev working_dir: /app + env_file: + - path: .env + required: false environment: HF_BACKEND_HOST: "0.0.0.0" HF_BACKEND_PORT: "7741" diff --git a/foundry/compose.mock.yaml b/foundry/compose.mock.yaml new file mode 100644 index 0000000..ffe560c --- /dev/null +++ b/foundry/compose.mock.yaml @@ -0,0 +1,32 @@ +name: foundry-mock + +services: + frontend: + build: + context: .. + dockerfile: foundry/docker/frontend.dev.Dockerfile + working_dir: /app + environment: + HOME: "/tmp" + FOUNDRY_FRONTEND_CLIENT_MODE: "mock" + ports: + - "4174:4174" + command: ["bash", "-lc", "pnpm install --force --frozen-lockfile --filter @sandbox-agent/foundry-frontend... && cd foundry/packages/frontend && exec pnpm vite --host 0.0.0.0 --port 4174"] + volumes: + - "..:/app" + - "./.foundry:/app/foundry/.foundry" + - "../../../task/rivet-checkout:/task/rivet-checkout:ro" + - "mock_node_modules:/app/node_modules" + - "mock_client_node_modules:/app/foundry/packages/client/node_modules" + - "mock_frontend_errors_node_modules:/app/foundry/packages/frontend-errors/node_modules" + - "mock_frontend_node_modules:/app/foundry/packages/frontend/node_modules" + - "mock_shared_node_modules:/app/foundry/packages/shared/node_modules" + - "mock_pnpm_store:/tmp/.local/share/pnpm/store" + +volumes: + mock_node_modules: {} + mock_client_node_modules: {} + mock_frontend_errors_node_modules: {} + mock_frontend_node_modules: {} + mock_shared_node_modules: {} + mock_pnpm_store: {} diff --git a/foundry/docker/backend.dev.Dockerfile b/foundry/docker/backend.dev.Dockerfile index 3a0697d..cf8580c 100644 --- a/foundry/docker/backend.dev.Dockerfile +++ b/foundry/docker/backend.dev.Dockerfile @@ -39,4 +39,4 @@ ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent" WORKDIR /app -CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --force --frozen-lockfile --filter @sandbox-agent/foundry-backend... && exec bun foundry/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"] +CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --frozen-lockfile --filter @sandbox-agent/foundry-backend... && exec bun --hot foundry/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"] diff --git a/foundry/packages/backend/src/actors/task/index.ts b/foundry/packages/backend/src/actors/task/index.ts index d8bf069..5542abb 100644 --- a/foundry/packages/backend/src/actors/task/index.ts +++ b/foundry/packages/backend/src/actors/task/index.ts @@ -144,14 +144,9 @@ export const task = actor({ async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> { const self = selfTask(c); - const result = await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, { - wait: true, - timeout: 30 * 60_000, + await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, { + wait: false, }); - const response = expectQueueResponse<{ ok: boolean; error?: string }>(result); - if (!response.ok) { - throw new Error(response.error ?? "task provisioning failed"); - } return { ok: true }; }, @@ -180,47 +175,35 @@ export const task = actor({ async push(c, cmd?: TaskActionCommand): Promise { const self = selfTask(c); await self.send(taskWorkflowQueueName("task.command.push"), cmd ?? {}, { - wait: true, - timeout: 180_000, + wait: false, }); }, async sync(c, cmd?: TaskActionCommand): Promise { const self = selfTask(c); await self.send(taskWorkflowQueueName("task.command.sync"), cmd ?? {}, { - wait: true, - timeout: 30_000, + wait: false, }); }, async merge(c, cmd?: TaskActionCommand): Promise { const self = selfTask(c); await self.send(taskWorkflowQueueName("task.command.merge"), cmd ?? {}, { - wait: true, - timeout: 30_000, + wait: false, }); }, async archive(c, cmd?: TaskActionCommand): Promise { const self = selfTask(c); - void self - .send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, { - wait: true, - timeout: 60_000, - }) - .catch((error: unknown) => { - c.log.warn({ - msg: "archive command failed", - error: error instanceof Error ? error.message : String(error), - }); - }); + await self.send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, { + wait: false, + }); }, async kill(c, cmd?: TaskActionCommand): Promise { const self = selfTask(c); await self.send(taskWorkflowQueueName("task.command.kill"), cmd ?? {}, { - wait: true, - timeout: 60_000, + wait: false, }); }, @@ -255,8 +238,7 @@ export const task = actor({ async renameWorkbenchBranch(c, input: TaskWorkbenchRenameInput): Promise { const self = selfTask(c); await self.send(taskWorkflowQueueName("task.command.workbench.rename_branch"), { value: input.value } satisfies TaskWorkbenchValueCommand, { - wait: true, - timeout: 5 * 60_000, + wait: false, }); }, @@ -335,8 +317,7 @@ export const task = actor({ attachments: input.attachments, } satisfies TaskWorkbenchSendMessageCommand, { - wait: true, - timeout: 10 * 60_000, + wait: false, }, ); }, @@ -344,8 +325,7 @@ export const task = actor({ async stopWorkbenchSession(c, input: TaskTabCommand): Promise { const self = selfTask(c); await self.send(taskWorkflowQueueName("task.command.workbench.stop_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, { - wait: true, - timeout: 5 * 60_000, + wait: false, }); }, @@ -360,8 +340,7 @@ export const task = actor({ async closeWorkbenchSession(c, input: TaskTabCommand): Promise { const self = selfTask(c); await self.send(taskWorkflowQueueName("task.command.workbench.close_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, { - wait: true, - timeout: 5 * 60_000, + wait: false, }); }, @@ -371,8 +350,7 @@ export const task = actor({ taskWorkflowQueueName("task.command.workbench.publish_pr"), {}, { - wait: true, - timeout: 10 * 60_000, + wait: false, }, ); }, @@ -380,8 +358,7 @@ export const task = actor({ async revertWorkbenchFile(c, input: { path: string }): Promise { const self = selfTask(c); await self.send(taskWorkflowQueueName("task.command.workbench.revert_file"), input, { - wait: true, - timeout: 5 * 60_000, + wait: false, }); }, }, diff --git a/foundry/packages/backend/src/actors/task/workbench.ts b/foundry/packages/backend/src/actors/task/workbench.ts index fae749c..d26dd2f 100644 --- a/foundry/packages/backend/src/actors/task/workbench.ts +++ b/foundry/packages/backend/src/actors/task/workbench.ts @@ -6,6 +6,7 @@ import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, ge import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js"; import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js"; import { getCurrentRecord } from "./workflow/common.js"; +import { taskWorkflowQueueName } from "./workflow/queue.js"; const STATUS_SYNC_INTERVAL_MS = 1_000; @@ -551,9 +552,11 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise { let record = await ensureWorkbenchSeeded(c); if (!record.activeSandboxId) { + // Fire-and-forget: enqueue provisioning without waiting to avoid self-deadlock + // (this handler already runs inside the task workflow loop, so wait:true would deadlock). const providerId = record.providerId ?? c.state.providerId ?? getActorRuntimeContext().providers.defaultProviderId(); - await selfTask(c).provision({ providerId }); - record = await ensureWorkbenchSeeded(c); + await selfTask(c).send(taskWorkflowQueueName("task.command.provision"), { providerId }, { wait: false }); + throw new Error("sandbox is provisioning — retry shortly"); } if (record.activeSessionId) { diff --git a/foundry/packages/frontend/index.html b/foundry/packages/frontend/index.html index dc0af73..4e72d23 100644 --- a/foundry/packages/frontend/index.html +++ b/foundry/packages/frontend/index.html @@ -1,15 +1,12 @@ - diff --git a/foundry/packages/frontend/src/components/dev-panel.tsx b/foundry/packages/frontend/src/components/dev-panel.tsx new file mode 100644 index 0000000..0836a28 --- /dev/null +++ b/foundry/packages/frontend/src/components/dev-panel.tsx @@ -0,0 +1,301 @@ +import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { useStyletron } from "baseui"; +import { useFoundryTokens } from "../app/theme"; +import { isMockFrontendClient } from "../lib/env"; +import type { TaskWorkbenchSnapshot, WorkbenchTask } from "@sandbox-agent/foundry-shared"; + +interface DevPanelProps { + workspaceId: string; + snapshot: TaskWorkbenchSnapshot; +} + +interface TopicInfo { + label: string; + key: string; + listenerCount: number; + hasConnection: boolean; + lastRefresh: number | null; +} + +function timeAgo(ts: number | null): string { + if (!ts) return "never"; + const seconds = Math.floor((Date.now() - ts) / 1000); + if (seconds < 5) return "now"; + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + return `${Math.floor(minutes / 60)}h`; +} + +function taskStatusLabel(task: WorkbenchTask): string { + if (task.status === "archived") return "archived"; + const hasRunning = task.tabs?.some((tab) => tab.status === "running"); + if (hasRunning) return "running"; + return task.status ?? "idle"; +} + +function statusColor(status: string, t: ReturnType): string { + switch (status) { + case "running": + return t.statusSuccess; + case "archived": + return t.textMuted; + case "error": + case "failed": + return t.statusError; + default: + return t.textTertiary; + } +} + +export const DevPanel = memo(function DevPanel({ workspaceId, snapshot }: DevPanelProps) { + const [css] = useStyletron(); + const t = useFoundryTokens(); + const [now, setNow] = useState(Date.now()); + + // Tick every 2s to keep relative timestamps fresh + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 2000); + return () => clearInterval(id); + }, []); + + const topics = useMemo((): TopicInfo[] => { + const items: TopicInfo[] = []; + + // Workbench subscription topic + items.push({ + label: "Workbench", + key: `ws:${workspaceId}`, + listenerCount: 1, + hasConnection: true, + lastRefresh: now, + }); + + // Per-task tab subscriptions + for (const task of snapshot.tasks ?? []) { + if (task.status === "archived") continue; + for (const tab of task.tabs ?? []) { + items.push({ + label: `Tab/${task.title?.slice(0, 16) || task.id.slice(0, 8)}/${tab.sessionName.slice(0, 10)}`, + key: `${workspaceId}:${task.id}:${tab.id}`, + listenerCount: 1, + hasConnection: tab.status === "running", + lastRefresh: tab.status === "running" ? now : null, + }); + } + } + + return items; + }, [workspaceId, snapshot, now]); + + const tasks = snapshot.tasks ?? []; + const repos = snapshot.repos ?? []; + const projects = snapshot.projects ?? []; + + const mono = css({ + fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace", + fontSize: "10px", + }); + + return ( +
+ {/* Header */} +
+ + Dev + {isMockFrontendClient && MOCK} + + Shift+D +
+ + {/* Body */} +
+ {/* Interest Topics */} +
+ {topics.map((topic) => ( +
+ + + {topic.label} + + {topic.key.length > 24 ? `...${topic.key.slice(-20)}` : topic.key} + {timeAgo(topic.lastRefresh)} +
+ ))} + {topics.length === 0 && No active subscriptions} +
+ + {/* Snapshot Summary */} +
+
+ + + +
+
+ + {/* Tasks */} + {tasks.length > 0 && ( +
+ {tasks.slice(0, 10).map((task) => { + const status = taskStatusLabel(task); + return ( +
+ + + {task.title || task.id.slice(0, 12)} + + {status} + {task.tabs?.length ?? 0} tabs +
+ ); + })} +
+ )} + + {/* Workspace */} +
+
{workspaceId}
+
+
+
+ ); +}); + +function Section({ + label, + t, + css: cssFn, + children, +}: { + label: string; + t: ReturnType; + css: ReturnType[0]; + children: React.ReactNode; +}) { + return ( +
+
+ {label} +
+ {children} +
+ ); +} + +function Stat({ + label, + value, + t, + css: cssFn, +}: { + label: string; + value: number; + t: ReturnType; + css: ReturnType[0]; +}) { + return ( + + {value} + {label} + + ); +} + +export function useDevPanel() { + const [visible, setVisible] = useState(true); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.shiftKey && e.key === "D" && !e.metaKey && !e.ctrlKey && !e.altKey) { + const tag = (e.target as HTMLElement)?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; + e.preventDefault(); + setVisible((prev) => !prev); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + return visible; +} diff --git a/foundry/packages/frontend/src/components/mock-layout.tsx b/foundry/packages/frontend/src/components/mock-layout.tsx index baab797..922cb15 100644 --- a/foundry/packages/frontend/src/components/mock-layout.tsx +++ b/foundry/packages/frontend/src/components/mock-layout.tsx @@ -16,6 +16,7 @@ import { TabStrip } from "./mock-layout/tab-strip"; import { TerminalPane } from "./mock-layout/terminal-pane"; import { TranscriptHeader } from "./mock-layout/transcript-header"; import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui"; +import { DevPanel, useDevPanel } from "./dev-panel"; import { buildDisplayMessages, diffPath, @@ -910,6 +911,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M ); const tasks = viewModel.tasks ?? []; const rawProjects = viewModel.projects ?? []; + const showDevPanel = useDevPanel(); const appSnapshot = useMockAppSnapshot(); const activeOrg = activeMockOrganization(appSnapshot); const navigateToUsage = useCallback(() => { @@ -1610,6 +1612,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M + {showDevPanel && } ); diff --git a/justfile b/justfile index 7ff63a6..3abdefe 100644 --- a/justfile +++ b/justfile @@ -141,10 +141,24 @@ foundry-frontend-dev host='127.0.0.1' port='4173' backend='http://127.0.0.1:7741 VITE_HF_BACKEND_ENDPOINT="{{backend}}" pnpm --filter @sandbox-agent/foundry-frontend dev -- --host {{host}} --port {{port}} [group('foundry')] -foundry-dev-mock host='127.0.0.1' port='4173': +foundry-dev-mock host='127.0.0.1' port='4174': pnpm install FOUNDRY_FRONTEND_CLIENT_MODE=mock pnpm --filter @sandbox-agent/foundry-frontend dev -- --host {{host}} --port {{port}} +[group('foundry')] +foundry-mock: + pnpm install + mkdir -p foundry/.foundry/logs + docker compose -f foundry/compose.mock.yaml up --build --force-recreate -d + +[group('foundry')] +foundry-mock-down: + docker compose -f foundry/compose.mock.yaml down + +[group('foundry')] +foundry-mock-logs: + docker compose -f foundry/compose.mock.yaml logs -f --tail=200 + [group('foundry')] foundry-dev-turbo: pnpm exec turbo run dev --parallel --filter=@sandbox-agent/foundry-* diff --git a/scripts/release/docker.ts b/scripts/release/docker.ts index e2a4a9e..a8a52de 100644 --- a/scripts/release/docker.ts +++ b/scripts/release/docker.ts @@ -16,34 +16,46 @@ export async function tagDocker(opts: ReleaseOpts) { console.log(`==> Source commit: ${sourceCommit}`); } - // Check both architecture images exist using manifest inspect - console.log(`==> Checking images exist: ${IMAGE}:${sourceCommit}-{amd64,arm64}`); try { - console.log(`==> Inspecting ${IMAGE}:${sourceCommit}-amd64`); - await $({ stdio: "inherit" })`docker manifest inspect ${IMAGE}:${sourceCommit}-amd64`; - console.log(`==> Inspecting ${IMAGE}:${sourceCommit}-arm64`); - await $({ stdio: "inherit" })`docker manifest inspect ${IMAGE}:${sourceCommit}-arm64`; - console.log(`==> Both images exist`); + await ensureArchImagesExist(sourceCommit, ""); } catch (error) { console.warn(`⚠️ Docker images ${IMAGE}:${sourceCommit}-{amd64,arm64} not found - skipping Docker tagging`); console.warn(` To enable Docker tagging, build and push images first, then retry the release.`); return; } - // Create and push manifest with version await createManifest(sourceCommit, opts.version); - - // Create and push manifest with latest if (opts.latest) { await createManifest(sourceCommit, "latest"); await createManifest(sourceCommit, opts.minorVersionChannel); } + + try { + await ensureArchImagesExist(sourceCommit, "-full"); + await createManifest(sourceCommit, `${opts.version}-full`, "-full"); + if (opts.latest) { + await createManifest(sourceCommit, `${opts.minorVersionChannel}-full`, "-full"); + await createManifest(sourceCommit, "full", "-full"); + } + } catch (error) { + console.warn(`⚠️ Full Docker images ${IMAGE}:${sourceCommit}-full-{amd64,arm64} not found - skipping full Docker tagging`); + console.warn(` To enable full Docker tagging, build and push full images first, then retry the release.`); + } } -async function createManifest(from: string, to: string) { - console.log(`==> Creating manifest: ${IMAGE}:${to} from ${IMAGE}:${from}-{amd64,arm64}`); - - // Use buildx imagetools to create and push multi-arch manifest - // This works with manifest lists as inputs (unlike docker manifest create) - await $({ stdio: "inherit" })`docker buildx imagetools create --tag ${IMAGE}:${to} ${IMAGE}:${from}-amd64 ${IMAGE}:${from}-arm64`; +async function ensureArchImagesExist(sourceCommit: string, variantSuffix: "" | "-full") { + console.log(`==> Checking images exist: ${IMAGE}:${sourceCommit}${variantSuffix}-{amd64,arm64}`); + console.log(`==> Inspecting ${IMAGE}:${sourceCommit}${variantSuffix}-amd64`); + await $({ stdio: "inherit" })`docker manifest inspect ${IMAGE}:${sourceCommit}${variantSuffix}-amd64`; + console.log(`==> Inspecting ${IMAGE}:${sourceCommit}${variantSuffix}-arm64`); + await $({ stdio: "inherit" })`docker manifest inspect ${IMAGE}:${sourceCommit}${variantSuffix}-arm64`; + console.log(`==> Both images exist`); +} + +async function createManifest(from: string, to: string, variantSuffix: "" | "-full" = "") { + console.log(`==> Creating manifest: ${IMAGE}:${to} from ${IMAGE}:${from}${variantSuffix}-{amd64,arm64}`); + + await $({ + stdio: "inherit", + })`docker buildx imagetools create --tag ${IMAGE}:${to} ${IMAGE}:${from}${variantSuffix}-amd64 ${IMAGE}:${from}${variantSuffix}-arm64`; } diff --git a/server/packages/sandbox-agent/src/cli.rs b/server/packages/sandbox-agent/src/cli.rs index b1fc6bb..1c12e4b 100644 --- a/server/packages/sandbox-agent/src/cli.rs +++ b/server/packages/sandbox-agent/src/cli.rs @@ -79,7 +79,7 @@ pub enum Command { Opencode(OpencodeArgs), /// Manage the sandbox-agent background daemon. Daemon(DaemonArgs), - /// Install or reinstall an agent without running the server. + /// Install or reinstall one agent, or `all` supported agents, without running the server. InstallAgent(InstallAgentArgs), /// Inspect locally discovered credentials. Credentials(CredentialsArgs), @@ -295,7 +295,10 @@ pub struct AcpCloseArgs { #[derive(Args, Debug)] pub struct InstallAgentArgs { - agent: String, + #[arg(required_unless_present = "all", conflicts_with = "all")] + agent: Option, + #[arg(long, conflicts_with = "agent")] + all: bool, #[arg(long, short = 'r')] reinstall: bool, #[arg(long = "agent-version")] @@ -946,24 +949,73 @@ fn load_json_payload( } fn install_agent_local(args: &InstallAgentArgs) -> Result<(), CliError> { - let agent_id = AgentId::parse(&args.agent) - .ok_or_else(|| CliError::Server(format!("unsupported agent: {}", args.agent)))?; + if args.all && (args.agent_version.is_some() || args.agent_process_version.is_some()) { + return Err(CliError::Server( + "--agent-version and --agent-process-version are only supported for single-agent installs" + .to_string(), + )); + } + + let agents = resolve_install_agents(args)?; let manager = AgentManager::new(default_install_dir()) .map_err(|err| CliError::Server(err.to_string()))?; - let result = manager - .install( - agent_id, - InstallOptions { - reinstall: args.reinstall, - version: args.agent_version.clone(), - agent_process_version: args.agent_process_version.clone(), - }, - ) - .map_err(|err| CliError::Server(err.to_string()))?; + if agents.len() == 1 { + let result = manager + .install( + agents[0], + InstallOptions { + reinstall: args.reinstall, + version: args.agent_version.clone(), + agent_process_version: args.agent_process_version.clone(), + }, + ) + .map_err(|err| CliError::Server(err.to_string()))?; + let output = install_result_json(result); + return write_stdout_line(&serde_json::to_string_pretty(&output)?); + } - let output = json!({ + let mut results = Vec::with_capacity(agents.len()); + for agent_id in agents { + let result = manager + .install( + agent_id, + InstallOptions { + reinstall: args.reinstall, + version: None, + agent_process_version: None, + }, + ) + .map_err(|err| CliError::Server(err.to_string()))?; + results.push(json!({ + "agent": agent_id.as_str(), + "result": install_result_json(result), + })); + } + + write_stdout_line(&serde_json::to_string_pretty( + &json!({ "agents": results }), + )?) +} + +fn resolve_install_agents(args: &InstallAgentArgs) -> Result, CliError> { + if args.all { + return Ok(AgentId::all().to_vec()); + } + + let agent = args + .agent + .as_deref() + .ok_or_else(|| CliError::Server("missing agent: provide or --all".to_string()))?; + + AgentId::parse(agent) + .map(|agent_id| vec![agent_id]) + .ok_or_else(|| CliError::Server(format!("unsupported agent: {agent}"))) +} + +fn install_result_json(result: sandbox_agent_agent_management::agents::InstallResult) -> Value { + json!({ "alreadyInstalled": result.already_installed, "artifacts": result.artifacts.into_iter().map(|artifact| json!({ "kind": format!("{:?}", artifact.kind), @@ -971,9 +1023,7 @@ fn install_agent_local(args: &InstallAgentArgs) -> Result<(), CliError> { "source": format!("{:?}", artifact.source), "version": artifact.version, })).collect::>() - }); - - write_stdout_line(&serde_json::to_string_pretty(&output)?) + }) } #[derive(Serialize)] @@ -1416,6 +1466,60 @@ fn write_stderr_line(text: &str) -> Result<(), CliError> { mod tests { use super::*; + #[test] + fn resolve_install_agents_expands_all() { + assert_eq!( + resolve_install_agents(&InstallAgentArgs { + agent: None, + all: true, + reinstall: false, + agent_version: None, + agent_process_version: None, + }) + .unwrap(), + AgentId::all().to_vec() + ); + } + + #[test] + fn resolve_install_agents_supports_single_agent() { + assert_eq!( + resolve_install_agents(&InstallAgentArgs { + agent: Some("codex".to_string()), + all: false, + reinstall: false, + agent_version: None, + agent_process_version: None, + }) + .unwrap(), + vec![AgentId::Codex] + ); + } + + #[test] + fn resolve_install_agents_rejects_unknown_agent() { + assert!(resolve_install_agents(&InstallAgentArgs { + agent: Some("nope".to_string()), + all: false, + reinstall: false, + agent_version: None, + agent_process_version: None, + }) + .is_err()); + } + + #[test] + fn resolve_install_agents_rejects_positional_all() { + assert!(resolve_install_agents(&InstallAgentArgs { + agent: Some("all".to_string()), + all: false, + reinstall: false, + agent_version: None, + agent_process_version: None, + }) + .is_err()); + } + #[test] fn apply_last_event_id_header_sets_header_when_provided() { let client = HttpClient::builder().build().expect("build client");