mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 04:03:31 +00:00
docs: add mcp and skill session config (#106)
This commit is contained in:
parent
d236edf35c
commit
4c8d93e077
95 changed files with 10014 additions and 1342 deletions
17
examples/CLAUDE.md
Normal file
17
examples/CLAUDE.md
Normal 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"}'`
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/e2b.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
19
examples/file-system/package.json
Normal file
19
examples/file-system/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
57
examples/file-system/src/index.ts
Normal file
57
examples/file-system/src/index.ts
Normal 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)); });
|
||||
16
examples/file-system/tsconfig.json
Normal file
16
examples/file-system/tsconfig.json
Normal 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"]
|
||||
}
|
||||
22
examples/mcp-custom-tool/package.json
Normal file
22
examples/mcp-custom-tool/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
49
examples/mcp-custom-tool/src/index.ts
Normal file
49
examples/mcp-custom-tool/src/index.ts
Normal 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)); });
|
||||
24
examples/mcp-custom-tool/src/mcp-server.ts
Normal file
24
examples/mcp-custom-tool/src/mcp-server.ts
Normal 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();
|
||||
16
examples/mcp-custom-tool/tsconfig.json
Normal file
16
examples/mcp-custom-tool/tsconfig.json
Normal 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
18
examples/mcp/package.json
Normal 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
31
examples/mcp/src/index.ts
Normal 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)); });
|
||||
16
examples/mcp/tsconfig.json
Normal file
16
examples/mcp/tsconfig.json
Normal 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"]
|
||||
}
|
||||
5
examples/shared/Dockerfile
Normal file
5
examples/shared/Dockerfile
Normal 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
|
||||
58
examples/shared/Dockerfile.dev
Normal file
58
examples/shared/Dockerfile.dev
Normal 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
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
301
examples/shared/src/docker.ts
Normal file
301
examples/shared/src/docker.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
examples/skills-custom-tool/SKILL.md
Normal file
12
examples/skills-custom-tool/SKILL.md
Normal 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).
|
||||
20
examples/skills-custom-tool/package.json
Normal file
20
examples/skills-custom-tool/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
53
examples/skills-custom-tool/src/index.ts
Normal file
53
examples/skills-custom-tool/src/index.ts
Normal 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)); });
|
||||
9
examples/skills-custom-tool/src/random-number.ts
Normal file
9
examples/skills-custom-tool/src/random-number.ts
Normal 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);
|
||||
16
examples/skills-custom-tool/tsconfig.json
Normal file
16
examples/skills-custom-tool/tsconfig.json
Normal 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/skills/package.json
Normal file
18
examples/skills/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
26
examples/skills/src/index.ts
Normal file
26
examples/skills/src/index.ts
Normal 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)); });
|
||||
16
examples/skills/tsconfig.json
Normal file
16
examples/skills/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/vercel.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue