docs: add mcp and skill session config (#106)

This commit is contained in:
NathanFlurry 2026-02-09 10:13:25 +00:00
parent d236edf35c
commit 4c8d93e077
No known key found for this signature in database
GPG key ID: 6A5F43A4F3241BCA
95 changed files with 10014 additions and 1342 deletions

8
.gitignore vendored
View file

@ -40,5 +40,13 @@ npm-debug.log*
Cargo.lock
**/*.rs.bk
# Agent runtime directories
.agents/
.claude/
.opencode/
# Example temp files
.tmp-upload/
# CLI binaries (downloaded during npm publish)
sdks/cli/platforms/*/bin/

10
.mcp.json Normal file
View file

@ -0,0 +1,10 @@
{
"mcpServers": {
"everything": {
"args": [
"@modelcontextprotocol/server-everything"
],
"command": "npx"
}
}
}

View file

@ -47,6 +47,16 @@ Universal schema guidance:
- On parse failures, emit an `agent.unparsed` event (source=daemon, synthetic=true) and treat it as a test failure. Preserve raw payloads when `include_raw=true`.
- Track subagent support in `docs/conversion.md`. For now, normalize subagent activity into normal message/tool flow, but revisit explicit subagent modeling later.
- Keep the FAQ in `README.md` and `frontend/packages/website/src/components/FAQ.tsx` in sync. When adding or modifying FAQ entries, update both files.
- Update `research/wip-agent-support.md` as agent support changes are implemented.
### OpenAPI / utoipa requirements
Every `#[utoipa::path(...)]` handler function must have a doc comment where:
- The **first line** becomes the OpenAPI `summary` (short human-readable title, e.g. `"List Agents"`). This is used as the sidebar label and page heading in the docs site.
- The **remaining lines** become the OpenAPI `description` (one-sentence explanation of what the endpoint does).
- Every `responses(...)` entry must have a `description` (no empty descriptions).
When adding or modifying endpoints, regenerate `docs/openapi.json` and verify titles render correctly in the docs site.
### CLI ⇄ HTTP endpoint map (keep in sync)
@ -64,6 +74,14 @@ Universal schema guidance:
- `sandbox-agent api sessions reply-question``POST /v1/sessions/{sessionId}/questions/{questionId}/reply`
- `sandbox-agent api sessions reject-question``POST /v1/sessions/{sessionId}/questions/{questionId}/reject`
- `sandbox-agent api sessions reply-permission``POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply`
- `sandbox-agent api fs entries``GET /v1/fs/entries`
- `sandbox-agent api fs read``GET /v1/fs/file`
- `sandbox-agent api fs write``PUT /v1/fs/file`
- `sandbox-agent api fs delete``DELETE /v1/fs/entry`
- `sandbox-agent api fs mkdir``POST /v1/fs/mkdir`
- `sandbox-agent api fs move``POST /v1/fs/move`
- `sandbox-agent api fs stat``GET /v1/fs/stat`
- `sandbox-agent api fs upload-batch``POST /v1/fs/upload-batch`
## OpenCode Compatibility Layer

View file

@ -8,7 +8,7 @@ edition = "2021"
authors = [ "Rivet Gaming, LLC <developer@rivet.gg>" ]
license = "Apache-2.0"
repository = "https://github.com/rivet-dev/sandbox-agent"
description = "Universal API for automatic coding agents in sandboxes. Supprots Claude Code, Codex, OpenCode, and Amp."
description = "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, and Amp."
[workspace.dependencies]
# Internal crates
@ -69,6 +69,7 @@ url = "2.5"
regress = "0.10"
include_dir = "0.7"
base64 = "0.22"
toml_edit = "0.22"
# Code generation (build deps)
typify = "0.4"

278
docs/agent-sessions.mdx Normal file
View file

@ -0,0 +1,278 @@
---
title: "Agent Sessions"
description: "Create sessions and send messages to agents."
sidebarTitle: "Sessions"
icon: "comments"
---
Sessions are the unit of interaction with an agent. You create one session per task, then send messages and stream events.
## Session Options
`POST /v1/sessions/{sessionId}` accepts the following fields:
- `agent` (required): `claude`, `codex`, `opencode`, `amp`, or `mock`
- `agentMode`: agent mode string (for example, `build`, `plan`)
- `permissionMode`: permission mode string (`default`, `plan`, `bypass`, etc.)
- `model`: model override (agent-specific)
- `variant`: model variant (agent-specific)
- `agentVersion`: agent version override
- `mcp`: MCP server config map (see `MCP`)
- `skills`: skill path config (see `Skills`)
## Create A Session
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
await client.createSession("build-session", {
agent: "codex",
agentMode: "build",
permissionMode: "default",
model: "gpt-4.1",
variant: "reasoning",
agentVersion: "latest",
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"agent": "codex",
"agentMode": "build",
"permissionMode": "default",
"model": "gpt-4.1",
"variant": "reasoning",
"agentVersion": "latest"
}'
```
</CodeGroup>
## Send A Message
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
await client.postMessage("build-session", {
message: "Summarize the repository structure.",
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session/messages" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{"message":"Summarize the repository structure."}'
```
</CodeGroup>
## Stream A Turn
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
const response = await client.postMessageStream("build-session", {
message: "Explain the main entrypoints.",
});
const reader = response.body?.getReader();
if (reader) {
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(decoder.decode(value, { stream: true }));
}
}
```
```bash cURL
curl -N -X POST "http://127.0.0.1:2468/v1/sessions/build-session/messages/stream" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{"message":"Explain the main entrypoints."}'
```
</CodeGroup>
## Fetch Events
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
const events = await client.getEvents("build-session", {
offset: 0,
limit: 50,
includeRaw: false,
});
console.log(events.events);
```
```bash cURL
curl -X GET "http://127.0.0.1:2468/v1/sessions/build-session/events?offset=0&limit=50" \
-H "Authorization: Bearer $SANDBOX_TOKEN"
```
</CodeGroup>
`GET /v1/sessions/{sessionId}/get-messages` is an alias for `events`.
## Stream Events (SSE)
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
for await (const event of client.streamEvents("build-session", { offset: 0 })) {
console.log(event.type, event.data);
}
```
```bash cURL
curl -N -X GET "http://127.0.0.1:2468/v1/sessions/build-session/events/sse?offset=0" \
-H "Authorization: Bearer $SANDBOX_TOKEN"
```
</CodeGroup>
## List Sessions
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
const sessions = await client.listSessions();
console.log(sessions.sessions);
```
```bash cURL
curl -X GET "http://127.0.0.1:2468/v1/sessions" \
-H "Authorization: Bearer $SANDBOX_TOKEN"
```
</CodeGroup>
## Reply To A Question
When the agent asks a question, reply with an array of answers. Each inner array is one multi-select response.
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
await client.replyQuestion("build-session", "question-1", {
answers: [["yes"]],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session/questions/question-1/reply" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{"answers":[["yes"]]}'
```
</CodeGroup>
## Reject A Question
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
await client.rejectQuestion("build-session", "question-1");
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session/questions/question-1/reject" \
-H "Authorization: Bearer $SANDBOX_TOKEN"
```
</CodeGroup>
## Reply To A Permission Request
Use `once`, `always`, or `reject`.
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
await client.replyPermission("build-session", "permission-1", {
reply: "once",
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session/permissions/permission-1/reply" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{"reply":"once"}'
```
</CodeGroup>
## Terminate A Session
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
await client.terminateSession("build-session");
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session/terminate" \
-H "Authorization: Bearer $SANDBOX_TOKEN"
```
</CodeGroup>

87
docs/attachments.mdx Normal file
View file

@ -0,0 +1,87 @@
---
title: "Attachments"
description: "Upload files into the sandbox and attach them to prompts."
sidebarTitle: "Attachments"
icon: "paperclip"
---
Use the filesystem API to upload files, then reference them as attachments when sending prompts.
<Steps>
<Step title="Upload a file">
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
import fs from "node:fs";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
const buffer = await fs.promises.readFile("./data.csv");
const upload = await client.writeFsFile(
{ path: "./uploads/data.csv", sessionId: "my-session" },
buffer,
);
console.log(upload.path);
```
```bash cURL
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=./uploads/data.csv&sessionId=my-session" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
--data-binary @./data.csv
```
</CodeGroup>
The response returns the absolute path that you should use for attachments.
</Step>
<Step title="Attach the file in a prompt">
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
await client.postMessage("my-session", {
message: "Please analyze the attached CSV.",
attachments: [
{
path: "/home/sandbox/uploads/data.csv",
mime: "text/csv",
filename: "data.csv",
},
],
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/sessions/my-session/messages" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"message": "Please analyze the attached CSV.",
"attachments": [
{
"path": "/home/sandbox/uploads/data.csv",
"mime": "text/csv",
"filename": "data.csv"
}
]
}'
```
</CodeGroup>
</Step>
</Steps>
## Notes
- Use absolute paths from the upload response to avoid ambiguity.
- If `mime` is omitted, the server defaults to `application/octet-stream`.
- OpenCode receives file parts directly; other agents will see the attachment paths appended to the prompt.

View file

@ -2,7 +2,6 @@
title: "CLI Reference"
description: "Complete CLI reference for sandbox-agent."
sidebarTitle: "CLI"
icon: "terminal"
---
## Server
@ -250,6 +249,8 @@ sandbox-agent api sessions create <SESSION_ID> [OPTIONS]
| `-m, --model <MODEL>` | Model override |
| `-v, --variant <VARIANT>` | Model variant |
| `-A, --agent-version <VERSION>` | Agent version |
| `--mcp-config <PATH>` | JSON file with MCP server config (see `mcp` docs) |
| `--skill <PATH>` | Skill directory or `SKILL.md` path (repeatable) |
```bash
sandbox-agent api sessions create my-session \
@ -381,6 +382,132 @@ sandbox-agent api sessions reply-permission my-session perm1 --reply once
---
### Filesystem
#### List Entries
```bash
sandbox-agent api fs entries [OPTIONS]
```
| Option | Description |
|--------|-------------|
| `--path <PATH>` | Directory path (default: `.`) |
| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory |
```bash
sandbox-agent api fs entries --path ./workspace
```
#### Read File
`api fs read` writes raw bytes to stdout.
```bash
sandbox-agent api fs read <PATH> [OPTIONS]
```
| Option | Description |
|--------|-------------|
| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory |
```bash
sandbox-agent api fs read ./notes.txt > ./notes.txt
```
#### Write File
```bash
sandbox-agent api fs write <PATH> [OPTIONS]
```
| Option | Description |
|--------|-------------|
| `--content <TEXT>` | Write UTF-8 content |
| `--from-file <PATH>` | Read content from a local file |
| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory |
```bash
sandbox-agent api fs write ./hello.txt --content "hello"
sandbox-agent api fs write ./image.bin --from-file ./image.bin
```
#### Delete Entry
```bash
sandbox-agent api fs delete <PATH> [OPTIONS]
```
| Option | Description |
|--------|-------------|
| `--recursive` | Delete directories recursively |
| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory |
```bash
sandbox-agent api fs delete ./old.log
```
#### Create Directory
```bash
sandbox-agent api fs mkdir <PATH> [OPTIONS]
```
| Option | Description |
|--------|-------------|
| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory |
```bash
sandbox-agent api fs mkdir ./cache
```
#### Move/Rename
```bash
sandbox-agent api fs move <FROM> <TO> [OPTIONS]
```
| Option | Description |
|--------|-------------|
| `--overwrite` | Overwrite destination if it exists |
| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory |
```bash
sandbox-agent api fs move ./a.txt ./b.txt --overwrite
```
#### Stat
```bash
sandbox-agent api fs stat <PATH> [OPTIONS]
```
| Option | Description |
|--------|-------------|
| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory |
```bash
sandbox-agent api fs stat ./notes.txt
```
#### Upload Batch (tar)
```bash
sandbox-agent api fs upload-batch --tar <PATH> [OPTIONS]
```
| Option | Description |
|--------|-------------|
| `--tar <PATH>` | Tar archive to extract |
| `--path <PATH>` | Destination directory |
| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory |
```bash
sandbox-agent api fs upload-batch --tar ./skills.tar --path ./skills
```
---
## CLI to HTTP Mapping
| CLI Command | HTTP Endpoint |
@ -399,3 +526,11 @@ sandbox-agent api sessions reply-permission my-session perm1 --reply once
| `api sessions reply-question` | `POST /v1/sessions/{sessionId}/questions/{questionId}/reply` |
| `api sessions reject-question` | `POST /v1/sessions/{sessionId}/questions/{questionId}/reject` |
| `api sessions reply-permission` | `POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply` |
| `api fs entries` | `GET /v1/fs/entries` |
| `api fs read` | `GET /v1/fs/file` |
| `api fs write` | `PUT /v1/fs/file` |
| `api fs delete` | `DELETE /v1/fs/entry` |
| `api fs mkdir` | `POST /v1/fs/mkdir` |
| `api fs move` | `POST /v1/fs/move` |
| `api fs stat` | `GET /v1/fs/stat` |
| `api fs upload-batch` | `POST /v1/fs/upload-batch` |

245
docs/custom-tools.mdx Normal file
View file

@ -0,0 +1,245 @@
---
title: "Custom Tools"
description: "Give agents custom tools inside the sandbox using MCP servers or skills."
sidebarTitle: "Custom Tools"
icon: "wrench"
---
There are two ways to give agents custom tools that run inside the sandbox:
| | MCP Server | Skill |
|---|---|---|
| **How it works** | Sandbox Agent spawns your MCP server process and routes tool calls to it via stdio | A markdown file that instructs the agent to run your script with `node` (or any command) |
| **Tool discovery** | Agent sees tools automatically via MCP protocol | Agent reads instructions from the skill file |
| **Best for** | Structured tools with typed inputs/outputs | Lightweight scripts with natural-language instructions |
| **Requires** | `@modelcontextprotocol/sdk` dependency | Just a markdown file and a script |
Both approaches execute code inside the sandbox, so your tools have full access to the sandbox filesystem, network, and installed system tools.
## Option A: Tools via MCP
<Steps>
<Step title="Write your MCP server">
Create an MCP server that exposes tools using `@modelcontextprotocol/sdk` with `StdioServerTransport`. This server will run inside the sandbox.
```ts src/mcp-server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "rand",
version: "1.0.0",
});
server.tool(
"random_number",
"Generate a random integer between min and max (inclusive)",
{
min: z.number().describe("Minimum value"),
max: z.number().describe("Maximum value"),
},
async ({ min, max }) => ({
content: [{ type: "text", text: String(Math.floor(Math.random() * (max - min + 1)) + min) }],
}),
);
const transport = new StdioServerTransport();
await server.connect(transport);
```
This is a simple example. Your MCP server runs inside the sandbox, so you can execute any code you'd like: query databases, call internal APIs, run shell commands, or interact with any service available in the container.
</Step>
<Step title="Package the MCP server">
Bundle into a single JS file so it can be uploaded and executed without a `node_modules` folder.
```bash
npx esbuild src/mcp-server.ts --bundle --format=cjs --platform=node --target=node18 --minify --outfile=dist/mcp-server.cjs
```
This creates `dist/mcp-server.cjs` ready to upload.
</Step>
<Step title="Create sandbox and upload MCP server">
Start your sandbox, then write the bundled file into it.
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
import fs from "node:fs";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
const content = await fs.promises.readFile("./dist/mcp-server.cjs");
await client.writeFsFile(
{ path: "/opt/mcp/custom-tools/mcp-server.cjs" },
content,
);
```
```bash cURL
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=/opt/mcp/custom-tools/mcp-server.cjs" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
--data-binary @./dist/mcp-server.cjs
```
</CodeGroup>
</Step>
<Step title="Create a session">
Point an MCP server config at the bundled JS file. When the session starts, Sandbox Agent spawns the MCP server process and routes tool calls to it.
<CodeGroup>
```ts TypeScript
await client.createSession("custom-tools", {
agent: "claude",
mcp: {
customTools: {
type: "local",
command: ["node", "/opt/mcp/custom-tools/mcp-server.cjs"],
},
},
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/sessions/custom-tools" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"agent": "claude",
"mcp": {
"customTools": {
"type": "local",
"command": ["node", "/opt/mcp/custom-tools/mcp-server.cjs"]
}
}
}'
```
</CodeGroup>
</Step>
</Steps>
## Option B: Tools via Skills
Skills are markdown files that instruct the agent how to use a script. Upload the script and a skill file, then point the session at the skill directory.
<Steps>
<Step title="Write your script">
Write a script that the agent will execute. This runs inside the sandbox just like an MCP server, but the agent invokes it directly via its shell tool.
```ts src/random-number.ts
const min = Number(process.argv[2]);
const max = Number(process.argv[3]);
if (Number.isNaN(min) || Number.isNaN(max)) {
console.error("Usage: random-number <min> <max>");
process.exit(1);
}
console.log(Math.floor(Math.random() * (max - min + 1)) + min);
```
</Step>
<Step title="Write a skill file">
Create a `SKILL.md` that tells the agent what the script does and how to run it. The frontmatter `name` and `description` fields are required. See [Skill authoring best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for tips on writing effective skills.
```md SKILL.md
---
name: random-number
description: Generate a random integer between min and max (inclusive). Use when the user asks for a random number.
---
To generate a random number, run:
```bash
node /opt/skills/random-number/random-number.cjs <min> <max>
```
This prints a single random integer between min and max (inclusive).
</Step>
<Step title="Package the script">
Bundle the script just like an MCP server so it has no dependencies at runtime.
```bash
npx esbuild src/random-number.ts --bundle --format=cjs --platform=node --target=node18 --minify --outfile=dist/random-number.cjs
```
</Step>
<Step title="Create sandbox and upload files">
Upload both the bundled script and the skill file.
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
import fs from "node:fs";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
const script = await fs.promises.readFile("./dist/random-number.cjs");
await client.writeFsFile(
{ path: "/opt/skills/random-number/random-number.cjs" },
script,
);
const skill = await fs.promises.readFile("./SKILL.md");
await client.writeFsFile(
{ path: "/opt/skills/random-number/SKILL.md" },
skill,
);
```
```bash cURL
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=/opt/skills/random-number/random-number.cjs" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
--data-binary @./dist/random-number.cjs
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=/opt/skills/random-number/SKILL.md" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
--data-binary @./SKILL.md
```
</CodeGroup>
</Step>
<Step title="Create a session">
Point the session at the skill directory. The agent reads `SKILL.md` and learns how to use your script.
<CodeGroup>
```ts TypeScript
await client.createSession("custom-tools", {
agent: "claude",
skills: {
sources: [
{ type: "local", source: "/opt/skills/random-number" },
],
},
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/sessions/custom-tools" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"agent": "claude",
"skills": {
"sources": [
{ "type": "local", "source": "/opt/skills/random-number" }
]
}
}'
```
</CodeGroup>
</Step>
</Steps>
## Notes
- The sandbox image must include a Node.js runtime that can execute the bundled files.

View file

@ -1,27 +0,0 @@
---
title: "Deploy"
sidebarTitle: "Overview"
description: "Choose where to run the sandbox-agent server."
icon: "server"
---
<CardGroup cols={2}>
<Card title="Local" icon="laptop" href="/deploy/local">
Run locally for development. The SDK can auto-spawn the server.
</Card>
<Card title="E2B" icon="cube" href="/deploy/e2b">
Deploy inside an E2B sandbox with network access.
</Card>
<Card title="Vercel" icon="triangle" href="/deploy/vercel">
Deploy inside a Vercel Sandbox with port forwarding.
</Card>
<Card title="Cloudflare" icon="cloud" href="/deploy/cloudflare">
Deploy inside a Cloudflare Sandbox with port exposure.
</Card>
<Card title="Daytona" icon="cloud" href="/deploy/daytona">
Run in a Daytona workspace with port forwarding.
</Card>
<Card title="Docker" icon="docker" href="/deploy/docker">
Build and run in a container (development only).
</Card>
</CardGroup>

View file

@ -25,66 +25,97 @@
},
"navbar": {
"links": [
{
"label": "Gigacode",
"icon": "terminal",
"href": "https://github.com/rivet-dev/sandbox-agent/tree/main/gigacode"
},
{
"label": "Discord",
"icon": "discord",
"href": "https://discord.gg/auCecybynK"
},
{
"label": "GitHub",
"icon": "github",
"type": "github",
"href": "https://github.com/rivet-dev/sandbox-agent"
}
]
},
"navigation": {
"pages": [
"tabs": [
{
"group": "Getting started",
"tab": "Documentation",
"pages": [
"quickstart",
"building-chat-ui",
"manage-sessions",
"opencode-compatibility"
]
},
{
"group": "Deploy",
"pages": [
"deploy/index",
"deploy/local",
"deploy/e2b",
"deploy/daytona",
"deploy/vercel",
"deploy/cloudflare",
"deploy/docker"
]
},
{
"group": "SDKs",
"pages": ["sdks/typescript", "sdks/python"]
},
{
"group": "Reference",
"pages": [
"cli",
"inspector",
"session-transcript-schema",
"credentials",
"gigacode",
{
"group": "AI",
"pages": ["ai/skill", "ai/llms-txt"]
"group": "Getting started",
"pages": [
"quickstart",
"building-chat-ui",
"manage-sessions",
{
"group": "Deploy",
"icon": "server",
"pages": [
"deploy/local",
"deploy/e2b",
"deploy/daytona",
"deploy/vercel",
"deploy/cloudflare",
"deploy/docker"
]
}
]
},
{
"group": "Advanced",
"pages": ["daemon", "cors", "telemetry"]
"group": "SDKs",
"pages": ["sdks/typescript", "sdks/python"]
},
{
"group": "Agent Features",
"pages": [
"agent-sessions",
"attachments",
"skills",
"mcp",
"custom-tools"
]
},
{
"group": "Features",
"pages": ["file-system"]
},
{
"group": "Reference",
"pages": [
"cli",
"inspector",
"session-transcript-schema",
"opencode-compatibility",
{
"group": "More",
"pages": [
"credentials",
"daemon",
"cors",
"telemetry",
{
"group": "AI",
"pages": ["ai/skill", "ai/llms-txt"]
}
]
}
]
}
]
},
{
"group": "HTTP API Reference",
"openapi": "openapi.json"
"tab": "HTTP API",
"pages": [
{
"group": "HTTP Reference",
"openapi": "openapi.json"
}
]
}
]
}

184
docs/file-system.mdx Normal file
View file

@ -0,0 +1,184 @@
---
title: "File System"
description: "Read, write, and manage files inside the sandbox."
sidebarTitle: "File System"
icon: "folder"
---
The filesystem API lets you list, read, write, move, and delete files inside the sandbox, plus upload batches of files via tar archives.
## Path Resolution
- Absolute paths are used as-is.
- Relative paths use the session working directory when `sessionId` is provided.
- Without `sessionId`, relative paths resolve against the server home directory.
- Relative paths cannot contain `..` or absolute prefixes; requests that attempt to escape the root are rejected.
The session working directory is the server process current working directory at the moment the session is created.
## List Entries
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
const entries = await client.listFsEntries({
path: "./workspace",
sessionId: "my-session",
});
console.log(entries);
```
```bash cURL
curl -X GET "http://127.0.0.1:2468/v1/fs/entries?path=./workspace&sessionId=my-session" \
-H "Authorization: Bearer $SANDBOX_TOKEN"
```
</CodeGroup>
## Read And Write Files
`PUT /v1/fs/file` writes raw bytes. `GET /v1/fs/file` returns raw bytes.
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
await client.writeFsFile({ path: "./notes.txt", sessionId: "my-session" }, "hello");
const bytes = await client.readFsFile({
path: "./notes.txt",
sessionId: "my-session",
});
const text = new TextDecoder().decode(bytes);
console.log(text);
```
```bash cURL
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=./notes.txt&sessionId=my-session" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
--data-binary "hello"
curl -X GET "http://127.0.0.1:2468/v1/fs/file?path=./notes.txt&sessionId=my-session" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
--output ./notes.txt
```
</CodeGroup>
## Create Directories
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
await client.mkdirFs({
path: "./data",
sessionId: "my-session",
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/fs/mkdir?path=./data&sessionId=my-session" \
-H "Authorization: Bearer $SANDBOX_TOKEN"
```
</CodeGroup>
## Move, Delete, And Stat
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
await client.moveFs(
{ from: "./notes.txt", to: "./notes-old.txt", overwrite: true },
{ sessionId: "my-session" },
);
const stat = await client.statFs({
path: "./notes-old.txt",
sessionId: "my-session",
});
await client.deleteFsEntry({
path: "./notes-old.txt",
sessionId: "my-session",
});
console.log(stat);
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/fs/move?sessionId=my-session" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{"from":"./notes.txt","to":"./notes-old.txt","overwrite":true}'
curl -X GET "http://127.0.0.1:2468/v1/fs/stat?path=./notes-old.txt&sessionId=my-session" \
-H "Authorization: Bearer $SANDBOX_TOKEN"
curl -X DELETE "http://127.0.0.1:2468/v1/fs/entry?path=./notes-old.txt&sessionId=my-session" \
-H "Authorization: Bearer $SANDBOX_TOKEN"
```
</CodeGroup>
## Batch Upload (Tar)
Batch upload accepts `application/x-tar` only and extracts into the destination directory. The response returns absolute paths for extracted files, capped at 1024 entries.
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
import fs from "node:fs";
import path from "node:path";
import tar from "tar";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
const archivePath = path.join(process.cwd(), "skills.tar");
await tar.c({
cwd: "./skills",
file: archivePath,
}, ["."]);
const tarBuffer = await fs.promises.readFile(archivePath);
const result = await client.uploadFsBatch(tarBuffer, {
path: "./skills",
sessionId: "my-session",
});
console.log(result);
```
```bash cURL
tar -cf skills.tar -C ./skills .
curl -X POST "http://127.0.0.1:2468/v1/fs/upload-batch?path=./skills&sessionId=my-session" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
-H "Content-Type: application/x-tar" \
--data-binary @skills.tar
```
</CodeGroup>

View file

@ -1,7 +1,6 @@
---
title: "Inspector"
description: "Debug and inspect agent sessions with the Inspector UI."
icon: "magnifying-glass"
---
The Inspector is a web-based GUI for debugging and inspecting Sandbox Agent sessions. Use it to view events, send messages, and troubleshoot agent behavior in real-time.

122
docs/mcp.mdx Normal file
View file

@ -0,0 +1,122 @@
---
title: "MCP"
description: "Configure MCP servers for agent sessions."
sidebarTitle: "MCP"
icon: "plug"
---
MCP (Model Context Protocol) servers extend agents with tools. Sandbox Agent can auto-load MCP servers when a session starts by passing an `mcp` map in the create-session request.
## Session Config
The `mcp` field is a map of server name to config. Use `type: "local"` for stdio servers and `type: "remote"` for HTTP/SSE servers:
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
await client.createSession("claude-mcp", {
agent: "claude",
mcp: {
filesystem: {
type: "local",
command: "my-mcp-server",
args: ["--root", "."],
},
github: {
type: "remote",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer ${GITHUB_TOKEN}",
},
},
},
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/sessions/claude-mcp" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"agent": "claude",
"mcp": {
"filesystem": {
"type": "local",
"command": "my-mcp-server",
"args": ["--root", "."]
},
"github": {
"type": "remote",
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer ${GITHUB_TOKEN}"
}
}
}
}'
```
</CodeGroup>
## Config Fields
### Local Server
Stdio servers that run inside the sandbox.
| Field | Description |
|---|---|
| `type` | `local` |
| `command` | string or array (`["node", "server.js"]`) |
| `args` | array of string arguments |
| `env` | environment variables map |
| `enabled` | enable or disable the server |
| `timeoutMs` | tool timeout override |
| `cwd` | working directory for the MCP process |
```json
{
"type": "local",
"command": ["node", "./mcp/server.js"],
"args": ["--root", "."],
"env": { "LOG_LEVEL": "debug" },
"cwd": "/workspace"
}
```
### Remote Server
HTTP/SSE servers accessed over the network.
| Field | Description |
|---|---|
| `type` | `remote` |
| `url` | MCP server URL |
| `headers` | static headers map |
| `bearerTokenEnvVar` | env var name to inject into `Authorization: Bearer ...` |
| `envHeaders` | map of header name to env var name |
| `oauth` | object with `clientId`, `clientSecret`, `scope`, or `false` to disable |
| `enabled` | enable or disable the server |
| `timeoutMs` | tool timeout override |
| `transport` | `http` or `sse` |
```json
{
"type": "remote",
"url": "https://example.com/mcp",
"headers": { "x-client": "sandbox-agent" },
"bearerTokenEnvVar": "MCP_TOKEN",
"transport": "sse"
}
```
## Custom MCP Servers
To bundle and upload your own MCP server into the sandbox, see [Custom Tools](/custom-tools).

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,6 @@
---
title: "OpenCode SDK & UI Support"
title: "OpenCode Compatibility"
description: "Connect OpenCode clients, SDKs, and web UI to Sandbox Agent."
icon: "rectangle-terminal"
---
<Warning>

View file

@ -1,7 +1,6 @@
---
title: "Session Transcript Schema"
description: "Universal event schema for session transcripts across all agents."
icon: "brackets-curly"
---
Each coding agent outputs events in its own native format. The sandbox-agent converts these into a universal event schema, giving you a consistent session transcript regardless of which agent you use.
@ -27,7 +26,7 @@ This table shows which agent feature coverage appears in the universal event str
| Reasoning/Thinking | - | ✓ | - | - |
| Command Execution | - | ✓ | - | - |
| File Changes | - | ✓ | - | - |
| MCP Tools | - | ✓ | - | - |
| MCP Tools | ✓ | ✓ | ✓ | ✓ |
| Streaming Deltas | ✓ | ✓ | ✓ | - |
| Variants | | ✓ | ✓ | ✓ |

87
docs/skills.mdx Normal file
View file

@ -0,0 +1,87 @@
---
title: "Skills"
description: "Auto-load skills into agent sessions."
sidebarTitle: "Skills"
icon: "sparkles"
---
Skills are local instruction bundles stored in `SKILL.md` files. Sandbox Agent can fetch, discover, and link skill directories into agent-specific skill paths at session start using the `skills.sources` field. The format is fully compatible with [skills.sh](https://skills.sh).
## Session Config
Pass `skills.sources` when creating a session to load skills from GitHub repos, local paths, or git URLs.
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const client = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
token: process.env.SANDBOX_TOKEN,
});
await client.createSession("claude-skills", {
agent: "claude",
skills: {
sources: [
{ type: "github", source: "rivet-dev/skills", skills: ["sandbox-agent"] },
{ type: "local", source: "/workspace/my-custom-skill" },
],
},
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/sessions/claude-skills" \
-H "Authorization: Bearer $SANDBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"agent": "claude",
"skills": {
"sources": [
{ "type": "github", "source": "rivet-dev/skills", "skills": ["sandbox-agent"] },
{ "type": "local", "source": "/workspace/my-custom-skill" }
]
}
}'
```
</CodeGroup>
Each skill directory must contain `SKILL.md`. See [Skill authoring best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for tips on writing effective skills.
## Skill Sources
Each entry in `skills.sources` describes where to find skills. Three source types are supported:
| Type | `source` value | Example |
|------|---------------|---------|
| `github` | `owner/repo` | `"rivet-dev/skills"` |
| `local` | Filesystem path | `"/workspace/my-skill"` |
| `git` | Git clone URL | `"https://git.example.com/skills.git"` |
### Optional fields
- **`skills`** — Array of skill directory names to include. When omitted, all discovered skills are installed.
- **`ref`** — Branch, tag, or commit to check out (default: HEAD). Applies to `github` and `git` types.
- **`subpath`** — Subdirectory within the repo to search for skills.
## Custom Skills
To write, upload, and configure your own skills inside the sandbox, see [Custom Tools](/custom-tools).
## Advanced
### Discovery logic
After resolving a source to a local directory (cloning if needed), Sandbox Agent discovers skills by:
1. Checking if the directory itself contains `SKILL.md`.
2. Scanning `skills/` subdirectory for child directories containing `SKILL.md`.
3. Scanning immediate children of the directory for `SKILL.md`.
Discovered skills are symlinked into project-local skill roots (`.claude/skills/<name>`, `.agents/skills/<name>`, `.opencode/skill/<name>`).
### Caching
GitHub sources are downloaded as zip archives and git sources are cloned to `~/.sandbox-agent/skills-cache/` and updated on subsequent session creations. GitHub sources do not require `git` to be installed.

17
examples/CLAUDE.md Normal file
View file

@ -0,0 +1,17 @@
# Examples Instructions
## Docker Isolation
- Docker examples must behave like standalone sandboxes.
- Do not bind mount host files or host directories into Docker example containers.
- If an example needs tools, skills, or MCP servers, install them inside the container during setup.
## Testing Examples
Examples can be tested by starting them in the background and communicating directly with the sandbox-agent API:
1. Start the example: `SANDBOX_AGENT_DEV=1 pnpm start &`
2. Note the base URL and session ID from the output.
3. Send messages: `curl -X POST http://127.0.0.1:<port>/v1/sessions/<sessionId>/messages -H "Content-Type: application/json" -d '{"message":"..."}'`
4. Poll events: `curl http://127.0.0.1:<port>/v1/sessions/<sessionId>/events`
5. Approve permissions: `curl -X POST http://127.0.0.1:<port>/v1/sessions/<sessionId>/permissions/<permissionId>/reply -H "Content-Type: application/json" -d '{"reply":"once"}'`

View file

@ -1,7 +1,7 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "sandbox-agent-cloudflare",
"main": "src/cloudflare.ts",
"main": "src/index.ts",
"compatibility_date": "2025-01-01",
"compatibility_flags": ["nodejs_compat"],
"assets": {

View file

@ -3,13 +3,14 @@
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/daytona.ts",
"start": "tsx src/index.ts",
"start:snapshot": "tsx src/daytona-with-snapshot.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@daytonaio/sdk": "latest",
"@sandbox-agent/example-shared": "workspace:*"
"@sandbox-agent/example-shared": "workspace:*",
"sandbox-agent": "workspace:*"
},
"devDependencies": {
"@types/node": "latest",

View file

@ -1,5 +1,6 @@
import { Daytona, Image } from "@daytonaio/sdk";
import { runPrompt } from "@sandbox-agent/example-shared";
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared";
const daytona = new Daytona();
@ -24,12 +25,21 @@ await sandbox.process.executeCommand(
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
console.log("Waiting for server...");
await waitForHealth({ baseUrl });
const client = await SandboxAgent.connect({ baseUrl });
const sessionId = generateSessionId();
await client.createSession(sessionId, { agent: detectAgent() });
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);
await runPrompt(baseUrl);
await cleanup();

View file

@ -1,5 +1,6 @@
import { Daytona } from "@daytonaio/sdk";
import { runPrompt } from "@sandbox-agent/example-shared";
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared";
const daytona = new Daytona();
@ -25,12 +26,21 @@ await sandbox.process.executeCommand(
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
console.log("Waiting for server...");
await waitForHealth({ baseUrl });
const client = await SandboxAgent.connect({ baseUrl });
const sessionId = generateSessionId();
await client.createSession(sessionId, { agent: detectAgent() });
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);
await runPrompt(baseUrl);
await cleanup();

View file

@ -3,12 +3,13 @@
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/docker.ts",
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@sandbox-agent/example-shared": "workspace:*",
"dockerode": "latest"
"dockerode": "latest",
"sandbox-agent": "workspace:*"
},
"devDependencies": {
"@types/dockerode": "latest",

View file

@ -1,5 +1,6 @@
import Docker from "dockerode";
import { runPrompt, waitForHealth } from "@sandbox-agent/example-shared";
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared";
const IMAGE = "alpine:latest";
const PORT = 3000;
@ -44,13 +45,19 @@ await container.start();
const baseUrl = `http://127.0.0.1:${PORT}`;
await waitForHealth({ baseUrl });
const client = await SandboxAgent.connect({ baseUrl });
const sessionId = generateSessionId();
await client.createSession(sessionId, { agent: detectAgent() });
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000);
const cleanup = async () => {
clearInterval(keepAlive);
try { await container.stop({ t: 5 }); } catch {}
try { await container.remove({ force: true }); } catch {}
process.exit(0);
};
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);
await runPrompt(baseUrl);
await cleanup();

View file

@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/e2b.ts",
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {

View file

@ -1,5 +1,6 @@
import { Sandbox } from "@e2b/code-interpreter";
import { runPrompt, waitForHealth } from "@sandbox-agent/example-shared";
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared";
const envs: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
@ -29,12 +30,18 @@ const baseUrl = `https://${sandbox.getHost(3000)}`;
console.log("Waiting for server...");
await waitForHealth({ baseUrl });
const client = await SandboxAgent.connect({ baseUrl });
const sessionId = generateSessionId();
await client.createSession(sessionId, { agent: detectAgent() });
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.kill();
process.exit(0);
};
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);
await runPrompt(baseUrl);
await cleanup();

View file

@ -0,0 +1,19 @@
{
"name": "@sandbox-agent/example-file-system",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@sandbox-agent/example-shared": "workspace:*",
"sandbox-agent": "workspace:*",
"tar": "^7"
},
"devDependencies": {
"@types/node": "latest",
"tsx": "latest",
"typescript": "latest"
}
}

View file

@ -0,0 +1,57 @@
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared";
import { startDockerSandbox } from "@sandbox-agent/example-shared/docker";
import * as tar from "tar";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
console.log("Starting sandbox...");
const { baseUrl, cleanup } = await startDockerSandbox({ port: 3003 });
console.log("Creating sample files...");
const tmpDir = path.resolve(__dirname, "../.tmp-upload");
const projectDir = path.join(tmpDir, "my-project");
fs.mkdirSync(path.join(projectDir, "src"), { recursive: true });
fs.writeFileSync(path.join(projectDir, "README.md"), "# My Project\n\nUploaded via batch tar.\n");
fs.writeFileSync(path.join(projectDir, "src", "index.ts"), 'console.log("hello from uploaded project");\n');
fs.writeFileSync(path.join(projectDir, "package.json"), JSON.stringify({ name: "my-project", version: "1.0.0" }, null, 2) + "\n");
console.log(" Created 3 files in my-project/");
console.log("Uploading files via batch tar...");
const client = await SandboxAgent.connect({ baseUrl });
const tarPath = path.join(tmpDir, "upload.tar");
await tar.create(
{ file: tarPath, cwd: tmpDir },
["my-project"],
);
const tarBuffer = await fs.promises.readFile(tarPath);
const uploadResult = await client.uploadFsBatch(tarBuffer, { path: "/opt" });
console.log(` Uploaded ${uploadResult.paths.length} files: ${uploadResult.paths.join(", ")}`);
// Cleanup temp files
fs.rmSync(tmpDir, { recursive: true, force: true });
console.log("Verifying uploaded files...");
const entries = await client.listFsEntries({ path: "/opt/my-project" });
console.log(` Found ${entries.length} entries in /opt/my-project`);
for (const entry of entries) {
console.log(` ${entry.entryType === "directory" ? "d" : "-"} ${entry.name}`);
}
const readmeBytes = await client.readFsFile({ path: "/opt/my-project/README.md" });
const readmeText = new TextDecoder().decode(readmeBytes);
console.log(` README.md content: ${readmeText.trim()}`);
console.log("Creating session...");
const sessionId = generateSessionId();
await client.createSession(sessionId, { agent: detectAgent() });
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(' Try: "read the README in /opt/my-project"');
console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000);
process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); });

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"]
}

View file

@ -0,0 +1,22 @@
{
"name": "@sandbox-agent/example-mcp-custom-tool",
"private": true,
"type": "module",
"scripts": {
"build:mcp": "esbuild src/mcp-server.ts --bundle --format=cjs --platform=node --target=node18 --minify --outfile=dist/mcp-server.cjs",
"start": "pnpm build:mcp && tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@modelcontextprotocol/sdk": "latest",
"@sandbox-agent/example-shared": "workspace:*",
"sandbox-agent": "workspace:*",
"zod": "latest"
},
"devDependencies": {
"@types/node": "latest",
"esbuild": "latest",
"tsx": "latest",
"typescript": "latest"
}
}

View file

@ -0,0 +1,49 @@
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared";
import { startDockerSandbox } from "@sandbox-agent/example-shared/docker";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Verify the bundled MCP server exists (built by `pnpm build:mcp`).
const serverFile = path.resolve(__dirname, "../dist/mcp-server.cjs");
if (!fs.existsSync(serverFile)) {
console.error("Error: dist/mcp-server.cjs not found. Run `pnpm build:mcp` first.");
process.exit(1);
}
// Start a Docker container running sandbox-agent.
console.log("Starting sandbox...");
const { baseUrl, cleanup } = await startDockerSandbox({ port: 3004 });
// Upload the bundled MCP server into the sandbox filesystem.
console.log("Uploading MCP server bundle...");
const client = await SandboxAgent.connect({ baseUrl });
const bundle = await fs.promises.readFile(serverFile);
const written = await client.writeFsFile(
{ path: "/opt/mcp/custom-tools/mcp-server.cjs" },
bundle,
);
console.log(` Written: ${written.path} (${written.bytesWritten} bytes)`);
// Create a session with the uploaded MCP server as a local command.
console.log("Creating session with custom MCP tool...");
const sessionId = generateSessionId();
await client.createSession(sessionId, {
agent: detectAgent(),
mcp: {
customTools: {
type: "local",
command: ["node", "/opt/mcp/custom-tools/mcp-server.cjs"],
},
},
});
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(' Try: "generate a random number between 1 and 100"');
console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000);
process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); });

View file

@ -0,0 +1,24 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
async function main() {
const server = new McpServer({ name: "rand", version: "1.0.0" });
server.tool(
"random_number",
"Generate a random integer between min and max (inclusive)",
{
min: z.number().describe("Minimum value"),
max: z.number().describe("Maximum value"),
},
async ({ min, max }) => ({
content: [{ type: "text", text: String(Math.floor(Math.random() * (max - min + 1)) + min) }],
}),
);
const transport = new StdioServerTransport();
await server.connect(transport);
}
main();

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"]
}

18
examples/mcp/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "@sandbox-agent/example-mcp",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@sandbox-agent/example-shared": "workspace:*",
"sandbox-agent": "workspace:*"
},
"devDependencies": {
"@types/node": "latest",
"tsx": "latest",
"typescript": "latest"
}
}

31
examples/mcp/src/index.ts Normal file
View file

@ -0,0 +1,31 @@
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared";
import { startDockerSandbox } from "@sandbox-agent/example-shared/docker";
console.log("Starting sandbox...");
const { baseUrl, cleanup } = await startDockerSandbox({
port: 3002,
setupCommands: [
"npm install -g --silent @modelcontextprotocol/server-everything@2026.1.26",
],
});
console.log("Creating session with everything MCP server...");
const client = await SandboxAgent.connect({ baseUrl });
const sessionId = generateSessionId();
await client.createSession(sessionId, {
agent: detectAgent(),
mcp: {
everything: {
type: "local",
command: ["mcp-server-everything"],
timeoutMs: 10000,
},
},
});
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(' Try: "generate a random number between 1 and 100"');
console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000);
process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); });

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"]
}

View file

@ -0,0 +1,5 @@
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

@ -0,0 +1,58 @@
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 -> cli-shared
COPY sdks/typescript/ sdks/typescript/
COPY sdks/cli-shared/ sdks/cli-shared/
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 \
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 --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

@ -3,15 +3,18 @@
"private": true,
"type": "module",
"exports": {
".": "./src/sandbox-agent-client.ts"
".": "./src/sandbox-agent-client.ts",
"./docker": "./src/docker.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"dockerode": "latest",
"sandbox-agent": "workspace:*"
},
"devDependencies": {
"@types/dockerode": "latest",
"@types/node": "latest",
"typescript": "latest"
}

View file

@ -0,0 +1,301 @@
import Docker from "dockerode";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { PassThrough } from "node:stream";
import { fileURLToPath } from "node:url";
import { waitForHealth } from "./sandbox-agent-client.ts";
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, "../..");
export interface DockerSandboxOptions {
/** Container port used by sandbox-agent inside Docker. */
port: number;
/** Optional fixed host port mapping. If omitted, Docker assigns a free host port automatically. */
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. */
image?: string;
}
export interface DockerSandbox {
baseUrl: string;
cleanup: () => Promise<void>;
}
const DIRECT_CREDENTIAL_KEYS = [
"ANTHROPIC_API_KEY",
"CLAUDE_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_AUTH_TOKEN",
"OPENAI_API_KEY",
"CODEX_API_KEY",
"CEREBRAS_API_KEY",
"OPENCODE_API_KEY",
] as const;
function stripShellQuotes(value: string): string {
const trimmed = value.trim();
if (trimmed.length >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
return trimmed.slice(1, -1);
}
if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function parseExtractedCredentials(output: string): Record<string, string> {
const parsed: Record<string, string> = {};
for (const rawLine of output.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
const cleanLine = line.startsWith("export ") ? line.slice(7) : line;
const match = cleanLine.match(/^([A-Z0-9_]+)=(.*)$/);
if (!match) continue;
const [, key, rawValue] = match;
const value = stripShellQuotes(rawValue);
if (!value) continue;
parsed[key] = value;
}
return parsed;
}
interface ClaudeCredentialFile {
hostPath: string;
containerPath: string;
base64Content: string;
}
function readClaudeCredentialFiles(): ClaudeCredentialFile[] {
const homeDir = process.env.HOME || "";
if (!homeDir) return [];
const candidates: Array<{ hostPath: string; containerPath: string }> = [
{
hostPath: path.join(homeDir, ".claude", ".credentials.json"),
containerPath: "/root/.claude/.credentials.json",
},
{
hostPath: path.join(homeDir, ".claude-oauth-credentials.json"),
containerPath: "/root/.claude-oauth-credentials.json",
},
];
const files: ClaudeCredentialFile[] = [];
for (const candidate of candidates) {
if (!fs.existsSync(candidate.hostPath)) continue;
try {
const raw = fs.readFileSync(candidate.hostPath, "utf8");
files.push({
hostPath: candidate.hostPath,
containerPath: candidate.containerPath,
base64Content: Buffer.from(raw, "utf8").toString("base64"),
});
} catch {
// Ignore unreadable credential file candidates.
}
}
return files;
}
function collectCredentialEnv(): Record<string, string> {
const merged: Record<string, string> = {};
let extracted: Record<string, string> = {};
try {
const output = execFileSync(
"sandbox-agent",
["credentials", "extract-env"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
extracted = parseExtractedCredentials(output);
} catch {
// Fall back to direct env vars if extraction is unavailable.
}
for (const [key, value] of Object.entries(extracted)) {
if (value) merged[key] = value;
}
for (const key of DIRECT_CREDENTIAL_KEYS) {
const direct = process.env[key];
if (direct) merged[key] = direct;
}
return merged;
}
function shellSingleQuotedLiteral(value: string): string {
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
}
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)...");
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], {
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 imageName;
}
/**
* Start a Docker container running sandbox-agent and wait for it to be healthy.
* Registers SIGINT/SIGTERM handlers for cleanup.
*/
export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<DockerSandbox> {
const { port, hostPort } = opts;
const useCustomImage = !!opts.image;
let image = opts.image ?? EXAMPLE_IMAGE;
// TODO: Replace setupCommands shell bootstrapping with native sandbox-agent exec API once available.
const setupCommands = [...(opts.setupCommands ?? [])];
const credentialEnv = collectCredentialEnv();
const claudeCredentialFiles = readClaudeCredentialFiles();
const bootstrapEnv: Record<string, string> = {};
if (claudeCredentialFiles.length > 0) {
delete credentialEnv.ANTHROPIC_API_KEY;
delete credentialEnv.CLAUDE_API_KEY;
delete credentialEnv.CLAUDE_CODE_OAUTH_TOKEN;
delete credentialEnv.ANTHROPIC_AUTH_TOKEN;
const credentialBootstrapCommands = claudeCredentialFiles.flatMap((file, index) => {
const envKey = `SANDBOX_AGENT_CLAUDE_CREDENTIAL_${index}_B64`;
bootstrapEnv[envKey] = file.base64Content;
return [
`mkdir -p ${shellSingleQuotedLiteral(path.posix.dirname(file.containerPath))}`,
`printf %s "$${envKey}" | base64 -d > ${shellSingleQuotedLiteral(file.containerPath)}`,
];
});
setupCommands.unshift(...credentialBootstrapCommands);
}
for (const [key, value] of Object.entries(credentialEnv)) {
if (!process.env[key]) process.env[key] = value;
}
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);
}
const bootCommands = [
...setupCommands,
`sandbox-agent server --no-token --host 0.0.0.0 --port ${port}`,
];
const container = await docker.createContainer({
Image: image,
WorkingDir: "/root",
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`]: {} },
HostConfig: {
AutoRemove: true,
PortBindings: { [`${port}/tcp`]: [{ HostPort: hostPort ? `${hostPort}` : "0" }] },
},
});
await container.start();
const logChunks: string[] = [];
const startupLogs = await container.logs({
follow: true,
stdout: true,
stderr: true,
since: 0,
}) as NodeJS.ReadableStream;
const stdoutStream = new PassThrough();
const stderrStream = new PassThrough();
stdoutStream.on("data", (chunk) => {
logChunks.push(stripAnsi(String(chunk)));
});
stderrStream.on("data", (chunk) => {
logChunks.push(stripAnsi(String(chunk)));
});
docker.modem.demuxStream(startupLogs, stdoutStream, stderrStream);
const stopStartupLogs = () => {
const stream = startupLogs as NodeJS.ReadableStream & { destroy?: () => void };
try { stream.destroy?.(); } catch {}
};
const inspect = await container.inspect();
const mappedPorts = inspect.NetworkSettings?.Ports?.[`${port}/tcp`];
const mappedHostPort = mappedPorts?.[0]?.HostPort;
if (!mappedHostPort) {
throw new Error(`Failed to resolve mapped host port for container port ${port}`);
}
const baseUrl = `http://127.0.0.1:${mappedHostPort}`;
try {
await waitForHealth({ baseUrl });
} catch (err) {
stopStartupLogs();
console.error(" Container logs:");
for (const chunk of logChunks) {
process.stderr.write(` ${chunk}`);
}
throw err;
}
stopStartupLogs();
console.log(` Ready (${baseUrl})`);
const cleanup = async () => {
stopStartupLogs();
try { await container.stop({ t: 5 }); } catch {}
try { await container.remove({ force: true }); } catch {}
process.exit(0);
};
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);
return { baseUrl, cleanup };
}

