diff --git a/examples/daytona/src/daytona.ts b/examples/daytona/src/daytona.ts index b2a076d..effb6a6 100644 --- a/examples/daytona/src/daytona.ts +++ b/examples/daytona/src/daytona.ts @@ -1,140 +1,38 @@ import { Daytona, Image } from "@daytonaio/sdk"; import { logInspectorUrl, runPrompt } from "@sandbox-agent/example-shared"; -import { readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; - -// Extract API key from Claude's config files -function getAnthropicApiKey(): string | undefined { - if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY; - - const home = homedir(); - const configPaths = [ - join(home, ".claude.json"), - join(home, ".claude.json.api"), - ]; - - for (const path of configPaths) { - try { - const data = JSON.parse(readFileSync(path, "utf-8")); - const key = data.primaryApiKey || data.apiKey || data.anthropicApiKey; - if (key?.startsWith("sk-ant-")) return key; - } catch { - // Ignore errors - } - } - return undefined; -} - -const anthropicKey = getAnthropicApiKey(); -const openaiKey = process.env.OPENAI_API_KEY; - -if (!process.env.DAYTONA_API_KEY || (!anthropicKey && !openaiKey)) { - throw new Error( - "DAYTONA_API_KEY and (ANTHROPIC_API_KEY or OPENAI_API_KEY) required", - ); -} - -console.log( - "\x1b[33m[NOTE]\x1b[0m Daytona Tier 3+ required to access api.anthropic.com and api.openai.com.\n" + - " Tier 1/2 sandboxes have restricted network access that will cause 'Agent Process Exited' errors.\n" + - " See: https://www.daytona.io/docs/en/network-limits/\n", -); - -const SNAPSHOT = "sandbox-agent-ready-v2"; const daytona = new Daytona(); -const hasSnapshot = await daytona.snapshot.get(SNAPSHOT).then( - () => true, - () => false, -); -if (!hasSnapshot) { - console.log(`Creating snapshot '${SNAPSHOT}' (one-time setup, ~2-3min)...`); - await daytona.snapshot.create( - { - name: SNAPSHOT, - image: Image.base("ubuntu:22.04").runCommands( - // Install dependencies - "apt-get update && apt-get install -y curl ca-certificates", - // Install sandbox-agent - "curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh", - // Install agents - "sandbox-agent install-agent claude", - "sandbox-agent install-agent codex", - ), - }, - { onLogs: (log) => console.log(` ${log}`) }, - ); - console.log("Snapshot created. Future runs will be instant."); -} - -console.log("Creating sandbox..."); const envVars: Record = {}; -if (anthropicKey) envVars.ANTHROPIC_API_KEY = anthropicKey; -if (openaiKey) envVars.OPENAI_API_KEY = openaiKey; +if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; +if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY; -// NOTE: Tier 1/2 sandboxes have restricted network access that cannot be overridden -// If you're on Tier 1/2 and see "Agent Process Exited", contact Daytona to whitelist -// api.anthropic.com and api.openai.com for your organization -// See: https://www.daytona.io/docs/en/network-limits/ -const sandbox = await daytona.create({ - snapshot: SNAPSHOT, - envVars, -}); +const image = Image.base("ubuntu:22.04").runCommands( + "apt-get update && apt-get install -y curl ca-certificates", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh", +); + +const sandbox = await daytona.create({ envVars, image }); -console.log("Starting server..."); await sandbox.process.executeCommand( "nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &", ); -// Wait for server to be ready -await new Promise((r) => setTimeout(r, 2000)); - -// Debug: check environment and agent binaries -const envCheck = await sandbox.process.executeCommand( - "env | grep -E 'ANTHROPIC|OPENAI' | sed 's/=.*/=/'", -); -console.log("Sandbox env:", envCheck.result || "(none)"); - -const binCheck = await sandbox.process.executeCommand( - "ls -la /root/.local/share/sandbox-agent/bin/", -); -console.log("Agent binaries:", binCheck.result); - -// Network connectivity test -console.log("Testing network connectivity..."); -const netTest = await sandbox.process.executeCommand( - "curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 https://api.anthropic.com/v1/messages 2>&1 || echo 'FAILED'", -); -const httpCode = netTest.result?.trim(); -if (httpCode === "405" || httpCode === "401") { - console.log("api.anthropic.com: reachable"); -} else if (httpCode === "000" || httpCode === "FAILED" || !httpCode) { - console.log("\x1b[31mapi.anthropic.com: UNREACHABLE - Tier 1/2 network restriction detected\x1b[0m"); - console.log("Claude/Codex will fail. Upgrade to Tier 3+ or contact Daytona support."); -} else { - console.log(`api.anthropic.com: ${httpCode}`); -} - const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; logInspectorUrl({ baseUrl }); const cleanup = async () => { - // Show server logs before cleanup - const logs = await sandbox.process.executeCommand( - "cat /tmp/sandbox-agent.log 2>/dev/null | tail -50", - ); - if (logs.result) { - console.log("\n--- Server logs ---"); - console.log(logs.result); - } - console.log("Cleaning up..."); await sandbox.delete(60); process.exit(0); }; process.once("SIGINT", cleanup); process.once("SIGTERM", cleanup); -await runPrompt({ baseUrl }); +// When running as root in a container, Claude requires interactive permission prompts +// (bypass mode is not supported). Set autoApprovePermissions: true to auto-approve, +// or leave false for interactive prompts. +await runPrompt({ + baseUrl, + autoApprovePermissions: process.env.AUTO_APPROVE_PERMISSIONS === "true", +}); await cleanup(); diff --git a/examples/docker/src/docker.ts b/examples/docker/src/docker.ts index 4753bfd..431c5ad 100644 --- a/examples/docker/src/docker.ts +++ b/examples/docker/src/docker.ts @@ -79,6 +79,12 @@ if (isMainModule) { process.exit(0); }); - await runPrompt({ baseUrl }); + // When running as root in a container, Claude requires interactive permission prompts + // (bypass mode is not supported). Set autoApprovePermissions: true to auto-approve, + // or leave false for interactive prompts. + await runPrompt({ + baseUrl, + autoApprovePermissions: process.env.AUTO_APPROVE_PERMISSIONS === "true", + }); await cleanup(); } diff --git a/examples/e2b/src/e2b.ts b/examples/e2b/src/e2b.ts index 692c15b..47d5760 100644 --- a/examples/e2b/src/e2b.ts +++ b/examples/e2b/src/e2b.ts @@ -60,6 +60,12 @@ if (isMainModule) { process.exit(0); }); - await runPrompt({ baseUrl }); + // When running as root in a container, Claude requires interactive permission prompts + // (bypass mode is not supported). Set autoApprovePermissions: true to auto-approve, + // or leave false for interactive prompts. + await runPrompt({ + baseUrl, + autoApprovePermissions: process.env.AUTO_APPROVE_PERMISSIONS === "true", + }); await cleanup(); } diff --git a/examples/shared/src/sandbox-agent-client.ts b/examples/shared/src/sandbox-agent-client.ts index 0512951..2ccd8f6 100644 --- a/examples/shared/src/sandbox-agent-client.ts +++ b/examples/shared/src/sandbox-agent-client.ts @@ -1,7 +1,8 @@ -import { createInterface } from "node:readline/promises"; +import { createInterface, Interface } from "node:readline/promises"; import { randomUUID } from "node:crypto"; import { setTimeout as delay } from "node:timers/promises"; import { SandboxAgent } from "sandbox-agent"; +import type { PermissionReply, PermissionEventData, QuestionEventData } from "sandbox-agent"; export function normalizeBaseUrl(baseUrl: string): string { return baseUrl.replace(/\/+$/, ""); @@ -278,17 +279,84 @@ function detectAgent(): string { return "claude"; } -export async function runPrompt({ - baseUrl, - token, - extraHeaders, - agentId, -}: { +export type PermissionHandler = ( + data: PermissionEventData +) => Promise | PermissionReply; + +export type QuestionHandler = ( + data: QuestionEventData +) => Promise | string[][] | null; + +export interface RunPromptOptions { baseUrl: string; token?: string; extraHeaders?: Record; agentId?: string; -}): Promise { + agentMode?: string; + permissionMode?: string; + model?: string; + /** Auto-approve all permissions with "once" (default: false, prompts interactively) */ + autoApprovePermissions?: boolean; + /** Custom permission handler (overrides autoApprovePermissions) */ + onPermission?: PermissionHandler; + /** Custom question handler (return null to reject) */ + onQuestion?: QuestionHandler; +} + +async function promptForPermission( + rl: Interface, + data: PermissionEventData +): Promise { + console.log(`\n[Permission Required] ${data.action}`); + if (data.metadata) { + console.log(` Details: ${JSON.stringify(data.metadata, null, 2)}`); + } + while (true) { + const answer = await rl.question(" Allow? [y]es / [n]o / [a]lways: "); + const lower = answer.trim().toLowerCase(); + if (lower === "y" || lower === "yes") return "once"; + if (lower === "a" || lower === "always") return "always"; + if (lower === "n" || lower === "no") return "reject"; + console.log(" Please enter y, n, or a"); + } +} + +async function promptForQuestion( + rl: Interface, + data: QuestionEventData +): Promise { + console.log(`\n[Question] ${data.prompt}`); + if (data.options.length > 0) { + console.log(" Options:"); + data.options.forEach((opt, i) => console.log(` ${i + 1}. ${opt}`)); + const answer = await rl.question(" Enter option number (or 'skip' to reject): "); + if (answer.trim().toLowerCase() === "skip") return null; + const idx = parseInt(answer.trim(), 10) - 1; + if (idx >= 0 && idx < data.options.length) { + return [[data.options[idx]]]; + } + console.log(" Invalid option, rejecting question"); + return null; + } + const answer = await rl.question(" Your answer (or 'skip' to reject): "); + if (answer.trim().toLowerCase() === "skip") return null; + return [[answer.trim()]]; +} + +export async function runPrompt(options: RunPromptOptions): Promise { + const { + baseUrl, + token, + extraHeaders, + agentId, + agentMode, + permissionMode, + model, + autoApprovePermissions = false, + onPermission, + onQuestion, + } = options; + const client = await SandboxAgent.connect({ baseUrl, token, @@ -296,15 +364,61 @@ export async function runPrompt({ }); const agent = agentId || detectAgent(); + console.log(`Using agent: ${agent}`); const sessionId = randomUUID(); - await client.createSession(sessionId, { agent }); - console.log(`Session ${sessionId} using ${agent}. Press Ctrl+C to quit.`); + await client.createSession(sessionId, { + agent, + agentMode, + permissionMode, + model, + }); + console.log(`Session ${sessionId}. Press Ctrl+C to quit.`); + + // Create readline interface for interactive prompts + const rl = createInterface({ input: process.stdin, output: process.stdout }); let isThinking = false; let hasStartedOutput = false; let turnResolve: (() => void) | null = null; let sessionEnded = false; + // Handle permission request + const handlePermission = async (data: PermissionEventData): Promise => { + try { + let reply: PermissionReply; + if (onPermission) { + reply = await onPermission(data); + } else if (autoApprovePermissions) { + console.log(`\n[Auto-approved] ${data.action}`); + reply = "once"; + } else { + reply = await promptForPermission(rl, data); + } + await client.replyPermission(sessionId, data.permission_id, { reply }); + } catch (err) { + console.error("Failed to reply to permission:", err instanceof Error ? err.message : err); + } + }; + + // Handle question request + const handleQuestion = async (data: QuestionEventData): Promise => { + try { + let answers: string[][] | null; + if (onQuestion) { + answers = await onQuestion(data); + } else { + answers = await promptForQuestion(rl, data); + } + if (answers === null) { + await client.rejectQuestion(sessionId, data.question_id); + } else { + await client.replyQuestion(sessionId, data.question_id, { answers }); + } + } catch (err) { + console.error("Failed to reply to question:", err instanceof Error ? err.message : err); + } + }; + // Stream events in background using SDK const processEvents = async () => { for await (const event of client.streamEvents(sessionId)) { @@ -342,6 +456,26 @@ export async function runPrompt({ } } + // Handle permission requests + if (event.type === "permission.requested") { + const data = event.data as PermissionEventData; + // Clear thinking indicator if shown + if (isThinking && !hasStartedOutput) { + process.stdout.write("\r\x1b[K"); + } + await handlePermission(data); + } + + // Handle question requests + if (event.type === "question.requested") { + const data = event.data as QuestionEventData; + // Clear thinking indicator if shown + if (isThinking && !hasStartedOutput) { + process.stdout.write("\r\x1b[K"); + } + await handleQuestion(data); + } + // Handle errors if (event.type === "error") { const data = event.data as any; @@ -351,7 +485,36 @@ export async function runPrompt({ // Handle session ended if (event.type === "session.ended") { const data = event.data as any; - console.log(`Agent Process Exited${data?.reason ? `: ${data.reason}` : ""}`); + const reason = data?.reason || "unknown"; + const exitCode = data?.exit_code; + const message = data?.message; + const stderr = data?.stderr; + + if (reason === "error") { + console.error(`\nAgent exited with error:`); + if (exitCode !== undefined) { + console.error(` Exit code: ${exitCode}`); + } + if (message) { + console.error(` Message: ${message}`); + } + if (stderr) { + console.error(`\n--- Agent Stderr ---`); + if (stderr.head) { + console.error(stderr.head); + } + if (stderr.truncated && stderr.tail) { + const omitted = stderr.total_lines + ? stderr.total_lines - 70 // 20 head + 50 tail + : "..."; + console.error(`\n... ${omitted} lines omitted ...\n`); + console.error(stderr.tail); + } + console.error(`--- End Stderr ---`); + } + } else { + console.log(`Agent session ${reason}`); + } sessionEnded = true; turnResolve?.(); turnResolve = null; @@ -365,7 +528,6 @@ export async function runPrompt({ }); // Read user input and post messages - const rl = createInterface({ input: process.stdin, output: process.stdout }); while (true) { const line = await rl.question("> "); if (!line.trim()) continue; diff --git a/examples/shared/tsconfig.json b/examples/shared/tsconfig.json index 2be3197..66aca36 100644 --- a/examples/shared/tsconfig.json +++ b/examples/shared/tsconfig.json @@ -9,7 +9,8 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8637a29..cfd55b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5973,14 +5973,6 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@22.19.7))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 5.4.21(@types/node@22.19.7) - '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.0.10))': dependencies: '@vitest/spy': 3.2.4 @@ -8424,7 +8416,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@22.19.7)) + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.0.10)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4