mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 08:03:46 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
58c54156f1
commit
110e969f98
29 changed files with 804 additions and 283 deletions
18
.github/workflows/release.yaml
vendored
18
.github/workflows/release.yaml
vendored
|
|
@ -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' }}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
162
docker/runtime/Dockerfile.full
Normal file
162
docker/runtime/Dockerfile.full
Normal 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"]
|
||||
12
docs/cli.mdx
12
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 <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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/index.ts",
|
||||
"start:snapshot": "tsx src/daytona-with-snapshot.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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 })}`);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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`]: {} },
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
32
foundry/compose.mock.yaml
Normal 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: {}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -255,8 +238,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,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -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<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,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -360,8 +340,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,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -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<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.revert_file"), input, {
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<void
|
|||
export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
301
foundry/packages/frontend/src/components/dev-panel.tsx
Normal file
301
foundry/packages/frontend/src/components/dev-panel.tsx
Normal file
|
|
@ -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<typeof useFoundryTokens>): 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 (
|
||||
<div
|
||||
className={css({
|
||||
position: "fixed",
|
||||
bottom: "8px",
|
||||
right: "8px",
|
||||
width: "320px",
|
||||
maxHeight: "50vh",
|
||||
zIndex: 99999,
|
||||
backgroundColor: t.surfaceElevated,
|
||||
border: `1px solid ${t.borderMedium}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: t.shadow,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "4px 8px",
|
||||
borderBottom: `1px solid ${t.borderSubtle}`,
|
||||
backgroundColor: t.surfaceTertiary,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: "10px",
|
||||
fontWeight: 600,
|
||||
color: t.textSecondary,
|
||||
letterSpacing: "0.5px",
|
||||
textTransform: "uppercase",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
})}
|
||||
>
|
||||
Dev
|
||||
{isMockFrontendClient && <span className={css({ fontSize: "8px", fontWeight: 600, color: t.statusWarning, letterSpacing: "0.3px" })}>MOCK</span>}
|
||||
</span>
|
||||
<span className={css({ fontSize: "9px", color: t.textMuted })}>Shift+D</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className={css({ overflowY: "auto", padding: "6px" })}>
|
||||
{/* Interest Topics */}
|
||||
<Section label="Interest Topics" t={t} css={css}>
|
||||
{topics.map((topic) => (
|
||||
<div
|
||||
key={topic.key}
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "2px 0",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: topic.hasConnection ? t.statusSuccess : t.textMuted,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: "10px", color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
||||
{topic.label}
|
||||
</span>
|
||||
<span className={`${mono} ${css({ color: t.textMuted })}`}>{topic.key.length > 24 ? `...${topic.key.slice(-20)}` : topic.key}</span>
|
||||
<span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(topic.lastRefresh)}</span>
|
||||
</div>
|
||||
))}
|
||||
{topics.length === 0 && <span className={css({ fontSize: "10px", color: t.textMuted })}>No active subscriptions</span>}
|
||||
</Section>
|
||||
|
||||
{/* Snapshot Summary */}
|
||||
<Section label="Snapshot" t={t} css={css}>
|
||||
<div className={css({ display: "flex", gap: "10px", fontSize: "10px" })}>
|
||||
<Stat label="repos" value={repos.length} t={t} css={css} />
|
||||
<Stat label="projects" value={projects.length} t={t} css={css} />
|
||||
<Stat label="tasks" value={tasks.length} t={t} css={css} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Tasks */}
|
||||
{tasks.length > 0 && (
|
||||
<Section label="Tasks" t={t} css={css}>
|
||||
{tasks.slice(0, 10).map((task) => {
|
||||
const status = taskStatusLabel(task);
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "1px 0",
|
||||
fontSize: "10px",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: statusColor(status, t),
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span className={css({ color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
||||
{task.title || task.id.slice(0, 12)}
|
||||
</span>
|
||||
<span className={`${mono} ${css({ color: statusColor(status, t) })}`}>{status}</span>
|
||||
<span className={`${mono} ${css({ color: t.textMuted })}`}>{task.tabs?.length ?? 0} tabs</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Workspace */}
|
||||
<Section label="Workspace" t={t} css={css}>
|
||||
<div className={`${mono} ${css({ color: t.textTertiary })}`}>{workspaceId}</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function Section({
|
||||
label,
|
||||
t,
|
||||
css: cssFn,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
t: ReturnType<typeof useFoundryTokens>;
|
||||
css: ReturnType<typeof useStyletron>[0];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cssFn({ marginBottom: "6px" })}>
|
||||
<div
|
||||
className={cssFn({
|
||||
fontSize: "9px",
|
||||
fontWeight: 600,
|
||||
color: t.textMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.5px",
|
||||
marginBottom: "2px",
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
t,
|
||||
css: cssFn,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
t: ReturnType<typeof useFoundryTokens>;
|
||||
css: ReturnType<typeof useStyletron>[0];
|
||||
}) {
|
||||
return (
|
||||
<span>
|
||||
<span className={cssFn({ fontWeight: 600, color: t.textPrimary })}>{value}</span>
|
||||
<span className={cssFn({ color: t.textTertiary, marginLeft: "2px" })}>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -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
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showDevPanel && <DevPanel workspaceId={workspaceId} snapshot={viewModel} />}
|
||||
</Shell>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
16
justfile
16
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-*
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue