Merge remote-tracking branch 'origin/full-docker-defaults' into async-action-fixes

# Conflicts:
#	foundry/CLAUDE.md
#	foundry/packages/backend/src/actors/task/workbench.ts
#	foundry/packages/frontend/src/components/dev-panel.tsx
#	foundry/packages/frontend/src/components/mock-layout.tsx
#	justfile
This commit is contained in:
Nathan Flurry 2026-03-13 18:50:46 -07:00
commit 14d5413f8a
30 changed files with 521 additions and 413 deletions

View file

@ -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' }}

View file

@ -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`

View file

@ -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:

View file

@ -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"]

View file

@ -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"]

View file

@ -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 <AGENT> [OPTIONS]
sandbox-agent install-agent [<AGENT>] [OPTIONS]
```
| Option | Description |
|--------|-------------|
| `--all` | Install every supported agent |
| `-r, --reinstall` | Force reinstall |
| `--agent-version <VERSION>` | Override agent package version |
| `--agent-process-version <VERSION>` | Override agent process version |
| `--agent-version <VERSION>` | Override agent package version (conflicts with `--all`) |
| `--agent-process-version <VERSION>` | Override agent process version (conflicts with `--all`) |
Examples:
```bash
sandbox-agent install-agent claude --reinstall
sandbox-agent install-agent --all
```
## opencode (experimental)

View file

@ -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

View file

@ -61,9 +61,11 @@ icon: "rocket"
<Tab title="Docker">
```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
```
</Tab>
</Tabs>
@ -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.

View file

@ -4,7 +4,6 @@
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"start:snapshot": "tsx src/daytona-with-snapshot.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {

View file

@ -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<string, string> = {};
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);

View file

@ -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 })}`);

View file

@ -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 });

View file

@ -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 });

View file

@ -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 });

View file

@ -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

View file

@ -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

View file

@ -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<string> {
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<void> {
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<void>((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<string> {
*/
export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<DockerSandbox> {
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<Do
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
if (useCustomImage) {
try {
await docker.getImage(image).inspect();
} catch {
console.log(` Pulling ${image}...`);
await new Promise<void>((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`]: {} },

View file

@ -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-id> --deployment --lines 200`.
@ -117,6 +127,17 @@ The client subscribes to `app` always, `workspace` when entering a workspace, `t
- Backend mutations that affect sidebar data (task title, status, branch, PR state) must push the updated summary to the parent workspace actor, which broadcasts to workspace subscribers.
- Comment architecture-related code: add doc comments explaining the materialized state pattern, why deltas flow the way they do, and the relationship between parent/child actor broadcasts. New contributors should understand the data flow from comments alone.
## 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.
@ -174,7 +195,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.

View file

@ -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"

32
foundry/compose.mock.yaml Normal file
View file

@ -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: {}

View file

@ -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"]

View file

@ -146,14 +146,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 };
},
@ -182,47 +177,35 @@ export const task = actor({
async push(c, cmd?: TaskActionCommand): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.kill"), cmd ?? {}, {
wait: true,
timeout: 60_000,
wait: false,
});
},
@ -265,8 +248,7 @@ export const task = actor({
async renameWorkbenchBranch(c, input: TaskWorkbenchRenameInput): Promise<void> {
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,
});
},
@ -345,8 +327,7 @@ export const task = actor({
attachments: input.attachments,
} satisfies TaskWorkbenchSendMessageCommand,
{
wait: true,
timeout: 10 * 60_000,
wait: false,
},
);
},
@ -354,8 +335,7 @@ export const task = actor({
async stopWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
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,
});
},
@ -370,8 +350,7 @@ export const task = actor({
async closeWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
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,
});
},
@ -381,8 +360,7 @@ export const task = actor({
taskWorkflowQueueName("task.command.workbench.publish_pr"),
{},
{
wait: true,
timeout: 10 * 60_000,
wait: false,
},
);
},
@ -390,8 +368,7 @@ export const task = actor({
async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.revert_file"), input, {
wait: true,
timeout: 5 * 60_000,
wait: false,
});
},
},

View file

