diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index e4443f55abf..61220666ab3 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -9,6 +9,11 @@ import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-g import { processCLIAdapter } from "./process/index.js"; import { httpCLIAdapter } from "./http/index.js"; +const emissoSandboxCLIAdapter: CLIAdapterModule = { + type: "emisso_sandbox", + formatStdoutEvent: printClaudeStreamEvent, +}; + const claudeLocalCLIAdapter: CLIAdapterModule = { type: "claude_local", formatStdoutEvent: printClaudeStreamEvent, @@ -55,6 +60,7 @@ const adaptersByType = new Map( openclawGatewayCLIAdapter, processCLIAdapter, httpCLIAdapter, + emissoSandboxCLIAdapter, ].map((a) => [a.type, a]), ); diff --git a/doc/guides/engineering-agent-setup.md b/doc/guides/engineering-agent-setup.md new file mode 100644 index 00000000000..51841afa35f --- /dev/null +++ b/doc/guides/engineering-agent-setup.md @@ -0,0 +1,93 @@ +# Engineering Agent Setup Guide + +How to configure an engineering agent using the Emisso Sandbox adapter. + +## Recommended Adapter Config + +```json +{ + "model": "claude-sonnet-4-6", + "vcpus": 2, + "timeoutSec": 180, + "maxTurns": 30, + "cloneDepth": 1, + "snapshotId": "", + "mcpServers": { + "github": { + "command": "mcp-server-github", + "args": ["--token", "$GITHUB_TOKEN"] + } + } +} +``` + +## Environment Variables + +| Variable | Required | Purpose | +|---|---|---| +| `ANTHROPIC_API_KEY` | Yes | Claude API authentication | +| `GITHUB_TOKEN` | For private repos | Git clone authentication | +| `VERCEL_TOKEN` | Non-Vercel hosts | Vercel Sandbox authentication | +| `VERCEL_TEAM_ID` | Non-Vercel hosts | Vercel team for sandbox billing | +| `VERCEL_PROJECT_ID` | Optional | Vercel project scope | + +## MCP Server Examples + +### GitHub MCP Server + +Gives the agent access to GitHub issues, PRs, and repository metadata: + +```json +{ + "github": { + "command": "mcp-server-github", + "args": ["--token", "$GITHUB_TOKEN"] + } +} +``` + +### Supabase MCP Server + +Gives the agent access to query the database: + +```json +{ + "supabase": { + "command": "mcp-server-supabase", + "args": ["--url", "$SUPABASE_URL", "--key", "$SUPABASE_SERVICE_KEY"] + } +} +``` + +## Creating an Agent via UI + +1. Go to **Agents** → **Create Agent** +2. Set adapter type to **Emisso Sandbox** +3. Configure the model (Sonnet 4.6 recommended for most tasks) +4. Set vCPUs to 2 (increase to 4-8 for complex tasks) +5. Set timeout to 180s (increase for longer-running tasks) +6. Add MCP servers as needed +7. Set the repo URL (or rely on workspace context) +8. Run the environment test to verify configuration + +## Snapshots + +For faster cold starts, create a snapshot with the CLI pre-installed: + +1. Create a sandbox manually with `@vercel/sandbox` +2. Install Claude Code: `npm install -g @anthropic-ai/claude-code` +3. Create a snapshot: `sandbox.snapshot()` +4. Use the returned `snapshotId` in the agent config + +This reduces sandbox startup from ~60s to ~5s. + +## Workspace Strategy + +The adapter resolves the repo URL in this order: + +1. `adapterConfig.repoUrl` (explicit override) +2. `context.paperclipWorkspace.repoUrl` (from project workspace) +3. Falls back with an error if neither is set + +For project-scoped agents, the workspace context is automatically provided +by Paperclip when assigning issues from a project with a configured workspace. diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index ce89e0e80be..6653c365d09 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -344,7 +344,14 @@ export interface CreateConfigValues { workspaceBranchTemplate?: string; worktreeParentDir?: string; runtimeServicesJson?: string; + mcpServersJson?: string; maxTurnsPerRun: number; + maxTurns?: number; heartbeatEnabled: boolean; intervalSec: number; + // Emisso Sandbox fields + repoUrl?: string; + vcpus?: number; + timeoutSec?: number; + snapshotId?: string; } diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index b28ae18097b..3d618b8582c 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -27,6 +27,7 @@ Core fields: - env (object, optional): KEY=VALUE environment variables - workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? } - workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env +- mcpServers (object, optional): MCP server configuration. Keys are server names, values are { command, args?, env? }. Written to a temp file and passed via --mcp-config. Operational fields: - timeoutSec (number, optional): run timeout in seconds diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index c755c627141..4b56ceca46c 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -341,6 +341,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { + mcpConfigPath = path.join(skillsDir, "mcp-config.json"); + await fs.writeFile(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), "utf-8"); + } + // When instructionsFilePath is configured, create a combined temp file that // includes both the file content and the path directive, so we only need // --append-system-prompt-file (Claude CLI forbids using both flags together). @@ -406,6 +414,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push(...extraArgs); return args; }; diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index 98d570c4327..950a3624c0e 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -115,6 +115,43 @@ export async function testEnvironment( }); } + // Validate mcpServers structure if configured + const mcpServers = parseObject(config.mcpServers); + const mcpKeys = Object.keys(mcpServers); + if (mcpKeys.length > 0) { + let allValid = true; + for (const key of mcpKeys) { + const entry = parseObject(mcpServers[key]); + const hasCommand = isNonEmpty(entry.command); + const hasUrl = isNonEmpty(entry.url); + if (!hasCommand && !hasUrl) { + checks.push({ + code: "claude_mcp_server_invalid", + level: "warn", + message: `MCP server "${key}" is missing both "command" and "url".`, + hint: "Each MCP server entry must have a \"command\" (for stdio transport) or \"url\" (for SSE transport).", + }); + allValid = false; + } + if (entry.args !== undefined && !Array.isArray(entry.args)) { + checks.push({ + code: "claude_mcp_server_args_invalid", + level: "warn", + message: `MCP server "${key}" has non-array "args".`, + hint: "\"args\" must be a string array.", + }); + allValid = false; + } + } + if (allValid) { + checks.push({ + code: "claude_mcp_servers_valid", + level: "info", + message: `${mcpKeys.length} MCP server(s) configured: ${mcpKeys.join(", ")}`, + }); + } + } + const canRunProbe = checks.every((check) => check.code !== "claude_cwd_invalid" && check.code !== "claude_command_unresolvable"); if (canRunProbe) { diff --git a/packages/adapters/claude-local/src/ui/build-config.ts b/packages/adapters/claude-local/src/ui/build-config.ts index 748c8dabd2d..caf0a570b4c 100644 --- a/packages/adapters/claude-local/src/ui/build-config.ts +++ b/packages/adapters/claude-local/src/ui/build-config.ts @@ -97,5 +97,7 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record= 20'} + + '@vercel/sandbox@1.9.0': + resolution: {integrity: sha512-zgr1ad0tkT1xZn/8Vxo60wOUOLqMAVGo4WqJQ8/UDcUtWynNJsBjI2tiMdWZrAo9EKH1MIqEzJNkcclF0UT1EQ==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3558,6 +3568,9 @@ packages: resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} engines: {node: '>=0.12.0'} + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -3565,9 +3578,25 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + b4a@1.8.0: + resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -4327,6 +4356,9 @@ packages: event-emitter@0.3.5: resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -4347,6 +4379,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} @@ -4611,6 +4646,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonlines@0.1.1: + resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==} + katex@0.16.37: resolution: {integrity: sha512-TIGjO2cCGYono+uUzgkE7RFF329mLLWGuHUlSr6cwIVj9O8f0VQZ783rsanmJpFUo32vvtj7XT04NGRPh+SZFg==} hasBin: true @@ -5035,6 +5073,10 @@ packages: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} + os-paths@4.4.0: + resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} + engines: {node: '>= 6.0'} + outvariant@1.4.0: resolution: {integrity: sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==} @@ -5361,6 +5403,10 @@ packages: engines: {node: '>= 0.4'} hasBin: true + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} @@ -5505,6 +5551,9 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} + strict-event-emitter@0.4.6: resolution: {integrity: sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg==} @@ -5564,6 +5613,12 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} @@ -5927,6 +5982,14 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} + xdg-app-paths@5.1.0: + resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} + engines: {node: '>=6'} + + xdg-portable@7.3.0: + resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} + engines: {node: '>= 6.0'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -5945,6 +6008,9 @@ packages: resolution: {integrity: sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} + zod@3.24.4: + resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -9312,6 +9378,23 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@vercel/oidc@3.2.0': {} + + '@vercel/sandbox@1.9.0': + dependencies: + '@vercel/oidc': 3.2.0 + async-retry: 1.3.3 + jsonlines: 0.1.1 + ms: 2.1.3 + picocolors: 1.1.1 + tar-stream: 3.1.7 + undici: 7.24.4 + xdg-app-paths: 5.1.0 + zod: 3.24.4 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 @@ -9416,12 +9499,20 @@ snapshots: async-exit-hook@2.0.1: {} + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + asynckit@0.4.0: {} atomic-sleep@1.0.0: {} + b4a@1.8.0: {} + bail@2.0.2: {} + bare-events@2.8.2: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.9.19: {} @@ -10159,6 +10250,12 @@ snapshots: d: 1.0.2 es5-ext: 0.10.64 + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + expect-type@1.3.0: {} express@5.2.1: @@ -10204,6 +10301,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-safe-stringify@2.1.1: {} fast-uri@3.1.0: {} @@ -10472,6 +10571,8 @@ snapshots: json5@2.2.3: {} + jsonlines@0.1.1: {} + katex@0.16.37: dependencies: commander: 8.3.0 @@ -11170,6 +11271,8 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 + os-paths@4.4.0: {} + outvariant@1.4.0: {} package-manager-detector@1.6.0: {} @@ -11579,6 +11682,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + retry@0.13.1: {} + robust-predicates@3.0.2: {} rollup@4.57.1: @@ -11787,6 +11892,15 @@ snapshots: streamsearch@1.1.0: {} + streamx@2.25.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + strict-event-emitter@0.4.6: {} string_decoder@1.3.0: @@ -11852,6 +11966,21 @@ snapshots: tapable@2.3.0: {} + tar-stream@3.1.7: + dependencies: + b4a: 1.8.0 + fast-fifo: 1.3.2 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.0 + transitivePeerDependencies: + - react-native-b4a + thread-stream@3.1.0: dependencies: real-require: 0.2.0 @@ -12263,6 +12392,14 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 + xdg-app-paths@5.1.0: + dependencies: + xdg-portable: 7.3.0 + + xdg-portable@7.3.0: + dependencies: + os-paths: 4.4.0 + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} @@ -12275,6 +12412,8 @@ snapshots: dependencies: lib0: 0.2.117 + zod@3.24.4: {} + zod@3.25.76: {} zod@4.3.6: {} diff --git a/server/package.json b/server/package.json index 042331299f6..c2254894d80 100644 --- a/server/package.json +++ b/server/package.json @@ -55,6 +55,7 @@ "@paperclipai/db": "workspace:*", "@paperclipai/plugin-sdk": "workspace:*", "@paperclipai/shared": "workspace:*", + "@vercel/sandbox": "^1.9.0", "ajv": "^8.18.0", "ajv-formats": "^3.0.1", "better-auth": "1.4.18", diff --git a/server/src/adapters/emisso-sandbox/cost-calculator.ts b/server/src/adapters/emisso-sandbox/cost-calculator.ts new file mode 100644 index 00000000000..e935ad5ff0c --- /dev/null +++ b/server/src/adapters/emisso-sandbox/cost-calculator.ts @@ -0,0 +1,86 @@ +/** + * Cost estimation for Vercel Sandbox + Claude API sessions. + * + * Pricing sources (as of 2025): + * - Active CPU: ~$0.13 / vCPU-hour + * - Provisioned Memory: ~$0.043 / GB-hour + * - Sandbox Creation: $0.0000006 per creation + * - Claude API: per-model token pricing + */ + +import type { TokenUsage } from "./types.js"; + +// --------------------------------------------------------------------------- +// Pricing constants +// --------------------------------------------------------------------------- + +const VCPU_HOUR_COST = 0.13; +const MEMORY_GB_HOUR_COST = 0.043; +const MEMORY_PER_VCPU_GB = 2; +const CREATION_FEE = 0.0000006; + +/** Model pricing (input / output per million tokens). */ +const MODEL_PRICING: Record = { + "claude-opus-4-6": { input: 15.0, output: 75.0 }, + "claude-sonnet-4-6": { input: 3.0, output: 15.0 }, + "claude-haiku-4-6": { input: 0.8, output: 4.0 }, + "claude-haiku-4-5-20251001": { input: 0.8, output: 4.0 }, +}; + +const DEFAULT_MODEL = "claude-sonnet-4-6"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export interface CostBreakdown { + sandboxComputeCost: number; + sandboxMemoryCost: number; + sandboxCreationCost: number; + apiCost: number; + totalCost: number; +} + +export function estimateSessionCost(params: { + durationMs: number; + vcpus: number; + usage?: TokenUsage; + model?: string; + cpuUtilizationFactor?: number; +}): CostBreakdown { + const { + durationMs, + vcpus, + usage, + model = DEFAULT_MODEL, + cpuUtilizationFactor = 0.25, + } = params; + + const wallClockHours = durationMs / (1000 * 60 * 60); + const activeHours = wallClockHours * cpuUtilizationFactor; + + const sandboxComputeCost = round(activeHours * vcpus * VCPU_HOUR_COST); + const memoryGb = vcpus * MEMORY_PER_VCPU_GB; + const sandboxMemoryCost = round(wallClockHours * memoryGb * MEMORY_GB_HOUR_COST); + const sandboxCreationCost = CREATION_FEE; + + let apiCost = 0; + if (usage) { + const pricing = MODEL_PRICING[model] ?? MODEL_PRICING[DEFAULT_MODEL]!; + apiCost += (usage.inputTokens / 1_000_000) * pricing.input; + apiCost += (usage.outputTokens / 1_000_000) * pricing.output; + apiCost = round(apiCost); + } + + return { + sandboxComputeCost, + sandboxMemoryCost, + sandboxCreationCost, + apiCost, + totalCost: round(sandboxComputeCost + sandboxMemoryCost + sandboxCreationCost + apiCost), + }; +} + +function round(value: number): number { + return Math.round(value * 1_000_000) / 1_000_000; +} diff --git a/server/src/adapters/emisso-sandbox/execute.ts b/server/src/adapters/emisso-sandbox/execute.ts new file mode 100644 index 00000000000..871a50a5ae7 --- /dev/null +++ b/server/src/adapters/emisso-sandbox/execute.ts @@ -0,0 +1,467 @@ +/** + * Emisso Sandbox adapter — execute function. + * + * Runs Claude Code inside an ephemeral Vercel Sandbox microVM. + * Lifecycle: create sandbox → clone repos → inject instructions + MCP config → + * run Claude CLI → stream logs → parse result → stop sandbox. + * + * Ported from emisso-hq's VercelSandboxService, adapted for the Paperclip + * adapter execution model (AdapterExecutionContext → AdapterExecutionResult). + */ + +import type { AdapterExecutionContext, AdapterExecutionResult } from "../types.js"; +import { asString, asNumber, parseObject } from "../utils.js"; +import { parseStreamJsonOutput, extractRepoDirName, embedGitCredentials } from "./helpers.js"; +import { estimateSessionCost } from "./cost-calculator.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MIN_TIMEOUT_SEC = 10; +const MAX_TIMEOUT_SEC = 300; +const DEFAULT_TIMEOUT_SEC = 120; +const DEFAULT_VCPUS = 2; +const DEFAULT_MODEL = "claude-sonnet-4-6"; +const DEFAULT_MAX_TURNS = 30; +const CLONE_TIMEOUT_MS = 60_000; +const INSTALL_TIMEOUT_MS = 120_000; +const WORKSPACE_DIR = "/vercel/sandbox/workspace"; + +const DEFAULT_ALLOWED_DOMAINS = [ + "github.com", + "*.github.com", + "api.anthropic.com", + "registry.npmjs.org", + "*.npmjs.org", +]; + +// --------------------------------------------------------------------------- +// Sandbox types (minimal interface matching @vercel/sandbox) +// --------------------------------------------------------------------------- + +interface SandboxCommandFinished { + exitCode: number; + stdout(opts?: { signal?: AbortSignal }): Promise; + stderr(opts?: { signal?: AbortSignal }): Promise; +} + +interface SandboxCommand { + logs(opts?: { signal?: AbortSignal }): AsyncGenerator< + { data: string; stream: "stdout" | "stderr" }, + void, + void + >; + wait(opts?: { signal?: AbortSignal }): Promise; + kill(signal?: string): Promise; +} + +interface SandboxHandle { + sandboxId: string; + stop(opts?: { signal?: AbortSignal }): Promise; + writeFiles( + files: Array<{ path: string; content: Buffer }>, + opts?: { signal?: AbortSignal }, + ): Promise; + runCommand(params: { + cmd: string; + args?: string[]; + cwd?: string; + env?: Record; + sudo?: boolean; + signal?: AbortSignal; + detached?: boolean; + }): Promise; +} + +// --------------------------------------------------------------------------- +// Execute +// --------------------------------------------------------------------------- + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { runId, agent, config, context, onLog } = ctx; + const startTime = Date.now(); + + // --- Resolve configuration --- + const workspaceContext = parseObject(context.paperclipWorkspace); + const repoUrl = + asString(config.repoUrl, "") || asString(workspaceContext.repoUrl, ""); + const revision = + asString(config.revision, "") || asString(workspaceContext.repoRef, ""); + const additionalRepos = Array.isArray(config.additionalRepos) + ? (config.additionalRepos as Array<{ repoUrl: string; dirName?: string }>) + : []; + const cloneDepth = asNumber(config.cloneDepth, 1); + const model = asString(config.model, DEFAULT_MODEL); + const maxTurns = asNumber(config.maxTurns, DEFAULT_MAX_TURNS); + const timeoutSec = clamp(asNumber(config.timeoutSec, DEFAULT_TIMEOUT_SEC), MIN_TIMEOUT_SEC, MAX_TIMEOUT_SEC); + const vcpus = clamp(asNumber(config.vcpus, DEFAULT_VCPUS), 1, 8); + const snapshotId = asString(config.snapshotId, ""); + const promptTemplate = asString( + config.promptTemplate, + "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", + ); + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const mcpServers = parseObject(config.mcpServers); + + // Auth — resolve from config, then env vars + const anthropicApiKey = + asString(config.anthropicApiKey, "") || process.env.ANTHROPIC_API_KEY || ""; + const gitToken = + asString(config.gitToken, "") || process.env.GITHUB_TOKEN || ""; + const vercelTeamId = + asString(config.vercelTeamId, "") || process.env.VERCEL_TEAM_ID || ""; + const vercelProjectId = + asString(config.vercelProjectId, "") || process.env.VERCEL_PROJECT_ID || ""; + const vercelToken = + asString(config.vercelToken, "") || process.env.VERCEL_TOKEN || ""; + + // Network policy + const networkPolicyConfig = parseObject(config.networkPolicy); + const networkPolicy = Object.keys(networkPolicyConfig).length > 0 + ? networkPolicyConfig + : { allow: DEFAULT_ALLOWED_DOMAINS }; + + if (!repoUrl) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: "Emisso Sandbox adapter requires a repoUrl (in adapterConfig or workspace context).", + errorCode: "missing_repo_url", + }; + } + + if (!anthropicApiKey) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: "ANTHROPIC_API_KEY is required for sandbox execution.", + errorCode: "missing_api_key", + }; + } + + // --- Render prompt --- + const prompt = promptTemplate + .replace(/\{\{agent\.id\}\}/g, agent.id) + .replace(/\{\{agent\.name\}\}/g, agent.name) + .replace(/\{\{agent\.companyId\}\}/g, agent.companyId) + .replace(/\{\{run\.id\}\}/g, runId); + + let sandbox: SandboxHandle | null = null; + let sandboxId = ""; + + try { + // --- 1. Create sandbox --- + await onLog("stdout", `[emisso-sandbox] Creating sandbox (${vcpus} vCPUs, ${timeoutSec}s timeout)...\n`); + + const { Sandbox } = await import("@vercel/sandbox"); + const createOptions: Record = { + resources: { vcpus }, + timeout: timeoutSec * 1000, + runtime: "node22", + networkPolicy, + }; + + if (snapshotId) { + createOptions.source = { type: "snapshot", snapshotId }; + } + if (vercelTeamId) createOptions.teamId = vercelTeamId; + if (vercelProjectId) createOptions.projectId = vercelProjectId; + if (vercelToken) createOptions.token = vercelToken; + + sandbox = await (Sandbox.create(createOptions) as unknown as Promise); + sandboxId = sandbox.sandboxId; + await onLog("stdout", `[emisso-sandbox] Sandbox created: ${sandboxId}\n`); + + // --- 2. Install Claude Code CLI (skip if snapshot) --- + if (!snapshotId) { + await onLog("stdout", "[emisso-sandbox] Installing Claude Code CLI...\n"); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), INSTALL_TIMEOUT_MS); + try { + const installResult = await sandbox.runCommand({ + cmd: "npm", + args: ["install", "-g", "@anthropic-ai/claude-code"], + sudo: true, + signal: controller.signal, + }); + if (installResult.exitCode !== 0) { + const stderr = await installResult.stderr(); + throw new Error(`Claude Code CLI install failed (exit ${installResult.exitCode}): ${stderr.substring(0, 500)}`); + } + } finally { + clearTimeout(timer); + } + await onLog("stdout", "[emisso-sandbox] Claude Code CLI installed.\n"); + } + + // --- 3. Clone repositories --- + await onLog("stdout", `[emisso-sandbox] Cloning ${repoUrl}...\n`); + await sandbox.runCommand({ cmd: "mkdir", args: ["-p", WORKSPACE_DIR] }); + + const gitAuth = gitToken ? { username: "x-access-token", token: gitToken } : null; + const primaryDirName = extractRepoDirName(repoUrl); + const primaryCloneUrl = gitAuth ? embedGitCredentials(repoUrl, gitAuth) : repoUrl; + + const repos: Array<{ url: string; dirName: string }> = [ + { url: primaryCloneUrl, dirName: primaryDirName }, + ]; + for (const extra of additionalRepos) { + const url = gitAuth ? embedGitCredentials(extra.repoUrl, gitAuth) : extra.repoUrl; + repos.push({ url, dirName: extra.dirName || extractRepoDirName(extra.repoUrl) }); + } + + const totalCloneTimeout = CLONE_TIMEOUT_MS + Math.max(0, repos.length - 1) * 15_000; + const cloneController = new AbortController(); + const cloneTimer = setTimeout(() => cloneController.abort(), totalCloneTimeout); + try { + await Promise.all( + repos.map(async ({ url, dirName }, index) => { + const args = ["clone", "--depth", String(cloneDepth), "--single-branch"]; + if (revision && index === 0) args.push("--branch", revision); + args.push(url, `${WORKSPACE_DIR}/${dirName}`); + const result = await sandbox!.runCommand({ + cmd: "git", + args, + signal: cloneController.signal, + }); + if (result.exitCode !== 0) { + const stderr = await result.stderr(); + cloneController.abort(); + throw new Error(`Git clone failed for ${dirName} (exit ${result.exitCode}): ${stderr.substring(0, 500)}`); + } + }), + ); + } finally { + clearTimeout(cloneTimer); + } + await onLog("stdout", `[emisso-sandbox] Repos cloned: ${repos.map((r) => r.dirName).join(", ")}\n`); + + // --- 4. Write instructions file --- + if (instructionsFilePath) { + // Read instructions from the cloned repo + const readResult = await sandbox.runCommand({ + cmd: "cat", + args: [instructionsFilePath.startsWith("/") ? instructionsFilePath : `${WORKSPACE_DIR}/${instructionsFilePath}`], + }); + if (readResult.exitCode === 0) { + const content = await readResult.stdout(); + await sandbox.writeFiles([{ + path: `${WORKSPACE_DIR}/CLAUDE.md`, + content: Buffer.from(content), + }]); + await onLog("stdout", `[emisso-sandbox] Instruction file written from ${instructionsFilePath}\n`); + } + } + + // --- 5. Write MCP config --- + const mcpKeys = Object.keys(mcpServers); + let mcpConfigPath: string | null = null; + if (mcpKeys.length > 0) { + mcpConfigPath = "/vercel/sandbox/mcp-config.json"; + await sandbox.writeFiles([{ + path: mcpConfigPath, + content: Buffer.from(JSON.stringify({ mcpServers }, null, 2)), + }]); + await onLog("stdout", `[emisso-sandbox] MCP config written (${mcpKeys.length} server(s))\n`); + } + + // --- 6. Run Claude CLI --- + await onLog("stdout", `[emisso-sandbox] Running Claude (model=${model}, maxTurns=${maxTurns})...\n`); + + const claudeArgs = [ + "--print", "-", + "--output-format", "stream-json", + "--model", model, + "--max-turns", String(maxTurns), + "--dangerously-skip-permissions", + "--verbose", + ]; + if (mcpConfigPath) claudeArgs.push("--mcp-config", mcpConfigPath); + + const cmd = await sandbox.runCommand({ + cmd: "claude", + args: claudeArgs, + cwd: WORKSPACE_DIR, + env: { + ANTHROPIC_API_KEY: anthropicApiKey, + CI: "1", + CLAUDE_CODE_ENTRYPOINT: "cli", + PAPERCLIP_AGENT_ID: agent.id, + PAPERCLIP_COMPANY_ID: agent.companyId, + PAPERCLIP_RUN_ID: runId, + }, + detached: true, + }); + + // Stream stdin (the prompt) + // Note: The sandbox cmd interface uses stdin via the command itself. + // For Claude's `--print -` mode, we write the prompt via a runner script approach. + // Since we can't pipe stdin directly, we use a runner script. + + // Actually, let's use a runner script like emisso-hq does + // to handle stdin properly with the `claude` CLI. + // Kill the detached process and re-run via runner script. + await cmd.kill("SIGTERM"); + + const runnerScript = buildRunnerScript(prompt, model, maxTurns, mcpConfigPath); + await sandbox.writeFiles([{ + path: "/vercel/sandbox/runner.mjs", + content: Buffer.from(runnerScript), + }]); + + const runnerCmd = await sandbox.runCommand({ + cmd: "node", + args: ["/vercel/sandbox/runner.mjs"], + cwd: WORKSPACE_DIR, + env: { + ANTHROPIC_API_KEY: anthropicApiKey, + CI: "1", + CLAUDE_CODE_ENTRYPOINT: "cli", + PAPERCLIP_AGENT_ID: agent.id, + PAPERCLIP_COMPANY_ID: agent.companyId, + PAPERCLIP_RUN_ID: runId, + }, + detached: true, + }); + + // Stream logs to the UI in real-time + let fullStdout = ""; + for await (const log of runnerCmd.logs()) { + if (log.stream === "stdout") { + fullStdout += log.data; + } + await onLog(log.stream, log.data); + } + + const finished = await runnerCmd.wait(); + + // --- 7. Parse output --- + const durationMs = Date.now() - startTime; + const parsed = parseStreamJsonOutput(fullStdout); + + if (finished.exitCode !== 0 && !parsed.resultJson) { + const stderr = await finished.stderr(); + const isTimeout = stderr.includes("ETIMEDOUT") || stderr.includes("timed out"); + + return { + exitCode: finished.exitCode, + signal: null, + timedOut: isTimeout, + errorMessage: isTimeout + ? `Sandbox execution timed out after ${timeoutSec}s` + : `Claude exited with code ${finished.exitCode}`, + errorCode: isTimeout ? "timeout" : "sandbox_execution_failed", + provider: "anthropic", + model: parsed.model || model, + costUsd: estimateSessionCost({ durationMs, vcpus, usage: parsed.usage ?? undefined, model }).totalCost, + resultJson: { sandboxId, stdout: fullStdout.substring(0, 10000) }, + }; + } + + // --- 8. Build result --- + const costBreakdown = estimateSessionCost({ + durationMs, + vcpus, + usage: parsed.usage ?? undefined, + model: parsed.model || model, + }); + + return { + exitCode: finished.exitCode, + signal: null, + timedOut: false, + errorMessage: finished.exitCode === 0 ? null : `Claude exited with code ${finished.exitCode}`, + usage: parsed.usage ?? undefined, + provider: "anthropic", + biller: "anthropic", + model: parsed.model || model, + billingType: "api", + costUsd: costBreakdown.totalCost, + resultJson: parsed.resultJson ?? { sandboxId }, + summary: parsed.summary || null, + sessionId: parsed.sessionId, + }; + } catch (err) { + const durationMs = Date.now() - startTime; + const message = err instanceof Error ? err.message : String(err); + const isTimeout = message.includes("AbortError") || message.includes("timed out"); + + return { + exitCode: 1, + signal: null, + timedOut: isTimeout, + errorMessage: message, + errorCode: isTimeout ? "timeout" : "sandbox_execution_failed", + provider: "anthropic", + model, + costUsd: estimateSessionCost({ durationMs, vcpus, model }).totalCost, + resultJson: { sandboxId }, + }; + } finally { + // Always stop the sandbox to avoid lingering costs. + if (sandbox) { + try { + await sandbox.stop(); + await onLog("stdout", `[emisso-sandbox] Sandbox ${sandboxId} stopped.\n`); + } catch { + // Best-effort cleanup + } + } + } +} + +// --------------------------------------------------------------------------- +// Runner script builder +// --------------------------------------------------------------------------- + +function buildRunnerScript( + prompt: string, + model: string, + maxTurns: number, + mcpConfigPath: string | null, +): string { + const escapedPrompt = JSON.stringify(prompt); + const mcpArg = mcpConfigPath ? `"--mcp-config", ${JSON.stringify(mcpConfigPath)},` : ""; + + return ` +import { spawn } from "node:child_process"; + +const prompt = ${escapedPrompt}; +const model = ${JSON.stringify(model)}; + +const args = [ + "--print", + "--verbose", + "--output-format", "stream-json", + "--model", model, + "--max-turns", "${maxTurns}", + "--dangerously-skip-permissions", + ${mcpArg} +]; + +const proc = spawn("claude", args, { + cwd: process.cwd(), + stdio: ["pipe", "inherit", "inherit"], + env: { + ...process.env, + CI: "1", + CLAUDE_CODE_ENTRYPOINT: "cli", + }, +}); + +proc.stdin.write(prompt); +proc.stdin.end(); +proc.on("close", (code, signal) => process.exit(code ?? (signal ? 1 : 0))); +`.trim(); +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/server/src/adapters/emisso-sandbox/helpers.ts b/server/src/adapters/emisso-sandbox/helpers.ts new file mode 100644 index 00000000000..db33778fca7 --- /dev/null +++ b/server/src/adapters/emisso-sandbox/helpers.ts @@ -0,0 +1,123 @@ +/** + * Output parsing helpers for the Emisso Sandbox adapter. + * + * Parses Claude Code's stream-json output format to extract + * token usage, session IDs, cost, model, and summary text. + * + * These are adapted from claude-local's parse.ts patterns + * but simplified for the sandbox context where we only need + * the final aggregated values (not streaming transcript entries). + */ + +import type { TokenUsage } from "./types.js"; + +// --------------------------------------------------------------------------- +// Stream-json line parser +// --------------------------------------------------------------------------- + +export interface ParsedStreamResult { + /** The final "result" event's JSON, if found. */ + resultJson: Record | null; + /** Aggregated token usage from result event. */ + usage: TokenUsage | null; + /** Session ID from the result event. */ + sessionId: string | null; + /** Model used (from result event). */ + model: string; + /** Total cost in USD (from result event). */ + costUsd: number; + /** Summary text (from result event). */ + summary: string; +} + +/** + * Parse a stream-json stdout blob (newline-delimited JSON events) + * and extract the aggregated result. + */ +export function parseStreamJsonOutput(stdout: string): ParsedStreamResult { + const result: ParsedStreamResult = { + resultJson: null, + usage: null, + sessionId: null, + model: "", + costUsd: 0, + summary: "", + }; + + for (const line of stdout.split("\n")) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line); + if (event.type === "result") { + result.resultJson = event; + result.summary = typeof event.result === "string" ? event.result : ""; + result.sessionId = typeof event.session_id === "string" ? event.session_id : null; + result.model = typeof event.model === "string" ? event.model : ""; + result.costUsd = typeof event.total_cost_usd === "number" ? event.total_cost_usd : 0; + + // Extract aggregated usage from modelUsage or top-level usage + if (event.modelUsage && typeof event.modelUsage === "object") { + let inputTokens = 0; + let outputTokens = 0; + let cachedInputTokens = 0; + for (const model of Object.values(event.modelUsage) as Record[]) { + inputTokens += model.inputTokens ?? 0; + outputTokens += model.outputTokens ?? 0; + cachedInputTokens += model.cacheReadInputTokens ?? 0; + } + result.usage = { inputTokens, outputTokens, cachedInputTokens: cachedInputTokens || undefined }; + } else if (event.usage) { + result.usage = { + inputTokens: event.usage.input_tokens ?? 0, + outputTokens: event.usage.output_tokens ?? 0, + cachedInputTokens: event.usage.cache_read_input_tokens || undefined, + }; + } + } + } catch { + // Non-JSON line — skip + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// Git URL helpers (ported from emisso-hq sandbox-service.ts) +// --------------------------------------------------------------------------- + +/** + * Extract "owner-repo" directory name from a clone URL. + * e.g. "https://github.com/acme/api.git" → "acme-api" + */ +export function extractRepoDirName(url: string): string { + try { + const segments = new URL(url).pathname.split("/").filter(Boolean); + if (segments.length >= 2) { + const owner = segments[segments.length - 2]!; + const repo = segments[segments.length - 1]!.replace(/\.git$/, ""); + return `${owner}-${repo}`; + } + return segments[0]?.replace(/\.git$/, "") ?? "repo"; + } catch { + const match = url.match(/\/([^/]+)\/([^/]+?)(?:\.git)?$/); + return match ? `${match[1]}-${match[2]}` : "repo"; + } +} + +/** + * Embed credentials into an HTTPS git URL. + */ +export function embedGitCredentials( + url: string, + auth: { username: string; token: string }, +): string { + try { + const parsed = new URL(url); + parsed.username = encodeURIComponent(auth.username); + parsed.password = encodeURIComponent(auth.token); + return parsed.toString(); + } catch { + return url; + } +} diff --git a/server/src/adapters/emisso-sandbox/index.ts b/server/src/adapters/emisso-sandbox/index.ts new file mode 100644 index 00000000000..71137f30f3e --- /dev/null +++ b/server/src/adapters/emisso-sandbox/index.ts @@ -0,0 +1,45 @@ +import type { ServerAdapterModule } from "../types.js"; +import { execute } from "./execute.js"; +import { testEnvironment } from "./test.js"; + +export const emissoSandboxAdapter: ServerAdapterModule = { + type: "emisso_sandbox", + execute, + testEnvironment, + models: [ + { id: "claude-opus-4-6", label: "Claude Opus 4.6" }, + { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, + { id: "claude-haiku-4-6", label: "Claude Haiku 4.6" }, + { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" }, + ], + agentConfigurationDoc: `# emisso_sandbox agent configuration + +Adapter: emisso_sandbox + +Runs Claude Code inside ephemeral Vercel Sandbox microVMs. Each run gets +a fresh sandbox with the repo cloned, instructions injected, and Claude +CLI executed. The sandbox is destroyed after execution. + +Core fields: +- repoUrl (string, optional): Git clone URL. Falls back to workspace context. +- revision (string, optional): Branch/tag to clone. Falls back to workspace context. +- additionalRepos (array, optional): Additional repos to clone alongside primary. +- cloneDepth (number, optional): Shallow clone depth. Default 1. +- model (string, optional): Claude model. Default "claude-sonnet-4-6". +- maxTurns (number, optional): Max agent turns. Default 30. +- timeoutSec (number, optional): Sandbox timeout 10-300s. Default 120. +- vcpus (number, optional): vCPUs 1-8. Default 2. +- instructionsFilePath (string, optional): Path to instructions file (in repo or absolute). +- promptTemplate (string, optional): Run prompt template with {{agent.id}}, {{agent.name}}, etc. +- mcpServers (object, optional): MCP server config. Keys are names, values are { command, args?, env? } or { url }. +- snapshotId (string, optional): Pre-built sandbox snapshot for fast starts. +- networkPolicy (object, optional): { allow?: string[], deny?: string[] }. + +Auth fields (resolved from env if not in config): +- anthropicApiKey (string): Or ANTHROPIC_API_KEY env. +- gitToken (string): Or GITHUB_TOKEN env. +- vercelTeamId (string): Or VERCEL_TEAM_ID env. +- vercelProjectId (string): Or VERCEL_PROJECT_ID env. +- vercelToken (string): Or VERCEL_TOKEN env. +`, +}; diff --git a/server/src/adapters/emisso-sandbox/test.ts b/server/src/adapters/emisso-sandbox/test.ts new file mode 100644 index 00000000000..3095836a5eb --- /dev/null +++ b/server/src/adapters/emisso-sandbox/test.ts @@ -0,0 +1,168 @@ +/** + * Environment test for the Emisso Sandbox adapter. + * + * Validates that the required auth and configuration is in place + * before attempting to run agents in Vercel Sandbox. + */ + +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "../types.js"; +import { asString, asNumber, parseObject } from "../utils.js"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((c) => c.level === "error")) return "fail"; + if (checks.some((c) => c.level === "warn")) return "warn"; + return "pass"; +} + +function isNonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + + // --- Anthropic API key --- + const configApiKey = asString(config.anthropicApiKey, ""); + const envApiKey = process.env.ANTHROPIC_API_KEY ?? ""; + if (isNonEmpty(configApiKey) || isNonEmpty(envApiKey)) { + const source = isNonEmpty(configApiKey) ? "adapter config" : "environment"; + checks.push({ + code: "sandbox_anthropic_api_key_present", + level: "info", + message: `ANTHROPIC_API_KEY detected (source: ${source}).`, + }); + } else { + checks.push({ + code: "sandbox_anthropic_api_key_missing", + level: "error", + message: "ANTHROPIC_API_KEY is not set. Required for Claude execution in sandbox.", + hint: "Set ANTHROPIC_API_KEY in the environment or in adapterConfig.anthropicApiKey.", + }); + } + + // --- Vercel auth --- + const vercelToken = asString(config.vercelToken, "") || process.env.VERCEL_TOKEN || ""; + const vercelTeamId = asString(config.vercelTeamId, "") || process.env.VERCEL_TEAM_ID || ""; + if (isNonEmpty(vercelToken) && isNonEmpty(vercelTeamId)) { + checks.push({ + code: "sandbox_vercel_auth_present", + level: "info", + message: "Vercel authentication configured (token + team ID).", + }); + } else if (!isNonEmpty(vercelToken) && !isNonEmpty(vercelTeamId)) { + checks.push({ + code: "sandbox_vercel_auth_missing", + level: "warn", + message: "Vercel authentication not configured. Sandbox creation may rely on OIDC (Vercel-hosted only).", + hint: "Set VERCEL_TOKEN and VERCEL_TEAM_ID for non-Vercel environments.", + }); + } else { + checks.push({ + code: "sandbox_vercel_auth_partial", + level: "warn", + message: "Partial Vercel auth: both VERCEL_TOKEN and VERCEL_TEAM_ID are needed.", + hint: "Set both values in the environment or adapterConfig.", + }); + } + + // --- Git token --- + const gitToken = asString(config.gitToken, "") || process.env.GITHUB_TOKEN || ""; + if (isNonEmpty(gitToken)) { + checks.push({ + code: "sandbox_git_token_present", + level: "info", + message: "Git token available for private repo cloning.", + }); + } else { + checks.push({ + code: "sandbox_git_token_missing", + level: "info", + message: "No git token configured. Only public repos can be cloned.", + hint: "Set GITHUB_TOKEN in the environment or adapterConfig.gitToken for private repos.", + }); + } + + // --- Repo URL --- + const repoUrl = asString(config.repoUrl, ""); + if (isNonEmpty(repoUrl)) { + checks.push({ + code: "sandbox_repo_url_configured", + level: "info", + message: `Repo URL configured: ${repoUrl}`, + }); + } else { + checks.push({ + code: "sandbox_repo_url_missing", + level: "info", + message: "No repoUrl in adapter config. Will use workspace context at runtime.", + }); + } + + // --- Sandbox config --- + const vcpus = asNumber(config.vcpus, 2); + const timeoutSec = asNumber(config.timeoutSec, 120); + const model = asString(config.model, "claude-sonnet-4-6"); + const snapshotId = asString(config.snapshotId, ""); + checks.push({ + code: "sandbox_config_summary", + level: "info", + message: `Config: ${vcpus} vCPUs, ${timeoutSec}s timeout, model=${model}${snapshotId ? `, snapshot=${snapshotId}` : ""}`, + }); + + // --- MCP servers --- + const mcpServers = parseObject(config.mcpServers); + const mcpKeys = Object.keys(mcpServers); + if (mcpKeys.length > 0) { + let allValid = true; + for (const key of mcpKeys) { + const entry = parseObject(mcpServers[key]); + if (!isNonEmpty(entry.command) && !isNonEmpty(entry.url)) { + checks.push({ + code: "sandbox_mcp_server_invalid", + level: "warn", + message: `MCP server "${key}" is missing both "command" and "url".`, + hint: "Each MCP server entry must have a \"command\" (stdio) or \"url\" (SSE).", + }); + allValid = false; + } + } + if (allValid) { + checks.push({ + code: "sandbox_mcp_servers_valid", + level: "info", + message: `${mcpKeys.length} MCP server(s) configured: ${mcpKeys.join(", ")}`, + }); + } + } + + // --- @vercel/sandbox availability --- + try { + await import("@vercel/sandbox"); + checks.push({ + code: "sandbox_sdk_available", + level: "info", + message: "@vercel/sandbox SDK is installed and importable.", + }); + } catch { + checks.push({ + code: "sandbox_sdk_missing", + level: "error", + message: "@vercel/sandbox SDK could not be imported.", + hint: "Run `pnpm add @vercel/sandbox` in the server package.", + }); + } + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/server/src/adapters/emisso-sandbox/types.ts b/server/src/adapters/emisso-sandbox/types.ts new file mode 100644 index 00000000000..04912f32361 --- /dev/null +++ b/server/src/adapters/emisso-sandbox/types.ts @@ -0,0 +1,73 @@ +/** + * Types for the Emisso Sandbox adapter. + * + * Runs Claude Code inside ephemeral Vercel Sandbox microVMs. + * Ported from emisso-hq's VercelSandboxService, adapted for the + * Paperclip adapter execution model (heartbeat loop, issue assignment). + */ + +// --------------------------------------------------------------------------- +// Adapter configuration (stored in agent.adapterConfig) +// --------------------------------------------------------------------------- + +export interface EmissoSandboxConfig { + // Repo (from workspace context or explicit override) + repoUrl?: string; + revision?: string; + additionalRepos?: Array<{ repoUrl: string; dirName?: string }>; + cloneDepth?: number; + + // Execution + model?: string; + maxTurns?: number; + timeoutSec?: number; + vcpus?: number; + instructionsFilePath?: string; + promptTemplate?: string; + + // MCP servers (same shape as claude-local) + mcpServers?: Record< + string, + { + command?: string; + url?: string; + args?: string[]; + env?: Record; + } + >; + + // Sandbox infra + snapshotId?: string; + networkPolicy?: { allow?: string[]; deny?: string[] }; + + // Auth (resolved from env if not in config) + anthropicApiKey?: string; + gitToken?: string; + vercelTeamId?: string; + vercelProjectId?: string; + vercelToken?: string; +} + +// --------------------------------------------------------------------------- +// Internal session state +// --------------------------------------------------------------------------- + +export type SandboxPhase = + | "creating" + | "cloning" + | "installing" + | "running" + | "parsing" + | "completed" + | "failed" + | "timed_out"; + +// --------------------------------------------------------------------------- +// Token usage (from Claude stream-json parsing) +// --------------------------------------------------------------------------- + +export interface TokenUsage { + inputTokens: number; + outputTokens: number; + cachedInputTokens?: number; +} diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 67a8e95ba22..ea3270c9090 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -77,6 +77,7 @@ import { } from "hermes-paperclip-adapter"; import { processAdapter } from "./process/index.js"; import { httpAdapter } from "./http/index.js"; +import { emissoSandboxAdapter } from "./emisso-sandbox/index.js"; const claudeLocalAdapter: ServerAdapterModule = { type: "claude_local", @@ -193,6 +194,7 @@ const adaptersByType = new Map( hermesLocalAdapter, processAdapter, httpAdapter, + emissoSandboxAdapter, ].map((a) => [a.type, a]), ); diff --git a/skills/emisso-engineering/SKILL.md b/skills/emisso-engineering/SKILL.md new file mode 100644 index 00000000000..3ae8f150af8 --- /dev/null +++ b/skills/emisso-engineering/SKILL.md @@ -0,0 +1,43 @@ +# Emisso Engineering Skill + +You are an engineering agent working on the Emisso platform. Follow these conventions strictly. + +## Tech Stack + +- **Frontend:** Next.js (App Router), TypeScript, TailwindCSS v4, Shadcn/Radix UI +- **Backend:** Effect TS for typed APIs, Zod at boundaries +- **Database:** Supabase (PostgreSQL + RLS + pgvector), Drizzle ORM +- **AI:** Vercel AI SDK, OpenAI, pgvector embeddings +- **Auth:** Supabase Auth with JWT +- **Testing:** Vitest + Happy DOM + PGLite (integration tests) + +## Code Style + +- **Commits:** Conventional Commits — `type(scope): description` +- **API endpoints:** Effect TS pattern (Layer: Repo → Service → Route Handler) +- **Path aliases:** `@/` `@app/` `@core/` `@features/` `@shared/` +- **Colors:** Semantic CSS variables only, never hardcode — emerald primary, dark mode first +- **Typography:** Figtree (primary), Geist Mono (code) +- **Icons:** Lucide React + +## Key Patterns + +- Feature modules in `src/features/` — each feature has repos, services, components +- Database schemas in `src/core/db/` +- Effect endpoints follow Layer pattern: Repo (data access) → Service (business logic) → Route Handler (HTTP boundary) +- Testing uses PGLite for real PostgreSQL integration tests, no mocks + +## Testing Requirements + +- Every new feature MUST have tests +- Use PGLite for database integration tests +- Test edge cases thoroughly +- Run `npm run check` before marking work as complete + +## Important Rules + +- Never use large headers (`text-2xl font-bold`) or excessive padding (`p-6 space-y-6`) +- Never hardcode colors without dark mode variants +- Don't create stats card grids — use tables for listing data +- Database has Row Level Security — all queries require tenant context +- TailwindCSS v4 — use `@import` instead of `@tailwind` diff --git a/ui/src/adapters/emisso-sandbox/build-config.ts b/ui/src/adapters/emisso-sandbox/build-config.ts new file mode 100644 index 00000000000..d231dfeb0f9 --- /dev/null +++ b/ui/src/adapters/emisso-sandbox/build-config.ts @@ -0,0 +1,31 @@ +import type { CreateConfigValues } from "../types"; + +function parseJsonObject(text: string): Record | null { + const trimmed = text.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +} + +export function buildEmissoSandboxConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + + if (v.model) ac.model = v.model; + if (v.repoUrl) ac.repoUrl = v.repoUrl; + if (v.vcpus) ac.vcpus = Number(v.vcpus); + if (v.timeoutSec) ac.timeoutSec = Number(v.timeoutSec); + if (v.maxTurns) ac.maxTurns = Number(v.maxTurns); + if (v.snapshotId) ac.snapshotId = v.snapshotId; + if (v.promptTemplate) ac.promptTemplate = v.promptTemplate; + if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; + + const mcpServers = parseJsonObject(v.mcpServersJson ?? ""); + if (mcpServers) ac.mcpServers = mcpServers; + + return ac; +} diff --git a/ui/src/adapters/emisso-sandbox/config-fields.tsx b/ui/src/adapters/emisso-sandbox/config-fields.tsx new file mode 100644 index 00000000000..6494e2081b4 --- /dev/null +++ b/ui/src/adapters/emisso-sandbox/config-fields.tsx @@ -0,0 +1,179 @@ +import type { AdapterConfigFieldsProps } from "../types"; +import { + Field, + DraftInput, + DraftNumberInput, + help, +} from "../../components/agent-config-primitives"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; + +const textareaClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40 min-h-[80px] resize-y"; + +export function EmissoSandboxConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, + models, +}: AdapterConfigFieldsProps) { + return ( + <> + + {isCreate ? ( + + ) : ( + + )} + + + + + isCreate + ? set!({ repoUrl: v }) + : mark("adapterConfig", "repoUrl", v || undefined) + } + immediate + className={inputClass} + placeholder="https://github.com/org/repo.git" + /> + + + + {isCreate ? ( + set!({ vcpus: Number(e.target.value) })} + /> + ) : ( + mark("adapterConfig", "vcpus", v || 2)} + immediate + className={inputClass} + /> + )} + + + + {isCreate ? ( + set!({ timeoutSec: Number(e.target.value) })} + /> + ) : ( + mark("adapterConfig", "timeoutSec", v || 120)} + immediate + className={inputClass} + /> + )} + + + + {isCreate ? ( + set!({ maxTurns: Number(e.target.value) })} + /> + ) : ( + mark("adapterConfig", "maxTurns", v || 30)} + immediate + className={inputClass} + /> + )} + + + + + isCreate + ? set!({ snapshotId: v }) + : mark("adapterConfig", "snapshotId", v || undefined) + } + immediate + className={inputClass} + placeholder="snap_..." + /> + + + + {isCreate ? ( +