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

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

17
examples/CLAUDE.md Normal file
View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import { Daytona, Image } from "@daytonaio/sdk";
import { runPrompt } from "@sandbox-agent/example-shared";
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared";
const daytona = new Daytona();
@ -24,12 +25,21 @@ await sandbox.process.executeCommand(
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
console.log("Waiting for server...");
await waitForHealth({ baseUrl });
const client = await SandboxAgent.connect({ baseUrl });
const sessionId = generateSessionId();
await client.createSession(sessionId, { agent: detectAgent() });
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000);
const cleanup = async () => {
clearInterval(keepAlive);
await sandbox.delete(60);
process.exit(0);
};
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);
await runPrompt(baseUrl);
await cleanup();

View file

@ -1,5 +1,6 @@
import { Daytona } from "@daytonaio/sdk";
import { runPrompt } from "@sandbox-agent/example-shared";
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared";
const daytona = new Daytona();
@ -25,12 +26,21 @@ await sandbox.process.executeCommand(
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
console.log("Waiting for server...");
await waitForHealth({ baseUrl });
const client = await SandboxAgent.connect({ baseUrl });
const sessionId = generateSessionId();
await client.createSession(sessionId, { agent: detectAgent() });
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000);
const cleanup = async () => {
clearInterval(keepAlive);
await sandbox.delete(60);
process.exit(0);
};
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);
await runPrompt(baseUrl);
await cleanup();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

@ -0,0 +1,5 @@
FROM node:22-bookworm-slim
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates > /dev/null 2>&1 && \
rm -rf /var/lib/apt/lists/* && \
npm install -g --silent @sandbox-agent/cli@latest && \
sandbox-agent install-agent claude

View file

@ -0,0 +1,58 @@
FROM node:22-bookworm-slim AS frontend
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /build
# Copy workspace root config
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Copy packages needed for the inspector build chain:
# inspector -> sandbox-agent SDK -> cli-shared
COPY sdks/typescript/ sdks/typescript/
COPY sdks/cli-shared/ sdks/cli-shared/
COPY frontend/packages/inspector/ frontend/packages/inspector/
COPY docs/openapi.json docs/
# Create stub package.json for workspace packages referenced in pnpm-workspace.yaml
# but not needed for the inspector build (avoids install errors).
RUN set -e; for dir in \
sdks/cli sdks/gigacode \
resources/agent-schemas resources/vercel-ai-sdk-schemas \
scripts/release scripts/sandbox-testing \
examples/shared examples/docker examples/e2b examples/vercel \
examples/daytona examples/cloudflare examples/file-system \
examples/mcp examples/mcp-custom-tool \
examples/skills examples/skills-custom-tool \
frontend/packages/website; do \
mkdir -p "$dir"; \
printf '{"name":"@stub/%s","private":true,"version":"0.0.0"}\n' "$(basename "$dir")" > "$dir/package.json"; \
done; \
for parent in sdks/cli/platforms sdks/gigacode/platforms; do \
for plat in darwin-arm64 darwin-x64 linux-arm64 linux-x64 win32-x64; do \
mkdir -p "$parent/$plat"; \
printf '{"name":"@stub/%s-%s","private":true,"version":"0.0.0"}\n' "$(basename "$parent")" "$plat" > "$parent/$plat/package.json"; \
done; \
done
RUN pnpm install --no-frozen-lockfile
ENV SKIP_OPENAPI_GEN=1
RUN pnpm --filter sandbox-agent build && \
pnpm --filter @sandbox-agent/inspector build
FROM rust:1.88.0-bookworm AS builder
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY server/ ./server/
COPY gigacode/ ./gigacode/
COPY resources/agent-schemas/artifacts/ ./resources/agent-schemas/artifacts/
COPY --from=frontend /build/frontend/packages/inspector/dist/ ./frontend/packages/inspector/dist/
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/build/target \
cargo build -p sandbox-agent --release && \
cp target/release/sandbox-agent /sandbox-agent
FROM node:22-bookworm-slim
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates > /dev/null 2>&1 && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent
RUN sandbox-agent install-agent claude

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import { Sandbox } from "@vercel/sandbox";
import { runPrompt, waitForHealth } from "@sandbox-agent/example-shared";
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared";
const envs: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
@ -40,12 +41,18 @@ const baseUrl = sandbox.domain(3000);
console.log("Waiting for server...");
await waitForHealth({ baseUrl });
const client = await SandboxAgent.connect({ baseUrl });
const sessionId = generateSessionId();
await client.createSession(sessionId, { agent: detectAgent() });
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000);
const cleanup = async () => {
clearInterval(keepAlive);
await sandbox.stop();
process.exit(0);
};
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);
await runPrompt(baseUrl);
await cleanup();