View file

@ -3,11 +3,7 @@
* Provides minimal helpers for connecting to and interacting with sandbox-agent servers.
*/
import { createInterface } from "node:readline/promises";
import { randomUUID } from "node:crypto";
import { setTimeout as delay } from "node:timers/promises";
import { SandboxAgent } from "sandbox-agent";
import type { PermissionEventData, QuestionEventData } from "sandbox-agent";
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/+$/, "");
@ -27,10 +23,12 @@ export function buildInspectorUrl({
baseUrl,
token,
headers,
sessionId,
}: {
baseUrl: string;
token?: string;
headers?: Record<string, string>;
sessionId?: string;
}): string {
const normalized = normalizeBaseUrl(ensureUrl(baseUrl));
const params = new URLSearchParams();
@ -41,7 +39,8 @@ export function buildInspectorUrl({
params.set("headers", JSON.stringify(headers));
}
const queryString = params.toString();
return `${normalized}/ui/${queryString ? `?${queryString}` : ""}`;
const sessionPath = sessionId ? `sessions/${sessionId}` : "";
return `${normalized}/ui/${sessionPath}${queryString ? `?${queryString}` : ""}`;
}
export function logInspectorUrl({
@ -110,125 +109,39 @@ export async function waitForHealth({
throw (lastError ?? new Error("Timed out waiting for /v1/health")) as Error;
}
function detectAgent(): string {
export function generateSessionId(): string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let id = "session-";
for (let i = 0; i < 8; i++) {
id += chars[Math.floor(Math.random() * chars.length)];
}
return id;
}
export function detectAgent(): string {
if (process.env.SANDBOX_AGENT) return process.env.SANDBOX_AGENT;
if (process.env.ANTHROPIC_API_KEY) return "claude";
if (process.env.OPENAI_API_KEY) return "codex";
const hasClaude = Boolean(
process.env.ANTHROPIC_API_KEY ||
process.env.CLAUDE_API_KEY ||
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
process.env.ANTHROPIC_AUTH_TOKEN,
);
const openAiLikeKey = process.env.OPENAI_API_KEY || process.env.CODEX_API_KEY || "";
const hasCodexApiKey = openAiLikeKey.startsWith("sk-");
if (hasCodexApiKey && hasClaude) {
console.log("Both Claude and Codex API keys detected; defaulting to codex. Set SANDBOX_AGENT to override.");
return "codex";
}
if (!hasCodexApiKey && openAiLikeKey) {
console.log("OpenAI/Codex credential is not an API key (expected sk-...), skipping codex auto-select.");
}
if (hasCodexApiKey) return "codex";
if (hasClaude) {
if (openAiLikeKey && !hasCodexApiKey) {
console.log("Using claude by default.");
}
return "claude";
}
return "claude";
}
export async function runPrompt(baseUrl: string): Promise<void> {
console.log(`UI: ${buildInspectorUrl({ baseUrl })}`);
const client = await SandboxAgent.connect({ baseUrl });
const agent = detectAgent();
console.log(`Using agent: ${agent}`);
const sessionId = randomUUID();
await client.createSession(sessionId, { agent });
console.log(`Session ${sessionId}. Press Ctrl+C to quit.`);
const rl = createInterface({ input: process.stdin, output: process.stdout });
let isThinking = false;
let hasStartedOutput = false;
let turnResolve: (() => void) | null = null;
let sessionEnded = false;
const processEvents = async () => {
for await (const event of client.streamEvents(sessionId)) {
if (event.type === "item.started") {
const item = (event.data as any)?.item;
if (item?.role === "assistant") {
isThinking = true;
hasStartedOutput = false;
process.stdout.write("Thinking...");
}
}
if (event.type === "item.delta" && isThinking) {
const delta = (event.data as any)?.delta;
if (delta) {
if (!hasStartedOutput) {
process.stdout.write("\r\x1b[K");
hasStartedOutput = true;
}
const text = typeof delta === "string" ? delta : delta.type === "text" ? delta.text || "" : "";
if (text) process.stdout.write(text);
}
}
if (event.type === "item.completed") {
const item = (event.data as any)?.item;
if (item?.role === "assistant") {
isThinking = false;
process.stdout.write("\n");
turnResolve?.();
turnResolve = null;
}
}
if (event.type === "permission.requested") {
const data = event.data as PermissionEventData;
if (isThinking && !hasStartedOutput) {
process.stdout.write("\r\x1b[K");
}
console.log(`[Auto-approved] ${data.action}`);
await client.replyPermission(sessionId, data.permission_id, { reply: "once" });
}
if (event.type === "question.requested") {
const data = event.data as QuestionEventData;
if (isThinking && !hasStartedOutput) {
process.stdout.write("\r\x1b[K");
}
console.log(`[Question rejected] ${data.prompt}`);
await client.rejectQuestion(sessionId, data.question_id);
}
if (event.type === "error") {
const data = event.data as any;
console.error(`\nError: ${data?.message || JSON.stringify(data)}`);
}
if (event.type === "session.ended") {
const data = event.data as any;
const reason = data?.reason || "unknown";
if (reason === "error") {
console.error(`\nAgent exited with error: ${data?.message || ""}`);
if (data?.exit_code !== undefined) {
console.error(` Exit code: ${data.exit_code}`);
}
} else {
console.log(`Agent session ${reason}`);
}
sessionEnded = true;
turnResolve?.();
turnResolve = null;
}
}
};
processEvents().catch((err) => {
if (!sessionEnded) {
console.error("Event stream error:", err instanceof Error ? err.message : err);
}
});
while (true) {
const line = await rl.question("> ");
if (!line.trim()) continue;
const turnComplete = new Promise<void>((resolve) => {
turnResolve = resolve;
});
try {
await client.postMessage(sessionId, { message: line.trim() });
await turnComplete;
} catch (error) {
console.error(error instanceof Error ? error.message : error);
turnResolve = null;
}
}
}

View file

@ -0,0 +1,12 @@
---
name: random-number
description: Generate a random integer between min and max (inclusive). Use when the user asks for a random number.
---
To generate a random number, run:
```bash
node /opt/skills/random-number/random-number.cjs <min> <max>
```
This prints a single random integer between min and max (inclusive).

View file

@ -0,0 +1,20 @@
{
"name": "@sandbox-agent/example-skills-custom-tool",
"private": true,
"type": "module",
"scripts": {
"build:script": "esbuild src/random-number.ts --bundle --format=cjs --platform=node --target=node18 --minify --outfile=dist/random-number.cjs",
"start": "pnpm build:script && tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@sandbox-agent/example-shared": "workspace:*",
"sandbox-agent": "workspace:*"
},
"devDependencies": {
"@types/node": "latest",
"esbuild": "latest",
"tsx": "latest",
"typescript": "latest"
}
}

View file

@ -0,0 +1,53 @@
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared";
import { startDockerSandbox } from "@sandbox-agent/example-shared/docker";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Verify the bundled script exists (built by `pnpm build:script`).
const scriptFile = path.resolve(__dirname, "../dist/random-number.cjs");
if (!fs.existsSync(scriptFile)) {
console.error("Error: dist/random-number.cjs not found. Run `pnpm build:script` first.");
process.exit(1);
}
// Start a Docker container running sandbox-agent.
console.log("Starting sandbox...");
const { baseUrl, cleanup } = await startDockerSandbox({ port: 3005 });
// Upload the bundled script and SKILL.md into the sandbox filesystem.
console.log("Uploading script and skill file...");
const client = await SandboxAgent.connect({ baseUrl });
const script = await fs.promises.readFile(scriptFile);
const scriptResult = await client.writeFsFile(
{ path: "/opt/skills/random-number/random-number.cjs" },
script,
);
console.log(` Script: ${scriptResult.path} (${scriptResult.bytesWritten} bytes)`);
const skillMd = await fs.promises.readFile(path.resolve(__dirname, "../SKILL.md"));
const skillResult = await client.writeFsFile(
{ path: "/opt/skills/random-number/SKILL.md" },
skillMd,
);
console.log(` Skill: ${skillResult.path} (${skillResult.bytesWritten} bytes)`);
// Create a session with the uploaded skill as a local source.
console.log("Creating session with custom skill...");
const sessionId = generateSessionId();
await client.createSession(sessionId, {
agent: detectAgent(),
skills: {
sources: [{ type: "local", source: "/opt/skills/random-number" }],
},
});
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(' Try: "generate a random number between 1 and 100"');
console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000);
process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); });

View file

@ -0,0 +1,9 @@
const min = Number(process.argv[2]);
const max = Number(process.argv[3]);
if (Number.isNaN(min) || Number.isNaN(max)) {
console.error("Usage: random-number <min> <max>");
process.exit(1);
}
console.log(Math.floor(Math.random() * (max - min + 1)) + min);

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"]
}

View file

@ -0,0 +1,18 @@
{
"name": "@sandbox-agent/example-skills",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@sandbox-agent/example-shared": "workspace:*",
"sandbox-agent": "workspace:*"
},
"devDependencies": {
"@types/node": "latest",
"tsx": "latest",
"typescript": "latest"
}
}

View file

@ -0,0 +1,26 @@
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared";
import { startDockerSandbox } from "@sandbox-agent/example-shared/docker";
console.log("Starting sandbox...");
const { baseUrl, cleanup } = await startDockerSandbox({
port: 3001,
});
console.log("Creating session with skill source...");
const client = await SandboxAgent.connect({ baseUrl });
const sessionId = generateSessionId();
await client.createSession(sessionId, {
agent: detectAgent(),
skills: {
sources: [
{ type: "github", source: "rivet-dev/skills", skills: ["sandbox-agent"] },
],
},
});
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(' Try: "How do I start sandbox-agent?"');
console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000);
process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); });

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"]
}

View file

@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/vercel.ts",
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {

View file

@ -1,5 +1,6 @@
import { Sandbox } from "@vercel/sandbox";
import { runPrompt, waitForHealth } from "@sandbox-agent/example-shared";
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared";
const envs: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
@ -40,12 +41,18 @@ const baseUrl = sandbox.domain(3000);
console.log("Waiting for server...");
await waitForHealth({ baseUrl });
const client = await SandboxAgent.connect({ baseUrl });
const sessionId = generateSessionId();
await client.createSession(sessionId, { agent: detectAgent() });
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.stop();
process.exit(0);
};
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);
await runPrompt(baseUrl);
await cleanup();

View file