@ -7,6 +7,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;
@ -835,7 +836,14 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
}
export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> {
const record = await ensureWorkbenchSeeded(c);
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).send(taskWorkflowQueueName("task.command.provision"), { providerId }, { wait: false });
throw new Error("sandbox is provisioning — retry shortly");
}
if (record.activeSessionId) {
const existingSessions = await listSessionMetaRows(c);

View file

@ -1,15 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<!--
<script src="https://unpkg.com/react-scan/dist/auto.global.js" crossorigin="anonymous"></script>
<script type="module">
if (import.meta.env.DEV) {
import("react-grab");
import("@react-grab/mcp/client");
}
</script>
-->
<script>if(window.__TAURI_INTERNALS__)document.documentElement.dataset.tauri="1"</script>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />

View file

@ -17,6 +17,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,
@ -1759,6 +1760,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
</div>
</div>
</div>
{showDevPanel && <DevPanel workspaceId={workspaceId} snapshot={viewModel} />}
</Shell>
</>
);

View file

@ -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<Platform | null>(null);
const [showDropdown, setShowDropdown] = useState(false);
useEffect(() => {
setDetected(detectPlatform());
}, []);
const primary = detected ?? PLATFORMS[0];
const secondary = PLATFORMS.filter((p) => p !== primary);
return (
<section className="border-t border-white/10 py-48">
<div className="mx-auto max-w-7xl px-6">
<div className="mb-12 text-center">
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="mb-4 text-2xl font-normal tracking-tight text-white md:text-4xl"
>
Download Foundry
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
className="mx-auto max-w-xl text-base leading-relaxed text-zinc-500"
>
Run Foundry as a native desktop app. Manage workspaces, handoffs, and coding agents locally.
</motion.p>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
className="mx-auto flex max-w-md flex-col items-center gap-4"
>
{/* Primary download button */}
<a
href={`${DOWNLOAD_BASE}/${primary.filename}`}
className="inline-flex w-full items-center justify-center gap-3 rounded-xl bg-white px-8 py-4 text-base font-medium text-black transition-colors hover:bg-zinc-200"
>
<Download className="h-5 w-5" />
Download for {primary.label}
</a>
{/* Other platforms */}
<div className="relative">
<button
onClick={() => setShowDropdown(!showDropdown)}
className="inline-flex items-center gap-2 text-sm text-zinc-500 transition-colors hover:text-white"
>
<Monitor className="h-4 w-4" />
Other platforms
<ChevronDown className={`h-3 w-3 transition-transform ${showDropdown ? "rotate-180" : ""}`} />
</button>
{showDropdown && (
<div className="absolute left-1/2 top-full mt-2 -translate-x-1/2 rounded-lg border border-white/10 bg-[#0f0f11] p-2 shadow-xl">
{secondary.map((p) => (
<a
key={p.arch}
href={`${DOWNLOAD_BASE}/${p.filename}`}
className="block whitespace-nowrap rounded-md px-4 py-2 text-sm text-zinc-300 transition-colors hover:bg-white/10 hover:text-white"
>
{p.label}
</a>
))}
</div>
)}
</div>
{/* Unsigned app note */}
<p className="mt-4 text-center text-xs leading-relaxed text-zinc-600">
macOS only. On first launch, right-click the app and select "Open" to bypass Gatekeeper.
</p>
</motion.div>
</div>
</section>
);
}

View file

@ -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";
<PainPoints client:visible />
<FeatureGrid client:visible />
<GetStarted client:visible />
<DownloadFoundry client:visible />
<Inspector client:visible />
<FAQ client:visible />
</main>

View file

@ -135,6 +135,34 @@ foundry-preview:
mkdir -p foundry/.foundry/logs
HF_DOCKER_UID="$(id -u)" HF_DOCKER_GID="$(id -g)" docker compose --env-file .env -f foundry/compose.preview.yaml up --build --force-recreate -d
[group('foundry')]
foundry-frontend-dev host='127.0.0.1' port='4173' backend='http://127.0.0.1:7741/api/rivet':
pnpm install
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='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-*
[group('foundry')]
foundry-dev-down:
docker compose --env-file .env -f foundry/compose.dev.yaml down

View file

@ -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`;
}

View file

@ -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<String>,
#[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<Vec<AgentId>, CliError> {
if args.all {
return Ok(AgentId::all().to_vec());
}
let agent = args
.agent
.as_deref()
.ok_or_else(|| CliError::Server("missing agent: provide <AGENT> 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::<Vec<_>>()
});
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");