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 Cargo.lock
**/*.rs.bk **/*.rs.bk
# Agent runtime directories
.agents/
.claude/
.opencode/
# Example temp files
.tmp-upload/
# CLI binaries (downloaded during npm publish) # CLI binaries (downloaded during npm publish)
sdks/cli/platforms/*/bin/ 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`. - 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. - 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. - 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) ### 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 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 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 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 ## OpenCode Compatibility Layer

View file

@ -8,7 +8,7 @@ edition = "2021"
authors = [ "Rivet Gaming, LLC <developer@rivet.gg>" ] authors = [ "Rivet Gaming, LLC <developer@rivet.gg>" ]
license = "Apache-2.0" license = "Apache-2.0"
repository = "https://github.com/rivet-dev/sandbox-agent" 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] [workspace.dependencies]
# Internal crates # Internal crates
@ -69,6 +69,7 @@ url = "2.5"
regress = "0.10" regress = "0.10"
include_dir = "0.7" include_dir = "0.7"
base64 = "0.22" base64 = "0.22"
toml_edit = "0.22"
# Code generation (build deps) # Code generation (build deps)
typify = "0.4" 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" title: "CLI Reference"
description: "Complete CLI reference for sandbox-agent." description: "Complete CLI reference for sandbox-agent."
sidebarTitle: "CLI" sidebarTitle: "CLI"
icon: "terminal"
--- ---
## Server ## Server
@ -250,6 +249,8 @@ sandbox-agent api sessions create <SESSION_ID> [OPTIONS]
| `-m, --model <MODEL>` | Model override | | `-m, --model <MODEL>` | Model override |
| `-v, --variant <VARIANT>` | Model variant | | `-v, --variant <VARIANT>` | Model variant |
| `-A, --agent-version <VERSION>` | Agent version | | `-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 ```bash
sandbox-agent api sessions create my-session \ 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 to HTTP Mapping
| CLI Command | HTTP Endpoint | | 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 reply-question` | `POST /v1/sessions/{sessionId}/questions/{questionId}/reply` |
| `api sessions reject-question` | `POST /v1/sessions/{sessionId}/questions/{questionId}/reject` | | `api sessions reject-question` | `POST /v1/sessions/{sessionId}/questions/{questionId}/reject` |
| `api sessions reply-permission` | `POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply` | | `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": { "navbar": {
"links": [ "links": [
{
"label": "Gigacode",
"icon": "terminal",
"href": "https://github.com/rivet-dev/sandbox-agent/tree/main/gigacode"
},
{ {
"label": "Discord", "label": "Discord",
"icon": "discord", "icon": "discord",
"href": "https://discord.gg/auCecybynK" "href": "https://discord.gg/auCecybynK"
}, },
{ {
"label": "GitHub", "type": "github",
"icon": "github",
"href": "https://github.com/rivet-dev/sandbox-agent" "href": "https://github.com/rivet-dev/sandbox-agent"
} }
] ]
}, },
"navigation": { "navigation": {
"pages": [ "tabs": [
{ {
"group": "Getting started", "tab": "Documentation",
"pages": [ "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", "group": "Getting started",
"pages": ["ai/skill", "ai/llms-txt"] "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", "group": "SDKs",
"pages": ["daemon", "cors", "telemetry"] "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", "tab": "HTTP API",
"openapi": "openapi.json" "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" title: "Inspector"
description: "Debug and inspect agent sessions with the Inspector UI." 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. 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." description: "Connect OpenCode clients, SDKs, and web UI to Sandbox Agent."
icon: "rectangle-terminal"
--- ---
<Warning> <Warning>

View file

@ -1,7 +1,6 @@
--- ---
title: "Session Transcript Schema" title: "Session Transcript Schema"
description: "Universal event schema for session transcripts across all agents." 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. 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 | - | ✓ | - | - | | Reasoning/Thinking | - | ✓ | - | - |
| Command Execution | - | ✓ | - | - | | Command Execution | - | ✓ | - | - |
| File Changes | - | ✓ | - | - | | File Changes | - | ✓ | - | - |
| MCP Tools | - | ✓ | - | - | | MCP Tools | ✓ | ✓ | ✓ | ✓ |
| Streaming Deltas | ✓ | ✓ | ✓ | - | | Streaming Deltas | ✓ | ✓ | ✓ | - |
| Variants | | ✓ | ✓ | ✓ | | 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", "$schema": "node_modules/wrangler/config-schema.json",
"name": "sandbox-agent-cloudflare", "name": "sandbox-agent-cloudflare",
"main": "src/cloudflare.ts", "main": "src/index.ts",
"compatibility_date": "2025-01-01", "compatibility_date": "2025-01-01",
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"assets": { "assets": {

View file

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

View file

@ -1,5 +1,6 @@
import { Daytona, Image } from "@daytonaio/sdk"; 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(); const daytona = new Daytona();
@ -24,12 +25,21 @@ await sandbox.process.executeCommand(
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; 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 () => { const cleanup = async () => {
clearInterval(keepAlive);
await sandbox.delete(60); await sandbox.delete(60);
process.exit(0); process.exit(0);
}; };
process.once("SIGINT", cleanup); process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup); process.once("SIGTERM", cleanup);
await runPrompt(baseUrl);
await cleanup();

View file

@ -1,5 +1,6 @@
import { Daytona } from "@daytonaio/sdk"; 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(); const daytona = new Daytona();
@ -25,12 +26,21 @@ await sandbox.process.executeCommand(
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; 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 () => { const cleanup = async () => {
clearInterval(keepAlive);
await sandbox.delete(60); await sandbox.delete(60);
process.exit(0); process.exit(0);
}; };
process.once("SIGINT", cleanup); process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup); process.once("SIGTERM", cleanup);
await runPrompt(baseUrl);
await cleanup();

View file

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

View file

@ -1,5 +1,6 @@
import Docker from "dockerode"; 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 IMAGE = "alpine:latest";
const PORT = 3000; const PORT = 3000;
@ -44,13 +45,19 @@ await container.start();
const baseUrl = `http://127.0.0.1:${PORT}`; const baseUrl = `http://127.0.0.1:${PORT}`;
await waitForHealth({ baseUrl }); 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 () => { const cleanup = async () => {
clearInterval(keepAlive);
try { await container.stop({ t: 5 }); } catch {} try { await container.stop({ t: 5 }); } catch {}
try { await container.remove({ force: true }); } catch {} try { await container.remove({ force: true }); } catch {}
process.exit(0); process.exit(0);
}; };
process.once("SIGINT", cleanup); process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup); process.once("SIGTERM", cleanup);
await runPrompt(baseUrl);
await cleanup();

View file

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

View file

@ -1,5 +1,6 @@
import { Sandbox } from "@e2b/code-interpreter"; 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> = {}; const envs: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; 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..."); console.log("Waiting for server...");
await waitForHealth({ baseUrl }); 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 () => { const cleanup = async () => {
clearInterval(keepAlive);
await sandbox.kill(); await sandbox.kill();
process.exit(0); process.exit(0);
}; };
process.once("SIGINT", cleanup); process.once("SIGINT", cleanup);
process.once("SIGTERM", 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, "private": true,
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/sandbox-agent-client.ts" ".": "./src/sandbox-agent-client.ts",
"./docker": "./src/docker.ts"
}, },
"scripts": { "scripts": {
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"dockerode": "latest",
"sandbox-agent": "workspace:*" "sandbox-agent": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@types/dockerode": "latest",
"@types/node": "latest", "@types/node": "latest",
"typescript": "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. * 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 { setTimeout as delay } from "node:timers/promises";
import { SandboxAgent } from "sandbox-agent";
import type { PermissionEventData, QuestionEventData } from "sandbox-agent";
function normalizeBaseUrl(baseUrl: string): string { function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/+$/, ""); return baseUrl.replace(/\/+$/, "");
@ -27,10 +23,12 @@ export function buildInspectorUrl({
baseUrl, baseUrl,
token, token,
headers, headers,
sessionId,
}: { }: {
baseUrl: string; baseUrl: string;
token?: string; token?: string;
headers?: Record<string, string>; headers?: Record<string, string>;
sessionId?: string;
}): string { }): string {
const normalized = normalizeBaseUrl(ensureUrl(baseUrl)); const normalized = normalizeBaseUrl(ensureUrl(baseUrl));
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -41,7 +39,8 @@ export function buildInspectorUrl({
params.set("headers", JSON.stringify(headers)); params.set("headers", JSON.stringify(headers));
} }
const queryString = params.toString(); const queryString = params.toString();
return `${normalized}/ui/${queryString ? `?${queryString}` : ""}`; const sessionPath = sessionId ? `sessions/${sessionId}` : "";
return `${normalized}/ui/${sessionPath}${queryString ? `?${queryString}` : ""}`;
} }
export function logInspectorUrl({ export function logInspectorUrl({
@ -110,125 +109,39 @@ export async function waitForHealth({
throw (lastError ?? new Error("Timed out waiting for /v1/health")) as Error; 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.SANDBOX_AGENT) return process.env.SANDBOX_AGENT;
if (process.env.ANTHROPIC_API_KEY) return "claude"; const hasClaude = Boolean(
if (process.env.OPENAI_API_KEY) return "codex"; 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"; 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, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "tsx src/vercel.ts", "start": "tsx src/index.ts",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {

View file

@ -1,5 +1,6 @@
import { Sandbox } from "@vercel/sandbox"; 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> = {}; const envs: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; 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..."); console.log("Waiting for server...");
await waitForHealth({ baseUrl }); 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 () => { const cleanup = async () => {
clearInterval(keepAlive);
await sandbox.stop(); await sandbox.stop();
process.exit(0); process.exit(0);
}; };
process.once("SIGINT", cleanup); process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup); process.once("SIGTERM", cleanup);
await runPrompt(baseUrl);
await cleanup();

View file

@ -336,6 +336,12 @@
color: var(--danger); color: var(--danger);
} }
.banner.config-note {
background: rgba(255, 159, 10, 0.12);
border-left: 3px solid var(--warning);
color: var(--warning);
}
.banner.success { .banner.success {
background: rgba(48, 209, 88, 0.1); background: rgba(48, 209, 88, 0.1);
border-left: 3px solid var(--success); border-left: 3px solid var(--success);
@ -471,11 +477,12 @@
position: relative; position: relative;
} }
.sidebar-add-menu { .sidebar-add-menu,
.session-create-menu {
position: absolute; position: absolute;
top: 36px; top: 36px;
left: 0; left: 0;
min-width: 200px; min-width: 220px;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border-2); border: 1px solid var(--border-2);
border-radius: 8px; border-radius: 8px;
@ -487,6 +494,405 @@
z-index: 60; 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 { .sidebar-add-option {
background: transparent; background: transparent;
border: 1px solid transparent; border: 1px solid transparent;
@ -515,12 +921,40 @@
.agent-option-left { .agent-option-left {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start;
gap: 2px; gap: 2px;
min-width: 0; min-width: 0;
} }
.agent-option-name { .agent-option-name {
white-space: nowrap; 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 { .agent-badge {
@ -535,9 +969,6 @@
flex-shrink: 0; flex-shrink: 0;
} }
.agent-badge.version {
color: var(--muted);
}
.sidebar-add-status { .sidebar-add-status {
padding: 6px 8px; padding: 6px 8px;
@ -1043,6 +1474,36 @@
height: 16px; 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 */
.setup-row { .setup-row {
display: flex; display: flex;
@ -1207,6 +1668,29 @@
color: #fff; 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 { .setup-version {
font-size: 10px; font-size: 10px;
color: var(--muted); color: var(--muted);
@ -1311,6 +1795,15 @@
margin-bottom: 0; margin-bottom: 0;
} }
.config-textarea {
min-height: 130px;
}
.config-inline-error {
margin-top: 8px;
margin-bottom: 0;
}
.card-header { .card-header {
display: flex; display: flex;
align-items: center; align-items: center;
@ -1319,6 +1812,16 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.card-header-pills {
display: flex;
align-items: center;
gap: 6px;
}
.spinner-icon {
animation: spin 0.8s linear infinite;
}
.card-title { .card-title {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;

View file

@ -3,11 +3,13 @@ import {
SandboxAgentError, SandboxAgentError,
SandboxAgent, SandboxAgent,
type AgentInfo, type AgentInfo,
type CreateSessionRequest,
type AgentModelInfo, type AgentModelInfo,
type AgentModeInfo, type AgentModeInfo,
type PermissionEventData, type PermissionEventData,
type QuestionEventData, type QuestionEventData,
type SessionInfo, type SessionInfo,
type SkillSource,
type UniversalEvent, type UniversalEvent,
type UniversalItem type UniversalItem
} from "sandbox-agent"; } from "sandbox-agent";
@ -32,6 +34,41 @@ type ItemDeltaEventData = {
delta: string; 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 => { const buildStubItem = (itemId: string, nativeItemId?: string | null): UniversalItem => {
return { return {
item_id: itemId, item_id: itemId,
@ -53,6 +90,23 @@ const getCurrentOriginEndpoint = () => {
return window.location.origin; 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 = () => { const getInitialConnection = () => {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return { endpoint: "http://127.0.0.1:2468", token: "", headers: {} as Record<string, string>, hasUrlParam: false }; 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 [modelsErrorByAgent, setModelsErrorByAgent] = useState<Record<string, string | null>>({});
const [agentId, setAgentId] = useState("claude"); const [agentId, setAgentId] = useState("claude");
const [agentMode, setAgentMode] = useState(""); const [sessionId, setSessionId] = useState(getSessionIdFromPath());
const [permissionMode, setPermissionMode] = useState("default");
const [model, setModel] = useState("");
const [variant, setVariant] = useState("");
const [sessionId, setSessionId] = useState("");
const [sessionError, setSessionError] = useState<string | null>(null); const [sessionError, setSessionError] = useState<string | null>(null);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
@ -115,6 +165,8 @@ export default function App() {
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const offsetRef = useRef(0); const offsetRef = useRef(0);
const [eventsLoading, setEventsLoading] = useState(false); const [eventsLoading, setEventsLoading] = useState(false);
const [mcpServers, setMcpServers] = useState<McpServerEntry[]>([]);
const [skillSources, setSkillSources] = useState<SkillSource[]>([]);
const [polling, setPolling] = useState(false); const [polling, setPolling] = useState(false);
const pollTimerRef = useRef<number | null>(null); const pollTimerRef = useRef<number | null>(null);
@ -377,50 +429,52 @@ export default function App() {
stopSse(); stopSse();
stopTurnStream(); stopTurnStream();
setSessionId(session.sessionId); setSessionId(session.sessionId);
updateSessionPath(session.sessionId);
setAgentId(session.agent); setAgentId(session.agent);
setAgentMode(session.agentMode);
setPermissionMode(session.permissionMode);
setModel(session.model ?? "");
setVariant(session.variant ?? "");
setEvents([]); setEvents([]);
setOffset(0); setOffset(0);
offsetRef.current = 0; offsetRef.current = 0;
setSessionError(null); setSessionError(null);
}; };
const createNewSession = async (nextAgentId?: string) => { const createNewSession = async (
nextAgentId: string,
config: { model: string; agentMode: string; permissionMode: string; variant: string }
) => {
stopPolling(); stopPolling();
stopSse(); stopSse();
stopTurnStream(); stopTurnStream();
const selectedAgent = nextAgentId ?? agentId; setAgentId(nextAgentId);
if (nextAgentId) { if (parsedMcpConfig.error) {
setAgentId(nextAgentId); setSessionError(parsedMcpConfig.error);
return;
} }
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let id = "session-"; let id = "session-";
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
id += chars[Math.floor(Math.random() * chars.length)]; id += chars[Math.floor(Math.random() * chars.length)];
} }
setSessionId(id);
setEvents([]);
setOffset(0);
offsetRef.current = 0;
setSessionError(null); setSessionError(null);
try { try {
const body: { const body: CreateSessionRequest = { agent: nextAgentId };
agent: string; if (config.agentMode) body.agentMode = config.agentMode;
agentMode?: string; if (config.permissionMode) body.permissionMode = config.permissionMode;
permissionMode?: string; if (config.model) body.model = config.model;
model?: string; if (config.variant) body.variant = config.variant;
variant?: string; if (parsedMcpConfig.count > 0) {
} = { agent: selectedAgent }; body.mcp = parsedMcpConfig.value;
if (agentMode) body.agentMode = agentMode; }
if (permissionMode) body.permissionMode = permissionMode; if (parsedSkillsConfig.sources.length > 0) {
if (model) body.model = model; body.skills = parsedSkillsConfig;
if (variant) body.variant = variant; }
await getClient().createSession(id, body); await getClient().createSession(id, body);
setSessionId(id);
updateSessionPath(id);
setEvents([]);
setOffset(0);
offsetRef.current = 0;
await fetchSessions(); await fetchSessions();
} catch (error) { } catch (error) {
setSessionError(getErrorMessage(error, "Unable to create session")); setSessionError(getErrorMessage(error, "Unable to create session"));
@ -876,38 +930,10 @@ export default function App() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [transcriptEntries]); }, [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 currentAgent = agents.find((agent) => agent.id === agentId);
const activeModes = modesByAgent[agentId] ?? []; const currentSessionInfo = sessions.find((s) => s.sessionId === sessionId);
const modesLoading = modesLoadingByAgent[agentId] ?? false; const parsedMcpConfig = useMemo(() => buildMcpConfig(mcpServers), [mcpServers]);
const modesError = modesErrorByAgent[agentId] ?? null; const parsedSkillsConfig = useMemo(() => buildSkillsConfig(skillSources), [skillSources]);
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 agentDisplayNames: Record<string, string> = { const agentDisplayNames: Record<string, string> = {
claude: "Claude Code", claude: "Claude Code",
codex: "Codex", codex: "Codex",
@ -917,6 +943,15 @@ export default function App() {
}; };
const agentLabel = agentDisplayNames[agentId] ?? agentId; 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>) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
@ -980,17 +1015,28 @@ export default function App() {
onSelectSession={selectSession} onSelectSession={selectSession}
onRefresh={fetchSessions} onRefresh={fetchSessions}
onCreateSession={createNewSession} onCreateSession={createNewSession}
onSelectAgent={handleSelectAgent}
agents={agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, capabilities: {} }) as AgentInfo)} agents={agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, capabilities: {} }) as AgentInfo)}
agentsLoading={agentsLoading} agentsLoading={agentsLoading}
agentsError={agentsError} agentsError={agentsError}
sessionsLoading={sessionsLoading} sessionsLoading={sessionsLoading}
sessionsError={sessionsError} 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 <ChatPanel
sessionId={sessionId} sessionId={sessionId}
polling={polling}
turnStreaming={turnStreaming}
transcriptEntries={transcriptEntries} transcriptEntries={transcriptEntries}
sessionError={sessionError} sessionError={sessionError}
message={message} message={message}
@ -998,36 +1044,19 @@ export default function App() {
onSendMessage={sendMessage} onSendMessage={sendMessage}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onCreateSession={createNewSession} onCreateSession={createNewSession}
onSelectAgent={handleSelectAgent}
agents={agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, capabilities: {} }) as AgentInfo)} agents={agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, capabilities: {} }) as AgentInfo)}
agentsLoading={agentsLoading} agentsLoading={agentsLoading}
agentsError={agentsError} agentsError={agentsError}
messagesEndRef={messagesEndRef} messagesEndRef={messagesEndRef}
agentId={agentId}
agentLabel={agentLabel} 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} currentAgentVersion={currentAgent?.version ?? null}
modesLoading={modesLoading} sessionModel={currentSessionInfo?.model ?? null}
modesError={modesError} sessionVariant={currentSessionInfo?.variant ?? null}
onAgentModeChange={setAgentMode} sessionPermissionMode={currentSessionInfo?.permissionMode ?? null}
onPermissionModeChange={setPermissionMode} sessionMcpServerCount={currentSessionInfo?.mcp ? Object.keys(currentSessionInfo.mcp).length : 0}
onModelChange={setModel} sessionSkillSourceCount={currentSessionInfo?.skills?.sources?.length ?? 0}
onVariantChange={setVariant}
onStreamModeChange={setStreamMode}
onToggleStream={toggleStream}
onEndSession={endSession} onEndSession={endSession}
hasSession={Boolean(sessionId)}
eventError={eventError} eventError={eventError}
questionRequests={questionRequests} questionRequests={questionRequests}
permissionRequests={permissionRequests} permissionRequests={permissionRequests}
@ -1036,6 +1065,18 @@ export default function App() {
onAnswerQuestion={answerQuestion} onAnswerQuestion={answerQuestion}
onRejectQuestion={rejectQuestion} onRejectQuestion={rejectQuestion}
onReplyPermission={replyPermission} 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 <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 { Plus, RefreshCw } from "lucide-react";
import { useEffect, useRef, useState } from "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 = ({ const SessionSidebar = ({
sessions, sessions,
@ -8,22 +18,48 @@ const SessionSidebar = ({
onSelectSession, onSelectSession,
onRefresh, onRefresh,
onCreateSession, onCreateSession,
onSelectAgent,
agents, agents,
agentsLoading, agentsLoading,
agentsError, agentsError,
sessionsLoading, sessionsLoading,
sessionsError sessionsError,
modesByAgent,
modelsByAgent,
defaultModelByAgent,
modesLoadingByAgent,
modelsLoadingByAgent,
modesErrorByAgent,
modelsErrorByAgent,
mcpServers,
onMcpServersChange,
mcpConfigError,
skillSources,
onSkillSourcesChange
}: { }: {
sessions: SessionInfo[]; sessions: SessionInfo[];
selectedSessionId: string; selectedSessionId: string;
onSelectSession: (session: SessionInfo) => void; onSelectSession: (session: SessionInfo) => void;
onRefresh: () => void; onRefresh: () => void;
onCreateSession: (agentId: string) => void; onCreateSession: (agentId: string, config: SessionConfig) => void;
onSelectAgent: (agentId: string) => void;
agents: AgentInfo[]; agents: AgentInfo[];
agentsLoading: boolean; agentsLoading: boolean;
agentsError: string | null; agentsError: string | null;
sessionsLoading: boolean; sessionsLoading: boolean;
sessionsError: string | null; 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 [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
@ -40,14 +76,6 @@ const SessionSidebar = ({
return () => document.removeEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler);
}, [showMenu]); }, [showMenu]);
const agentLabels: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode",
amp: "Amp",
mock: "Mock"
};
return ( return (
<div className="session-sidebar"> <div className="session-sidebar">
<div className="sidebar-header"> <div className="sidebar-header">
@ -64,32 +92,27 @@ const SessionSidebar = ({
> >
<Plus size={14} /> <Plus size={14} />
</button> </button>
{showMenu && ( <SessionCreateMenu
<div className="sidebar-add-menu"> agents={agents}
{agentsLoading && <div className="sidebar-add-status">Loading agents...</div>} agentsLoading={agentsLoading}
{agentsError && <div className="sidebar-add-status error">{agentsError}</div>} agentsError={agentsError}
{!agentsLoading && !agentsError && agents.length === 0 && ( modesByAgent={modesByAgent}
<div className="sidebar-add-status">No agents available.</div> modelsByAgent={modelsByAgent}
)} defaultModelByAgent={defaultModelByAgent}
{!agentsLoading && !agentsError && modesLoadingByAgent={modesLoadingByAgent}
agents.map((agent) => ( modelsLoadingByAgent={modelsLoadingByAgent}
<button modesErrorByAgent={modesErrorByAgent}
key={agent.id} modelsErrorByAgent={modelsErrorByAgent}
className="sidebar-add-option" mcpServers={mcpServers}
onClick={() => { onMcpServersChange={onMcpServersChange}
onCreateSession(agent.id); mcpConfigError={mcpConfigError}
setShowMenu(false); skillSources={skillSources}
}} onSkillSourcesChange={onSkillSourcesChange}
> onSelectAgent={onSelectAgent}
<div className="agent-option-left"> onCreateSession={onCreateSession}
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span> open={showMenu}
{agent.version && <span className="agent-badge version">v{agent.version}</span>} onClose={() => setShowMenu(false)}
</div> />
{agent.installed && <span className="agent-badge installed">Installed</span>}
</button>
))}
</div>
)}
</div> </div>
</div> </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 { 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 ApprovalsTab from "../debug/ApprovalsTab";
import SessionCreateMenu, { type SessionConfig } from "../SessionCreateMenu";
import ChatInput from "./ChatInput"; import ChatInput from "./ChatInput";
import ChatMessages from "./ChatMessages"; import ChatMessages from "./ChatMessages";
import ChatSetup from "./ChatSetup";
import type { TimelineEntry } from "./types"; import type { TimelineEntry } from "./types";
const ChatPanel = ({ const ChatPanel = ({
sessionId, sessionId,
polling,
turnStreaming,
transcriptEntries, transcriptEntries,
sessionError, sessionError,
message, message,
@ -18,35 +17,18 @@ const ChatPanel = ({
onSendMessage, onSendMessage,
onKeyDown, onKeyDown,
onCreateSession, onCreateSession,
onSelectAgent,
agents, agents,
agentsLoading, agentsLoading,
agentsError, agentsError,
messagesEndRef, messagesEndRef,
agentId,
agentLabel, agentLabel,
agentMode,
permissionMode,
model,
variant,
modelOptions,
defaultModel,
modelsLoading,
modelsError,
variantOptions,
defaultVariant,
supportsVariants,
streamMode,
activeModes,
currentAgentVersion, currentAgentVersion,
hasSession, sessionModel,
modesLoading, sessionVariant,
modesError, sessionPermissionMode,
onAgentModeChange, sessionMcpServerCount,
onPermissionModeChange, sessionSkillSourceCount,
onModelChange,
onVariantChange,
onStreamModeChange,
onToggleStream,
onEndSession, onEndSession,
eventError, eventError,
questionRequests, questionRequests,
@ -55,47 +37,40 @@ const ChatPanel = ({
onSelectQuestionOption, onSelectQuestionOption,
onAnswerQuestion, onAnswerQuestion,
onRejectQuestion, onRejectQuestion,
onReplyPermission onReplyPermission,
modesByAgent,
modelsByAgent,
defaultModelByAgent,
modesLoadingByAgent,
modelsLoadingByAgent,
modesErrorByAgent,
modelsErrorByAgent,
mcpServers,
onMcpServersChange,
mcpConfigError,
skillSources,
onSkillSourcesChange
}: { }: {
sessionId: string; sessionId: string;
polling: boolean;
turnStreaming: boolean;
transcriptEntries: TimelineEntry[]; transcriptEntries: TimelineEntry[];
sessionError: string | null; sessionError: string | null;
message: string; message: string;
onMessageChange: (value: string) => void; onMessageChange: (value: string) => void;
onSendMessage: () => void; onSendMessage: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void; onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onCreateSession: (agentId: string) => void; onCreateSession: (agentId: string, config: SessionConfig) => void;
onSelectAgent: (agentId: string) => void;
agents: AgentInfo[]; agents: AgentInfo[];
agentsLoading: boolean; agentsLoading: boolean;
agentsError: string | null; agentsError: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>; messagesEndRef: React.RefObject<HTMLDivElement>;
agentId: string;
agentLabel: 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; currentAgentVersion?: string | null;
hasSession: boolean; sessionModel?: string | null;
modesLoading: boolean; sessionVariant?: string | null;
modesError: string | null; sessionPermissionMode?: string | null;
onAgentModeChange: (value: string) => void; sessionMcpServerCount: number;
onPermissionModeChange: (value: string) => void; sessionSkillSourceCount: number;
onModelChange: (value: string) => void;
onVariantChange: (value: string) => void;
onStreamModeChange: (value: "poll" | "sse" | "turn") => void;
onToggleStream: () => void;
onEndSession: () => void; onEndSession: () => void;
eventError: string | null; eventError: string | null;
questionRequests: QuestionEventData[]; questionRequests: QuestionEventData[];
@ -105,6 +80,18 @@ const ChatPanel = ({
onAnswerQuestion: (request: QuestionEventData) => void; onAnswerQuestion: (request: QuestionEventData) => void;
onRejectQuestion: (requestId: string) => void; onRejectQuestion: (requestId: string) => void;
onReplyPermission: (requestId: string, reply: "once" | "always" | "reject") => 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 [showAgentMenu, setShowAgentMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
@ -121,18 +108,7 @@ const ChatPanel = ({
return () => document.removeEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler);
}, [showAgentMenu]); }, [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 hasApprovals = questionRequests.length > 0 || permissionRequests.length > 0;
const isTurnMode = streamMode === "turn";
const isStreaming = isTurnMode ? turnStreaming : polling;
const turnLabel = turnStreaming ? "Streaming" : "On Send";
return ( return (
<div className="chat-panel"> <div className="chat-panel">
@ -141,12 +117,6 @@ const ChatPanel = ({
<MessageSquare className="button-icon" /> <MessageSquare className="button-icon" />
<span className="panel-title">{sessionId ? "Session" : "No Session"}</span> <span className="panel-title">{sessionId ? "Session" : "No Session"}</span>
{sessionId && <span className="session-id-display">{sessionId}</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>
<div className="panel-header-right"> <div className="panel-header-right">
{sessionId && ( {sessionId && (
@ -160,42 +130,6 @@ const ChatPanel = ({
End End
</button> </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>
</div> </div>
@ -213,32 +147,27 @@ const ChatPanel = ({
<Plus className="button-icon" /> <Plus className="button-icon" />
Create Session Create Session
</button> </button>
{showAgentMenu && ( <SessionCreateMenu
<div className="empty-state-menu"> agents={agents}
{agentsLoading && <div className="sidebar-add-status">Loading agents...</div>} agentsLoading={agentsLoading}
{agentsError && <div className="sidebar-add-status error">{agentsError}</div>} agentsError={agentsError}
{!agentsLoading && !agentsError && agents.length === 0 && ( modesByAgent={modesByAgent}
<div className="sidebar-add-status">No agents available.</div> modelsByAgent={modelsByAgent}
)} defaultModelByAgent={defaultModelByAgent}
{!agentsLoading && !agentsError && modesLoadingByAgent={modesLoadingByAgent}
agents.map((agent) => ( modelsLoadingByAgent={modelsLoadingByAgent}
<button modesErrorByAgent={modesErrorByAgent}
key={agent.id} modelsErrorByAgent={modelsErrorByAgent}
className="sidebar-add-option" mcpServers={mcpServers}
onClick={() => { onMcpServersChange={onMcpServersChange}
onCreateSession(agent.id); mcpConfigError={mcpConfigError}
setShowAgentMenu(false); skillSources={skillSources}
}} onSkillSourcesChange={onSkillSourcesChange}
> onSelectAgent={onSelectAgent}
<div className="agent-option-left"> onCreateSession={onCreateSession}
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span> open={showAgentMenu}
{agent.version && <span className="agent-badge version">v{agent.version}</span>} onClose={() => setShowAgentMenu(false)}
</div> />
{agent.installed && <span className="agent-badge installed">Installed</span>}
</button>
))}
</div>
)}
</div> </div>
</div> </div>
) : transcriptEntries.length === 0 && !sessionError ? ( ) : transcriptEntries.length === 0 && !sessionError ? (
@ -246,7 +175,7 @@ const ChatPanel = ({
<Terminal className="empty-state-icon" /> <Terminal className="empty-state-icon" />
<div className="empty-state-title">Ready to Chat</div> <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> <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"> <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. The mock agent simulates agent responses for testing the inspector UI without requiring API credentials. Send <code>help</code> for available commands.
</div> </div>
@ -283,30 +212,37 @@ const ChatPanel = ({
onSendMessage={onSendMessage} onSendMessage={onSendMessage}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder={sessionId ? "Send a message..." : "Select or create a session first"} placeholder={sessionId ? "Send a message..." : "Select or create a session first"}
disabled={!sessionId || turnStreaming} disabled={!sessionId}
/> />
<ChatSetup {sessionId && (
agentMode={agentMode} <div className="session-config-bar">
permissionMode={permissionMode} <div className="session-config-field">
model={model} <span className="session-config-label">Agent</span>
variant={variant} <span className="session-config-value">{agentLabel}</span>
modelOptions={modelOptions} </div>
defaultModel={defaultModel} <div className="session-config-field">
modelsLoading={modelsLoading} <span className="session-config-label">Model</span>
modelsError={modelsError} <span className="session-config-value">{sessionModel || "-"}</span>
variantOptions={variantOptions} </div>
defaultVariant={defaultVariant} <div className="session-config-field">
supportsVariants={supportsVariants} <span className="session-config-label">Variant</span>
activeModes={activeModes} <span className="session-config-value">{sessionVariant || "-"}</span>
modesLoading={modesLoading} </div>
modesError={modesError} <div className="session-config-field">
onAgentModeChange={onAgentModeChange} <span className="session-config-label">Permission</span>
onPermissionModeChange={onPermissionModeChange} <span className="session-config-value">{sessionPermissionMode || "-"}</span>
onModelChange={onModelChange} </div>
onVariantChange={onVariantChange} <div className="session-config-field">
hasSession={hasSession} <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> </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 type { AgentInfo, AgentModeInfo } from "sandbox-agent";
import FeatureCoverageBadges from "../agents/FeatureCoverageBadges"; import FeatureCoverageBadges from "../agents/FeatureCoverageBadges";
import { emptyFeatureCoverage } from "../../types/agents"; import { emptyFeatureCoverage } from "../../types/agents";
@ -16,10 +17,21 @@ const AgentsTab = ({
defaultAgents: string[]; defaultAgents: string[];
modesByAgent: Record<string, AgentModeInfo[]>; modesByAgent: Record<string, AgentModeInfo[]>;
onRefresh: () => void; onRefresh: () => void;
onInstall: (agentId: string, reinstall: boolean) => void; onInstall: (agentId: string, reinstall: boolean) => Promise<void>;
loading: boolean; loading: boolean;
error: string | null; 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 ( return (
<> <>
<div className="inline-row" style={{ marginBottom: 16 }}> <div className="inline-row" style={{ marginBottom: 16 }}>
@ -43,42 +55,53 @@ const AgentsTab = ({
version: undefined, version: undefined,
path: undefined, path: undefined,
capabilities: emptyFeatureCoverage capabilities: emptyFeatureCoverage
}))).map((agent) => ( }))).map((agent) => {
<div key={agent.id} className="card"> const isInstalling = installingAgent === agent.id;
<div className="card-header"> return (
<span className="card-title">{agent.id}</span> <div key={agent.id} className="card">
<span className={`pill ${agent.installed ? "success" : "danger"}`}> <div className="card-header">
{agent.installed ? "Installed" : "Missing"} <span className="card-title">{agent.id}</span>
</span> <div className="card-header-pills">
<span className={`pill ${agent.credentialsAvailable ? "success" : "warning"}`}> <span className={`pill ${agent.installed ? "success" : "danger"}`}>
{agent.credentialsAvailable ? "Authenticated" : "No Credentials"} {agent.installed ? "Installed" : "Missing"}
</span> </span>
</div> <span className={`pill ${agent.credentialsAvailable ? "success" : "warning"}`}>
<div className="card-meta"> {agent.credentialsAvailable ? "Authenticated" : "No Credentials"}
{agent.version ? `v${agent.version}` : "Version unknown"} </span>
{agent.path && <span className="mono muted" style={{ marginLeft: 8 }}>{agent.path}</span>} </div>
</div> </div>
<div className="card-meta" style={{ marginTop: 8 }}> <div className="card-meta">
Feature coverage {agent.version ?? "Version unknown"}
</div> {agent.path && <span className="mono muted" style={{ marginLeft: 8 }}>{agent.path}</span>}
<div style={{ marginTop: 8 }}> </div>
<FeatureCoverageBadges featureCoverage={agent.capabilities ?? emptyFeatureCoverage} /> <div className="card-meta" style={{ marginTop: 8 }}>
</div> Feature coverage
{modesByAgent[agent.id] && modesByAgent[agent.id].length > 0 && ( </div>
<div className="card-meta" style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
Modes: {modesByAgent[agent.id].map((mode) => mode.id).join(", ")} <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>
)}
<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>
</div> );
))} })}
</> </>
); );
}; };

View file

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

View file

@ -27,8 +27,12 @@ release-build-all:
# ============================================================================= # =============================================================================
[group('dev')] [group('dev')]
dev: dev-daemon:
pnpm dev -F @sandbox-agent/inspector 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')] [group('dev')]
build: build:
@ -60,13 +64,17 @@ install-gigacode:
rm -f ~/.cargo/bin/gigacode rm -f ~/.cargo/bin/gigacode
cp target/release/gigacode ~/.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')] [group('dev')]
run-gigacode *ARGS: run-gigacode *ARGS:
SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo run -p gigacode -- {{ ARGS }} SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo run -p gigacode -- {{ ARGS }}
[group('dev')] [group('dev')]
dev-docs: dev-docs:
cd docs && pnpm dlx mintlify dev cd docs && pnpm dlx mintlify dev --host 0.0.0.0
install: install:
pnpm 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", "name": "sandbox-agent",
"version": "0.1.10", "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", "license": "Apache-2.0",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -8,6 +8,18 @@ import type {
CreateSessionResponse, CreateSessionResponse,
EventsQuery, EventsQuery,
EventsResponse, EventsResponse,
FsActionResponse,
FsDeleteQuery,
FsEntriesQuery,
FsEntry,
FsMoveRequest,
FsMoveResponse,
FsPathQuery,
FsSessionQuery,
FsStat,
FsUploadBatchQuery,
FsUploadBatchResponse,
FsWriteResponse,
HealthResponse, HealthResponse,
MessageRequest, MessageRequest,
PermissionReplyRequest, PermissionReplyRequest,
@ -52,6 +64,8 @@ type QueryValue = string | number | boolean | null | undefined;
type RequestOptions = { type RequestOptions = {
query?: Record<string, QueryValue>; query?: Record<string, QueryValue>;
body?: unknown; body?: unknown;
rawBody?: BodyInit;
contentType?: string;
headers?: HeadersInit; headers?: HeadersInit;
accept?: string; accept?: string;
signal?: AbortSignal; signal?: AbortSignal;
@ -216,6 +230,57 @@ export class SandboxAgent {
await this.requestJson("POST", `${API_PREFIX}/sessions/${encodeURIComponent(sessionId)}/terminate`); 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> { async dispose(): Promise<void> {
if (this.spawnHandle) { if (this.spawnHandle) {
await this.spawnHandle.dispose(); await this.spawnHandle.dispose();
@ -256,7 +321,15 @@ export class SandboxAgent {
} }
const init: RequestInit = { method, headers, signal: options.signal }; 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"); headers.set("Content-Type", "application/json");
init.body = JSON.stringify(options.body); init.body = JSON.stringify(options.body);
} }

View file

@ -6,48 +6,162 @@
export interface paths { export interface paths {
"/v1/agents": { "/v1/agents": {
/**
* List Agents
* @description Returns all available coding agents and their installation status.
*/
get: operations["list_agents"]; get: operations["list_agents"];
}; };
"/v1/agents/{agent}/install": { "/v1/agents/{agent}/install": {
/**
* Install Agent
* @description Installs or updates a coding agent (e.g. claude, codex, opencode, amp).
*/
post: operations["install_agent"]; post: operations["install_agent"];
}; };
"/v1/agents/{agent}/models": { "/v1/agents/{agent}/models": {
/**
* List Agent Models
* @description Returns the available LLM models for an agent.
*/
get: operations["get_agent_models"]; get: operations["get_agent_models"];
}; };
"/v1/agents/{agent}/modes": { "/v1/agents/{agent}/modes": {
/**
* List Agent Modes
* @description Returns the available interaction modes for an agent.
*/
get: operations["get_agent_modes"]; 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": { "/v1/health": {
/**
* Health Check
* @description Returns the server health status.
*/
get: operations["get_health"]; get: operations["get_health"];
}; };
"/v1/sessions": { "/v1/sessions": {
/**
* List Sessions
* @description Returns all active sessions.
*/
get: operations["list_sessions"]; get: operations["list_sessions"];
}; };
"/v1/sessions/{session_id}": { "/v1/sessions/{session_id}": {
/**
* Create Session
* @description Creates a new agent session with the given configuration.
*/
post: operations["create_session"]; post: operations["create_session"];
}; };
"/v1/sessions/{session_id}/events": { "/v1/sessions/{session_id}/events": {
/**
* Get Events
* @description Returns session events with optional offset-based pagination.
*/
get: operations["get_events"]; get: operations["get_events"];
}; };
"/v1/sessions/{session_id}/events/sse": { "/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"]; get: operations["get_events_sse"];
}; };
"/v1/sessions/{session_id}/messages": { "/v1/sessions/{session_id}/messages": {
/**
* Send Message
* @description Sends a message to a session and returns immediately.
*/
post: operations["post_message"]; post: operations["post_message"];
}; };
"/v1/sessions/{session_id}/messages/stream": { "/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"]; post: operations["post_message_stream"];
}; };
"/v1/sessions/{session_id}/permissions/{permission_id}/reply": { "/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"]; post: operations["reply_permission"];
}; };
"/v1/sessions/{session_id}/questions/{question_id}/reject": { "/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"]; post: operations["reject_question"];
}; };
"/v1/sessions/{session_id}/questions/{question_id}/reply": { "/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"]; post: operations["reply_question"];
}; };
"/v1/sessions/{session_id}/terminate": { "/v1/sessions/{session_id}/terminate": {
/**
* Terminate Session
* @description Terminates a running session and cleans up resources.
*/
post: operations["terminate_session"]; post: operations["terminate_session"];
}; };
} }
@ -76,7 +190,6 @@ export interface components {
textMessages: boolean; textMessages: boolean;
toolCalls: boolean; toolCalls: boolean;
toolResults: boolean; toolResults: boolean;
variants: boolean;
}; };
AgentError: { AgentError: {
agent?: string | null; agent?: string | null;
@ -170,8 +283,12 @@ export interface components {
agentMode?: string | null; agentMode?: string | null;
agentVersion?: string | null; agentVersion?: string | null;
directory?: string | null; directory?: string | null;
mcp?: {
[key: string]: components["schemas"]["McpServerConfig"];
} | null;
model?: string | null; model?: string | null;
permissionMode?: string | null; permissionMode?: string | null;
skills?: components["schemas"]["SkillsConfig"] | null;
title?: string | null; title?: string | null;
variant?: string | null; variant?: string | null;
}; };
@ -202,6 +319,64 @@ export interface components {
}; };
/** @enum {string} */ /** @enum {string} */
FileAction: "read" | "write" | "patch"; 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: { HealthResponse: {
status: string; status: string;
}; };
@ -219,7 +394,51 @@ export interface components {
ItemRole: "user" | "assistant" | "system" | "tool"; ItemRole: "user" | "assistant" | "system" | "tool";
/** @enum {string} */ /** @enum {string} */
ItemStatus: "in_progress" | "completed" | "failed"; 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: { MessageRequest: {
attachments?: components["schemas"]["MessageAttachment"][];
message: string; message: string;
}; };
PermissionEventData: { PermissionEventData: {
@ -295,10 +514,14 @@ export interface components {
ended: boolean; ended: boolean;
/** Format: int64 */ /** Format: int64 */
eventCount: number; eventCount: number;
mcp?: {
[key: string]: components["schemas"]["McpServerConfig"];
} | null;
model?: string | null; model?: string | null;
nativeSessionId?: string | null; nativeSessionId?: string | null;
permissionMode: string; permissionMode: string;
sessionId: string; sessionId: string;
skills?: components["schemas"]["SkillsConfig"] | null;
title?: string | null; title?: string | null;
/** Format: int64 */ /** Format: int64 */
updatedAt: number; updatedAt: number;
@ -310,6 +533,16 @@ export interface components {
SessionStartedData: { SessionStartedData: {
metadata?: unknown; metadata?: unknown;
}; };
SkillSource: {
ref?: string | null;
skills?: string[] | null;
source: string;
subpath?: string | null;
type: string;
};
SkillsConfig: {
sources: components["schemas"]["SkillSource"][];
};
StderrOutput: { StderrOutput: {
/** @description First N lines of stderr (if truncated) or full stderr (if not truncated) */ /** @description First N lines of stderr (if truncated) or full stderr (if not truncated) */
head?: string | null; head?: string | null;
@ -371,8 +604,13 @@ export type external = Record<string, never>;
export interface operations { export interface operations {
/**
* List Agents
* @description Returns all available coding agents and their installation status.
*/
list_agents: { list_agents: {
responses: { responses: {
/** @description List of available agents */
200: { 200: {
content: { content: {
"application/json": components["schemas"]["AgentListResponse"]; "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: { install_agent: {
parameters: { parameters: {
path: { path: {
@ -397,16 +639,19 @@ export interface operations {
204: { 204: {
content: never; content: never;
}; };
/** @description Invalid request */
400: { 400: {
content: { content: {
"application/json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ProblemDetails"];
}; };
}; };
/** @description Agent not found */
404: { 404: {
content: { content: {
"application/json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ProblemDetails"];
}; };
}; };
/** @description Installation failed */
500: { 500: {
content: { content: {
"application/json": components["schemas"]["ProblemDetails"]; "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: { get_agent_models: {
parameters: { parameters: {
path: { path: {
@ -422,18 +671,24 @@ export interface operations {
}; };
}; };
responses: { responses: {
/** @description Available models */
200: { 200: {
content: { content: {
"application/json": components["schemas"]["AgentModelsResponse"]; "application/json": components["schemas"]["AgentModelsResponse"];
}; };
}; };
400: { /** @description Agent not found */
404: {
content: { content: {
"application/json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ProblemDetails"];
}; };
}; };
}; };
}; };
/**
* List Agent Modes
* @description Returns the available interaction modes for an agent.
*/
get_agent_modes: { get_agent_modes: {
parameters: { parameters: {
path: { path: {
@ -442,11 +697,13 @@ export interface operations {
}; };
}; };
responses: { responses: {
/** @description Available modes */
200: { 200: {
content: { content: {
"application/json": components["schemas"]["AgentModesResponse"]; "application/json": components["schemas"]["AgentModesResponse"];
}; };
}; };
/** @description Invalid request */
400: { 400: {
content: { content: {
"application/json": components["schemas"]["ProblemDetails"]; "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: { get_health: {
responses: { responses: {
/** @description Server is healthy */
200: { 200: {
content: { content: {
"application/json": components["schemas"]["HealthResponse"]; "application/json": components["schemas"]["HealthResponse"];
@ -463,8 +916,13 @@ export interface operations {
}; };
}; };
}; };
/**
* List Sessions
* @description Returns all active sessions.
*/
list_sessions: { list_sessions: {
responses: { responses: {
/** @description List of active sessions */
200: { 200: {
content: { content: {
"application/json": components["schemas"]["SessionListResponse"]; "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: { create_session: {
parameters: { parameters: {
path: { path: {
@ -485,16 +947,19 @@ export interface operations {
}; };
}; };
responses: { responses: {
/** @description Session created */
200: { 200: {
content: { content: {
"application/json": components["schemas"]["CreateSessionResponse"]; "application/json": components["schemas"]["CreateSessionResponse"];
}; };
}; };
/** @description Invalid request */
400: { 400: {
content: { content: {
"application/json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ProblemDetails"];
}; };
}; };
/** @description Session already exists */
409: { 409: {
content: { content: {
"application/json": components["schemas"]["ProblemDetails"]; "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: { get_events: {
parameters: { parameters: {
query?: { query?: {
@ -518,11 +987,13 @@ export interface operations {
}; };
}; };
responses: { responses: {
/** @description Session events */
200: { 200: {
content: { content: {
"application/json": components["schemas"]["EventsResponse"]; "application/json": components["schemas"]["EventsResponse"];
}; };
}; };
/** @description Session not found */
404: { 404: {
content: { content: {
"application/json": components["schemas"]["ProblemDetails"]; "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: { get_events_sse: {
parameters: { parameters: {
query?: { query?: {
@ -550,6 +1025,10 @@ export interface operations {
}; };
}; };
}; };
/**
* Send Message
* @description Sends a message to a session and returns immediately.
*/
post_message: { post_message: {
parameters: { parameters: {
path: { path: {
@ -567,6 +1046,7 @@ export interface operations {
204: { 204: {
content: never; content: never;
}; };
/** @description Session not found */
404: { 404: {
content: { content: {
"application/json": components["schemas"]["ProblemDetails"]; "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: { post_message_stream: {
parameters: { parameters: {
query?: { query?: {
@ -595,6 +1079,7 @@ export interface operations {
200: { 200: {
content: never; content: never;
}; };
/** @description Session not found */
404: { 404: {
content: { content: {
"application/json": components["schemas"]["ProblemDetails"]; "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: { reply_permission: {
parameters: { parameters: {
path: { path: {
@ -621,6 +1110,7 @@ export interface operations {
204: { 204: {
content: never; content: never;
}; };
/** @description Session or permission not found */
404: { 404: {
content: { content: {
"application/json": components["schemas"]["ProblemDetails"]; "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: { reject_question: {
parameters: { parameters: {
path: { path: {
@ -642,6 +1136,7 @@ export interface operations {
204: { 204: {
content: never; content: never;
}; };
/** @description Session or question not found */
404: { 404: {
content: { content: {
"application/json": components["schemas"]["ProblemDetails"]; "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: { reply_question: {
parameters: { parameters: {
path: { path: {
@ -668,6 +1167,7 @@ export interface operations {
204: { 204: {
content: never; content: never;
}; };
/** @description Session or question not found */
404: { 404: {
content: { content: {
"application/json": components["schemas"]["ProblemDetails"]; "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: { terminate_session: {
parameters: { parameters: {
path: { path: {
@ -687,6 +1191,7 @@ export interface operations {
204: { 204: {
content: never; content: never;
}; };
/** @description Session not found */
404: { 404: {
content: { content: {
"application/json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ProblemDetails"];

View file

@ -23,12 +23,26 @@ export type {
EventsQuery, EventsQuery,
EventsResponse, EventsResponse,
FileAction, FileAction,
FsActionResponse,
FsDeleteQuery,
FsEntriesQuery,
FsEntry,
FsEntryType,
FsMoveRequest,
FsMoveResponse,
FsPathQuery,
FsSessionQuery,
FsStat,
FsUploadBatchQuery,
FsUploadBatchResponse,
FsWriteResponse,
HealthResponse, HealthResponse,
ItemDeltaData, ItemDeltaData,
ItemEventData, ItemEventData,
ItemKind, ItemKind,
ItemRole, ItemRole,
ItemStatus, ItemStatus,
MessageAttachment,
MessageRequest, MessageRequest,
PermissionEventData, PermissionEventData,
PermissionReply, PermissionReply,
@ -50,6 +64,13 @@ export type {
UniversalEventData, UniversalEventData,
UniversalEventType, UniversalEventType,
UniversalItem, UniversalItem,
McpServerConfig,
McpCommand,
McpRemoteTransport,
McpOAuthConfig,
McpOAuthConfigOrDisabled,
SkillSource,
SkillsConfig,
} from "./types.ts"; } from "./types.ts";
export type { components, paths } from "./generated/openapi.ts"; export type { components, paths } from "./generated/openapi.ts";
export type { SandboxAgentSpawnOptions, SandboxAgentSpawnLogMode } from "./spawn.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 EventsQuery = S["EventsQuery"];
export type EventsResponse = S["EventsResponse"]; export type EventsResponse = S["EventsResponse"];
export type FileAction = S["FileAction"]; 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 HealthResponse = S["HealthResponse"];
export type ItemDeltaData = S["ItemDeltaData"]; export type ItemDeltaData = S["ItemDeltaData"];
export type ItemEventData = S["ItemEventData"]; export type ItemEventData = S["ItemEventData"];
@ -26,6 +39,7 @@ export type ItemKind = S["ItemKind"];
export type ItemRole = S["ItemRole"]; export type ItemRole = S["ItemRole"];
export type ItemStatus = S["ItemStatus"]; export type ItemStatus = S["ItemStatus"];
export type MessageRequest = S["MessageRequest"]; export type MessageRequest = S["MessageRequest"];
export type MessageAttachment = S["MessageAttachment"];
export type PermissionEventData = S["PermissionEventData"]; export type PermissionEventData = S["PermissionEventData"];
export type PermissionReply = S["PermissionReply"]; export type PermissionReply = S["PermissionReply"];
export type PermissionReplyRequest = S["PermissionReplyRequest"]; export type PermissionReplyRequest = S["PermissionReplyRequest"];
@ -46,3 +60,11 @@ export type UniversalEvent = S["UniversalEvent"];
export type UniversalEventData = S["UniversalEventData"]; export type UniversalEventData = S["UniversalEventData"];
export type UniversalEventType = S["UniversalEventType"]; export type UniversalEventType = S["UniversalEventType"];
export type UniversalItem = S["UniversalItem"]; 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. 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 # Server Testing
## Test placement ## Test placement

View file

@ -743,7 +743,13 @@ fn parse_version_output(output: &std::process::Output) -> Option<String> {
.lines() .lines()
.map(str::trim) .map(str::trim)
.find(|line| !line.is_empty()) .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> { fn parse_jsonl(text: &str) -> Vec<Value> {

View file

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

View file

@ -1,4 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::File;
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Command as ProcessCommand, Stdio}; 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::{build_router_with_state, shutdown_servers};
use crate::router::{ use crate::router::{
AgentInstallRequest, AppState, AuthConfig, BrandingMode, CreateSessionRequest, MessageRequest, AgentInstallRequest, AppState, AuthConfig, BrandingMode, CreateSessionRequest, McpServerConfig,
PermissionReply, PermissionReplyRequest, QuestionReplyRequest, MessageRequest, PermissionReply, PermissionReplyRequest, QuestionReplyRequest, SkillSource,
SkillsConfig,
}; };
use crate::router::{ use crate::router::{
AgentListResponse, AgentModelsResponse, AgentModesResponse, CreateSessionResponse, AgentListResponse, AgentModelsResponse, AgentModesResponse, CreateSessionResponse, EventsResponse,
EventsResponse, SessionListResponse, FsActionResponse, FsEntry, FsMoveRequest, FsMoveResponse, FsStat, FsUploadBatchResponse,
FsWriteResponse, SessionListResponse,
}; };
use crate::server_logs::ServerLogs; use crate::server_logs::ServerLogs;
use crate::telemetry; use crate::telemetry;
@ -176,6 +179,10 @@ pub struct DaemonStartArgs {
#[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)]
port: u16, 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)] #[derive(Args, Debug)]
@ -202,6 +209,8 @@ pub enum ApiCommand {
Agents(AgentsArgs), Agents(AgentsArgs),
/// Create sessions and interact with session events. /// Create sessions and interact with session events.
Sessions(SessionsArgs), Sessions(SessionsArgs),
/// Manage filesystem entries.
Fs(FsArgs),
} }
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
@ -225,6 +234,12 @@ pub struct SessionsArgs {
command: SessionsCommand, command: SessionsCommand,
} }
#[derive(Args, Debug)]
pub struct FsArgs {
#[command(subcommand)]
command: FsCommand,
}
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum AgentsCommand { pub enum AgentsCommand {
/// List all agents and install status. /// List all agents and install status.
@ -272,6 +287,27 @@ pub enum SessionsCommand {
ReplyPermission(PermissionReplyArgs), 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)] #[derive(Args, Debug, Clone)]
pub struct ClientArgs { pub struct ClientArgs {
#[arg(long, short = 'e')] #[arg(long, short = 'e')]
@ -323,6 +359,10 @@ pub struct CreateSessionArgs {
variant: Option<String>, variant: Option<String>,
#[arg(long, short = 'A')] #[arg(long, short = 'A')]
agent_version: Option<String>, agent_version: Option<String>,
#[arg(long)]
mcp_config: Option<PathBuf>,
#[arg(long)]
skill: Vec<PathBuf>,
#[command(flatten)] #[command(flatten)]
client: ClientArgs, client: ClientArgs,
} }
@ -406,6 +446,91 @@ pub struct PermissionReplyArgs {
client: ClientArgs, 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)] #[derive(Args, Debug)]
pub struct CredentialsExtractArgs { pub struct CredentialsExtractArgs {
#[arg(long, short = 'a', value_enum)] #[arg(long, short = 'a', value_enum)]
@ -433,6 +558,8 @@ pub struct CredentialsExtractEnvArgs {
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum CliError { pub enum CliError {
#[error("missing --token or --no-token for server mode")]
MissingToken,
#[error("invalid cors origin: {0}")] #[error("invalid cors origin: {0}")]
InvalidCorsOrigin(String), InvalidCorsOrigin(String),
#[error("invalid cors method: {0}")] #[error("invalid cors method: {0}")]
@ -590,6 +717,7 @@ fn run_api(command: &ApiCommand, cli: &CliConfig) -> Result<(), CliError> {
match command { match command {
ApiCommand::Agents(subcommand) => run_agents(&subcommand.command, cli), ApiCommand::Agents(subcommand) => run_agents(&subcommand.command, cli),
ApiCommand::Sessions(subcommand) => run_sessions(&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> { fn run_daemon(command: &DaemonCommand, cli: &CliConfig) -> Result<(), CliError> {
let token = cli.token.as_deref(); let token = cli.token.as_deref();
match command { 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::Start(args) => crate::daemon::start(cli, &args.host, args.port, token),
DaemonCommand::Stop(args) => crate::daemon::stop(&args.host, args.port), DaemonCommand::Stop(args) => crate::daemon::stop(&args.host, args.port),
DaemonCommand::Status(args) => { DaemonCommand::Status(args) => {
@ -722,6 +853,33 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr
} }
SessionsCommand::Create(args) => { SessionsCommand::Create(args) => {
let ctx = ClientContext::new(cli, &args.client)?; 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 { let body = CreateSessionRequest {
agent: args.agent.clone(), agent: args.agent.clone(),
agent_mode: args.agent_mode.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(), agent_version: args.agent_version.clone(),
directory: None, directory: None,
title: None, title: None,
mcp,
skills,
}; };
let path = format!("{API_PREFIX}/sessions/{}", args.session_id); let path = format!("{API_PREFIX}/sessions/{}", args.session_id);
let response = ctx.post(&path, &body)?; 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 ctx = ClientContext::new(cli, &args.client)?;
let body = MessageRequest { let body = MessageRequest {
message: args.message.clone(), message: args.message.clone(),
attachments: Vec::new(),
}; };
let path = format!("{API_PREFIX}/sessions/{}/messages", args.session_id); let path = format!("{API_PREFIX}/sessions/{}/messages", args.session_id);
let response = ctx.post(&path, &body)?; 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 ctx = ClientContext::new(cli, &args.client)?;
let body = MessageRequest { let body = MessageRequest {
message: args.message.clone(), message: args.message.clone(),
attachments: Vec::new(),
}; };
let path = format!("{API_PREFIX}/sessions/{}/messages/stream", args.session_id); let path = format!("{API_PREFIX}/sessions/{}/messages/stream", args.session_id);
let response = ctx.post_with_query( 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( fn create_opencode_session(
base_url: &str, base_url: &str,
token: Option<&str>, token: Option<&str>,
@ -1275,9 +1560,75 @@ impl ClientContext {
Ok(request.send()?) 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> { fn post_empty(&self, path: &str) -> Result<reqwest::blocking::Response, CliError> {
Ok(self.request(Method::POST, path).send()?) 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>( fn print_json_response<T: serde::de::DeserializeOwned + Serialize>(
@ -1310,6 +1661,25 @@ fn print_text_response(response: reqwest::blocking::Response) -> Result<(), CliE
Ok(()) 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> { fn print_empty_response(response: reqwest::blocking::Response) -> Result<(), CliError> {
let status = response.status(); let status = response.status();
if status.is_success() { if status.is_success() {

View file

@ -1,5 +1,7 @@
use sandbox_agent::cli::run_sandbox_agent;
fn main() { 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"); tracing::error!(error = %err, "sandbox-agent failed");
std::process::exit(1); std::process::exit(1);
} }

View file

@ -524,6 +524,8 @@ async fn ensure_backing_session(
agent_version: None, agent_version: None,
directory, directory,
title, title,
mcp: None,
skills: None,
}; };
let manager = state.inner.session_manager(); let manager = state.inner.session_manager();
match manager match manager
@ -4264,7 +4266,7 @@ async fn oc_session_message_create(
if let Err(err) = state if let Err(err) = state
.inner .inner
.session_manager() .session_manager()
.send_message(session_id.clone(), prompt_text) .send_message(session_id.clone(), prompt_text, Vec::new())
.await .await
{ {
let mut should_emit_idle = false; 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"] #[path = "http/agent_endpoints.rs"]
mod agent_endpoints; 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

@ -1,6 +1,5 @@
--- ---
source: server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs source: server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs
assertion_line: 12
expression: value expression: value
--- ---
healthy: true healthy: true

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