@ -336,6 +336,12 @@
color: var(--danger);
}
.banner.config-note {
background: rgba(255, 159, 10, 0.12);
border-left: 3px solid var(--warning);
color: var(--warning);
}
.banner.success {
background: rgba(48, 209, 88, 0.1);
border-left: 3px solid var(--success);
@ -471,11 +477,12 @@
position: relative;
}
.sidebar-add-menu {
.sidebar-add-menu,
.session-create-menu {
position: absolute;
top: 36px;
left: 0;
min-width: 200px;
min-width: 220px;
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: 8px;
@ -487,6 +494,405 @@
z-index: 60;
}
.session-create-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 6px 4px;
margin-bottom: 4px;
}
.session-create-back {
width: 24px;
height: 24px;
background: transparent;
border: 1px solid var(--border-2);
border-radius: 4px;
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition);
flex-shrink: 0;
}
.session-create-back:hover {
border-color: var(--accent);
color: var(--accent);
}
.session-create-agent-name {
font-size: 12px;
font-weight: 600;
color: var(--text);
}
.session-create-form {
display: flex;
flex-direction: column;
gap: 0;
padding: 4px 2px;
}
.session-create-form .setup-field {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
height: 28px;
}
.session-create-form .setup-label {
width: 72px;
flex-shrink: 0;
text-align: right;
}
.session-create-form .setup-select,
.session-create-form .setup-input {
flex: 1;
min-width: 0;
}
.session-create-section {
overflow: hidden;
}
.session-create-section-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 28px;
padding: 0;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 11px;
cursor: pointer;
transition: color var(--transition);
}
.session-create-section-toggle:hover {
color: var(--text);
}
.session-create-section-toggle .setup-label {
width: 72px;
flex-shrink: 0;
text-align: right;
}
.session-create-section-count {
font-size: 11px;
font-weight: 400;
color: var(--muted);
}
.session-create-section-arrow {
margin-left: auto;
color: var(--muted-2);
flex-shrink: 0;
}
.session-create-section-body {
margin: 4px 0 6px;
padding: 8px;
border: 1px solid var(--border-2);
border-radius: 4px;
background: var(--surface-2);
}
.session-create-textarea {
width: 100%;
background: var(--surface-2);
border: 1px solid var(--border-2);
border-radius: 4px;
padding: 6px 8px;
font-size: 10px;
color: var(--text);
outline: none;
resize: vertical;
min-height: 60px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace;
transition: border-color var(--transition);
}
.session-create-textarea:focus {
border-color: var(--accent);
}
.session-create-textarea::placeholder {
color: var(--muted-2);
}
.session-create-inline-error {
font-size: 10px;
color: var(--danger);
margin-top: 4px;
line-height: 1.4;
}
.session-create-skill-list {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 4px;
}
.session-create-skill-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 4px 3px 8px;
background: var(--surface-2);
border: 1px solid var(--border-2);
border-radius: 4px;
}
.session-create-skill-path {
flex: 1;
min-width: 0;
font-size: 10px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-create-skill-remove {
width: 18px;
height: 18px;
background: transparent;
border: none;
border-radius: 3px;
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all var(--transition);
}
.session-create-skill-remove:hover {
color: var(--danger);
background: rgba(255, 59, 48, 0.12);
}
.session-create-skill-add-row {
display: flex;
}
.session-create-skill-input {
width: 100%;
background: var(--surface-2);
border: 1px solid var(--accent);
border-radius: 4px;
padding: 4px 8px;
font-size: 10px;
color: var(--text);
outline: none;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace;
}
.session-create-skill-input::placeholder {
color: var(--muted-2);
}
.session-create-skill-type-badge {
display: inline-flex;
align-items: center;
padding: 1px 5px;
border-radius: 3px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
background: rgba(255, 79, 0, 0.15);
color: var(--accent);
flex-shrink: 0;
}
.session-create-skill-type-row {
display: flex;
gap: 4px;
}
.session-create-skill-type-select {
width: 80px;
flex-shrink: 0;
background: var(--surface-2);
border: 1px solid var(--accent);
border-radius: 4px;
padding: 4px 6px;
font-size: 10px;
color: var(--text);
outline: none;
cursor: pointer;
}
.session-create-mcp-list {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 4px;
}
.session-create-mcp-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 4px 3px 8px;
background: var(--surface-2);
border: 1px solid var(--border-2);
border-radius: 4px;
}
.session-create-mcp-info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.session-create-mcp-name {
font-size: 11px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.session-create-mcp-type {
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
color: var(--muted);
background: var(--surface);
padding: 1px 4px;
border-radius: 3px;
white-space: nowrap;
}
.session-create-mcp-summary {
font-size: 10px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.session-create-mcp-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.session-create-mcp-edit {
display: flex;
flex-direction: column;
gap: 4px;
}
.session-create-mcp-name-input {
width: 100%;
background: var(--surface-2);
border: 1px solid var(--accent);
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
color: var(--text);
outline: none;
}
.session-create-mcp-name-input:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.session-create-mcp-name-input::placeholder {
color: var(--muted-2);
}
.session-create-mcp-edit-actions {
display: flex;
gap: 4px;
}
.session-create-mcp-save,
.session-create-mcp-cancel {
flex: 1;
padding: 4px 8px;
border-radius: 4px;
border: none;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: background var(--transition);
}
.session-create-mcp-save {
background: var(--accent);
color: #fff;
}
.session-create-mcp-save:hover {
background: var(--accent-hover);
}
.session-create-mcp-cancel {
background: var(--border-2);
color: var(--text-secondary);
}
.session-create-mcp-cancel:hover {
background: var(--muted-2);
}
.session-create-add-btn {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
padding: 4px 8px;
background: transparent;
border: 1px dashed var(--border-2);
border-radius: 4px;
color: var(--muted);
font-size: 10px;
cursor: pointer;
transition: all var(--transition);
}
.session-create-add-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.session-create-actions {
padding: 4px 2px 2px;
margin-top: 4px;
}
.session-create-actions .button.primary {
width: 100%;
padding: 8px 12px;
font-size: 12px;
}
/* Empty state variant of session-create-menu */
.empty-state-menu-wrapper .session-create-menu {
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 8px;
}
.sidebar-add-option {
background: transparent;
border: 1px solid transparent;
@ -515,12 +921,40 @@
.agent-option-left {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
}
.agent-option-name {
white-space: nowrap;
min-width: 0;
}
.agent-option-version {
font-size: 10px;
color: var(--muted);
white-space: nowrap;
}
.sidebar-add-option:hover .agent-option-version {
color: rgba(255, 255, 255, 0.6);
}
.agent-option-badges {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.agent-option-arrow {
color: var(--muted-2);
transition: color var(--transition);
}
.sidebar-add-option:hover .agent-option-arrow {
color: rgba(255, 255, 255, 0.6);
}
.agent-badge {
@ -535,9 +969,6 @@
flex-shrink: 0;
}
.agent-badge.version {
color: var(--muted);
}
.sidebar-add-status {
padding: 6px 8px;
@ -1043,6 +1474,36 @@
height: 16px;
}
/* Session Config Bar */
.session-config-bar {
display: flex;
align-items: flex-start;
gap: 20px;
padding: 10px 16px 12px;
border-top: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
.session-config-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.session-config-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--muted);
}
.session-config-value {
font-size: 12px;
color: #8e8e93;
}
/* Setup Row */
.setup-row {
display: flex;
@ -1207,6 +1668,29 @@
color: #fff;
}
.setup-config-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.setup-config-btn {
border: 1px solid var(--border-2);
border-radius: 4px;
background: var(--surface);
color: var(--text-secondary);
}
.setup-config-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.setup-config-btn.error {
color: var(--danger);
border-color: rgba(255, 59, 48, 0.4);
}
.setup-version {
font-size: 10px;
color: var(--muted);
@ -1311,6 +1795,15 @@
margin-bottom: 0;
}
.config-textarea {
min-height: 130px;
}
.config-inline-error {
margin-top: 8px;
margin-bottom: 0;
}
.card-header {
display: flex;
align-items: center;
@ -1319,6 +1812,16 @@
margin-bottom: 8px;
}
.card-header-pills {
display: flex;
align-items: center;
gap: 6px;
}
.spinner-icon {
animation: spin 0.8s linear infinite;
}
.card-title {
font-size: 13px;
font-weight: 600;

View file

@ -3,11 +3,13 @@ import {
SandboxAgentError,
SandboxAgent,
type AgentInfo,
type CreateSessionRequest,
type AgentModelInfo,
type AgentModeInfo,
type PermissionEventData,
type QuestionEventData,
type SessionInfo,
type SkillSource,
type UniversalEvent,
type UniversalItem
} from "sandbox-agent";
@ -32,6 +34,41 @@ type ItemDeltaEventData = {
delta: string;
};
export type McpServerEntry = {
name: string;
configJson: string;
error: string | null;
};
type ParsedMcpConfig = {
value: NonNullable<CreateSessionRequest["mcp"]>;
count: number;
error: string | null;
};
const buildMcpConfig = (entries: McpServerEntry[]): ParsedMcpConfig => {
if (entries.length === 0) {
return { value: {}, count: 0, error: null };
}
const firstError = entries.find((e) => e.error);
if (firstError) {
return { value: {}, count: entries.length, error: `${firstError.name}: ${firstError.error}` };
}
const value: NonNullable<CreateSessionRequest["mcp"]> = {};
for (const entry of entries) {
try {
value[entry.name] = JSON.parse(entry.configJson);
} catch {
return { value: {}, count: entries.length, error: `${entry.name}: Invalid JSON` };
}
}
return { value, count: entries.length, error: null };
};
const buildSkillsConfig = (sources: SkillSource[]): NonNullable<CreateSessionRequest["skills"]> => {
return { sources };
};
const buildStubItem = (itemId: string, nativeItemId?: string | null): UniversalItem => {
return {
item_id: itemId,
@ -53,6 +90,23 @@ const getCurrentOriginEndpoint = () => {
return window.location.origin;
};
const getSessionIdFromPath = (): string => {
const basePath = import.meta.env.BASE_URL;
const path = window.location.pathname;
const relative = path.startsWith(basePath) ? path.slice(basePath.length) : path;
const match = relative.match(/^sessions\/(.+)/);
return match ? match[1] : "";
};
const updateSessionPath = (id: string) => {
const basePath = import.meta.env.BASE_URL;
const params = window.location.search;
const newPath = id ? `${basePath}sessions/${id}${params}` : `${basePath}${params}`;
if (window.location.pathname + window.location.search !== newPath) {
window.history.replaceState(null, "", newPath);
}
};
const getInitialConnection = () => {
if (typeof window === "undefined") {
return { endpoint: "http://127.0.0.1:2468", token: "", headers: {} as Record<string, string>, hasUrlParam: false };
@ -103,11 +157,7 @@ export default function App() {
const [modelsErrorByAgent, setModelsErrorByAgent] = useState<Record<string, string | null>>({});
const [agentId, setAgentId] = useState("claude");
const [agentMode, setAgentMode] = useState("");
const [permissionMode, setPermissionMode] = useState("default");
const [model, setModel] = useState("");
const [variant, setVariant] = useState("");
const [sessionId, setSessionId] = useState("");
const [sessionId, setSessionId] = useState(getSessionIdFromPath());
const [sessionError, setSessionError] = useState<string | null>(null);
const [message, setMessage] = useState("");
@ -115,6 +165,8 @@ export default function App() {
const [offset, setOffset] = useState(0);
const offsetRef = useRef(0);
const [eventsLoading, setEventsLoading] = useState(false);
const [mcpServers, setMcpServers] = useState<McpServerEntry[]>([]);
const [skillSources, setSkillSources] = useState<SkillSource[]>([]);
const [polling, setPolling] = useState(false);
const pollTimerRef = useRef<number | null>(null);
@ -377,50 +429,52 @@ export default function App() {
stopSse();
stopTurnStream();
setSessionId(session.sessionId);
updateSessionPath(session.sessionId);
setAgentId(session.agent);
setAgentMode(session.agentMode);
setPermissionMode(session.permissionMode);
setModel(session.model ?? "");
setVariant(session.variant ?? "");
setEvents([]);
setOffset(0);
offsetRef.current = 0;
setSessionError(null);
};
const createNewSession = async (nextAgentId?: string) => {
const createNewSession = async (
nextAgentId: string,
config: { model: string; agentMode: string; permissionMode: string; variant: string }
) => {
stopPolling();
stopSse();
stopTurnStream();
const selectedAgent = nextAgentId ?? agentId;
if (nextAgentId) {
setAgentId(nextAgentId);
setAgentId(nextAgentId);
if (parsedMcpConfig.error) {
setSessionError(parsedMcpConfig.error);
return;
}
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let id = "session-";
for (let i = 0; i < 8; i++) {
id += chars[Math.floor(Math.random() * chars.length)];
}
setSessionId(id);
setEvents([]);
setOffset(0);
offsetRef.current = 0;
setSessionError(null);
try {
const body: {
agent: string;
agentMode?: string;
permissionMode?: string;
model?: string;
variant?: string;
} = { agent: selectedAgent };
if (agentMode) body.agentMode = agentMode;
if (permissionMode) body.permissionMode = permissionMode;
if (model) body.model = model;
if (variant) body.variant = variant;
const body: CreateSessionRequest = { agent: nextAgentId };
if (config.agentMode) body.agentMode = config.agentMode;
if (config.permissionMode) body.permissionMode = config.permissionMode;
if (config.model) body.model = config.model;
if (config.variant) body.variant = config.variant;
if (parsedMcpConfig.count > 0) {
body.mcp = parsedMcpConfig.value;
}
if (parsedSkillsConfig.sources.length > 0) {
body.skills = parsedSkillsConfig;
}
await getClient().createSession(id, body);
setSessionId(id);
updateSessionPath(id);
setEvents([]);
setOffset(0);
offsetRef.current = 0;
await fetchSessions();
} catch (error) {
setSessionError(getErrorMessage(error, "Unable to create session"));
@ -876,38 +930,10 @@ export default function App() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [transcriptEntries]);
useEffect(() => {
if (connected && agentId && !modesByAgent[agentId]) {
loadModes(agentId);
}
}, [connected, agentId]);
useEffect(() => {
if (connected && agentId && !modelsByAgent[agentId]) {
loadModels(agentId);
}
}, [connected, agentId]);
useEffect(() => {
const modes = modesByAgent[agentId];
if (modes && modes.length > 0 && !agentMode) {
setAgentMode(modes[0].id);
}
}, [modesByAgent, agentId]);
const currentAgent = agents.find((agent) => agent.id === agentId);
const activeModes = modesByAgent[agentId] ?? [];
const modesLoading = modesLoadingByAgent[agentId] ?? false;
const modesError = modesErrorByAgent[agentId] ?? null;
const modelOptions = modelsByAgent[agentId] ?? [];
const modelsLoading = modelsLoadingByAgent[agentId] ?? false;
const modelsError = modelsErrorByAgent[agentId] ?? null;
const defaultModel = defaultModelByAgent[agentId] ?? "";
const selectedModelId = model || defaultModel;
const selectedModel = modelOptions.find((entry) => entry.id === selectedModelId);
const variantOptions = selectedModel?.variants ?? [];
const defaultVariant = selectedModel?.defaultVariant ?? "";
const supportsVariants = Boolean(currentAgent?.capabilities?.variants);
const currentSessionInfo = sessions.find((s) => s.sessionId === sessionId);
const parsedMcpConfig = useMemo(() => buildMcpConfig(mcpServers), [mcpServers]);
const parsedSkillsConfig = useMemo(() => buildSkillsConfig(skillSources), [skillSources]);
const agentDisplayNames: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
@ -917,6 +943,15 @@ export default function App() {
};
const agentLabel = agentDisplayNames[agentId] ?? agentId;
const handleSelectAgent = useCallback((targetAgentId: string) => {
if (connected && !modesByAgent[targetAgentId]) {
loadModes(targetAgentId);
}
if (connected && !modelsByAgent[targetAgentId]) {
loadModels(targetAgentId);
}
}, [connected, modesByAgent, modelsByAgent]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
@ -980,17 +1015,28 @@ export default function App() {
onSelectSession={selectSession}
onRefresh={fetchSessions}
onCreateSession={createNewSession}
onSelectAgent={handleSelectAgent}
agents={agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, capabilities: {} }) as AgentInfo)}
agentsLoading={agentsLoading}
agentsError={agentsError}
sessionsLoading={sessionsLoading}
sessionsError={sessionsError}
modesByAgent={modesByAgent}
modelsByAgent={modelsByAgent}
defaultModelByAgent={defaultModelByAgent}
modesLoadingByAgent={modesLoadingByAgent}
modelsLoadingByAgent={modelsLoadingByAgent}
modesErrorByAgent={modesErrorByAgent}
modelsErrorByAgent={modelsErrorByAgent}
mcpServers={mcpServers}
onMcpServersChange={setMcpServers}
mcpConfigError={parsedMcpConfig.error}
skillSources={skillSources}
onSkillSourcesChange={setSkillSources}
/>
<ChatPanel
sessionId={sessionId}
polling={polling}
turnStreaming={turnStreaming}
transcriptEntries={transcriptEntries}
sessionError={sessionError}
message={message}
@ -998,36 +1044,19 @@ export default function App() {
onSendMessage={sendMessage}
onKeyDown={handleKeyDown}
onCreateSession={createNewSession}
onSelectAgent={handleSelectAgent}
agents={agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, capabilities: {} }) as AgentInfo)}
agentsLoading={agentsLoading}
agentsError={agentsError}
messagesEndRef={messagesEndRef}
agentId={agentId}
agentLabel={agentLabel}
agentMode={agentMode}
permissionMode={permissionMode}
model={model}
variant={variant}
modelOptions={modelOptions}
defaultModel={defaultModel}
modelsLoading={modelsLoading}
modelsError={modelsError}
variantOptions={variantOptions}
defaultVariant={defaultVariant}
supportsVariants={supportsVariants}
streamMode={streamMode}
activeModes={activeModes}
currentAgentVersion={currentAgent?.version ?? null}
modesLoading={modesLoading}
modesError={modesError}
onAgentModeChange={setAgentMode}
onPermissionModeChange={setPermissionMode}
onModelChange={setModel}
onVariantChange={setVariant}
onStreamModeChange={setStreamMode}
onToggleStream={toggleStream}
sessionModel={currentSessionInfo?.model ?? null}
sessionVariant={currentSessionInfo?.variant ?? null}
sessionPermissionMode={currentSessionInfo?.permissionMode ?? null}
sessionMcpServerCount={currentSessionInfo?.mcp ? Object.keys(currentSessionInfo.mcp).length : 0}
sessionSkillSourceCount={currentSessionInfo?.skills?.sources?.length ?? 0}
onEndSession={endSession}
hasSession={Boolean(sessionId)}
eventError={eventError}
questionRequests={questionRequests}
permissionRequests={permissionRequests}
@ -1036,6 +1065,18 @@ export default function App() {
onAnswerQuestion={answerQuestion}
onRejectQuestion={rejectQuestion}
onReplyPermission={replyPermission}
modesByAgent={modesByAgent}
modelsByAgent={modelsByAgent}
defaultModelByAgent={defaultModelByAgent}
modesLoadingByAgent={modesLoadingByAgent}
modelsLoadingByAgent={modelsLoadingByAgent}
modesErrorByAgent={modesErrorByAgent}
modelsErrorByAgent={modelsErrorByAgent}
mcpServers={mcpServers}
onMcpServersChange={setMcpServers}
mcpConfigError={parsedMcpConfig.error}
skillSources={skillSources}
onSkillSourcesChange={setSkillSources}
/>
<DebugPanel

View file

@ -0,0 +1,750 @@
import { ArrowLeft, ArrowRight, ChevronDown, ChevronRight, Pencil, Plus, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { AgentInfo, AgentModelInfo, AgentModeInfo, SkillSource } from "sandbox-agent";
import type { McpServerEntry } from "../App";
export type SessionConfig = {
model: string;
agentMode: string;
permissionMode: string;
variant: string;
};
const agentLabels: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode",
amp: "Amp",
mock: "Mock"
};
const validateServerJson = (json: string): string | null => {
const trimmed = json.trim();
if (!trimmed) return "Config is required";
try {
const parsed = JSON.parse(trimmed);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return "Must be a JSON object";
}
if (!parsed.type) return 'Missing "type" field';
if (parsed.type !== "local" && parsed.type !== "remote") {
return 'Type must be "local" or "remote"';
}
if (parsed.type === "local" && !parsed.command) return 'Local server requires "command"';
if (parsed.type === "remote" && !parsed.url) return 'Remote server requires "url"';
return null;
} catch {
return "Invalid JSON";
}
};
const getServerType = (configJson: string): string | null => {
try {
const parsed = JSON.parse(configJson);
return parsed?.type ?? null;
} catch {
return null;
}
};
const getServerSummary = (configJson: string): string => {
try {
const parsed = JSON.parse(configJson);
if (parsed?.type === "local") {
const cmd = Array.isArray(parsed.command) ? parsed.command.join(" ") : parsed.command;
return cmd ?? "local";
}
if (parsed?.type === "remote") {
return parsed.url ?? "remote";
}
return parsed?.type ?? "";
} catch {
return "";
}
};
const skillSourceSummary = (source: SkillSource): string => {
let summary = source.source;
if (source.skills && source.skills.length > 0) {
summary += ` [${source.skills.join(", ")}]`;
}
return summary;
};
const SessionCreateMenu = ({
agents,
agentsLoading,
agentsError,
modesByAgent,
modelsByAgent,
defaultModelByAgent,
modesLoadingByAgent,
modelsLoadingByAgent,
modesErrorByAgent,
modelsErrorByAgent,
mcpServers,
onMcpServersChange,
mcpConfigError,
skillSources,
onSkillSourcesChange,
onSelectAgent,
onCreateSession,
open,
onClose
}: {
agents: AgentInfo[];
agentsLoading: boolean;
agentsError: string | null;
modesByAgent: Record<string, AgentModeInfo[]>;
modelsByAgent: Record<string, AgentModelInfo[]>;
defaultModelByAgent: Record<string, string>;
modesLoadingByAgent: Record<string, boolean>;
modelsLoadingByAgent: Record<string, boolean>;
modesErrorByAgent: Record<string, string | null>;
modelsErrorByAgent: Record<string, string | null>;
mcpServers: McpServerEntry[];
onMcpServersChange: (servers: McpServerEntry[]) => void;
mcpConfigError: string | null;
skillSources: SkillSource[];
onSkillSourcesChange: (sources: SkillSource[]) => void;
onSelectAgent: (agentId: string) => void;
onCreateSession: (agentId: string, config: SessionConfig) => void;
open: boolean;
onClose: () => void;
}) => {
const [phase, setPhase] = useState<"agent" | "config">("agent");
const [selectedAgent, setSelectedAgent] = useState("");
const [agentMode, setAgentMode] = useState("");
const [permissionMode, setPermissionMode] = useState("default");
const [model, setModel] = useState("");
const [variant, setVariant] = useState("");
const [mcpExpanded, setMcpExpanded] = useState(false);
const [skillsExpanded, setSkillsExpanded] = useState(false);
// Skill add/edit state
const [addingSkill, setAddingSkill] = useState(false);
const [editingSkillIndex, setEditingSkillIndex] = useState<number | null>(null);
const [skillType, setSkillType] = useState<"github" | "local" | "git">("github");
const [skillSource, setSkillSource] = useState("");
const [skillFilter, setSkillFilter] = useState("");
const [skillRef, setSkillRef] = useState("");
const [skillSubpath, setSkillSubpath] = useState("");
const [skillLocalError, setSkillLocalError] = useState<string | null>(null);
const skillSourceRef = useRef<HTMLInputElement>(null);
// MCP add/edit state
const [addingMcp, setAddingMcp] = useState(false);
const [editingMcpIndex, setEditingMcpIndex] = useState<number | null>(null);
const [mcpName, setMcpName] = useState("");
const [mcpJson, setMcpJson] = useState("");
const [mcpLocalError, setMcpLocalError] = useState<string | null>(null);
const mcpNameRef = useRef<HTMLInputElement>(null);
const mcpJsonRef = useRef<HTMLTextAreaElement>(null);
const cancelSkillEdit = () => {
setAddingSkill(false);
setEditingSkillIndex(null);
setSkillType("github");
setSkillSource("");
setSkillFilter("");
setSkillRef("");
setSkillSubpath("");
setSkillLocalError(null);
};
// Reset state when menu closes
useEffect(() => {
if (!open) {
setPhase("agent");
setSelectedAgent("");
setAgentMode("");
setPermissionMode("default");
setModel("");
setVariant("");
setMcpExpanded(false);
setSkillsExpanded(false);
cancelSkillEdit();
setAddingMcp(false);
setEditingMcpIndex(null);
setMcpName("");
setMcpJson("");
setMcpLocalError(null);
}
}, [open]);
// Auto-select first mode when modes load for selected agent
useEffect(() => {
if (!selectedAgent) return;
const modes = modesByAgent[selectedAgent];
if (modes && modes.length > 0 && !agentMode) {
setAgentMode(modes[0].id);
}
}, [modesByAgent, selectedAgent, agentMode]);
// Focus skill source input when adding
useEffect(() => {
if ((addingSkill || editingSkillIndex !== null) && skillSourceRef.current) {
skillSourceRef.current.focus();
}
}, [addingSkill, editingSkillIndex]);
// Focus MCP name input when adding
useEffect(() => {
if (addingMcp && mcpNameRef.current) {
mcpNameRef.current.focus();
}
}, [addingMcp]);
// Focus MCP json textarea when editing
useEffect(() => {
if (editingMcpIndex !== null && mcpJsonRef.current) {
mcpJsonRef.current.focus();
}
}, [editingMcpIndex]);
if (!open) return null;
const handleAgentClick = (agentId: string) => {
setSelectedAgent(agentId);
setPhase("config");
onSelectAgent(agentId);
};
const handleBack = () => {
setPhase("agent");
setSelectedAgent("");
setAgentMode("");
setPermissionMode("default");
setModel("");
setVariant("");
};
const handleCreate = () => {
if (mcpConfigError) return;
onCreateSession(selectedAgent, { model, agentMode, permissionMode, variant });
onClose();
};
// Skill source helpers
const startAddSkill = () => {
setAddingSkill(true);
setEditingSkillIndex(null);
setSkillType("github");
setSkillSource("rivet-dev/skills");
setSkillFilter("sandbox-agent");
setSkillRef("");
setSkillSubpath("");
setSkillLocalError(null);
};
const startEditSkill = (index: number) => {
const entry = skillSources[index];
setEditingSkillIndex(index);
setAddingSkill(false);
setSkillType(entry.type as "github" | "local" | "git");
setSkillSource(entry.source);
setSkillFilter(entry.skills?.join(", ") ?? "");
setSkillRef(entry.ref ?? "");
setSkillSubpath(entry.subpath ?? "");
setSkillLocalError(null);
};
const commitSkill = () => {
const src = skillSource.trim();
if (!src) {
setSkillLocalError("Source is required");
return;
}
const entry: SkillSource = {
type: skillType,
source: src,
};
const filterList = skillFilter.trim()
? skillFilter.split(",").map((s) => s.trim()).filter(Boolean)
: undefined;
if (filterList && filterList.length > 0) entry.skills = filterList;
if (skillRef.trim()) entry.ref = skillRef.trim();
if (skillSubpath.trim()) entry.subpath = skillSubpath.trim();
if (editingSkillIndex !== null) {
const updated = [...skillSources];
updated[editingSkillIndex] = entry;
onSkillSourcesChange(updated);
} else {
onSkillSourcesChange([...skillSources, entry]);
}
cancelSkillEdit();
};
const removeSkill = (index: number) => {
onSkillSourcesChange(skillSources.filter((_, i) => i !== index));
if (editingSkillIndex === index) {
cancelSkillEdit();
}
};
const isEditingSkill = addingSkill || editingSkillIndex !== null;
const startAddMcp = () => {
setAddingMcp(true);
setEditingMcpIndex(null);
setMcpName("everything");
setMcpJson('{\n "type": "local",\n "command": "npx",\n "args": ["@modelcontextprotocol/server-everything"]\n}');
setMcpLocalError(null);
};
const startEditMcp = (index: number) => {
const entry = mcpServers[index];
setEditingMcpIndex(index);
setAddingMcp(false);
setMcpName(entry.name);
setMcpJson(entry.configJson);
setMcpLocalError(entry.error);
};
const cancelMcpEdit = () => {
setAddingMcp(false);
setEditingMcpIndex(null);
setMcpName("");
setMcpJson("");
setMcpLocalError(null);
};
const commitMcp = () => {
const name = mcpName.trim();
if (!name) {
setMcpLocalError("Server name is required");
return;
}
const error = validateServerJson(mcpJson);
if (error) {
setMcpLocalError(error);
return;
}
// Check for duplicate names (except when editing the same entry)
const duplicate = mcpServers.findIndex((e) => e.name === name);
if (duplicate !== -1 && duplicate !== editingMcpIndex) {
setMcpLocalError(`Server "${name}" already exists`);
return;
}
const entry: McpServerEntry = { name, configJson: mcpJson.trim(), error: null };
if (editingMcpIndex !== null) {
const updated = [...mcpServers];
updated[editingMcpIndex] = entry;
onMcpServersChange(updated);
} else {
onMcpServersChange([...mcpServers, entry]);
}
cancelMcpEdit();
};
const removeMcp = (index: number) => {
onMcpServersChange(mcpServers.filter((_, i) => i !== index));
if (editingMcpIndex === index) {
cancelMcpEdit();
}
};
const isEditingMcp = addingMcp || editingMcpIndex !== null;
if (phase === "agent") {
return (
<div className="session-create-menu">
{agentsLoading && <div className="sidebar-add-status">Loading agents...</div>}
{agentsError && <div className="sidebar-add-status error">{agentsError}</div>}
{!agentsLoading && !agentsError && agents.length === 0 && (
<div className="sidebar-add-status">No agents available.</div>
)}
{!agentsLoading && !agentsError &&
agents.map((agent) => (
<button
key={agent.id}
className="sidebar-add-option"
onClick={() => handleAgentClick(agent.id)}
>
<div className="agent-option-left">
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span>
{agent.version && <span className="agent-option-version">{agent.version}</span>}
</div>
<div className="agent-option-badges">
{agent.installed && <span className="agent-badge installed">Installed</span>}
<ArrowRight size={12} className="agent-option-arrow" />
</div>
</button>
))}
</div>
);
}
// Phase 2: config form
const activeModes = modesByAgent[selectedAgent] ?? [];
const modesLoading = modesLoadingByAgent[selectedAgent] ?? false;
const modesError = modesErrorByAgent[selectedAgent] ?? null;
const modelOptions = modelsByAgent[selectedAgent] ?? [];
const modelsLoading = modelsLoadingByAgent[selectedAgent] ?? false;
const modelsError = modelsErrorByAgent[selectedAgent] ?? null;
const defaultModel = defaultModelByAgent[selectedAgent] ?? "";
const selectedModelId = model || defaultModel;
const selectedModelObj = modelOptions.find((entry) => entry.id === selectedModelId);
const variantOptions = selectedModelObj?.variants ?? [];
const showModelSelect = modelsLoading || Boolean(modelsError) || modelOptions.length > 0;
const hasModelOptions = modelOptions.length > 0;
const modelCustom =
model && hasModelOptions && !modelOptions.some((entry) => entry.id === model);
const supportsVariants =
modelsLoading ||
Boolean(modelsError) ||
modelOptions.some((entry) => (entry.variants?.length ?? 0) > 0);
const showVariantSelect =
supportsVariants && (modelsLoading || Boolean(modelsError) || variantOptions.length > 0);
const hasVariantOptions = variantOptions.length > 0;
const variantCustom = variant && hasVariantOptions && !variantOptions.includes(variant);
const agentLabel = agentLabels[selectedAgent] ?? selectedAgent;
return (
<div className="session-create-menu">
<div className="session-create-header">
<button className="session-create-back" onClick={handleBack} title="Back to agents">
<ArrowLeft size={14} />
</button>
<span className="session-create-agent-name">{agentLabel}</span>
</div>
<div className="session-create-form">
<div className="setup-field">
<span className="setup-label">Model</span>
{showModelSelect ? (
<select
className="setup-select"
value={model}
onChange={(e) => { setModel(e.target.value); setVariant(""); }}
title="Model"
disabled={modelsLoading || Boolean(modelsError)}
>
{modelsLoading ? (
<option value="">Loading models...</option>
) : modelsError ? (
<option value="">{modelsError}</option>
) : (
<>
<option value="">
{defaultModel ? `Default (${defaultModel})` : "Default"}
</option>
{modelCustom && <option value={model}>{model} (custom)</option>}
{modelOptions.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name ?? entry.id}
</option>
))}
</>
)}
</select>
) : (
<input
className="setup-input"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="Model"
title="Model"
/>
)}
</div>
<div className="setup-field">
<span className="setup-label">Mode</span>
<select
className="setup-select"
value={agentMode}
onChange={(e) => setAgentMode(e.target.value)}
title="Mode"
disabled={modesLoading || Boolean(modesError)}
>
{modesLoading ? (
<option value="">Loading modes...</option>
) : modesError ? (
<option value="">{modesError}</option>
) : activeModes.length > 0 ? (
activeModes.map((m) => (
<option key={m.id} value={m.id}>
{m.name || m.id}
</option>
))
) : (
<option value="">Mode</option>
)}
</select>
</div>
<div className="setup-field">
<span className="setup-label">Permission</span>
<select
className="setup-select"
value={permissionMode}
onChange={(e) => setPermissionMode(e.target.value)}
title="Permission Mode"
>
<option value="default">Default</option>
<option value="plan">Plan</option>
<option value="bypass">Bypass</option>
</select>
</div>
{supportsVariants && (
<div className="setup-field">
<span className="setup-label">Variant</span>
{showVariantSelect ? (
<select
className="setup-select"
value={variant}
onChange={(e) => setVariant(e.target.value)}
title="Variant"
disabled={modelsLoading || Boolean(modelsError)}
>
{modelsLoading ? (
<option value="">Loading variants...</option>
) : modelsError ? (
<option value="">{modelsError}</option>
) : (
<>
<option value="">Default</option>
{variantCustom && <option value={variant}>{variant} (custom)</option>}
{variantOptions.map((entry) => (
<option key={entry} value={entry}>
{entry}
</option>
))}
</>
)}
</select>
) : (
<input
className="setup-input"
value={variant}
onChange={(e) => setVariant(e.target.value)}
placeholder="Variant"
title="Variant"
/>
)}
</div>
)}
{/* MCP Servers - collapsible */}
<div className="session-create-section">
<button
type="button"
className="session-create-section-toggle"
onClick={() => setMcpExpanded(!mcpExpanded)}
>
<span className="setup-label">MCP</span>
<span className="session-create-section-count">{mcpServers.length} server{mcpServers.length !== 1 ? "s" : ""}</span>
{mcpExpanded ? <ChevronDown size={12} className="session-create-section-arrow" /> : <ChevronRight size={12} className="session-create-section-arrow" />}
</button>
{mcpExpanded && (
<div className="session-create-section-body">
{mcpServers.length > 0 && !isEditingMcp && (
<div className="session-create-mcp-list">
{mcpServers.map((entry, index) => (
<div key={entry.name} className="session-create-mcp-item">
<div className="session-create-mcp-info">
<span className="session-create-mcp-name">{entry.name}</span>
{getServerType(entry.configJson) && (
<span className="session-create-mcp-type">{getServerType(entry.configJson)}</span>
)}
<span className="session-create-mcp-summary mono">{getServerSummary(entry.configJson)}</span>
</div>
<div className="session-create-mcp-actions">
<button
type="button"
className="session-create-skill-remove"
onClick={() => startEditMcp(index)}
title="Edit server"
>
<Pencil size={10} />
</button>
<button
type="button"
className="session-create-skill-remove"
onClick={() => removeMcp(index)}
title="Remove server"
>
<X size={12} />
</button>
</div>
</div>
))}
</div>
)}
{isEditingMcp ? (
<div className="session-create-mcp-edit">
<input
ref={mcpNameRef}
className="session-create-mcp-name-input"
value={mcpName}
onChange={(e) => { setMcpName(e.target.value); setMcpLocalError(null); }}
placeholder="server-name"
disabled={editingMcpIndex !== null}
/>
<textarea
ref={mcpJsonRef}
className="session-create-textarea mono"
value={mcpJson}
onChange={(e) => { setMcpJson(e.target.value); setMcpLocalError(null); }}
placeholder='{"type":"local","command":"node","args":["./server.js"]}'
rows={4}
/>
{mcpLocalError && (
<div className="session-create-inline-error">{mcpLocalError}</div>
)}
<div className="session-create-mcp-edit-actions">
<button type="button" className="session-create-mcp-save" onClick={commitMcp}>
{editingMcpIndex !== null ? "Save" : "Add"}
</button>
<button type="button" className="session-create-mcp-cancel" onClick={cancelMcpEdit}>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
className="session-create-add-btn"
onClick={startAddMcp}
>
<Plus size={12} />
Add server
</button>
)}
{mcpConfigError && !isEditingMcp && (
<div className="session-create-inline-error">{mcpConfigError}</div>
)}
</div>
)}
</div>
{/* Skills - collapsible with source-based list */}
<div className="session-create-section">
<button
type="button"
className="session-create-section-toggle"
onClick={() => setSkillsExpanded(!skillsExpanded)}
>
<span className="setup-label">Skills</span>
<span className="session-create-section-count">{skillSources.length} source{skillSources.length !== 1 ? "s" : ""}</span>
{skillsExpanded ? <ChevronDown size={12} className="session-create-section-arrow" /> : <ChevronRight size={12} className="session-create-section-arrow" />}
</button>
{skillsExpanded && (
<div className="session-create-section-body">
{skillSources.length > 0 && !isEditingSkill && (
<div className="session-create-skill-list">
{skillSources.map((entry, index) => (
<div key={`${entry.type}-${entry.source}-${index}`} className="session-create-skill-item">
<span className="session-create-skill-type-badge">{entry.type}</span>
<span className="session-create-skill-path mono">{skillSourceSummary(entry)}</span>
<div className="session-create-mcp-actions">
<button
type="button"
className="session-create-skill-remove"
onClick={() => startEditSkill(index)}
title="Edit source"
>
<Pencil size={10} />
</button>
<button
type="button"
className="session-create-skill-remove"
onClick={() => removeSkill(index)}
title="Remove source"
>
<X size={12} />
</button>
</div>
</div>
))}
</div>
)}
{isEditingSkill ? (
<div className="session-create-mcp-edit">
<div className="session-create-skill-type-row">
<select
className="session-create-skill-type-select"
value={skillType}
onChange={(e) => { setSkillType(e.target.value as "github" | "local" | "git"); setSkillLocalError(null); }}
>
<option value="github">github</option>
<option value="local">local</option>
<option value="git">git</option>
</select>
<input
ref={skillSourceRef}
className="session-create-skill-input mono"
value={skillSource}
onChange={(e) => { setSkillSource(e.target.value); setSkillLocalError(null); }}
placeholder={skillType === "github" ? "owner/repo" : skillType === "local" ? "/path/to/skill" : "https://git.example.com/repo.git"}
/>
</div>
<input
className="session-create-skill-input mono"
value={skillFilter}
onChange={(e) => setSkillFilter(e.target.value)}
placeholder="Filter skills (comma-separated, optional)"
/>
{skillType !== "local" && (
<div className="session-create-skill-type-row">
<input
className="session-create-skill-input mono"
value={skillRef}
onChange={(e) => setSkillRef(e.target.value)}
placeholder="Branch/tag (optional)"
/>
<input
className="session-create-skill-input mono"
value={skillSubpath}
onChange={(e) => setSkillSubpath(e.target.value)}
placeholder="Subpath (optional)"
/>
</div>
)}
{skillLocalError && (
<div className="session-create-inline-error">{skillLocalError}</div>
)}
<div className="session-create-mcp-edit-actions">
<button type="button" className="session-create-mcp-save" onClick={commitSkill}>
{editingSkillIndex !== null ? "Save" : "Add"}
</button>
<button type="button" className="session-create-mcp-cancel" onClick={cancelSkillEdit}>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
className="session-create-add-btn"
onClick={startAddSkill}
>
<Plus size={12} />
Add source
</button>
)}
</div>
)}
</div>
</div>
<div className="session-create-actions">
<button
className="button primary"
onClick={handleCreate}
disabled={Boolean(mcpConfigError)}
>
Create Session
</button>
</div>
</div>
);
};
export default SessionCreateMenu;

View file

@ -1,6 +1,16 @@
import { Plus, RefreshCw } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { AgentInfo, SessionInfo } from "sandbox-agent";
import type { AgentInfo, AgentModelInfo, AgentModeInfo, SessionInfo, SkillSource } from "sandbox-agent";
import type { McpServerEntry } from "../App";
import SessionCreateMenu, { type SessionConfig } from "./SessionCreateMenu";
const agentLabels: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode",
amp: "Amp",
mock: "Mock"
};
const SessionSidebar = ({
sessions,
@ -8,22 +18,48 @@ const SessionSidebar = ({
onSelectSession,
onRefresh,
onCreateSession,
onSelectAgent,
agents,
agentsLoading,
agentsError,
sessionsLoading,
sessionsError
sessionsError,
modesByAgent,
modelsByAgent,
defaultModelByAgent,
modesLoadingByAgent,
modelsLoadingByAgent,
modesErrorByAgent,
modelsErrorByAgent,
mcpServers,
onMcpServersChange,
mcpConfigError,
skillSources,
onSkillSourcesChange
}: {
sessions: SessionInfo[];
selectedSessionId: string;
onSelectSession: (session: SessionInfo) => void;
onRefresh: () => void;
onCreateSession: (agentId: string) => void;
onCreateSession: (agentId: string, config: SessionConfig) => void;
onSelectAgent: (agentId: string) => void;
agents: AgentInfo[];
agentsLoading: boolean;
agentsError: string | null;
sessionsLoading: boolean;
sessionsError: string | null;
modesByAgent: Record<string, AgentModeInfo[]>;
modelsByAgent: Record<string, AgentModelInfo[]>;
defaultModelByAgent: Record<string, string>;
modesLoadingByAgent: Record<string, boolean>;
modelsLoadingByAgent: Record<string, boolean>;
modesErrorByAgent: Record<string, string | null>;
modelsErrorByAgent: Record<string, string | null>;
mcpServers: McpServerEntry[];
onMcpServersChange: (servers: McpServerEntry[]) => void;
mcpConfigError: string | null;
skillSources: SkillSource[];
onSkillSourcesChange: (sources: SkillSource[]) => void;
}) => {
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
@ -40,14 +76,6 @@ const SessionSidebar = ({
return () => document.removeEventListener("mousedown", handler);
}, [showMenu]);
const agentLabels: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode",
amp: "Amp",
mock: "Mock"
};
return (
<div className="session-sidebar">
<div className="sidebar-header">
@ -64,32 +92,27 @@ const SessionSidebar = ({
>
<Plus size={14} />
</button>
{showMenu && (
<div className="sidebar-add-menu">
{agentsLoading && <div className="sidebar-add-status">Loading agents...</div>}
{agentsError && <div className="sidebar-add-status error">{agentsError}</div>}
{!agentsLoading && !agentsError && agents.length === 0 && (
<div className="sidebar-add-status">No agents available.</div>
)}
{!agentsLoading && !agentsError &&
agents.map((agent) => (
<button
key={agent.id}
className="sidebar-add-option"
onClick={() => {
onCreateSession(agent.id);
setShowMenu(false);
}}
>
<div className="agent-option-left">
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span>
{agent.version && <span className="agent-badge version">v{agent.version}</span>}
</div>
{agent.installed && <span className="agent-badge installed">Installed</span>}
</button>
))}
</div>
)}
<SessionCreateMenu
agents={agents}
agentsLoading={agentsLoading}
agentsError={agentsError}
modesByAgent={modesByAgent}
modelsByAgent={modelsByAgent}
defaultModelByAgent={defaultModelByAgent}
modesLoadingByAgent={modesLoadingByAgent}
modelsLoadingByAgent={modelsLoadingByAgent}
modesErrorByAgent={modesErrorByAgent}
modelsErrorByAgent={modelsErrorByAgent}
mcpServers={mcpServers}
onMcpServersChange={onMcpServersChange}
mcpConfigError={mcpConfigError}
skillSources={skillSources}
onSkillSourcesChange={onSkillSourcesChange}
onSelectAgent={onSelectAgent}
onCreateSession={onCreateSession}
open={showMenu}
onClose={() => setShowMenu(false)}
/>
</div>
</div>
</div>

View file

@ -1,16 +1,15 @@
import { MessageSquare, PauseCircle, PlayCircle, Plus, Square, Terminal } from "lucide-react";
import { MessageSquare, Plus, Square, Terminal } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { AgentInfo, AgentModelInfo, AgentModeInfo, PermissionEventData, QuestionEventData } from "sandbox-agent";
import type { AgentInfo, AgentModelInfo, AgentModeInfo, PermissionEventData, QuestionEventData, SkillSource } from "sandbox-agent";
import type { McpServerEntry } from "../../App";
import ApprovalsTab from "../debug/ApprovalsTab";
import SessionCreateMenu, { type SessionConfig } from "../SessionCreateMenu";
import ChatInput from "./ChatInput";
import ChatMessages from "./ChatMessages";
import ChatSetup from "./ChatSetup";
import type { TimelineEntry } from "./types";
const ChatPanel = ({
sessionId,
polling,
turnStreaming,
transcriptEntries,
sessionError,
message,
@ -18,35 +17,18 @@ const ChatPanel = ({
onSendMessage,
onKeyDown,
onCreateSession,
onSelectAgent,
agents,
agentsLoading,
agentsError,
messagesEndRef,
agentId,
agentLabel,
agentMode,
permissionMode,
model,
variant,
modelOptions,
defaultModel,
modelsLoading,
modelsError,
variantOptions,
defaultVariant,
supportsVariants,
streamMode,
activeModes,
currentAgentVersion,
hasSession,
modesLoading,
modesError,
onAgentModeChange,
onPermissionModeChange,
onModelChange,
onVariantChange,
onStreamModeChange,
onToggleStream,
sessionModel,
sessionVariant,
sessionPermissionMode,
sessionMcpServerCount,
sessionSkillSourceCount,
onEndSession,
eventError,
questionRequests,
@ -55,47 +37,40 @@ const ChatPanel = ({
onSelectQuestionOption,
onAnswerQuestion,
onRejectQuestion,
onReplyPermission
onReplyPermission,
modesByAgent,
modelsByAgent,
defaultModelByAgent,
modesLoadingByAgent,
modelsLoadingByAgent,
modesErrorByAgent,
modelsErrorByAgent,
mcpServers,
onMcpServersChange,
mcpConfigError,
skillSources,
onSkillSourcesChange
}: {
sessionId: string;
polling: boolean;
turnStreaming: boolean;
transcriptEntries: TimelineEntry[];
sessionError: string | null;
message: string;
onMessageChange: (value: string) => void;
onSendMessage: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onCreateSession: (agentId: string) => void;
onCreateSession: (agentId: string, config: SessionConfig) => void;
onSelectAgent: (agentId: string) => void;
agents: AgentInfo[];
agentsLoading: boolean;
agentsError: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>;
agentId: string;
agentLabel: string;
agentMode: string;
permissionMode: string;
model: string;
variant: string;
modelOptions: AgentModelInfo[];
defaultModel: string;
modelsLoading: boolean;
modelsError: string | null;
variantOptions: string[];
defaultVariant: string;
supportsVariants: boolean;
streamMode: "poll" | "sse" | "turn";
activeModes: AgentModeInfo[];
currentAgentVersion?: string | null;
hasSession: boolean;
modesLoading: boolean;
modesError: string | null;
onAgentModeChange: (value: string) => void;
onPermissionModeChange: (value: string) => void;
onModelChange: (value: string) => void;
onVariantChange: (value: string) => void;
onStreamModeChange: (value: "poll" | "sse" | "turn") => void;
onToggleStream: () => void;
sessionModel?: string | null;
sessionVariant?: string | null;
sessionPermissionMode?: string | null;
sessionMcpServerCount: number;
sessionSkillSourceCount: number;
onEndSession: () => void;
eventError: string | null;
questionRequests: QuestionEventData[];
@ -105,6 +80,18 @@ const ChatPanel = ({
onAnswerQuestion: (request: QuestionEventData) => void;
onRejectQuestion: (requestId: string) => void;
onReplyPermission: (requestId: string, reply: "once" | "always" | "reject") => void;
modesByAgent: Record<string, AgentModeInfo[]>;
modelsByAgent: Record<string, AgentModelInfo[]>;
defaultModelByAgent: Record<string, string>;
modesLoadingByAgent: Record<string, boolean>;
modelsLoadingByAgent: Record<string, boolean>;
modesErrorByAgent: Record<string, string | null>;
modelsErrorByAgent: Record<string, string | null>;
mcpServers: McpServerEntry[];
onMcpServersChange: (servers: McpServerEntry[]) => void;
mcpConfigError: string | null;
skillSources: SkillSource[];
onSkillSourcesChange: (sources: SkillSource[]) => void;
}) => {
const [showAgentMenu, setShowAgentMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
@ -121,18 +108,7 @@ const ChatPanel = ({
return () => document.removeEventListener("mousedown", handler);
}, [showAgentMenu]);
const agentLabels: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode",
amp: "Amp",
mock: "Mock"
};
const hasApprovals = questionRequests.length > 0 || permissionRequests.length > 0;
const isTurnMode = streamMode === "turn";
const isStreaming = isTurnMode ? turnStreaming : polling;
const turnLabel = turnStreaming ? "Streaming" : "On Send";
return (
<div className="chat-panel">
@ -141,12 +117,6 @@ const ChatPanel = ({
<MessageSquare className="button-icon" />
<span className="panel-title">{sessionId ? "Session" : "No Session"}</span>
{sessionId && <span className="session-id-display">{sessionId}</span>}
{sessionId && (
<span className="session-agent-display">
{agentLabel}
{currentAgentVersion && <span className="session-agent-version">v{currentAgentVersion}</span>}
</span>
)}
</div>
<div className="panel-header-right">
{sessionId && (
@ -160,42 +130,6 @@ const ChatPanel = ({
End
</button>
)}
<div className="setup-stream">
<select
className="setup-select-small"
value={streamMode}
onChange={(e) => onStreamModeChange(e.target.value as "poll" | "sse" | "turn")}
title="Stream Mode"
disabled={!sessionId}
>
<option value="poll">Poll</option>
<option value="sse">SSE</option>
<option value="turn">Turn</option>
</select>
<button
className={`setup-stream-btn ${isStreaming ? "active" : ""}`}
onClick={onToggleStream}
title={isTurnMode ? "Turn streaming starts on send" : polling ? "Stop streaming" : "Start streaming"}
disabled={!sessionId || isTurnMode}
>
{isTurnMode ? (
<>
<PlayCircle size={14} />
<span>{turnLabel}</span>
</>
) : polling ? (
<>
<PauseCircle size={14} />
<span>Pause</span>
</>
) : (
<>
<PlayCircle size={14} />
<span>Resume</span>
</>
)}
</button>
</div>
</div>
</div>
@ -213,32 +147,27 @@ const ChatPanel = ({
<Plus className="button-icon" />
Create Session
</button>
{showAgentMenu && (
<div className="empty-state-menu">
{agentsLoading && <div className="sidebar-add-status">Loading agents...</div>}
{agentsError && <div className="sidebar-add-status error">{agentsError}</div>}
{!agentsLoading && !agentsError && agents.length === 0 && (
<div className="sidebar-add-status">No agents available.</div>
)}
{!agentsLoading && !agentsError &&
agents.map((agent) => (
<button
key={agent.id}
className="sidebar-add-option"
onClick={() => {
onCreateSession(agent.id);
setShowAgentMenu(false);
}}
>
<div className="agent-option-left">
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span>
{agent.version && <span className="agent-badge version">v{agent.version}</span>}
</div>
{agent.installed && <span className="agent-badge installed">Installed</span>}
</button>
))}
</div>
)}
<SessionCreateMenu
agents={agents}
agentsLoading={agentsLoading}
agentsError={agentsError}
modesByAgent={modesByAgent}
modelsByAgent={modelsByAgent}
defaultModelByAgent={defaultModelByAgent}
modesLoadingByAgent={modesLoadingByAgent}
modelsLoadingByAgent={modelsLoadingByAgent}
modesErrorByAgent={modesErrorByAgent}
modelsErrorByAgent={modelsErrorByAgent}
mcpServers={mcpServers}
onMcpServersChange={onMcpServersChange}
mcpConfigError={mcpConfigError}
skillSources={skillSources}
onSkillSourcesChange={onSkillSourcesChange}
onSelectAgent={onSelectAgent}
onCreateSession={onCreateSession}
open={showAgentMenu}
onClose={() => setShowAgentMenu(false)}
/>
</div>
</div>
) : transcriptEntries.length === 0 && !sessionError ? (
@ -246,7 +175,7 @@ const ChatPanel = ({
<Terminal className="empty-state-icon" />
<div className="empty-state-title">Ready to Chat</div>
<p className="empty-state-text">Send a message to start a conversation with the agent.</p>
{agentId === "mock" && (
{agentLabel === "Mock" && (
<div className="mock-agent-hint">
The mock agent simulates agent responses for testing the inspector UI without requiring API credentials. Send <code>help</code> for available commands.
</div>
@ -283,30 +212,37 @@ const ChatPanel = ({
onSendMessage={onSendMessage}
onKeyDown={onKeyDown}
placeholder={sessionId ? "Send a message..." : "Select or create a session first"}
disabled={!sessionId || turnStreaming}
disabled={!sessionId}
/>
<ChatSetup
agentMode={agentMode}
permissionMode={permissionMode}
model={model}
variant={variant}
modelOptions={modelOptions}
defaultModel={defaultModel}
modelsLoading={modelsLoading}
modelsError={modelsError}
variantOptions={variantOptions}
defaultVariant={defaultVariant}
supportsVariants={supportsVariants}
activeModes={activeModes}
modesLoading={modesLoading}
modesError={modesError}
onAgentModeChange={onAgentModeChange}
onPermissionModeChange={onPermissionModeChange}
onModelChange={onModelChange}
onVariantChange={onVariantChange}
hasSession={hasSession}
/>
{sessionId && (
<div className="session-config-bar">
<div className="session-config-field">
<span className="session-config-label">Agent</span>
<span className="session-config-value">{agentLabel}</span>
</div>
<div className="session-config-field">
<span className="session-config-label">Model</span>
<span className="session-config-value">{sessionModel || "-"}</span>
</div>
<div className="session-config-field">
<span className="session-config-label">Variant</span>
<span className="session-config-value">{sessionVariant || "-"}</span>
</div>
<div className="session-config-field">
<span className="session-config-label">Permission</span>
<span className="session-config-value">{sessionPermissionMode || "-"}</span>
</div>
<div className="session-config-field">
<span className="session-config-label">MCP Servers</span>
<span className="session-config-value">{sessionMcpServerCount}</span>
</div>
<div className="session-config-field">
<span className="session-config-label">Skills</span>
<span className="session-config-value">{sessionSkillSourceCount}</span>
</div>
</div>
)}
</div>
);
};

View file

@ -1,179 +0,0 @@
import type { AgentModelInfo, AgentModeInfo } from "sandbox-agent";
const ChatSetup = ({
agentMode,
permissionMode,
model,
variant,
modelOptions,
defaultModel,
modelsLoading,
modelsError,
variantOptions,
defaultVariant,
supportsVariants,
activeModes,
hasSession,
modesLoading,
modesError,
onAgentModeChange,
onPermissionModeChange,
onModelChange,
onVariantChange
}: {
agentMode: string;
permissionMode: string;
model: string;
variant: string;
modelOptions: AgentModelInfo[];
defaultModel: string;
modelsLoading: boolean;
modelsError: string | null;
variantOptions: string[];
defaultVariant: string;
supportsVariants: boolean;
activeModes: AgentModeInfo[];
hasSession: boolean;
modesLoading: boolean;
modesError: string | null;
onAgentModeChange: (value: string) => void;
onPermissionModeChange: (value: string) => void;
onModelChange: (value: string) => void;
onVariantChange: (value: string) => void;
}) => {
const showModelSelect = modelsLoading || Boolean(modelsError) || modelOptions.length > 0;
const hasModelOptions = modelOptions.length > 0;
const showVariantSelect =
supportsVariants && (modelsLoading || Boolean(modelsError) || variantOptions.length > 0);
const hasVariantOptions = variantOptions.length > 0;
const modelCustom =
model && hasModelOptions && !modelOptions.some((entry) => entry.id === model);
const variantCustom =
variant && hasVariantOptions && !variantOptions.includes(variant);
return (
<div className="setup-row">
<div className="setup-field">
<span className="setup-label">Mode</span>
<select
className="setup-select"
value={agentMode}
onChange={(e) => onAgentModeChange(e.target.value)}
title="Mode"
disabled={!hasSession || modesLoading || Boolean(modesError)}
>
{modesLoading ? (
<option value="">Loading modes...</option>
) : modesError ? (
<option value="">{modesError}</option>
) : activeModes.length > 0 ? (
activeModes.map((mode) => (
<option key={mode.id} value={mode.id}>
{mode.name || mode.id}
</option>
))
) : (
<option value="">Mode</option>
)}
</select>
</div>
<div className="setup-field">
<span className="setup-label">Permission</span>
<select
className="setup-select"
value={permissionMode}
onChange={(e) => onPermissionModeChange(e.target.value)}
title="Permission Mode"
disabled={!hasSession}
>
<option value="default">Default</option>
<option value="plan">Plan</option>
<option value="bypass">Bypass</option>
</select>
</div>
<div className="setup-field">
<span className="setup-label">Model</span>
{showModelSelect ? (
<select
className="setup-select"
value={model}
onChange={(e) => onModelChange(e.target.value)}
title="Model"
disabled={!hasSession || modelsLoading || Boolean(modelsError)}
>
{modelsLoading ? (
<option value="">Loading models...</option>
) : modelsError ? (
<option value="">{modelsError}</option>
) : (
<>
<option value="">
{defaultModel ? `Default (${defaultModel})` : "Default"}
</option>
{modelCustom && <option value={model}>{model} (custom)</option>}
{modelOptions.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name ?? entry.id}
</option>
))}
</>
)}
</select>
) : (
<input
className="setup-input"
value={model}
onChange={(e) => onModelChange(e.target.value)}
placeholder="Model"
title="Model"
disabled={!hasSession}
/>
)}
</div>
<div className="setup-field">
<span className="setup-label">Variant</span>
{showVariantSelect ? (
<select
className="setup-select"
value={variant}
onChange={(e) => onVariantChange(e.target.value)}
title="Variant"
disabled={!hasSession || !supportsVariants || modelsLoading || Boolean(modelsError)}
>
{modelsLoading ? (
<option value="">Loading variants...</option>
) : modelsError ? (
<option value="">{modelsError}</option>
) : (
<>
<option value="">
{defaultVariant ? `Default (${defaultVariant})` : "Default"}
</option>
{variantCustom && <option value={variant}>{variant} (custom)</option>}
{variantOptions.map((entry) => (
<option key={entry} value={entry}>
{entry}
</option>
))}
</>
)}
</select>
) : (
<input
className="setup-input"
value={variant}
onChange={(e) => onVariantChange(e.target.value)}
placeholder={supportsVariants ? "Variant" : "Variants unsupported"}
title="Variant"
disabled={!hasSession || !supportsVariants}
/>
)}
</div>
</div>
);
};
export default ChatSetup;

View file

@ -1,4 +1,5 @@
import { Download, RefreshCw } from "lucide-react";
import { Download, Loader2, RefreshCw } from "lucide-react";
import { useState } from "react";
import type { AgentInfo, AgentModeInfo } from "sandbox-agent";
import FeatureCoverageBadges from "../agents/FeatureCoverageBadges";
import { emptyFeatureCoverage } from "../../types/agents";
@ -16,10 +17,21 @@ const AgentsTab = ({
defaultAgents: string[];
modesByAgent: Record<string, AgentModeInfo[]>;
onRefresh: () => void;
onInstall: (agentId: string, reinstall: boolean) => void;
onInstall: (agentId: string, reinstall: boolean) => Promise<void>;
loading: boolean;
error: string | null;
}) => {
const [installingAgent, setInstallingAgent] = useState<string | null>(null);
const handleInstall = async (agentId: string, reinstall: boolean) => {
setInstallingAgent(agentId);
try {
await onInstall(agentId, reinstall);
} finally {
setInstallingAgent(null);
}
};
return (
<>
<div className="inline-row" style={{ marginBottom: 16 }}>
@ -43,42 +55,53 @@ const AgentsTab = ({
version: undefined,
path: undefined,
capabilities: emptyFeatureCoverage
}))).map((agent) => (
<div key={agent.id} className="card">
<div className="card-header">
<span className="card-title">{agent.id}</span>
<span className={`pill ${agent.installed ? "success" : "danger"}`}>
{agent.installed ? "Installed" : "Missing"}
</span>
<span className={`pill ${agent.credentialsAvailable ? "success" : "warning"}`}>
{agent.credentialsAvailable ? "Authenticated" : "No Credentials"}
</span>
</div>
<div className="card-meta">
{agent.version ? `v${agent.version}` : "Version unknown"}
{agent.path && <span className="mono muted" style={{ marginLeft: 8 }}>{agent.path}</span>}
</div>
<div className="card-meta" style={{ marginTop: 8 }}>
Feature coverage
</div>
<div style={{ marginTop: 8 }}>
<FeatureCoverageBadges featureCoverage={agent.capabilities ?? emptyFeatureCoverage} />
</div>
{modesByAgent[agent.id] && modesByAgent[agent.id].length > 0 && (
<div className="card-meta" style={{ marginTop: 8 }}>
Modes: {modesByAgent[agent.id].map((mode) => mode.id).join(", ")}
}))).map((agent) => {
const isInstalling = installingAgent === agent.id;
return (
<div key={agent.id} className="card">
<div className="card-header">
<span className="card-title">{agent.id}</span>
<div className="card-header-pills">
<span className={`pill ${agent.installed ? "success" : "danger"}`}>
{agent.installed ? "Installed" : "Missing"}
</span>
<span className={`pill ${agent.credentialsAvailable ? "success" : "warning"}`}>
{agent.credentialsAvailable ? "Authenticated" : "No Credentials"}
</span>
</div>
</div>
<div className="card-meta">
{agent.version ?? "Version unknown"}
{agent.path && <span className="mono muted" style={{ marginLeft: 8 }}>{agent.path}</span>}
</div>
<div className="card-meta" style={{ marginTop: 8 }}>
Feature coverage
</div>
<div style={{ marginTop: 8 }}>
<FeatureCoverageBadges featureCoverage={agent.capabilities ?? emptyFeatureCoverage} />
</div>
{modesByAgent[agent.id] && modesByAgent[agent.id].length > 0 && (
<div className="card-meta" style={{ marginTop: 8 }}>
Modes: {modesByAgent[agent.id].map((mode) => mode.id).join(", ")}
</div>
)}
<div className="card-actions">
<button
className="button secondary small"
onClick={() => handleInstall(agent.id, agent.installed)}
disabled={isInstalling}
>
{isInstalling ? (
<Loader2 className="button-icon spinner-icon" />
) : (
<Download className="button-icon" />
)}
{isInstalling ? "Installing..." : agent.installed ? "Reinstall" : "Install"}
</button>
</div>
)}
<div className="card-actions">
<button className="button secondary small" onClick={() => onInstall(agent.id, false)}>
<Download className="button-icon" /> Install
</button>
<button className="button ghost small" onClick={() => onInstall(agent.id, true)}>
Reinstall
</button>
</div>
</div>
))}
);
})}
</>
);
};

View file

@ -40,7 +40,7 @@ const DebugPanel = ({
defaultAgents: string[];
modesByAgent: Record<string, AgentModeInfo[]>;
onRefreshAgents: () => void;
onInstallAgent: (agentId: string, reinstall: boolean) => void;
onInstallAgent: (agentId: string, reinstall: boolean) => Promise<void>;
agentsLoading: boolean;
agentsError: string | null;
}) => {

View file

@ -27,8 +27,12 @@ release-build-all:
# =============================================================================
[group('dev')]
dev:
pnpm dev -F @sandbox-agent/inspector
dev-daemon:
SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo run -p sandbox-agent -- daemon start --upgrade
[group('dev')]
dev: dev-daemon
pnpm dev -F @sandbox-agent/inspector -- --host 0.0.0.0
[group('dev')]
build:
@ -60,13 +64,17 @@ install-gigacode:
rm -f ~/.cargo/bin/gigacode
cp target/release/gigacode ~/.cargo/bin/gigacode
[group('dev')]
run-sa *ARGS:
SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo run -p sandbox-agent -- {{ ARGS }}
[group('dev')]
run-gigacode *ARGS:
SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo run -p gigacode -- {{ ARGS }}
[group('dev')]
dev-docs:
cd docs && pnpm dlx mintlify dev
cd docs && pnpm dlx mintlify dev --host 0.0.0.0
install:
pnpm install

1098
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,442 @@
# Universal Agent Configuration Support
Work-in-progress research on configuration features across agents and what can be made universal.
---
## TODO: Features Needed for Full Coverage
### Currently Implemented (in `CreateSessionRequest`)
- [x] `agent` - Agent selection (claude, codex, opencode, amp)
- [x] `agentMode` - Agent mode (plan, build, default)
- [x] `permissionMode` - Permission mode (default, plan, bypass)
- [x] `model` - Model selection
- [x] `variant` - Reasoning variant
- [x] `agentVersion` - Agent version selection
- [x] `mcp` - MCP server configuration (Claude/Codex/OpenCode/Amp)
- [x] `skills` - Skill path configuration (link or copy into agent skill roots)
### Tier 1: Universal Features (High Priority)
- [ ] `projectInstructions` - Inject CLAUDE.md / AGENTS.md content
- Write to appropriate file before agent spawn
- All agents support this natively
- [ ] `workingDirectory` - Set working directory for session
- Currently captures server `cwd` on session creation; not yet user-configurable
- [x] `mcp` - MCP server configuration
- Claude: Writes `.mcp.json` entries under `mcpServers`
- Codex: Updates `.codex/config.toml` with `mcp_servers`
- Amp: Calls `amp mcp add` for each server
- OpenCode: Uses `/mcp` API
- [x] `skills` - Skill path configuration
- Claude: Link to `./.claude/skills/<name>/`
- Codex: Link to `./.agents/skills/<name>/`
- OpenCode: Link to `./.opencode/skill/<name>/` + config `skills.paths`
- Amp: Link to Claude/Codex-style directories
- [ ] `credentials` - Pass credentials via API (not just env vars)
- Currently extracted from host env
- Need API-level credential injection
### Filesystem API (Implemented)
- [x] `/v1/fs` - Read/write/list/move/delete/stat files and upload batches
- Batch upload is tar-only (`application/x-tar`) with path output capped at 1024
- Relative paths resolve from session working dir when `sessionId` is provided
- CLI `sandbox-agent api fs ...` covers all filesystem endpoints
### Message Attachments (Implemented)
- [x] `MessageRequest.attachments` - Attach uploaded files when sending prompts
- OpenCode receives file parts; other agents get attachment paths appended to the prompt
### Tier 2: Partial Support (Medium Priority)
- [ ] `appendSystemPrompt` - High-priority system prompt additions
- Claude: `--append-system-prompt` flag
- Codex: `developer_instructions` config
- OpenCode: Custom agent definition
- Amp: Not supported (fallback to projectInstructions)
- [ ] `resumeSession` / native session resume
- Claude: `--resume SESSION_ID`
- Codex: Thread persistence (automatic)
- OpenCode: `-c/--continue`
- Amp: `--continue SESSION_ID`
### Tier 3: Agent-Specific Pass-through (Low Priority)
- [ ] `agentSpecific.claude` - Raw Claude options
- [ ] `agentSpecific.codex` - Raw Codex options (e.g., `replaceSystemPrompt`)
- [ ] `agentSpecific.opencode` - Raw OpenCode options (e.g., `customAgent`)
- [ ] `agentSpecific.amp` - Raw Amp options (e.g., `permissionRules`)
### Event/Feature Coverage Gaps (from compatibility matrix)
| Feature | Claude | Codex | OpenCode | Amp | Status |
|---------|--------|-------|----------|-----|--------|
| Tool Calls | —* | ✓ | ✓ | ✓ | Claude coming soon |
| Tool Results | —* | ✓ | ✓ | ✓ | Claude coming soon |
| Questions (HITL) | —* | — | ✓ | — | Only OpenCode |
| Permissions (HITL) | —* | — | ✓ | — | Only OpenCode |
| Images | — | ✓ | ✓ | — | 2/4 agents |
| File Attachments | — | ✓ | ✓ | — | 2/4 agents |
| Session Lifecycle | — | ✓ | ✓ | — | 2/4 agents |
| Reasoning/Thinking | — | ✓ | — | — | Codex only |
| Command Execution | — | ✓ | — | — | Codex only |
| File Changes | — | ✓ | — | — | Codex only |
| MCP Tools | ✓ | ✓ | ✓ | ✓ | Supported via session MCP config injection |
| Streaming Deltas | — | ✓ | ✓ | — | 2/4 agents |
\* Claude features marked as "coming imminently"
### Implementation Order (Suggested)
1. **mcp** - Done (session config injection + agent config writers)
2. **skills** - Done (session config injection + skill directory linking)
3. **projectInstructions** - Highest value, all agents support
4. **appendSystemPrompt** - High-priority instructions
5. **workingDirectory** - Basic session configuration
6. **resumeSession** - Session continuity
7. **credentials** - API-level auth injection
8. **agentSpecific** - Escape hatch for edge cases
---
## Legend
- ✅ Native support
- 🔄 Can be adapted/emulated
- ❌ Not supported
- ⚠️ Supported with caveats
---
## 1. Instructions & System Prompt
| Feature | Claude | Codex | OpenCode | Amp | Universal? |
|---------|--------|-------|----------|-----|------------|
| **Project instructions file** | ✅ `CLAUDE.md` | ✅ `AGENTS.md` | 🔄 Config-based | ⚠️ Limited | ✅ Yes - write to agent's file |
| **Append to system prompt** | ✅ `--append-system-prompt` | ✅ `developer_instructions` | 🔄 Custom agent | ❌ | ⚠️ Partial - 3/4 agents |
| **Replace system prompt** | ❌ | ✅ `model_instructions_file` | 🔄 Custom agent | ❌ | ❌ No - Codex only |
| **Hierarchical discovery** | ✅ cwd → root | ✅ root → cwd | ❌ | ❌ | ❌ No - Claude/Codex only |
### Priority Comparison
| Agent | Priority Order (highest → lowest) |
|-------|-----------------------------------|
| Claude | `--append-system-prompt` > base prompt > `CLAUDE.md` |
| Codex | `AGENTS.md` > `developer_instructions` > base prompt |
| OpenCode | Custom agent prompt > base prompt |
| Amp | Server-controlled (opaque) |
### Key Differences
**Claude**: System prompt additions have highest priority. `CLAUDE.md` is injected as first user message (below system prompt).
**Codex**: Project instructions (`AGENTS.md`) have highest priority and can override system prompt. This is the inverse of Claude's model.
---
## 2. Permission Modes
| Feature | Claude | Codex | OpenCode | Amp | Universal? |
|---------|--------|-------|----------|-----|------------|
| **Read-only** | ✅ `plan` | ✅ `read-only` | 🔄 Rulesets | 🔄 Rules | ✅ Yes |
| **Write workspace** | ✅ `acceptEdits` | ✅ `workspace-write` | 🔄 Rulesets | 🔄 Rules | ✅ Yes |
| **Full bypass** | ✅ `--dangerously-skip-permissions` | ✅ `danger-full-access` | 🔄 Allow-all ruleset | ✅ `--dangerously-skip-permissions` | ✅ Yes |
| **Per-tool rules** | ❌ | ❌ | ✅ | ✅ | ❌ No - OpenCode/Amp only |
### Universal Mapping
```typescript
type PermissionMode = "readonly" | "write" | "bypass";
// Maps to:
// Claude: plan | acceptEdits | --dangerously-skip-permissions
// Codex: read-only | workspace-write | danger-full-access
// OpenCode: restrictive ruleset | permissive ruleset | allow-all
// Amp: reject rules | allow rules | dangerouslyAllowAll
```
---
## 3. Agent Modes
| Feature | Claude | Codex | OpenCode | Amp | Universal? |
|---------|--------|-------|----------|-----|------------|
| **Plan mode** | ✅ `--permission-mode plan` | 🔄 Prompt prefix | ✅ `--agent plan` | 🔄 Mode selection | ✅ Yes |
| **Build/execute mode** | ✅ Default | ✅ Default | ✅ `--agent build` | ✅ Default | ✅ Yes |
| **Chat mode** | ❌ | 🔄 Prompt prefix | ❌ | ❌ | ❌ No - Codex only |
| **Custom agents** | ❌ | ❌ | ✅ Config-defined | ❌ | ❌ No - OpenCode only |
---
## 4. Model & Variant Selection
| Feature | Claude | Codex | OpenCode | Amp | Universal? |
|---------|--------|-------|----------|-----|------------|
| **Model selection** | ✅ `--model` | ✅ `-m/--model` | ✅ `-m provider/model` | ⚠️ `--mode` (abstracted) | ⚠️ Partial |
| **Model discovery API** | ✅ Anthropic API | ✅ `model/list` RPC | ✅ `GET /provider` | ❌ Server-side | ⚠️ Partial - 3/4 |
| **Reasoning variants** | ❌ | ✅ `model_reasoning_effort` | ✅ `--variant` | ✅ Deep mode levels | ⚠️ Partial |
---
## 5. MCP & Tools
| Feature | Claude | Codex | OpenCode | Amp | Universal? |
|---------|--------|-------|----------|-----|------------|
| **MCP servers** | ✅ `mcpServers` in settings | ✅ `mcp_servers` in config | ✅ `/mcp` API | ✅ `--toolbox` | ✅ Yes - inject config |
| **Tool restrictions** | ❌ | ❌ | ✅ Per-tool permissions | ✅ Permission rules | ⚠️ Partial |
### MCP Config Mapping
| Agent | Local Server | Remote Server |
|-------|--------------|---------------|
| Claude | `.mcp.json` or `.claude/settings.json``mcpServers` | Same, with `url` |
| Codex | `.codex/config.toml``mcp_servers` | Same schema |
| OpenCode | `/mcp` API with `McpLocalConfig` | `McpRemoteConfig` with `url`, `headers` |
| Amp | `amp mcp add` CLI | Supports remote with headers |
Local MCP servers can be bundled (for example with `tsup`) and uploaded via the filesystem API, then referenced in the session `mcp` config to auto-start and serve custom tools.
---
## 6. Skills & Extensions
| Feature | Claude | Codex | OpenCode | Amp | Universal? |
|---------|--------|-------|----------|-----|------------|
| **Skills/plugins** | ✅ `.claude/skills/` | ✅ `.agents/skills/` | ✅ `.opencode/skill/` | 🔄 Claude-style | ✅ Yes - link dirs |
| **Slash commands** | ✅ `.claude/commands/` | ✅ Custom prompts (deprecated) | ❌ | ❌ | ⚠️ Partial |
### Skill Path Mapping
| Agent | Project Skills | User Skills |
|-------|----------------|-------------|
| Claude | `.claude/skills/<name>/SKILL.md` | `~/.claude/skills/<name>/SKILL.md` |
| Codex | `.agents/skills/` | `~/.agents/skills/` |
| OpenCode | `.opencode/skill/`, `.claude/skills/`, `.agents/skills/` | `~/.config/opencode/skill/` |
| Amp | Uses Claude/Codex directories | — |
---
## 7. Session Management
| Feature | Claude | Codex | OpenCode | Amp | Universal? |
|---------|--------|-------|----------|-----|------------|
| **Resume session** | ✅ `--resume` | ✅ Thread persistence | ✅ `-c/--continue` | ✅ `--continue` | ✅ Yes |
| **Session ID** | ✅ `session_id` | ✅ `thread_id` | ✅ `sessionID` | ✅ `session_id` | ✅ Yes |
---
## 8. Human-in-the-Loop
| Feature | Claude | Codex | OpenCode | Amp | Universal? |
|---------|--------|-------|----------|-----|------------|
| **Permission requests** | ✅ Events | ⚠️ Upfront only | ✅ SSE events | ❌ Pre-configured | ⚠️ Partial |
| **Questions** | ⚠️ Limited in headless | ❌ | ✅ Full support | ❌ | ❌ No - OpenCode best |
---
## 9. Credentials
| Feature | Claude | Codex | OpenCode | Amp | Universal? |
|---------|--------|-------|----------|-----|------------|
| **API key env var** | ✅ `ANTHROPIC_API_KEY` | ✅ `OPENAI_API_KEY` | ✅ Both | ✅ `ANTHROPIC_API_KEY` | ✅ Yes |
| **OAuth tokens** | ✅ | ✅ | ✅ | ✅ | ✅ Yes |
| **Config file auth** | ✅ `~/.claude.json` | ✅ `~/.codex/auth.json` | ✅ `~/.local/share/opencode/auth.json` | ✅ `~/.amp/config.json` | ✅ Yes - extract per agent |
---
## Configuration Files Per Agent
### Claude Code
| File/Location | Purpose |
|---------------|---------|
| `CLAUDE.md` | Project instructions (hierarchical, cwd → root) |
| `~/.claude/CLAUDE.md` | Global user instructions |
| `~/.claude/settings.json` | User settings (permissions, MCP servers, env) |
| `.claude/settings.json` | Project-level settings |
| `.claude/settings.local.json` | Local overrides (gitignored) |
| `~/.claude/commands/` | Custom slash commands (user-level) |
| `.claude/commands/` | Project-level slash commands |
| `~/.claude/skills/` | Installed skills |
| `~/.claude/keybindings.json` | Custom keyboard shortcuts |
| `~/.claude/projects/<hash>/memory/MEMORY.md` | Auto-memory per project |
| `~/.claude.json` | Authentication/credentials |
| `~/.claude.json.api` | API key storage |
### OpenAI Codex
| File/Location | Purpose |
|---------------|---------|
| `AGENTS.md` | Project instructions (hierarchical, root → cwd) |
| `AGENTS.override.md` | Override file (takes precedence) |
| `~/.codex/AGENTS.md` | Global user instructions |
| `~/.codex/AGENTS.override.md` | Global override |
| `~/.codex/config.toml` | User configuration |
| `.codex/config.toml` | Project-level configuration |
| `~/.codex/auth.json` | Authentication/credentials |
Key config.toml options:
- `model` - Default model
- `developer_instructions` - Appended to system prompt
- `model_instructions_file` - Replace entire system prompt
- `project_doc_max_bytes` - Max AGENTS.md size (default 32KB)
- `project_doc_fallback_filenames` - Alternative instruction files
- `mcp_servers` - MCP server configuration
### OpenCode
| File/Location | Purpose |
|---------------|---------|
| `~/.local/share/opencode/auth.json` | Authentication |
| `~/.config/opencode/config.toml` | User configuration |
| `.opencode/config.toml` | Project configuration |
### Amp
| File/Location | Purpose |
|---------------|---------|
| `~/.amp/config.json` | Main configuration |
| `~/.config/amp/settings.json` | Additional settings |
| `.amp/rules.json` | Project permission rules |
---
## Summary: Universalization Tiers
### Tier 1: Fully Universal (implement now)
| Feature | API | Notes |
|---------|-----|-------|
| Project instructions | `projectInstructions: string` | Write to CLAUDE.md / AGENTS.md |
| Permission mode | `permissionMode: "readonly" \| "write" \| "bypass"` | Map to agent-specific flags |
| Agent mode | `agentMode: "plan" \| "build"` | Map to agent-specific mechanisms |
| Model selection | `model: string` | Pass through to agent |
| Resume session | `sessionId: string` | Map to agent's resume flag |
| Credentials | `credentials: { apiKey?, oauthToken? }` | Inject via env vars |
| MCP servers | `mcp: McpConfig` | Write to agent's config (docs drafted) |
| Skills | `skills: { paths: string[] }` | Link to agent's skill dirs (docs drafted) |
### Tier 2: Partial Support (with fallbacks)
| Feature | API | Notes |
|---------|-----|-------|
| Append system prompt | `appendSystemPrompt: string` | Falls back to projectInstructions for Amp |
| Reasoning variant | `variant: string` | Ignored for Claude |
### Tier 3: Agent-Specific (pass-through)
| Feature | Notes |
|---------|-------|
| Replace system prompt | Codex only (`model_instructions_file`) |
| Per-tool permissions | OpenCode/Amp only |
| Custom agents | OpenCode only |
| Hierarchical file discovery | Let agents handle natively |
---
## Recommended Universal API
```typescript
interface UniversalSessionConfig {
// Tier 1 - Universal
agent: "claude" | "codex" | "opencode" | "amp";
model?: string;
permissionMode?: "readonly" | "write" | "bypass";
agentMode?: "plan" | "build";
projectInstructions?: string;
sessionId?: string; // For resume
workingDirectory?: string;
credentials?: {
apiKey?: string;
oauthToken?: string;
};
// MCP servers (docs drafted in docs/mcp.mdx)
mcp?: Record<string, McpServerConfig>;
// Skills (docs drafted in docs/skills.mdx)
skills?: {
paths: string[];
};
// Tier 2 - Partial (with fallbacks)
appendSystemPrompt?: string;
variant?: string;
// Tier 3 - Pass-through
agentSpecific?: {
claude?: { /* raw Claude options */ };
codex?: { replaceSystemPrompt?: string; /* etc */ };
opencode?: { customAgent?: AgentDef; /* etc */ };
amp?: { permissionRules?: Rule[]; /* etc */ };
};
}
interface McpServerConfig {
type: "local" | "remote";
// Local
command?: string;
args?: string[];
env?: Record<string, string>;
timeoutMs?: number;
// Remote
url?: string;
headers?: Record<string, string>;
}
```
---
## Implementation Notes
### Priority Inversion Warning
Claude and Codex have inverted priority for project instructions vs system prompt:
- **Claude**: `--append-system-prompt` > base prompt > `CLAUDE.md`
- **Codex**: `AGENTS.md` > `developer_instructions` > base prompt
This means:
- In Claude, system prompt additions override project files
- In Codex, project files override system prompt additions
When using both `appendSystemPrompt` and `projectInstructions`, document this behavior clearly or consider normalizing by only using one mechanism.
### File Injection Strategy
For `projectInstructions`, sandbox-agent should:
1. Create a temp directory or use session working directory
2. Write instructions to the appropriate file:
- Claude: `.claude/CLAUDE.md` or `CLAUDE.md` in cwd
- Codex: `.codex/AGENTS.md` or `AGENTS.md` in cwd
- OpenCode: Config file or environment
- Amp: Limited - may only influence via context
3. Start agent in that directory
4. Agent discovers and loads instructions automatically
### MCP Server Injection
For `mcp`, sandbox-agent should:
1. Write MCP config to agent's settings file:
- Claude: `.mcp.json` or `.claude/settings.json``mcpServers` key
- Codex: `.codex/config.toml``mcp_servers`
- OpenCode: Call `/mcp` API
- Amp: Run `amp mcp add` or pass via `--toolbox`
2. Ensure MCP server binaries are available in PATH
3. Handle cleanup on session end
### Skill Linking
For `skills.paths`, sandbox-agent should:
1. For each skill path, symlink or copy to agent's skill directory:
- Claude: `.claude/skills/<name>/`
- Codex: `.agents/skills/<name>/`
- OpenCode: Update `skills.paths` in config
2. Skill directory must contain `SKILL.md`
3. Handle cleanup on session end

View file

@ -1,7 +1,7 @@
{
"name": "sandbox-agent",
"version": "0.1.10",
"description": "Universal API for automatic coding agents in sandboxes. Supprots Claude Code, Codex, OpenCode, and Amp.",
"description": "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, and Amp.",
"license": "Apache-2.0",
"repository": {
"type": "git",

View file

@ -8,6 +8,18 @@ import type {
CreateSessionResponse,
EventsQuery,
EventsResponse,
FsActionResponse,
FsDeleteQuery,
FsEntriesQuery,
FsEntry,
FsMoveRequest,
FsMoveResponse,
FsPathQuery,
FsSessionQuery,
FsStat,
FsUploadBatchQuery,
FsUploadBatchResponse,
FsWriteResponse,
HealthResponse,
MessageRequest,
PermissionReplyRequest,
@ -52,6 +64,8 @@ type QueryValue = string | number | boolean | null | undefined;
type RequestOptions = {
query?: Record<string, QueryValue>;
body?: unknown;
rawBody?: BodyInit;
contentType?: string;
headers?: HeadersInit;
accept?: string;
signal?: AbortSignal;
@ -216,6 +230,57 @@ export class SandboxAgent {
await this.requestJson("POST", `${API_PREFIX}/sessions/${encodeURIComponent(sessionId)}/terminate`);
}
async listFsEntries(query?: FsEntriesQuery): Promise<FsEntry[]> {
return this.requestJson("GET", `${API_PREFIX}/fs/entries`, { query });
}
async readFsFile(query: FsPathQuery): Promise<Uint8Array> {
const response = await this.requestRaw("GET", `${API_PREFIX}/fs/file`, {
query,
accept: "application/octet-stream",
});
const buffer = await response.arrayBuffer();
return new Uint8Array(buffer);
}
async writeFsFile(query: FsPathQuery, body: BodyInit): Promise<FsWriteResponse> {
const response = await this.requestRaw("PUT", `${API_PREFIX}/fs/file`, {
query,
rawBody: body,
contentType: "application/octet-stream",
accept: "application/json",
});
const text = await response.text();
return text ? (JSON.parse(text) as FsWriteResponse) : { path: "", bytesWritten: 0 };
}
async deleteFsEntry(query: FsDeleteQuery): Promise<FsActionResponse> {
return this.requestJson("DELETE", `${API_PREFIX}/fs/entry`, { query });
}
async mkdirFs(query: FsPathQuery): Promise<FsActionResponse> {
return this.requestJson("POST", `${API_PREFIX}/fs/mkdir`, { query });
}
async moveFs(request: FsMoveRequest, query?: FsSessionQuery): Promise<FsMoveResponse> {
return this.requestJson("POST", `${API_PREFIX}/fs/move`, { query, body: request });
}
async statFs(query: FsPathQuery): Promise<FsStat> {
return this.requestJson("GET", `${API_PREFIX}/fs/stat`, { query });
}
async uploadFsBatch(body: BodyInit, query?: FsUploadBatchQuery): Promise<FsUploadBatchResponse> {
const response = await this.requestRaw("POST", `${API_PREFIX}/fs/upload-batch`, {
query,
rawBody: body,
contentType: "application/x-tar",
accept: "application/json",
});
const text = await response.text();
return text ? (JSON.parse(text) as FsUploadBatchResponse) : { paths: [], truncated: false };
}
async dispose(): Promise<void> {
if (this.spawnHandle) {
await this.spawnHandle.dispose();
@ -256,7 +321,15 @@ export class SandboxAgent {
}
const init: RequestInit = { method, headers, signal: options.signal };
if (options.body !== undefined) {
if (options.rawBody !== undefined && options.body !== undefined) {
throw new Error("requestRaw received both rawBody and body");
}
if (options.rawBody !== undefined) {
if (options.contentType) {
headers.set("Content-Type", options.contentType);
}
init.body = options.rawBody;
} else if (options.body !== undefined) {
headers.set("Content-Type", "application/json");
init.body = JSON.stringify(options.body);
}

View file

@ -6,48 +6,162 @@
export interface paths {
"/v1/agents": {
/**
* List Agents
* @description Returns all available coding agents and their installation status.
*/
get: operations["list_agents"];
};
"/v1/agents/{agent}/install": {
/**
* Install Agent
* @description Installs or updates a coding agent (e.g. claude, codex, opencode, amp).
*/
post: operations["install_agent"];
};
"/v1/agents/{agent}/models": {
/**
* List Agent Models
* @description Returns the available LLM models for an agent.
*/
get: operations["get_agent_models"];
};
"/v1/agents/{agent}/modes": {
/**
* List Agent Modes
* @description Returns the available interaction modes for an agent.
*/
get: operations["get_agent_modes"];
};
"/v1/fs/entries": {
/**
* List Directory
* @description Lists files and directories at the given path.
*/
get: operations["fs_entries"];
};
"/v1/fs/entry": {
/**
* Delete Entry
* @description Deletes a file or directory.
*/
delete: operations["fs_delete_entry"];
};
"/v1/fs/file": {
/**
* Read File
* @description Reads the raw bytes of a file.
*/
get: operations["fs_read_file"];
/**
* Write File
* @description Writes raw bytes to a file, creating it if it doesn't exist.
*/
put: operations["fs_write_file"];
};
"/v1/fs/mkdir": {
/**
* Create Directory
* @description Creates a directory, including any missing parent directories.
*/
post: operations["fs_mkdir"];
};
"/v1/fs/move": {
/**
* Move Entry
* @description Moves or renames a file or directory.
*/
post: operations["fs_move"];
};
"/v1/fs/stat": {
/**
* Get File Info
* @description Returns metadata (size, timestamps, type) for a path.
*/
get: operations["fs_stat"];
};
"/v1/fs/upload-batch": {
/**
* Upload Files
* @description Uploads a tar.gz archive and extracts it to the destination directory.
*/
post: operations["fs_upload_batch"];
};
"/v1/health": {
/**
* Health Check
* @description Returns the server health status.
*/
get: operations["get_health"];
};
"/v1/sessions": {
/**
* List Sessions
* @description Returns all active sessions.
*/
get: operations["list_sessions"];
};
"/v1/sessions/{session_id}": {
/**
* Create Session
* @description Creates a new agent session with the given configuration.
*/
post: operations["create_session"];
};
"/v1/sessions/{session_id}/events": {
/**
* Get Events
* @description Returns session events with optional offset-based pagination.
*/
get: operations["get_events"];
};
"/v1/sessions/{session_id}/events/sse": {
/**
* Subscribe to Events (SSE)
* @description Opens an SSE stream for real-time session events.
*/
get: operations["get_events_sse"];
};
"/v1/sessions/{session_id}/messages": {
/**
* Send Message
* @description Sends a message to a session and returns immediately.
*/
post: operations["post_message"];
};
"/v1/sessions/{session_id}/messages/stream": {
/**
* Send Message (Streaming)
* @description Sends a message and returns an SSE event stream of the agent's response.
*/
post: operations["post_message_stream"];
};
"/v1/sessions/{session_id}/permissions/{permission_id}/reply": {
/**
* Reply to Permission
* @description Approves or denies a permission request from the agent.
*/
post: operations["reply_permission"];
};
"/v1/sessions/{session_id}/questions/{question_id}/reject": {
/**
* Reject Question
* @description Rejects a human-in-the-loop question from the agent.
*/
post: operations["reject_question"];
};
"/v1/sessions/{session_id}/questions/{question_id}/reply": {
/**
* Reply to Question
* @description Replies to a human-in-the-loop question from the agent.
*/
post: operations["reply_question"];
};
"/v1/sessions/{session_id}/terminate": {
/**
* Terminate Session
* @description Terminates a running session and cleans up resources.
*/
post: operations["terminate_session"];
};
}
@ -76,7 +190,6 @@ export interface components {
textMessages: boolean;
toolCalls: boolean;
toolResults: boolean;
variants: boolean;
};
AgentError: {
agent?: string | null;
@ -170,8 +283,12 @@ export interface components {
agentMode?: string | null;
agentVersion?: string | null;
directory?: string | null;
mcp?: {
[key: string]: components["schemas"]["McpServerConfig"];
} | null;
model?: string | null;
permissionMode?: string | null;
skills?: components["schemas"]["SkillsConfig"] | null;
title?: string | null;
variant?: string | null;
};
@ -202,6 +319,64 @@ export interface components {
};
/** @enum {string} */
FileAction: "read" | "write" | "patch";
FsActionResponse: {
path: string;
};
FsDeleteQuery: {
path: string;
recursive?: boolean | null;
sessionId?: string | null;
};
FsEntriesQuery: {
path?: string | null;
sessionId?: string | null;
};
FsEntry: {
entryType: components["schemas"]["FsEntryType"];
modified?: string | null;
name: string;
path: string;
/** Format: int64 */
size: number;
};
/** @enum {string} */
FsEntryType: "file" | "directory";
FsMoveRequest: {
from: string;
overwrite?: boolean | null;
to: string;
};
FsMoveResponse: {
from: string;
to: string;
};
FsPathQuery: {
path: string;
sessionId?: string | null;
};
FsSessionQuery: {
sessionId?: string | null;
};
FsStat: {
entryType: components["schemas"]["FsEntryType"];
modified?: string | null;
path: string;
/** Format: int64 */
size: number;
};
FsUploadBatchQuery: {
path?: string | null;
sessionId?: string | null;
};
FsUploadBatchResponse: {
paths: string[];
truncated: boolean;
};
FsWriteResponse: {
/** Format: int64 */
bytesWritten: number;
path: string;
};
HealthResponse: {
status: string;
};
@ -219,7 +394,51 @@ export interface components {
ItemRole: "user" | "assistant" | "system" | "tool";
/** @enum {string} */
ItemStatus: "in_progress" | "completed" | "failed";
McpCommand: string | string[];
McpOAuthConfig: {
clientId?: string | null;
clientSecret?: string | null;
scope?: string | null;
};
McpOAuthConfigOrDisabled: components["schemas"]["McpOAuthConfig"] | boolean;
/** @enum {string} */
McpRemoteTransport: "http" | "sse";
McpServerConfig: ({
args?: string[];
command: components["schemas"]["McpCommand"];
cwd?: string | null;
enabled?: boolean | null;
env?: {
[key: string]: string;
} | null;
/** Format: int64 */
timeoutMs?: number | null;
/** @enum {string} */
type: "local";
}) | ({
bearerTokenEnvVar?: string | null;
enabled?: boolean | null;
envHeaders?: {
[key: string]: string;
} | null;
headers?: {
[key: string]: string;
} | null;
oauth?: components["schemas"]["McpOAuthConfigOrDisabled"] | null;
/** Format: int64 */
timeoutMs?: number | null;
transport?: components["schemas"]["McpRemoteTransport"] | null;
/** @enum {string} */
type: "remote";
url: string;
});
MessageAttachment: {
filename?: string | null;
mime?: string | null;
path: string;
};
MessageRequest: {
attachments?: components["schemas"]["MessageAttachment"][];
message: string;
};
PermissionEventData: {
@ -295,10 +514,14 @@ export interface components {
ended: boolean;
/** Format: int64 */
eventCount: number;
mcp?: {
[key: string]: components["schemas"]["McpServerConfig"];
} | null;
model?: string | null;
nativeSessionId?: string | null;
permissionMode: string;
sessionId: string;
skills?: components["schemas"]["SkillsConfig"] | null;
title?: string | null;
/** Format: int64 */
updatedAt: number;
@ -310,6 +533,16 @@ export interface components {
SessionStartedData: {
metadata?: unknown;
};
SkillSource: {
ref?: string | null;
skills?: string[] | null;
source: string;
subpath?: string | null;
type: string;
};
SkillsConfig: {
sources: components["schemas"]["SkillSource"][];
};
StderrOutput: {
/** @description First N lines of stderr (if truncated) or full stderr (if not truncated) */
head?: string | null;
@ -371,8 +604,13 @@ export type external = Record<string, never>;
export interface operations {
/**
* List Agents
* @description Returns all available coding agents and their installation status.
*/
list_agents: {
responses: {
/** @description List of available agents */
200: {
content: {
"application/json": components["schemas"]["AgentListResponse"];
@ -380,6 +618,10 @@ export interface operations {
};
};
};
/**
* Install Agent
* @description Installs or updates a coding agent (e.g. claude, codex, opencode, amp).
*/
install_agent: {
parameters: {
path: {
@ -397,16 +639,19 @@ export interface operations {
204: {
content: never;
};
/** @description Invalid request */
400: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Agent not found */
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Installation failed */
500: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
@ -414,6 +659,10 @@ export interface operations {
};
};
};
/**
* List Agent Models
* @description Returns the available LLM models for an agent.
*/
get_agent_models: {
parameters: {
path: {
@ -422,18 +671,24 @@ export interface operations {
};
};
responses: {
/** @description Available models */
200: {
content: {
"application/json": components["schemas"]["AgentModelsResponse"];
};
};
400: {
/** @description Agent not found */
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
/**
* List Agent Modes
* @description Returns the available interaction modes for an agent.
*/
get_agent_modes: {
parameters: {
path: {
@ -442,11 +697,13 @@ export interface operations {
};
};
responses: {
/** @description Available modes */
200: {
content: {
"application/json": components["schemas"]["AgentModesResponse"];
};
};
/** @description Invalid request */
400: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
@ -454,8 +711,204 @@ export interface operations {
};
};
};
/**
* List Directory
* @description Lists files and directories at the given path.
*/
fs_entries: {
parameters: {
query?: {
/** @description Path to list (relative or absolute) */
path?: string | null;
/** @description Session id for relative paths */
session_id?: string | null;
};
};
responses: {
/** @description Directory listing */
200: {
content: {
"application/json": components["schemas"]["FsEntry"][];
};
};
};
};
/**
* Delete Entry
* @description Deletes a file or directory.
*/
fs_delete_entry: {
parameters: {
query: {
/** @description File or directory path */
path: string;
/** @description Session id for relative paths */
session_id?: string | null;
/** @description Delete directories recursively */
recursive?: boolean | null;
};
};
responses: {
/** @description Delete result */
200: {
content: {
"application/json": components["schemas"]["FsActionResponse"];
};
};
};
};
/**
* Read File
* @description Reads the raw bytes of a file.
*/
fs_read_file: {
parameters: {
query: {
/** @description File path (relative or absolute) */
path: string;
/** @description Session id for relative paths */
session_id?: string | null;
};
};
responses: {
/** @description File content */
200: {
content: {
"application/octet-stream": string;
};
};
};
};
/**
* Write File
* @description Writes raw bytes to a file, creating it if it doesn't exist.
*/
fs_write_file: {
parameters: {
query: {
/** @description File path (relative or absolute) */
path: string;
/** @description Session id for relative paths */
session_id?: string | null;
};
};
requestBody: {
content: {
"application/octet-stream": string;
};
};
responses: {
/** @description Write result */
200: {
content: {
"application/json": components["schemas"]["FsWriteResponse"];
};
};
};
};
/**
* Create Directory
* @description Creates a directory, including any missing parent directories.
*/
fs_mkdir: {
parameters: {
query: {
/** @description Directory path to create */
path: string;
/** @description Session id for relative paths */
session_id?: string | null;
};
};
responses: {
/** @description Directory created */
200: {
content: {
"application/json": components["schemas"]["FsActionResponse"];
};
};
};
};
/**
* Move Entry
* @description Moves or renames a file or directory.
*/
fs_move: {
parameters: {
query?: {
/** @description Session id for relative paths */
session_id?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["FsMoveRequest"];
};
};
responses: {
/** @description Move result */
200: {
content: {
"application/json": components["schemas"]["FsMoveResponse"];
};
};
};
};
/**
* Get File Info
* @description Returns metadata (size, timestamps, type) for a path.
*/
fs_stat: {
parameters: {
query: {
/** @description Path to stat */
path: string;
/** @description Session id for relative paths */
session_id?: string | null;
};
};
responses: {
/** @description File metadata */
200: {
content: {
"application/json": components["schemas"]["FsStat"];
};
};
};
};
/**
* Upload Files
* @description Uploads a tar.gz archive and extracts it to the destination directory.
*/
fs_upload_batch: {
parameters: {
query?: {
/** @description Destination directory for extraction */
path?: string | null;
/** @description Session id for relative paths */
session_id?: string | null;
};
};
requestBody: {
content: {
"application/octet-stream": string;
};
};
responses: {
/** @description Upload result */
200: {
content: {
"application/json": components["schemas"]["FsUploadBatchResponse"];
};
};
};
};
/**
* Health Check
* @description Returns the server health status.
*/
get_health: {
responses: {
/** @description Server is healthy */
200: {
content: {
"application/json": components["schemas"]["HealthResponse"];
@ -463,8 +916,13 @@ export interface operations {
};
};
};
/**
* List Sessions
* @description Returns all active sessions.
*/
list_sessions: {
responses: {
/** @description List of active sessions */
200: {
content: {
"application/json": components["schemas"]["SessionListResponse"];
@ -472,6 +930,10 @@ export interface operations {
};
};
};
/**
* Create Session
* @description Creates a new agent session with the given configuration.
*/
create_session: {
parameters: {
path: {
@ -485,16 +947,19 @@ export interface operations {
};
};
responses: {
/** @description Session created */
200: {
content: {
"application/json": components["schemas"]["CreateSessionResponse"];
};
};
/** @description Invalid request */
400: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Session already exists */
409: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
@ -502,6 +967,10 @@ export interface operations {
};
};
};
/**
* Get Events
* @description Returns session events with optional offset-based pagination.
*/
get_events: {
parameters: {
query?: {
@ -518,11 +987,13 @@ export interface operations {
};
};
responses: {
/** @description Session events */
200: {
content: {
"application/json": components["schemas"]["EventsResponse"];
};
};
/** @description Session not found */
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
@ -530,6 +1001,10 @@ export interface operations {
};
};
};
/**
* Subscribe to Events (SSE)
* @description Opens an SSE stream for real-time session events.
*/
get_events_sse: {
parameters: {
query?: {
@ -550,6 +1025,10 @@ export interface operations {
};
};
};
/**
* Send Message
* @description Sends a message to a session and returns immediately.
*/
post_message: {
parameters: {
path: {
@ -567,6 +1046,7 @@ export interface operations {
204: {
content: never;
};
/** @description Session not found */
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
@ -574,6 +1054,10 @@ export interface operations {
};
};
};
/**
* Send Message (Streaming)
* @description Sends a message and returns an SSE event stream of the agent's response.
*/
post_message_stream: {
parameters: {
query?: {
@ -595,6 +1079,7 @@ export interface operations {
200: {
content: never;
};
/** @description Session not found */
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
@ -602,6 +1087,10 @@ export interface operations {
};
};
};
/**
* Reply to Permission
* @description Approves or denies a permission request from the agent.
*/
reply_permission: {
parameters: {
path: {
@ -621,6 +1110,7 @@ export interface operations {
204: {
content: never;
};
/** @description Session or permission not found */
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
@ -628,6 +1118,10 @@ export interface operations {
};
};
};
/**
* Reject Question
* @description Rejects a human-in-the-loop question from the agent.
*/
reject_question: {
parameters: {
path: {
@ -642,6 +1136,7 @@ export interface operations {
204: {
content: never;
};
/** @description Session or question not found */
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
@ -649,6 +1144,10 @@ export interface operations {
};
};
};
/**
* Reply to Question
* @description Replies to a human-in-the-loop question from the agent.
*/
reply_question: {
parameters: {
path: {
@ -668,6 +1167,7 @@ export interface operations {
204: {
content: never;
};
/** @description Session or question not found */
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
@ -675,6 +1175,10 @@ export interface operations {
};
};
};
/**
* Terminate Session
* @description Terminates a running session and cleans up resources.
*/
terminate_session: {
parameters: {
path: {
@ -687,6 +1191,7 @@ export interface operations {
204: {
content: never;
};
/** @description Session not found */
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];

View file

@ -23,12 +23,26 @@ export type {
EventsQuery,
EventsResponse,
FileAction,
FsActionResponse,
FsDeleteQuery,
FsEntriesQuery,
FsEntry,
FsEntryType,
FsMoveRequest,
FsMoveResponse,
FsPathQuery,
FsSessionQuery,
FsStat,
FsUploadBatchQuery,
FsUploadBatchResponse,
FsWriteResponse,
HealthResponse,
ItemDeltaData,
ItemEventData,
ItemKind,
ItemRole,
ItemStatus,
MessageAttachment,
MessageRequest,
PermissionEventData,
PermissionReply,
@ -50,6 +64,13 @@ export type {
UniversalEventData,
UniversalEventType,
UniversalItem,
McpServerConfig,
McpCommand,
McpRemoteTransport,
McpOAuthConfig,
McpOAuthConfigOrDisabled,
SkillSource,
SkillsConfig,
} from "./types.ts";
export type { components, paths } from "./generated/openapi.ts";
export type { SandboxAgentSpawnOptions, SandboxAgentSpawnLogMode } from "./spawn.ts";

View file

@ -19,6 +19,19 @@ export type EventSource = S["EventSource"];
export type EventsQuery = S["EventsQuery"];
export type EventsResponse = S["EventsResponse"];
export type FileAction = S["FileAction"];
export type FsActionResponse = S["FsActionResponse"];
export type FsDeleteQuery = S["FsDeleteQuery"];
export type FsEntriesQuery = S["FsEntriesQuery"];
export type FsEntry = S["FsEntry"];
export type FsEntryType = S["FsEntryType"];
export type FsMoveRequest = S["FsMoveRequest"];
export type FsMoveResponse = S["FsMoveResponse"];
export type FsPathQuery = S["FsPathQuery"];
export type FsSessionQuery = S["FsSessionQuery"];
export type FsStat = S["FsStat"];
export type FsUploadBatchQuery = S["FsUploadBatchQuery"];
export type FsUploadBatchResponse = S["FsUploadBatchResponse"];
export type FsWriteResponse = S["FsWriteResponse"];
export type HealthResponse = S["HealthResponse"];
export type ItemDeltaData = S["ItemDeltaData"];
export type ItemEventData = S["ItemEventData"];
@ -26,6 +39,7 @@ export type ItemKind = S["ItemKind"];
export type ItemRole = S["ItemRole"];
export type ItemStatus = S["ItemStatus"];
export type MessageRequest = S["MessageRequest"];
export type MessageAttachment = S["MessageAttachment"];
export type PermissionEventData = S["PermissionEventData"];
export type PermissionReply = S["PermissionReply"];
export type PermissionReplyRequest = S["PermissionReplyRequest"];
@ -46,3 +60,11 @@ export type UniversalEvent = S["UniversalEvent"];
export type UniversalEventData = S["UniversalEventData"];
export type UniversalEventType = S["UniversalEventType"];
export type UniversalItem = S["UniversalItem"];
export type McpServerConfig = S["McpServerConfig"];
export type McpCommand = S["McpCommand"];
export type McpRemoteTransport = S["McpRemoteTransport"];
export type McpOAuthConfig = S["McpOAuthConfig"];
export type McpOAuthConfigOrDisabled = S["McpOAuthConfigOrDisabled"];
export type SkillSource = S["SkillSource"];
export type SkillsConfig = S["SkillsConfig"];

View file

@ -2,6 +2,10 @@
See [ARCHITECTURE.md](./ARCHITECTURE.md) for detailed architecture documentation covering the daemon, agent schema pipeline, session management, agent execution patterns, and SDK modes.
## Skill Source Installation
Skills are installed via `skills.sources` in the session create request. The [vercel-labs/skills](https://github.com/vercel-labs/skills) repo (`~/misc/skills`) provides reference for skill installation patterns and source parsing logic. The server handles fetching GitHub repos (via zip download) and git repos (via clone) to `~/.sandbox-agent/skills-cache/`, discovering `SKILL.md` files, and symlinking into agent skill roots.
# Server Testing
## Test placement

View file

@ -743,7 +743,13 @@ fn parse_version_output(output: &std::process::Output) -> Option<String> {
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(|line| line.to_string())
.map(|line| {
// Strip trailing metadata like " (released ...)" from version strings
match line.find(" (") {
Some(pos) => line[..pos].to_string(),
None => line.to_string(),
}
})
}
fn parse_jsonl(text: &str) -> Vec<Value> {

View file

@ -36,6 +36,9 @@ tracing-logfmt.workspace = true
tracing-subscriber.workspace = true
include_dir.workspace = true
base64.workspace = true
toml_edit.workspace = true
tar.workspace = true
zip.workspace = true
tempfile = { workspace = true, optional = true }
[target.'cfg(unix)'.dependencies]

View file

@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command as ProcessCommand, Stdio};
@ -13,12 +14,14 @@ mod build_version {
}
use crate::router::{build_router_with_state, shutdown_servers};
use crate::router::{
AgentInstallRequest, AppState, AuthConfig, BrandingMode, CreateSessionRequest, MessageRequest,
PermissionReply, PermissionReplyRequest, QuestionReplyRequest,
AgentInstallRequest, AppState, AuthConfig, BrandingMode, CreateSessionRequest, McpServerConfig,
MessageRequest, PermissionReply, PermissionReplyRequest, QuestionReplyRequest, SkillSource,
SkillsConfig,
};
use crate::router::{
AgentListResponse, AgentModelsResponse, AgentModesResponse, CreateSessionResponse,
EventsResponse, SessionListResponse,
AgentListResponse, AgentModelsResponse, AgentModesResponse, CreateSessionResponse, EventsResponse,
FsActionResponse, FsEntry, FsMoveRequest, FsMoveResponse, FsStat, FsUploadBatchResponse,
FsWriteResponse, SessionListResponse,
};
use crate::server_logs::ServerLogs;
use crate::telemetry;
@ -176,6 +179,10 @@ pub struct DaemonStartArgs {
#[arg(long, short = 'p', default_value_t = DEFAULT_PORT)]
port: u16,
/// If the daemon is already running but outdated, stop and restart it.
#[arg(long, default_value_t = false)]
upgrade: bool,
}
#[derive(Args, Debug)]
@ -202,6 +209,8 @@ pub enum ApiCommand {
Agents(AgentsArgs),
/// Create sessions and interact with session events.
Sessions(SessionsArgs),
/// Manage filesystem entries.
Fs(FsArgs),
}
#[derive(Subcommand, Debug)]
@ -225,6 +234,12 @@ pub struct SessionsArgs {
command: SessionsCommand,
}
#[derive(Args, Debug)]
pub struct FsArgs {
#[command(subcommand)]
command: FsCommand,
}
#[derive(Subcommand, Debug)]
pub enum AgentsCommand {
/// List all agents and install status.
@ -272,6 +287,27 @@ pub enum SessionsCommand {
ReplyPermission(PermissionReplyArgs),
}
#[derive(Subcommand, Debug)]
pub enum FsCommand {
/// List directory entries.
Entries(FsEntriesArgs),
/// Read a file.
Read(FsReadArgs),
/// Write a file.
Write(FsWriteArgs),
/// Delete a file or directory.
Delete(FsDeleteArgs),
/// Create a directory.
Mkdir(FsMkdirArgs),
/// Move a file or directory.
Move(FsMoveArgs),
/// Stat a file or directory.
Stat(FsStatArgs),
/// Upload a tar archive and extract it.
#[command(name = "upload-batch")]
UploadBatch(FsUploadBatchArgs),
}
#[derive(Args, Debug, Clone)]
pub struct ClientArgs {
#[arg(long, short = 'e')]
@ -323,6 +359,10 @@ pub struct CreateSessionArgs {
variant: Option<String>,
#[arg(long, short = 'A')]
agent_version: Option<String>,
#[arg(long)]
mcp_config: Option<PathBuf>,
#[arg(long)]
skill: Vec<PathBuf>,
#[command(flatten)]
client: ClientArgs,
}
@ -406,6 +446,91 @@ pub struct PermissionReplyArgs {
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct FsEntriesArgs {
#[arg(long)]
path: Option<String>,
#[arg(long)]
session_id: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct FsReadArgs {
path: String,
#[arg(long)]
session_id: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct FsWriteArgs {
path: String,
#[arg(long)]
content: Option<String>,
#[arg(long = "from-file")]
from_file: Option<PathBuf>,
#[arg(long)]
session_id: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct FsDeleteArgs {
path: String,
#[arg(long)]
recursive: bool,
#[arg(long)]
session_id: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct FsMkdirArgs {
path: String,
#[arg(long)]
session_id: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct FsMoveArgs {
from: String,
to: String,
#[arg(long)]
overwrite: bool,
#[arg(long)]
session_id: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct FsStatArgs {
path: String,
#[arg(long)]
session_id: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct FsUploadBatchArgs {
#[arg(long = "tar")]
tar_path: PathBuf,
#[arg(long)]
path: Option<String>,
#[arg(long)]
session_id: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct CredentialsExtractArgs {
#[arg(long, short = 'a', value_enum)]
@ -433,6 +558,8 @@ pub struct CredentialsExtractEnvArgs {
#[derive(Debug, Error)]
pub enum CliError {
#[error("missing --token or --no-token for server mode")]
MissingToken,
#[error("invalid cors origin: {0}")]
InvalidCorsOrigin(String),
#[error("invalid cors method: {0}")]
@ -590,6 +717,7 @@ fn run_api(command: &ApiCommand, cli: &CliConfig) -> Result<(), CliError> {
match command {
ApiCommand::Agents(subcommand) => run_agents(&subcommand.command, cli),
ApiCommand::Sessions(subcommand) => run_sessions(&subcommand.command, cli),
ApiCommand::Fs(subcommand) => run_fs(&subcommand.command, cli),
}
}
@ -672,6 +800,9 @@ fn run_opencode(cli: &CliConfig, args: &OpencodeArgs) -> Result<(), CliError> {
fn run_daemon(command: &DaemonCommand, cli: &CliConfig) -> Result<(), CliError> {
let token = cli.token.as_deref();
match command {
DaemonCommand::Start(args) if args.upgrade => {
crate::daemon::ensure_running(cli, &args.host, args.port, token)
}
DaemonCommand::Start(args) => crate::daemon::start(cli, &args.host, args.port, token),
DaemonCommand::Stop(args) => crate::daemon::stop(&args.host, args.port),
DaemonCommand::Status(args) => {
@ -722,6 +853,33 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr
}
SessionsCommand::Create(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let mcp = if let Some(path) = &args.mcp_config {
let text = std::fs::read_to_string(path)?;
let parsed =
serde_json::from_str::<std::collections::BTreeMap<String, McpServerConfig>>(
&text,
)?;
Some(parsed)
} else {
None
};
let skills = if args.skill.is_empty() {
None
} else {
Some(SkillsConfig {
sources: args
.skill
.iter()
.map(|path| SkillSource {
source_type: "local".to_string(),
source: path.to_string_lossy().to_string(),
skills: None,
git_ref: None,
subpath: None,
})
.collect(),
})
};
let body = CreateSessionRequest {
agent: args.agent.clone(),
agent_mode: args.agent_mode.clone(),
@ -731,6 +889,8 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr
agent_version: args.agent_version.clone(),
directory: None,
title: None,
mcp,
skills,
};
let path = format!("{API_PREFIX}/sessions/{}", args.session_id);
let response = ctx.post(&path, &body)?;
@ -740,6 +900,7 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr
let ctx = ClientContext::new(cli, &args.client)?;
let body = MessageRequest {
message: args.message.clone(),
attachments: Vec::new(),
};
let path = format!("{API_PREFIX}/sessions/{}/messages", args.session_id);
let response = ctx.post(&path, &body)?;
@ -749,6 +910,7 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr
let ctx = ClientContext::new(cli, &args.client)?;
let body = MessageRequest {
message: args.message.clone(),
attachments: Vec::new(),
};
let path = format!("{API_PREFIX}/sessions/{}/messages/stream", args.session_id);
let response = ctx.post_with_query(
@ -845,6 +1007,129 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr
}
}
fn run_fs(command: &FsCommand, cli: &CliConfig) -> Result<(), CliError> {
match command {
FsCommand::Entries(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let response = ctx.get_with_query(
&format!("{API_PREFIX}/fs/entries"),
&[
("path", args.path.clone()),
("session_id", args.session_id.clone()),
],
)?;
print_json_response::<Vec<FsEntry>>(response)
}
FsCommand::Read(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let response = ctx.get_with_query(
&format!("{API_PREFIX}/fs/file"),
&[
("path", Some(args.path.clone())),
("session_id", args.session_id.clone()),
],
)?;
print_binary_response(response)
}
FsCommand::Write(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let body = match (&args.content, &args.from_file) {
(Some(_), Some(_)) => {
return Err(CliError::Server(
"use --content or --from-file, not both".to_string(),
))
}
(None, None) => {
return Err(CliError::Server(
"write requires --content or --from-file".to_string(),
))
}
(Some(content), None) => content.clone().into_bytes(),
(None, Some(path)) => std::fs::read(path)?,
};
let response = ctx.put_raw_with_query(
&format!("{API_PREFIX}/fs/file"),
body,
"application/octet-stream",
&[
("path", Some(args.path.clone())),
("session_id", args.session_id.clone()),
],
)?;
print_json_response::<FsWriteResponse>(response)
}
FsCommand::Delete(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let response = ctx.delete_with_query(
&format!("{API_PREFIX}/fs/entry"),
&[
("path", Some(args.path.clone())),
("session_id", args.session_id.clone()),
(
"recursive",
if args.recursive {
Some("true".to_string())
} else {
None
},
),
],
)?;
print_json_response::<FsActionResponse>(response)
}
FsCommand::Mkdir(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let response = ctx.post_empty_with_query(
&format!("{API_PREFIX}/fs/mkdir"),
&[
("path", Some(args.path.clone())),
("session_id", args.session_id.clone()),
],
)?;
print_json_response::<FsActionResponse>(response)
}
FsCommand::Move(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let body = FsMoveRequest {
from: args.from.clone(),
to: args.to.clone(),
overwrite: if args.overwrite { Some(true) } else { None },
};
let response = ctx.post_with_query(
&format!("{API_PREFIX}/fs/move"),
&body,
&[("session_id", args.session_id.clone())],
)?;
print_json_response::<FsMoveResponse>(response)
}
FsCommand::Stat(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let response = ctx.get_with_query(
&format!("{API_PREFIX}/fs/stat"),
&[
("path", Some(args.path.clone())),
("session_id", args.session_id.clone()),
],
)?;
print_json_response::<FsStat>(response)
}
FsCommand::UploadBatch(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let file = File::open(&args.tar_path)?;
let response = ctx.post_raw_with_query(
&format!("{API_PREFIX}/fs/upload-batch"),
file,
"application/x-tar",
&[
("path", args.path.clone()),
("session_id", args.session_id.clone()),
],
)?;
print_json_response::<FsUploadBatchResponse>(response)
}
}
}
fn create_opencode_session(
base_url: &str,
token: Option<&str>,
@ -1275,9 +1560,75 @@ impl ClientContext {
Ok(request.send()?)
}
fn put_raw_with_query<B: Into<reqwest::blocking::Body>>(
&self,
path: &str,
body: B,
content_type: &str,
query: &[(&str, Option<String>)],
) -> Result<reqwest::blocking::Response, CliError> {
let mut request = self
.request(Method::PUT, path)
.header(reqwest::header::CONTENT_TYPE, content_type)
.header(reqwest::header::ACCEPT, "application/json");
for (key, value) in query {
if let Some(value) = value {
request = request.query(&[(key, value)]);
}
}
Ok(request.body(body).send()?)
}
fn post_empty(&self, path: &str) -> Result<reqwest::blocking::Response, CliError> {
Ok(self.request(Method::POST, path).send()?)
}
fn post_empty_with_query(
&self,
path: &str,
query: &[(&str, Option<String>)],
) -> Result<reqwest::blocking::Response, CliError> {
let mut request = self.request(Method::POST, path);
for (key, value) in query {
if let Some(value) = value {
request = request.query(&[(key, value)]);
}
}
Ok(request.send()?)
}
fn delete_with_query(
&self,
path: &str,
query: &[(&str, Option<String>)],
) -> Result<reqwest::blocking::Response, CliError> {
let mut request = self.request(Method::DELETE, path);
for (key, value) in query {
if let Some(value) = value {
request = request.query(&[(key, value)]);
}
}
Ok(request.send()?)
}
fn post_raw_with_query<B: Into<reqwest::blocking::Body>>(
&self,
path: &str,
body: B,
content_type: &str,
query: &[(&str, Option<String>)],
) -> Result<reqwest::blocking::Response, CliError> {
let mut request = self
.request(Method::POST, path)
.header(reqwest::header::CONTENT_TYPE, content_type)
.header(reqwest::header::ACCEPT, "application/json");
for (key, value) in query {
if let Some(value) = value {
request = request.query(&[(key, value)]);
}
}
Ok(request.body(body).send()?)
}
}
fn print_json_response<T: serde::de::DeserializeOwned + Serialize>(
@ -1310,6 +1661,25 @@ fn print_text_response(response: reqwest::blocking::Response) -> Result<(), CliE
Ok(())
}
fn print_binary_response(response: reqwest::blocking::Response) -> Result<(), CliError> {
let status = response.status();
let bytes = response.bytes()?;
if !status.is_success() {
if let Ok(text) = std::str::from_utf8(&bytes) {
print_error_body(text)?;
} else {
write_stderr_line("Request failed with non-text response body")?;
}
return Err(CliError::HttpStatus(status));
}
let mut out = std::io::stdout();
out.write_all(&bytes)?;
out.flush()?;
Ok(())
}
fn print_empty_response(response: reqwest::blocking::Response) -> Result<(), CliError> {
let status = response.status();
if status.is_success() {

View file

@ -1,5 +1,7 @@
use sandbox_agent::cli::run_sandbox_agent;
fn main() {
if let Err(err) = sandbox_agent::cli::run_sandbox_agent() {
if let Err(err) = run_sandbox_agent() {
tracing::error!(error = %err, "sandbox-agent failed");
std::process::exit(1);
}

View file

@ -524,6 +524,8 @@ async fn ensure_backing_session(
agent_version: None,
directory,
title,
mcp: None,
skills: None,
};
let manager = state.inner.session_manager();
match manager
@ -4264,7 +4266,7 @@ async fn oc_session_message_create(
if let Err(err) = state
.inner
.session_manager()
.send_message(session_id.clone(), prompt_text)
.send_message(session_id.clone(), prompt_text, Vec::new())
.await
{
let mut should_emit_idle = false;

File diff suppressed because it is too large Load diff

View file

@ -186,3 +186,130 @@ async fn agent_endpoints_snapshots() {
});
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_session_with_skill_sources() {
let app = TestApp::new();
// Create a temp skill directory with SKILL.md
let skill_dir = tempfile::tempdir().expect("create skill dir");
let skill_path = skill_dir.path().join("my-test-skill");
std::fs::create_dir_all(&skill_path).expect("create skill subdir");
std::fs::write(skill_path.join("SKILL.md"), "# Test Skill\nA test skill.").expect("write SKILL.md");
// Create session with local skill source
let (status, payload) = send_json(
&app.app,
Method::POST,
"/v1/sessions/skill-test-session",
Some(json!({
"agent": "mock",
"skills": {
"sources": [
{
"type": "local",
"source": skill_dir.path().to_string_lossy()
}
]
}
})),
)
.await;
assert_eq!(status, StatusCode::OK, "create session with skills: {payload}");
assert!(
payload.get("healthy").and_then(Value::as_bool).unwrap_or(false),
"session should be healthy"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_session_with_skill_sources_filter() {
let app = TestApp::new();
// Create a temp directory with two skills
let skill_dir = tempfile::tempdir().expect("create skill dir");
let wanted = skill_dir.path().join("wanted-skill");
let unwanted = skill_dir.path().join("unwanted-skill");
std::fs::create_dir_all(&wanted).expect("create wanted dir");
std::fs::create_dir_all(&unwanted).expect("create unwanted dir");
std::fs::write(wanted.join("SKILL.md"), "# Wanted").expect("write wanted SKILL.md");
std::fs::write(unwanted.join("SKILL.md"), "# Unwanted").expect("write unwanted SKILL.md");
// Create session with filter
let (status, payload) = send_json(
&app.app,
Method::POST,
"/v1/sessions/skill-filter-session",
Some(json!({
"agent": "mock",
"skills": {
"sources": [
{
"type": "local",
"source": skill_dir.path().to_string_lossy(),
"skills": ["wanted-skill"]
}
]
}
})),
)
.await;
assert_eq!(status, StatusCode::OK, "create session with skill filter: {payload}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_session_with_invalid_skill_source() {
let app = TestApp::new();
// Use a non-existent path
let (status, _payload) = send_json(
&app.app,
Method::POST,
"/v1/sessions/skill-invalid-session",
Some(json!({
"agent": "mock",
"skills": {
"sources": [
{
"type": "local",
"source": "/nonexistent/path/to/skills"
}
]
}
})),
)
.await;
// Should fail with a 4xx or 5xx error
assert_ne!(status, StatusCode::OK, "session with invalid skill source should fail");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_session_with_skill_filter_no_match() {
let app = TestApp::new();
let skill_dir = tempfile::tempdir().expect("create skill dir");
let skill_path = skill_dir.path().join("alpha");
std::fs::create_dir_all(&skill_path).expect("create alpha dir");
std::fs::write(skill_path.join("SKILL.md"), "# Alpha").expect("write SKILL.md");
// Filter for a skill that doesn't exist
let (status, _payload) = send_json(
&app.app,
Method::POST,
"/v1/sessions/skill-nomatch-session",
Some(json!({
"agent": "mock",
"skills": {
"sources": [
{
"type": "local",
"source": skill_dir.path().to_string_lossy(),
"skills": ["nonexistent"]
}
]
}
})),
)
.await;
assert_ne!(status, StatusCode::OK, "session with no matching skills should fail");
}

View file

@ -0,0 +1,267 @@
// Filesystem HTTP endpoints.
include!("../common/http.rs");
use std::fs as stdfs;
use tar::{Builder, Header};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_read_write_move_delete() {
let app = TestApp::new();
let cwd = std::env::current_dir().expect("cwd");
let temp = tempfile::tempdir_in(&cwd).expect("tempdir");
let dir_path = temp.path();
let file_path = dir_path.join("hello.txt");
let file_path_str = file_path.to_string_lossy().to_string();
let request = Request::builder()
.method(Method::PUT)
.uri(format!("/v1/fs/file?path={file_path_str}"))
.header(header::CONTENT_TYPE, "application/octet-stream")
.body(Body::from("hello"))
.expect("write request");
let (status, _headers, _payload) = send_json_request(&app.app, request).await;
assert_eq!(status, StatusCode::OK, "write file");
let request = Request::builder()
.method(Method::GET)
.uri(format!("/v1/fs/file?path={file_path_str}"))
.body(Body::empty())
.expect("read request");
let (status, headers, bytes) = send_request(&app.app, request).await;
assert_eq!(status, StatusCode::OK, "read file");
assert_eq!(
headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some("application/octet-stream")
);
assert_eq!(bytes.as_ref(), b"hello");
let entries_path = dir_path.to_string_lossy().to_string();
let (status, entries) = send_json(
&app.app,
Method::GET,
&format!("/v1/fs/entries?path={entries_path}"),
None,
)
.await;
assert_eq!(status, StatusCode::OK, "list entries");
let entry_list = entries.as_array().cloned().unwrap_or_default();
let entry_names: Vec<String> = entry_list
.iter()
.filter_map(|entry| entry.get("name").and_then(|value| value.as_str()))
.map(|value| value.to_string())
.collect();
assert!(entry_names.contains(&"hello.txt".to_string()));
let new_path = dir_path.join("moved.txt");
let new_path_str = new_path.to_string_lossy().to_string();
let (status, _payload) = send_json(
&app.app,
Method::POST,
"/v1/fs/move",
Some(json!({
"from": file_path_str,
"to": new_path_str,
"overwrite": true
})),
)
.await;
assert_eq!(status, StatusCode::OK, "move file");
assert!(new_path.exists(), "moved file exists");
let (status, _payload) = send_json(
&app.app,
Method::DELETE,
&format!("/v1/fs/entry?path={}", new_path.to_string_lossy()),
None,
)
.await;
assert_eq!(status, StatusCode::OK, "delete file");
assert!(!new_path.exists(), "file deleted");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_upload_batch_tar() {
let app = TestApp::new();
let cwd = std::env::current_dir().expect("cwd");
let dest_dir = tempfile::tempdir_in(&cwd).expect("tempdir");
let mut builder = Builder::new(Vec::new());
let mut tar_header = Header::new_gnu();
let contents = b"hello";
tar_header.set_size(contents.len() as u64);
tar_header.set_cksum();
builder
.append_data(&mut tar_header, "a.txt", &contents[..])
.expect("append tar entry");
let mut tar_header = Header::new_gnu();
let contents = b"world";
tar_header.set_size(contents.len() as u64);
tar_header.set_cksum();
builder
.append_data(&mut tar_header, "nested/b.txt", &contents[..])
.expect("append tar entry");
let tar_bytes = builder.into_inner().expect("tar bytes");
let request = Request::builder()
.method(Method::POST)
.uri(format!(
"/v1/fs/upload-batch?path={}",
dest_dir.path().to_string_lossy()
))
.header(header::CONTENT_TYPE, "application/x-tar")
.body(Body::from(tar_bytes))
.expect("tar request");
let (status, _headers, payload) = send_json_request(&app.app, request).await;
assert_eq!(status, StatusCode::OK, "upload batch");
assert!(payload
.get("paths")
.and_then(|value| value.as_array())
.map(|value| !value.is_empty())
.unwrap_or(false));
assert!(payload.get("truncated").and_then(|value| value.as_bool()) == Some(false));
let a_path = dest_dir.path().join("a.txt");
let b_path = dest_dir.path().join("nested").join("b.txt");
assert!(a_path.exists(), "a.txt extracted");
assert!(b_path.exists(), "b.txt extracted");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_relative_paths_use_session_dir() {
let app = TestApp::new();
let session_id = "fs-session";
let status = send_status(
&app.app,
Method::POST,
&format!("/v1/sessions/{session_id}"),
Some(json!({ "agent": "mock" })),
)
.await;
assert_eq!(status, StatusCode::OK, "create session");
let cwd = std::env::current_dir().expect("cwd");
let temp = tempfile::tempdir_in(&cwd).expect("tempdir");
let relative_dir = temp
.path()
.strip_prefix(&cwd)
.expect("strip prefix")
.to_path_buf();
let relative_path = relative_dir.join("session.txt");
let request = Request::builder()
.method(Method::PUT)
.uri(format!(
"/v1/fs/file?session_id={session_id}&path={}",
relative_path.to_string_lossy()
))
.header(header::CONTENT_TYPE, "application/octet-stream")
.body(Body::from("session"))
.expect("write request");
let (status, _headers, _payload) = send_json_request(&app.app, request).await;
assert_eq!(status, StatusCode::OK, "write relative file");
let absolute_path = cwd.join(relative_path);
let content = stdfs::read_to_string(&absolute_path).expect("read file");
assert_eq!(content, "session");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_upload_batch_truncates_paths() {
let app = TestApp::new();
let cwd = std::env::current_dir().expect("cwd");
let dest_dir = tempfile::tempdir_in(&cwd).expect("tempdir");
let mut builder = Builder::new(Vec::new());
for index in 0..1030 {
let mut tar_header = Header::new_gnu();
tar_header.set_size(0);
tar_header.set_cksum();
let name = format!("file_{index}.txt");
builder
.append_data(&mut tar_header, name, &[][..])
.expect("append tar entry");
}
let tar_bytes = builder.into_inner().expect("tar bytes");
let request = Request::builder()
.method(Method::POST)
.uri(format!(
"/v1/fs/upload-batch?path={}",
dest_dir.path().to_string_lossy()
))
.header(header::CONTENT_TYPE, "application/x-tar")
.body(Body::from(tar_bytes))
.expect("tar request");
let (status, _headers, payload) = send_json_request(&app.app, request).await;
assert_eq!(status, StatusCode::OK, "upload batch");
let paths = payload
.get("paths")
.and_then(|value| value.as_array())
.cloned()
.unwrap_or_default();
assert_eq!(paths.len(), 1024);
assert_eq!(payload.get("truncated").and_then(|value| value.as_bool()), Some(true));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_mkdir_stat_and_delete_directory() {
let app = TestApp::new();
let cwd = std::env::current_dir().expect("cwd");
let temp = tempfile::tempdir_in(&cwd).expect("tempdir");
let dir_path = temp.path().join("nested");
let dir_path_str = dir_path.to_string_lossy().to_string();
let status = send_status(
&app.app,
Method::POST,
&format!("/v1/fs/mkdir?path={dir_path_str}"),
None,
)
.await;
assert_eq!(status, StatusCode::OK, "mkdir");
assert!(dir_path.exists(), "directory created");
let (status, stat) = send_json(
&app.app,
Method::GET,
&format!("/v1/fs/stat?path={dir_path_str}"),
None,
)
.await;
assert_eq!(status, StatusCode::OK, "stat directory");
assert_eq!(stat["entryType"], "directory");
let file_path = dir_path.join("note.txt");
stdfs::write(&file_path, "content").expect("write file");
let file_path_str = file_path.to_string_lossy().to_string();
let (status, stat) = send_json(
&app.app,
Method::GET,
&format!("/v1/fs/stat?path={file_path_str}"),
None,
)
.await;
assert_eq!(status, StatusCode::OK, "stat file");
assert_eq!(stat["entryType"], "file");
let status = send_status(
&app.app,
Method::DELETE,
&format!("/v1/fs/entry?path={dir_path_str}&recursive=true"),
None,
)
.await;
assert_eq!(status, StatusCode::OK, "delete directory");
assert!(!dir_path.exists(), "directory deleted");
}

View file

@ -0,0 +1,6 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
assertion_line: 145
expression: snapshot_status(status)
---
status: 204

View file

@ -0,0 +1,5 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
expression: snapshot_status(status)
---
status: 204

View file

@ -0,0 +1,13 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
assertion_line: 185
expression: "normalize_agent_models(&models, config.agent)"
---
defaultInList: true
defaultModel: amp-default
hasDefault: true
hasVariants: false
ids:
- amp-default
modelCount: 1
nonEmpty: true

View file

@ -0,0 +1,9 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
assertion_line: 185
expression: "normalize_agent_models(&models, config.agent)"
---
defaultInList: true
hasDefault: true
hasVariants: "<redacted>"
nonEmpty: true

View file

@ -0,0 +1,9 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
assertion_line: 185
expression: "normalize_agent_models(&models, config.agent)"
---
defaultInList: true
hasDefault: true
hasVariants: false
nonEmpty: true

View file

@ -0,0 +1,8 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
expression: "normalize_agent_models(&models, config.agent)"
---
defaultInList: true
hasDefault: true
hasVariants: "<redacted>"
nonEmpty: true

View file

@ -0,0 +1,9 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
assertion_line: 162
expression: normalize_agent_modes(&modes)
---
modes:
- description: true
id: build
name: Build

View file

@ -0,0 +1,12 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
assertion_line: 162
expression: normalize_agent_modes(&modes)
---
modes:
- description: true
id: build
name: Build
- description: true
id: plan
name: Plan

View file

@ -0,0 +1,14 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
expression: normalize_agent_modes(&modes)
---
modes:
- description: true
id: build
name: Build
- description: true
id: custom
name: Custom
- description: true
id: plan
name: Plan

View file

@ -1,2 +1,4 @@
#[path = "http/agent_endpoints.rs"]
mod agent_endpoints;
#[path = "http/fs_endpoints.rs"]
mod fs_endpoints;

View file

@ -1,77 +0,0 @@
---
source: server/packages/sandbox-agent/tests/sessions/multi_turn.rs
assertion_line: 15
expression: value
---
first:
- metadata: true
seq: 1
session: started
type: session.started
- seq: 2
type: turn.started
- item:
content_types:
- text
kind: message
role: user
status: in_progress
seq: 3
type: item.started
- item:
content_types:
- text
kind: message
role: user
status: completed
seq: 4
type: item.completed
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 5
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 6
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 7
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 8
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 9
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 10
type: item.delta
second:
- seq: 1
type: turn.started
- item:
content_types:
- text
kind: message
role: assistant
status: completed
seq: 2
type: item.completed

View file

@ -1,65 +0,0 @@
---
source: server/packages/sandbox-agent/tests/sessions/permissions.rs
assertion_line: 12
expression: value
---
- metadata: true
seq: 1
session: started
type: session.started
- seq: 2
type: turn.started
- item:
content_types:
- text
kind: message
role: user
status: in_progress
seq: 3
type: item.started
- item:
content_types:
- text
kind: message
role: user
status: completed
seq: 4
type: item.completed
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 5
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 6
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 7
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 8
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 9
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 10
type: item.delta

View file

@ -1,49 +0,0 @@
---
source: server/packages/sandbox-agent/tests/sessions/questions.rs
assertion_line: 12
expression: value
---
- metadata: true
seq: 1
session: started
type: session.started
- seq: 2
type: turn.started
- item:
content_types:
- text
kind: message
role: user
status: in_progress
seq: 3
type: item.started
- item:
content_types:
- text
kind: message
role: user
status: completed
seq: 4
type: item.completed
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 5
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 6
type: item.delta
- item:
content_types:
- text
kind: message
role: assistant
status: completed
seq: 7
type: item.completed

View file

@ -1,79 +0,0 @@
---
source: server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs
assertion_line: 12
expression: value
---
session_a:
- metadata: true
seq: 1
session: started
type: session.started
- seq: 2
type: turn.started
- item:
content_types:
- text
kind: message
role: user
status: in_progress
seq: 3
type: item.started
- item:
content_types:
- text
kind: message
role: user
status: completed
seq: 4
type: item.completed
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 5
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 6
type: item.delta
session_b:
- metadata: true
seq: 1
session: started
type: session.started
- seq: 2
type: turn.started
- item:
content_types:
- text
kind: message
role: user
status: in_progress
seq: 3
type: item.started
- item:
content_types:
- text
kind: message
role: user
status: completed
seq: 4
type: item.completed
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 5
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 6
type: item.delta

View file

@ -0,0 +1,6 @@
---
source: server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs
expression: value
---
hasExpectedFields: true
sessionCount: 1

View file

@ -1,41 +0,0 @@
---
source: server/packages/sandbox-agent/tests/sessions/../common/http.rs
assertion_line: 1001
expression: normalized
---
- metadata: true
seq: 1
session: started
type: session.started
- seq: 2
type: turn.started
- item:
content_types:
- text
kind: message
role: user
status: in_progress
seq: 3
type: item.started
- item:
content_types:
- text
kind: message
role: user
status: completed
seq: 4
type: item.completed
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 5
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 6
type: item.delta