From 4c8d93e077235a38a857c6bea2416251ee9f845e Mon Sep 17 00:00:00 2001 From: NathanFlurry Date: Mon, 9 Feb 2026 10:13:25 +0000 Subject: [PATCH] docs: add mcp and skill session config (#106) --- .gitignore | 8 + .mcp.json | 10 + CLAUDE.md | 18 + Cargo.toml | 3 +- docs/agent-sessions.mdx | 278 ++ docs/attachments.mdx | 87 + docs/cli.mdx | 137 +- docs/custom-tools.mdx | 245 ++ docs/deploy/index.mdx | 27 - docs/docs.json | 111 +- docs/file-system.mdx | 184 ++ docs/inspector.mdx | 1 - docs/mcp.mdx | 122 + docs/openapi.json | 935 +++++- docs/opencode-compatibility.mdx | 3 +- docs/session-transcript-schema.mdx | 3 +- docs/skills.mdx | 87 + examples/CLAUDE.md | 17 + .../src/{cloudflare.ts => index.ts} | 0 examples/cloudflare/wrangler.jsonc | 2 +- examples/daytona/package.json | 5 +- examples/daytona/src/daytona-with-snapshot.ts | 18 +- examples/daytona/src/{daytona.ts => index.ts} | 18 +- examples/docker/package.json | 5 +- examples/docker/src/{docker.ts => index.ts} | 15 +- examples/e2b/package.json | 2 +- examples/e2b/src/{e2b.ts => index.ts} | 15 +- examples/file-system/package.json | 19 + examples/file-system/src/index.ts | 57 + examples/file-system/tsconfig.json | 16 + examples/mcp-custom-tool/package.json | 22 + examples/mcp-custom-tool/src/index.ts | 49 + examples/mcp-custom-tool/src/mcp-server.ts | 24 + examples/mcp-custom-tool/tsconfig.json | 16 + examples/mcp/package.json | 18 + examples/mcp/src/index.ts | 31 + examples/mcp/tsconfig.json | 16 + examples/shared/Dockerfile | 5 + examples/shared/Dockerfile.dev | 58 + examples/shared/package.json | 5 +- examples/shared/src/docker.ts | 301 ++ examples/shared/src/sandbox-agent-client.ts | 159 +- examples/skills-custom-tool/SKILL.md | 12 + examples/skills-custom-tool/package.json | 20 + examples/skills-custom-tool/src/index.ts | 53 + .../skills-custom-tool/src/random-number.ts | 9 + examples/skills-custom-tool/tsconfig.json | 16 + examples/skills/package.json | 18 + examples/skills/src/index.ts | 26 + examples/skills/tsconfig.json | 16 + examples/vercel/package.json | 2 +- examples/vercel/src/{vercel.ts => index.ts} | 15 +- frontend/packages/inspector/index.html | 513 +++- frontend/packages/inspector/src/App.tsx | 209 +- .../src/components/SessionCreateMenu.tsx | 750 +++++ .../src/components/SessionSidebar.tsx | 97 +- .../src/components/chat/ChatPanel.tsx | 250 +- .../src/components/chat/ChatSetup.tsx | 179 -- .../src/components/debug/AgentsTab.tsx | 95 +- .../src/components/debug/DebugPanel.tsx | 2 +- justfile | 14 +- pnpm-lock.yaml | 1098 ++++++- research/wip-agent-support.md | 442 +++ sdks/typescript/package.json | 2 +- sdks/typescript/src/client.ts | 75 +- sdks/typescript/src/generated/openapi.ts | 509 +++- sdks/typescript/src/index.ts | 21 + sdks/typescript/src/types.ts | 22 + server/CLAUDE.md | 4 + .../packages/agent-management/src/agents.rs | 8 +- server/packages/sandbox-agent/Cargo.toml | 3 + server/packages/sandbox-agent/src/cli.rs | 378 ++- server/packages/sandbox-agent/src/main.rs | 4 +- .../sandbox-agent/src/opencode_compat.rs | 4 +- server/packages/sandbox-agent/src/router.rs | 2539 +++++++++++++++-- .../tests/http/agent_endpoints.rs | 127 + .../sandbox-agent/tests/http/fs_endpoints.rs | 267 ++ ...ndpoints_snapshots@agent_install_amp.snap} | 0 ...dpoints_snapshots@agent_install_codex.snap | 6 + ...ints_snapshots@agent_install_opencode.snap | 5 + ..._endpoints_snapshots@agent_models_amp.snap | 13 + ...dpoints_snapshots@agent_models_claude.snap | 9 + ...ndpoints_snapshots@agent_models_codex.snap | 9 + ...oints_snapshots@agent_models_opencode.snap | 8 + ...t_endpoints_snapshots@agent_modes_amp.snap | 9 + ...endpoints_snapshots@agent_modes_codex.snap | 12 + ...points_snapshots@agent_modes_opencode.snap | 14 + .../sandbox-agent/tests/http_endpoints.rs | 2 + ..._session_snapshot@multi_turn_mock.snap.new | 77 - ...n_snapshot@permission_events_mock.snap.new | 65 - ...apshot@question_reply_events_mock.snap.new | 49 - ..._snapshot@concurrency_events_mock.snap.new | 79 - ...ssion_snapshot@create_session_mock-2.snap} | 1 - ...session_snapshot@sessions_list_mock-2.snap | 6 + ..._events_snapshot@http_events_mock.snap.new | 41 - 95 files changed, 10014 insertions(+), 1342 deletions(-) create mode 100644 .mcp.json create mode 100644 docs/agent-sessions.mdx create mode 100644 docs/attachments.mdx create mode 100644 docs/custom-tools.mdx delete mode 100644 docs/deploy/index.mdx create mode 100644 docs/file-system.mdx create mode 100644 docs/mcp.mdx create mode 100644 docs/skills.mdx create mode 100644 examples/CLAUDE.md rename examples/cloudflare/src/{cloudflare.ts => index.ts} (100%) rename examples/daytona/src/{daytona.ts => index.ts} (65%) rename examples/docker/src/{docker.ts => index.ts} (77%) rename examples/e2b/src/{e2b.ts => index.ts} (71%) create mode 100644 examples/file-system/package.json create mode 100644 examples/file-system/src/index.ts create mode 100644 examples/file-system/tsconfig.json create mode 100644 examples/mcp-custom-tool/package.json create mode 100644 examples/mcp-custom-tool/src/index.ts create mode 100644 examples/mcp-custom-tool/src/mcp-server.ts create mode 100644 examples/mcp-custom-tool/tsconfig.json create mode 100644 examples/mcp/package.json create mode 100644 examples/mcp/src/index.ts create mode 100644 examples/mcp/tsconfig.json create mode 100644 examples/shared/Dockerfile create mode 100644 examples/shared/Dockerfile.dev create mode 100644 examples/shared/src/docker.ts create mode 100644 examples/skills-custom-tool/SKILL.md create mode 100644 examples/skills-custom-tool/package.json create mode 100644 examples/skills-custom-tool/src/index.ts create mode 100644 examples/skills-custom-tool/src/random-number.ts create mode 100644 examples/skills-custom-tool/tsconfig.json create mode 100644 examples/skills/package.json create mode 100644 examples/skills/src/index.ts create mode 100644 examples/skills/tsconfig.json rename examples/vercel/src/{vercel.ts => index.ts} (73%) create mode 100644 frontend/packages/inspector/src/components/SessionCreateMenu.tsx delete mode 100644 frontend/packages/inspector/src/components/chat/ChatSetup.tsx create mode 100644 research/wip-agent-support.md create mode 100644 server/packages/sandbox-agent/tests/http/fs_endpoints.rs rename server/packages/sandbox-agent/tests/http/snapshots/{http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_install_amp.snap.new => http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_install_amp.snap} (100%) create mode 100644 server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_install_codex.snap create mode 100644 server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_install_opencode.snap create mode 100644 server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_models_amp.snap create mode 100644 server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_models_claude.snap create mode 100644 server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_models_codex.snap create mode 100644 server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_models_opencode.snap create mode 100644 server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_modes_amp.snap create mode 100644 server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_modes_codex.snap create mode 100644 server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_modes_opencode.snap delete mode 100644 server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__multi_turn__assert_session_snapshot@multi_turn_mock.snap.new delete mode 100644 server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__permissions__assert_session_snapshot@permission_events_mock.snap.new delete mode 100644 server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reply_events_mock.snap.new delete mode 100644 server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@concurrency_events_mock.snap.new rename server/packages/sandbox-agent/tests/sessions/snapshots/{sessions__sessions__session_lifecycle__assert_session_snapshot@create_session_mock-2.snap.new => sessions__sessions__session_lifecycle__assert_session_snapshot@create_session_mock-2.snap} (88%) create mode 100644 server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@sessions_list_mock-2.snap delete mode 100644 server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_http_events_snapshot@http_events_mock.snap.new diff --git a/.gitignore b/.gitignore index e983e76..07d74c2 100644 --- a/.gitignore +++ b/.gitignore @@ -40,5 +40,13 @@ npm-debug.log* Cargo.lock **/*.rs.bk +# Agent runtime directories +.agents/ +.claude/ +.opencode/ + +# Example temp files +.tmp-upload/ + # CLI binaries (downloaded during npm publish) sdks/cli/platforms/*/bin/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..cc04a2b --- /dev/null +++ b/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "everything": { + "args": [ + "@modelcontextprotocol/server-everything" + ], + "command": "npx" + } + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 35db516..c0dcf17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,16 @@ Universal schema guidance: - On parse failures, emit an `agent.unparsed` event (source=daemon, synthetic=true) and treat it as a test failure. Preserve raw payloads when `include_raw=true`. - Track subagent support in `docs/conversion.md`. For now, normalize subagent activity into normal message/tool flow, but revisit explicit subagent modeling later. - Keep the FAQ in `README.md` and `frontend/packages/website/src/components/FAQ.tsx` in sync. When adding or modifying FAQ entries, update both files. +- Update `research/wip-agent-support.md` as agent support changes are implemented. + +### OpenAPI / utoipa requirements + +Every `#[utoipa::path(...)]` handler function must have a doc comment where: +- The **first line** becomes the OpenAPI `summary` (short human-readable title, e.g. `"List Agents"`). This is used as the sidebar label and page heading in the docs site. +- The **remaining lines** become the OpenAPI `description` (one-sentence explanation of what the endpoint does). +- Every `responses(...)` entry must have a `description` (no empty descriptions). + +When adding or modifying endpoints, regenerate `docs/openapi.json` and verify titles render correctly in the docs site. ### CLI ⇄ HTTP endpoint map (keep in sync) @@ -64,6 +74,14 @@ Universal schema guidance: - `sandbox-agent api sessions reply-question` ↔ `POST /v1/sessions/{sessionId}/questions/{questionId}/reply` - `sandbox-agent api sessions reject-question` ↔ `POST /v1/sessions/{sessionId}/questions/{questionId}/reject` - `sandbox-agent api sessions reply-permission` ↔ `POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply` +- `sandbox-agent api fs entries` ↔ `GET /v1/fs/entries` +- `sandbox-agent api fs read` ↔ `GET /v1/fs/file` +- `sandbox-agent api fs write` ↔ `PUT /v1/fs/file` +- `sandbox-agent api fs delete` ↔ `DELETE /v1/fs/entry` +- `sandbox-agent api fs mkdir` ↔ `POST /v1/fs/mkdir` +- `sandbox-agent api fs move` ↔ `POST /v1/fs/move` +- `sandbox-agent api fs stat` ↔ `GET /v1/fs/stat` +- `sandbox-agent api fs upload-batch` ↔ `POST /v1/fs/upload-batch` ## OpenCode Compatibility Layer diff --git a/Cargo.toml b/Cargo.toml index 1b31ba5..4567f6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" authors = [ "Rivet Gaming, LLC " ] license = "Apache-2.0" repository = "https://github.com/rivet-dev/sandbox-agent" -description = "Universal API for automatic coding agents in sandboxes. Supprots Claude Code, Codex, OpenCode, and Amp." +description = "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, and Amp." [workspace.dependencies] # Internal crates @@ -69,6 +69,7 @@ url = "2.5" regress = "0.10" include_dir = "0.7" base64 = "0.22" +toml_edit = "0.22" # Code generation (build deps) typify = "0.4" diff --git a/docs/agent-sessions.mdx b/docs/agent-sessions.mdx new file mode 100644 index 0000000..78390a3 --- /dev/null +++ b/docs/agent-sessions.mdx @@ -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 + + +```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" + }' +``` + + +## Send A Message + + +```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."}' +``` + + +## Stream A Turn + + +```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."}' +``` + + +## Fetch Events + + +```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" +``` + + +`GET /v1/sessions/{sessionId}/get-messages` is an alias for `events`. + +## Stream Events (SSE) + + +```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" +``` + + +## List Sessions + + +```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" +``` + + +## Reply To A Question + +When the agent asks a question, reply with an array of answers. Each inner array is one multi-select response. + + +```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"]]}' +``` + + +## Reject A Question + + +```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" +``` + + +## Reply To A Permission Request + +Use `once`, `always`, or `reject`. + + +```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"}' +``` + + +## Terminate A Session + + +```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" +``` + diff --git a/docs/attachments.mdx b/docs/attachments.mdx new file mode 100644 index 0000000..2458c52 --- /dev/null +++ b/docs/attachments.mdx @@ -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. + + + + + ```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 + ``` + + + The response returns the absolute path that you should use for attachments. + + + + + ```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" + } + ] + }' + ``` + + + + +## 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. diff --git a/docs/cli.mdx b/docs/cli.mdx index 336ce3f..5f79dc6 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -2,7 +2,6 @@ title: "CLI Reference" description: "Complete CLI reference for sandbox-agent." sidebarTitle: "CLI" -icon: "terminal" --- ## Server @@ -250,6 +249,8 @@ sandbox-agent api sessions create [OPTIONS] | `-m, --model ` | Model override | | `-v, --variant ` | Model variant | | `-A, --agent-version ` | Agent version | +| `--mcp-config ` | JSON file with MCP server config (see `mcp` docs) | +| `--skill ` | Skill directory or `SKILL.md` path (repeatable) | ```bash sandbox-agent api sessions create my-session \ @@ -381,6 +382,132 @@ sandbox-agent api sessions reply-permission my-session perm1 --reply once --- +### Filesystem + +#### List Entries + +```bash +sandbox-agent api fs entries [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--path ` | Directory path (default: `.`) | +| `--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 [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--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 [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--content ` | Write UTF-8 content | +| `--from-file ` | Read content from a local file | +| `--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 [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--recursive` | Delete directories recursively | +| `--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 [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--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 [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--overwrite` | Overwrite destination if it exists | +| `--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 [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--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 [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--tar ` | Tar archive to extract | +| `--path ` | Destination directory | +| `--session-id ` | Resolve relative paths from the session working directory | + +```bash +sandbox-agent api fs upload-batch --tar ./skills.tar --path ./skills +``` + +--- + ## CLI to HTTP Mapping | CLI Command | HTTP Endpoint | @@ -399,3 +526,11 @@ sandbox-agent api sessions reply-permission my-session perm1 --reply once | `api sessions reply-question` | `POST /v1/sessions/{sessionId}/questions/{questionId}/reply` | | `api sessions reject-question` | `POST /v1/sessions/{sessionId}/questions/{questionId}/reject` | | `api sessions reply-permission` | `POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply` | +| `api fs entries` | `GET /v1/fs/entries` | +| `api fs read` | `GET /v1/fs/file` | +| `api fs write` | `PUT /v1/fs/file` | +| `api fs delete` | `DELETE /v1/fs/entry` | +| `api fs mkdir` | `POST /v1/fs/mkdir` | +| `api fs move` | `POST /v1/fs/move` | +| `api fs stat` | `GET /v1/fs/stat` | +| `api fs upload-batch` | `POST /v1/fs/upload-batch` | diff --git a/docs/custom-tools.mdx b/docs/custom-tools.mdx new file mode 100644 index 0000000..4690888 --- /dev/null +++ b/docs/custom-tools.mdx @@ -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 + + + + 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. + + + + 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. + + + + Start your sandbox, then write the bundled file into it. + + + ```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 + ``` + + + + + 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. + + + ```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"] + } + } + }' + ``` + + + + +## 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. + + + + 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 "); + process.exit(1); + } + + console.log(Math.floor(Math.random() * (max - min + 1)) + min); + ``` + + + + 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 + ``` + + This prints a single random integer between min and max (inclusive). + + + + 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 + ``` + + + + Upload both the bundled script and the skill file. + + + ```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 + ``` + + + + + Point the session at the skill directory. The agent reads `SKILL.md` and learns how to use your script. + + + ```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" } + ] + } + }' + ``` + + + + +## Notes + +- The sandbox image must include a Node.js runtime that can execute the bundled files. diff --git a/docs/deploy/index.mdx b/docs/deploy/index.mdx deleted file mode 100644 index 11d6790..0000000 --- a/docs/deploy/index.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: "Deploy" -sidebarTitle: "Overview" -description: "Choose where to run the sandbox-agent server." -icon: "server" ---- - - - - Run locally for development. The SDK can auto-spawn the server. - - - Deploy inside an E2B sandbox with network access. - - - Deploy inside a Vercel Sandbox with port forwarding. - - - Deploy inside a Cloudflare Sandbox with port exposure. - - - Run in a Daytona workspace with port forwarding. - - - Build and run in a container (development only). - - diff --git a/docs/docs.json b/docs/docs.json index f881604..4e919fd 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -25,66 +25,97 @@ }, "navbar": { "links": [ + { + "label": "Gigacode", + "icon": "terminal", + "href": "https://github.com/rivet-dev/sandbox-agent/tree/main/gigacode" + }, { "label": "Discord", "icon": "discord", "href": "https://discord.gg/auCecybynK" }, { - "label": "GitHub", - "icon": "github", + "type": "github", "href": "https://github.com/rivet-dev/sandbox-agent" } ] }, "navigation": { - "pages": [ + "tabs": [ { - "group": "Getting started", + "tab": "Documentation", "pages": [ - "quickstart", - "building-chat-ui", - "manage-sessions", - "opencode-compatibility" - ] - }, - { - "group": "Deploy", - "pages": [ - "deploy/index", - "deploy/local", - "deploy/e2b", - "deploy/daytona", - "deploy/vercel", - "deploy/cloudflare", - "deploy/docker" - ] - }, - { - "group": "SDKs", - "pages": ["sdks/typescript", "sdks/python"] - }, - { - "group": "Reference", - "pages": [ - "cli", - "inspector", - "session-transcript-schema", - "credentials", - "gigacode", { - "group": "AI", - "pages": ["ai/skill", "ai/llms-txt"] + "group": "Getting started", + "pages": [ + "quickstart", + "building-chat-ui", + "manage-sessions", + { + "group": "Deploy", + "icon": "server", + "pages": [ + "deploy/local", + "deploy/e2b", + "deploy/daytona", + "deploy/vercel", + "deploy/cloudflare", + "deploy/docker" + ] + } + ] }, { - "group": "Advanced", - "pages": ["daemon", "cors", "telemetry"] + "group": "SDKs", + "pages": ["sdks/typescript", "sdks/python"] + }, + { + "group": "Agent Features", + "pages": [ + "agent-sessions", + "attachments", + "skills", + "mcp", + "custom-tools" + ] + }, + { + "group": "Features", + "pages": ["file-system"] + }, + { + "group": "Reference", + "pages": [ + "cli", + "inspector", + "session-transcript-schema", + "opencode-compatibility", + { + "group": "More", + "pages": [ + "credentials", + "daemon", + "cors", + "telemetry", + { + "group": "AI", + "pages": ["ai/skill", "ai/llms-txt"] + } + ] + } + ] } ] }, { - "group": "HTTP API Reference", - "openapi": "openapi.json" + "tab": "HTTP API", + "pages": [ + { + "group": "HTTP Reference", + "openapi": "openapi.json" + } + ] } ] } diff --git a/docs/file-system.mdx b/docs/file-system.mdx new file mode 100644 index 0000000..3aae00c --- /dev/null +++ b/docs/file-system.mdx @@ -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 + + +```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" +``` + + +## Read And Write Files + +`PUT /v1/fs/file` writes raw bytes. `GET /v1/fs/file` returns raw bytes. + + +```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 +``` + + +## Create Directories + + +```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" +``` + + +## Move, Delete, And Stat + + +```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" +``` + + +## 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. + + +```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 +``` + diff --git a/docs/inspector.mdx b/docs/inspector.mdx index 8e80c22..30aec70 100644 --- a/docs/inspector.mdx +++ b/docs/inspector.mdx @@ -1,7 +1,6 @@ --- title: "Inspector" description: "Debug and inspect agent sessions with the Inspector UI." -icon: "magnifying-glass" --- The Inspector is a web-based GUI for debugging and inspecting Sandbox Agent sessions. Use it to view events, send messages, and troubleshoot agent behavior in real-time. diff --git a/docs/mcp.mdx b/docs/mcp.mdx new file mode 100644 index 0000000..668d937 --- /dev/null +++ b/docs/mcp.mdx @@ -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: + + + +```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}" + } + } + } + }' +``` + + + +## 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). diff --git a/docs/openapi.json b/docs/openapi.json index 275da61..d674d9a 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "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.", "contact": { "name": "Rivet Gaming, LLC", "email": "developer@rivet.gg" @@ -23,10 +23,12 @@ "tags": [ "agents" ], + "summary": "List Agents", + "description": "Returns all available coding agents and their installation status.", "operationId": "list_agents", "responses": { "200": { - "description": "", + "description": "List of available agents", "content": { "application/json": { "schema": { @@ -43,6 +45,8 @@ "tags": [ "agents" ], + "summary": "Install Agent", + "description": "Installs or updates a coding agent (e.g. claude, codex, opencode, amp).", "operationId": "install_agent", "parameters": [ { @@ -70,7 +74,7 @@ "description": "Agent installed" }, "400": { - "description": "", + "description": "Invalid request", "content": { "application/json": { "schema": { @@ -80,7 +84,7 @@ } }, "404": { - "description": "", + "description": "Agent not found", "content": { "application/json": { "schema": { @@ -90,7 +94,7 @@ } }, "500": { - "description": "", + "description": "Installation failed", "content": { "application/json": { "schema": { @@ -107,6 +111,8 @@ "tags": [ "agents" ], + "summary": "List Agent Models", + "description": "Returns the available LLM models for an agent.", "operationId": "get_agent_models", "parameters": [ { @@ -121,7 +127,7 @@ ], "responses": { "200": { - "description": "", + "description": "Available models", "content": { "application/json": { "schema": { @@ -130,8 +136,8 @@ } } }, - "400": { - "description": "", + "404": { + "description": "Agent not found", "content": { "application/json": { "schema": { @@ -148,6 +154,8 @@ "tags": [ "agents" ], + "summary": "List Agent Modes", + "description": "Returns the available interaction modes for an agent.", "operationId": "get_agent_modes", "parameters": [ { @@ -162,7 +170,7 @@ ], "responses": { "200": { - "description": "", + "description": "Available modes", "content": { "application/json": { "schema": { @@ -172,7 +180,7 @@ } }, "400": { - "description": "", + "description": "Invalid request", "content": { "application/json": { "schema": { @@ -184,15 +192,398 @@ } } }, + "/v1/fs/entries": { + "get": { + "tags": [ + "fs" + ], + "summary": "List Directory", + "description": "Lists files and directories at the given path.", + "operationId": "fs_entries", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "Path to list (relative or absolute)", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "session_id", + "in": "query", + "description": "Session id for relative paths", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Directory listing", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FsEntry" + } + } + } + } + } + } + } + }, + "/v1/fs/entry": { + "delete": { + "tags": [ + "fs" + ], + "summary": "Delete Entry", + "description": "Deletes a file or directory.", + "operationId": "fs_delete_entry", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "File or directory path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "session_id", + "in": "query", + "description": "Session id for relative paths", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "recursive", + "in": "query", + "description": "Delete directories recursively", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Delete result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsActionResponse" + } + } + } + } + } + } + }, + "/v1/fs/file": { + "get": { + "tags": [ + "fs" + ], + "summary": "Read File", + "description": "Reads the raw bytes of a file.", + "operationId": "fs_read_file", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "File path (relative or absolute)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "session_id", + "in": "query", + "description": "Session id for relative paths", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "File content", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "put": { + "tags": [ + "fs" + ], + "summary": "Write File", + "description": "Writes raw bytes to a file, creating it if it doesn't exist.", + "operationId": "fs_write_file", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "File path (relative or absolute)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "session_id", + "in": "query", + "description": "Session id for relative paths", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Write result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsWriteResponse" + } + } + } + } + } + } + }, + "/v1/fs/mkdir": { + "post": { + "tags": [ + "fs" + ], + "summary": "Create Directory", + "description": "Creates a directory, including any missing parent directories.", + "operationId": "fs_mkdir", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "Directory path to create", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "session_id", + "in": "query", + "description": "Session id for relative paths", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Directory created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsActionResponse" + } + } + } + } + } + } + }, + "/v1/fs/move": { + "post": { + "tags": [ + "fs" + ], + "summary": "Move Entry", + "description": "Moves or renames a file or directory.", + "operationId": "fs_move", + "parameters": [ + { + "name": "session_id", + "in": "query", + "description": "Session id for relative paths", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsMoveRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Move result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsMoveResponse" + } + } + } + } + } + } + }, + "/v1/fs/stat": { + "get": { + "tags": [ + "fs" + ], + "summary": "Get File Info", + "description": "Returns metadata (size, timestamps, type) for a path.", + "operationId": "fs_stat", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "Path to stat", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "session_id", + "in": "query", + "description": "Session id for relative paths", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "File metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsStat" + } + } + } + } + } + } + }, + "/v1/fs/upload-batch": { + "post": { + "tags": [ + "fs" + ], + "summary": "Upload Files", + "description": "Uploads a tar.gz archive and extracts it to the destination directory.", + "operationId": "fs_upload_batch", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "Destination directory for extraction", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "session_id", + "in": "query", + "description": "Session id for relative paths", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Upload result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsUploadBatchResponse" + } + } + } + } + } + } + }, "/v1/health": { "get": { "tags": [ "meta" ], + "summary": "Health Check", + "description": "Returns the server health status.", "operationId": "get_health", "responses": { "200": { - "description": "", + "description": "Server is healthy", "content": { "application/json": { "schema": { @@ -209,10 +600,12 @@ "tags": [ "sessions" ], + "summary": "List Sessions", + "description": "Returns all active sessions.", "operationId": "list_sessions", "responses": { "200": { - "description": "", + "description": "List of active sessions", "content": { "application/json": { "schema": { @@ -229,6 +622,8 @@ "tags": [ "sessions" ], + "summary": "Create Session", + "description": "Creates a new agent session with the given configuration.", "operationId": "create_session", "parameters": [ { @@ -253,7 +648,7 @@ }, "responses": { "200": { - "description": "", + "description": "Session created", "content": { "application/json": { "schema": { @@ -263,7 +658,7 @@ } }, "400": { - "description": "", + "description": "Invalid request", "content": { "application/json": { "schema": { @@ -273,7 +668,7 @@ } }, "409": { - "description": "", + "description": "Session already exists", "content": { "application/json": { "schema": { @@ -290,6 +685,8 @@ "tags": [ "sessions" ], + "summary": "Get Events", + "description": "Returns session events with optional offset-based pagination.", "operationId": "get_events", "parameters": [ { @@ -338,7 +735,7 @@ ], "responses": { "200": { - "description": "", + "description": "Session events", "content": { "application/json": { "schema": { @@ -348,7 +745,7 @@ } }, "404": { - "description": "", + "description": "Session not found", "content": { "application/json": { "schema": { @@ -365,6 +762,8 @@ "tags": [ "sessions" ], + "summary": "Subscribe to Events (SSE)", + "description": "Opens an SSE stream for real-time session events.", "operationId": "get_events_sse", "parameters": [ { @@ -411,6 +810,8 @@ "tags": [ "sessions" ], + "summary": "Send Message", + "description": "Sends a message to a session and returns immediately.", "operationId": "post_message", "parameters": [ { @@ -438,7 +839,7 @@ "description": "Message accepted" }, "404": { - "description": "", + "description": "Session not found", "content": { "application/json": { "schema": { @@ -455,6 +856,8 @@ "tags": [ "sessions" ], + "summary": "Send Message (Streaming)", + "description": "Sends a message and returns an SSE event stream of the agent's response.", "operationId": "post_message_stream", "parameters": [ { @@ -492,7 +895,7 @@ "description": "SSE event stream" }, "404": { - "description": "", + "description": "Session not found", "content": { "application/json": { "schema": { @@ -509,6 +912,8 @@ "tags": [ "sessions" ], + "summary": "Reply to Permission", + "description": "Approves or denies a permission request from the agent.", "operationId": "reply_permission", "parameters": [ { @@ -545,7 +950,7 @@ "description": "Permission reply accepted" }, "404": { - "description": "", + "description": "Session or permission not found", "content": { "application/json": { "schema": { @@ -562,6 +967,8 @@ "tags": [ "sessions" ], + "summary": "Reject Question", + "description": "Rejects a human-in-the-loop question from the agent.", "operationId": "reject_question", "parameters": [ { @@ -588,7 +995,7 @@ "description": "Question rejected" }, "404": { - "description": "", + "description": "Session or question not found", "content": { "application/json": { "schema": { @@ -605,6 +1012,8 @@ "tags": [ "sessions" ], + "summary": "Reply to Question", + "description": "Replies to a human-in-the-loop question from the agent.", "operationId": "reply_question", "parameters": [ { @@ -641,7 +1050,7 @@ "description": "Question answered" }, "404": { - "description": "", + "description": "Session or question not found", "content": { "application/json": { "schema": { @@ -658,6 +1067,8 @@ "tags": [ "sessions" ], + "summary": "Terminate Session", + "description": "Terminates a running session and cleans up resources.", "operationId": "terminate_session", "parameters": [ { @@ -675,7 +1086,7 @@ "description": "Session terminated" }, "404": { - "description": "", + "description": "Session not found", "content": { "application/json": { "schema": { @@ -710,7 +1121,6 @@ "mcpTools", "streamingDeltas", "itemStarted", - "variants", "sharedProcess" ], "properties": { @@ -768,9 +1178,6 @@ }, "toolResults": { "type": "boolean" - }, - "variants": { - "type": "boolean" } } }, @@ -1161,6 +1568,13 @@ "type": "string", "nullable": true }, + "mcp": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/McpServerConfig" + }, + "nullable": true + }, "model": { "type": "string", "nullable": true @@ -1169,6 +1583,14 @@ "type": "string", "nullable": true }, + "skills": { + "allOf": [ + { + "$ref": "#/components/schemas/SkillsConfig" + } + ], + "nullable": true + }, "title": { "type": "string", "nullable": true @@ -1291,6 +1713,216 @@ "patch" ] }, + "FsActionResponse": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + } + } + }, + "FsDeleteQuery": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + }, + "recursive": { + "type": "boolean", + "nullable": true + }, + "sessionId": { + "type": "string", + "nullable": true + } + } + }, + "FsEntriesQuery": { + "type": "object", + "properties": { + "path": { + "type": "string", + "nullable": true + }, + "sessionId": { + "type": "string", + "nullable": true + } + } + }, + "FsEntry": { + "type": "object", + "required": [ + "name", + "path", + "entryType", + "size" + ], + "properties": { + "entryType": { + "$ref": "#/components/schemas/FsEntryType" + }, + "modified": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "FsEntryType": { + "type": "string", + "enum": [ + "file", + "directory" + ] + }, + "FsMoveRequest": { + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "type": "string" + }, + "overwrite": { + "type": "boolean", + "nullable": true + }, + "to": { + "type": "string" + } + } + }, + "FsMoveResponse": { + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + "FsPathQuery": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + }, + "sessionId": { + "type": "string", + "nullable": true + } + } + }, + "FsSessionQuery": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "nullable": true + } + } + }, + "FsStat": { + "type": "object", + "required": [ + "path", + "entryType", + "size" + ], + "properties": { + "entryType": { + "$ref": "#/components/schemas/FsEntryType" + }, + "modified": { + "type": "string", + "nullable": true + }, + "path": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "FsUploadBatchQuery": { + "type": "object", + "properties": { + "path": { + "type": "string", + "nullable": true + }, + "sessionId": { + "type": "string", + "nullable": true + } + } + }, + "FsUploadBatchResponse": { + "type": "object", + "required": [ + "paths", + "truncated" + ], + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "truncated": { + "type": "boolean" + } + } + }, + "FsWriteResponse": { + "type": "object", + "required": [ + "path", + "bytesWritten" + ], + "properties": { + "bytesWritten": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "path": { + "type": "string" + } + } + }, "HealthResponse": { "type": "object", "required": [ @@ -1360,12 +1992,198 @@ "failed" ] }, + "McpCommand": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "McpOAuthConfig": { + "type": "object", + "properties": { + "clientId": { + "type": "string", + "nullable": true + }, + "clientSecret": { + "type": "string", + "nullable": true + }, + "scope": { + "type": "string", + "nullable": true + } + } + }, + "McpOAuthConfigOrDisabled": { + "oneOf": [ + { + "$ref": "#/components/schemas/McpOAuthConfig" + }, + { + "type": "boolean" + } + ] + }, + "McpRemoteTransport": { + "type": "string", + "enum": [ + "http", + "sse" + ] + }, + "McpServerConfig": { + "oneOf": [ + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "command": { + "$ref": "#/components/schemas/McpCommand" + }, + "cwd": { + "type": "string", + "nullable": true + }, + "enabled": { + "type": "boolean", + "nullable": true + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "timeoutMs": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "local" + ] + } + } + }, + { + "type": "object", + "required": [ + "url", + "type" + ], + "properties": { + "bearerTokenEnvVar": { + "type": "string", + "nullable": true + }, + "enabled": { + "type": "boolean", + "nullable": true + }, + "envHeaders": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "oauth": { + "allOf": [ + { + "$ref": "#/components/schemas/McpOAuthConfigOrDisabled" + } + ], + "nullable": true + }, + "timeoutMs": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "transport": { + "allOf": [ + { + "$ref": "#/components/schemas/McpRemoteTransport" + } + ], + "nullable": true + }, + "type": { + "type": "string", + "enum": [ + "remote" + ] + }, + "url": { + "type": "string" + } + } + } + ], + "discriminator": { + "propertyName": "type" + } + }, + "MessageAttachment": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "filename": { + "type": "string", + "nullable": true + }, + "mime": { + "type": "string", + "nullable": true + }, + "path": { + "type": "string" + } + } + }, "MessageRequest": { "type": "object", "required": [ "message" ], "properties": { + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageAttachment" + } + }, "message": { "type": "string" } @@ -1630,6 +2448,13 @@ "format": "int64", "minimum": 0 }, + "mcp": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/McpServerConfig" + }, + "nullable": true + }, "model": { "type": "string", "nullable": true @@ -1644,6 +2469,14 @@ "sessionId": { "type": "string" }, + "skills": { + "allOf": [ + { + "$ref": "#/components/schemas/SkillsConfig" + } + ], + "nullable": true + }, "title": { "type": "string", "nullable": true @@ -1680,6 +2513,50 @@ } } }, + "SkillSource": { + "type": "object", + "required": [ + "type", + "source" + ], + "properties": { + "ref": { + "type": "string", + "nullable": true + }, + "skills": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "source": { + "type": "string" + }, + "subpath": { + "type": "string", + "nullable": true + }, + "type": { + "type": "string" + } + } + }, + "SkillsConfig": { + "type": "object", + "required": [ + "sources" + ], + "properties": { + "sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SkillSource" + } + } + } + }, "StderrOutput": { "type": "object", "required": [ @@ -1902,6 +2779,10 @@ { "name": "sessions", "description": "Session management" + }, + { + "name": "fs", + "description": "Filesystem operations" } ] } \ No newline at end of file diff --git a/docs/opencode-compatibility.mdx b/docs/opencode-compatibility.mdx index 189f16e..559c16e 100644 --- a/docs/opencode-compatibility.mdx +++ b/docs/opencode-compatibility.mdx @@ -1,7 +1,6 @@ --- -title: "OpenCode SDK & UI Support" +title: "OpenCode Compatibility" description: "Connect OpenCode clients, SDKs, and web UI to Sandbox Agent." -icon: "rectangle-terminal" --- diff --git a/docs/session-transcript-schema.mdx b/docs/session-transcript-schema.mdx index 84a97a3..a1e4e22 100644 --- a/docs/session-transcript-schema.mdx +++ b/docs/session-transcript-schema.mdx @@ -1,7 +1,6 @@ --- title: "Session Transcript Schema" description: "Universal event schema for session transcripts across all agents." -icon: "brackets-curly" --- Each coding agent outputs events in its own native format. The sandbox-agent converts these into a universal event schema, giving you a consistent session transcript regardless of which agent you use. @@ -27,7 +26,7 @@ This table shows which agent feature coverage appears in the universal event str | Reasoning/Thinking | - | ✓ | - | - | | Command Execution | - | ✓ | - | - | | File Changes | - | ✓ | - | - | -| MCP Tools | - | ✓ | - | - | +| MCP Tools | ✓ | ✓ | ✓ | ✓ | | Streaming Deltas | ✓ | ✓ | ✓ | - | | Variants | | ✓ | ✓ | ✓ | diff --git a/docs/skills.mdx b/docs/skills.mdx new file mode 100644 index 0000000..5f35866 --- /dev/null +++ b/docs/skills.mdx @@ -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. + + + +```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" } + ] + } + }' +``` + + + +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/`, `.agents/skills/`, `.opencode/skill/`). + +### 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. diff --git a/examples/CLAUDE.md b/examples/CLAUDE.md new file mode 100644 index 0000000..57e13cd --- /dev/null +++ b/examples/CLAUDE.md @@ -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:/v1/sessions//messages -H "Content-Type: application/json" -d '{"message":"..."}'` +4. Poll events: `curl http://127.0.0.1:/v1/sessions//events` +5. Approve permissions: `curl -X POST http://127.0.0.1:/v1/sessions//permissions//reply -H "Content-Type: application/json" -d '{"reply":"once"}'` diff --git a/examples/cloudflare/src/cloudflare.ts b/examples/cloudflare/src/index.ts similarity index 100% rename from examples/cloudflare/src/cloudflare.ts rename to examples/cloudflare/src/index.ts diff --git a/examples/cloudflare/wrangler.jsonc b/examples/cloudflare/wrangler.jsonc index a1401c4..5959215 100644 --- a/examples/cloudflare/wrangler.jsonc +++ b/examples/cloudflare/wrangler.jsonc @@ -1,7 +1,7 @@ { "$schema": "node_modules/wrangler/config-schema.json", "name": "sandbox-agent-cloudflare", - "main": "src/cloudflare.ts", + "main": "src/index.ts", "compatibility_date": "2025-01-01", "compatibility_flags": ["nodejs_compat"], "assets": { diff --git a/examples/daytona/package.json b/examples/daytona/package.json index 281ba81..f105bac 100644 --- a/examples/daytona/package.json +++ b/examples/daytona/package.json @@ -3,13 +3,14 @@ "private": true, "type": "module", "scripts": { - "start": "tsx src/daytona.ts", + "start": "tsx src/index.ts", "start:snapshot": "tsx src/daytona-with-snapshot.ts", "typecheck": "tsc --noEmit" }, "dependencies": { "@daytonaio/sdk": "latest", - "@sandbox-agent/example-shared": "workspace:*" + "@sandbox-agent/example-shared": "workspace:*", + "sandbox-agent": "workspace:*" }, "devDependencies": { "@types/node": "latest", diff --git a/examples/daytona/src/daytona-with-snapshot.ts b/examples/daytona/src/daytona-with-snapshot.ts index d0d1ce8..e196065 100644 --- a/examples/daytona/src/daytona-with-snapshot.ts +++ b/examples/daytona/src/daytona-with-snapshot.ts @@ -1,5 +1,6 @@ import { Daytona, Image } from "@daytonaio/sdk"; -import { runPrompt } from "@sandbox-agent/example-shared"; +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; const daytona = new Daytona(); @@ -24,12 +25,21 @@ await sandbox.process.executeCommand( const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; +console.log("Waiting for server..."); +await waitForHealth({ baseUrl }); + +const client = await SandboxAgent.connect({ baseUrl }); +const sessionId = generateSessionId(); +await client.createSession(sessionId, { agent: detectAgent() }); + +console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); +console.log(" Press Ctrl+C to stop."); + +const keepAlive = setInterval(() => {}, 60_000); const cleanup = async () => { + clearInterval(keepAlive); await sandbox.delete(60); process.exit(0); }; process.once("SIGINT", cleanup); process.once("SIGTERM", cleanup); - -await runPrompt(baseUrl); -await cleanup(); diff --git a/examples/daytona/src/daytona.ts b/examples/daytona/src/index.ts similarity index 65% rename from examples/daytona/src/daytona.ts rename to examples/daytona/src/index.ts index 4fe6a3b..9fbd2f4 100644 --- a/examples/daytona/src/daytona.ts +++ b/examples/daytona/src/index.ts @@ -1,5 +1,6 @@ import { Daytona } from "@daytonaio/sdk"; -import { runPrompt } from "@sandbox-agent/example-shared"; +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; const daytona = new Daytona(); @@ -25,12 +26,21 @@ await sandbox.process.executeCommand( const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; +console.log("Waiting for server..."); +await waitForHealth({ baseUrl }); + +const client = await SandboxAgent.connect({ baseUrl }); +const sessionId = generateSessionId(); +await client.createSession(sessionId, { agent: detectAgent() }); + +console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); +console.log(" Press Ctrl+C to stop."); + +const keepAlive = setInterval(() => {}, 60_000); const cleanup = async () => { + clearInterval(keepAlive); await sandbox.delete(60); process.exit(0); }; process.once("SIGINT", cleanup); process.once("SIGTERM", cleanup); - -await runPrompt(baseUrl); -await cleanup(); diff --git a/examples/docker/package.json b/examples/docker/package.json index 289b0c3..2c29cfe 100644 --- a/examples/docker/package.json +++ b/examples/docker/package.json @@ -3,12 +3,13 @@ "private": true, "type": "module", "scripts": { - "start": "tsx src/docker.ts", + "start": "tsx src/index.ts", "typecheck": "tsc --noEmit" }, "dependencies": { "@sandbox-agent/example-shared": "workspace:*", - "dockerode": "latest" + "dockerode": "latest", + "sandbox-agent": "workspace:*" }, "devDependencies": { "@types/dockerode": "latest", diff --git a/examples/docker/src/docker.ts b/examples/docker/src/index.ts similarity index 77% rename from examples/docker/src/docker.ts rename to examples/docker/src/index.ts index 20fafe4..1ae51e7 100644 --- a/examples/docker/src/docker.ts +++ b/examples/docker/src/index.ts @@ -1,5 +1,6 @@ import Docker from "dockerode"; -import { runPrompt, waitForHealth } from "@sandbox-agent/example-shared"; +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; const IMAGE = "alpine:latest"; const PORT = 3000; @@ -44,13 +45,19 @@ await container.start(); const baseUrl = `http://127.0.0.1:${PORT}`; await waitForHealth({ baseUrl }); +const client = await SandboxAgent.connect({ baseUrl }); +const sessionId = generateSessionId(); +await client.createSession(sessionId, { agent: detectAgent() }); + +console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); +console.log(" Press Ctrl+C to stop."); + +const keepAlive = setInterval(() => {}, 60_000); const cleanup = async () => { + clearInterval(keepAlive); try { await container.stop({ t: 5 }); } catch {} try { await container.remove({ force: true }); } catch {} process.exit(0); }; process.once("SIGINT", cleanup); process.once("SIGTERM", cleanup); - -await runPrompt(baseUrl); -await cleanup(); diff --git a/examples/e2b/package.json b/examples/e2b/package.json index f44574c..3e28ae2 100644 --- a/examples/e2b/package.json +++ b/examples/e2b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "start": "tsx src/e2b.ts", + "start": "tsx src/index.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/examples/e2b/src/e2b.ts b/examples/e2b/src/index.ts similarity index 71% rename from examples/e2b/src/e2b.ts rename to examples/e2b/src/index.ts index 8d54c88..d82141d 100644 --- a/examples/e2b/src/e2b.ts +++ b/examples/e2b/src/index.ts @@ -1,5 +1,6 @@ import { Sandbox } from "@e2b/code-interpreter"; -import { runPrompt, waitForHealth } from "@sandbox-agent/example-shared"; +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -29,12 +30,18 @@ const baseUrl = `https://${sandbox.getHost(3000)}`; console.log("Waiting for server..."); await waitForHealth({ baseUrl }); +const client = await SandboxAgent.connect({ baseUrl }); +const sessionId = generateSessionId(); +await client.createSession(sessionId, { agent: detectAgent() }); + +console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); +console.log(" Press Ctrl+C to stop."); + +const keepAlive = setInterval(() => {}, 60_000); const cleanup = async () => { + clearInterval(keepAlive); await sandbox.kill(); process.exit(0); }; process.once("SIGINT", cleanup); process.once("SIGTERM", cleanup); - -await runPrompt(baseUrl); -await cleanup(); diff --git a/examples/file-system/package.json b/examples/file-system/package.json new file mode 100644 index 0000000..87921a3 --- /dev/null +++ b/examples/file-system/package.json @@ -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" + } +} diff --git a/examples/file-system/src/index.ts b/examples/file-system/src/index.ts new file mode 100644 index 0000000..2e2c8f9 --- /dev/null +++ b/examples/file-system/src/index.ts @@ -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)); }); diff --git a/examples/file-system/tsconfig.json b/examples/file-system/tsconfig.json new file mode 100644 index 0000000..96ba2fd --- /dev/null +++ b/examples/file-system/tsconfig.json @@ -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"] +} diff --git a/examples/mcp-custom-tool/package.json b/examples/mcp-custom-tool/package.json new file mode 100644 index 0000000..250bfb0 --- /dev/null +++ b/examples/mcp-custom-tool/package.json @@ -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" + } +} diff --git a/examples/mcp-custom-tool/src/index.ts b/examples/mcp-custom-tool/src/index.ts new file mode 100644 index 0000000..0c0bc33 --- /dev/null +++ b/examples/mcp-custom-tool/src/index.ts @@ -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)); }); diff --git a/examples/mcp-custom-tool/src/mcp-server.ts b/examples/mcp-custom-tool/src/mcp-server.ts new file mode 100644 index 0000000..38c79b7 --- /dev/null +++ b/examples/mcp-custom-tool/src/mcp-server.ts @@ -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(); diff --git a/examples/mcp-custom-tool/tsconfig.json b/examples/mcp-custom-tool/tsconfig.json new file mode 100644 index 0000000..96ba2fd --- /dev/null +++ b/examples/mcp-custom-tool/tsconfig.json @@ -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"] +} diff --git a/examples/mcp/package.json b/examples/mcp/package.json new file mode 100644 index 0000000..950cbb7 --- /dev/null +++ b/examples/mcp/package.json @@ -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" + } +} diff --git a/examples/mcp/src/index.ts b/examples/mcp/src/index.ts new file mode 100644 index 0000000..84be8df --- /dev/null +++ b/examples/mcp/src/index.ts @@ -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)); }); diff --git a/examples/mcp/tsconfig.json b/examples/mcp/tsconfig.json new file mode 100644 index 0000000..96ba2fd --- /dev/null +++ b/examples/mcp/tsconfig.json @@ -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"] +} diff --git a/examples/shared/Dockerfile b/examples/shared/Dockerfile new file mode 100644 index 0000000..1a960d6 --- /dev/null +++ b/examples/shared/Dockerfile @@ -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 diff --git a/examples/shared/Dockerfile.dev b/examples/shared/Dockerfile.dev new file mode 100644 index 0000000..87ba956 --- /dev/null +++ b/examples/shared/Dockerfile.dev @@ -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 diff --git a/examples/shared/package.json b/examples/shared/package.json index 18906ef..4c868ed 100644 --- a/examples/shared/package.json +++ b/examples/shared/package.json @@ -3,15 +3,18 @@ "private": true, "type": "module", "exports": { - ".": "./src/sandbox-agent-client.ts" + ".": "./src/sandbox-agent-client.ts", + "./docker": "./src/docker.ts" }, "scripts": { "typecheck": "tsc --noEmit" }, "dependencies": { + "dockerode": "latest", "sandbox-agent": "workspace:*" }, "devDependencies": { + "@types/dockerode": "latest", "@types/node": "latest", "typescript": "latest" } diff --git a/examples/shared/src/docker.ts b/examples/shared/src/docker.ts new file mode 100644 index 0000000..5ec8a8c --- /dev/null +++ b/examples/shared/src/docker.ts @@ -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; +} + +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 { + const parsed: Record = {}; + 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 { + const merged: Record = {}; + let extracted: Record = {}; + 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 { + 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 { + 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 = {}; + + 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((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 }; +} diff --git a/examples/shared/src/sandbox-agent-client.ts b/examples/shared/src/sandbox-agent-client.ts index 8258ee8..df8fa51 100644 --- a/examples/shared/src/sandbox-agent-client.ts +++ b/examples/shared/src/sandbox-agent-client.ts @@ -3,11 +3,7 @@ * Provides minimal helpers for connecting to and interacting with sandbox-agent servers. */ -import { createInterface } from "node:readline/promises"; -import { randomUUID } from "node:crypto"; import { setTimeout as delay } from "node:timers/promises"; -import { SandboxAgent } from "sandbox-agent"; -import type { PermissionEventData, QuestionEventData } from "sandbox-agent"; function normalizeBaseUrl(baseUrl: string): string { return baseUrl.replace(/\/+$/, ""); @@ -27,10 +23,12 @@ export function buildInspectorUrl({ baseUrl, token, headers, + sessionId, }: { baseUrl: string; token?: string; headers?: Record; + sessionId?: string; }): string { const normalized = normalizeBaseUrl(ensureUrl(baseUrl)); const params = new URLSearchParams(); @@ -41,7 +39,8 @@ export function buildInspectorUrl({ params.set("headers", JSON.stringify(headers)); } const queryString = params.toString(); - return `${normalized}/ui/${queryString ? `?${queryString}` : ""}`; + const sessionPath = sessionId ? `sessions/${sessionId}` : ""; + return `${normalized}/ui/${sessionPath}${queryString ? `?${queryString}` : ""}`; } export function logInspectorUrl({ @@ -110,125 +109,39 @@ export async function waitForHealth({ throw (lastError ?? new Error("Timed out waiting for /v1/health")) as Error; } -function detectAgent(): string { +export function generateSessionId(): string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let id = "session-"; + for (let i = 0; i < 8; i++) { + id += chars[Math.floor(Math.random() * chars.length)]; + } + return id; +} + +export function detectAgent(): string { if (process.env.SANDBOX_AGENT) return process.env.SANDBOX_AGENT; - if (process.env.ANTHROPIC_API_KEY) return "claude"; - if (process.env.OPENAI_API_KEY) return "codex"; + const hasClaude = Boolean( + process.env.ANTHROPIC_API_KEY || + process.env.CLAUDE_API_KEY || + process.env.CLAUDE_CODE_OAUTH_TOKEN || + process.env.ANTHROPIC_AUTH_TOKEN, + ); + const openAiLikeKey = process.env.OPENAI_API_KEY || process.env.CODEX_API_KEY || ""; + const hasCodexApiKey = openAiLikeKey.startsWith("sk-"); + if (hasCodexApiKey && hasClaude) { + console.log("Both Claude and Codex API keys detected; defaulting to codex. Set SANDBOX_AGENT to override."); + return "codex"; + } + if (!hasCodexApiKey && openAiLikeKey) { + console.log("OpenAI/Codex credential is not an API key (expected sk-...), skipping codex auto-select."); + } + if (hasCodexApiKey) return "codex"; + if (hasClaude) { + if (openAiLikeKey && !hasCodexApiKey) { + console.log("Using claude by default."); + } + return "claude"; + } return "claude"; } -export async function runPrompt(baseUrl: string): Promise { - 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((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; - } - } -} diff --git a/examples/skills-custom-tool/SKILL.md b/examples/skills-custom-tool/SKILL.md new file mode 100644 index 0000000..67afa25 --- /dev/null +++ b/examples/skills-custom-tool/SKILL.md @@ -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 +``` + +This prints a single random integer between min and max (inclusive). diff --git a/examples/skills-custom-tool/package.json b/examples/skills-custom-tool/package.json new file mode 100644 index 0000000..7edf635 --- /dev/null +++ b/examples/skills-custom-tool/package.json @@ -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" + } +} diff --git a/examples/skills-custom-tool/src/index.ts b/examples/skills-custom-tool/src/index.ts new file mode 100644 index 0000000..c53498b --- /dev/null +++ b/examples/skills-custom-tool/src/index.ts @@ -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)); }); diff --git a/examples/skills-custom-tool/src/random-number.ts b/examples/skills-custom-tool/src/random-number.ts new file mode 100644 index 0000000..2b3d758 --- /dev/null +++ b/examples/skills-custom-tool/src/random-number.ts @@ -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 "); + process.exit(1); +} + +console.log(Math.floor(Math.random() * (max - min + 1)) + min); diff --git a/examples/skills-custom-tool/tsconfig.json b/examples/skills-custom-tool/tsconfig.json new file mode 100644 index 0000000..96ba2fd --- /dev/null +++ b/examples/skills-custom-tool/tsconfig.json @@ -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"] +} diff --git a/examples/skills/package.json b/examples/skills/package.json new file mode 100644 index 0000000..65829dc --- /dev/null +++ b/examples/skills/package.json @@ -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" + } +} diff --git a/examples/skills/src/index.ts b/examples/skills/src/index.ts new file mode 100644 index 0000000..2e1990e --- /dev/null +++ b/examples/skills/src/index.ts @@ -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)); }); diff --git a/examples/skills/tsconfig.json b/examples/skills/tsconfig.json new file mode 100644 index 0000000..96ba2fd --- /dev/null +++ b/examples/skills/tsconfig.json @@ -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"] +} diff --git a/examples/vercel/package.json b/examples/vercel/package.json index 9f0569d..a193a36 100644 --- a/examples/vercel/package.json +++ b/examples/vercel/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "start": "tsx src/vercel.ts", + "start": "tsx src/index.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/examples/vercel/src/vercel.ts b/examples/vercel/src/index.ts similarity index 73% rename from examples/vercel/src/vercel.ts rename to examples/vercel/src/index.ts index ed2d836..93093ae 100644 --- a/examples/vercel/src/vercel.ts +++ b/examples/vercel/src/index.ts @@ -1,5 +1,6 @@ import { Sandbox } from "@vercel/sandbox"; -import { runPrompt, waitForHealth } from "@sandbox-agent/example-shared"; +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -40,12 +41,18 @@ const baseUrl = sandbox.domain(3000); console.log("Waiting for server..."); await waitForHealth({ baseUrl }); +const client = await SandboxAgent.connect({ baseUrl }); +const sessionId = generateSessionId(); +await client.createSession(sessionId, { agent: detectAgent() }); + +console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); +console.log(" Press Ctrl+C to stop."); + +const keepAlive = setInterval(() => {}, 60_000); const cleanup = async () => { + clearInterval(keepAlive); await sandbox.stop(); process.exit(0); }; process.once("SIGINT", cleanup); process.once("SIGTERM", cleanup); - -await runPrompt(baseUrl); -await cleanup(); diff --git a/frontend/packages/inspector/index.html b/frontend/packages/inspector/index.html index 539c467..225d119 100644 --- a/frontend/packages/inspector/index.html +++ b/frontend/packages/inspector/index.html @@ -336,6 +336,12 @@ color: var(--danger); } + .banner.config-note { + background: rgba(255, 159, 10, 0.12); + border-left: 3px solid var(--warning); + color: var(--warning); + } + .banner.success { background: rgba(48, 209, 88, 0.1); border-left: 3px solid var(--success); @@ -471,11 +477,12 @@ position: relative; } - .sidebar-add-menu { + .sidebar-add-menu, + .session-create-menu { position: absolute; top: 36px; left: 0; - min-width: 200px; + min-width: 220px; background: var(--surface); border: 1px solid var(--border-2); border-radius: 8px; @@ -487,6 +494,405 @@ z-index: 60; } + .session-create-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 6px 4px; + margin-bottom: 4px; + } + + .session-create-back { + width: 24px; + height: 24px; + background: transparent; + border: 1px solid var(--border-2); + border-radius: 4px; + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition); + flex-shrink: 0; + } + + .session-create-back:hover { + border-color: var(--accent); + color: var(--accent); + } + + .session-create-agent-name { + font-size: 12px; + font-weight: 600; + color: var(--text); + } + + .session-create-form { + display: flex; + flex-direction: column; + gap: 0; + padding: 4px 2px; + } + + .session-create-form .setup-field { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + height: 28px; + } + + .session-create-form .setup-label { + width: 72px; + flex-shrink: 0; + text-align: right; + } + + .session-create-form .setup-select, + .session-create-form .setup-input { + flex: 1; + min-width: 0; + } + + .session-create-section { + overflow: hidden; + } + + .session-create-section-toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 28px; + padding: 0; + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 11px; + cursor: pointer; + transition: color var(--transition); + } + + .session-create-section-toggle:hover { + color: var(--text); + } + + .session-create-section-toggle .setup-label { + width: 72px; + flex-shrink: 0; + text-align: right; + } + + .session-create-section-count { + font-size: 11px; + font-weight: 400; + color: var(--muted); + } + + .session-create-section-arrow { + margin-left: auto; + color: var(--muted-2); + flex-shrink: 0; + } + + .session-create-section-body { + margin: 4px 0 6px; + padding: 8px; + border: 1px solid var(--border-2); + border-radius: 4px; + background: var(--surface-2); + } + + .session-create-textarea { + width: 100%; + background: var(--surface-2); + border: 1px solid var(--border-2); + border-radius: 4px; + padding: 6px 8px; + font-size: 10px; + color: var(--text); + outline: none; + resize: vertical; + min-height: 60px; + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace; + transition: border-color var(--transition); + } + + .session-create-textarea:focus { + border-color: var(--accent); + } + + .session-create-textarea::placeholder { + color: var(--muted-2); + } + + .session-create-inline-error { + font-size: 10px; + color: var(--danger); + margin-top: 4px; + line-height: 1.4; + } + + .session-create-skill-list { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 4px; + } + + .session-create-skill-item { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 4px 3px 8px; + background: var(--surface-2); + border: 1px solid var(--border-2); + border-radius: 4px; + } + + .session-create-skill-path { + flex: 1; + min-width: 0; + font-size: 10px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .session-create-skill-remove { + width: 18px; + height: 18px; + background: transparent; + border: none; + border-radius: 3px; + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all var(--transition); + } + + .session-create-skill-remove:hover { + color: var(--danger); + background: rgba(255, 59, 48, 0.12); + } + + .session-create-skill-add-row { + display: flex; + } + + .session-create-skill-input { + width: 100%; + background: var(--surface-2); + border: 1px solid var(--accent); + border-radius: 4px; + padding: 4px 8px; + font-size: 10px; + color: var(--text); + outline: none; + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace; + } + + .session-create-skill-input::placeholder { + color: var(--muted-2); + } + + .session-create-skill-type-badge { + display: inline-flex; + align-items: center; + padding: 1px 5px; + border-radius: 3px; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + background: rgba(255, 79, 0, 0.15); + color: var(--accent); + flex-shrink: 0; + } + + .session-create-skill-type-row { + display: flex; + gap: 4px; + } + + .session-create-skill-type-select { + width: 80px; + flex-shrink: 0; + background: var(--surface-2); + border: 1px solid var(--accent); + border-radius: 4px; + padding: 4px 6px; + font-size: 10px; + color: var(--text); + outline: none; + cursor: pointer; + } + + .session-create-mcp-list { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 4px; + } + + .session-create-mcp-item { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 4px 3px 8px; + background: var(--surface-2); + border: 1px solid var(--border-2); + border-radius: 4px; + } + + .session-create-mcp-info { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + } + + .session-create-mcp-name { + font-size: 11px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + } + + .session-create-mcp-type { + font-size: 9px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.3px; + color: var(--muted); + background: var(--surface); + padding: 1px 4px; + border-radius: 3px; + white-space: nowrap; + } + + .session-create-mcp-summary { + font-size: 10px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + + .session-create-mcp-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + } + + .session-create-mcp-edit { + display: flex; + flex-direction: column; + gap: 4px; + } + + .session-create-mcp-name-input { + width: 100%; + background: var(--surface-2); + border: 1px solid var(--accent); + border-radius: 4px; + padding: 4px 8px; + font-size: 11px; + color: var(--text); + outline: none; + } + + .session-create-mcp-name-input:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + .session-create-mcp-name-input::placeholder { + color: var(--muted-2); + } + + .session-create-mcp-edit-actions { + display: flex; + gap: 4px; + } + + .session-create-mcp-save, + .session-create-mcp-cancel { + flex: 1; + padding: 4px 8px; + border-radius: 4px; + border: none; + font-size: 10px; + font-weight: 600; + cursor: pointer; + transition: background var(--transition); + } + + .session-create-mcp-save { + background: var(--accent); + color: #fff; + } + + .session-create-mcp-save:hover { + background: var(--accent-hover); + } + + .session-create-mcp-cancel { + background: var(--border-2); + color: var(--text-secondary); + } + + .session-create-mcp-cancel:hover { + background: var(--muted-2); + } + + .session-create-add-btn { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + padding: 4px 8px; + background: transparent; + border: 1px dashed var(--border-2); + border-radius: 4px; + color: var(--muted); + font-size: 10px; + cursor: pointer; + transition: all var(--transition); + } + + .session-create-add-btn:hover { + border-color: var(--accent); + color: var(--accent); + } + + .session-create-actions { + padding: 4px 2px 2px; + margin-top: 4px; + } + + .session-create-actions .button.primary { + width: 100%; + padding: 8px 12px; + font-size: 12px; + } + + /* Empty state variant of session-create-menu */ + .empty-state-menu-wrapper .session-create-menu { + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 8px; + } + .sidebar-add-option { background: transparent; border: 1px solid transparent; @@ -515,12 +921,40 @@ .agent-option-left { display: flex; flex-direction: column; + align-items: flex-start; gap: 2px; min-width: 0; } .agent-option-name { white-space: nowrap; + min-width: 0; + } + + .agent-option-version { + font-size: 10px; + color: var(--muted); + white-space: nowrap; + } + + .sidebar-add-option:hover .agent-option-version { + color: rgba(255, 255, 255, 0.6); + } + + .agent-option-badges { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + } + + .agent-option-arrow { + color: var(--muted-2); + transition: color var(--transition); + } + + .sidebar-add-option:hover .agent-option-arrow { + color: rgba(255, 255, 255, 0.6); } .agent-badge { @@ -535,9 +969,6 @@ flex-shrink: 0; } - .agent-badge.version { - color: var(--muted); - } .sidebar-add-status { padding: 6px 8px; @@ -1043,6 +1474,36 @@ height: 16px; } + /* Session Config Bar */ + .session-config-bar { + display: flex; + align-items: flex-start; + gap: 20px; + padding: 10px 16px 12px; + border-top: 1px solid var(--border); + flex-shrink: 0; + flex-wrap: wrap; + } + + .session-config-field { + display: flex; + flex-direction: column; + gap: 2px; + } + + .session-config-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--muted); + } + + .session-config-value { + font-size: 12px; + color: #8e8e93; + } + /* Setup Row */ .setup-row { display: flex; @@ -1207,6 +1668,29 @@ color: #fff; } + .setup-config-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + } + + .setup-config-btn { + border: 1px solid var(--border-2); + border-radius: 4px; + background: var(--surface); + color: var(--text-secondary); + } + + .setup-config-btn:hover { + border-color: var(--accent); + color: var(--accent); + } + + .setup-config-btn.error { + color: var(--danger); + border-color: rgba(255, 59, 48, 0.4); + } + .setup-version { font-size: 10px; color: var(--muted); @@ -1311,6 +1795,15 @@ margin-bottom: 0; } + .config-textarea { + min-height: 130px; + } + + .config-inline-error { + margin-top: 8px; + margin-bottom: 0; + } + .card-header { display: flex; align-items: center; @@ -1319,6 +1812,16 @@ margin-bottom: 8px; } + .card-header-pills { + display: flex; + align-items: center; + gap: 6px; + } + + .spinner-icon { + animation: spin 0.8s linear infinite; + } + .card-title { font-size: 13px; font-weight: 600; diff --git a/frontend/packages/inspector/src/App.tsx b/frontend/packages/inspector/src/App.tsx index 5bd196e..ae55079 100644 --- a/frontend/packages/inspector/src/App.tsx +++ b/frontend/packages/inspector/src/App.tsx @@ -3,11 +3,13 @@ import { SandboxAgentError, SandboxAgent, type AgentInfo, + type CreateSessionRequest, type AgentModelInfo, type AgentModeInfo, type PermissionEventData, type QuestionEventData, type SessionInfo, + type SkillSource, type UniversalEvent, type UniversalItem } from "sandbox-agent"; @@ -32,6 +34,41 @@ type ItemDeltaEventData = { delta: string; }; +export type McpServerEntry = { + name: string; + configJson: string; + error: string | null; +}; + +type ParsedMcpConfig = { + value: NonNullable; + 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 = {}; + 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 => { + return { sources }; +}; + const buildStubItem = (itemId: string, nativeItemId?: string | null): UniversalItem => { return { item_id: itemId, @@ -53,6 +90,23 @@ const getCurrentOriginEndpoint = () => { return window.location.origin; }; +const getSessionIdFromPath = (): string => { + const basePath = import.meta.env.BASE_URL; + const path = window.location.pathname; + const relative = path.startsWith(basePath) ? path.slice(basePath.length) : path; + const match = relative.match(/^sessions\/(.+)/); + return match ? match[1] : ""; +}; + +const updateSessionPath = (id: string) => { + const basePath = import.meta.env.BASE_URL; + const params = window.location.search; + const newPath = id ? `${basePath}sessions/${id}${params}` : `${basePath}${params}`; + if (window.location.pathname + window.location.search !== newPath) { + window.history.replaceState(null, "", newPath); + } +}; + const getInitialConnection = () => { if (typeof window === "undefined") { return { endpoint: "http://127.0.0.1:2468", token: "", headers: {} as Record, hasUrlParam: false }; @@ -103,11 +157,7 @@ export default function App() { const [modelsErrorByAgent, setModelsErrorByAgent] = useState>({}); const [agentId, setAgentId] = useState("claude"); - const [agentMode, setAgentMode] = useState(""); - const [permissionMode, setPermissionMode] = useState("default"); - const [model, setModel] = useState(""); - const [variant, setVariant] = useState(""); - const [sessionId, setSessionId] = useState(""); + const [sessionId, setSessionId] = useState(getSessionIdFromPath()); const [sessionError, setSessionError] = useState(null); const [message, setMessage] = useState(""); @@ -115,6 +165,8 @@ export default function App() { const [offset, setOffset] = useState(0); const offsetRef = useRef(0); const [eventsLoading, setEventsLoading] = useState(false); + const [mcpServers, setMcpServers] = useState([]); + const [skillSources, setSkillSources] = useState([]); const [polling, setPolling] = useState(false); const pollTimerRef = useRef(null); @@ -377,50 +429,52 @@ export default function App() { stopSse(); stopTurnStream(); setSessionId(session.sessionId); + updateSessionPath(session.sessionId); setAgentId(session.agent); - setAgentMode(session.agentMode); - setPermissionMode(session.permissionMode); - setModel(session.model ?? ""); - setVariant(session.variant ?? ""); setEvents([]); setOffset(0); offsetRef.current = 0; setSessionError(null); }; - const createNewSession = async (nextAgentId?: string) => { + const createNewSession = async ( + nextAgentId: string, + config: { model: string; agentMode: string; permissionMode: string; variant: string } + ) => { stopPolling(); stopSse(); stopTurnStream(); - const selectedAgent = nextAgentId ?? agentId; - if (nextAgentId) { - setAgentId(nextAgentId); + setAgentId(nextAgentId); + if (parsedMcpConfig.error) { + setSessionError(parsedMcpConfig.error); + return; } const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; let id = "session-"; for (let i = 0; i < 8; i++) { id += chars[Math.floor(Math.random() * chars.length)]; } - setSessionId(id); - setEvents([]); - setOffset(0); - offsetRef.current = 0; setSessionError(null); try { - const body: { - agent: string; - agentMode?: string; - permissionMode?: string; - model?: string; - variant?: string; - } = { agent: selectedAgent }; - if (agentMode) body.agentMode = agentMode; - if (permissionMode) body.permissionMode = permissionMode; - if (model) body.model = model; - if (variant) body.variant = variant; + const body: CreateSessionRequest = { agent: nextAgentId }; + if (config.agentMode) body.agentMode = config.agentMode; + if (config.permissionMode) body.permissionMode = config.permissionMode; + if (config.model) body.model = config.model; + if (config.variant) body.variant = config.variant; + if (parsedMcpConfig.count > 0) { + body.mcp = parsedMcpConfig.value; + } + if (parsedSkillsConfig.sources.length > 0) { + body.skills = parsedSkillsConfig; + } await getClient().createSession(id, body); + setSessionId(id); + updateSessionPath(id); + setEvents([]); + setOffset(0); + offsetRef.current = 0; await fetchSessions(); } catch (error) { setSessionError(getErrorMessage(error, "Unable to create session")); @@ -876,38 +930,10 @@ export default function App() { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [transcriptEntries]); - useEffect(() => { - if (connected && agentId && !modesByAgent[agentId]) { - loadModes(agentId); - } - }, [connected, agentId]); - - useEffect(() => { - if (connected && agentId && !modelsByAgent[agentId]) { - loadModels(agentId); - } - }, [connected, agentId]); - - useEffect(() => { - const modes = modesByAgent[agentId]; - if (modes && modes.length > 0 && !agentMode) { - setAgentMode(modes[0].id); - } - }, [modesByAgent, agentId]); - const currentAgent = agents.find((agent) => agent.id === agentId); - const activeModes = modesByAgent[agentId] ?? []; - const modesLoading = modesLoadingByAgent[agentId] ?? false; - const modesError = modesErrorByAgent[agentId] ?? null; - const modelOptions = modelsByAgent[agentId] ?? []; - const modelsLoading = modelsLoadingByAgent[agentId] ?? false; - const modelsError = modelsErrorByAgent[agentId] ?? null; - const defaultModel = defaultModelByAgent[agentId] ?? ""; - const selectedModelId = model || defaultModel; - const selectedModel = modelOptions.find((entry) => entry.id === selectedModelId); - const variantOptions = selectedModel?.variants ?? []; - const defaultVariant = selectedModel?.defaultVariant ?? ""; - const supportsVariants = Boolean(currentAgent?.capabilities?.variants); + const currentSessionInfo = sessions.find((s) => s.sessionId === sessionId); + const parsedMcpConfig = useMemo(() => buildMcpConfig(mcpServers), [mcpServers]); + const parsedSkillsConfig = useMemo(() => buildSkillsConfig(skillSources), [skillSources]); const agentDisplayNames: Record = { claude: "Claude Code", codex: "Codex", @@ -917,6 +943,15 @@ export default function App() { }; const agentLabel = agentDisplayNames[agentId] ?? agentId; + const handleSelectAgent = useCallback((targetAgentId: string) => { + if (connected && !modesByAgent[targetAgentId]) { + loadModes(targetAgentId); + } + if (connected && !modelsByAgent[targetAgentId]) { + loadModels(targetAgentId); + } + }, [connected, modesByAgent, modelsByAgent]); + const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); @@ -980,17 +1015,28 @@ export default function App() { onSelectSession={selectSession} onRefresh={fetchSessions} onCreateSession={createNewSession} + onSelectAgent={handleSelectAgent} agents={agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, capabilities: {} }) as AgentInfo)} agentsLoading={agentsLoading} agentsError={agentsError} sessionsLoading={sessionsLoading} sessionsError={sessionsError} + modesByAgent={modesByAgent} + modelsByAgent={modelsByAgent} + defaultModelByAgent={defaultModelByAgent} + modesLoadingByAgent={modesLoadingByAgent} + modelsLoadingByAgent={modelsLoadingByAgent} + modesErrorByAgent={modesErrorByAgent} + modelsErrorByAgent={modelsErrorByAgent} + mcpServers={mcpServers} + onMcpServersChange={setMcpServers} + mcpConfigError={parsedMcpConfig.error} + skillSources={skillSources} + onSkillSourcesChange={setSkillSources} /> ({ id, installed: false, capabilities: {} }) as AgentInfo)} agentsLoading={agentsLoading} agentsError={agentsError} messagesEndRef={messagesEndRef} - agentId={agentId} agentLabel={agentLabel} - agentMode={agentMode} - permissionMode={permissionMode} - model={model} - variant={variant} - modelOptions={modelOptions} - defaultModel={defaultModel} - modelsLoading={modelsLoading} - modelsError={modelsError} - variantOptions={variantOptions} - defaultVariant={defaultVariant} - supportsVariants={supportsVariants} - streamMode={streamMode} - activeModes={activeModes} currentAgentVersion={currentAgent?.version ?? null} - modesLoading={modesLoading} - modesError={modesError} - onAgentModeChange={setAgentMode} - onPermissionModeChange={setPermissionMode} - onModelChange={setModel} - onVariantChange={setVariant} - onStreamModeChange={setStreamMode} - onToggleStream={toggleStream} + sessionModel={currentSessionInfo?.model ?? null} + sessionVariant={currentSessionInfo?.variant ?? null} + sessionPermissionMode={currentSessionInfo?.permissionMode ?? null} + sessionMcpServerCount={currentSessionInfo?.mcp ? Object.keys(currentSessionInfo.mcp).length : 0} + sessionSkillSourceCount={currentSessionInfo?.skills?.sources?.length ?? 0} onEndSession={endSession} - hasSession={Boolean(sessionId)} eventError={eventError} questionRequests={questionRequests} permissionRequests={permissionRequests} @@ -1036,6 +1065,18 @@ export default function App() { onAnswerQuestion={answerQuestion} onRejectQuestion={rejectQuestion} onReplyPermission={replyPermission} + modesByAgent={modesByAgent} + modelsByAgent={modelsByAgent} + defaultModelByAgent={defaultModelByAgent} + modesLoadingByAgent={modesLoadingByAgent} + modelsLoadingByAgent={modelsLoadingByAgent} + modesErrorByAgent={modesErrorByAgent} + modelsErrorByAgent={modelsErrorByAgent} + mcpServers={mcpServers} + onMcpServersChange={setMcpServers} + mcpConfigError={parsedMcpConfig.error} + skillSources={skillSources} + onSkillSourcesChange={setSkillSources} /> = { + 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; + modelsByAgent: Record; + defaultModelByAgent: Record; + modesLoadingByAgent: Record; + modelsLoadingByAgent: Record; + modesErrorByAgent: Record; + modelsErrorByAgent: Record; + 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(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(null); + const skillSourceRef = useRef(null); + + // MCP add/edit state + const [addingMcp, setAddingMcp] = useState(false); + const [editingMcpIndex, setEditingMcpIndex] = useState(null); + const [mcpName, setMcpName] = useState(""); + const [mcpJson, setMcpJson] = useState(""); + const [mcpLocalError, setMcpLocalError] = useState(null); + const mcpNameRef = useRef(null); + const mcpJsonRef = useRef(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 ( +
+ {agentsLoading &&
Loading agents...
} + {agentsError &&
{agentsError}
} + {!agentsLoading && !agentsError && agents.length === 0 && ( +
No agents available.
+ )} + {!agentsLoading && !agentsError && + agents.map((agent) => ( + + ))} +
+ ); + } + + // 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 ( +
+
+ + {agentLabel} +
+ +
+
+ Model + {showModelSelect ? ( + + ) : ( + setModel(e.target.value)} + placeholder="Model" + title="Model" + /> + )} +
+ +
+ Mode + +
+ +
+ Permission + +
+ + {supportsVariants && ( +
+ Variant + {showVariantSelect ? ( + + ) : ( + setVariant(e.target.value)} + placeholder="Variant" + title="Variant" + /> + )} +
+ )} + + {/* MCP Servers - collapsible */} +
+ + {mcpExpanded && ( +
+ {mcpServers.length > 0 && !isEditingMcp && ( +
+ {mcpServers.map((entry, index) => ( +
+
+ {entry.name} + {getServerType(entry.configJson) && ( + {getServerType(entry.configJson)} + )} + {getServerSummary(entry.configJson)} +
+
+ + +
+
+ ))} +
+ )} + {isEditingMcp ? ( +
+ { setMcpName(e.target.value); setMcpLocalError(null); }} + placeholder="server-name" + disabled={editingMcpIndex !== null} + /> +