From 8a91b8e9aaf6b444e48f1ad64b3dcb297375b5a3 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 28 Jan 2026 01:11:57 -0800 Subject: [PATCH] feat: move api cli commands under api subcommand --- README.md | 17 ++- docs/cli.mdx | 68 ++++++---- docs/quickstart.mdx | 27 ++-- examples/daytona/daytona.ts | 76 ----------- examples/daytona/package.json | 2 +- examples/daytona/{ => src}/daytona.test.ts | 0 examples/daytona/src/daytona.ts | 65 +++++++++ examples/docker/package.json | 2 +- examples/docker/{ => src}/docker.test.ts | 0 examples/docker/{ => src}/docker.ts | 0 examples/e2b/package.json | 2 +- examples/e2b/{ => src}/e2b.test.ts | 0 examples/e2b/{ => src}/e2b.ts | 0 examples/shared/package.json | 2 +- .../shared/{ => src}/sandbox-agent-client.ts | 124 +++++++++--------- examples/vercel/package.json | 2 +- .../vercel/{ => src}/vercel-sandbox.test.ts | 0 examples/vercel/{ => src}/vercel-sandbox.ts | 0 .../packages/extracted-agent-schemas/build.rs | 8 +- server/packages/sandbox-agent/src/main.rs | 107 +++++++++++---- server/packages/sandbox-agent/src/router.rs | 93 +++++++------ todo.md | 2 + 22 files changed, 351 insertions(+), 246 deletions(-) delete mode 100644 examples/daytona/daytona.ts rename examples/daytona/{ => src}/daytona.test.ts (100%) create mode 100644 examples/daytona/src/daytona.ts rename examples/docker/{ => src}/docker.test.ts (100%) rename examples/docker/{ => src}/docker.ts (100%) rename examples/e2b/{ => src}/e2b.test.ts (100%) rename examples/e2b/{ => src}/e2b.ts (100%) rename examples/shared/{ => src}/sandbox-agent-client.ts (75%) rename examples/vercel/{ => src}/vercel-sandbox.test.ts (100%) rename examples/vercel/{ => src}/vercel-sandbox.ts (100%) diff --git a/README.md b/README.md index c502b8b..adc9e0a 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,15 @@ curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh sandbox-agent server --token "$SANDBOX_TOKEN" --host 127.0.0.1 --port 2468 ``` +Optional: preinstall agent binaries (no server required; they will be installed lazily on first use if you skip this): + +```bash +sandbox-agent install-agent claude +sandbox-agent install-agent codex +sandbox-agent install-agent opencode +sandbox-agent install-agent amp +``` + To disable auth locally: ```bash @@ -147,14 +156,14 @@ npm install -g @sandbox-agent/cli Create a session and send a message: ```bash -sandbox-agent sessions create my-session --agent codex --endpoint http://127.0.0.1:2468 --token "$SANDBOX_TOKEN" -sandbox-agent sessions send-message my-session --message "Hello" --endpoint http://127.0.0.1:2468 --token "$SANDBOX_TOKEN" -sandbox-agent sessions send-message-stream my-session --message "Hello" --endpoint http://127.0.0.1:2468 --token "$SANDBOX_TOKEN" +sandbox-agent api sessions create my-session --agent codex --endpoint http://127.0.0.1:2468 --token "$SANDBOX_TOKEN" +sandbox-agent api sessions send-message my-session --message "Hello" --endpoint http://127.0.0.1:2468 --token "$SANDBOX_TOKEN" +sandbox-agent api sessions send-message-stream my-session --message "Hello" --endpoint http://127.0.0.1:2468 --token "$SANDBOX_TOKEN" ``` Docs: https://rivet.dev/docs/cli -### Extract credentials +### Tip: Extract credentials ```bash sandbox-agent credentials extract-env --export diff --git a/docs/cli.mdx b/docs/cli.mdx index 938a8e0..16cea87 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -3,7 +3,7 @@ title: "CLI" description: "CLI reference and server flags." --- -The `sandbox-agent` CLI mirrors the HTTP API so you can script everything without writing client code. +The `sandbox-agent api` subcommand mirrors the HTTP API so you can script everything without writing client code. ## Server flags @@ -17,39 +17,57 @@ sandbox-agent server --token "$SANDBOX_TOKEN" --host 127.0.0.1 --port 2468 - `--cors-allow-origin`, `--cors-allow-method`, `--cors-allow-header`, `--cors-allow-credentials`: configure CORS. - `--no-telemetry`: disable anonymous telemetry. -## Agent commands +## Install agent (no server required)
-agents list +install-agent ```bash -sandbox-agent agents list --endpoint http://127.0.0.1:2468 +sandbox-agent install-agent claude --reinstall +``` +
+ +## API agent commands + +
+api agents list + +```bash +sandbox-agent api agents list --endpoint http://127.0.0.1:2468 ```
-agents install +api agents install ```bash -sandbox-agent agents install claude --reinstall --endpoint http://127.0.0.1:2468 +sandbox-agent api agents install claude --reinstall --endpoint http://127.0.0.1:2468 ```
-agents modes +api agents modes ```bash -sandbox-agent agents modes claude --endpoint http://127.0.0.1:2468 +sandbox-agent api agents modes claude --endpoint http://127.0.0.1:2468 ```
-## Session commands +## API session commands
-sessions create +api sessions list ```bash -sandbox-agent sessions create my-session \ +sandbox-agent api sessions list --endpoint http://127.0.0.1:2468 +``` +
+ +
+api sessions create + +```bash +sandbox-agent api sessions create my-session \ --agent claude \ --agent-mode build \ --permission-mode default \ @@ -58,64 +76,64 @@ sandbox-agent sessions create my-session \
-sessions send-message +api sessions send-message ```bash -sandbox-agent sessions send-message my-session \ +sandbox-agent api sessions send-message my-session \ --message "Summarize the repository" \ --endpoint http://127.0.0.1:2468 ```
-sessions send-message-stream +api sessions send-message-stream ```bash -sandbox-agent sessions send-message-stream my-session \ +sandbox-agent api sessions send-message-stream my-session \ --message "Summarize the repository" \ --endpoint http://127.0.0.1:2468 ```
-sessions events +api sessions events ```bash -sandbox-agent sessions events my-session --offset 0 --limit 50 --endpoint http://127.0.0.1:2468 +sandbox-agent api sessions events my-session --offset 0 --limit 50 --endpoint http://127.0.0.1:2468 ```
-sessions events-sse +api sessions events-sse ```bash -sandbox-agent sessions events-sse my-session --offset 0 --endpoint http://127.0.0.1:2468 +sandbox-agent api sessions events-sse my-session --offset 0 --endpoint http://127.0.0.1:2468 ```
-sessions reply-question +api sessions reply-question ```bash -sandbox-agent sessions reply-question my-session QUESTION_ID \ +sandbox-agent api sessions reply-question my-session QUESTION_ID \ --answers "yes" \ --endpoint http://127.0.0.1:2468 ```
-sessions reject-question +api sessions reject-question ```bash -sandbox-agent sessions reject-question my-session QUESTION_ID --endpoint http://127.0.0.1:2468 +sandbox-agent api sessions reject-question my-session QUESTION_ID --endpoint http://127.0.0.1:2468 ```
-sessions reply-permission +api sessions reply-permission ```bash -sandbox-agent sessions reply-permission my-session PERMISSION_ID \ +sandbox-agent api sessions reply-permission my-session PERMISSION_ID \ --reply once \ --endpoint http://127.0.0.1:2468 ``` diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 435ab0a..9162441 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -38,7 +38,18 @@ sandbox-agent server \ --cors-allow-credentials ``` -## 2. Create a session +## 2. Install agents (optional) + +Agents install lazily on first use. To preinstall everything up front: + +```bash +sandbox-agent install-agent claude +sandbox-agent install-agent codex +sandbox-agent install-agent opencode +sandbox-agent install-agent amp +``` + +## 3. Create a session ```bash curl -X POST "http://127.0.0.1:2468/v1/sessions/my-session" \ @@ -47,7 +58,7 @@ curl -X POST "http://127.0.0.1:2468/v1/sessions/my-session" \ -d '{"agent":"claude","agentMode":"build","permissionMode":"default"}' ``` -## 3. Send a message +## 4. Send a message ```bash curl -X POST "http://127.0.0.1:2468/v1/sessions/my-session/messages" \ @@ -56,7 +67,7 @@ curl -X POST "http://127.0.0.1:2468/v1/sessions/my-session/messages" \ -d '{"message":"Summarize the repository and suggest next steps."}' ``` -## 4. Read events +## 5. Read events ```bash curl "http://127.0.0.1:2468/v1/sessions/my-session/events?offset=0&limit=50" \ @@ -79,14 +90,14 @@ curl -N -X POST "http://127.0.0.1:2468/v1/sessions/my-session/messages/stream" \ -d '{"message":"Hello"}' ``` -## 5. CLI shortcuts +## 6. CLI shortcuts -The CLI mirrors the HTTP API: +The `sandbox-agent api` subcommand mirrors the HTTP API: ```bash -sandbox-agent sessions create my-session --agent claude --endpoint http://127.0.0.1:2468 --token "$SANDBOX_TOKEN" +sandbox-agent api sessions create my-session --agent claude --endpoint http://127.0.0.1:2468 --token "$SANDBOX_TOKEN" -sandbox-agent sessions send-message my-session --message "Hello" --endpoint http://127.0.0.1:2468 --token "$SANDBOX_TOKEN" +sandbox-agent api sessions send-message my-session --message "Hello" --endpoint http://127.0.0.1:2468 --token "$SANDBOX_TOKEN" -sandbox-agent sessions send-message-stream my-session --message "Hello" --endpoint http://127.0.0.1:2468 --token "$SANDBOX_TOKEN" +sandbox-agent api sessions send-message-stream my-session --message "Hello" --endpoint http://127.0.0.1:2468 --token "$SANDBOX_TOKEN" ``` diff --git a/examples/daytona/daytona.ts b/examples/daytona/daytona.ts deleted file mode 100644 index aba4e53..0000000 --- a/examples/daytona/daytona.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Daytona } from "@daytonaio/sdk"; -import { pathToFileURL, fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; -import { - ensureUrl, - logInspectorUrl, - runPrompt, - waitForHealth, -} from "@sandbox-agent/example-shared"; - -const DEFAULT_PORT = 3000; -const BINARY_PATH = resolve(dirname(fileURLToPath(import.meta.url)), "../../target/release/sandbox-agent"); - -export async function setupDaytonaSandboxAgent(): Promise<{ - baseUrl: string; - token: string; - extraHeaders: Record; - cleanup: () => Promise; -}> { - const token = process.env.SANDBOX_TOKEN || ""; - const port = Number.parseInt(process.env.SANDBOX_PORT || "", 10) || DEFAULT_PORT; - const language = process.env.DAYTONA_LANGUAGE || "typescript"; - - const daytona = new Daytona(); - console.log("Creating sandbox..."); - const sandbox = await daytona.create({ language }); - - // Daytona sandboxes can't reach releases.rivet.dev, so upload binary directly - console.log("Uploading sandbox-agent..."); - await sandbox.fs.uploadFile(BINARY_PATH, "/home/daytona/sandbox-agent"); - await sandbox.process.executeCommand("chmod +x /home/daytona/sandbox-agent"); - - console.log("Starting server..."); - const tokenFlag = token ? `--token ${token}` : "--no-token"; - await sandbox.process.executeCommand( - `nohup /home/daytona/sandbox-agent server ${tokenFlag} --host 0.0.0.0 --port ${port} >/tmp/sandbox-agent.log 2>&1 &` - ); - - const preview = await sandbox.getPreviewLink(port); - const extraHeaders: Record = { - "x-daytona-skip-preview-warning": "true", - }; - if (preview.token) { - extraHeaders["x-daytona-preview-token"] = preview.token; - } - - const baseUrl = ensureUrl(preview.url); - console.log("Waiting for health..."); - await waitForHealth({ baseUrl, token, extraHeaders }); - logInspectorUrl({ baseUrl, token }); - - return { - baseUrl, - token, - extraHeaders, - cleanup: async () => { - try { await sandbox.delete(60); } catch {} - }, - }; -} - -async function main(): Promise { - const { baseUrl, token, extraHeaders, cleanup } = await setupDaytonaSandboxAgent(); - - process.on("SIGINT", () => void cleanup().then(() => process.exit(0))); - process.on("SIGTERM", () => void cleanup().then(() => process.exit(0))); - - await runPrompt({ baseUrl, token, extraHeaders }); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch((error) => { - console.error(error); - process.exit(1); - }); -} diff --git a/examples/daytona/package.json b/examples/daytona/package.json index 8bd1404..ea9f37a 100644 --- a/examples/daytona/package.json +++ b/examples/daytona/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "start": "tsx daytona.ts" + "start": "tsx src/daytona.ts" }, "dependencies": { "@daytonaio/sdk": "latest", diff --git a/examples/daytona/daytona.test.ts b/examples/daytona/src/daytona.test.ts similarity index 100% rename from examples/daytona/daytona.test.ts rename to examples/daytona/src/daytona.test.ts diff --git a/examples/daytona/src/daytona.ts b/examples/daytona/src/daytona.ts new file mode 100644 index 0000000..9e5ed36 --- /dev/null +++ b/examples/daytona/src/daytona.ts @@ -0,0 +1,65 @@ +import { Daytona, Image } from "@daytonaio/sdk"; +import { logInspectorUrl, runPrompt } from "@sandbox-agent/example-shared"; + +if ( + !process.env.DAYTONA_API_KEY || + (!process.env.OPENAI_API_KEY && !process.env.ANTHROPIC_API_KEY) +) { + throw new Error( + "DAYTONA_API_KEY and (OPENAI_API_KEY or ANTHROPIC_API_KEY) required", + ); +} + +const SNAPSHOT = "sandbox-agent-ready"; +const BINARY = "/usr/local/bin/sandbox-agent"; + +const daytona = new Daytona(); + +const hasSnapshot = await daytona.snapshot.get(SNAPSHOT).then( + () => true, + () => false, +); +if (!hasSnapshot) { + console.log(`Creating snapshot '${SNAPSHOT}' (one-time setup, ~1-2min)...`); + await daytona.snapshot.create( + { + name: SNAPSHOT, + image: Image.base("ubuntu:22.04").runCommands( + "apt-get update && apt-get install -y curl ca-certificates", + `curl -fsSL -o ${BINARY} https://releases.rivet.dev/sandbox-agent/latest/binaries/sandbox-agent-x86_64-unknown-linux-musl`, + `chmod +x ${BINARY}`, + ), + }, + { onLogs: (log) => console.log(` ${log}`) }, + ); + console.log("Snapshot created. Future runs will be instant."); +} + +console.log("Creating sandbox..."); +const sandbox = await daytona.create({ + snapshot: SNAPSHOT, + envVars: { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + }, + autoStopInterval: 0, +}); + +console.log("Starting server..."); +await sandbox.process.executeCommand( + `nohup ${BINARY} server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &`, +); + +const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; +logInspectorUrl({ baseUrl }); + +const cleanup = async () => { + console.log("Cleaning up..."); + await sandbox.delete(60); + process.exit(0); +}; +process.once("SIGINT", cleanup); +process.once("SIGTERM", cleanup); + +await runPrompt({ baseUrl }); +await cleanup(); diff --git a/examples/docker/package.json b/examples/docker/package.json index f3eb6bd..28d126d 100644 --- a/examples/docker/package.json +++ b/examples/docker/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "start": "tsx docker.ts" + "start": "tsx src/docker.ts" }, "dependencies": { "dockerode": "latest", diff --git a/examples/docker/docker.test.ts b/examples/docker/src/docker.test.ts similarity index 100% rename from examples/docker/docker.test.ts rename to examples/docker/src/docker.test.ts diff --git a/examples/docker/docker.ts b/examples/docker/src/docker.ts similarity index 100% rename from examples/docker/docker.ts rename to examples/docker/src/docker.ts diff --git a/examples/e2b/package.json b/examples/e2b/package.json index 5db786f..001851c 100644 --- a/examples/e2b/package.json +++ b/examples/e2b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "start": "tsx e2b.ts" + "start": "tsx src/e2b.ts" }, "dependencies": { "@e2b/code-interpreter": "latest", diff --git a/examples/e2b/e2b.test.ts b/examples/e2b/src/e2b.test.ts similarity index 100% rename from examples/e2b/e2b.test.ts rename to examples/e2b/src/e2b.test.ts diff --git a/examples/e2b/e2b.ts b/examples/e2b/src/e2b.ts similarity index 100% rename from examples/e2b/e2b.ts rename to examples/e2b/src/e2b.ts diff --git a/examples/shared/package.json b/examples/shared/package.json index f3b54c0..30a9ff5 100644 --- a/examples/shared/package.json +++ b/examples/shared/package.json @@ -3,6 +3,6 @@ "private": true, "type": "module", "exports": { - ".": "./sandbox-agent-client.ts" + ".": "./src/sandbox-agent-client.ts" } } diff --git a/examples/shared/sandbox-agent-client.ts b/examples/shared/src/sandbox-agent-client.ts similarity index 75% rename from examples/shared/sandbox-agent-client.ts rename to examples/shared/src/sandbox-agent-client.ts index 9f5423a..2fad2a0 100644 --- a/examples/shared/sandbox-agent-client.ts +++ b/examples/shared/src/sandbox-agent-client.ts @@ -140,7 +140,7 @@ export async function createSession({ const normalized = normalizeBaseUrl(baseUrl); const sessionId = randomUUID(); const body: Record = { - agent: agentId || process.env.SANDBOX_AGENT || "codex", + agent: agentId || process.env.SANDBOX_AGENT || "claude", }; const envAgentMode = agentMode || process.env.SANDBOX_AGENT_MODE; const envPermissionMode = permissionMode || process.env.SANDBOX_PERMISSION_MODE; @@ -163,28 +163,6 @@ export async function createSession({ return sessionId; } -export async function sendMessage({ - baseUrl, - token, - extraHeaders, - sessionId, - message, -}: { - baseUrl: string; - token?: string; - extraHeaders?: Record; - sessionId: string; - message: string; -}): Promise { - const normalized = normalizeBaseUrl(baseUrl); - await fetchJson(`${normalized}/v1/sessions/${sessionId}/messages`, { - token, - extraHeaders, - method: "POST", - body: { message }, - }); -} - function extractTextFromItem(item: any): string { if (!item?.content) return ""; const textParts = item.content @@ -197,53 +175,81 @@ function extractTextFromItem(item: any): string { return JSON.stringify(item.content, null, 2); } -export async function waitForAssistantComplete({ +export async function sendMessageStream({ baseUrl, token, extraHeaders, sessionId, - offset = 0, - timeoutMs = 120_000, + message, + onText, }: { baseUrl: string; token?: string; extraHeaders?: Record; sessionId: string; - offset?: number; - timeoutMs?: number; -}): Promise<{ text: string; offset: number }> { + message: string; + onText?: (text: string) => void; +}): Promise { const normalized = normalizeBaseUrl(baseUrl); - const deadline = Date.now() + timeoutMs; - let currentOffset = offset; + const headers = buildHeaders({ token, extraHeaders, contentType: true }); - while (Date.now() < deadline) { - const data = await fetchJson( - `${normalized}/v1/sessions/${sessionId}/events?offset=${currentOffset}&limit=100`, - { token, extraHeaders } - ); + const response = await fetch(`${normalized}/v1/sessions/${sessionId}/messages/stream`, { + method: "POST", + headers, + body: JSON.stringify({ message }), + }); - for (const event of data.events || []) { - if (typeof event.sequence === "number") { - currentOffset = Math.max(currentOffset, event.sequence); + if (!response.ok || !response.body) { + const text = await response.text(); + throw new Error(`HTTP ${response.status} ${response.statusText}: ${text}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let fullText = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6); + if (data === "[DONE]") continue; + + try { + const event = JSON.parse(data); + + // Handle text deltas + if (event.type === "item.delta" && event.data?.delta?.type === "text") { + const text = event.data.delta.text || ""; + fullText += text; + onText?.(text); + } + + // Handle completed assistant message + if ( + event.type === "item.completed" && + event.data?.item?.kind === "message" && + event.data?.item?.role === "assistant" + ) { + const itemText = extractTextFromItem(event.data.item); + if (itemText && !fullText) { + fullText = itemText; + } + } + } catch { + // Ignore parse errors } - if ( - event.type === "item.completed" && - event.data?.item?.kind === "message" && - event.data?.item?.role === "assistant" - ) { - return { - text: extractTextFromItem(event.data.item), - offset: currentOffset, - }; - } - } - - if (!data.hasMore) { - await delay(300); } } - throw new Error("Timed out waiting for assistant response"); + return fullText; } export async function runPrompt({ @@ -258,7 +264,6 @@ export async function runPrompt({ agentId?: string; }): Promise { const sessionId = await createSession({ baseUrl, token, extraHeaders, agentId }); - let offset = 0; console.log(`Session ${sessionId} ready. Type /exit to quit.`); @@ -280,16 +285,15 @@ export async function runPrompt({ } try { - await sendMessage({ baseUrl, token, extraHeaders, sessionId, message: trimmed }); - const result = await waitForAssistantComplete({ + await sendMessageStream({ baseUrl, token, extraHeaders, sessionId, - offset, + message: trimmed, + onText: (text) => process.stdout.write(text), }); - offset = result.offset; - process.stdout.write(`${result.text}\n`); + process.stdout.write("\n"); } catch (error) { console.error(error instanceof Error ? error.message : error); } diff --git a/examples/vercel/package.json b/examples/vercel/package.json index 0c50a97..b89c3c8 100644 --- a/examples/vercel/package.json +++ b/examples/vercel/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "start": "tsx vercel-sandbox.ts" + "start": "tsx src/vercel-sandbox.ts" }, "dependencies": { "@vercel/sandbox": "latest", diff --git a/examples/vercel/vercel-sandbox.test.ts b/examples/vercel/src/vercel-sandbox.test.ts similarity index 100% rename from examples/vercel/vercel-sandbox.test.ts rename to examples/vercel/src/vercel-sandbox.test.ts diff --git a/examples/vercel/vercel-sandbox.ts b/examples/vercel/src/vercel-sandbox.ts similarity index 100% rename from examples/vercel/vercel-sandbox.ts rename to examples/vercel/src/vercel-sandbox.ts diff --git a/server/packages/extracted-agent-schemas/build.rs b/server/packages/extracted-agent-schemas/build.rs index 6ab8f0c..cdf328b 100644 --- a/server/packages/extracted-agent-schemas/build.rs +++ b/server/packages/extracted-agent-schemas/build.rs @@ -56,10 +56,10 @@ fn main() { fs::write(&out_path, formatted) .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_path.display(), e)); - emit_stdout(&format!( - "cargo:warning=Generated {} types from {}", - name, file - )); + // emit_stdout(&format!( + // "cargo:warning=Generated {} types from {}", + // name, file + // )); } } diff --git a/server/packages/sandbox-agent/src/main.rs b/server/packages/sandbox-agent/src/main.rs index fea4e6a..072063f 100644 --- a/server/packages/sandbox-agent/src/main.rs +++ b/server/packages/sandbox-agent/src/main.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use clap::{Args, Parser, Subcommand}; use reqwest::blocking::Client as HttpClient; use reqwest::Method; -use sandbox_agent_agent_management::agents::AgentManager; +use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions}; use sandbox_agent_agent_management::credentials::{ extract_all_credentials, AuthType, CredentialExtractionOptions, ExtractedCredentials, ProviderCredentials, @@ -16,7 +16,9 @@ use sandbox_agent::router::{ PermissionReply, PermissionReplyRequest, QuestionReplyRequest, }; use sandbox_agent::telemetry; -use sandbox_agent::router::{AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse}; +use sandbox_agent::router::{ + AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse, SessionListResponse, +}; use sandbox_agent::router::{build_router_with_state, shutdown_servers}; use sandbox_agent::ui; use serde::Serialize; @@ -47,10 +49,10 @@ struct Cli { enum Command { /// Run the sandbox agent HTTP server. Server(ServerArgs), - /// Manage installed agents and their modes. - Agents(AgentsArgs), - /// Create sessions and interact with session events. - Sessions(SessionsArgs), + /// Call the HTTP API without writing client code. + Api(ApiArgs), + /// Install or reinstall an agent without running the server. + InstallAgent(InstallAgentArgs), /// Inspect locally discovered credentials. Credentials(CredentialsArgs), } @@ -80,15 +82,9 @@ struct ServerArgs { } #[derive(Args, Debug)] -struct AgentsArgs { +struct ApiArgs { #[command(subcommand)] - command: AgentsCommand, -} - -#[derive(Args, Debug)] -struct SessionsArgs { - #[command(subcommand)] - command: SessionsCommand, + command: ApiCommand, } #[derive(Args, Debug)] @@ -98,13 +94,11 @@ struct CredentialsArgs { } #[derive(Subcommand, Debug)] -enum AgentsCommand { - /// List all agents and install status. - List(ClientArgs), - /// Install or reinstall an agent. - Install(InstallAgentArgs), - /// Show available modes for an agent. - Modes(AgentModesArgs), +enum ApiCommand { + /// Manage installed agents and their modes. + Agents(AgentsArgs), + /// Create sessions and interact with session events. + Sessions(SessionsArgs), } #[derive(Subcommand, Debug)] @@ -116,8 +110,32 @@ enum CredentialsCommand { ExtractEnv(CredentialsExtractEnvArgs), } +#[derive(Args, Debug)] +struct AgentsArgs { + #[command(subcommand)] + command: AgentsCommand, +} + +#[derive(Args, Debug)] +struct SessionsArgs { + #[command(subcommand)] + command: SessionsCommand, +} + +#[derive(Subcommand, Debug)] +enum AgentsCommand { + /// List all agents and install status. + List(ClientArgs), + /// Install or reinstall an agent. + Install(ApiInstallAgentArgs), + /// Show available modes for an agent. + Modes(AgentModesArgs), +} + #[derive(Subcommand, Debug)] enum SessionsCommand { + /// List active sessions. + List(ClientArgs), /// Create a new session for an agent. Create(CreateSessionArgs), #[command(name = "send-message")] @@ -156,7 +174,7 @@ struct ClientArgs { } #[derive(Args, Debug)] -struct InstallAgentArgs { +struct ApiInstallAgentArgs { agent: String, #[arg(long, short = 'r')] reinstall: bool, @@ -164,6 +182,13 @@ struct InstallAgentArgs { client: ClientArgs, } +#[derive(Args, Debug)] +struct InstallAgentArgs { + agent: String, + #[arg(long, short = 'r')] + reinstall: bool, +} + #[derive(Args, Debug)] struct AgentModesArgs { agent: String, @@ -277,7 +302,7 @@ struct CredentialsExtractArgs { provider: Option, #[arg(long, short = 'd')] home_dir: Option, - #[arg(long, short = 'n')] + #[arg(long)] no_oauth: bool, #[arg(long, short = 'r')] reveal: bool, @@ -290,7 +315,7 @@ struct CredentialsExtractEnvArgs { export: bool, #[arg(long, short = 'd')] home_dir: Option, - #[arg(long, short = 'n')] + #[arg(long)] no_oauth: bool, } @@ -407,12 +432,19 @@ fn run_client(command: &Command, cli: &Cli) -> Result<(), CliError> { Command::Server(_) => Err(CliError::Server( "server subcommand must be invoked as `sandbox-agent server`".to_string(), )), - Command::Agents(subcommand) => run_agents(&subcommand.command, cli), - Command::Sessions(subcommand) => run_sessions(&subcommand.command, cli), + Command::Api(subcommand) => run_api(&subcommand.command, cli), + Command::InstallAgent(args) => install_agent_local(args), Command::Credentials(subcommand) => run_credentials(&subcommand.command), } } +fn run_api(command: &ApiCommand, cli: &Cli) -> Result<(), CliError> { + match command { + ApiCommand::Agents(subcommand) => run_agents(&subcommand.command, cli), + ApiCommand::Sessions(subcommand) => run_sessions(&subcommand.command, cli), + } +} + fn run_agents(command: &AgentsCommand, cli: &Cli) -> Result<(), CliError> { match command { AgentsCommand::List(args) => { @@ -440,6 +472,11 @@ fn run_agents(command: &AgentsCommand, cli: &Cli) -> Result<(), CliError> { fn run_sessions(command: &SessionsCommand, cli: &Cli) -> Result<(), CliError> { match command { + SessionsCommand::List(args) => { + let ctx = ClientContext::new(cli, args)?; + let response = ctx.get(&format!("{API_PREFIX}/sessions"))?; + print_json_response::(response) + } SessionsCommand::Create(args) => { let ctx = ClientContext::new(cli, &args.client)?; let body = CreateSessionRequest { @@ -674,6 +711,24 @@ fn redact_key(key: &str) -> String { format!("{prefix}...{suffix}") } +fn install_agent_local(args: &InstallAgentArgs) -> Result<(), CliError> { + let agent_id = AgentId::parse(&args.agent).ok_or_else(|| { + CliError::Server(format!("unsupported agent: {}", args.agent)) + })?; + let manager = + AgentManager::new(default_install_dir()).map_err(|err| CliError::Server(err.to_string()))?; + manager + .install( + agent_id, + InstallOptions { + reinstall: args.reinstall, + version: None, + }, + ) + .map_err(|err| CliError::Server(err.to_string()))?; + Ok(()) +} + fn select_token_for_agent( credentials: &ExtractedCredentials, agent: CredentialAgent, diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 520c648..e967c69 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -2329,47 +2329,63 @@ impl SessionManager { } }; let event_type = value.get("type").and_then(Value::as_str).unwrap_or(""); - if event_type == "assistant" { + let native_session_id = value + .get("session_id") + .and_then(Value::as_str) + .or_else(|| value.get("sessionId").and_then(Value::as_str)) + .map(|id| id.to_string()); + if event_type == "assistant" || event_type == "result" || native_session_id.is_some() { let mut sessions = self.sessions.lock().await; if let Some(session) = Self::session_mut(&mut sessions, session_id) { - let id = value - .get("message") - .and_then(|message| message.get("id")) - .and_then(Value::as_str) - .map(|id| id.to_string()) - .unwrap_or_else(|| { - session.claude_message_counter += 1; - let generated = - format!("{}_message_{}", session.session_id, session.claude_message_counter); - if let Some(message) = value.get_mut("message").and_then(Value::as_object_mut) - { - message.insert("id".to_string(), Value::String(generated.clone())); - } else if let Some(map) = value.as_object_mut() { - map.insert( - "message".to_string(), - serde_json::json!({ - "id": generated - }), - ); - } - generated - }); - session.last_claude_message_id = Some(id); - } - } else if event_type == "result" { - let has_message_id = value.get("message_id").is_some() || value.get("messageId").is_some(); - let mut sessions = self.sessions.lock().await; - if let Some(session) = Self::session_mut(&mut sessions, session_id) { - if !has_message_id { - let id = session.last_claude_message_id.take().unwrap_or_else(|| { - session.claude_message_counter += 1; - format!("{}_message_{}", session.session_id, session.claude_message_counter) - }); - if let Some(map) = value.as_object_mut() { - map.insert("message_id".to_string(), Value::String(id)); + if let Some(native_session_id) = native_session_id.as_ref() { + if session.native_session_id.is_none() { + session.native_session_id = Some(native_session_id.clone()); + } + } + if event_type == "assistant" { + let id = value + .get("message") + .and_then(|message| message.get("id")) + .and_then(Value::as_str) + .map(|id| id.to_string()) + .unwrap_or_else(|| { + session.claude_message_counter += 1; + let generated = format!( + "{}_message_{}", + session.session_id, session.claude_message_counter + ); + if let Some(message) = + value.get_mut("message").and_then(Value::as_object_mut) + { + message.insert("id".to_string(), Value::String(generated.clone())); + } else if let Some(map) = value.as_object_mut() { + map.insert( + "message".to_string(), + serde_json::json!({ + "id": generated + }), + ); + } + generated + }); + session.last_claude_message_id = Some(id); + } else if event_type == "result" { + let has_message_id = + value.get("message_id").is_some() || value.get("messageId").is_some(); + if !has_message_id { + let id = session.last_claude_message_id.take().unwrap_or_else(|| { + session.claude_message_counter += 1; + format!( + "{}_message_{}", + session.session_id, session.claude_message_counter + ) + }); + if let Some(map) = value.as_object_mut() { + map.insert("message_id".to_string(), Value::String(id)); + } + } else { + session.last_claude_message_id = None; } - } else { - session.last_claude_message_id = None; } } } @@ -3958,6 +3974,7 @@ fn normalize_permission_mode( return Ok("bypass".to_string()); } let supported = match agent { + AgentId::Claude => false, AgentId::Codex => matches!(mode, "default" | "plan" | "bypass"), AgentId::Amp => matches!(mode, "default" | "bypass"), AgentId::Opencode => matches!(mode, "default"), diff --git a/todo.md b/todo.md index 6986730..3bdcd8e 100644 --- a/todo.md +++ b/todo.md @@ -9,4 +9,6 @@ - [x] Add Docker/Vercel/Daytona/E2B examples with basic prompt scripts and tests. - [x] Add unified AgentServerManager for shared agent servers (Codex/OpenCode). - [x] Expose server status details in agent list API (uptime/restarts/last error/base URL). +- [x] Add local agent install CLI command and document optional preinstall step. +- [x] Move API CLI commands under the api subcommand. - [ ] Regenerate TypeScript SDK from updated OpenAPI (blocked: Node/pnpm not available in env).