diff --git a/Cargo.toml b/Cargo.toml index 6f71204..2559709 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" authors = [ "Rivet Gaming, LLC " ] license = "Apache-2.0" repository = "https://github.com/rivet-dev/sandbox-agent" -description = "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, Cursor, Amp, and Pi." +description = "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, and Amp." [workspace.dependencies] # Internal crates diff --git a/README.md b/README.md index 609194c..f57d233 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,7 @@ Sandbox Agent solves three problems: ## Features - **Universal Agent API**: Single interface to control Claude Code, Codex, OpenCode, Cursor, Amp, and Pi with full feature coverage -- **Streaming Events**: Real-time SSE stream of everything the agent does — tool calls, permission requests, file edits, and more -- **Universal Session Schema**: [Standardized schema](https://sandboxagent.dev/docs/session-transcript-schema) that normalizes all agent event formats for storage and replay -- **Human-in-the-Loop**: Approve or deny tool executions and answer agent questions remotely over HTTP -- **Automatic Agent Installation**: Agents are installed on-demand when first used — no setup required +- **Universal Session Schema**: Standardized schema that normalizes all agent event formats for storage and replay - **Runs Inside Any Sandbox**: Lightweight static Rust binary. One curl command to install inside E2B, Daytona, Vercel Sandboxes, or Docker - **Server or SDK Mode**: Run as an HTTP server or embed with the TypeScript SDK - **OpenAPI Spec**: [Well documented](https://sandboxagent.dev/docs/api-reference) and easy to integrate from any language @@ -83,11 +80,11 @@ Import the SDK directly into your Node or browser application. Full type safety **Install** ```bash -npm install sandbox-agent +npm install sandbox-agent@0.2.x ``` ```bash -bun add sandbox-agent +bun add sandbox-agent@0.2.x # Optional: allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 ``` @@ -141,7 +138,7 @@ Run as an HTTP server and connect from any language. Deploy to E2B, Daytona, Ver ```bash # Install it -curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh +curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh # Run it sandbox-agent server --token "$SANDBOX_TOKEN" --host 127.0.0.1 --port 2468 ``` @@ -168,12 +165,12 @@ sandbox-agent server --no-token --host 127.0.0.1 --port 2468 Install the CLI wrapper (optional but convenient): ```bash -npm install -g @sandbox-agent/cli +npm install -g @sandbox-agent/cli@0.2.x ``` ```bash # Allow Bun to run postinstall scripts for native binaries. -bun add -g @sandbox-agent/cli +bun add -g @sandbox-agent/cli@0.2.x bun pm -g trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 ``` @@ -188,11 +185,11 @@ sandbox-agent api sessions send-message-stream my-session --message "Hello" --en You can also use npx like: ```bash -npx sandbox-agent --help +npx @sandbox-agent/cli@0.2.x --help ``` ```bash -bunx sandbox-agent --help +bunx @sandbox-agent/cli@0.2.x --help ``` [CLI documentation](https://sandboxagent.dev/docs/cli) @@ -209,10 +206,6 @@ Debug sessions and events with the built-in Inspector UI (e.g., `http://localhos [Explore API](https://sandboxagent.dev/docs/api-reference) — [View Specification](https://github.com/rivet-dev/sandbox-agent/blob/main/docs/openapi.json) -### Session Transcript Schema - -All events follow a [session transcript schema](https://sandboxagent.dev/docs/session-transcript-schema) that normalizes differences between agents. - ### Tip: Extract credentials Often you need to use your personal API tokens to test agents on sandboxes: diff --git a/docs/cli.mdx b/docs/cli.mdx index 2111b35..9472a5e 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -1,12 +1,17 @@ --- title: "CLI Reference" -description: "Complete CLI reference for sandbox-agent." +description: "CLI reference for sandbox-agent." sidebarTitle: "CLI" --- -## Server +Global flags (available on all commands): -Start the HTTP server: +- `-t, --token `: require/use bearer auth +- `-n, --no-token`: disable auth + +## server + +Run the HTTP server. ```bash sandbox-agent server [OPTIONS] @@ -14,32 +19,27 @@ sandbox-agent server [OPTIONS] | Option | Default | Description | |--------|---------|-------------| -| `-t, --token ` | - | Authentication token for all requests | -| `-n, --no-token` | - | Disable authentication (local dev only) | -| `-H, --host ` | `127.0.0.1` | Host to bind to | -| `-p, --port ` | `2468` | Port to bind to | -| `-O, --cors-allow-origin ` | - | CORS origin to allow (repeatable) | -| `-M, --cors-allow-method ` | all | CORS allowed method (repeatable) | -| `-A, --cors-allow-header
` | all | CORS allowed header (repeatable) | -| `-C, --cors-allow-credentials` | - | Enable CORS credentials | -| `--no-telemetry` | - | Disable anonymous telemetry | -| `--log-to-file` | - | Redirect server logs to a daily log file | +| `-H, --host ` | `127.0.0.1` | Host to bind | +| `-p, --port ` | `2468` | Port to bind | +| `-O, --cors-allow-origin ` | - | Allowed CORS origin (repeatable) | +| `-M, --cors-allow-method ` | all | Allowed CORS method (repeatable) | +| `-A, --cors-allow-header
` | all | Allowed CORS header (repeatable) | +| `-C, --cors-allow-credentials` | false | Enable CORS credentials | +| `--no-telemetry` | false | Disable anonymous telemetry | ```bash -sandbox-agent server --token "$TOKEN" --port 3000 +sandbox-agent server --port 3000 ``` -Server logs print to stdout/stderr by default. Use `--log-to-file` or `SANDBOX_AGENT_LOG_TO_FILE=1` to redirect logs to a daily log file under the sandbox-agent data directory (for example, `~/.local/share/sandbox-agent/logs`). Override the directory with `SANDBOX_AGENT_LOG_DIR`, or set `SANDBOX_AGENT_LOG_STDOUT=1` to force stdout/stderr. +Notes: -HTTP request logging is enabled by default. Control it with: -- `SANDBOX_AGENT_LOG_HTTP=0` to disable request logs -- `SANDBOX_AGENT_LOG_HTTP_HEADERS=1` to include request headers (Authorization is redacted) +- Server logs are redirected to files by default. +- Set `SANDBOX_AGENT_LOG_STDOUT=1` to force stdout/stderr logging. +- Use `SANDBOX_AGENT_LOG_DIR` to override log directory. ---- +## install-agent -## Install Agent (Local) - -Install an agent without running the server: +Install or reinstall a single agent. ```bash sandbox-agent install-agent [OPTIONS] @@ -47,17 +47,17 @@ sandbox-agent install-agent [OPTIONS] | Option | Description | |--------|-------------| -| `-r, --reinstall` | Force reinstall even if already installed | +| `-r, --reinstall` | Force reinstall | +| `--agent-version ` | Override agent package version | +| `--agent-process-version ` | Override agent process version | ```bash sandbox-agent install-agent claude --reinstall ``` ---- +## opencode (experimental) -## OpenCode (Experimental) - -Start (or reuse) a sandbox-agent daemon and attach an OpenCode session (uses `opencode attach`): +Start/reuse daemon and run `opencode attach` against `/opencode`. ```bash sandbox-agent opencode [OPTIONS] @@ -65,27 +65,20 @@ sandbox-agent opencode [OPTIONS] | Option | Default | Description | |--------|---------|-------------| -| `-t, --token ` | - | Authentication token for all requests | -| `-n, --no-token` | - | Disable authentication (local dev only) | -| `-H, --host ` | `127.0.0.1` | Host to bind to | -| `-p, --port ` | `2468` | Port to bind to | -| `--session-title ` | - | Title for the OpenCode session | +| `-H, --host <HOST>` | `127.0.0.1` | Daemon host | +| `-p, --port <PORT>` | `2468` | Daemon port | +| `--session-title <TITLE>` | - | Reserved option (currently no-op) | +| `--yolo` | false | OpenCode attach mode flag | ```bash -sandbox-agent opencode --token "$TOKEN" +sandbox-agent opencode ``` -The daemon logs to a per-host log file under the sandbox-agent data directory (for example, `~/.local/share/sandbox-agent/daemon/daemon-127-0-0-1-2468.log`). +## daemon -Existing installs are reused and missing binaries are installed automatically. +Manage the background daemon. ---- - -## Daemon - -Manage the background daemon. See the [Daemon](/daemon) docs for details on lifecycle and auto-upgrade. - -### Start +### daemon start ```bash sandbox-agent daemon start [OPTIONS] @@ -93,16 +86,16 @@ sandbox-agent daemon start [OPTIONS] | Option | Default | Description | |--------|---------|-------------| -| `-H, --host <HOST>` | `127.0.0.1` | Host to bind to | -| `-p, --port <PORT>` | `2468` | Port to bind to | -| `-t, --token <TOKEN>` | - | Authentication token | -| `-n, --no-token` | - | Disable authentication | +| `-H, --host <HOST>` | `127.0.0.1` | Host | +| `-p, --port <PORT>` | `2468` | Port | +| `--upgrade` | false | Use ensure-running + upgrade behavior | ```bash -sandbox-agent daemon start --no-token +sandbox-agent daemon start +sandbox-agent daemon start --upgrade ``` -### Stop +### daemon stop ```bash sandbox-agent daemon stop [OPTIONS] @@ -110,10 +103,10 @@ sandbox-agent daemon stop [OPTIONS] | Option | Default | Description | |--------|---------|-------------| -| `-H, --host <HOST>` | `127.0.0.1` | Host of the daemon | -| `-p, --port <PORT>` | `2468` | Port of the daemon | +| `-H, --host <HOST>` | `127.0.0.1` | Host | +| `-p, --port <PORT>` | `2468` | Port | -### Status +### daemon status ```bash sandbox-agent daemon status [OPTIONS] @@ -121,16 +114,12 @@ sandbox-agent daemon status [OPTIONS] | Option | Default | Description | |--------|---------|-------------| -| `-H, --host <HOST>` | `127.0.0.1` | Host of the daemon | -| `-p, --port <PORT>` | `2468` | Port of the daemon | +| `-H, --host <HOST>` | `127.0.0.1` | Host | +| `-p, --port <PORT>` | `2468` | Port | ---- +## credentials -## Credentials - -### Extract - -Extract locally discovered credentials: +### credentials extract ```bash sandbox-agent credentials extract [OPTIONS] @@ -138,20 +127,17 @@ sandbox-agent credentials extract [OPTIONS] | Option | Description | |--------|-------------| -| `-a, --agent <AGENT>` | Filter by agent (`claude`, `codex`, `opencode`, `amp`, `pi`) | -| `-p, --provider <PROVIDER>` | Filter by provider (`anthropic`, `openai`) | -| `-d, --home-dir <DIR>` | Custom home directory for credential search | -| `-r, --reveal` | Show full credential values (default: redacted) | -| `--no-oauth` | Exclude OAuth credentials | +| `-a, --agent <AGENT>` | Filter by `claude`, `codex`, `opencode`, or `amp` | +| `-p, --provider <PROVIDER>` | Filter by provider | +| `-d, --home-dir <DIR>` | Override home dir | +| `--no-oauth` | Skip OAuth sources | +| `-r, --reveal` | Show full credential values | ```bash sandbox-agent credentials extract --agent claude --reveal -sandbox-agent credentials extract --provider anthropic ``` -### Extract as Environment Variables - -Output credentials as shell environment variables: +### credentials extract-env ```bash sandbox-agent credentials extract-env [OPTIONS] @@ -159,378 +145,28 @@ sandbox-agent credentials extract-env [OPTIONS] | Option | Description | |--------|-------------| -| `-e, --export` | Prefix each line with `export` | -| `-d, --home-dir <DIR>` | Custom home directory for credential search | -| `--no-oauth` | Exclude OAuth credentials | +| `-e, --export` | Prefix output with `export` | +| `-d, --home-dir <DIR>` | Override home dir | +| `--no-oauth` | Skip OAuth sources | ```bash -# Source directly into shell eval "$(sandbox-agent credentials extract-env --export)" ``` ---- +## api -## API Commands +API subcommands for scripting. -The `sandbox-agent api` subcommand mirrors the HTTP API for scripting without client code. - -All API commands support: +Shared option: | Option | Default | Description | |--------|---------|-------------| -| `-e, --endpoint <URL>` | `http://127.0.0.1:2468` | API endpoint | -| `-t, --token <TOKEN>` | - | Authentication token | +| `-e, --endpoint <URL>` | `http://127.0.0.1:2468` | Target server | ---- - -### Agents - -#### List Agents +### api agents ```bash -sandbox-agent api agents list +sandbox-agent api agents list [--endpoint <URL>] +sandbox-agent api agents install <AGENT> [--reinstall] [--endpoint <URL>] ``` -#### Install Agent - -```bash -sandbox-agent api agents install <AGENT> [OPTIONS] -``` - -| Option | Description | -|--------|-------------| -| `-r, --reinstall` | Force reinstall | - -```bash -sandbox-agent api agents install claude --reinstall -``` - -#### Get Agent Modes - -```bash -sandbox-agent api agents modes <AGENT> -``` - -```bash -sandbox-agent api agents modes claude -``` - -#### Get Agent Models - -```bash -sandbox-agent api agents models <AGENT> -``` - -```bash -sandbox-agent api agents models claude -``` - ---- - -### Sessions - -#### List Sessions - -```bash -sandbox-agent api sessions list -``` - -#### Create Session - -```bash -sandbox-agent api sessions create <SESSION_ID> [OPTIONS] -``` - -| Option | Description | -|--------|-------------| -| `-a, --agent <AGENT>` | Agent identifier (required) | -| `-g, --agent-mode <MODE>` | Agent mode | -| `-p, --permission-mode <MODE>` | Permission mode (`default`, `plan`, `bypass`, `acceptEdits`) | -| `-m, --model <MODEL>` | Model override | -| `-v, --variant <VARIANT>` | Model variant | -| `-A, --agent-version <VERSION>` | Agent version | -| `--mcp-config <PATH>` | JSON file with MCP server config (see `mcp` docs) | -| `--skill <PATH>` | Skill directory or `SKILL.md` path (repeatable) | - -```bash -sandbox-agent api sessions create my-session \ - --agent claude \ - --agent-mode code \ - --permission-mode default -``` - -`acceptEdits` passes through to Claude, auto-approves file changes for Codex, and is treated as `default` for other agents. - -#### Send Message - -```bash -sandbox-agent api sessions send-message <SESSION_ID> [OPTIONS] -``` - -| Option | Description | -|--------|-------------| -| `-m, --message <TEXT>` | Message text (required) | - -```bash -sandbox-agent api sessions send-message my-session \ - --message "Summarize the repository" -``` - -#### Send Message (Streaming) - -Send a message and stream the response: - -```bash -sandbox-agent api sessions send-message-stream <SESSION_ID> [OPTIONS] -``` - -| Option | Description | -|--------|-------------| -| `-m, --message <TEXT>` | Message text (required) | -| `--include-raw` | Include raw agent data | - -```bash -sandbox-agent api sessions send-message-stream my-session \ - --message "Help me debug this" -``` - -#### Terminate Session - -```bash -sandbox-agent api sessions terminate <SESSION_ID> -``` - -```bash -sandbox-agent api sessions terminate my-session -``` - -#### Get Events - -Fetch session events: - -```bash -sandbox-agent api sessions events <SESSION_ID> [OPTIONS] -``` - -| Option | Description | -|--------|-------------| -| `-o, --offset <N>` | Event offset | -| `-l, --limit <N>` | Max events to return | -| `--include-raw` | Include raw agent data | - -```bash -sandbox-agent api sessions events my-session --offset 0 --limit 50 -``` - -`get-messages` is an alias for `events`. - -#### Stream Events (SSE) - -Stream session events via Server-Sent Events: - -```bash -sandbox-agent api sessions events-sse <SESSION_ID> [OPTIONS] -``` - -| Option | Description | -|--------|-------------| -| `-o, --offset <N>` | Event offset to start from | -| `--include-raw` | Include raw agent data | - -```bash -sandbox-agent api sessions events-sse my-session --offset 0 -``` - -#### Reply to Question - -```bash -sandbox-agent api sessions reply-question <SESSION_ID> <QUESTION_ID> [OPTIONS] -``` - -| Option | Description | -|--------|-------------| -| `-a, --answers <JSON>` | JSON array of answers (required) | - -```bash -sandbox-agent api sessions reply-question my-session q1 \ - --answers '[["yes"]]' -``` - -#### Reject Question - -```bash -sandbox-agent api sessions reject-question <SESSION_ID> <QUESTION_ID> -``` - -```bash -sandbox-agent api sessions reject-question my-session q1 -``` - -#### Reply to Permission - -```bash -sandbox-agent api sessions reply-permission <SESSION_ID> <PERMISSION_ID> [OPTIONS] -``` - -| Option | Description | -|--------|-------------| -| `-r, --reply <REPLY>` | `once`, `always`, or `reject` (required) | - -```bash -sandbox-agent api sessions reply-permission my-session perm1 --reply once -``` - ---- - -### Filesystem - -#### List Entries - -```bash -sandbox-agent api fs entries [OPTIONS] -``` - -| Option | Description | -|--------|-------------| -| `--path <PATH>` | Directory path (default: `.`) | -| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory | - -```bash -sandbox-agent api fs entries --path ./workspace -``` - -#### Read File - -`api fs read` writes raw bytes to stdout. - -```bash -sandbox-agent api fs read <PATH> [OPTIONS] -``` - -| Option | Description | -|--------|-------------| -| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory | - -```bash -sandbox-agent api fs read ./notes.txt > ./notes.txt -``` - -#### Write File - -```bash -sandbox-agent api fs write <PATH> [OPTIONS] -``` - -| Option | Description | -|--------|-------------| -| `--content <TEXT>` | Write UTF-8 content | -| `--from-file <PATH>` | Read content from a local file | -| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory | - -```bash -sandbox-agent api fs write ./hello.txt --content "hello" -sandbox-agent api fs write ./image.bin --from-file ./image.bin -``` - -#### Delete Entry - -```bash -sandbox-agent api fs delete <PATH> [OPTIONS] -``` - -| Option | Description | -|--------|-------------| -| `--recursive` | Delete directories recursively | -| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory | - -```bash -sandbox-agent api fs delete ./old.log -``` - -#### Create Directory - -```bash -sandbox-agent api fs mkdir <PATH> [OPTIONS] -``` - -| Option | Description | -|--------|-------------| -| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory | - -```bash -sandbox-agent api fs mkdir ./cache -``` - -#### Move/Rename - -```bash -sandbox-agent api fs move <FROM> <TO> [OPTIONS] -``` - -| Option | Description | -|--------|-------------| -| `--overwrite` | Overwrite destination if it exists | -| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory | - -```bash -sandbox-agent api fs move ./a.txt ./b.txt --overwrite -``` - -#### Stat - -```bash -sandbox-agent api fs stat <PATH> [OPTIONS] -``` - -| Option | Description | -|--------|-------------| -| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory | - -```bash -sandbox-agent api fs stat ./notes.txt -``` - -#### Upload Batch (tar) - -```bash -sandbox-agent api fs upload-batch --tar <PATH> [OPTIONS] -``` - -| Option | Description | -|--------|-------------| -| `--tar <PATH>` | Tar archive to extract | -| `--path <PATH>` | Destination directory | -| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory | - -```bash -sandbox-agent api fs upload-batch --tar ./skills.tar --path ./skills -``` - ---- - -## CLI to HTTP Mapping - -| CLI Command | HTTP Endpoint | -|-------------|---------------| -| `api agents list` | `GET /v1/agents` | -| `api agents install` | `POST /v1/agents/{agent}/install` | -| `api agents modes` | `GET /v1/agents/{agent}/modes` | -| `api agents models` | `GET /v1/agents/{agent}/models` | -| `api sessions list` | `GET /v1/sessions` | -| `api sessions create` | `POST /v1/sessions/{sessionId}` | -| `api sessions send-message` | `POST /v1/sessions/{sessionId}/messages` | -| `api sessions send-message-stream` | `POST /v1/sessions/{sessionId}/messages/stream` | -| `api sessions terminate` | `POST /v1/sessions/{sessionId}/terminate` | -| `api sessions events` | `GET /v1/sessions/{sessionId}/events` | -| `api sessions events-sse` | `GET /v1/sessions/{sessionId}/events/sse` | -| `api sessions reply-question` | `POST /v1/sessions/{sessionId}/questions/{questionId}/reply` | -| `api sessions reject-question` | `POST /v1/sessions/{sessionId}/questions/{questionId}/reject` | -| `api sessions reply-permission` | `POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply` | -| `api fs entries` | `GET /v1/fs/entries` | -| `api fs read` | `GET /v1/fs/file` | -| `api fs write` | `PUT /v1/fs/file` | -| `api fs delete` | `DELETE /v1/fs/entry` | -| `api fs mkdir` | `POST /v1/fs/mkdir` | -| `api fs move` | `POST /v1/fs/move` | -| `api fs stat` | `GET /v1/fs/stat` | -| `api fs upload-batch` | `POST /v1/fs/upload-batch` | diff --git a/docs/inspector.mdx b/docs/inspector.mdx index 3a964b6..f3b3dc6 100644 --- a/docs/inspector.mdx +++ b/docs/inspector.mdx @@ -3,7 +3,7 @@ title: "Inspector" description: "Debug and inspect agent sessions with the Inspector UI." --- -The Inspector is a web-based GUI for debugging and inspecting Sandbox Agent sessions. Use it to view events, send messages, and troubleshoot agent behavior in real-time. +The Inspector is a web UI for inspecting Sandbox Agent sessions. Use it to view events, inspect payloads, and troubleshoot behavior. <Frame> <img src="/images/inspector.png" alt="Sandbox Agent Inspector" /> @@ -11,35 +11,32 @@ The Inspector is a web-based GUI for debugging and inspecting Sandbox Agent sess ## Open the Inspector -The Inspector UI is served at `/ui/` on your sandbox-agent server. For example, if your server is running at `http://localhost:2468`, open `http://localhost:2468/ui/` in your browser. +The Inspector is served at `/ui/` on your Sandbox Agent server. +For example, if your server runs at `http://localhost:2468`, open `http://localhost:2468/ui/`. -You can also generate a pre-filled Inspector URL with authentication from the TypeScript SDK: +You can also generate a pre-filled Inspector URL from the SDK: ```typescript import { buildInspectorUrl } from "sandbox-agent"; const url = buildInspectorUrl({ baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, }); + console.log(url); -// http://127.0.0.1:2468/ui/?token=... +// http://127.0.0.1:2468/ui/ ``` ## Features -- **Session list**: View all active sessions and their status -- **Event stream**: See events in real-time as they arrive (SSE or polling) -- **Event details**: Expand any event to see its full JSON payload -- **Send messages**: Post messages to a session directly from the UI -- **Agent selection**: Switch between agents and modes -- **Request log**: View raw HTTP requests and responses for debugging -- **Pi concurrent sessions**: Pi sessions run concurrently by default via per-session runtime processes +- Session list +- Event stream view +- Event JSON inspector +- Prompt testing +- Request/response debugging -## When to Use +## When to use -The Inspector is useful for: - -- **Development**: Test your integration without writing client code -- **Debugging**: Inspect event payloads and timing issues -- **Learning**: Understand how agents respond to different prompts +- Development: validate session behavior quickly +- Debugging: inspect raw event payloads +- Integration work: compare UI behavior with SDK/API calls diff --git a/docs/openapi.json b/docs/openapi.json index 6fc7ae0..bc5d316 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "sandbox-agent", - "description": "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, Cursor, Amp, and Pi.", + "description": "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, and Amp.", "contact": { "name": "Rivet Gaming, LLC", "email": "developer@rivet.gg" diff --git a/examples/computesdk/package.json b/examples/computesdk/package.json index c801516..e22b51b 100644 --- a/examples/computesdk/package.json +++ b/examples/computesdk/package.json @@ -8,7 +8,8 @@ }, "dependencies": { "@sandbox-agent/example-shared": "workspace:*", - "computesdk": "latest" + "computesdk": "latest", + "sandbox-agent": "workspace:*" }, "devDependencies": { "@types/node": "latest", diff --git a/examples/computesdk/src/computesdk.ts b/examples/computesdk/src/computesdk.ts index b21dd53..484817d 100644 --- a/examples/computesdk/src/computesdk.ts +++ b/examples/computesdk/src/computesdk.ts @@ -9,7 +9,8 @@ import { type ExplicitComputeConfig, type ProviderName, } from "computesdk"; -import { runPrompt, waitForHealth } from "@sandbox-agent/example-shared"; +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; import { fileURLToPath } from "node:url"; import { resolve } from "node:path"; @@ -140,8 +141,15 @@ export async function runComputeSdkExample(): Promise<void> { process.once("SIGINT", handleExit); process.once("SIGTERM", handleExit); - await runPrompt(baseUrl); - await cleanup(); + const client = await SandboxAgent.connect({ baseUrl }); + const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); + const sessionId = session.id; + + console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); + console.log(" Press Ctrl+C to stop."); + + // Keep alive until SIGINT/SIGTERM triggers cleanup above + await new Promise(() => {}); } const isDirectRun = Boolean( diff --git a/examples/daytona/src/daytona-with-snapshot.ts b/examples/daytona/src/daytona-with-snapshot.ts index e196065..b19fad3 100644 --- a/examples/daytona/src/daytona-with-snapshot.ts +++ b/examples/daytona/src/daytona-with-snapshot.ts @@ -1,6 +1,6 @@ import { Daytona, Image } from "@daytonaio/sdk"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; const daytona = new Daytona(); @@ -13,7 +13,7 @@ if (process.env.OPENAI_API_KEY) // Build a custom image with sandbox-agent pre-installed (slower first run, faster subsequent runs) 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", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", ); console.log("Creating Daytona sandbox (first run builds the base image and may take a few minutes, subsequent runs are fast)..."); @@ -29,8 +29,8 @@ console.log("Waiting for server..."); await waitForHealth({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl }); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { agent: detectAgent() }); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(" Press Ctrl+C to stop."); diff --git a/examples/daytona/src/index.ts b/examples/daytona/src/index.ts index 9fbd2f4..9a6a1b9 100644 --- a/examples/daytona/src/index.ts +++ b/examples/daytona/src/index.ts @@ -1,6 +1,6 @@ import { Daytona } from "@daytonaio/sdk"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; const daytona = new Daytona(); @@ -17,7 +17,7 @@ const sandbox = await daytona.create({ envVars, autoStopInterval: 0 }); // Install sandbox-agent and start server console.log("Installing sandbox-agent..."); await sandbox.process.executeCommand( - "curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", ); await sandbox.process.executeCommand( @@ -30,8 +30,8 @@ console.log("Waiting for server..."); await waitForHealth({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl }); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { agent: detectAgent() }); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(" Press Ctrl+C to stop."); diff --git a/examples/docker/src/index.ts b/examples/docker/src/index.ts index 1ae51e7..e31d8ed 100644 --- a/examples/docker/src/index.ts +++ b/examples/docker/src/index.ts @@ -1,6 +1,6 @@ import Docker from "dockerode"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; const IMAGE = "alpine:latest"; const PORT = 3000; @@ -25,7 +25,7 @@ const container = await docker.createContainer({ Image: IMAGE, Cmd: ["sh", "-c", [ "apk add --no-cache curl ca-certificates libstdc++ libgcc bash", - "curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", "sandbox-agent install-agent claude", "sandbox-agent install-agent codex", `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, @@ -46,8 +46,8 @@ const baseUrl = `http://127.0.0.1:${PORT}`; await waitForHealth({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl }); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { agent: detectAgent() }); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(" Press Ctrl+C to stop."); diff --git a/examples/e2b/src/index.ts b/examples/e2b/src/index.ts index d82141d..98d6034 100644 --- a/examples/e2b/src/index.ts +++ b/examples/e2b/src/index.ts @@ -1,6 +1,6 @@ import { Sandbox } from "@e2b/code-interpreter"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; const envs: Record<string, string> = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -16,7 +16,7 @@ const run = async (cmd: string) => { }; console.log("Installing sandbox-agent..."); -await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh"); +await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh"); console.log("Installing agents..."); await run("sandbox-agent install-agent claude"); @@ -31,8 +31,8 @@ console.log("Waiting for server..."); await waitForHealth({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl }); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { agent: detectAgent() }); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(" Press Ctrl+C to stop."); diff --git a/examples/file-system/src/index.ts b/examples/file-system/src/index.ts index 2e2c8f9..4595fb8 100644 --- a/examples/file-system/src/index.ts +++ b/examples/file-system/src/index.ts @@ -1,5 +1,5 @@ import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; import * as tar from "tar"; import fs from "node:fs"; @@ -47,8 +47,8 @@ const readmeText = new TextDecoder().decode(readmeBytes); console.log(` README.md content: ${readmeText.trim()}`); console.log("Creating session..."); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { agent: detectAgent() }); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/opt/my-project", mcpServers: [] } }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(' Try: "read the README in /opt/my-project"'); console.log(" Press Ctrl+C to stop."); diff --git a/examples/skills-custom-tool/src/index.ts b/examples/skills-custom-tool/src/index.ts index c53498b..9ead3f9 100644 --- a/examples/skills-custom-tool/src/index.ts +++ b/examples/skills-custom-tool/src/index.ts @@ -1,5 +1,5 @@ import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; import fs from "node:fs"; import path from "node:path"; @@ -36,15 +36,17 @@ const skillResult = await client.writeFsFile( ); console.log(` Skill: ${skillResult.path} (${skillResult.bytesWritten} bytes)`); -// Create a session with the uploaded skill as a local source. +// Configure the uploaded skill. +console.log("Configuring custom skill..."); +await client.setSkillsConfig( + { directory: "/", skillName: "random-number" }, + { sources: [{ type: "local", source: "/opt/skills/random-number" }] }, +); + +// Create a session. console.log("Creating session with custom skill..."); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { - agent: detectAgent(), - skills: { - sources: [{ type: "local", source: "/opt/skills/random-number" }], - }, -}); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(' Try: "generate a random number between 1 and 100"'); console.log(" Press Ctrl+C to stop."); diff --git a/examples/skills/src/index.ts b/examples/skills/src/index.ts index 2e1990e..1fd3df9 100644 --- a/examples/skills/src/index.ts +++ b/examples/skills/src/index.ts @@ -1,5 +1,5 @@ import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; console.log("Starting sandbox..."); @@ -7,17 +7,16 @@ const { baseUrl, cleanup } = await startDockerSandbox({ port: 3001, }); -console.log("Creating session with skill source..."); +console.log("Configuring skill source..."); const client = await SandboxAgent.connect({ baseUrl }); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { - agent: detectAgent(), - skills: { - sources: [ - { type: "github", source: "rivet-dev/skills", skills: ["sandbox-agent"] }, - ], - }, -}); +await client.setSkillsConfig( + { directory: "/", skillName: "rivet-dev-skills" }, + { sources: [{ type: "github", source: "rivet-dev/skills", skills: ["sandbox-agent"] }] }, +); + +console.log("Creating session..."); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(' Try: "How do I start sandbox-agent?"'); console.log(" Press Ctrl+C to stop."); diff --git a/examples/vercel/src/index.ts b/examples/vercel/src/index.ts index 93093ae..818d08c 100644 --- a/examples/vercel/src/index.ts +++ b/examples/vercel/src/index.ts @@ -1,6 +1,6 @@ import { Sandbox } from "@vercel/sandbox"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; const envs: Record<string, string> = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -22,7 +22,7 @@ const run = async (cmd: string, args: string[] = []) => { }; console.log("Installing sandbox-agent..."); -await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh"]); +await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh"]); console.log("Installing agents..."); await run("sandbox-agent", ["install-agent", "claude"]); @@ -42,8 +42,8 @@ console.log("Waiting for server..."); await waitForHealth({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl }); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { agent: detectAgent() }); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(" Press Ctrl+C to stop."); diff --git a/frontend/packages/inspector/src/App.tsx b/frontend/packages/inspector/src/App.tsx index 3ee7180..b99bc72 100644 --- a/frontend/packages/inspector/src/App.tsx +++ b/frontend/packages/inspector/src/App.tsx @@ -1,18 +1,27 @@ +import { BookOpen } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - SandboxAgentError, SandboxAgent, + SandboxAgentError, type AgentInfo, - type CreateSessionRequest, - type AgentModelInfo, - type AgentModeInfo, - type PermissionEventData, - type QuestionEventData, - type SessionInfo, - type SkillSource, - type UniversalEvent, - type UniversalItem + type SessionEvent, + type Session, + InMemorySessionPersistDriver, + type SessionPersistDriver, } from "sandbox-agent"; + +type ConfigSelectOption = { value: string; name: string; description?: string }; +type ConfigOption = { + id: string; + name: string; + category?: string; + type?: string; + currentValue?: string; + options?: ConfigSelectOption[] | Array<{ group: string; name: string; options: ConfigSelectOption[] }>; +}; +type AgentModeInfo = { id: string; name: string; description: string }; +type AgentModelInfo = { id: string; name?: string }; +import { IndexedDbSessionPersistDriver } from "@sandbox-agent/persist-indexeddb"; import ChatPanel from "./components/chat/ChatPanel"; import type { TimelineEntry } from "./components/chat/types"; import ConnectScreen from "./components/ConnectScreen"; @@ -21,65 +30,31 @@ import SessionSidebar from "./components/SessionSidebar"; import type { RequestLog } from "./types/requestLog"; import { buildCurl } from "./utils/http"; +const flattenSelectOptions = ( + options: ConfigSelectOption[] | Array<{ group: string; name: string; options: ConfigSelectOption[] }> +): ConfigSelectOption[] => { + if (options.length === 0) return []; + if ("value" in options[0]) return options as ConfigSelectOption[]; + return (options as Array<{ options: ConfigSelectOption[] }>).flatMap((g) => g.options); +}; + const logoUrl = `${import.meta.env.BASE_URL}logos/sandboxagent.svg`; -const defaultAgents = ["claude", "codex", "opencode", "amp", "pi", "mock"]; +const defaultAgents = ["claude", "codex", "opencode", "amp", "pi", "cursor"]; -type ItemEventData = { - item: UniversalItem; +type ErrorToast = { + id: number; + message: string; }; -type ItemDeltaEventData = { - item_id: string; - native_item_id?: string | null; - delta: string; +type SessionListItem = { + sessionId: string; + agent: string; + ended: boolean; }; -export type McpServerEntry = { - name: string; - configJson: string; - error: string | null; -}; - -type ParsedMcpConfig = { - value: NonNullable<CreateSessionRequest["mcp"]>; - count: number; - error: string | null; -}; - -const buildMcpConfig = (entries: McpServerEntry[]): ParsedMcpConfig => { - if (entries.length === 0) { - return { value: {}, count: 0, error: null }; - } - const firstError = entries.find((e) => e.error); - if (firstError) { - return { value: {}, count: entries.length, error: `${firstError.name}: ${firstError.error}` }; - } - const value: NonNullable<CreateSessionRequest["mcp"]> = {}; - for (const entry of entries) { - try { - value[entry.name] = JSON.parse(entry.configJson); - } catch { - return { value: {}, count: entries.length, error: `${entry.name}: Invalid JSON` }; - } - } - return { value, count: entries.length, error: null }; -}; - -const buildSkillsConfig = (sources: SkillSource[]): NonNullable<CreateSessionRequest["skills"]> => { - return { sources }; -}; - -const buildStubItem = (itemId: string, nativeItemId?: string | null): UniversalItem => { - return { - item_id: itemId, - native_item_id: nativeItemId ?? null, - parent_id: null, - kind: "message", - role: null, - content: [], - status: "in_progress" - } as UniversalItem; -}; +const ERROR_TOAST_MS = 6000; +const MAX_ERROR_TOASTS = 3; +const HTTP_ERROR_EVENT = "inspector-http-error"; const DEFAULT_ENDPOINT = "http://localhost:2468"; @@ -90,6 +65,53 @@ const getCurrentOriginEndpoint = () => { return window.location.origin; }; +const getErrorMessage = (error: unknown, fallback: string) => { + if (error instanceof SandboxAgentError) { + return error.problem?.detail ?? error.problem?.title ?? error.message; + } + if (error instanceof Error) { + // ACP RequestError may carry a data object with a hint or details field. + const data = (error as { data?: Record<string, unknown> }).data; + if (data && typeof data === "object") { + const hint = typeof data.hint === "string" ? data.hint : null; + const details = typeof data.details === "string" ? data.details : null; + if (hint) return hint; + if (details) return details; + } + return error.message; + } + return fallback; +}; + +const getHttpErrorMessage = (status: number, statusText: string, responseBody: string) => { + const base = statusText ? `HTTP ${status} ${statusText}` : `HTTP ${status}`; + const body = responseBody.trim(); + if (!body) { + return base; + } + try { + const parsed = JSON.parse(body); + if (parsed && typeof parsed === "object") { + const detail = (parsed as { detail?: unknown }).detail; + if (typeof detail === "string" && detail.trim()) { + return detail; + } + const title = (parsed as { title?: unknown }).title; + if (typeof title === "string" && title.trim()) { + return title; + } + const message = (parsed as { message?: unknown }).message; + if (typeof message === "string" && message.trim()) { + return message; + } + } + } catch { + // Ignore parse failures and fall through to body text. + } + const clippedBody = body.length > 240 ? `${body.slice(0, 240)}...` : body; + return `${base}: ${clippedBody}`; +}; + const getSessionIdFromPath = (): string => { const basePath = import.meta.env.BASE_URL; const path = window.location.pathname; @@ -132,8 +154,19 @@ const getInitialConnection = () => { }; }; +const agentDisplayNames: Record<string, string> = { + claude: "Claude Code", + codex: "Codex", + opencode: "OpenCode", + amp: "Amp", + pi: "Pi", + cursor: "Cursor" +}; + export default function App() { - const issueTrackerUrl = "https://github.com/rivet-dev/sandbox-agent/issues/new"; + const issueTrackerUrl = "https://github.com/rivet-dev/sandbox-agent/issues"; + const docsUrl = "https://sandboxagent.dev/docs"; + const discordUrl = "https://rivet.dev/discord"; const initialConnectionRef = useRef(getInitialConnection()); const [endpoint, setEndpoint] = useState(initialConnectionRef.current.endpoint); const [token, setToken] = useState(initialConnectionRef.current.token); @@ -143,52 +176,34 @@ export default function App() { const [connectError, setConnectError] = useState<string | null>(null); const [agents, setAgents] = useState<AgentInfo[]>([]); - const [modesByAgent, setModesByAgent] = useState<Record<string, AgentModeInfo[]>>({}); - const [modelsByAgent, setModelsByAgent] = useState<Record<string, AgentModelInfo[]>>({}); - const [defaultModelByAgent, setDefaultModelByAgent] = useState<Record<string, string>>({}); - const [sessions, setSessions] = useState<SessionInfo[]>([]); + const [sessions, setSessions] = useState<SessionListItem[]>([]); const [agentsLoading, setAgentsLoading] = useState(false); const [agentsError, setAgentsError] = useState<string | null>(null); const [sessionsLoading, setSessionsLoading] = useState(false); const [sessionsError, setSessionsError] = useState<string | null>(null); - const [modesLoadingByAgent, setModesLoadingByAgent] = useState<Record<string, boolean>>({}); - const [modesErrorByAgent, setModesErrorByAgent] = useState<Record<string, string | null>>({}); - const [modelsLoadingByAgent, setModelsLoadingByAgent] = useState<Record<string, boolean>>({}); - const [modelsErrorByAgent, setModelsErrorByAgent] = useState<Record<string, string | null>>({}); const [agentId, setAgentId] = useState("claude"); const [sessionId, setSessionId] = useState(getSessionIdFromPath()); const [sessionError, setSessionError] = useState<string | null>(null); const [message, setMessage] = useState(""); - const [events, setEvents] = useState<UniversalEvent[]>([]); - const [offset, setOffset] = useState(0); - const offsetRef = useRef(0); - const [eventsLoading, setEventsLoading] = useState(false); - const [mcpServers, setMcpServers] = useState<McpServerEntry[]>([]); - const [skillSources, setSkillSources] = useState<SkillSource[]>([]); - - const [polling, setPolling] = useState(false); - const pollTimerRef = useRef<number | null>(null); - const [turnStreaming, setTurnStreaming] = useState(false); - const [streamMode, setStreamMode] = useState<"poll" | "sse" | "turn">("sse"); - const [eventError, setEventError] = useState<string | null>(null); - - const [questionSelections, setQuestionSelections] = useState<Record<string, string[][]>>({}); - const [questionStatus, setQuestionStatus] = useState<Record<string, "replied" | "rejected">>({}); - const [permissionStatus, setPermissionStatus] = useState<Record<string, "replied" | "rejected">>({}); + const [events, setEvents] = useState<SessionEvent[]>([]); + const [sending, setSending] = useState(false); const [requestLog, setRequestLog] = useState<RequestLog[]>([]); const logIdRef = useRef(1); const [copiedLogId, setCopiedLogId] = useState<number | null>(null); + const [errorToasts, setErrorToasts] = useState<ErrorToast[]>([]); + const toastIdRef = useRef(1); + const toastTimeoutsRef = useRef<Map<number, number>>(new Map()); const [debugTab, setDebugTab] = useState<DebugTab>("events"); const messagesEndRef = useRef<HTMLDivElement>(null); const clientRef = useRef<SandboxAgent | null>(null); - const sseAbortRef = useRef<AbortController | null>(null); - const turnAbortRef = useRef<AbortController | null>(null); + const activeSessionRef = useRef<Session | null>(null); + const eventUnsubRef = useRef<(() => void) | null>(null); const logRequest = useCallback((entry: RequestLog) => { setRequestLog((prev) => { @@ -210,17 +225,24 @@ export default function App() { const bodyText = typeof init?.body === "string" ? init.body : undefined; const curl = buildCurl(method, url, bodyText, token); const logId = logIdRef.current++; + + const headers: Record<string, string> = {}; + if (init?.headers) { + const h = new Headers(init.headers as HeadersInit); + h.forEach((v, k) => { headers[k] = v; }); + } + const entry: RequestLog = { id: logId, method, url, + headers, body: bodyText, time: new Date().toLocaleTimeString(), curl }; let logged = false; - // Add targetAddressSpace for local network access from HTTPS const fetchInit = { ...init, targetAddressSpace: "loopback" @@ -228,7 +250,25 @@ export default function App() { try { const response = await fetch(input, fetchInit); - logRequest({ ...entry, status: response.status }); + const acceptsStream = headers["accept"]?.includes("text/event-stream"); + if (acceptsStream) { + const ct = response.headers.get("content-type") ?? ""; + if (!ct.includes("text/event-stream")) { + throw new Error( + `Expected text/event-stream from ${method} ${url} but got ${ct || "(no content-type)"} (HTTP ${response.status})` + ); + } + logRequest({ ...entry, status: response.status, responseBody: "(SSE stream)" }); + logged = true; + return response; + } + const clone = response.clone(); + const responseBody = await clone.text().catch(() => ""); + logRequest({ ...entry, status: response.status, responseBody }); + if (!response.ok && response.status >= 500) { + const messageText = getHttpErrorMessage(response.status, response.statusText, responseBody); + window.dispatchEvent(new CustomEvent<string>(HTTP_ERROR_EVENT, { detail: messageText })); + } logged = true; return response; } catch (error) { @@ -240,11 +280,24 @@ export default function App() { } }; + let persist: SessionPersistDriver; + try { + persist = new IndexedDbSessionPersistDriver({ + databaseName: "sandbox-agent-inspector", + }); + } catch { + persist = new InMemorySessionPersistDriver({ + maxSessions: 512, + maxEventsPerSession: 5_000, + }); + } + const client = await SandboxAgent.connect({ baseUrl: targetEndpoint, token: token || undefined, fetch: fetchWithLog, - headers: Object.keys(extraHeaders).length > 0 ? extraHeaders : undefined + headers: Object.keys(extraHeaders).length > 0 ? extraHeaders : undefined, + persist, }); clientRef.current = client; return client; @@ -257,12 +310,64 @@ export default function App() { return clientRef.current; }, []); - const getErrorMessage = (error: unknown, fallback: string) => { - if (error instanceof SandboxAgentError) { - return error.problem?.detail ?? error.problem?.title ?? error.message; + const dismissErrorToast = useCallback((toastId: number) => { + const timeoutId = toastTimeoutsRef.current.get(toastId); + if (timeoutId != null) { + window.clearTimeout(timeoutId); + toastTimeoutsRef.current.delete(toastId); } - return error instanceof Error ? error.message : fallback; - }; + setErrorToasts((prev) => prev.filter((toast) => toast.id !== toastId)); + }, []); + + const pushErrorToast = useCallback((error: unknown, fallback: string) => { + const messageText = getErrorMessage(error, fallback).trim() || fallback; + const toastId = toastIdRef.current++; + setErrorToasts((prev) => { + if (prev.some((toast) => toast.message === messageText)) { + return prev; + } + return [...prev, { id: toastId, message: messageText }].slice(-MAX_ERROR_TOASTS); + }); + const timeoutId = window.setTimeout(() => { + dismissErrorToast(toastId); + }, ERROR_TOAST_MS); + toastTimeoutsRef.current.set(toastId, timeoutId); + }, [dismissErrorToast]); + + // Subscribe to events for the current active session + const subscribeToSession = useCallback((session: Session) => { + // Unsubscribe from previous + if (eventUnsubRef.current) { + eventUnsubRef.current(); + eventUnsubRef.current = null; + } + + activeSessionRef.current = session; + + // Hydrate existing events from persistence + const hydrateEvents = async () => { + const allEvents: SessionEvent[] = []; + let cursor: string | undefined; + while (true) { + const page = await getClient().getEvents({ + sessionId: session.id, + cursor, + limit: 250, + }); + allEvents.push(...page.items); + if (!page.nextCursor) break; + cursor = page.nextCursor; + } + setEvents(allEvents); + }; + hydrateEvents().catch(() => {}); + + // Subscribe to new events + const unsub = session.onEvent((event) => { + setEvents((prev) => [...prev, event]); + }); + eventUnsubRef.current = unsub; + }, [getClient]); const connectToDaemon = async (reportError: boolean, overrideEndpoint?: string) => { setConnecting(true); @@ -297,26 +402,24 @@ export default function App() { const connect = () => connectToDaemon(true); const disconnect = () => { + if (eventUnsubRef.current) { + eventUnsubRef.current(); + eventUnsubRef.current = null; + } + activeSessionRef.current = null; + if (clientRef.current) { + void clientRef.current.dispose(); + } setConnected(false); clientRef.current = null; setSessionError(null); setEvents([]); - setOffset(0); - offsetRef.current = 0; - setEventError(null); - stopPolling(); - stopSse(); - stopTurnStream(); setAgents([]); setSessions([]); - setModelsByAgent({}); - setDefaultModelByAgent({}); setAgentsLoading(false); setSessionsLoading(false); setAgentsError(null); setSessionsError(null); - setModelsLoadingByAgent({}); - setModelsErrorByAgent({}); }; const refreshAgents = async () => { @@ -324,14 +427,7 @@ export default function App() { setAgentsError(null); try { const data = await getClient().listAgents(); - const agentList = data.agents ?? []; - setAgents(agentList); - for (const agent of agentList) { - if (agent.installed) { - loadModes(agent.id); - loadModels(agent.id); - } - } + setAgents(data.agents ?? []); } catch (error) { setAgentsError(getErrorMessage(error, "Unable to refresh agents")); } finally { @@ -339,13 +435,39 @@ export default function App() { } }; + const loadAgentConfig = useCallback(async (targetAgentId: string) => { + try { + const info = await getClient().getAgent(targetAgentId, { config: true }); + setAgents((prev) => + prev.map((a) => (a.id === targetAgentId ? { ...a, configOptions: info.configOptions, configError: info.configError } : a)) + ); + } catch { + // Config loading is best-effort; the menu still works without it. + } + }, [getClient]); + const fetchSessions = async () => { setSessionsLoading(true); setSessionsError(null); try { - const data = await getClient().listSessions(); - const sessionList = data.sessions ?? []; - setSessions(sessionList); + // TODO: This eagerly paginates all sessions so we can reverse-sort to + // show newest first. Replace with a server-side descending sort or a + // dedicated "recent sessions" query once the API supports it. + const all: SessionListItem[] = []; + let cursor: string | undefined; + do { + const page = await getClient().listSessions({ cursor, limit: 200 }); + for (const s of page.items) { + all.push({ + sessionId: s.id, + agent: s.agent, + ended: s.destroyedAt != null, + }); + } + cursor = page.nextCursor; + } while (cursor); + all.reverse(); + setSessions(all); } catch { setSessionsError("Unable to load sessions."); } finally { @@ -362,258 +484,99 @@ export default function App() { } }; - const loadModes = async (targetId: string) => { - setModesLoadingByAgent((prev) => ({ ...prev, [targetId]: true })); - setModesErrorByAgent((prev) => ({ ...prev, [targetId]: null })); - try { - const data = await getClient().getAgentModes(targetId); - const modes = data.modes ?? []; - setModesByAgent((prev) => ({ ...prev, [targetId]: modes })); - } catch { - setModesErrorByAgent((prev) => ({ ...prev, [targetId]: "Unable to load modes." })); - } finally { - setModesLoadingByAgent((prev) => ({ ...prev, [targetId]: false })); - } - }; - - const loadModels = async (targetId: string) => { - setModelsLoadingByAgent((prev) => ({ ...prev, [targetId]: true })); - setModelsErrorByAgent((prev) => ({ ...prev, [targetId]: null })); - try { - const data = await getClient().getAgentModels(targetId); - const models = data.models ?? []; - setModelsByAgent((prev) => ({ ...prev, [targetId]: models })); - if (data.defaultModel) { - setDefaultModelByAgent((prev) => ({ ...prev, [targetId]: data.defaultModel! })); - } else { - setDefaultModelByAgent((prev) => { - const next = { ...prev }; - delete next[targetId]; - return next; - }); - } - } catch { - setModelsErrorByAgent((prev) => ({ ...prev, [targetId]: "Unable to load models." })); - } finally { - setModelsLoadingByAgent((prev) => ({ ...prev, [targetId]: false })); - } - }; - const sendMessage = async () => { const prompt = message.trim(); - if (!prompt || !sessionId || turnStreaming) return; + if (!prompt || !sessionId || sending) return; setSessionError(null); setMessage(""); - - if (streamMode === "turn") { - await startTurnStream(prompt); - return; - } + setSending(true); try { - await getClient().postMessage(sessionId, { message: prompt }); - if (!polling) { - if (streamMode === "poll") { - startPolling(); - } else { - startSse(); - } + let session = activeSessionRef.current; + if (!session || session.id !== sessionId) { + session = await getClient().resumeSession(sessionId); + subscribeToSession(session); } + await session.prompt([{ type: "text", text: prompt }]); } catch (error) { setSessionError(getErrorMessage(error, "Unable to send message")); + } finally { + setSending(false); } }; - const selectSession = (session: SessionInfo) => { - stopPolling(); - stopSse(); - stopTurnStream(); + const selectSession = async (session: SessionListItem) => { setSessionId(session.sessionId); updateSessionPath(session.sessionId); setAgentId(session.agent); setEvents([]); - setOffset(0); - offsetRef.current = 0; - setSessionError(null); - }; - - const createNewSession = async ( - nextAgentId: string, - config: { model: string; agentMode: string; permissionMode: string; variant: string } - ) => { - stopPolling(); - stopSse(); - stopTurnStream(); - setAgentId(nextAgentId); - if (parsedMcpConfig.error) { - setSessionError(parsedMcpConfig.error); - return; - } - const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; - let id = "session-"; - for (let i = 0; i < 8; i++) { - id += chars[Math.floor(Math.random() * chars.length)]; - } setSessionError(null); try { - const body: CreateSessionRequest = { agent: nextAgentId }; - if (config.agentMode) body.agentMode = config.agentMode; - if (config.permissionMode) body.permissionMode = config.permissionMode; - if (config.model) body.model = config.model; - if (config.variant) body.variant = config.variant; - if (parsedMcpConfig.count > 0) { - body.mcp = parsedMcpConfig.value; - } - if (parsedSkillsConfig.sources.length > 0) { - body.skills = parsedSkillsConfig; + const sdkSession = await getClient().resumeSession(session.sessionId); + subscribeToSession(sdkSession); + } catch (error) { + setSessionError(getErrorMessage(error, "Unable to load session")); + } + }; + + const createNewSession = async (nextAgentId: string, config: { agentMode: string; model: string }) => { + setAgentId(nextAgentId); + setSessionError(null); + setEvents([]); + + try { + const session = await getClient().createSession({ + agent: nextAgentId, + sessionInit: { + cwd: "/", + mcpServers: [], + }, + }); + + setSessionId(session.id); + updateSessionPath(session.id); + subscribeToSession(session); + + // Apply mode if selected + if (config.agentMode) { + try { + await session.send("session/set_mode", { modeId: config.agentMode }); + } catch { + // Mode application is best-effort + } + } + + // Apply model if selected + if (config.model) { + try { + await session.send("unstable/set_session_model", { modelId: config.model }); + } catch { + // Model application is best-effort + } } - await getClient().createSession(id, body); - setSessionId(id); - updateSessionPath(id); - setEvents([]); - setOffset(0); - offsetRef.current = 0; await fetchSessions(); } catch (error) { - setSessionError(getErrorMessage(error, "Unable to create session")); + const messageText = getErrorMessage(error, "Unable to create session"); + setSessionError(messageText); + pushErrorToast(error, messageText); } }; - const appendEvents = useCallback((incoming: UniversalEvent[]) => { - if (!incoming.length) return; - setEvents((prev) => [...prev, ...incoming]); - const lastSeq = incoming[incoming.length - 1]?.sequence ?? offsetRef.current; - offsetRef.current = lastSeq; - setOffset(lastSeq); - }, []); - - const fetchEvents = useCallback(async () => { + const endSession = async () => { if (!sessionId) return; - setEventsLoading(true); try { - const response = await getClient().getEvents(sessionId, { - offset: offsetRef.current, - limit: 200 - }); - const newEvents = response.events ?? []; - appendEvents(newEvents); - setEventError(null); + await getClient().destroySession(sessionId); + if (eventUnsubRef.current) { + eventUnsubRef.current(); + eventUnsubRef.current = null; + } + activeSessionRef.current = null; + await fetchSessions(); } catch (error) { - setEventError(getErrorMessage(error, "Unable to fetch events")); - } finally { - setEventsLoading(false); + setSessionError(getErrorMessage(error, "Unable to end session")); } - }, [appendEvents, getClient, sessionId]); - - const startPolling = () => { - stopSse(); - if (pollTimerRef.current) return; - setPolling(true); - fetchEvents(); - pollTimerRef.current = window.setInterval(fetchEvents, 500); - }; - - const stopPolling = () => { - if (pollTimerRef.current) { - window.clearInterval(pollTimerRef.current); - pollTimerRef.current = null; - } - setPolling(false); - }; - - const startSse = () => { - stopPolling(); - if (sseAbortRef.current) return; - if (!sessionId) { - setEventError("Select or create a session first."); - return; - } - setEventError(null); - setPolling(true); - const controller = new AbortController(); - sseAbortRef.current = controller; - const start = async () => { - try { - for await (const event of getClient().streamEvents( - sessionId, - { offset: offsetRef.current }, - controller.signal - )) { - appendEvents([event]); - } - } catch (error) { - if (controller.signal.aborted) { - return; - } - setEventError(getErrorMessage(error, "SSE connection error. Falling back to polling.")); - stopSse(); - startPolling(); - } finally { - if (sseAbortRef.current === controller) { - sseAbortRef.current = null; - setPolling(false); - } - } - }; - void start(); - }; - - const stopSse = () => { - if (sseAbortRef.current) { - sseAbortRef.current.abort(); - sseAbortRef.current = null; - } - setPolling(false); - }; - - const startTurnStream = async (prompt: string) => { - stopPolling(); - stopSse(); - if (turnAbortRef.current) return; - if (!sessionId) { - setEventError("Select or create a session first."); - return; - } - setEventError(null); - setTurnStreaming(true); - const controller = new AbortController(); - turnAbortRef.current = controller; - try { - for await (const event of getClient().streamTurn( - sessionId, - { message: prompt }, - undefined, - controller.signal - )) { - appendEvents([event]); - } - } catch (error) { - if (controller.signal.aborted) { - return; - } - setEventError(getErrorMessage(error, "Turn stream error.")); - } finally { - if (turnAbortRef.current === controller) { - turnAbortRef.current = null; - setTurnStreaming(false); - } - } - }; - - const stopTurnStream = () => { - if (turnAbortRef.current) { - turnAbortRef.current.abort(); - turnAbortRef.current = null; - } - setTurnStreaming(false); - }; - - const resetEvents = () => { - setEvents([]); - setOffset(0); - offsetRef.current = 0; }; const handleCopy = (entry: RequestLog) => { @@ -648,200 +611,266 @@ export default function App() { document.body.removeChild(textarea); }; - const selectQuestionOption = (requestId: string, optionLabel: string) => { - setQuestionSelections((prev) => ({ - ...prev, - [requestId]: [[optionLabel]] - })); - }; - - const answerQuestion = async (request: QuestionEventData) => { - const answers = questionSelections[request.question_id] ?? []; - try { - await getClient().replyQuestion(sessionId, request.question_id, { answers }); - setQuestionStatus((prev) => ({ ...prev, [request.question_id]: "replied" })); - } catch (error) { - setEventError(getErrorMessage(error, "Unable to reply")); - } - }; - - const rejectQuestion = async (requestId: string) => { - try { - await getClient().rejectQuestion(sessionId, requestId); - setQuestionStatus((prev) => ({ ...prev, [requestId]: "rejected" })); - } catch (error) { - setEventError(getErrorMessage(error, "Unable to reject")); - } - }; - - const replyPermission = async (requestId: string, reply: "once" | "always" | "reject") => { - try { - await getClient().replyPermission(sessionId, requestId, { reply }); - setPermissionStatus((prev) => ({ ...prev, [requestId]: "replied" })); - } catch (error) { - setEventError(getErrorMessage(error, "Unable to reply")); - } - }; - - const endSession = async () => { - if (!sessionId) return; - try { - await getClient().terminateSession(sessionId); - await fetchSessions(); - } catch (error) { - setSessionError(getErrorMessage(error, "Unable to end session")); - } - }; - - const questionRequests = useMemo(() => { - const latestById = new Map<string, QuestionEventData>(); - for (const event of events) { - if (event.type === "question.requested" || event.type === "question.resolved") { - const data = event.data as QuestionEventData; - latestById.set(data.question_id, data); - } - } - return Array.from(latestById.values()).filter( - (request) => request.status === "requested" && !questionStatus[request.question_id] - ); - }, [events, questionStatus]); - - const permissionRequests = useMemo(() => { - const latestById = new Map<string, PermissionEventData>(); - for (const event of events) { - if (event.type === "permission.requested" || event.type === "permission.resolved") { - const data = event.data as PermissionEventData; - latestById.set(data.permission_id, data); - } - } - return Array.from(latestById.values()).filter( - (request) => request.status === "requested" && !permissionStatus[request.permission_id] - ); - }, [events, permissionStatus]); - + // Build transcript entries from raw SessionEvents const transcriptEntries = useMemo(() => { const entries: TimelineEntry[] = []; - const itemMap = new Map<string, TimelineEntry>(); - const upsertItemEntry = (item: UniversalItem, time: string) => { - let entry = itemMap.get(item.item_id); - if (!entry) { - entry = { - id: item.item_id, - kind: "item", - time, - item, - deltaText: "" - }; - itemMap.set(item.item_id, entry); - entries.push(entry); - } else { - entry.item = item; - entry.time = time; + // Accumulators for streaming chunks + let assistantAccumId: string | null = null; + let assistantAccumText = ""; + let thoughtAccumId: string | null = null; + let thoughtAccumText = ""; + + const flushAssistant = (time: string) => { + if (assistantAccumId) { + const existing = entries.find((e) => e.id === assistantAccumId); + if (existing) { + existing.text = assistantAccumText; + existing.time = time; + } } - return entry; + assistantAccumId = null; + assistantAccumText = ""; }; + const flushThought = (time: string) => { + if (thoughtAccumId) { + const existing = entries.find((e) => e.id === thoughtAccumId); + if (existing && existing.reasoning) { + existing.reasoning.text = thoughtAccumText; + existing.time = time; + } + } + thoughtAccumId = null; + thoughtAccumText = ""; + }; + + // Track tool calls by ID for updates + const toolEntryMap = new Map<string, TimelineEntry>(); + for (const event of events) { - switch (event.type) { - case "item.started": { - const data = event.data as ItemEventData; - upsertItemEntry(data.item, event.time); - break; - } - case "item.delta": { - const data = event.data as ItemDeltaEventData; - const stub = buildStubItem(data.item_id, data.native_item_id); - const entry = upsertItemEntry(stub, event.time); - entry.deltaText = `${entry.deltaText ?? ""}${data.delta ?? ""}`; - break; - } - case "item.completed": { - const data = event.data as ItemEventData; - const entry = upsertItemEntry(data.item, event.time); - entry.deltaText = ""; - break; - } - case "error": { - const data = event.data as { message: string; code?: string | null }; - entries.push({ - id: event.event_id, - kind: "meta", - time: event.time, - meta: { - title: data.code ? `Error - ${data.code}` : "Error", - detail: data.message, - severity: "error" + const payload = event.payload as Record<string, unknown>; + const method = typeof payload.method === "string" ? payload.method : null; + const time = new Date(event.createdAt).toISOString(); + + if (event.sender === "client" && method === "session/prompt") { + // User message + flushAssistant(time); + flushThought(time); + const params = payload.params as Record<string, unknown> | undefined; + const promptArray = params?.prompt as Array<{ type: string; text?: string }> | undefined; + const text = promptArray?.[0]?.text ?? ""; + entries.push({ + id: event.id, + kind: "message", + time, + role: "user", + text, + }); + continue; + } + + if (event.sender === "agent" && method === "session/update") { + const params = payload.params as Record<string, unknown> | undefined; + const update = params?.update as Record<string, unknown> | undefined; + if (!update || typeof update.sessionUpdate !== "string") continue; + + switch (update.sessionUpdate) { + case "agent_message_chunk": { + const content = update.content as { type?: string; text?: string } | undefined; + if (content?.type === "text" && content.text) { + if (!assistantAccumId) { + assistantAccumId = `assistant-${event.id}`; + assistantAccumText = ""; + entries.push({ + id: assistantAccumId, + kind: "message", + time, + role: "assistant", + text: "", + }); + } + assistantAccumText += content.text; + const entry = entries.find((e) => e.id === assistantAccumId); + if (entry) { + entry.text = assistantAccumText; + entry.time = time; + } } - }); - break; - } - case "agent.unparsed": { - const data = event.data as { error: string; location: string }; - entries.push({ - id: event.event_id, - kind: "meta", - time: event.time, - meta: { - title: "Agent parse failure", - detail: `${data.location}: ${data.error}`, - severity: "error" + break; + } + case "agent_thought_chunk": { + const content = update.content as { type?: string; text?: string } | undefined; + if (content?.type === "text" && content.text) { + if (!thoughtAccumId) { + thoughtAccumId = `thought-${event.id}`; + thoughtAccumText = ""; + entries.push({ + id: thoughtAccumId, + kind: "reasoning", + time, + reasoning: { text: "", visibility: "public" }, + }); + } + thoughtAccumText += content.text; + const entry = entries.find((e) => e.id === thoughtAccumId); + if (entry && entry.reasoning) { + entry.reasoning.text = thoughtAccumText; + entry.time = time; + } } - }); - break; - } - case "session.started": { - entries.push({ - id: event.event_id, - kind: "meta", - time: event.time, - meta: { - title: "Session started", - severity: "info" + break; + } + case "user_message_chunk": { + const content = update.content as { type?: string; text?: string } | undefined; + const text = content?.type === "text" ? (content.text ?? "") : JSON.stringify(content); + entries.push({ + id: event.id, + kind: "message", + time, + role: "user", + text, + }); + break; + } + case "tool_call": { + flushAssistant(time); + flushThought(time); + const toolCallId = (update.toolCallId as string) ?? event.id; + const existing = toolEntryMap.get(toolCallId); + if (existing) { + // Update existing entry instead of creating a duplicate + if (update.status) existing.toolStatus = update.status as string; + if (update.rawInput != null) existing.toolInput = JSON.stringify(update.rawInput, null, 2); + if (update.rawOutput != null) existing.toolOutput = JSON.stringify(update.rawOutput, null, 2); + if (update.title) existing.toolName = update.title as string; + existing.time = time; + } else { + const entry: TimelineEntry = { + id: `tool-${toolCallId}`, + kind: "tool", + time, + toolName: (update.title as string) ?? "tool", + toolInput: update.rawInput != null ? JSON.stringify(update.rawInput, null, 2) : undefined, + toolOutput: update.rawOutput != null ? JSON.stringify(update.rawOutput, null, 2) : undefined, + toolStatus: (update.status as string) ?? "in_progress", + }; + toolEntryMap.set(toolCallId, entry); + entries.push(entry); } - }); - break; - } - case "session.ended": { - const data = event.data as { reason: string; terminated_by: string }; - entries.push({ - id: event.event_id, - kind: "meta", - time: event.time, - meta: { - title: "Session ended", - detail: `${data.reason} - ${data.terminated_by}`, - severity: "info" + break; + } + case "tool_call_update": { + const toolCallId = update.toolCallId as string; + const existing = toolEntryMap.get(toolCallId); + if (existing) { + if (update.status) existing.toolStatus = update.status as string; + if (update.rawOutput != null) existing.toolOutput = JSON.stringify(update.rawOutput, null, 2); + if (update.title) existing.toolName = (existing.toolName ?? "") + (update.title as string); + existing.time = time; } - }); - break; - } - case "turn.started": { - entries.push({ - id: event.event_id, - kind: "meta", - time: event.time, - meta: { - title: "Turn started", - severity: "info" + break; + } + case "plan": { + const planEntries = (update.entries as Array<{ content: string; status: string }>) ?? []; + const detail = planEntries.map((e) => `[${e.status}] ${e.content}`).join("\n"); + entries.push({ + id: event.id, + kind: "meta", + time, + meta: { title: "Plan", detail, severity: "info" }, + }); + break; + } + case "session_info_update": { + const title = update.title as string | undefined; + entries.push({ + id: event.id, + kind: "meta", + time, + meta: { title: "Session info update", detail: title ? `Title: ${title}` : undefined, severity: "info" }, + }); + break; + } + case "usage_update": { + const size = update.size as number | undefined; + const used = update.used as number | undefined; + const cost = update.cost as { total?: number } | undefined; + const parts = [`${used ?? 0}/${size ?? 0} tokens`]; + if (cost?.total != null) { + parts.push(`cost: $${cost.total.toFixed(4)}`); } - }); - break; + entries.push({ + id: event.id, + kind: "meta", + time, + meta: { title: "Usage update", detail: parts.join(" | "), severity: "info" }, + }); + break; + } + case "current_mode_update": { + entries.push({ + id: event.id, + kind: "meta", + time, + meta: { title: "Mode changed", detail: update.currentModeId as string, severity: "info" }, + }); + break; + } + case "config_option_update": { + entries.push({ + id: event.id, + kind: "meta", + time, + meta: { title: "Config option update", severity: "info" }, + }); + break; + } + case "available_commands_update": { + entries.push({ + id: event.id, + kind: "meta", + time, + meta: { title: "Available commands update", severity: "info" }, + }); + break; + } + default: { + entries.push({ + id: event.id, + kind: "meta", + time, + meta: { title: `session/update: ${update.sessionUpdate}`, severity: "info" }, + }); + break; + } } - case "turn.ended": { - entries.push({ - id: event.event_id, - kind: "meta", - time: event.time, - meta: { - title: "Turn ended", - severity: "info" - } - }); - break; - } - default: - break; + continue; + } + + if (event.sender === "agent" && method === "_sandboxagent/agent/unparsed") { + const params = payload.params as { error?: string; location?: string } | undefined; + entries.push({ + id: event.id, + kind: "meta", + time, + meta: { + title: "Agent parse failure", + detail: `${params?.location ?? "unknown"}: ${params?.error ?? "unknown error"}`, + severity: "error", + }, + }); + continue; + } + + // For any other ACP envelope, show as generic meta + if (method) { + entries.push({ + id: event.id, + kind: "meta", + time, + meta: { title: method, detail: event.sender, severity: "info" }, + }); } } @@ -850,9 +879,42 @@ export default function App() { useEffect(() => { return () => { - stopPolling(); - stopSse(); - stopTurnStream(); + if (eventUnsubRef.current) { + eventUnsubRef.current(); + eventUnsubRef.current = null; + } + }; + }, []); + + useEffect(() => { + const handleWindowError = (event: ErrorEvent) => { + pushErrorToast(event.error ?? event.message, "Unexpected error"); + }; + const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + pushErrorToast(event.reason, "Unhandled promise rejection"); + }; + const handleHttpError = (event: Event) => { + const detail = (event as CustomEvent<string>).detail; + if (typeof detail === "string" && detail.trim()) { + pushErrorToast(new Error(detail), detail); + } + }; + window.addEventListener("error", handleWindowError); + window.addEventListener("unhandledrejection", handleUnhandledRejection); + window.addEventListener(HTTP_ERROR_EVENT, handleHttpError); + return () => { + window.removeEventListener("error", handleWindowError); + window.removeEventListener("unhandledrejection", handleUnhandledRejection); + window.removeEventListener(HTTP_ERROR_EVENT, handleHttpError); + }; + }, [pushErrorToast]); + + useEffect(() => { + return () => { + for (const timeoutId of toastTimeoutsRef.current.values()) { + window.clearTimeout(timeoutId); + } + toastTimeoutsRef.current.clear(); }; }, []); @@ -861,7 +923,6 @@ export default function App() { const attempt = async () => { const { hasUrlParam } = initialConnectionRef.current; - // If URL param was provided, just try that endpoint (don't fall back) if (hasUrlParam) { try { await connectToDaemon(false); @@ -871,7 +932,6 @@ export default function App() { return; } - // No URL param: try current origin first const originEndpoint = getCurrentOriginEndpoint(); if (originEndpoint) { try { @@ -882,12 +942,10 @@ export default function App() { } } - // Fall back to localhost:2468 if (!active) return; try { await connectToDaemon(false, DEFAULT_ENDPOINT); } catch { - // Keep localhost:2468 as the default in the form setEndpoint(DEFAULT_ENDPOINT); } }; @@ -905,53 +963,72 @@ export default function App() { refreshAgents(); }, [connected]); + // Auto-load session when sessionId changes useEffect(() => { - if (!connected || !sessionId || polling) return; - if (streamMode === "turn") return; - const hasSession = sessions.some((session) => session.sessionId === sessionId); + if (!connected || !sessionId) return; + const hasSession = sessions.some((s) => s.sessionId === sessionId); if (!hasSession) return; - if (streamMode === "poll") { - startPolling(); - } else { - startSse(); - } - }, [connected, sessionId, polling, streamMode, sessions]); + if (activeSessionRef.current?.id === sessionId) return; - useEffect(() => { - if (streamMode === "turn") { - stopPolling(); - stopSse(); - } else if (turnStreaming) { - stopTurnStream(); - } - }, [streamMode, turnStreaming]); + getClient().resumeSession(sessionId).then((session) => { + subscribeToSession(session); + }).catch(() => {}); + }, [connected, sessionId, sessions, getClient, subscribeToSession]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [transcriptEntries]); const currentAgent = agents.find((agent) => agent.id === agentId); - const currentSessionInfo = sessions.find((s) => s.sessionId === sessionId); - const parsedMcpConfig = useMemo(() => buildMcpConfig(mcpServers), [mcpServers]); - const parsedSkillsConfig = useMemo(() => buildSkillsConfig(skillSources), [skillSources]); - const agentDisplayNames: Record<string, string> = { - claude: "Claude Code", - codex: "Codex", - opencode: "OpenCode", - amp: "Amp", - pi: "Pi", - mock: "Mock" - }; const agentLabel = agentDisplayNames[agentId] ?? agentId; + const sessionEnded = sessions.find((s) => s.sessionId === sessionId)?.ended ?? false; - const handleSelectAgent = useCallback((targetAgentId: string) => { - if (connected && !modesByAgent[targetAgentId]) { - loadModes(targetAgentId); + // Extract modes and models from configOptions + const modesByAgent = useMemo(() => { + const result: Record<string, AgentModeInfo[]> = {}; + for (const agent of agents) { + const options = (agent.configOptions ?? []) as ConfigOption[]; + for (const opt of options) { + if (opt.category === "mode" && opt.type === "select" && opt.options) { + result[agent.id] = flattenSelectOptions(opt.options).map((o) => ({ + id: o.value, + name: o.name, + description: o.description ?? "", + })); + } + } } - if (connected && !modelsByAgent[targetAgentId]) { - loadModels(targetAgentId); + return result; + }, [agents]); + + const modelsByAgent = useMemo(() => { + const result: Record<string, AgentModelInfo[]> = {}; + for (const agent of agents) { + const options = (agent.configOptions ?? []) as ConfigOption[]; + for (const opt of options) { + if (opt.category === "model" && opt.type === "select" && opt.options) { + result[agent.id] = flattenSelectOptions(opt.options).map((o) => ({ + id: o.value, + name: o.name, + })); + } + } } - }, [connected, modesByAgent, modelsByAgent]); + return result; + }, [agents]); + + const defaultModelByAgent = useMemo(() => { + const result: Record<string, string> = {}; + for (const agent of agents) { + const options = (agent.configOptions ?? []) as ConfigOption[]; + for (const opt of options) { + if (opt.category === "model" && opt.type === "select" && opt.currentValue) { + result[agent.id] = opt.currentValue; + } + } + } + return result; + }, [agents]); const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => { if (event.key === "Enter" && !event.shiftKey) { @@ -960,35 +1037,44 @@ export default function App() { } }; - const toggleStream = () => { - if (streamMode === "turn") { - return; - } - if (polling) { - if (streamMode === "poll") { - stopPolling(); - } else { - stopSse(); - } - } else if (streamMode === "poll") { - startPolling(); - } else { - startSse(); - } - }; + const toastStack = ( + <div className="toast-stack" aria-live="assertive" aria-atomic="false"> + {errorToasts.map((toast) => ( + <div key={toast.id} className="toast error" role="status"> + <div className="toast-content"> + <div className="toast-title">Request failed</div> + <div className="toast-message">{toast.message}</div> + </div> + <button + type="button" + className="toast-close" + aria-label="Dismiss error" + onClick={() => dismissErrorToast(toast.id)} + > + x + </button> + </div> + ))} + </div> + ); if (!connected) { return ( - <ConnectScreen - endpoint={endpoint} - token={token} - connectError={connectError} - connecting={connecting} - onEndpointChange={setEndpoint} - onTokenChange={setToken} - onConnect={connect} - reportUrl={issueTrackerUrl} - /> + <> + <ConnectScreen + endpoint={endpoint} + token={token} + connectError={connectError} + connecting={connecting} + onEndpointChange={setEndpoint} + onTokenChange={setToken} + onConnect={connect} + reportUrl={issueTrackerUrl} + docsUrl={docsUrl} + discordUrl={discordUrl} + /> + {toastStack} + </> ); } @@ -999,8 +1085,17 @@ export default function App() { <img src={logoUrl} alt="Sandbox Agent" className="logo-text" style={{ height: '20px', width: 'auto' }} /> </div> <div className="header-right"> - <a className="button ghost small" href={issueTrackerUrl} target="_blank" rel="noreferrer"> - Report Bug + <a className="header-link" href={docsUrl} target="_blank" rel="noreferrer"> + <BookOpen size={12} /> + Docs + </a> + <a className="header-link" href={discordUrl} target="_blank" rel="noreferrer"> + <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg> + Discord + </a> + <a className="header-link" href={issueTrackerUrl} target="_blank" rel="noreferrer"> + <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg> + Issues </a> <span className="header-endpoint">{endpoint}</span> <button className="button secondary small" onClick={disconnect}> @@ -1016,8 +1111,13 @@ export default function App() { onSelectSession={selectSession} onRefresh={fetchSessions} onCreateSession={createNewSession} - onSelectAgent={handleSelectAgent} - agents={agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, capabilities: {} }) as AgentInfo)} + onSelectAgent={loadAgentConfig} + agents={agents.length ? agents : defaultAgents.map((id) => ({ + id, + installed: false, + credentialsAvailable: true, + capabilities: {} as AgentInfo["capabilities"], + }))} agentsLoading={agentsLoading} agentsError={agentsError} sessionsLoading={sessionsLoading} @@ -1025,15 +1125,6 @@ export default function App() { modesByAgent={modesByAgent} modelsByAgent={modelsByAgent} defaultModelByAgent={defaultModelByAgent} - modesLoadingByAgent={modesLoadingByAgent} - modelsLoadingByAgent={modelsLoadingByAgent} - modesErrorByAgent={modesErrorByAgent} - modelsErrorByAgent={modelsErrorByAgent} - mcpServers={mcpServers} - onMcpServersChange={setMcpServers} - mcpConfigError={parsedMcpConfig.error} - skillSources={skillSources} - onSkillSourcesChange={setSkillSources} /> <ChatPanel @@ -1045,48 +1136,30 @@ export default function App() { onSendMessage={sendMessage} onKeyDown={handleKeyDown} onCreateSession={createNewSession} - onSelectAgent={handleSelectAgent} - agents={agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, capabilities: {} }) as AgentInfo)} + onSelectAgent={loadAgentConfig} + agents={agents.length ? agents : defaultAgents.map((id) => ({ + id, + installed: false, + credentialsAvailable: true, + capabilities: {} as AgentInfo["capabilities"], + }))} agentsLoading={agentsLoading} agentsError={agentsError} messagesEndRef={messagesEndRef} agentLabel={agentLabel} currentAgentVersion={currentAgent?.version ?? null} - sessionModel={currentSessionInfo?.model ?? null} - sessionVariant={currentSessionInfo?.variant ?? null} - sessionPermissionMode={currentSessionInfo?.permissionMode ?? null} - sessionMcpServerCount={currentSessionInfo?.mcp ? Object.keys(currentSessionInfo.mcp).length : 0} - sessionSkillSourceCount={currentSessionInfo?.skills?.sources?.length ?? 0} + sessionEnded={sessionEnded} onEndSession={endSession} - eventError={eventError} - questionRequests={questionRequests} - permissionRequests={permissionRequests} - questionSelections={questionSelections} - onSelectQuestionOption={selectQuestionOption} - onAnswerQuestion={answerQuestion} - onRejectQuestion={rejectQuestion} - onReplyPermission={replyPermission} modesByAgent={modesByAgent} modelsByAgent={modelsByAgent} defaultModelByAgent={defaultModelByAgent} - modesLoadingByAgent={modesLoadingByAgent} - modelsLoadingByAgent={modelsLoadingByAgent} - modesErrorByAgent={modesErrorByAgent} - modelsErrorByAgent={modelsErrorByAgent} - mcpServers={mcpServers} - onMcpServersChange={setMcpServers} - mcpConfigError={parsedMcpConfig.error} - skillSources={skillSources} - onSkillSourcesChange={setSkillSources} /> <DebugPanel debugTab={debugTab} onDebugTabChange={setDebugTab} events={events} - offset={offset} - onResetEvents={resetEvents} - eventsError={eventError} + onResetEvents={() => setEvents([])} requestLog={requestLog} copiedLogId={copiedLogId} onClearRequestLog={() => setRequestLog([])} @@ -1098,8 +1171,10 @@ export default function App() { onInstallAgent={installAgent} agentsLoading={agentsLoading} agentsError={agentsError} + getClient={getClient} /> </main> + {toastStack} </div> ); } diff --git a/frontend/packages/inspector/src/components/SessionSidebar.tsx b/frontend/packages/inspector/src/components/SessionSidebar.tsx index ed4e19a..d45df21 100644 --- a/frontend/packages/inspector/src/components/SessionSidebar.tsx +++ b/frontend/packages/inspector/src/components/SessionSidebar.tsx @@ -1,17 +1,26 @@ import { Plus, RefreshCw } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import type { AgentInfo, AgentModelInfo, AgentModeInfo, SessionInfo, SkillSource } from "sandbox-agent"; -import type { McpServerEntry } from "../App"; +import type { AgentInfo } from "sandbox-agent"; + +type AgentModeInfo = { id: string; name: string; description: string }; +type AgentModelInfo = { id: string; name?: string }; import SessionCreateMenu, { type SessionConfig } from "./SessionCreateMenu"; +type SessionListItem = { + sessionId: string; + agent: string; + ended: boolean; +}; + const agentLabels: Record<string, string> = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", amp: "Amp", pi: "Pi", - mock: "Mock" + cursor: "Cursor" }; +const persistenceDocsUrl = "https://sandboxagent.dev/docs/session-persistence"; const SessionSidebar = ({ sessions, @@ -28,22 +37,13 @@ const SessionSidebar = ({ modesByAgent, modelsByAgent, defaultModelByAgent, - modesLoadingByAgent, - modelsLoadingByAgent, - modesErrorByAgent, - modelsErrorByAgent, - mcpServers, - onMcpServersChange, - mcpConfigError, - skillSources, - onSkillSourcesChange }: { - sessions: SessionInfo[]; + sessions: SessionListItem[]; selectedSessionId: string; - onSelectSession: (session: SessionInfo) => void; + onSelectSession: (session: SessionListItem) => void; onRefresh: () => void; onCreateSession: (agentId: string, config: SessionConfig) => void; - onSelectAgent: (agentId: string) => void; + onSelectAgent: (agentId: string) => Promise<void>; agents: AgentInfo[]; agentsLoading: boolean; agentsError: string | null; @@ -52,15 +52,6 @@ const SessionSidebar = ({ modesByAgent: Record<string, AgentModeInfo[]>; modelsByAgent: Record<string, AgentModelInfo[]>; defaultModelByAgent: Record<string, string>; - modesLoadingByAgent: Record<string, boolean>; - modelsLoadingByAgent: Record<string, boolean>; - modesErrorByAgent: Record<string, string | null>; - modelsErrorByAgent: Record<string, string | null>; - mcpServers: McpServerEntry[]; - onMcpServersChange: (servers: McpServerEntry[]) => void; - mcpConfigError: string | null; - skillSources: SkillSource[]; - onSkillSourcesChange: (sources: SkillSource[]) => void; }) => { const [showMenu, setShowMenu] = useState(false); const menuRef = useRef<HTMLDivElement | null>(null); @@ -100,17 +91,8 @@ const SessionSidebar = ({ modesByAgent={modesByAgent} modelsByAgent={modelsByAgent} defaultModelByAgent={defaultModelByAgent} - modesLoadingByAgent={modesLoadingByAgent} - modelsLoadingByAgent={modelsLoadingByAgent} - modesErrorByAgent={modesErrorByAgent} - modelsErrorByAgent={modelsErrorByAgent} - mcpServers={mcpServers} - onMcpServersChange={onMcpServersChange} - mcpConfigError={mcpConfigError} - skillSources={skillSources} - onSkillSourcesChange={onSkillSourcesChange} - onSelectAgent={onSelectAgent} onCreateSession={onCreateSession} + onSelectAgent={onSelectAgent} open={showMenu} onClose={() => setShowMenu(false)} /> @@ -135,13 +117,19 @@ const SessionSidebar = ({ <div className="session-item-id">{session.sessionId}</div> <div className="session-item-meta"> <span className="session-item-agent">{agentLabels[session.agent] ?? session.agent}</span> - <span className="session-item-events">{session.eventCount} events</span> {session.ended && <span className="session-item-ended">ended</span>} </div> </button> )) )} </div> + <div className="session-persistence-note"> + Sessions are persisted in your browser using IndexedDB.{" "} + <a href={persistenceDocsUrl} target="_blank" rel="noreferrer"> + Configure persistence + </a> + . + </div> </div> ); }; diff --git a/frontend/packages/website/src/components/Hero.tsx b/frontend/packages/website/src/components/Hero.tsx index 9f12fd0..873c166 100644 --- a/frontend/packages/website/src/components/Hero.tsx +++ b/frontend/packages/website/src/components/Hero.tsx @@ -1,15 +1,13 @@ 'use client'; import { useState, useEffect } from 'react'; -import { motion } from 'framer-motion'; import { Terminal, Check, ArrowRight } from 'lucide-react'; -const ADAPTERS = [ - { label: 'Claude Code', color: '#D97757', x: 20, y: 70, logo: '/logos/claude.svg' }, - { label: 'Codex', color: '#10A37F', x: 132, y: 70, logo: 'openai' }, - { label: 'Pi', color: '#06B6D4', x: 244, y: 70, logo: 'pi' }, - { label: 'Amp', color: '#F59E0B', x: 76, y: 155, logo: '/logos/amp.svg' }, - { label: 'OpenCode', color: '#8B5CF6', x: 188, y: 155, logo: 'opencode' }, +const AGENT_PROCESSES = [ + { label: 'Claude Code', color: '#D97757', x: 35, y: 70, logo: '/logos/claude.svg' }, + { label: 'Codex', color: '#10A37F', x: 185, y: 70, logo: 'openai' }, + { label: 'Amp', color: '#F59E0B', x: 35, y: 155, logo: '/logos/amp.svg' }, + { label: 'OpenCode', color: '#8B5CF6', x: 185, y: 155, logo: 'opencode' }, ]; function UniversalAPIDiagram() { @@ -17,22 +15,29 @@ function UniversalAPIDiagram() { useEffect(() => { const interval = setInterval(() => { - setActiveIndex((prev) => (prev + 1) % ADAPTERS.length); + setActiveIndex((prev) => (prev + 1) % AGENT_PROCESSES.length); }, 2000); return () => clearInterval(interval); }, []); return ( - <div className="relative w-full aspect-[16/9] bg-[#050505] rounded-2xl border border-white/10 overflow-hidden flex items-center justify-center shadow-2xl"> - {/* Background Dots - color changes with active adapter */} + <div className="relative w-full aspect-[16/9] bg-[#050505] rounded-xl border border-white/10 overflow-hidden flex items-center justify-center"> + {/* Background Grid */} <div - className="absolute inset-0 opacity-[0.15] pointer-events-none transition-all duration-1000" + className="absolute inset-0 opacity-[0.03] pointer-events-none" style={{ - backgroundImage: `radial-gradient(circle, ${ADAPTERS[activeIndex].color} 1px, transparent 1px)`, - backgroundSize: '24px 24px', + backgroundImage: + 'linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)', + backgroundSize: '40px 40px', }} /> + {/* Dynamic Background Glow */} + <div + className="absolute top-1/2 right-1/4 -translate-y-1/2 w-64 h-64 blur-[100px] rounded-full transition-colors duration-1000 opacity-20" + style={{ backgroundColor: AGENT_PROCESSES[activeIndex].color }} + /> + <svg viewBox="0 0 800 450" className="w-full h-full relative z-10"> <defs> <filter id="glow" x="-20%" y="-20%" width="140%" height="140%"> @@ -44,14 +49,13 @@ function UniversalAPIDiagram() { </filter> </defs> - {/* YOUR APP NODE - Glass dark effect with backdrop blur */} - <foreignObject x="60" y="175" width="180" height="100"> - <div - className="w-full h-full rounded-2xl border border-white/10 bg-black/40 backdrop-blur-md flex items-center justify-center" - > - <span className="text-white text-xl font-bold">Your App</span> - </div> - </foreignObject> + {/* YOUR APP NODE */} + <g transform="translate(60, 175)"> + <rect width="180" height="100" rx="16" fill="#0A0A0A" stroke="#333" strokeWidth="2" /> + <text x="90" y="55" fill="#FFFFFF" textAnchor="middle" fontSize="20" fontWeight="700"> + Your App + </text> + </g> {/* HTTP/SSE LINE */} <g> @@ -69,60 +73,55 @@ function UniversalAPIDiagram() { </text> </g> - {/* SANDBOX BOUNDARY - Glass dark effect with backdrop blur */} - <foreignObject x="360" y="45" width="410" height="360"> - <div className="w-full h-full rounded-3xl border border-white/10 bg-black/40 backdrop-blur-md"> - <div className="text-white text-sm font-extrabold tracking-[0.2em] text-center pt-4"> - SANDBOX - </div> - </div> - </foreignObject> + {/* SANDBOX BOUNDARY */} + <g transform="translate(360, 45)"> + <rect width="380" height="360" rx="24" fill="#080808" stroke="#333" strokeWidth="1.5" /> + <rect width="380" height="45" rx="12" fill="rgba(255,255,255,0.02)" /> + <text x="190" y="28" fill="#FFFFFF" textAnchor="middle" fontSize="14" fontWeight="800" letterSpacing="0.2em"> + SANDBOX + </text> - {/* SANDBOX AGENT SDK */} - <g transform="translate(385, 110)"> - <rect width="360" height="270" rx="20" fill="rgba(0,0,0,0.4)" stroke="rgba(255,255,255,0.2)" strokeWidth="1" /> - <text x="180" y="35" fill="#FFFFFF" textAnchor="middle" fontSize="18" fontWeight="800"> + {/* SANDBOX AGENT SDK */} + <g transform="translate(25, 65)"> + <rect width="330" height="270" rx="20" fill="#0D0D0F" stroke="#3B82F6" strokeWidth="2" /> + <text x="165" y="35" fill="#FFFFFF" textAnchor="middle" fontSize="18" fontWeight="800"> Sandbox Agent Server </text> + <line x1="40" y1="50" x2="290" y2="50" stroke="#333" strokeWidth="1" /> - {/* PROVIDER ADAPTERS */} - {ADAPTERS.map((p, i) => { + {/* PROVIDER AGENT PROCESSES */} + {AGENT_PROCESSES.map((p, i) => { const isActive = i === activeIndex; return ( <g key={i} transform={`translate(${p.x}, ${p.y})`}> <rect - width="95" - height="58" - rx="10" + width="110" + height="65" + rx="12" fill={isActive ? '#1A1A1E' : '#111'} stroke={isActive ? p.color : '#333'} strokeWidth={isActive ? 2 : 1.5} /> <g opacity={isActive ? 1 : 0.4}> {p.logo === 'openai' ? ( - <svg x="36.75" y="8" width="22" height="22" viewBox="0 0 24 24" fill="none"> - <path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" fill="#ffffff" /> + <svg x="43" y="10" width="24" height="24" viewBox="0 0 24 24" fill="none"> + <path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v 2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v 2.9994l-2.5974 1.4997-2.6067-1.4997Z" fill="#ffffff" /> </svg> ) : p.logo === 'opencode' ? ( - <svg x="38.5" y="8" width="17" height="22" viewBox="0 0 32 40" fill="none"> + <svg x="43" y="10" width="19" height="24" viewBox="0 0 32 40" fill="none"> <path d="M24 32H8V16H24V32Z" fill="#4B4646"/> <path d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#F1ECEC"/> </svg> - ) : p.logo === 'pi' ? ( - <svg x="36.75" y="8" width="22" height="22" viewBox="0 0 800 800" fill="none"> - <path fill="#fff" fillRule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"/> - <path fill="#fff" d="M517.36 400H634.72V634.72H517.36Z"/> - </svg> ) : ( - <image href={p.logo} x="36.75" y="8" width="22" height="22" filter="url(#invert-white)" /> + <image href={p.logo} x="43" y="10" width="24" height="24" filter="url(#invert-white)" /> )} </g> <text - x="47.5" - y="46" + x="55" + y="52" fill="#FFFFFF" textAnchor="middle" - fontSize="10" + fontSize="11" fontWeight="600" opacity={isActive ? 1 : 0.4} > @@ -134,18 +133,19 @@ function UniversalAPIDiagram() { {/* Active Agent Label */} <text - x="180" + x="165" y="250" - fill={ADAPTERS[activeIndex].color} + fill={AGENT_PROCESSES[activeIndex].color} textAnchor="middle" - fontSize="12" + fontSize="10" fontWeight="800" fontFamily="monospace" letterSpacing="0.1em" > - CONNECTED TO {ADAPTERS[activeIndex].label.toUpperCase()} + CONNECTED TO {AGENT_PROCESSES[activeIndex].label.toUpperCase()} </text> </g> + </g> </svg> </div> ); @@ -166,104 +166,47 @@ const CopyInstallButton = () => { }; return ( - <div className="relative group w-full sm:w-auto"> - <button - onClick={handleCopy} - className="w-full sm:w-auto inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md border border-white/10 px-4 py-2 text-sm text-zinc-300 transition-colors hover:border-white/20 hover:text-white font-mono" - > - {copied ? <Check className="h-4 w-4 text-green-400" /> : <Terminal className="h-4 w-4" />} - {installCommand} - </button> - <div className="absolute left-1/2 -translate-x-1/2 top-full mt-3 opacity-0 translate-y-2 group-hover:opacity-100 group-hover:translate-y-0 transition-all duration-200 ease-out text-xs text-zinc-500 whitespace-nowrap pointer-events-none font-mono"> - Give this to your coding agent - </div> - </div> + <button + onClick={handleCopy} + className='inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md border border-white/10 bg-white/5 px-4 py-2 text-sm text-white subpixel-antialiased shadow-sm transition-colors hover:border-white/20' + > + {copied ? <Check className='h-4 w-4' /> : <Terminal className='h-4 w-4' />} + {installCommand} + </button> ); }; export function Hero() { - const [scrollOpacity, setScrollOpacity] = useState(1); - - useEffect(() => { - const handleScroll = () => { - const scrollY = window.scrollY; - const windowHeight = window.innerHeight; - const isMobile = window.innerWidth < 1024; - - const fadeStart = windowHeight * (isMobile ? 0.3 : 0.15); - const fadeEnd = windowHeight * (isMobile ? 0.7 : 0.5); - const opacity = 1 - Math.min(1, Math.max(0, (scrollY - fadeStart) / (fadeEnd - fadeStart))); - setScrollOpacity(opacity); - }; - - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); - }, []); - return ( - <section className="relative flex min-h-screen flex-col overflow-hidden"> - {/* Background gradient */} - <div className="absolute inset-0 bg-gradient-to-b from-zinc-900/20 via-transparent to-transparent pointer-events-none" /> + <section className="relative pt-44 pb-24 overflow-hidden"> + <div className="max-w-7xl mx-auto px-6 relative z-10"> + <div className="flex flex-col lg:flex-row items-center gap-16"> + <div className="flex-1 text-center lg:text-left"> + <h1 className="mb-6 text-3xl font-medium leading-[1.1] tracking-tight text-white sm:text-4xl md:text-5xl lg:text-6xl"> + Run Coding Agents in Sandboxes.<br /> + <span className="text-zinc-400">Control Them Over HTTP.</span> + </h1> + <p className="mt-6 text-lg text-zinc-500 leading-relaxed max-w-xl mx-auto lg:mx-0"> + The Sandbox Agent SDK is a server that runs inside your sandbox. Your app connects remotely to control Claude Code, Codex, OpenCode, or Amp — streaming events, handling permissions, managing sessions. + </p> - {/* Main content */} - <div - className="flex flex-1 flex-col justify-start pt-32 lg:justify-center lg:pt-0 lg:pb-20 px-6" - style={{ opacity: scrollOpacity, filter: `blur(${(1 - scrollOpacity) * 8}px)` }} - > - <div className="mx-auto w-full max-w-7xl"> - <div className="flex flex-col gap-12 lg:flex-row lg:items-center lg:justify-between lg:gap-16 xl:gap-24"> - {/* Left side - Text content */} - <div className="max-w-xl lg:max-w-2xl"> - <motion.h1 - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.5 }} - className="mb-6 text-3xl font-medium leading-[1.1] tracking-tight text-white md:text-5xl" + <div className="mt-10 flex flex-col items-center gap-4 sm:flex-row sm:justify-center lg:justify-start"> + <a + href="/docs" + className='inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md border border-white/10 bg-white px-4 py-2 text-sm text-black subpixel-antialiased shadow-sm transition-colors hover:bg-zinc-200' > - Run Coding Agents in Sandboxes. - <br /> - <span className="text-zinc-400">Control Them Over HTTP.</span> - </motion.h1> - - <motion.p - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.5, delay: 0.1 }} - className="mb-8 text-lg text-zinc-500 leading-relaxed" - > - The Sandbox Agent SDK is a server that runs inside your sandbox. Your app connects remotely to control Claude Code, Codex, OpenCode, Amp, or Pi — streaming events, handling permissions, managing sessions. - </motion.p> - - <motion.div - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.5, delay: 0.2 }} - className="flex flex-col gap-3 sm:flex-row" - > - <a - href="/docs" - className="selection-dark inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md bg-white px-5 py-2.5 text-sm font-medium text-black transition-colors hover:bg-zinc-200" - > - Read the Docs - <ArrowRight className="h-4 w-4" /> - </a> - <CopyInstallButton /> - </motion.div> + Read the Docs + <ArrowRight className='h-4 w-4' /> + </a> + <CopyInstallButton /> </div> + </div> - {/* Right side - Diagram */} - <motion.div - initial={{ opacity: 0, x: 20 }} - animate={{ opacity: 1, x: 0 }} - transition={{ duration: 0.8, delay: 0.3 }} - className="flex-1 w-full max-w-2xl" - > - <UniversalAPIDiagram /> - </motion.div> + <div className="flex-1 w-full max-w-2xl"> + <UniversalAPIDiagram /> </div> </div> </div> - </section> ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 275a1f3..21a0a12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,9 @@ importers: computesdk: specifier: latest version: 2.2.0 + sandbox-agent: + specifier: workspace:* + version: link:../../sdks/typescript devDependencies: '@types/node': specifier: latest @@ -228,6 +231,15 @@ importers: specifier: latest version: 5.9.3 + examples/mock-acp-agent: + devDependencies: + '@types/node': + specifier: latest + version: 25.2.3 + typescript: + specifier: latest + version: 5.9.3 + examples/shared: dependencies: dockerode: @@ -315,6 +327,9 @@ importers: frontend/packages/inspector: dependencies: + '@sandbox-agent/persist-indexeddb': + specifier: workspace:* + version: link:../../../sdks/persist-indexeddb lucide-react: specifier: ^0.469.0 version: 0.469.0(react@18.3.1) @@ -334,6 +349,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.1 version: 4.7.0(vite@5.4.21(@types/node@25.2.3)) + fake-indexeddb: + specifier: ^6.2.4 + version: 6.2.5 sandbox-agent: specifier: workspace:* version: link:../../../sdks/typescript @@ -343,6 +361,9 @@ importers: vite: specifier: ^5.4.7 version: 5.4.21(@types/node@25.2.3) + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) frontend/packages/website: dependencies: @@ -381,34 +402,6 @@ importers: specifier: ^5.7.0 version: 5.9.3 - resources/vercel-ai-sdk-schemas: - dependencies: - semver: - specifier: ^7.6.3 - version: 7.7.3 - tar: - specifier: ^7.0.0 - version: 7.5.6 - ts-json-schema-generator: - specifier: ^2.4.0 - version: 2.4.0 - typescript: - specifier: ^5.7.0 - version: 5.9.3 - devDependencies: - '@types/json-schema': - specifier: ^7.0.15 - version: 7.0.15 - '@types/node': - specifier: ^22.0.0 - version: 22.19.7 - '@types/semver': - specifier: ^7.5.0 - version: 7.7.1 - tsx: - specifier: ^4.19.0 - version: 4.21.0 - scripts/release: dependencies: commander: @@ -553,6 +546,94 @@ importers: sdks/gigacode/platforms/win32-x64: {} + sdks/persist-indexeddb: + dependencies: + sandbox-agent: + specifier: workspace:* + version: link:../typescript + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.7 + fake-indexeddb: + specifier: ^6.2.4 + version: 6.2.5 + tsup: + specifier: ^8.0.0 + version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + + sdks/persist-postgres: + dependencies: + pg: + specifier: ^8.16.3 + version: 8.18.0 + sandbox-agent: + specifier: workspace:* + version: link:../typescript + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.7 + '@types/pg': + specifier: ^8.15.6 + version: 8.16.0 + tsup: + specifier: ^8.0.0 + version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + + sdks/persist-rivet: + dependencies: + rivetkit: + specifier: '>=0.5.0' + version: 2.0.42(@hono/node-server@1.19.9(hono@4.11.8))(@standard-schema/spec@1.0.0)(ws@8.19.0) + sandbox-agent: + specifier: workspace:* + version: link:../typescript + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.7 + tsup: + specifier: ^8.0.0 + version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + + sdks/persist-sqlite: + dependencies: + sandbox-agent: + specifier: workspace:* + version: link:../typescript + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.7 + tsup: + specifier: ^8.0.0 + version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + sdks/typescript: dependencies: '@sandbox-agent/cli-shared': @@ -593,6 +674,11 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asteasolutions/zod-to-openapi@8.4.0': + resolution: {integrity: sha512-Ckp971tmTw4pnv+o7iK85ldBHBKk6gxMaoNyLn3c2Th/fKoTG8G3jdYuOanpdGqwlDB0z01FOjry2d32lfTqrA==} + peerDependencies: + zod: ^4.0.0 + '@astrojs/compiler@2.13.0': resolution: {integrity: sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==} @@ -907,6 +993,36 @@ packages: resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} engines: {node: '>=18'} + '@cbor-extract/cbor-extract-darwin-arm64@2.2.0': + resolution: {integrity: sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.0': + resolution: {integrity: sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.0': + resolution: {integrity: sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.0': + resolution: {integrity: sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.0': + resolution: {integrity: sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.0': + resolution: {integrity: sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==} + cpu: [x64] + os: [win32] + '@cloudflare/containers@0.0.30': resolution: {integrity: sha512-i148xBgmyn/pje82ZIyuTr/Ae0BT/YWwa1/GTJcw6DxEjUHAzZLaBCiX446U9OeuJ2rBh/L/9FIzxX5iYNt1AQ==} @@ -1787,6 +1903,25 @@ packages: peerDependencies: hono: ^4 + '@hono/standard-validator@0.1.5': + resolution: {integrity: sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w==} + peerDependencies: + '@standard-schema/spec': 1.0.0 + hono: '>=3.9.0' + + '@hono/zod-openapi@1.2.1': + resolution: {integrity: sha512-aZza4V8wkqpdHBWFNPiCeWd0cGOXbYuQW9AyezHs/jwQm5p67GkUyXwfthAooAwnG7thTpvOJkThZpCoY6us8w==} + engines: {node: '>=16.0.0'} + peerDependencies: + hono: '>=4.3.6' + zod: ^4.0.0 + + '@hono/zod-validator@0.7.6': + resolution: {integrity: sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw==} + peerDependencies: + hono: '>=3.9.0' + zod: ^3.25.0 || ^4.0.0 + '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} @@ -1990,6 +2125,9 @@ packages: '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2033,6 +2171,26 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@rivetkit/bare-ts@0.6.2': + resolution: {integrity: sha512-3qndQUQXLdwafMEqfhz24hUtDPcsf1Bu3q52Kb8MqeH8JUh3h6R4HYW3ZJXiQsLcyYyFM68PuIwlLRlg1xDEpg==} + engines: {node: ^14.18.0 || >=16.0.0} + + '@rivetkit/engine-runner-protocol@2.0.42': + resolution: {integrity: sha512-gzo3J5NWS3sTIxPKTPTM8IIZuTfkHaMaAe9WI/WGO1/kYhh5y4oZ6WcyMicQlK5W6XmtYC3q0IqbNmxMYfHA1w==} + + '@rivetkit/engine-runner@2.0.42': + resolution: {integrity: sha512-diHBqZoE97F7Pd1yqY/qWpMCzHH0ddJt8YRUf7qwjjwqUdPF21wPsVOwcmvRArpd2JqEtUw+8CFKFB+pnqmX+A==} + + '@rivetkit/fast-json-patch@3.1.2': + resolution: {integrity: sha512-CtA50xgsSSzICQduF/NDShPRzvucnNvsW/lQO0WgMTT1XAj9Lfae4pm7r3llFwilgG+9iq76Hv1LUqNy72v6yw==} + + '@rivetkit/on-change@6.0.2-rc.1': + resolution: {integrity: sha512-5RC9Ze/wTKqSlJvopdCgr+EfyV93+iiH8Thog0QXrl8PT1unuBNw/jadXNMtwgAxrIaCJL+JLaHQH9w7rqpMDw==} + engines: {node: '>=20'} + + '@rivetkit/virtual-websocket@2.0.33': + resolution: {integrity: sha512-sMoHZgBy9WDW76pv+ML3LPgf7TWk5vXdu3ZpPO20j6n+rB3fLacnnmzjt5xD6tZcJ/x5qINyEywGgcxA7MTMuQ==} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -2414,6 +2572,9 @@ packages: '@speed-highlight/core@1.2.14': resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2447,9 +2608,6 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -2474,6 +2632,9 @@ packages: '@types/node@25.2.3': resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -2485,6 +2646,9 @@ packages: '@types/react@18.3.27': resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} + '@types/retry@0.12.2': + resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -2620,6 +2784,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + autoprefixer@10.4.23: resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==} engines: {node: ^10 || ^12 || >=14} @@ -2752,6 +2920,13 @@ packages: caniuse-lite@1.0.30001766: resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + cbor-extract@2.2.0: + resolution: {integrity: sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==} + hasBin: true + + cbor-x@1.6.0: + resolution: {integrity: sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2833,10 +3008,6 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} - commander@13.1.0: - resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} - engines: {node: '>=18'} - commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -3167,6 +3338,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3287,6 +3462,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-port@7.1.0: + resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} + engines: {node: '>=16'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -3407,6 +3586,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + ip-address@10.0.1: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} @@ -3448,6 +3630,10 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-network-error@1.3.0: + resolution: {integrity: sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==} + engines: {node: '>=16'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -3799,6 +3985,10 @@ packages: nan@2.25.0: resolution: {integrity: sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==} + nanoevents@9.1.0: + resolution: {integrity: sha512-Jd0fILWG44a9luj8v5kED4WI+zfkkgwKyRQKItTtlPfEsh7Lznfi1kr8/iZ+XAIss4Qq5GqRB0qtWbaz9ceO/A==} + engines: {node: ^18.0.0 || >=20.0.0} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3818,6 +4008,10 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + node-mock-http@1.0.4: resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} @@ -3853,6 +4047,10 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -3880,6 +4078,9 @@ packages: resolution: {integrity: sha512-c/hfooPx+RBIOPM09GSxABOZhYPblDoyaGhqBkD/59vtpN21jEuWKDlM0KYTvqJVlSYjKs0tBcIdeXKChlSPtw==} hasBin: true + openapi3-ts@4.5.0: + resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==} + os-paths@4.4.0: resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} engines: {node: '>= 6.0'} @@ -3892,6 +4093,10 @@ packages: resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==} engines: {node: '>=18'} + p-retry@6.2.1: + resolution: {integrity: sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==} + engines: {node: '>=16.17'} + p-timeout@6.1.4: resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} engines: {node: '>=14.16'} @@ -3948,6 +4153,40 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.11.0: + resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.11.0: + resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.18.0: + resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + piccolore@0.1.3: resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} @@ -3966,6 +4205,16 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@9.14.0: + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + hasBin: true + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -4039,10 +4288,29 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -4071,6 +4339,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} @@ -4123,6 +4394,10 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -4200,6 +4475,24 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rivetkit@2.0.42: + resolution: {integrity: sha512-b78t9RT8fb5e2f16Wl6z4CX6St8gJ5s5C3FfS1zEFtivAtPrhdAt/0yMqXaW76r62AWMrZqEi/tHzJlVd94yHQ==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@hono/node-server': ^1.14.0 + '@hono/node-ws': ^1.1.1 + eventsource: ^4.0.0 + ws: ^8.0.0 + peerDependenciesMeta: + '@hono/node-server': + optional: true + '@hono/node-ws': + optional: true + eventsource: + optional: true + ws: + optional: true + rollup@4.56.0: resolution: {integrity: sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4301,6 +4594,9 @@ packages: resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} engines: {node: '>= 18'} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4315,6 +4611,10 @@ packages: split-ca@1.0.1: resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + ssh2@1.17.0: resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} engines: {node: '>=10.16.0'} @@ -4427,6 +4727,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -4477,11 +4780,6 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - ts-json-schema-generator@2.4.0: - resolution: {integrity: sha512-HbmNsgs58CfdJq0gpteRTxPXG26zumezOs+SB9tgky6MpqiFgQwieCn2MW70+sxpHouZ/w9LW0V6L4ZQO4y1Ug==} - engines: {node: '>=18.0.0'} - hasBin: true - tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} @@ -4714,10 +5012,18 @@ packages: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true + uuid@12.0.0: + resolution: {integrity: sha512-USe1zesMYh4fjCA8ZH5+X5WIVD0J4V1Jksm1bFTVBX2F/cwSXt0RO5w/3UXbdLKmZX65MiWV+hwhSS8p6oBTGA==} + hasBin: true + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vbare@0.0.4: + resolution: {integrity: sha512-QsxSVw76NqYUWYPVcQmOnQPX8buIVjgn+yqldTHlWISulBTB9TJ9rnzZceDu+GZmycOtzsmuPbPN1YNxvK12fg==} + engines: {node: '>=18.0.0'} + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -4928,6 +5234,10 @@ packages: resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} engines: {node: '>= 6.0'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} @@ -5004,6 +5314,11 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@asteasolutions/zod-to-openapi@8.4.0(zod@4.3.6)': + dependencies: + openapi3-ts: 4.5.0 + zod: 4.3.6 + '@astrojs/compiler@2.13.0': {} '@astrojs/internal-helpers@0.7.5': {} @@ -5747,6 +6062,24 @@ snapshots: dependencies: fontkitten: 1.0.2 + '@cbor-extract/cbor-extract-darwin-arm64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.0': + optional: true + '@cloudflare/containers@0.0.30': {} '@cloudflare/kv-asset-handler@0.4.2': {} @@ -6242,6 +6575,24 @@ snapshots: dependencies: hono: 4.11.8 + '@hono/standard-validator@0.1.5(@standard-schema/spec@1.0.0)(hono@4.11.8)': + dependencies: + '@standard-schema/spec': 1.0.0 + hono: 4.11.8 + + '@hono/zod-openapi@1.2.1(hono@4.11.8)(zod@4.3.6)': + dependencies: + '@asteasolutions/zod-to-openapi': 8.4.0(zod@4.3.6) + '@hono/zod-validator': 0.7.6(hono@4.11.8)(zod@4.3.6) + hono: 4.11.8 + openapi3-ts: 4.5.0 + zod: 4.3.6 + + '@hono/zod-validator@0.7.6(hono@4.11.8)(zod@4.3.6)': + dependencies: + hono: 4.11.8 + zod: 4.3.6 + '@iarna/toml@2.2.5': {} '@img/colour@1.0.0': {} @@ -6421,6 +6772,8 @@ snapshots: '@oslojs/encoding@1.1.0': {} + '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -6459,6 +6812,29 @@ snapshots: '@protobufjs/utf8@1.1.0': {} + '@rivetkit/bare-ts@0.6.2': {} + + '@rivetkit/engine-runner-protocol@2.0.42': + dependencies: + '@rivetkit/bare-ts': 0.6.2 + + '@rivetkit/engine-runner@2.0.42': + dependencies: + '@rivetkit/engine-runner-protocol': 2.0.42 + '@rivetkit/virtual-websocket': 2.0.33 + pino: 9.14.0 + uuid: 12.0.0 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@rivetkit/fast-json-patch@3.1.2': {} + + '@rivetkit/on-change@6.0.2-rc.1': {} + + '@rivetkit/virtual-websocket@2.0.33': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/pluginutils@5.3.0(rollup@4.56.0)': @@ -6919,6 +7295,8 @@ snapshots: '@speed-highlight/core@1.2.14': {} + '@standard-schema/spec@1.0.0': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.6 @@ -6953,13 +7331,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 24.10.9 + '@types/node': 25.2.3 '@types/ssh2': 1.15.5 '@types/dockerode@4.0.1': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 25.2.2 + '@types/node': 25.2.3 '@types/ssh2': 1.15.5 '@types/estree@1.0.8': {} @@ -6968,8 +7346,6 @@ snapshots: dependencies: '@types/unist': 3.0.3 - '@types/json-schema@7.0.15': {} - '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -7000,6 +7376,12 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/pg@8.16.0': + dependencies: + '@types/node': 25.2.3 + pg-protocol: 1.11.0 + pg-types: 2.2.0 + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.27)': @@ -7011,6 +7393,8 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/retry@0.12.2': {} + '@types/semver@7.7.1': {} '@types/ssh2@1.15.5': @@ -7287,6 +7671,8 @@ snapshots: asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} + autoprefixer@10.4.23(postcss@8.5.6): dependencies: browserslist: 4.28.1 @@ -7421,6 +7807,22 @@ snapshots: caniuse-lite@1.0.30001766: {} + cbor-extract@2.2.0: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.0 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.0 + '@cbor-extract/cbor-extract-linux-arm': 2.2.0 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.0 + '@cbor-extract/cbor-extract-linux-x64': 2.2.0 + '@cbor-extract/cbor-extract-win32-x64': 2.2.0 + optional: true + + cbor-x@1.6.0: + optionalDependencies: + cbor-extract: 2.2.0 + ccount@2.0.1: {} chai@5.3.3: @@ -7493,8 +7895,6 @@ snapshots: commander@12.1.0: {} - commander@13.1.0: {} - commander@4.1.1: {} common-ancestor-path@1.0.1: {} @@ -7948,6 +8348,8 @@ snapshots: extend@3.0.2: {} + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -8061,6 +8463,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-port@7.1.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -8243,6 +8647,10 @@ snapshots: inherits@2.0.4: {} + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + ip-address@10.0.1: {} ipaddr.js@1.9.1: {} @@ -8271,6 +8679,8 @@ snapshots: dependencies: is-docker: 3.0.0 + is-network-error@1.3.0: {} + is-number@7.0.0: {} is-plain-obj@4.1.0: {} @@ -8771,6 +9181,8 @@ snapshots: nan@2.25.0: optional: true + nanoevents@9.1.0: {} + nanoid@3.3.11: {} negotiator@1.0.0: {} @@ -8783,6 +9195,11 @@ snapshots: node-fetch-native@1.6.7: {} + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.1.2 + optional: true + node-mock-http@1.0.4: {} node-releases@2.0.27: {} @@ -8811,6 +9228,8 @@ snapshots: ohash@2.0.11: {} + on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -8846,6 +9265,10 @@ snapshots: undici: 5.29.0 yargs-parser: 21.1.1 + openapi3-ts@4.5.0: + dependencies: + yaml: 2.8.2 + os-paths@4.4.0: {} p-limit@6.2.0: @@ -8857,6 +9280,12 @@ snapshots: eventemitter3: 5.0.4 p-timeout: 6.1.4 + p-retry@6.2.1: + dependencies: + '@types/retry': 0.12.2 + is-network-error: 1.3.0 + retry: 0.13.1 + p-timeout@6.1.4: {} package-json-from-dist@1.0.1: {} @@ -8904,6 +9333,41 @@ snapshots: pathval@2.0.1: {} + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.11.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.11.0(pg@8.18.0): + dependencies: + pg: 8.18.0 + + pg-protocol@1.11.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.18.0: + dependencies: + pg-connection-string: 2.11.0 + pg-pool: 3.11.0(pg@8.18.0) + pg-protocol: 1.11.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + piccolore@0.1.3: {} picocolors@1.1.1: {} @@ -8914,6 +9378,26 @@ snapshots: pify@2.3.0: {} + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@9.14.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 3.1.0 + pirates@4.0.7: {} pkce-challenge@5.0.1: {} @@ -8972,8 +9456,20 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prismjs@1.30.0: {} + process-warning@5.0.0: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -8993,7 +9489,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 24.10.9 + '@types/node': 25.2.3 long: 5.3.2 proxy-addr@2.0.7: @@ -9014,6 +9510,8 @@ snapshots: queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + radix3@1.1.2: {} range-parser@1.2.1: {} @@ -9062,6 +9560,8 @@ snapshots: readdirp@5.0.0: {} + real-require@0.2.0: {} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -9181,6 +9681,33 @@ snapshots: reusify@1.1.0: {} + rivetkit@2.0.42(@hono/node-server@1.19.9(hono@4.11.8))(@standard-schema/spec@1.0.0)(ws@8.19.0): + dependencies: + '@hono/standard-validator': 0.1.5(@standard-schema/spec@1.0.0)(hono@4.11.8) + '@hono/zod-openapi': 1.2.1(hono@4.11.8)(zod@4.3.6) + '@rivetkit/bare-ts': 0.6.2 + '@rivetkit/engine-runner': 2.0.42 + '@rivetkit/fast-json-patch': 3.1.2 + '@rivetkit/on-change': 6.0.2-rc.1 + '@rivetkit/virtual-websocket': 2.0.33 + cbor-x: 1.6.0 + get-port: 7.1.0 + hono: 4.11.8 + invariant: 2.2.4 + nanoevents: 9.1.0 + p-retry: 6.2.1 + pino: 9.14.0 + uuid: 12.0.0 + vbare: 0.0.4 + zod: 4.3.6 + optionalDependencies: + '@hono/node-server': 1.19.9(hono@4.11.8) + ws: 8.19.0 + transitivePeerDependencies: + - '@standard-schema/spec' + - bufferutil + - utf-8-validate + rollup@4.56.0: dependencies: '@types/estree': 1.0.8 @@ -9357,6 +9884,10 @@ snapshots: smol-toml@1.6.0: {} + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map@0.7.6: {} @@ -9365,6 +9896,8 @@ snapshots: split-ca@1.0.1: {} + split2@4.2.0: {} + ssh2@1.17.0: dependencies: asn1: 0.2.6 @@ -9538,6 +10071,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + tiny-inflate@1.0.3: {} tinybench@2.9.0: {} @@ -9571,17 +10108,6 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-json-schema-generator@2.4.0: - dependencies: - '@types/json-schema': 7.0.15 - commander: 13.1.0 - glob: 11.1.0 - json5: 2.2.3 - normalize-path: 3.0.0 - safe-stable-stringify: 2.5.0 - tslib: 2.8.1 - typescript: 5.9.3 - tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -9767,8 +10293,12 @@ snapshots: uuid@10.0.0: {} + uuid@12.0.0: {} + vary@1.1.2: {} + vbare@0.0.4: {} + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -10127,6 +10657,8 @@ snapshots: dependencies: os-paths: 4.4.0 + xtend@4.0.2: {} + xxhash-wasm@1.1.0: {} y18n@5.0.8: {} diff --git a/server/packages/agent-management/src/agents.rs b/server/packages/agent-management/src/agents.rs index cc7be62..c36d6f8 100644 --- a/server/packages/agent-management/src/agents.rs +++ b/server/packages/agent-management/src/agents.rs @@ -1,18 +1,19 @@ use std::collections::HashMap; use std::fmt; use std::fs; -use std::io::{self, BufRead, BufReader, Read, Write}; +use std::io::{self, Read}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus, Stdio}; -use std::time::{Duration, Instant}; +use std::process::{Command, Stdio}; use flate2::read::GzDecoder; -use sandbox_agent_extracted_agent_schemas::codex as codex_schema; +use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; -use serde_json::Value; use thiserror::Error; use url::Url; +const DEFAULT_ACP_REGISTRY_URL: &str = + "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AgentId { @@ -62,6 +63,51 @@ impl AgentId { _ => None, } } + + pub fn all() -> &'static [AgentId] { + &[ + AgentId::Claude, + AgentId::Codex, + AgentId::Opencode, + AgentId::Amp, + AgentId::Pi, + AgentId::Cursor, + AgentId::Mock, + ] + } + + fn agent_process_registry_id(self) -> Option<&'static str> { + match self { + AgentId::Claude => Some("claude-code-acp"), + AgentId::Codex => Some("codex-acp"), + AgentId::Opencode => Some("opencode"), + AgentId::Amp => Some("amp-acp"), + AgentId::Pi => Some("pi-acp"), + AgentId::Cursor => Some("cursor-agent-acp"), + AgentId::Mock => None, + } + } + + fn agent_process_binary_hint(self) -> Option<&'static str> { + match self { + AgentId::Claude => Some("claude-code-acp"), + AgentId::Codex => Some("codex-acp"), + AgentId::Opencode => Some("opencode"), + AgentId::Amp => Some("amp-acp"), + AgentId::Pi => Some("pi-acp"), + AgentId::Cursor => Some("cursor-agent-acp"), + AgentId::Mock => None, + } + } + + fn native_required(self) -> bool { + matches!(self, AgentId::Claude | AgentId::Codex | AgentId::Opencode) + } + + fn unstable_enabled(self) -> bool { + // v1 profile includes unstable methods; support still depends on agent process capability. + !matches!(self, AgentId::Amp) + } } impl fmt::Display for AgentId { @@ -77,14 +123,14 @@ pub enum Platform { LinuxArm64, MacosArm64, MacosX64, + WindowsX64, + WindowsArm64, } impl Platform { pub fn detect() -> Result<Self, AgentError> { let os = std::env::consts::OS; let arch = std::env::consts::ARCH; - // Detect musl at runtime by checking for the musl dynamic linker - // This is more reliable than cfg!(target_env = "musl") which checks compile-time let is_musl = Self::detect_musl_runtime(); match (os, arch, is_musl) { @@ -93,6 +139,8 @@ impl Platform { ("linux", "aarch64", _) => Ok(Self::LinuxArm64), ("macos", "aarch64", _) => Ok(Self::MacosArm64), ("macos", "x86_64", _) => Ok(Self::MacosX64), + ("windows", "x86_64", _) => Ok(Self::WindowsX64), + ("windows", "aarch64", _) => Ok(Self::WindowsArm64), _ => Err(AgentError::UnsupportedPlatform { os: os.to_string(), arch: arch.to_string(), @@ -100,19 +148,102 @@ impl Platform { } } - /// Detect if the runtime environment uses musl libc by checking for musl dynamic linker + #[cfg(target_os = "linux")] fn detect_musl_runtime() -> bool { - use std::path::Path; - // Check for musl dynamic linkers (x86_64 and aarch64) Path::new("/lib/ld-musl-x86_64.so.1").exists() || Path::new("/lib/ld-musl-aarch64.so.1").exists() } + + #[cfg(not(target_os = "linux"))] + fn detect_musl_runtime() -> bool { + false + } + + fn registry_key(self) -> &'static str { + match self { + Platform::LinuxX64 | Platform::LinuxX64Musl => "linux-x86_64", + Platform::LinuxArm64 => "linux-aarch64", + Platform::MacosArm64 => "darwin-aarch64", + Platform::MacosX64 => "darwin-x86_64", + Platform::WindowsX64 => "windows-x86_64", + Platform::WindowsArm64 => "windows-aarch64", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InstallSource { + Registry, + Fallback, + LocalPath, + Builtin, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InstalledArtifactKind { + NativeAgent, + AgentProcess, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstalledArtifact { + pub kind: InstalledArtifactKind, + pub path: PathBuf, + pub version: Option<String>, + pub source: InstallSource, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstallResult { + pub artifacts: Vec<InstalledArtifact>, + pub already_installed: bool, +} + +#[derive(Debug, Clone)] +pub struct InstallOptions { + pub reinstall: bool, + pub version: Option<String>, + pub agent_process_version: Option<String>, +} + +impl Default for InstallOptions { + fn default() -> Self { + Self { + reinstall: false, + version: None, + agent_process_version: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentInstallStatus { + pub agent: AgentId, + pub native_required: bool, + pub native_installed: bool, + pub native_version: Option<String>, + pub agent_process_installed: bool, + pub agent_process_source: Option<InstallSource>, + pub agent_process_version: Option<String>, + pub unstable_enabled: bool, +} + +#[derive(Debug, Clone)] +pub struct AgentProcessLaunchSpec { + pub program: PathBuf, + pub args: Vec<String>, + pub env: HashMap<String, String>, + pub source: InstallSource, + pub version: Option<String>, } #[derive(Debug, Clone)] pub struct AgentManager { install_dir: PathBuf, platform: Platform, + registry_url: Url, } impl AgentManager { @@ -120,68 +251,109 @@ impl AgentManager { Ok(Self { install_dir: install_dir.into(), platform: Platform::detect()?, + registry_url: registry_url_from_env()?, }) } pub fn with_platform(install_dir: impl Into<PathBuf>, platform: Platform) -> Self { + let registry_url = registry_url_from_env().unwrap_or_else(|_| { + Url::parse(DEFAULT_ACP_REGISTRY_URL).expect("hardcoded valid ACP registry URL") + }); Self { install_dir: install_dir.into(), platform, + registry_url, } } + pub fn install_dir(&self) -> &Path { + &self.install_dir + } + + pub fn binary_path(&self, agent: AgentId) -> PathBuf { + self.install_dir.join(agent.binary_name()) + } + + pub fn agent_process_path(&self, agent: AgentId) -> PathBuf { + let base = self.install_dir.join("agent_processes"); + if cfg!(windows) { + base.join(format!("{}-acp.cmd", agent.as_str())) + } else { + base.join(format!("{}-acp", agent.as_str())) + } + } + + pub fn agent_process_storage_dir(&self, agent: AgentId) -> PathBuf { + self.install_dir + .join("agent_processes") + .join(agent.as_str()) + } + + pub fn list_status(&self) -> Vec<AgentInstallStatus> { + AgentId::all() + .iter() + .copied() + .map(|agent| { + let native_required = agent.native_required(); + let native_installed = !native_required || self.native_installed(agent); + let native_version = if native_installed && native_required { + self.version(agent).ok().flatten() + } else { + None + }; + let agent_process = self.agent_process_status(agent); + AgentInstallStatus { + agent, + native_required, + native_installed, + native_version, + agent_process_installed: agent_process.is_some(), + agent_process_source: agent_process.as_ref().map(|a| a.source), + agent_process_version: agent_process.and_then(|a| a.version), + unstable_enabled: agent.unstable_enabled(), + } + }) + .collect() + } + pub fn install( &self, agent: AgentId, options: InstallOptions, ) -> Result<InstallResult, AgentError> { - let install_path = self.binary_path(agent); - if !options.reinstall { - if let Ok(existing_path) = self.resolve_binary(agent) { - return Ok(InstallResult { - path: existing_path, - version: self.version(agent).unwrap_or(None), - }); + fs::create_dir_all(&self.install_dir)?; + fs::create_dir_all(self.install_dir.join("agent_processes"))?; + + let mut artifacts = Vec::new(); + let mut already_installed = true; + + if agent.native_required() { + let native_artifact = self.install_native(agent, &options)?; + if native_artifact.is_some() { + already_installed = false; + } + if let Some(artifact) = native_artifact { + artifacts.push(artifact); } } - fs::create_dir_all(&self.install_dir)?; - - match agent { - AgentId::Claude => { - install_claude(&install_path, self.platform, options.version.as_deref())? - } - AgentId::Codex => { - install_codex(&install_path, self.platform, options.version.as_deref())? - } - AgentId::Opencode => { - install_opencode(&install_path, self.platform, options.version.as_deref())? - } - AgentId::Amp => install_amp(&install_path, self.platform, options.version.as_deref())?, - AgentId::Pi => install_pi(&install_path, self.platform, options.version.as_deref())?, - AgentId::Cursor => install_cursor(&install_path, self.platform, options.version.as_deref())?, - AgentId::Mock => { - if !install_path.exists() { - fs::write(&install_path, b"mock")?; - } - } + let agent_process_artifact = self.install_agent_process(agent, &options)?; + if agent_process_artifact.is_some() { + already_installed = false; + } + if let Some(artifact) = agent_process_artifact { + artifacts.push(artifact); } Ok(InstallResult { - path: install_path, - version: self.version(agent).unwrap_or(None), + artifacts, + already_installed, }) } pub fn is_installed(&self, agent: AgentId) -> bool { - if agent == AgentId::Mock { - return true; - } - self.binary_path(agent).exists() || find_in_path(agent.binary_name()).is_some() - } - - pub fn binary_path(&self, agent: AgentId) -> PathBuf { - self.install_dir.join(agent.binary_name()) + let native_ok = !agent.native_required() || self.native_installed(agent); + native_ok && self.agent_process_status(agent).is_some() } pub fn version(&self, agent: AgentId) -> Result<Option<String>, AgentError> { @@ -189,8 +361,7 @@ impl AgentManager { return Ok(Some("builtin".to_string())); } let path = self.resolve_binary(agent)?; - let attempts = [vec!["--version"], vec!["version"], vec!["-V"]]; - for args in attempts { + for args in [["--version"], ["version"], ["-V"]] { let output = Command::new(&path).args(args).output(); if let Ok(output) = output { if output.status.success() { @@ -203,515 +374,10 @@ impl AgentManager { Ok(None) } - pub fn spawn(&self, agent: AgentId, options: SpawnOptions) -> Result<SpawnResult, AgentError> { - if agent == AgentId::Mock { - return Err(AgentError::UnsupportedAgent { - agent: agent.as_str().to_string(), - }); - } - if agent == AgentId::Codex { - return self.spawn_codex_app_server(options); - } - let path = self.resolve_binary(agent)?; - let working_dir = options - .working_dir - .clone() - .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); - let mut command = Command::new(&path); - command.current_dir(&working_dir); - - match agent { - AgentId::Claude => { - command - .arg("--output-format") - .arg("stream-json") - .arg("--verbose"); - if let Some(model) = options.model.as_deref() { - command.arg("--model").arg(model); - } - if let Some(session_id) = options.session_id.as_deref() { - command.arg("--resume").arg(session_id); - } - match options.permission_mode.as_deref() { - Some("plan") => { - command.arg("--permission-mode").arg("plan"); - } - Some("bypass") => { - command.arg("--dangerously-skip-permissions"); - } - Some("acceptEdits") => { - command.arg("--permission-mode").arg("acceptEdits"); - } - _ => {} - } - if options.streaming_input { - command - .arg("--input-format") - .arg("stream-json") - .arg("--permission-prompt-tool") - .arg("stdio") - .arg("--include-partial-messages"); - } else { - command.arg("--print").arg("--").arg(&options.prompt); - } - } - AgentId::Codex => { - if options.session_id.is_some() { - return Err(AgentError::ResumeUnsupported { agent }); - } - command.arg("app-server"); - } - AgentId::Opencode => { - command.arg("run").arg("--format").arg("json"); - if let Some(model) = options.model.as_deref() { - command.arg("-m").arg(model); - } - if let Some(agent_mode) = options.agent_mode.as_deref() { - command.arg("--agent").arg(agent_mode); - } - if let Some(variant) = options.variant.as_deref() { - command.arg("--variant").arg(variant); - } - if options.permission_mode.as_deref() == Some("bypass") { - command.arg("--dangerously-skip-permissions"); - } - if let Some(session_id) = options.session_id.as_deref() { - command.arg("-s").arg(session_id); - } - command.arg(&options.prompt); - } - AgentId::Cursor => { - // cursor-agent typically runs as HTTP server on localhost:32123 - // For CLI usage similar to opencode - command.arg("run").arg("--format").arg("json"); - if let Some(model) = options.model.as_deref() { - command.arg("-m").arg(model); - } - if let Some(session_id) = options.session_id.as_deref() { - command.arg("-s").arg(session_id); - } - command.arg(&options.prompt); - } - AgentId::Amp => { - let output = spawn_amp(&path, &working_dir, &options)?; - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let events = parse_jsonl_from_outputs(&stdout, &stderr); - return Ok(SpawnResult { - status: output.status, - stdout, - stderr, - session_id: extract_session_id(agent, &events), - result: extract_result_text(agent, &events), - events, - }); - } - AgentId::Pi => { - let output = spawn_pi(&path, &working_dir, &options)?; - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let events = parse_jsonl_from_outputs(&stdout, &stderr); - return Ok(SpawnResult { - status: output.status, - stdout, - stderr, - session_id: extract_session_id(agent, &events), - result: extract_result_text(agent, &events), - events, - }); - } - AgentId::Mock => { - return Err(AgentError::UnsupportedAgent { - agent: agent.as_str().to_string(), - }); - } - } - - for (key, value) in options.env { - command.env(key, value); - } - - let output = command.output().map_err(AgentError::Io)?; - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let events = parse_jsonl_from_outputs(&stdout, &stderr); - Ok(SpawnResult { - status: output.status, - stdout, - stderr, - session_id: extract_session_id(agent, &events), - result: extract_result_text(agent, &events), - events, - }) - } - - pub fn spawn_streaming( - &self, - agent: AgentId, - mut options: SpawnOptions, - ) -> Result<StreamingSpawn, AgentError> { - // Pi sessions are intentionally handled by the router's dedicated RPC runtime - // (one process per daemon session), not by generic subprocess streaming. - if agent == AgentId::Pi { - return Err(AgentError::UnsupportedRuntimePath { - agent, - operation: "spawn_streaming", - recommended_path: "router-managed per-session RPC runtime", - }); - } - let codex_options = if agent == AgentId::Codex { - Some(options.clone()) - } else { - None - }; - if agent == AgentId::Claude { - options.streaming_input = true; - } - let mut command = self.build_command(agent, &options)?; - - // Pass environment variables to the agent process (e.g., ANTHROPIC_API_KEY) - for (key, value) in &options.env { - command.env(key, value); - } - - if matches!(agent, AgentId::Codex | AgentId::Claude) { - command.stdin(Stdio::piped()); - } - command.stdout(Stdio::piped()).stderr(Stdio::piped()); - let mut child = command.spawn().map_err(AgentError::Io)?; - let stdin = child.stdin.take(); - let stdout = child.stdout.take(); - let stderr = child.stderr.take(); - Ok(StreamingSpawn { - child, - stdin, - stdout, - stderr, - codex_options, - }) - } - - fn spawn_codex_app_server(&self, options: SpawnOptions) -> Result<SpawnResult, AgentError> { - if options.session_id.is_some() { - return Err(AgentError::ResumeUnsupported { - agent: AgentId::Codex, - }); - } - let mut command = self.build_command(AgentId::Codex, &options)?; - command - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - for (key, value) in options.env { - command.env(key, value); - } - - let mut child = command.spawn().map_err(AgentError::Io)?; - let mut stdin = child.stdin.take().ok_or_else(|| { - AgentError::Io(io::Error::new(io::ErrorKind::Other, "missing codex stdin")) - })?; - let stdout = child.stdout.take().ok_or_else(|| { - AgentError::Io(io::Error::new(io::ErrorKind::Other, "missing codex stdout")) - })?; - let stderr = child.stderr.take().ok_or_else(|| { - AgentError::Io(io::Error::new(io::ErrorKind::Other, "missing codex stderr")) - })?; - - let stderr_handle = std::thread::spawn(move || { - let mut buffer = String::new(); - let _ = BufReader::new(stderr).read_to_string(&mut buffer); - buffer - }); - - let approval_policy = codex_approval_policy(options.permission_mode.as_deref()); - let sandbox_mode = codex_sandbox_mode(options.permission_mode.as_deref()); - let sandbox_policy = codex_sandbox_policy(options.permission_mode.as_deref()); - let prompt = codex_prompt_for_mode(&options.prompt, options.agent_mode.as_deref()); - let cwd = options - .working_dir - .as_ref() - .map(|path| path.to_string_lossy().to_string()); - - let mut next_id = 1i64; - let init_id = next_request_id(&mut next_id); - send_json_line( - &mut stdin, - &codex_schema::ClientRequest::Initialize { - id: init_id.clone(), - params: codex_schema::InitializeParams { - client_info: codex_schema::ClientInfo { - name: "sandbox-agent".to_string(), - title: Some("sandbox-agent".to_string()), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - }, - }, - )?; - - let mut init_done = false; - let mut thread_start_sent = false; - let mut thread_start_id: Option<String> = None; - let mut turn_start_sent = false; - let mut thread_id: Option<String> = None; - let mut stdout_buffer = String::new(); - let mut events = Vec::new(); - let mut line = String::new(); - let mut reader = BufReader::new(stdout); - let mut completed = false; - while reader.read_line(&mut line).map_err(AgentError::Io)? > 0 { - stdout_buffer.push_str(&line); - let trimmed = line.trim_end_matches(&['\r', '\n'][..]).to_string(); - line.clear(); - if trimmed.is_empty() { - continue; - } - let value: Value = match serde_json::from_str(&trimmed) { - Ok(value) => value, - Err(_) => continue, - }; - let message: codex_schema::JsonrpcMessage = match serde_json::from_value(value.clone()) - { - Ok(message) => message, - Err(_) => continue, - }; - match message { - codex_schema::JsonrpcMessage::Response(response) => { - let response_id = response.id.to_string(); - if !init_done && response_id == init_id.to_string() { - init_done = true; - send_json_line( - &mut stdin, - &codex_schema::JsonrpcNotification { - method: "initialized".to_string(), - params: None, - }, - )?; - let request_id = next_request_id(&mut next_id); - let request_id_str = request_id.to_string(); - let mut params = codex_schema::ThreadStartParams::default(); - params.approval_policy = approval_policy; - params.sandbox = sandbox_mode; - params.model = options.model.clone(); - params.cwd = cwd.clone(); - send_json_line( - &mut stdin, - &codex_schema::ClientRequest::ThreadStart { - id: request_id, - params, - }, - )?; - thread_start_id = Some(request_id_str); - thread_start_sent = true; - } else if thread_start_id.as_deref() == Some(&response_id) - && thread_id.is_none() - { - thread_id = codex_thread_id_from_response(&response.result); - } - events.push(value); - } - codex_schema::JsonrpcMessage::Notification(_) => { - if let Ok(notification) = - serde_json::from_value::<codex_schema::ServerNotification>(value.clone()) - { - if thread_id.is_none() { - thread_id = codex_thread_id_from_notification(¬ification); - } - if matches!( - notification, - codex_schema::ServerNotification::TurnCompleted(_) - | codex_schema::ServerNotification::Error(_) - ) { - completed = true; - } - if let codex_schema::ServerNotification::ItemCompleted(params) = - ¬ification - { - if matches!(params.item, codex_schema::ThreadItem::AgentMessage { .. }) - { - completed = true; - } - } - } - events.push(value); - } - codex_schema::JsonrpcMessage::Request(_) => { - events.push(value); - } - codex_schema::JsonrpcMessage::Error(_) => { - events.push(value); - completed = true; - } - } - if thread_id.is_some() && thread_start_sent && !turn_start_sent { - let request_id = next_request_id(&mut next_id); - let params = codex_schema::TurnStartParams { - approval_policy, - collaboration_mode: None, - cwd: cwd.clone(), - effort: None, - input: vec![codex_schema::UserInput::Text { - text: prompt.clone(), - text_elements: Vec::new(), - }], - model: options.model.clone(), - output_schema: None, - sandbox_policy: sandbox_policy.clone(), - summary: None, - thread_id: thread_id.clone().unwrap_or_default(), - }; - send_json_line( - &mut stdin, - &codex_schema::ClientRequest::TurnStart { - id: request_id, - params, - }, - )?; - turn_start_sent = true; - } - if completed { - break; - } - } - - drop(stdin); - let status = if completed { - let start = Instant::now(); - loop { - if let Some(status) = child.try_wait().map_err(AgentError::Io)? { - break status; - } - if start.elapsed() > Duration::from_secs(5) { - let _ = child.kill(); - break child.wait().map_err(AgentError::Io)?; - } - std::thread::sleep(Duration::from_millis(50)); - } - } else { - child.wait().map_err(AgentError::Io)? - }; - let stderr_output = stderr_handle.join().unwrap_or_default(); - - Ok(SpawnResult { - status, - stdout: stdout_buffer, - stderr: stderr_output, - session_id: extract_session_id(AgentId::Codex, &events), - result: extract_result_text(AgentId::Codex, &events), - events, - }) - } - - fn build_command(&self, agent: AgentId, options: &SpawnOptions) -> Result<Command, AgentError> { - if agent == AgentId::Pi { - return Err(AgentError::UnsupportedRuntimePath { - agent, - operation: "build_command", - recommended_path: "router-managed per-session RPC runtime", - }); - } - if agent == AgentId::Mock { - return Err(AgentError::UnsupportedAgent { - agent: agent.as_str().to_string(), - }); - } - - let path = self.resolve_binary(agent)?; - let working_dir = options - .working_dir - .clone() - .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); - let mut command = Command::new(&path); - command.current_dir(&working_dir); - - match agent { - AgentId::Claude => { - command - .arg("--output-format") - .arg("stream-json") - .arg("--verbose"); - if let Some(model) = options.model.as_deref() { - command.arg("--model").arg(model); - } - if let Some(session_id) = options.session_id.as_deref() { - command.arg("--resume").arg(session_id); - } - match options.permission_mode.as_deref() { - Some("plan") => { - command.arg("--permission-mode").arg("plan"); - } - Some("bypass") => { - command.arg("--dangerously-skip-permissions"); - } - Some("acceptEdits") => { - command.arg("--permission-mode").arg("acceptEdits"); - } - _ => {} - } - if options.streaming_input { - command - .arg("--input-format") - .arg("stream-json") - .arg("--permission-prompt-tool") - .arg("stdio") - .arg("--include-partial-messages"); - } else { - command.arg(&options.prompt); - } - } - AgentId::Codex => { - if options.session_id.is_some() { - return Err(AgentError::ResumeUnsupported { agent }); - } - command.arg("app-server"); - } - AgentId::Opencode => { - command.arg("run").arg("--format").arg("json"); - if let Some(model) = options.model.as_deref() { - command.arg("-m").arg(model); - } - if let Some(agent_mode) = options.agent_mode.as_deref() { - command.arg("--agent").arg(agent_mode); - } - if let Some(variant) = options.variant.as_deref() { - command.arg("--variant").arg(variant); - } - if options.permission_mode.as_deref() == Some("bypass") { - command.arg("--dangerously-skip-permissions"); - } - if let Some(session_id) = options.session_id.as_deref() { - command.arg("-s").arg(session_id); - } - command.arg(&options.prompt); - } - AgentId::Amp => { - return Ok(build_amp_command(&path, &working_dir, options)); - } - AgentId::Cursor => { - command.arg("run").arg("--format").arg("json"); - if let Some(model) = options.model.as_deref() { - command.arg("-m").arg(model); - } - if let Some(session_id) = options.session_id.as_deref() { - command.arg("-s").arg(session_id); - } - command.arg(&options.prompt); - } - AgentId::Pi => { - unreachable!("Pi is handled by router RPC runtime"); - } - AgentId::Mock => { - unreachable!("Mock is handled above"); - } - } - - for (key, value) in &options.env { - command.env(key, value); - } - - Ok(command) - } - pub fn resolve_binary(&self, agent: AgentId) -> Result<PathBuf, AgentError> { + if agent == AgentId::Mock { + return Ok(self.binary_path(agent)); + } let path = self.binary_path(agent); if path.exists() { return Ok(path); @@ -721,76 +387,322 @@ impl AgentManager { } Err(AgentError::BinaryNotFound { agent }) } -} -#[derive(Debug, Clone)] -pub struct InstallOptions { - pub reinstall: bool, - pub version: Option<String>, -} + pub fn resolve_agent_process( + &self, + agent: AgentId, + ) -> Result<AgentProcessLaunchSpec, AgentError> { + if agent == AgentId::Mock { + return Ok(AgentProcessLaunchSpec { + program: self.agent_process_path(agent), + args: Vec::new(), + env: HashMap::new(), + source: InstallSource::Builtin, + version: Some("builtin".to_string()), + }); + } -impl Default for InstallOptions { - fn default() -> Self { - Self { - reinstall: false, + let launcher = self.agent_process_path(agent); + if launcher.exists() { + return Ok(AgentProcessLaunchSpec { + program: launcher, + args: Vec::new(), + env: HashMap::new(), + source: InstallSource::LocalPath, + version: None, + }); + } + + if let Some(bin) = agent.agent_process_binary_hint().and_then(find_in_path) { + let args = if agent == AgentId::Opencode { + vec!["acp".to_string()] + } else { + Vec::new() + }; + return Ok(AgentProcessLaunchSpec { + program: bin, + args, + env: HashMap::new(), + source: InstallSource::LocalPath, + version: None, + }); + } + + if agent == AgentId::Opencode { + let native = self.resolve_binary(agent)?; + return Ok(AgentProcessLaunchSpec { + program: native, + args: vec!["acp".to_string()], + env: HashMap::new(), + source: InstallSource::LocalPath, + version: None, + }); + } + + Err(AgentError::AgentProcessNotFound { + agent, + hint: Some("run install to provision ACP agent process".to_string()), + }) + } + + fn native_installed(&self, agent: AgentId) -> bool { + self.binary_path(agent).exists() || find_in_path(agent.binary_name()).is_some() + } + + fn install_native( + &self, + agent: AgentId, + options: &InstallOptions, + ) -> Result<Option<InstalledArtifact>, AgentError> { + if !options.reinstall && self.native_installed(agent) { + return Ok(None); + } + + let path = self.binary_path(agent); + match agent { + AgentId::Claude => install_claude(&path, self.platform, options.version.as_deref())?, + AgentId::Codex => install_codex(&path, self.platform, options.version.as_deref())?, + AgentId::Opencode => { + install_opencode(&path, self.platform, options.version.as_deref())? + } + AgentId::Amp => install_amp(&path, self.platform, options.version.as_deref())?, + AgentId::Pi | AgentId::Cursor => { + return Ok(None); + } + AgentId::Mock => { + write_text_file(&path, "#!/usr/bin/env sh\nexit 0\n")?; + } + } + + Ok(Some(InstalledArtifact { + kind: InstalledArtifactKind::NativeAgent, + path, + version: self.version(agent).ok().flatten(), + source: InstallSource::Fallback, + })) + } + + fn install_agent_process( + &self, + agent: AgentId, + options: &InstallOptions, + ) -> Result<Option<InstalledArtifact>, AgentError> { + if !options.reinstall { + if self.agent_process_status(agent).is_some() { + return Ok(None); + } + } + + if agent == AgentId::Mock { + let path = self.agent_process_path(agent); + write_mock_agent_process_launcher(&path)?; + return Ok(Some(InstalledArtifact { + kind: InstalledArtifactKind::AgentProcess, + path, + version: Some("builtin".to_string()), + source: InstallSource::Builtin, + })); + } + + if let Some(artifact) = self.install_agent_process_from_registry(agent, options)? { + return Ok(Some(artifact)); + } + + let artifact = self.install_agent_process_fallback(agent, options)?; + Ok(Some(artifact)) + } + + fn agent_process_status(&self, agent: AgentId) -> Option<AgentProcessStatus> { + if agent == AgentId::Mock { + return Some(AgentProcessStatus { + source: InstallSource::Builtin, + version: Some("builtin".to_string()), + }); + } + + let launcher = self.agent_process_path(agent); + if launcher.exists() { + return Some(AgentProcessStatus { + source: InstallSource::LocalPath, + version: None, + }); + } + + agent.agent_process_binary_hint().and_then(find_in_path)?; + Some(AgentProcessStatus { + source: InstallSource::LocalPath, version: None, + }) + } + + fn install_agent_process_from_registry( + &self, + agent: AgentId, + options: &InstallOptions, + ) -> Result<Option<InstalledArtifact>, AgentError> { + let Some(registry_id) = agent.agent_process_registry_id() else { + return Ok(None); + }; + + let registry = fetch_registry(&self.registry_url)?; + let Some(entry) = registry.agents.into_iter().find(|a| a.id == registry_id) else { + return Ok(None); + }; + + if let Some(npx) = entry.distribution.npx { + let package = + apply_npx_version_override(&npx.package, options.agent_process_version.as_deref()); + let launcher = self.agent_process_path(agent); + write_npx_agent_process_launcher(&launcher, &package, &npx.args, &npx.env)?; + verify_command(&launcher, &[])?; + return Ok(Some(InstalledArtifact { + kind: InstalledArtifactKind::AgentProcess, + path: launcher, + version: options + .agent_process_version + .clone() + .or(entry.version) + .or(extract_npx_version(&package)), + source: InstallSource::Registry, + })); } + + if let Some(binary) = entry.distribution.binary { + let key = self.platform.registry_key(); + if let Some(target) = binary.get(key) { + let archive_url = Url::parse(&target.archive)?; + let payload = download_bytes(&archive_url)?; + let root = self.agent_process_storage_dir(agent); + if root.exists() { + fs::remove_dir_all(&root)?; + } + fs::create_dir_all(&root)?; + unpack_archive(&payload, &archive_url, &root)?; + + let cmd_path = resolve_extracted_command(&root, &target.cmd)?; + let launcher = self.agent_process_path(agent); + write_exec_agent_process_launcher(&launcher, &cmd_path, &target.args, &target.env)?; + verify_command(&launcher, &[])?; + + return Ok(Some(InstalledArtifact { + kind: InstalledArtifactKind::AgentProcess, + path: launcher, + version: options.agent_process_version.clone().or(entry.version), + source: InstallSource::Registry, + })); + } + } + + Ok(None) + } + + fn install_agent_process_fallback( + &self, + agent: AgentId, + options: &InstallOptions, + ) -> Result<InstalledArtifact, AgentError> { + let launcher = self.agent_process_path(agent); + + match agent { + AgentId::Claude => { + let package = fallback_npx_package( + "@zed-industries/claude-code-acp", + options.agent_process_version.as_deref(), + ); + write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?; + } + AgentId::Codex => { + let package = fallback_npx_package( + "@zed-industries/codex-acp", + options.agent_process_version.as_deref(), + ); + write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?; + } + AgentId::Opencode => { + let native = self.resolve_binary(agent)?; + write_exec_agent_process_launcher( + &launcher, + &native, + &["acp".to_string()], + &HashMap::new(), + )?; + } + AgentId::Amp => { + let package = + fallback_npx_package("amp-acp", options.agent_process_version.as_deref()); + write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?; + } + AgentId::Pi => { + let package = + fallback_npx_package("pi-acp", options.agent_process_version.as_deref()); + write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?; + } + AgentId::Cursor => { + let package = fallback_npx_package( + "@blowmage/cursor-agent-acp", + options.agent_process_version.as_deref(), + ); + write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?; + } + AgentId::Mock => { + write_mock_agent_process_launcher(&launcher)?; + } + } + + verify_command(&launcher, &[])?; + + Ok(InstalledArtifact { + kind: InstalledArtifactKind::AgentProcess, + path: launcher, + version: options.agent_process_version.clone(), + source: InstallSource::Fallback, + }) } } #[derive(Debug, Clone)] -pub struct InstallResult { - pub path: PathBuf, - pub version: Option<String>, +struct AgentProcessStatus { + source: InstallSource, + version: Option<String>, } -#[derive(Debug, Clone)] -pub struct SpawnOptions { - pub prompt: String, - pub model: Option<String>, - pub variant: Option<String>, - pub agent_mode: Option<String>, - pub permission_mode: Option<String>, - pub session_id: Option<String>, - pub working_dir: Option<PathBuf>, - pub env: HashMap<String, String>, - /// Use stream-json input via stdin (Claude only). - pub streaming_input: bool, +#[derive(Debug, Deserialize)] +struct RegistryDocument { + agents: Vec<RegistryAgent>, } -impl SpawnOptions { - pub fn new(prompt: impl Into<String>) -> Self { - Self { - prompt: prompt.into(), - model: None, - variant: None, - agent_mode: None, - permission_mode: None, - session_id: None, - working_dir: None, - env: HashMap::new(), - streaming_input: false, - } - } +#[derive(Debug, Deserialize)] +struct RegistryAgent { + id: String, + version: Option<String>, + distribution: RegistryDistribution, } -#[derive(Debug, Clone)] -pub struct SpawnResult { - pub status: ExitStatus, - pub stdout: String, - pub stderr: String, - pub events: Vec<Value>, - pub session_id: Option<String>, - pub result: Option<String>, +#[derive(Debug, Deserialize)] +struct RegistryDistribution { + #[serde(default)] + npx: Option<RegistryNpx>, + #[serde(default)] + binary: Option<HashMap<String, RegistryBinaryTarget>>, } -#[derive(Debug)] -pub struct StreamingSpawn { - pub child: Child, - pub stdin: Option<ChildStdin>, - pub stdout: Option<ChildStdout>, - pub stderr: Option<ChildStderr>, - pub codex_options: Option<SpawnOptions>, +#[derive(Debug, Deserialize)] +struct RegistryNpx { + package: String, + #[serde(default)] + args: Vec<String>, + #[serde(default)] + env: HashMap<String, String>, +} + +#[derive(Debug, Deserialize)] +struct RegistryBinaryTarget { + archive: String, + cmd: String, + #[serde(default)] + args: Vec<String>, + #[serde(default)] + env: HashMap<String, String>, } #[derive(Debug, Error)] @@ -801,6 +713,11 @@ pub enum AgentError { UnsupportedAgent { agent: String }, #[error("binary not found for {agent}")] BinaryNotFound { agent: AgentId }, + #[error("agent process not found for {agent}")] + AgentProcessNotFound { + agent: AgentId, + hint: Option<String>, + }, #[error("download failed: {url}")] DownloadFailed { url: Url }, #[error("http error: {0}")] @@ -811,584 +728,241 @@ pub enum AgentError { Io(#[from] io::Error), #[error("extract failed: {0}")] ExtractFailed(String), - #[error("resume unsupported for {agent}")] - ResumeUnsupported { agent: AgentId }, - #[error("unsupported runtime path for {agent}: {operation}; use {recommended_path}")] - UnsupportedRuntimePath { - agent: AgentId, - operation: &'static str, - recommended_path: &'static str, - }, + #[error("registry parse failed: {0}")] + RegistryParse(String), + #[error("command verification failed: {0}")] + VerifyFailed(String), } -fn parse_version_output(output: &std::process::Output) -> Option<String> { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let combined = format!("{}\n{}", stdout, stderr); - combined - .lines() - .map(str::trim) - .find(|line| !line.is_empty()) - .map(|line| { - // Strip trailing metadata like " (released ...)" from version strings - match line.find(" (") { - Some(pos) => line[..pos].to_string(), - None => line.to_string(), +fn fallback_npx_package(base: &str, version: Option<&str>) -> String { + match version { + Some(version) => format!("{base}@{version}"), + None => base.to_string(), + } +} + +fn registry_url_from_env() -> Result<Url, AgentError> { + match std::env::var("SANDBOX_AGENT_ACP_REGISTRY_URL") { + Ok(url) => Ok(Url::parse(url.trim())?), + Err(_) => { + Ok(Url::parse(DEFAULT_ACP_REGISTRY_URL).expect("hardcoded valid ACP registry URL")) + } + } +} + +fn apply_npx_version_override(package: &str, version: Option<&str>) -> String { + let Some(version) = version else { + return package.to_string(); + }; + + if let Some((scope_and_name, _)) = split_package_version(package) { + format!("{scope_and_name}@{version}") + } else { + format!("{package}@{version}") + } +} + +fn extract_npx_version(package: &str) -> Option<String> { + split_package_version(package).map(|(_, version)| version.to_string()) +} + +fn split_package_version(package: &str) -> Option<(&str, &str)> { + if let Some(stripped) = package.strip_prefix('@') { + let idx = stripped.rfind('@')? + 1; + let full_idx = idx + 1; + let (name, version) = package.split_at(full_idx); + Some((name.trim_end_matches('@'), version.trim_start_matches('@'))) + } else { + let idx = package.rfind('@')?; + let (name, version) = package.split_at(idx); + Some((name, version.trim_start_matches('@'))) + } +} + +fn write_npx_agent_process_launcher( + path: &Path, + package: &str, + args: &[String], + env: &HashMap<String, String>, +) -> Result<(), AgentError> { + let mut command = vec!["npx".to_string(), "-y".to_string(), package.to_string()]; + command.extend(args.iter().cloned()); + write_launcher(path, &command, env) +} + +fn write_exec_agent_process_launcher( + path: &Path, + executable: &Path, + args: &[String], + env: &HashMap<String, String>, +) -> Result<(), AgentError> { + let mut command = vec![executable.to_string_lossy().to_string()]; + command.extend(args.iter().cloned()); + write_launcher(path, &command, env) +} + +fn write_mock_agent_process_launcher(path: &Path) -> Result<(), AgentError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let script = if cfg!(windows) { + "@echo off\r\necho mock agent process is in-process in sandbox-agent\r\nexit /b 1\r\n" + } else { + "#!/usr/bin/env sh\necho 'mock agent process is in-process in sandbox-agent'\nexit 1\n" + }; + write_text_file(path, script) +} + +fn write_launcher( + path: &Path, + command: &[String], + env: &HashMap<String, String>, +) -> Result<(), AgentError> { + if command.is_empty() { + return Err(AgentError::ExtractFailed( + "launcher command cannot be empty".to_string(), + )); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + if cfg!(windows) { + let mut script = String::from("@echo off\r\nsetlocal enabledelayedexpansion\r\n"); + for (key, value) in env { + script.push_str(&format!("set {}={}\r\n", key, value)); + } + script.push_str("\""); + script.push_str(&command[0]); + script.push_str("\""); + for arg in &command[1..] { + script.push(' '); + script.push_str(arg); + } + script.push_str(" %*\r\n"); + write_text_file(path, &script)?; + } else { + let mut script = String::from("#!/usr/bin/env sh\nset -e\n"); + for (key, value) in env { + script.push_str(&format!("export {}='{}'\n", key, shell_escape(value))); + } + script.push_str("exec "); + for (idx, part) in command.iter().enumerate() { + if idx > 0 { + script.push(' '); } - }) -} - -fn parse_jsonl(text: &str) -> Vec<Value> { - text.lines() - .filter_map(|line| serde_json::from_str::<Value>(line).ok()) - .collect() -} - -fn parse_jsonl_from_outputs(stdout: &str, stderr: &str) -> Vec<Value> { - let mut events = parse_jsonl(stdout); - events.extend(parse_jsonl(stderr)); - events -} - -fn codex_prompt_for_mode(prompt: &str, mode: Option<&str>) -> String { - match mode { - Some("plan") => format!("Make a plan before acting.\n\n{prompt}"), - _ => prompt.to_string(), + script.push('\''); + script.push_str(&shell_escape(part)); + script.push('\''); + } + script.push_str(" \"$@\"\n"); + write_text_file(path, &script)?; } -} -fn codex_approval_policy(mode: Option<&str>) -> Option<codex_schema::AskForApproval> { - match mode { - Some("plan") => Some(codex_schema::AskForApproval::Untrusted), - Some("bypass") => Some(codex_schema::AskForApproval::Never), - _ => None, - } -} - -fn codex_sandbox_mode(mode: Option<&str>) -> Option<codex_schema::SandboxMode> { - match mode { - Some("plan") => Some(codex_schema::SandboxMode::ReadOnly), - Some("bypass") => Some(codex_schema::SandboxMode::DangerFullAccess), - _ => None, - } -} - -fn codex_sandbox_policy(mode: Option<&str>) -> Option<codex_schema::SandboxPolicy> { - match mode { - Some("plan") => Some(codex_schema::SandboxPolicy::ReadOnly), - Some("bypass") => Some(codex_schema::SandboxPolicy::DangerFullAccess), - _ => None, - } -} - -fn next_request_id(next_id: &mut i64) -> codex_schema::RequestId { - let id = *next_id; - *next_id += 1; - codex_schema::RequestId::from(id) -} - -fn send_json_line<T: Serialize>(stdin: &mut ChildStdin, payload: &T) -> Result<(), AgentError> { - let line = serde_json::to_string(payload) - .map_err(|err| AgentError::Io(io::Error::new(io::ErrorKind::Other, err)))?; - writeln!(stdin, "{line}").map_err(AgentError::Io)?; - stdin.flush().map_err(AgentError::Io)?; Ok(()) } -fn codex_thread_id_from_notification( - notification: &codex_schema::ServerNotification, -) -> Option<String> { - match notification { - codex_schema::ServerNotification::ThreadStarted(params) => Some(params.thread.id.clone()), - codex_schema::ServerNotification::TurnStarted(params) => Some(params.thread_id.clone()), - codex_schema::ServerNotification::TurnCompleted(params) => Some(params.thread_id.clone()), - codex_schema::ServerNotification::ItemStarted(params) => Some(params.thread_id.clone()), - codex_schema::ServerNotification::ItemCompleted(params) => Some(params.thread_id.clone()), - codex_schema::ServerNotification::ItemAgentMessageDelta(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemReasoningTextDelta(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemReasoningSummaryTextDelta(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemCommandExecutionOutputDelta(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemFileChangeOutputDelta(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemMcpToolCallProgress(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ThreadTokenUsageUpdated(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::TurnDiffUpdated(params) => Some(params.thread_id.clone()), - codex_schema::ServerNotification::TurnPlanUpdated(params) => Some(params.thread_id.clone()), - codex_schema::ServerNotification::ItemCommandExecutionTerminalInteraction(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemReasoningSummaryPartAdded(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ThreadCompacted(params) => Some(params.thread_id.clone()), - _ => None, - } +fn shell_escape(value: &str) -> String { + value.replace('\'', "'\\''") } -fn codex_thread_id_from_response(result: &Value) -> Option<String> { - if let Some(id) = result - .get("thread") - .and_then(|thread| thread.get("id")) - .and_then(Value::as_str) - { - return Some(id.to_string()); - } - if let Some(id) = result.get("threadId").and_then(Value::as_str) { - return Some(id.to_string()); - } - None +fn write_text_file(path: &Path, contents: &str) -> Result<(), AgentError> { + fs::write(path, contents)?; + set_executable(path)?; + Ok(()) } -fn extract_nested_string(value: &Value, path: &[&str]) -> Option<String> { - let mut current = value; - for key in path { - if let Ok(index) = key.parse::<usize>() { - current = current.get(index)?; - } else { - current = current.get(*key)?; - } - } - current.as_str().map(|s| s.to_string()) -} - -fn extract_session_id(agent: AgentId, events: &[Value]) -> Option<String> { - for event in events { - match agent { - AgentId::Claude | AgentId::Amp => { - if let Some(id) = event.get("session_id").and_then(Value::as_str) { - return Some(id.to_string()); - } - } - AgentId::Codex => { - if let Ok(notification) = - serde_json::from_value::<codex_schema::ServerNotification>(event.clone()) - { - match notification { - codex_schema::ServerNotification::ThreadStarted(params) => { - return Some(params.thread.id); - } - codex_schema::ServerNotification::TurnStarted(params) => { - return Some(params.thread_id); - } - codex_schema::ServerNotification::TurnCompleted(params) => { - return Some(params.thread_id); - } - codex_schema::ServerNotification::ItemStarted(params) => { - return Some(params.thread_id); - } - codex_schema::ServerNotification::ItemCompleted(params) => { - return Some(params.thread_id); - } - _ => {} - } - } - if let Some(id) = event.get("thread_id").and_then(Value::as_str) { - return Some(id.to_string()); - } - if let Some(id) = event.get("threadId").and_then(Value::as_str) { - return Some(id.to_string()); - } - } - AgentId::Opencode => { - if let Some(id) = event.get("session_id").and_then(Value::as_str) { - return Some(id.to_string()); - } - if let Some(id) = event.get("sessionID").and_then(Value::as_str) { - return Some(id.to_string()); - } - if let Some(id) = event.get("sessionId").and_then(Value::as_str) { - return Some(id.to_string()); - } - if let Some(id) = extract_nested_string(event, &["properties", "sessionID"]) { - return Some(id); - } - if let Some(id) = extract_nested_string(event, &["properties", "part", "sessionID"]) - { - return Some(id); - } - if let Some(id) = extract_nested_string(event, &["session", "id"]) { - return Some(id); - } - if let Some(id) = extract_nested_string(event, &["properties", "session", "id"]) { - return Some(id); - } - } - AgentId::Pi => { - if event.get("type").and_then(Value::as_str) == Some("session") { - if let Some(id) = event.get("id").and_then(Value::as_str) { - return Some(id.to_string()); - } - } - if let Some(id) = event.get("session_id").and_then(Value::as_str) { - return Some(id.to_string()); - } - if let Some(id) = event.get("sessionId").and_then(Value::as_str) { - return Some(id.to_string()); - } - if let Some(id) = extract_nested_string(event, &["data", "sessionId"]) { - return Some(id); - } - if let Some(id) = extract_nested_string(event, &["session", "id"]) { - return Some(id); - } - } - AgentId::Cursor => { - if let Some(id) = event.get("session_id").and_then(Value::as_str) { - return Some(id.to_string()); - } - if let Some(id) = event.get("sessionId").and_then(Value::as_str) { - return Some(id.to_string()); - } - } - AgentId::Mock => {} - } - } - None -} - -fn extract_result_text(agent: AgentId, events: &[Value]) -> Option<String> { - match agent { - AgentId::Claude | AgentId::Amp => { - for event in events { - if let Some(result) = event.get("result").and_then(Value::as_str) { - return Some(result.to_string()); - } - if let Some(text) = - extract_nested_string(event, &["message", "content", "0", "text"]) - { - return Some(text); - } - } - None - } - AgentId::Codex => { - let mut last = None; - for event in events { - if let Ok(notification) = - serde_json::from_value::<codex_schema::ServerNotification>(event.clone()) - { - match notification { - codex_schema::ServerNotification::ItemCompleted(params) => { - if let codex_schema::ThreadItem::AgentMessage { text, .. } = params.item - { - last = Some(text); - } - } - codex_schema::ServerNotification::TurnCompleted(params) => { - for item in params.turn.items.iter().rev() { - if let codex_schema::ThreadItem::AgentMessage { text, .. } = item { - last = Some(text.clone()); - break; - } - } - } - _ => {} - } - } - if let Some(result) = event.get("result").and_then(Value::as_str) { - last = Some(result.to_string()); - } - if let Some(output) = event.get("output").and_then(Value::as_str) { - last = Some(output.to_string()); - } - if let Some(message) = event.get("message").and_then(Value::as_str) { - last = Some(message.to_string()); - } - } - last - } - AgentId::Opencode => { - let mut buffer = String::new(); - for event in events { - if event.get("type").and_then(Value::as_str) == Some("message.part.updated") { - if let Some(delta) = extract_nested_string(event, &["properties", "delta"]) { - buffer.push_str(&delta); - } - if let Some(content) = - extract_nested_string(event, &["properties", "part", "content"]) - { - buffer.push_str(&content); - } - } - if let Some(result) = event.get("result").and_then(Value::as_str) { - if buffer.is_empty() { - buffer.push_str(result); - } - } - } - if buffer.is_empty() { - None - } else { - Some(buffer) - } - } - AgentId::Pi => extract_pi_result_text(events), - AgentId::Cursor => None, - AgentId::Mock => None, - } -} - -fn extract_text_from_content_parts(content: &Value) -> Option<String> { - let parts = content.as_array()?; - let mut text = String::new(); - for part in parts { - if part.get("type").and_then(Value::as_str) != Some("text") { - continue; - } - if let Some(part_text) = part.get("text").and_then(Value::as_str) { - text.push_str(part_text); - } - } - if text.is_empty() { - None +fn verify_command(path: &Path, args: &[&str]) -> Result<(), AgentError> { + let mut command = Command::new(path); + if args.is_empty() { + command.arg("--help"); } else { - Some(text) + command.args(args); + } + command.stdout(Stdio::null()).stderr(Stdio::null()); + + match command.status() { + Ok(status) if status.success() => Ok(()), + Ok(status) => Err(AgentError::VerifyFailed(format!( + "{} exited with status {}", + path.display(), + status + ))), + Err(err) => Err(AgentError::VerifyFailed(format!( + "{} failed to execute: {}", + path.display(), + err + ))), } } -fn extract_assistant_message_text(message: &Value) -> Option<String> { - if message.get("role").and_then(Value::as_str) != Some("assistant") { - return None; +fn fetch_registry(url: &Url) -> Result<RegistryDocument, AgentError> { + let client = Client::builder().build()?; + let response = client.get(url.clone()).send()?; + if !response.status().is_success() { + return Err(AgentError::DownloadFailed { url: url.clone() }); } - if let Some(content) = message.get("content") { - return extract_text_from_content_parts(content); - } - None + response + .json::<RegistryDocument>() + .map_err(|err| AgentError::RegistryParse(err.to_string())) } -fn extract_pi_result_text(events: &[Value]) -> Option<String> { - let mut delta_buffer = String::new(); - let mut last_full = None; - for event in events { - if event.get("type").and_then(Value::as_str) == Some("message_update") { - if let Some(delta_kind) = - extract_nested_string(event, &["assistantMessageEvent", "type"]) - { - if delta_kind == "text_delta" { - if let Some(delta) = - extract_nested_string(event, &["assistantMessageEvent", "delta"]) - { - delta_buffer.push_str(&delta); - } - if let Some(delta) = - extract_nested_string(event, &["assistantMessageEvent", "text"]) - { - delta_buffer.push_str(&delta); - } - } +fn resolve_extracted_command(root: &Path, cmd: &str) -> Result<PathBuf, AgentError> { + let normalized = cmd.trim_start_matches("./"); + let direct = root.join(normalized); + if direct.exists() { + return Ok(direct); + } + + let filename = Path::new(normalized) + .file_name() + .and_then(|x| x.to_str()) + .ok_or_else(|| AgentError::ExtractFailed(format!("invalid command path: {cmd}")))?; + + find_file_recursive(root, filename)? + .ok_or_else(|| AgentError::ExtractFailed(format!("missing extracted command: {cmd}"))) +} + +fn unpack_archive(bytes: &[u8], url: &Url, destination: &Path) -> Result<(), AgentError> { + let path = url.path().to_ascii_lowercase(); + if path.ends_with(".zip") { + let reader = io::Cursor::new(bytes.to_vec()); + let mut archive = zip::ZipArchive::new(reader) + .map_err(|err| AgentError::ExtractFailed(err.to_string()))?; + for idx in 0..archive.len() { + let mut file = archive + .by_index(idx) + .map_err(|err| AgentError::ExtractFailed(err.to_string()))?; + let Some(name) = file.enclosed_name().map(|p| p.to_path_buf()) else { + continue; + }; + let out_path = destination.join(name); + if file.is_dir() { + fs::create_dir_all(&out_path)?; + continue; } - } - if let Some(message) = event.get("message") { - if let Some(text) = extract_assistant_message_text(message) { - last_full = Some(text); + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent)?; } + let mut out = fs::File::create(&out_path)?; + io::copy(&mut file, &mut out)?; + let _ = set_executable(&out_path); } - if event.get("type").and_then(Value::as_str) == Some("agent_end") { - if let Some(messages) = event.get("messages").and_then(Value::as_array) { - for message in messages { - if let Some(text) = extract_assistant_message_text(message) { - last_full = Some(text); - } - } - } - } - } - if delta_buffer.is_empty() { - last_full - } else { - Some(delta_buffer) - } -} - -fn apply_pi_model_args(command: &mut Command, model: Option<&str>) { - let Some(model) = model else { - return; - }; - if let Some((provider, model_id)) = model.split_once('/') { - command - .arg("--provider") - .arg(provider) - .arg("--model") - .arg(model_id); - return; - } - command.arg("--model").arg(model); -} - -fn spawn_pi( - path: &Path, - working_dir: &Path, - options: &SpawnOptions, -) -> Result<std::process::Output, AgentError> { - if options.session_id.is_some() { - return Err(AgentError::ResumeUnsupported { agent: AgentId::Pi }); + return Ok(()); } - let mut command = Command::new(path); - command - .current_dir(working_dir) - .arg("--mode") - .arg("json") - .arg("--print"); - apply_pi_model_args(&mut command, options.model.as_deref()); - if let Some(variant) = options.variant.as_deref() { - command.arg("--thinking").arg(variant); - } - command.arg(&options.prompt); - for (key, value) in &options.env { - command.env(key, value); - } - command.output().map_err(AgentError::Io) -} - -fn spawn_amp( - path: &Path, - working_dir: &Path, - options: &SpawnOptions, -) -> Result<std::process::Output, AgentError> { - let flags = detect_amp_flags(path, working_dir).unwrap_or_default(); - let mut args: Vec<&str> = Vec::new(); - if flags.execute { - args.push("--execute"); - args.push(&options.prompt); - } - if flags.output_format { - args.push("--stream-json"); - } - if flags.dangerously_skip_permissions && options.permission_mode.as_deref() == Some("bypass") { - args.push("--dangerously-allow-all"); + if path.ends_with(".tar.gz") || path.ends_with(".tgz") { + let cursor = io::Cursor::new(bytes.to_vec()); + let mut archive = tar::Archive::new(GzDecoder::new(cursor)); + archive.unpack(destination)?; + return Ok(()); } - let mut command = Command::new(path); - command.current_dir(working_dir); - if let Some(session_id) = options.session_id.as_deref() { - command.arg("--continue").arg(session_id); - } - command.args(&args); - for (key, value) in &options.env { - command.env(key, value); - } - let output = command.output().map_err(AgentError::Io)?; - if output.status.success() { - return Ok(output); - } - - let stderr = String::from_utf8_lossy(&output.stderr); - if stderr.contains("unknown option") - || stderr.contains("unknown flag") - || stderr.contains("User message must be provided") - { - return spawn_amp_fallback(path, working_dir, options); - } - - Ok(output) -} - -fn build_amp_command(path: &Path, working_dir: &Path, options: &SpawnOptions) -> Command { - let flags = detect_amp_flags(path, working_dir).unwrap_or_default(); - let mut command = Command::new(path); - command.current_dir(working_dir); - if let Some(session_id) = options.session_id.as_deref() { - command.arg("--continue").arg(session_id); - } - if flags.execute { - command.arg("--execute"); - command.arg(&options.prompt); - } - if flags.output_format { - command.arg("--stream-json"); - } - if flags.dangerously_skip_permissions && options.permission_mode.as_deref() == Some("bypass") { - command.arg("--dangerously-allow-all"); - } - for (key, value) in &options.env { - command.env(key, value); - } - command -} - -#[derive(Debug, Default, Clone, Copy)] -struct AmpFlags { - execute: bool, - output_format: bool, - dangerously_skip_permissions: bool, -} - -fn detect_amp_flags(path: &Path, working_dir: &Path) -> Option<AmpFlags> { - let output = Command::new(path) - .current_dir(working_dir) - .arg("--help") - .output() - .ok()?; - let text = format!( - "{}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - Some(AmpFlags { - execute: text.contains("--execute"), - output_format: text.contains("--stream-json"), - dangerously_skip_permissions: text.contains("--dangerously-allow-all"), - }) -} - -fn spawn_amp_fallback( - path: &Path, - working_dir: &Path, - options: &SpawnOptions, -) -> Result<std::process::Output, AgentError> { - let mut attempts: Vec<Vec<&str>> = vec![ - vec!["--execute"], - vec!["stream-json"], - vec!["--dangerously-allow-all"], - vec![], - ]; - if options.permission_mode.as_deref() != Some("bypass") { - attempts.retain(|args| !args.contains(&"--dangerously-allow-all")); - } - - for args in attempts { - let mut command = Command::new(path); - command.current_dir(working_dir); - if let Some(session_id) = options.session_id.as_deref() { - command.arg("--continue").arg(session_id); - } - if !args.is_empty() { - command.args(&args); - } - command.arg(&options.prompt); - for (key, value) in &options.env { - command.env(key, value); - } - let output = command.output().map_err(AgentError::Io)?; - if output.status.success() { - return Ok(output); - } - } - - let mut command = Command::new(path); - command.current_dir(working_dir); - if let Some(session_id) = options.session_id.as_deref() { - command.arg("--continue").arg(session_id); - } - command.arg(&options.prompt); - for (key, value) in &options.env { - command.env(key, value); - } - Ok(command.output().map_err(AgentError::Io)?) + Err(AgentError::ExtractFailed(format!( + "unsupported archive format: {}", + url + ))) } fn find_in_path(binary_name: &str) -> Option<PathBuf> { @@ -1398,36 +972,18 @@ fn find_in_path(binary_name: &str) -> Option<PathBuf> { if candidate.exists() { return Some(candidate); } + if cfg!(windows) { + let candidate_exe = path.join(format!("{binary_name}.exe")); + if candidate_exe.exists() { + return Some(candidate_exe); + } + } } None } -fn install_cursor(path: &Path, platform: Platform, _version: Option<&str>) -> Result<(), AgentError> { - // Note: cursor-agent binary URL needs to be verified - // Cursor Pro includes cursor-agent, typically installed via: curl -fsS https://cursor.com/install | bash - // For sandbox-agent, we need standalone cursor-agent binary - // TODO: Determine correct download URL for cursor-agent releases - - let platform_segment = match platform { - Platform::LinuxX64 | Platform::LinuxX64Musl => "linux-x64", - Platform::LinuxArm64 => "linux-arm64", - Platform::MacosArm64 => "darwin-arm64", - Platform::MacosX64 => "darwin-x64", - }; - - // Placeholder URL - needs to be updated with actual cursor-agent release URL - let url = Url::parse(&format!( - "https://cursor.com/api/v1/releases/latest/download/cursor-agent-{platform_segment}", - platform_segment = platform_segment - ))?; - - let bytes = download_bytes(&url)?; - write_executable(path, &bytes)?; - Ok(()) -} - fn download_bytes(url: &Url) -> Result<Vec<u8>, AgentError> { - let client = crate::http_client::blocking_client_builder().build()?; + let client = Client::builder().build()?; let mut response = client.get(url.clone()).send()?; if !response.status().is_success() { return Err(AgentError::DownloadFailed { url: url.clone() }); @@ -1437,28 +993,6 @@ fn download_bytes(url: &Url) -> Result<Vec<u8>, AgentError> { Ok(bytes) } -fn install_pi(path: &Path, platform: Platform, version: Option<&str>) -> Result<(), AgentError> { - let asset = match platform { - Platform::LinuxX64 | Platform::LinuxX64Musl => "pi-linux-x64", - Platform::LinuxArm64 => "pi-linux-arm64", - Platform::MacosArm64 => "pi-darwin-arm64", - Platform::MacosX64 => "pi-darwin-x64", - } - .to_string(); - let url = match version { - Some(version) => Url::parse(&format!( - "https://upd.dev/badlogic/pi-mono/releases/download/{version}/{asset}" - ))?, - None => Url::parse(&format!( - "https://upd.dev/badlogic/pi-mono/releases/latest/download/{asset}" - ))?, - }; - - let bytes = download_bytes(&url)?; - write_executable(path, &bytes)?; - Ok(()) -} - fn install_claude( path: &Path, platform: Platform, @@ -1482,6 +1016,8 @@ fn install_claude( Platform::LinuxArm64 => "linux-arm64", Platform::MacosArm64 => "darwin-arm64", Platform::MacosX64 => "darwin-x64", + Platform::WindowsX64 => "win32-x64", + Platform::WindowsArm64 => "win32-arm64", }; let url = Url::parse(&format!( @@ -1510,6 +1046,8 @@ fn install_amp(path: &Path, platform: Platform, version: Option<&str>) -> Result Platform::LinuxArm64 => "linux-arm64", Platform::MacosArm64 => "darwin-arm64", Platform::MacosX64 => "darwin-x64", + Platform::WindowsX64 => "win32-x64", + Platform::WindowsArm64 => "win32-arm64", }; let url = Url::parse(&format!( @@ -1526,6 +1064,8 @@ fn install_codex(path: &Path, platform: Platform, version: Option<&str>) -> Resu Platform::LinuxArm64 => "aarch64-unknown-linux-musl", Platform::MacosArm64 => "aarch64-apple-darwin", Platform::MacosX64 => "x86_64-apple-darwin", + Platform::WindowsX64 => "x86_64-pc-windows-msvc", + Platform::WindowsArm64 => "aarch64-pc-windows-msvc", }; let url = match version { @@ -1543,7 +1083,12 @@ fn install_codex(path: &Path, platform: Platform, version: Option<&str>) -> Resu let mut archive = tar::Archive::new(GzDecoder::new(cursor)); archive.unpack(temp_dir.path())?; - let expected = format!("codex-{target}"); + let expected = if cfg!(windows) { + format!("codex-{target}.exe") + } else { + format!("codex-{target}") + }; + let binary = find_file_recursive(temp_dir.path(), &expected)? .ok_or_else(|| AgentError::ExtractFailed(format!("missing {expected}")))?; move_executable(&binary, path)?; @@ -1578,11 +1123,13 @@ fn install_opencode( }; install_zip_binary(path, &url, "opencode") } - Platform::LinuxX64 | Platform::LinuxX64Musl | Platform::LinuxArm64 => { + _ => { let platform_segment = match platform { Platform::LinuxX64 => "linux-x64", Platform::LinuxX64Musl => "linux-x64-musl", Platform::LinuxArm64 => "linux-arm64", + Platform::WindowsX64 => "win32-x64", + Platform::WindowsArm64 => "win32-arm64", Platform::MacosArm64 | Platform::MacosX64 => unreachable!(), }; let url = match version { @@ -1599,7 +1146,8 @@ fn install_opencode( let cursor = io::Cursor::new(bytes); let mut archive = tar::Archive::new(GzDecoder::new(cursor)); archive.unpack(temp_dir.path())?; - let binary = find_file_recursive(temp_dir.path(), "opencode")? + let binary = find_file_recursive(temp_dir.path(), "opencode") + .or_else(|_| find_file_recursive(temp_dir.path(), "opencode.exe"))? .ok_or_else(|| AgentError::ExtractFailed("missing opencode".to_string()))?; move_executable(&binary, path)?; Ok(()) @@ -1617,7 +1165,9 @@ fn install_zip_binary(path: &Path, url: &Url, binary_name: &str) -> Result<(), A let mut file = archive .by_index(i) .map_err(|err| AgentError::ExtractFailed(err.to_string()))?; - if !file.name().ends_with(binary_name) { + if !file.name().ends_with(binary_name) + && !file.name().ends_with(&format!("{binary_name}.exe")) + { continue; } let out_path = temp_dir.path().join(binary_name); @@ -1681,86 +1231,426 @@ fn find_file_recursive(dir: &Path, filename: &str) -> Result<Option<PathBuf>, Ag Ok(None) } +fn parse_version_output(output: &std::process::Output) -> Option<String> { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{}\n{}", stdout, stderr); + combined + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(|line| match line.find(" (") { + Some(pos) => line[..pos].to_string(), + None => line.to_string(), + }) +} + #[cfg(test)] mod tests { - use serde_json::json; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::{Mutex, OnceLock}; + use std::thread; - use super::{ - extract_result_text, extract_session_id, AgentError, AgentId, AgentManager, SpawnOptions, - }; + use super::*; - #[test] - fn pi_spawn_streaming_fails_fast_with_runtime_contract_error() { - let temp_dir = tempfile::tempdir().expect("temp dir"); - let manager = AgentManager::new(temp_dir.path().join("bin")).expect("agent manager"); - let err = manager - .spawn_streaming(AgentId::Pi, SpawnOptions::new("hello")) - .expect_err("expected Pi spawn_streaming to be rejected"); - assert!(matches!( - err, - AgentError::UnsupportedRuntimePath { - agent: AgentId::Pi, - operation: "spawn_streaming", - .. + fn write_exec(path: &Path, script: &str) { + fs::write(path, script).expect("write script"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).expect("set mode"); + } + } + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + struct EnvVarGuard { + key: &'static str, + previous: Option<std::ffi::OsString>, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &std::ffi::OsStr) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(previous) = &self.previous { + std::env::set_var(self.key, previous); + } else { + std::env::remove_var(self.key); } - )); + } } - #[test] - fn extract_pi_session_id_from_session_event() { - let events = vec![json!({ - "type": "session", - "id": "pi-session-123" - })]; - assert_eq!( - extract_session_id(AgentId::Pi, &events).as_deref(), - Some("pi-session-123") + fn serve_registry_once(document: serde_json::Value) -> Url { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind registry server"); + let addr = listener.local_addr().expect("local addr"); + let body = document.to_string(); + + thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + respond_json(&mut stream, &body); + } + }); + + Url::parse(&format!("http://{addr}/registry.json")).expect("registry url") + } + + fn respond_json(stream: &mut TcpStream, body: &str) { + let mut buffer = [0_u8; 4096]; + let _ = stream.read(&mut buffer); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body ); + stream + .write_all(response.as_bytes()) + .expect("write response"); + stream.flush().expect("flush response"); } #[test] - fn extract_pi_result_text_from_agent_end_message() { - let events = vec![json!({ - "type": "agent_end", - "messages": [ + fn install_is_idempotent_when_native_and_agent_process_exists() { + let temp_dir = tempfile::tempdir().expect("create tempdir"); + let manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64); + + fs::create_dir_all(temp_dir.path().join("agent_processes")) + .expect("create agent processes dir"); + fs::write(manager.binary_path(AgentId::Codex), b"stub").expect("write native binary"); + fs::write(manager.agent_process_path(AgentId::Codex), b"stub") + .expect("write agent process launcher"); + + let result = manager + .install(AgentId::Codex, InstallOptions::default()) + .expect("install should succeed"); + + assert!(result.already_installed); + assert!(result.artifacts.is_empty()); + } + + #[test] + fn split_package_version_handles_scoped_and_unscoped_packages() { + let scoped = split_package_version("@scope/pkg@1.2.3").expect("scoped"); + assert_eq!(scoped.0, "@scope/pkg"); + assert_eq!(scoped.1, "1.2.3"); + + let unscoped = split_package_version("pkg@2.0.0").expect("unscoped"); + assert_eq!(unscoped.0, "pkg"); + assert_eq!(unscoped.1, "2.0.0"); + + assert!(split_package_version("pkg").is_none()); + } + + #[test] + fn install_is_idempotent_for_all_supported_agents_when_artifacts_exist() { + let temp_dir = tempfile::tempdir().expect("create tempdir"); + let manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64); + + fs::create_dir_all(temp_dir.path().join("agent_processes")) + .expect("create agent processes dir"); + + for agent in [AgentId::Claude, AgentId::Codex, AgentId::Opencode] { + fs::write(manager.binary_path(agent), b"stub").expect("write native binary"); + fs::write(manager.agent_process_path(agent), b"stub") + .expect("write agent process launcher"); + } + + // Pi and Cursor only need agent process launchers (native_required = false). + for agent in [AgentId::Pi, AgentId::Cursor] { + fs::write(manager.agent_process_path(agent), b"stub") + .expect("write agent process launcher"); + } + + for agent in [ + AgentId::Claude, + AgentId::Codex, + AgentId::Opencode, + AgentId::Pi, + AgentId::Cursor, + AgentId::Mock, + ] { + let result = manager + .install(agent, InstallOptions::default()) + .expect("install should succeed"); + assert!( + result.already_installed, + "expected idempotent install for {agent}" + ); + assert!(result.artifacts.is_empty(), "no artifacts for {agent}"); + } + } + + #[test] + fn install_uses_registry_provenance_with_agent_process_version_override() { + let _env_lock = env_lock().lock().expect("env lock"); + + let temp_dir = tempfile::tempdir().expect("create tempdir"); + let mut manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64); + + // Keep native install path satisfied locally so install only provisions agent process. + write_exec( + &manager.binary_path(AgentId::Codex), + "#!/usr/bin/env sh\nexit 0\n", + ); + + let bin_dir = temp_dir.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("create bin dir"); + write_exec(&bin_dir.join("npx"), "#!/usr/bin/env sh\nexit 0\n"); + + let original_path = std::env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![bin_dir.clone()]; + paths.extend(std::env::split_paths(&original_path)); + let combined_path = std::env::join_paths(paths).expect("join PATH"); + let _path_guard = EnvVarGuard::set("PATH", &combined_path); + + let registry_url = serve_registry_once(serde_json::json!({ + "agents": [ { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "OK" + "id": "codex-acp", + "version": "1.2.3", + "distribution": { + "npx": { + "package": "@example/codex-acp@1.2.3", + "args": [], + "env": {} } - ] + } } ] - })]; - assert_eq!( - extract_result_text(AgentId::Pi, &events).as_deref(), - Some("OK") + })); + manager.registry_url = registry_url; + + let result = manager + .install( + AgentId::Codex, + InstallOptions { + reinstall: false, + version: None, + agent_process_version: Some("9.9.9".to_string()), + }, + ) + .expect("install succeeds"); + + assert!(!result.already_installed); + let agent_process_artifact = result + .artifacts + .iter() + .find(|artifact| artifact.kind == InstalledArtifactKind::AgentProcess) + .expect("agent process artifact"); + assert_eq!(agent_process_artifact.source, InstallSource::Registry); + assert_eq!(agent_process_artifact.version.as_deref(), Some("9.9.9")); + + let launcher = + fs::read_to_string(manager.agent_process_path(AgentId::Codex)).expect("launcher"); + assert!( + launcher.contains("@example/codex-acp@9.9.9"), + "launcher should include overridden package version" ); } #[test] - fn extract_pi_result_text_from_message_update_deltas() { - let events = vec![ - json!({ - "type": "message_update", - "assistantMessageEvent": { - "type": "text_delta", - "delta": "O" - } - }), - json!({ - "type": "message_update", - "assistantMessageEvent": { - "type": "text_delta", - "delta": "K" - } - }), - ]; + fn install_falls_back_when_registry_entry_missing() { + let _env_lock = env_lock().lock().expect("env lock"); + + let temp_dir = tempfile::tempdir().expect("create tempdir"); + let mut manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64); + + write_exec( + &manager.binary_path(AgentId::Codex), + "#!/usr/bin/env sh\nexit 0\n", + ); + + let bin_dir = temp_dir.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("create bin dir"); + write_exec(&bin_dir.join("npx"), "#!/usr/bin/env sh\nexit 0\n"); + + let original_path = std::env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![bin_dir.clone()]; + paths.extend(std::env::split_paths(&original_path)); + let combined_path = std::env::join_paths(paths).expect("join PATH"); + let _path_guard = EnvVarGuard::set("PATH", &combined_path); + + manager.registry_url = serve_registry_once(serde_json::json!({ "agents": [] })); + + let result = manager + .install(AgentId::Codex, InstallOptions::default()) + .expect("install succeeds"); + assert!(!result.already_installed); + let agent_process_artifact = result + .artifacts + .iter() + .find(|artifact| artifact.kind == InstalledArtifactKind::AgentProcess) + .expect("agent process artifact"); + assert_eq!(agent_process_artifact.source, InstallSource::Fallback); + } + + #[test] + fn reinstall_mock_returns_agent_process_artifact() { + let temp_dir = tempfile::tempdir().expect("create tempdir"); + let manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64); + + let result = manager + .install( + AgentId::Mock, + InstallOptions { + reinstall: true, + version: None, + agent_process_version: None, + }, + ) + .expect("mock reinstall"); + + assert!(!result.already_installed); + assert_eq!(result.artifacts.len(), 1); assert_eq!( - extract_result_text(AgentId::Pi, &events).as_deref(), - Some("OK") + result.artifacts[0].kind, + InstalledArtifactKind::AgentProcess + ); + assert_eq!(result.artifacts[0].source, InstallSource::Builtin); + } + + #[test] + fn install_pi_skips_native_and_writes_fallback_npx_launcher() { + let _env_lock = env_lock().lock().expect("env lock"); + + let temp_dir = tempfile::tempdir().expect("create tempdir"); + let mut manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64); + + let bin_dir = temp_dir.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("create bin dir"); + write_exec(&bin_dir.join("npx"), "#!/usr/bin/env sh\nexit 0\n"); + + let original_path = std::env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![bin_dir.clone()]; + paths.extend(std::env::split_paths(&original_path)); + let combined_path = std::env::join_paths(paths).expect("join PATH"); + let _path_guard = EnvVarGuard::set("PATH", &combined_path); + + // Empty registry so we hit the fallback path. + manager.registry_url = serve_registry_once(serde_json::json!({ "agents": [] })); + + let result = manager + .install(AgentId::Pi, InstallOptions::default()) + .expect("pi install succeeds"); + + // No native artifact (native_required = false). + assert!( + !result + .artifacts + .iter() + .any(|a| a.kind == InstalledArtifactKind::NativeAgent), + "pi should not produce a native artifact" + ); + + let agent_process = result + .artifacts + .iter() + .find(|a| a.kind == InstalledArtifactKind::AgentProcess) + .expect("pi agent process artifact"); + assert_eq!(agent_process.source, InstallSource::Fallback); + + let launcher = + fs::read_to_string(manager.agent_process_path(AgentId::Pi)).expect("read pi launcher"); + assert!( + launcher.contains("pi-acp"), + "pi launcher should reference pi-acp package" + ); + + // resolve_agent_process should now find it. + let spec = manager + .resolve_agent_process(AgentId::Pi) + .expect("resolve pi agent process"); + assert_eq!(spec.source, InstallSource::LocalPath); + + // is_installed should return true. + assert!(manager.is_installed(AgentId::Pi), "pi should be installed"); + + // Second install should be idempotent. + // Need a new registry server since the first one was consumed. + manager.registry_url = serve_registry_once(serde_json::json!({ "agents": [] })); + let result2 = manager + .install(AgentId::Pi, InstallOptions::default()) + .expect("pi re-install succeeds"); + assert!( + result2.already_installed, + "pi re-install should be idempotent" + ); + } + + #[test] + fn install_cursor_skips_native_and_writes_fallback_npx_launcher() { + let _env_lock = env_lock().lock().expect("env lock"); + + let temp_dir = tempfile::tempdir().expect("create tempdir"); + let mut manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64); + + let bin_dir = temp_dir.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("create bin dir"); + write_exec(&bin_dir.join("npx"), "#!/usr/bin/env sh\nexit 0\n"); + + let original_path = std::env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![bin_dir.clone()]; + paths.extend(std::env::split_paths(&original_path)); + let combined_path = std::env::join_paths(paths).expect("join PATH"); + let _path_guard = EnvVarGuard::set("PATH", &combined_path); + + manager.registry_url = serve_registry_once(serde_json::json!({ "agents": [] })); + + let result = manager + .install(AgentId::Cursor, InstallOptions::default()) + .expect("cursor install succeeds"); + + assert!( + !result + .artifacts + .iter() + .any(|a| a.kind == InstalledArtifactKind::NativeAgent), + "cursor should not produce a native artifact" + ); + + let agent_process = result + .artifacts + .iter() + .find(|a| a.kind == InstalledArtifactKind::AgentProcess) + .expect("cursor agent process artifact"); + assert_eq!(agent_process.source, InstallSource::Fallback); + + let launcher = fs::read_to_string(manager.agent_process_path(AgentId::Cursor)) + .expect("read cursor launcher"); + assert!( + launcher.contains("@blowmage/cursor-agent-acp"), + "cursor launcher should reference @blowmage/cursor-agent-acp package" + ); + + let spec = manager + .resolve_agent_process(AgentId::Cursor) + .expect("resolve cursor agent process"); + assert_eq!(spec.source, InstallSource::LocalPath); + + assert!( + manager.is_installed(AgentId::Cursor), + "cursor should be installed" + ); + + manager.registry_url = serve_registry_once(serde_json::json!({ "agents": [] })); + let result2 = manager + .install(AgentId::Cursor, InstallOptions::default()) + .expect("cursor re-install succeeds"); + assert!( + result2.already_installed, + "cursor re-install should be idempotent" ); } } diff --git a/server/packages/agent-management/src/http_client.rs b/server/packages/agent-management/src/http_client.rs deleted file mode 100644 index e9c8bd6..0000000 --- a/server/packages/agent-management/src/http_client.rs +++ /dev/null @@ -1,20 +0,0 @@ -use std::env; - -use reqwest::blocking::ClientBuilder; - -const NO_SYSTEM_PROXY_ENV: &str = "SANDBOX_AGENT_NO_SYSTEM_PROXY"; - -fn disable_system_proxy() -> bool { - env::var(NO_SYSTEM_PROXY_ENV) - .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) - .unwrap_or(false) -} - -pub(crate) fn blocking_client_builder() -> ClientBuilder { - let builder = reqwest::blocking::Client::builder(); - if disable_system_proxy() { - builder.no_proxy() - } else { - builder - } -} diff --git a/server/packages/agent-management/src/lib.rs b/server/packages/agent-management/src/lib.rs index 7d776af..9ef62da 100644 --- a/server/packages/agent-management/src/lib.rs +++ b/server/packages/agent-management/src/lib.rs @@ -1,4 +1,3 @@ pub mod agents; pub mod credentials; -mod http_client; pub mod testing; diff --git a/server/packages/agent-management/src/testing.rs b/server/packages/agent-management/src/testing.rs index e790ca7..6b17f06 100644 --- a/server/packages/agent-management/src/testing.rs +++ b/server/packages/agent-management/src/testing.rs @@ -2,6 +2,7 @@ use std::env; use std::path::PathBuf; use std::time::Duration; +use reqwest::blocking::Client; use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; use reqwest::StatusCode; use thiserror::Error; @@ -35,7 +36,6 @@ pub enum TestAgentConfigError { const AGENTS_ENV: &str = "SANDBOX_TEST_AGENTS"; const ANTHROPIC_ENV: &str = "SANDBOX_TEST_ANTHROPIC_API_KEY"; const OPENAI_ENV: &str = "SANDBOX_TEST_OPENAI_API_KEY"; -const PI_ENV: &str = "SANDBOX_TEST_PI"; const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models"; const OPENAI_MODELS_URL: &str = "https://api.openai.com/v1/models"; const ANTHROPIC_VERSION: &str = "2023-06-01"; @@ -64,6 +64,7 @@ pub fn test_agents_from_env() -> Result<Vec<TestAgentConfig>, TestAgentConfigErr AgentId::Opencode, AgentId::Amp, AgentId::Pi, + AgentId::Cursor, ]); continue; } @@ -74,12 +75,6 @@ pub fn test_agents_from_env() -> Result<Vec<TestAgentConfig>, TestAgentConfigErr agents }; - let include_pi = pi_tests_enabled() && find_in_path(AgentId::Pi.binary_name()); - if !include_pi && agents.iter().any(|agent| *agent == AgentId::Pi) { - eprintln!("Skipping Pi tests: set {PI_ENV}=1 and ensure pi is on PATH."); - } - agents.retain(|agent| *agent != AgentId::Pi || include_pi); - agents.sort_by(|a, b| a.as_str().cmp(b.as_str())); agents.dedup(); @@ -144,22 +139,7 @@ pub fn test_agents_from_env() -> Result<Vec<TestAgentConfig>, TestAgentConfigErr } credentials_with(anthropic_cred.clone(), openai_cred.clone()) } - AgentId::Pi => { - if anthropic_cred.is_none() && openai_cred.is_none() { - return Err(TestAgentConfigError::MissingCredentials { - agent, - missing: format!("{ANTHROPIC_ENV} or {OPENAI_ENV}"), - }); - } - if let Some(cred) = anthropic_cred.as_ref() { - ensure_anthropic_ok(&mut health_cache, cred)?; - } - if let Some(cred) = openai_cred.as_ref() { - ensure_openai_ok(&mut health_cache, cred)?; - } - credentials_with(anthropic_cred.clone(), openai_cred.clone()) - } - AgentId::Cursor => credentials_with(None, None), + AgentId::Pi | AgentId::Cursor => credentials_with(None, None), AgentId::Mock => credentials_with(None, None), }; configs.push(TestAgentConfig { agent, credentials }); @@ -195,7 +175,7 @@ fn ensure_openai_ok( fn health_check_anthropic(credentials: &ProviderCredentials) -> Result<(), TestAgentConfigError> { let credentials = credentials.clone(); run_blocking_check("anthropic", move || { - let client = crate::http_client::blocking_client_builder() + let client = Client::builder() .timeout(Duration::from_secs(10)) .build() .map_err(|err| TestAgentConfigError::HealthCheckFailed { @@ -249,7 +229,7 @@ fn health_check_anthropic(credentials: &ProviderCredentials) -> Result<(), TestA fn health_check_openai(credentials: &ProviderCredentials) -> Result<(), TestAgentConfigError> { let credentials = credentials.clone(); run_blocking_check("openai", move || { - let client = crate::http_client::blocking_client_builder() + let client = Client::builder() .timeout(Duration::from_secs(10)) .build() .map_err(|err| TestAgentConfigError::HealthCheckFailed { @@ -321,15 +301,14 @@ where } fn detect_system_agents() -> Vec<AgentId> { - let mut candidates = vec![ + let candidates = [ AgentId::Claude, AgentId::Codex, AgentId::Opencode, AgentId::Amp, + AgentId::Pi, + AgentId::Cursor, ]; - if pi_tests_enabled() && find_in_path(AgentId::Pi.binary_name()) { - candidates.push(AgentId::Pi); - } let install_dir = default_install_dir(); candidates .into_iter() @@ -371,15 +350,6 @@ fn read_env_key(name: &str) -> Option<String> { }) } -fn pi_tests_enabled() -> bool { - env::var(PI_ENV) - .map(|value| { - let value = value.trim().to_ascii_lowercase(); - value == "1" || value == "true" || value == "yes" - }) - .unwrap_or(false) -} - fn credentials_with( anthropic_cred: Option<ProviderCredentials>, openai_cred: Option<ProviderCredentials>, diff --git a/server/packages/sandbox-agent/src/cli.rs b/server/packages/sandbox-agent/src/cli.rs index 0a4a4bf..2ce5384 100644 --- a/server/packages/sandbox-agent/src/cli.rs +++ b/server/packages/sandbox-agent/src/cli.rs @@ -774,7 +774,6 @@ enum CredentialAgent { Codex, Opencode, Amp, - Pi, } fn credentials_to_output(credentials: ExtractedCredentials, reveal: bool) -> CredentialsOutput { @@ -877,31 +876,6 @@ fn select_token_for_agent( ))) } } - CredentialAgent::Pi => { - if let Some(provider) = provider { - return select_token_for_provider(credentials, provider); - } - if let Some(openai) = credentials.openai.as_ref() { - return Ok(openai.api_key.clone()); - } - if let Some(anthropic) = credentials.anthropic.as_ref() { - return Ok(anthropic.api_key.clone()); - } - if credentials.other.len() == 1 { - if let Some((_, cred)) = credentials.other.iter().next() { - return Ok(cred.api_key.clone()); - } - } - let available = available_providers(credentials); - if available.is_empty() { - Err(CliError::Server("no credentials found for pi".to_string())) - } else { - Err(CliError::Server(format!( - "multiple providers available for pi: {} (use --provider)", - available.join(", ") - ))) - } - } } } diff --git a/server/packages/sandbox-agent/src/http_client.rs b/server/packages/sandbox-agent/src/http_client.rs deleted file mode 100644 index aed5a71..0000000 --- a/server/packages/sandbox-agent/src/http_client.rs +++ /dev/null @@ -1,30 +0,0 @@ -use std::env; - -use reqwest::blocking::ClientBuilder as BlockingClientBuilder; -use reqwest::ClientBuilder; - -const NO_SYSTEM_PROXY_ENV: &str = "SANDBOX_AGENT_NO_SYSTEM_PROXY"; - -fn disable_system_proxy() -> bool { - env::var(NO_SYSTEM_PROXY_ENV) - .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) - .unwrap_or(false) -} - -pub fn client_builder() -> ClientBuilder { - let builder = reqwest::Client::builder(); - if disable_system_proxy() { - builder.no_proxy() - } else { - builder - } -} - -pub fn blocking_client_builder() -> BlockingClientBuilder { - let builder = reqwest::blocking::Client::builder(); - if disable_system_proxy() { - builder.no_proxy() - } else { - builder - } -} diff --git a/server/packages/sandbox-agent/src/lib.rs b/server/packages/sandbox-agent/src/lib.rs index a109867..b5031e1 100644 --- a/server/packages/sandbox-agent/src/lib.rs +++ b/server/packages/sandbox-agent/src/lib.rs @@ -1,13 +1,8 @@ //! Sandbox agent core utilities. -mod acp_runtime; -mod agent_server_logs; -mod opencode_session_manager; -mod universal_events; +mod acp_proxy_runtime; pub mod cli; pub mod daemon; -pub mod http_client; -pub mod opencode_compat; pub mod router; pub mod server_logs; pub mod telemetry; diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs index ff07af6..440b3d2 100644 --- a/server/packages/sandbox-agent/src/opencode_compat.rs +++ b/server/packages/sandbox-agent/src/opencode_compat.rs @@ -10,7 +10,6 @@ use std::str::FromStr; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::sync::Mutex as StdMutex; -use std::time::{Duration, Instant}; use axum::body::Body; use axum::extract::{Path, Query, State}; @@ -32,16 +31,16 @@ use crate::router::{ is_question_tool_action, AgentModelInfo, AppState, CreateSessionRequest, PermissionReply, SessionInfo, }; -use sandbox_agent_agent_management::agents::AgentId; -use sandbox_agent_agent_management::credentials::{ - extract_all_credentials, CredentialExtractionOptions, ExtractedCredentials, -}; -use sandbox_agent_error::SandboxError; -use sandbox_agent_universal_agent_schema::{ +use crate::universal_events::{ ContentPart, FileAction, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus, PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus, UniversalEvent, UniversalEventData, UniversalEventType, UniversalItem, }; +use sandbox_agent_agent_credentials::{ + extract_all_credentials, CredentialExtractionOptions, ExtractedCredentials, +}; +use sandbox_agent_agent_management::agents::AgentId; +use sandbox_agent_error::SandboxError; static SESSION_COUNTER: AtomicU64 = AtomicU64::new(1); static MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1); @@ -53,7 +52,6 @@ const OPENCODE_EVENT_LOG_SIZE: usize = 4096; const OPENCODE_DEFAULT_MODEL_ID: &str = "mock"; const OPENCODE_DEFAULT_PROVIDER_ID: &str = "mock"; const OPENCODE_DEFAULT_AGENT_MODE: &str = "build"; -const OPENCODE_MODEL_CACHE_TTL: Duration = Duration::from_secs(30); const OPENCODE_MODEL_CHANGE_AFTER_SESSION_CREATE_ERROR: &str = "OpenCode compatibility currently does not support changing the model after creating a session. Export with /export and load in to a new session."; #[derive(Clone, Debug)] @@ -153,6 +151,9 @@ impl OpenCodeSessionRecord { if let Some(url) = &self.share_url { map.insert("share".to_string(), json!({"url": url})); } + if let Some(permission_mode) = &self.permission_mode { + map.insert("permissionMode".to_string(), json!(permission_mode)); + } Value::Object(map) } } @@ -164,7 +165,7 @@ fn session_info_to_opencode_value(info: &SessionInfo, default_project_id: &str) .clone() .unwrap_or_else(|| format!("Session {}", info.session_id)); let directory = info.directory.clone().unwrap_or_default(); - json!({ + let mut value = json!({ "id": info.session_id, "slug": format!("session-{}", info.session_id), "projectID": default_project_id, @@ -175,7 +176,15 @@ fn session_info_to_opencode_value(info: &SessionInfo, default_project_id: &str) "created": info.created_at, "updated": info.updated_at, } - }) + }); + if let Some(obj) = value.as_object_mut() { + obj.insert("agent".to_string(), json!(info.agent)); + obj.insert("permissionMode".to_string(), json!(info.permission_mode)); + if let Some(model) = &info.model { + obj.insert("model".to_string(), json!(model)); + } + } + value } #[derive(Clone, Debug)] @@ -303,8 +312,6 @@ struct OpenCodeModelCache { group_names: HashMap<String, String>, default_group: String, default_model: String, - cached_at: Instant, - had_discovery_errors: bool, /// Group IDs that have valid credentials available connected: Vec<String>, } @@ -818,8 +825,6 @@ fn available_agent_ids() -> Vec<AgentId> { AgentId::Codex, AgentId::Opencode, AgentId::Amp, - AgentId::Pi, - AgentId::Cursor, AgentId::Mock, ] } @@ -838,30 +843,18 @@ async fn opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCache { // spawning duplicate provider/model fetches. let mut slot = state.opencode.model_cache.lock().await; if let Some(cache) = slot.as_ref() { - if cache.cached_at.elapsed() < OPENCODE_MODEL_CACHE_TTL { - info!( - entries = cache.entries.len(), - groups = cache.group_names.len(), - connected = cache.connected.len(), - "opencode model cache hit" - ); - return cache.clone(); - } + info!( + entries = cache.entries.len(), + groups = cache.group_names.len(), + connected = cache.connected.len(), + "opencode model cache hit" + ); + return cache.clone(); } - let previous_cache = slot.clone(); let started = std::time::Instant::now(); info!("opencode model cache miss; building cache"); - let mut cache = build_opencode_model_cache(state).await; - if let Some(previous_cache) = previous_cache { - if cache.had_discovery_errors - && cache.entries.is_empty() - && !previous_cache.entries.is_empty() - { - cache = previous_cache; - cache.cached_at = Instant::now(); - } - } + let cache = build_opencode_model_cache(state).await; info!( elapsed_ms = started.elapsed().as_millis() as u64, entries = cache.entries.len(), @@ -902,7 +895,6 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa let mut group_agents: HashMap<String, AgentId> = HashMap::new(); let mut group_names: HashMap<String, String> = HashMap::new(); let mut default_model: Option<String> = None; - let mut had_discovery_errors = false; let agents = available_agent_ids(); let manager = state.inner.session_manager(); @@ -920,10 +912,6 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa let response = match response { Ok(response) => response, Err(err) => { - had_discovery_errors = true; - let (group_id, group_name) = fallback_group_for_agent(agent); - group_agents.entry(group_id.clone()).or_insert(agent); - group_names.entry(group_id).or_insert(group_name); warn!( agent = agent.as_str(), elapsed_ms = elapsed.as_millis() as u64, @@ -941,12 +929,6 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa "opencode model cache fetched agent models" ); - if response.models.is_empty() { - let (group_id, group_name) = fallback_group_for_agent(agent); - group_agents.entry(group_id.clone()).or_insert(agent); - group_names.entry(group_id).or_insert(group_name); - } - let first_model_id = response.models.first().map(|model| model.id.clone()); for model in response.models { let model_id = model.id.clone(); @@ -1031,25 +1013,10 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa } } - // Build connected list based on credential availability + // Build connected list conservatively for deterministic compat behavior. let mut connected = Vec::new(); for group_id in group_names.keys() { - let is_connected = match group_agents.get(group_id) { - Some(AgentId::Claude) | Some(AgentId::Amp) => has_anthropic, - Some(AgentId::Codex) => has_openai, - Some(AgentId::Opencode) => { - // Check the specific provider for opencode groups (e.g., "opencode:anthropic") - match opencode_group_provider(group_id) { - Some("anthropic") => has_anthropic, - Some("openai") => has_openai, - _ => has_anthropic || has_openai, - } - } - Some(AgentId::Pi) => true, - Some(AgentId::Cursor) => true, - Some(AgentId::Mock) => true, - None => false, - }; + let is_connected = matches!(group_agents.get(group_id), Some(AgentId::Mock)); if is_connected { connected.push(group_id.clone()); } @@ -1063,8 +1030,6 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa group_names, default_group, default_model, - cached_at: Instant::now(), - had_discovery_errors, connected, }; info!( @@ -1079,19 +1044,6 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa cache } -fn fallback_group_for_agent(agent: AgentId) -> (String, String) { - if agent == AgentId::Opencode { - return ( - "opencode".to_string(), - agent_display_name(agent).to_string(), - ); - } - ( - agent.as_str().to_string(), - agent_display_name(agent).to_string(), - ) -} - fn resolve_agent_from_model( cache: &OpenCodeModelCache, provider_id: &str, @@ -1205,8 +1157,6 @@ fn agent_display_name(agent: AgentId) -> &'static str { AgentId::Codex => "Codex", AgentId::Opencode => "OpenCode", AgentId::Amp => "Amp", - AgentId::Pi => "Pi", - AgentId::Cursor => "Cursor", AgentId::Mock => "Mock", } } @@ -3295,9 +3245,6 @@ async fn oc_config_providers(State(state): State<Arc<OpenCodeAppState>>) -> impl .or_default() .push(entry); } - for group_id in cache.group_names.keys() { - grouped.entry(group_id.clone()).or_default(); - } let mut providers = Vec::new(); let mut defaults = serde_json::Map::new(); for (group_id, entries) in grouped { @@ -4886,9 +4833,6 @@ async fn oc_provider_list(State(state): State<Arc<OpenCodeAppState>>) -> impl In .or_default() .push(entry); } - for group_id in cache.group_names.keys() { - grouped.entry(group_id.clone()).or_default(); - } let mut providers = Vec::new(); let mut defaults = serde_json::Map::new(); for (group_id, entries) in grouped { @@ -5834,7 +5778,7 @@ pub struct OpenCodeApiDoc; #[cfg(test)] mod tests { use super::*; - use sandbox_agent_universal_agent_schema::ReasoningVisibility; + use crate::universal_events::ReasoningVisibility; fn assistant_item(content: Vec<ContentPart>) -> UniversalItem { UniversalItem { diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 8b94554..99971ff 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -1,102 +1,46 @@ -use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; -use std::convert::Infallible; +use std::collections::{BTreeMap, HashMap}; use std::fs; -use std::io::{BufRead, BufReader, Cursor, Write}; -use std::net::TcpListener; +use std::io::Cursor; use std::path::{Path as StdPath, PathBuf}; -use std::process::{Command, Stdio}; -use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; -use std::sync::{Arc, Weak}; -use std::time::{Duration, Instant}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; use axum::body::Bytes; use axum::extract::{Path, Query, State}; -use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode}; +use axum::http::{header, HeaderMap, Request, StatusCode}; use axum::middleware::Next; -use axum::response::sse::Event; +use axum::response::sse::KeepAlive; use axum::response::{IntoResponse, Response, Sse}; use axum::routing::{delete, get, post}; -use axum::Json; -use axum::Router; -use base64::Engine; -use futures::{stream, StreamExt}; -use reqwest::Client; -use sandbox_agent_error::{AgentError, ErrorType, ProblemDetails, SandboxError}; -use sandbox_agent_universal_agent_schema::{ - codex as codex_schema, convert_amp, convert_claude, convert_codex, convert_opencode, - convert_pi, opencode as opencode_schema, pi as pi_schema, turn_ended_event, - turn_started_event, AgentUnparsedData, ContentPart, ErrorData, - EventConversion, EventSource, FileAction, ItemDeltaData, ItemEventData, ItemKind, ItemRole, - ItemStatus, PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus, - ReasoningVisibility, SessionEndReason, SessionEndedData, SessionStartedData, StderrOutput, - TerminatedBy, TurnEventData, TurnPhase, UniversalEvent, UniversalEventData, - UniversalEventType, UniversalItem, +use axum::{Json, Router}; +use sandbox_agent_agent_management::agents::{ + AgentId, AgentManager, InstallOptions, InstallResult, InstallSource, InstalledArtifactKind, }; +use sandbox_agent_agent_management::credentials::{ + extract_all_credentials, CredentialExtractionOptions, +}; +use sandbox_agent_error::{ErrorType, ProblemDetails, SandboxError}; +use sandbox_agent_opencode_adapter::{build_opencode_router, OpenCodeAdapterConfig}; +use sandbox_agent_opencode_server_manager::{OpenCodeServerManager, OpenCodeServerManagerConfig}; use schemars::JsonSchema; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; +use serde_json::{json, Value}; use tar::Archive; -use tokio::sync::futures::OwnedNotified; -use tokio::sync::{broadcast, mpsc, oneshot, Mutex, Notify}; -use tokio::time::sleep; -use tokio_stream::wrappers::BroadcastStream; -use toml_edit::{value, Array, DocumentMut, Item, Table}; use tower_http::trace::TraceLayer; use tracing::Span; use utoipa::{Modify, OpenApi, ToSchema}; -use crate::agent_server_logs::AgentServerLogs; -use crate::http_client; -use crate::opencode_compat::{build_opencode_router, OpenCodeAppState}; +use crate::acp_proxy_runtime::{AcpProxyRuntime, ProxyPostOutcome}; use crate::ui; -use sandbox_agent_agent_management::agents::{ - AgentError as ManagerError, AgentId, AgentManager, InstallOptions, SpawnOptions, StreamingSpawn, -}; -use sandbox_agent_agent_management::credentials::{ - extract_all_credentials, AuthType, CredentialExtractionOptions, ExtractedCredentials, - ProviderCredentials, -}; -const MOCK_EVENT_DELAY_MS: u64 = 200; -static USER_MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1); -const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models?beta=true"; -const ANTHROPIC_VERSION: &str = "2023-06-01"; -const CODEX_MODEL_LIST_TIMEOUT_SECS: u64 = 10; -const SKILL_ROOTS: [&str; 3] = [".agents/skills", ".claude/skills", ".opencode/skill"]; +mod support; +mod types; +use self::support::*; +pub use self::types::*; -fn claude_fallback_models() -> AgentModelsResponse { - // Claude Code accepts model aliases: default, sonnet, opus, haiku - // These work for both API key and OAuth users - AgentModelsResponse { - models: vec![ - AgentModelInfo { - id: "default".to_string(), - name: Some("Default (recommended)".to_string()), - variants: None, - default_variant: None, - }, - AgentModelInfo { - id: "sonnet".to_string(), - name: Some("Sonnet".to_string()), - variants: None, - default_variant: None, - }, - AgentModelInfo { - id: "opus".to_string(), - name: Some("Opus".to_string()), - variants: None, - default_variant: None, - }, - AgentModelInfo { - id: "haiku".to_string(), - name: Some("Haiku".to_string()), - variants: None, - default_variant: None, - }, - ], - default_model: Some("default".to_string()), - } -} +const APPLICATION_JSON: &str = "application/json"; +const TEXT_EVENT_STREAM: &str = "text/event-stream"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum BrandingMode { @@ -121,12 +65,20 @@ impl BrandingMode { } } +#[derive(Debug, Clone)] +pub(crate) struct CachedAgentVersion { + pub version: Option<String>, + pub path: Option<String>, +} + #[derive(Debug)] pub struct AppState { auth: AuthConfig, agent_manager: Arc<AgentManager>, - session_manager: Arc<SessionManager>, + acp_proxy: Arc<AcpProxyRuntime>, + opencode_server_manager: Arc<OpenCodeServerManager>, pub(crate) branding: BrandingMode, + version_cache: Mutex<HashMap<AgentId, CachedAgentVersion>>, } impl AppState { @@ -140,25 +92,46 @@ impl AppState { branding: BrandingMode, ) -> Self { let agent_manager = Arc::new(agent_manager); - let session_manager = Arc::new(SessionManager::new(agent_manager.clone())); - session_manager - .server_manager - .set_owner(Arc::downgrade(&session_manager)); + let acp_proxy = Arc::new(AcpProxyRuntime::new(agent_manager.clone())); + let opencode_server_manager = Arc::new(OpenCodeServerManager::new( + agent_manager.clone(), + OpenCodeServerManagerConfig { + log_dir: default_opencode_server_log_dir(), + auto_restart: true, + }, + )); Self { auth, agent_manager, - session_manager, + acp_proxy, + opencode_server_manager, branding, + version_cache: Mutex::new(HashMap::new()), } } - pub(crate) fn session_manager(&self) -> Arc<SessionManager> { - self.session_manager.clone() + pub(crate) fn acp_proxy(&self) -> Arc<AcpProxyRuntime> { + self.acp_proxy.clone() } - pub(crate) async fn ensure_opencode_server(&self) -> Result<String, SandboxError> { - self.session_manager.ensure_opencode_server().await + pub(crate) fn agent_manager(&self) -> Arc<AgentManager> { + self.agent_manager.clone() } + + pub(crate) fn opencode_server_manager(&self) -> Arc<OpenCodeServerManager> { + self.opencode_server_manager.clone() + } + + pub(crate) fn purge_version_cache(&self, agent: AgentId) { + self.version_cache.lock().unwrap().remove(&agent); + } +} + +fn default_opencode_server_log_dir() -> PathBuf { + let mut base = dirs::data_local_dir().unwrap_or_else(std::env::temp_dir); + base.push("sandbox-agent"); + base.push("agent-logs"); + base } #[derive(Debug, Clone)] @@ -182,40 +155,34 @@ pub fn build_router(state: AppState) -> Router { pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>) { let mut v1_router = Router::new() - .route("/health", get(get_health)) - .route("/agents", get(list_agents)) - .route("/agents/:agent/install", post(install_agent)) - .route("/agents/:agent/modes", get(get_agent_modes)) - .route("/agents/:agent/models", get(get_agent_models)) - .route("/sessions", get(list_sessions)) - .route("/sessions/:session_id", post(create_session)) - .route("/sessions/:session_id/messages", post(post_message)) + .route("/health", get(get_v1_health)) + .route("/agents", get(get_v1_agents)) + .route("/agents/:agent", get(get_v1_agent)) + .route("/agents/:agent/install", post(post_v1_agent_install)) + .route("/fs/entries", get(get_v1_fs_entries)) + .route("/fs/file", get(get_v1_fs_file).put(put_v1_fs_file)) + .route("/fs/entry", delete(delete_v1_fs_entry)) + .route("/fs/mkdir", post(post_v1_fs_mkdir)) + .route("/fs/move", post(post_v1_fs_move)) + .route("/fs/stat", get(get_v1_fs_stat)) + .route("/fs/upload-batch", post(post_v1_fs_upload_batch)) .route( - "/sessions/:session_id/messages/stream", - post(post_message_stream), - ) - .route("/sessions/:session_id/terminate", post(terminate_session)) - .route("/sessions/:session_id/events", get(get_events)) - .route("/sessions/:session_id/events/sse", get(get_events_sse)) - .route( - "/sessions/:session_id/questions/:question_id/reply", - post(reply_question), + "/config/mcp", + get(get_v1_config_mcp) + .put(put_v1_config_mcp) + .delete(delete_v1_config_mcp), ) .route( - "/sessions/:session_id/questions/:question_id/reject", - post(reject_question), + "/config/skills", + get(get_v1_config_skills) + .put(put_v1_config_skills) + .delete(delete_v1_config_skills), ) + .route("/acp", get(get_v1_acp_servers)) .route( - "/sessions/:session_id/permissions/:permission_id/reply", - post(reply_permission), + "/acp/:server_id", + post(post_v1_acp).get(get_v1_acp).delete(delete_v1_acp), ) - .route("/fs/entries", get(fs_entries)) - .route("/fs/file", get(fs_read_file).put(fs_write_file)) - .route("/fs/entry", delete(fs_delete_entry)) - .route("/fs/mkdir", post(fs_mkdir)) - .route("/fs/move", post(fs_move)) - .route("/fs/stat", get(fs_stat)) - .route("/fs/upload-batch", post(fs_upload_batch)) .with_state(shared.clone()); if shared.auth.token.is_some() { @@ -225,25 +192,24 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>) )); } - let opencode_state = OpenCodeAppState::new(shared.clone()); - let mut opencode_router = build_opencode_router(opencode_state.clone()); - let mut opencode_root_router = build_opencode_router(opencode_state); - if shared.auth.token.is_some() { - opencode_router = opencode_router.layer(axum::middleware::from_fn_with_state( - shared.clone(), - require_token, - )); - opencode_root_router = opencode_root_router.layer(axum::middleware::from_fn_with_state( - shared.clone(), - require_token, - )); - } + let opencode_router = build_opencode_router(OpenCodeAdapterConfig { + auth_token: shared.auth.token.clone(), + sqlite_path: std::env::var("OPENCODE_COMPAT_DB_PATH").ok(), + native_proxy_base_url: std::env::var("OPENCODE_COMPAT_PROXY_URL").ok(), + native_proxy_manager: Some(shared.opencode_server_manager()), + acp_dispatch: Some(shared.acp_proxy() as Arc<dyn sandbox_agent_opencode_adapter::AcpDispatch>), + provider_payload: Some(build_provider_payload_for_opencode(&shared)), + ..OpenCodeAdapterConfig::default() + }) + .unwrap_or_else(|err| { + tracing::error!(error = %err, "failed to initialize opencode adapter router; using fallback"); + Router::new().fallback(opencode_unavailable) + }); let mut router = Router::new() .route("/", get(get_root)) .nest("/v1", v1_router) .nest("/opencode", opencode_router) - .merge(opencode_root_router) .fallback(not_found); router = router.merge(ui::router()); @@ -252,6 +218,7 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>) Ok(value) if value == "0" || value.eq_ignore_ascii_case("false") => false, _ => true, }; + if http_logging { let include_headers = std::env::var("SANDBOX_AGENT_LOG_HTTP_HEADERS").is_ok(); let trace_layer = TraceLayer::new_for_http() @@ -291,70 +258,67 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>) latency_ms = latency.as_millis() ); }); + router = router.layer(trace_layer); } (router, shared) } +async fn opencode_unavailable() -> Response { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "errors": [{"message": "/opencode is unavailable: adapter initialization failed"}] + })), + ) + .into_response() +} + pub async fn shutdown_servers(state: &Arc<AppState>) { - state.session_manager.shutdown().await; + state.acp_proxy().shutdown_all().await; + state.opencode_server_manager().shutdown().await; } #[derive(OpenApi)] #[openapi( paths( - get_health, - install_agent, - get_agent_modes, - get_agent_models, - list_agents, - list_sessions, - create_session, - post_message, - post_message_stream, - terminate_session, - get_events, - get_events_sse, - reply_question, - reject_question, - reply_permission, - fs_entries, - fs_read_file, - fs_write_file, - fs_delete_entry, - fs_mkdir, - fs_move, - fs_stat, - fs_upload_batch + get_v1_health, + get_v1_agents, + get_v1_agent, + post_v1_agent_install, + get_v1_fs_entries, + get_v1_fs_file, + put_v1_fs_file, + delete_v1_fs_entry, + post_v1_fs_mkdir, + post_v1_fs_move, + get_v1_fs_stat, + post_v1_fs_upload_batch, + get_v1_config_mcp, + put_v1_config_mcp, + delete_v1_config_mcp, + get_v1_config_skills, + put_v1_config_skills, + delete_v1_config_skills, + get_v1_acp_servers, + post_v1_acp, + get_v1_acp, + delete_v1_acp ), components( schemas( - AgentInstallRequest, - AgentModeInfo, - AgentModesResponse, - AgentModelInfo, - AgentModelsResponse, + HealthResponse, + ServerStatus, + ServerStatusInfo, AgentCapabilities, AgentInfo, AgentListResponse, - ServerStatus, - ServerStatusInfo, - SessionInfo, - SessionListResponse, - HealthResponse, - CreateSessionRequest, - SkillsConfig, - SkillSource, - McpCommand, - McpRemoteTransport, - McpOAuthConfig, - McpOAuthConfigOrDisabled, - McpServerConfig, - CreateSessionResponse, + AgentInstallRequest, + AgentInstallArtifact, + AgentInstallResponse, FsPathQuery, FsEntriesQuery, - FsSessionQuery, FsDeleteQuery, FsUploadBatchQuery, FsEntryType, @@ -365,50 +329,21 @@ pub async fn shutdown_servers(state: &Arc<AppState>) { FsMoveResponse, FsActionResponse, FsUploadBatchResponse, - MessageRequest, - MessageAttachment, - EventsQuery, - TurnStreamQuery, - EventsResponse, - UniversalEvent, - UniversalEventData, - UniversalEventType, - EventSource, - SessionStartedData, - SessionEndedData, - TurnEventData, - TurnPhase, - SessionEndReason, - TerminatedBy, - StderrOutput, - ItemEventData, - ItemDeltaData, - UniversalItem, - ItemKind, - ItemRole, - ItemStatus, - ContentPart, - FileAction, - ReasoningVisibility, - ErrorData, - AgentUnparsedData, - PermissionEventData, - PermissionStatus, - QuestionEventData, - QuestionStatus, - QuestionReplyRequest, - PermissionReplyRequest, - PermissionReply, + AcpPostQuery, + AcpServerInfo, + AcpServerListResponse, + McpConfigQuery, + SkillsConfigQuery, + McpServerConfig, + SkillsConfig, + SkillSource, ProblemDetails, ErrorType, - AgentError + AcpEnvelope ) ), tags( - (name = "meta", description = "Service metadata"), - (name = "agents", description = "Agent management"), - (name = "sessions", description = "Session management"), - (name = "fs", description = "Filesystem operations") + (name = "v1", description = "ACP proxy v1 API") ), modifiers(&ServerAddon) )] @@ -430,5746 +365,36 @@ pub enum ApiError { impl IntoResponse for ApiError { fn into_response(self) -> Response { - let problem: ProblemDetails = match &self { - ApiError::Sandbox(err) => err.to_problem_details(), + let problem = match &self { + ApiError::Sandbox(error) => problem_from_sandbox_error(error), }; let status = StatusCode::from_u16(problem.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); - (status, Json(problem)).into_response() - } -} - -#[derive(Debug)] -struct SessionState { - session_id: String, - agent: AgentId, - agent_mode: String, - permission_mode: String, - model: Option<String>, - variant: Option<String>, - working_dir: PathBuf, - native_session_id: Option<String>, - pi_runtime: Option<Arc<PiSessionRuntime>>, - ended: bool, - ended_exit_code: Option<i32>, - ended_message: Option<String>, - ended_reason: Option<SessionEndReason>, - terminated_by: Option<TerminatedBy>, - next_event_sequence: u64, - next_item_id: u64, - events: Vec<UniversalEvent>, - pending_questions: HashMap<String, PendingQuestion>, - pending_permissions: HashMap<String, PendingPermission>, - always_allow_actions: HashSet<String>, - item_started: HashSet<String>, - item_delta_seen: HashSet<String>, - item_map: HashMap<String, String>, - mock_sequence: u64, - broadcaster: broadcast::Sender<UniversalEvent>, - opencode_stream_started: bool, - codex_sender: Option<mpsc::UnboundedSender<String>>, - claude_sender: Option<mpsc::UnboundedSender<String>>, - session_started_emitted: bool, - last_claude_message_id: Option<String>, - claude_message_counter: u64, - pending_assistant_native_ids: VecDeque<String>, - pending_assistant_counter: u64, - created_at: i64, - updated_at: i64, - directory: Option<String>, - title: Option<String>, - mcp: Option<BTreeMap<String, McpServerConfig>>, - skills: Option<SkillsConfig>, -} - -#[derive(Debug, Clone)] -struct PendingPermission { - action: String, - metadata: Option<Value>, -} - -#[derive(Debug, Clone)] -struct PendingQuestion { - prompt: String, - options: Vec<String>, -} - -impl SessionState { - fn new( - session_id: String, - agent: AgentId, - request: &CreateSessionRequest, - ) -> Result<Self, SandboxError> { - let (agent_mode, permission_mode) = normalize_modes( - agent, - request.agent_mode.as_deref(), - request.permission_mode.as_deref(), - )?; - let (broadcaster, _rx) = broadcast::channel(256); - let working_dir = std::env::current_dir().map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0); - - Ok(Self { - session_id, - agent, - agent_mode, - permission_mode, - model: request.model.clone(), - variant: request.variant.clone(), - working_dir, - native_session_id: None, - pi_runtime: None, - ended: false, - ended_exit_code: None, - ended_message: None, - ended_reason: None, - terminated_by: None, - next_event_sequence: 0, - next_item_id: 0, - events: Vec::new(), - pending_questions: HashMap::new(), - pending_permissions: HashMap::new(), - always_allow_actions: HashSet::new(), - item_started: HashSet::new(), - item_delta_seen: HashSet::new(), - item_map: HashMap::new(), - mock_sequence: 0, - broadcaster, - opencode_stream_started: false, - codex_sender: None, - claude_sender: None, - session_started_emitted: false, - last_claude_message_id: None, - claude_message_counter: 0, - pending_assistant_native_ids: VecDeque::new(), - pending_assistant_counter: 0, - created_at: now, - updated_at: now, - directory: request.directory.clone(), - title: request.title.clone(), - mcp: request.mcp.clone(), - skills: request.skills.clone(), - }) - } - - fn next_pending_assistant_native_id(&mut self) -> String { - self.pending_assistant_counter += 1; - format!( - "{}_pending_assistant_{}", - self.session_id, self.pending_assistant_counter - ) - } - - fn enqueue_pending_assistant_start(&mut self) -> EventConversion { - let native_item_id = self.next_pending_assistant_native_id(); - self.pending_assistant_native_ids - .push_back(native_item_id.clone()); - EventConversion::new( - UniversalEventType::ItemStarted, - UniversalEventData::Item(ItemEventData { - item: UniversalItem { - item_id: String::new(), - native_item_id: Some(native_item_id), - parent_id: None, - kind: ItemKind::Message, - role: Some(ItemRole::Assistant), - content: Vec::new(), - status: ItemStatus::InProgress, - }, - }), - ) - .synthetic() - } - - fn record_conversions(&mut self, conversions: Vec<EventConversion>) -> Vec<UniversalEvent> { - let mut events = Vec::new(); - for conversion in conversions { - for normalized in self.normalize_conversion(conversion) { - if let Some(event) = self.push_event(normalized) { - events.push(event); - } - } - } - if !events.is_empty() { - self.updated_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(self.updated_at); - } - events - } - - fn set_codex_sender(&mut self, sender: Option<mpsc::UnboundedSender<String>>) { - self.codex_sender = sender; - } - - // Note: This is unused now that Codex uses the shared server model, - // but keeping it for potential future use with other agents. - #[allow(dead_code)] - fn codex_sender(&self) -> Option<mpsc::UnboundedSender<String>> { - self.codex_sender.clone() - } - - fn set_claude_sender(&mut self, sender: Option<mpsc::UnboundedSender<String>>) { - self.claude_sender = sender; - } - - #[allow(dead_code)] - fn claude_sender(&self) -> Option<mpsc::UnboundedSender<String>> { - self.claude_sender.clone() - } - - fn normalize_conversion(&mut self, mut conversion: EventConversion) -> Vec<EventConversion> { - if self.native_session_id.is_none() && conversion.native_session_id.is_some() { - self.native_session_id = conversion.native_session_id.clone(); - } - if conversion.native_session_id.is_none() { - conversion.native_session_id = self.native_session_id.clone(); - } - - let mut conversions = Vec::new(); - if !agent_supports_item_started(self.agent) { - if conversion.event_type == UniversalEventType::ItemStarted { - if let UniversalEventData::Item(ref data) = conversion.data { - let is_assistant_message = data.item.kind == ItemKind::Message - && matches!(data.item.role, Some(ItemRole::Assistant)); - if is_assistant_message { - let keep = data - .item - .native_item_id - .as_ref() - .map(|id| self.pending_assistant_native_ids.contains(id)) - .unwrap_or(false); - if !keep { - return conversions; - } - } - } - } - match conversion.event_type { - UniversalEventType::ItemCompleted => { - if let UniversalEventData::Item(ref mut data) = conversion.data { - let is_assistant_message = data.item.kind == ItemKind::Message - && matches!(data.item.role, Some(ItemRole::Assistant)); - if is_assistant_message { - if let Some(pending) = self.pending_assistant_native_ids.pop_front() { - data.item.native_item_id = Some(pending); - data.item.item_id.clear(); - } - } - } - } - UniversalEventType::ItemDelta => { - if let UniversalEventData::ItemDelta(ref mut data) = conversion.data { - let is_user = data - .native_item_id - .as_ref() - .is_some_and(|id| id.starts_with("user_")); - if !is_user { - if let Some(pending) = self.pending_assistant_native_ids.front() { - data.native_item_id = Some(pending.clone()); - data.item_id.clear(); - } - } - } - } - _ => {} - } - } - match conversion.event_type { - UniversalEventType::ItemStarted | UniversalEventType::ItemCompleted => { - if let UniversalEventData::Item(ref mut data) = conversion.data { - self.ensure_item_id(&mut data.item); - self.ensure_parent_id(&mut data.item); - if conversion.event_type == UniversalEventType::ItemCompleted - && !self.item_started.contains(&data.item.item_id) - { - let mut started_item = data.item.clone(); - started_item.status = ItemStatus::InProgress; - conversions.push( - EventConversion::new( - UniversalEventType::ItemStarted, - UniversalEventData::Item(ItemEventData { item: started_item }), - ) - .synthetic() - .with_native_session(conversion.native_session_id.clone()), - ); - } - if conversion.event_type == UniversalEventType::ItemCompleted - && data.item.kind == ItemKind::Message - && !matches!(data.item.role, Some(ItemRole::User)) - && !self.item_delta_seen.contains(&data.item.item_id) - { - if let Some(delta) = text_delta_from_parts(&data.item.content) { - conversions.push( - EventConversion::new( - UniversalEventType::ItemDelta, - UniversalEventData::ItemDelta(ItemDeltaData { - item_id: data.item.item_id.clone(), - native_item_id: data.item.native_item_id.clone(), - delta, - }), - ) - .synthetic() - .with_native_session(conversion.native_session_id.clone()), - ); - } - } - } - } - UniversalEventType::ItemDelta => { - if let UniversalEventData::ItemDelta(ref mut data) = conversion.data { - if data.item_id.is_empty() { - data.item_id = match data.native_item_id.as_ref() { - Some(native) => self.item_id_for_native(native), - None => self.next_item_id(), - }; - } - } - } - _ => {} - } - - conversions.push(conversion); - conversions - } - - fn push_event(&mut self, conversion: EventConversion) -> Option<UniversalEvent> { - if conversion.event_type == UniversalEventType::SessionStarted { - if self.session_started_emitted { - return None; - } - self.session_started_emitted = true; - } - if conversion.event_type == UniversalEventType::SessionEnded - && agent_supports_resume(self.agent) - && !conversion.synthetic - { - return None; - } - if conversion.event_type == UniversalEventType::ItemStarted { - if let UniversalEventData::Item(ref data) = conversion.data { - if self.item_started.contains(&data.item.item_id) { - return None; - } - } - } - - self.next_event_sequence += 1; - let sequence = self.next_event_sequence; - let event = UniversalEvent { - event_id: format!("evt_{sequence}"), - sequence, - time: now_rfc3339(), - session_id: self.session_id.clone(), - native_session_id: conversion.native_session_id.clone(), - synthetic: conversion.synthetic, - source: conversion.source, - event_type: conversion.event_type, - data: conversion.data, - raw: conversion.raw, - }; - - self.update_pending(&event); - self.update_item_tracking(&event); - - // Suppress question-tool permissions (AskUserQuestion/ExitPlanMode) from frontends. - // The permission is still stored in pending_permissions (via update_pending above) - // so reply_question/reject_question can find and resolve it internally. - if matches!( - event.event_type, - UniversalEventType::PermissionRequested | UniversalEventType::PermissionResolved - ) { - if let UniversalEventData::Permission(ref data) = event.data { - if is_question_tool_action(&data.action) { - return None; - } - } - } - if event.event_type == UniversalEventType::PermissionRequested - && self.permission_mode == "acceptEdits" - { - if let UniversalEventData::Permission(ref data) = event.data { - if is_file_change_action(&data.action) { - return None; - } - } - } - - self.events.push(event.clone()); - let _ = self.broadcaster.send(event.clone()); - if self.native_session_id.is_none() { - self.native_session_id = event.native_session_id.clone(); - } - Some(event) - } - - fn update_pending(&mut self, event: &UniversalEvent) { - match event.event_type { - UniversalEventType::QuestionRequested => { - if let UniversalEventData::Question(data) = &event.data { - self.pending_questions.insert( - data.question_id.clone(), - PendingQuestion { - prompt: data.prompt.clone(), - options: data.options.clone(), - }, - ); - } - } - UniversalEventType::QuestionResolved => { - if let UniversalEventData::Question(data) = &event.data { - self.pending_questions.remove(&data.question_id); - } - } - UniversalEventType::PermissionRequested => { - if let UniversalEventData::Permission(data) = &event.data { - self.pending_permissions.insert( - data.permission_id.clone(), - PendingPermission { - action: data.action.clone(), - metadata: data.metadata.clone(), - }, - ); - } - } - UniversalEventType::PermissionResolved => { - if let UniversalEventData::Permission(data) = &event.data { - self.pending_permissions.remove(&data.permission_id); - } - } - _ => {} - } - } - - fn update_item_tracking(&mut self, event: &UniversalEvent) { - match event.event_type { - UniversalEventType::ItemStarted | UniversalEventType::ItemCompleted => { - if let UniversalEventData::Item(data) = &event.data { - self.item_started.insert(data.item.item_id.clone()); - if let Some(native) = data.item.native_item_id.as_ref() { - self.item_map - .insert(native.clone(), data.item.item_id.clone()); - } - } - } - UniversalEventType::ItemDelta => { - if let UniversalEventData::ItemDelta(data) = &event.data { - self.item_delta_seen.insert(data.item_id.clone()); - if let Some(native) = data.native_item_id.as_ref() { - self.item_map.insert(native.clone(), data.item_id.clone()); - } - } - } - _ => {} - } - } - - fn take_question(&mut self, question_id: &str) -> Option<PendingQuestion> { - self.pending_questions.remove(question_id) - } - - fn take_permission(&mut self, permission_id: &str) -> Option<PendingPermission> { - self.pending_permissions.remove(permission_id) - } - - fn remember_permission_allow_for_session(&mut self, action: &str, metadata: &Option<Value>) { - for key in permission_cache_keys(action, metadata) { - self.always_allow_actions.insert(key); - } - } - - fn should_auto_approve_permission(&self, action: &str, metadata: &Option<Value>) -> bool { - permission_cache_keys(action, metadata) - .iter() - .any(|key| self.always_allow_actions.contains(key)) - } - - /// Find and remove a pending permission whose action matches a question tool - /// (AskUserQuestion or ExitPlanMode variants). Returns (permission_id, PendingPermission). - fn take_question_tool_permission(&mut self) -> Option<(String, PendingPermission)> { - let key = self - .pending_permissions - .iter() - .find(|(_, p)| is_question_tool_action(&p.action)) - .map(|(k, _)| k.clone()); - key.and_then(|k| self.pending_permissions.remove(&k).map(|p| (k, p))) - } - - fn mark_ended( - &mut self, - exit_code: Option<i32>, - message: String, - reason: SessionEndReason, - terminated_by: TerminatedBy, - ) { - self.ended = true; - self.ended_exit_code = exit_code; - self.ended_message = Some(message); - self.ended_reason = Some(reason); - self.terminated_by = Some(terminated_by); - } - - fn ended_error(&self) -> Option<SandboxError> { - self.ended_error_for_messages(false) - } - - /// Returns an error if the session cannot accept new messages. - /// `for_new_message` should be true when checking before sending a new message - - /// this allows agents that support resumption (Claude, Amp, OpenCode) to continue - /// after their process exits successfully. - fn ended_error_for_messages(&self, for_new_message: bool) -> Option<SandboxError> { - if !self.ended { - return None; - } - if matches!(self.terminated_by, Some(TerminatedBy::Daemon)) { - return Some(SandboxError::InvalidRequest { - message: "session terminated".to_string(), - }); - } - // For agents that support resumption (Claude, Amp, OpenCode), allow new messages - // after the process exits with success (Completed reason). The new message will - // spawn a fresh process with --resume/--continue to continue the conversation. - if for_new_message - && matches!(self.ended_reason, Some(SessionEndReason::Completed)) - && agent_supports_resume(self.agent) - { - return None; - } - Some(SandboxError::AgentProcessExited { - agent: self.agent.as_str().to_string(), - exit_code: self.ended_exit_code, - stderr: self.ended_message.clone(), - }) - } - - fn ensure_item_id(&mut self, item: &mut UniversalItem) { - if item.item_id.is_empty() { - if let Some(native) = item.native_item_id.as_ref() { - item.item_id = self.item_id_for_native(native); - } else { - item.item_id = self.next_item_id(); - } - } - } - - fn ensure_parent_id(&mut self, item: &mut UniversalItem) { - let Some(parent_id) = item.parent_id.clone() else { - return; - }; - if parent_id.starts_with("itm_") { - return; - } - let mapped = self.item_id_for_native(&parent_id); - item.parent_id = Some(mapped); - } - - fn item_id_for_native(&mut self, native: &str) -> String { - if let Some(item_id) = self.item_map.get(native) { - return item_id.clone(); - } - let item_id = self.next_item_id(); - self.item_map.insert(native.to_string(), item_id.clone()); - item_id - } - - fn next_item_id(&mut self) -> String { - self.next_item_id += 1; - format!("itm_{}", self.next_item_id) - } -} - -#[derive(Debug)] -enum ManagedServerKind { - Http { base_url: String }, - StdioCodex { server: Arc<CodexServer> }, -} - -#[derive(Debug)] -struct ManagedServer { - kind: ManagedServerKind, - child: Arc<std::sync::Mutex<Option<std::process::Child>>>, - status: ServerStatus, - start_time: Option<Instant>, - restart_count: u64, - last_error: Option<String>, - shutdown_requested: bool, - instance_id: u64, -} - -#[derive(Debug)] -struct AgentServerManager { - agent_manager: Arc<AgentManager>, - servers: Mutex<HashMap<AgentId, ManagedServer>>, - sessions: Mutex<HashMap<AgentId, HashSet<String>>>, - native_sessions: Mutex<HashMap<AgentId, HashMap<String, String>>>, - http_client: Client, - log_base_dir: PathBuf, - auto_restart: bool, - owner: std::sync::Mutex<Option<Weak<SessionManager>>>, - #[cfg(feature = "test-utils")] - restart_notifier: Mutex<Option<mpsc::UnboundedSender<AgentId>>>, -} - -#[derive(Debug)] -pub(crate) struct SessionManager { - agent_manager: Arc<AgentManager>, - sessions: Mutex<Vec<SessionState>>, - server_manager: Arc<AgentServerManager>, - http_client: Client, - model_catalog: Mutex<ModelCatalogState>, -} - -#[derive(Debug, Default)] -struct ModelCatalogState { - models: HashMap<AgentId, AgentModelsResponse>, - in_flight: HashMap<AgentId, Arc<Notify>>, -} - -/// Shared Codex app-server process that handles multiple sessions via JSON-RPC. -/// Similar to OpenCode's server model - a single long-running process that multiplexes -/// multiple thread (session) conversations. -struct CodexServer { - /// Sender for writing to the process stdin - stdin_sender: mpsc::UnboundedSender<String>, - /// Pending JSON-RPC requests awaiting responses, keyed by request ID - pending_requests: std::sync::Mutex<HashMap<i64, oneshot::Sender<CodexRequestResult>>>, - /// Optional mapping from request ID to session ID for routing request-scoped errors - request_sessions: std::sync::Mutex<HashMap<i64, String>>, - /// Next request ID for JSON-RPC - next_id: AtomicI64, - /// Whether initialize/initialized handshake has completed - initialized: std::sync::Mutex<bool>, - /// Serializes initialize handshakes so only one request is in flight at a time. - initialize_lock: Mutex<()>, - /// Mapping from thread_id to session_id for routing notifications - thread_sessions: std::sync::Mutex<HashMap<String, String>>, -} - -impl std::fmt::Debug for CodexServer { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("CodexServer") - .field("next_id", &self.next_id.load(Ordering::SeqCst)) - .finish() - } -} - -impl CodexServer { - fn new(stdin_sender: mpsc::UnboundedSender<String>) -> Self { - Self { - stdin_sender, - pending_requests: std::sync::Mutex::new(HashMap::new()), - request_sessions: std::sync::Mutex::new(HashMap::new()), - next_id: AtomicI64::new(1), - initialized: std::sync::Mutex::new(false), - initialize_lock: Mutex::new(()), - thread_sessions: std::sync::Mutex::new(HashMap::new()), - } - } - - fn next_request_id(&self) -> i64 { - self.next_id.fetch_add(1, Ordering::SeqCst) - } - - fn send_request( - &self, - id: i64, - request: &impl Serialize, - ) -> Option<oneshot::Receiver<CodexRequestResult>> { - self.send_request_with_session(id, request, None) - } - - fn send_request_with_session( - &self, - id: i64, - request: &impl Serialize, - session_id: Option<String>, - ) -> Option<oneshot::Receiver<CodexRequestResult>> { - let (tx, rx) = oneshot::channel(); - { - let mut pending = self.pending_requests.lock().unwrap(); - pending.insert(id, tx); - } - if let Some(session_id) = session_id { - let mut sessions = self.request_sessions.lock().unwrap(); - sessions.insert(id, session_id); - } - let line = serde_json::to_string(request).ok()?; - if self.stdin_sender.send(line).is_err() { - let mut pending = self.pending_requests.lock().unwrap(); - pending.remove(&id); - let mut sessions = self.request_sessions.lock().unwrap(); - sessions.remove(&id); - return None; - } - Some(rx) - } - - fn send_notification(&self, notification: &impl Serialize) -> bool { - let Ok(line) = serde_json::to_string(notification) else { - return false; - }; - self.stdin_sender.send(line).is_ok() - } - - fn complete_request(&self, id: i64, result: CodexRequestResult) { - let tx = { - let mut pending = self.pending_requests.lock().unwrap(); - pending.remove(&id) - }; - if let Some(tx) = tx { - let _ = tx.send(result); - } - } - - fn take_request_session(&self, id: i64) -> Option<String> { - let mut sessions = self.request_sessions.lock().unwrap(); - sessions.remove(&id) - } - - fn register_thread(&self, thread_id: String, session_id: String) { - let mut sessions = self.thread_sessions.lock().unwrap(); - sessions.insert(thread_id, session_id); - } - - fn session_for_thread(&self, thread_id: &str) -> Option<String> { - let sessions = self.thread_sessions.lock().unwrap(); - sessions.get(thread_id).cloned() - } - - fn is_initialized(&self) -> bool { - *self.initialized.lock().unwrap() - } - - fn set_initialized(&self) { - *self.initialized.lock().unwrap() = true; - } - - fn clear_pending(&self) { - let mut pending = self.pending_requests.lock().unwrap(); - pending.clear(); - let mut sessions = self.request_sessions.lock().unwrap(); - sessions.clear(); - } - - fn clear_threads(&self) { - let mut sessions = self.thread_sessions.lock().unwrap(); - sessions.clear(); - } -} - -/// Long-lived Pi RPC process dedicated to exactly one daemon session. -struct PiSessionRuntime { - /// Sender for writing to the process stdin. - stdin_sender: mpsc::UnboundedSender<String>, - /// Pending RPC requests awaiting responses, keyed by request ID. - pending_requests: std::sync::Mutex<HashMap<i64, oneshot::Sender<Value>>>, - /// Next request ID for RPC. - next_id: AtomicI64, - /// Per-session conversion state (partial tool results, reasoning buffers). - converter: std::sync::Mutex<convert_pi::PiEventConverter>, - /// Child process handle for lifecycle management. - child: Arc<std::sync::Mutex<Option<std::process::Child>>>, - /// True when daemon-initiated shutdown/terminate requested. - shutdown_requested: AtomicBool, -} - -impl std::fmt::Debug for PiSessionRuntime { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("PiSessionRuntime") - .field("next_id", &self.next_id.load(Ordering::SeqCst)) - .field( - "shutdown_requested", - &self.shutdown_requested.load(Ordering::SeqCst), - ) - .finish() - } -} - -impl PiSessionRuntime { - fn new( - stdin_sender: mpsc::UnboundedSender<String>, - child: Arc<std::sync::Mutex<Option<std::process::Child>>>, - ) -> Self { - Self { - stdin_sender, - pending_requests: std::sync::Mutex::new(HashMap::new()), - next_id: AtomicI64::new(1), - converter: std::sync::Mutex::new(convert_pi::PiEventConverter::default()), - child, - shutdown_requested: AtomicBool::new(false), - } - } - - fn next_request_id(&self) -> i64 { - self.next_id.fetch_add(1, Ordering::SeqCst) - } - - fn send_request(&self, id: i64, request: &impl Serialize) -> Option<oneshot::Receiver<Value>> { - let (tx, rx) = oneshot::channel(); - { - let mut pending = self.pending_requests.lock().unwrap(); - pending.insert(id, tx); - } - let line = serde_json::to_string(request).ok()?; - self.stdin_sender.send(line).ok()?; - Some(rx) - } - - fn complete_request(&self, id: i64, result: Value) { - let tx = { - let mut pending = self.pending_requests.lock().unwrap(); - pending.remove(&id) - }; - if let Some(tx) = tx { - let _ = tx.send(result); - } - } - - fn clear_pending(&self) { - let mut pending = self.pending_requests.lock().unwrap(); - pending.clear(); - } - - fn mark_shutdown_requested(&self) { - self.shutdown_requested.store(true, Ordering::SeqCst); - } - - fn shutdown_requested(&self) -> bool { - self.shutdown_requested.load(Ordering::SeqCst) - } - - fn kill_process(&self) { - if let Ok(mut guard) = self.child.lock() { - if let Some(child) = guard.as_mut() { - let _ = child.kill(); - } - } - } - - fn shutdown(&self) { - self.mark_shutdown_requested(); - self.clear_pending(); - self.kill_process(); - } -} - -#[derive(Debug)] -struct PiSessionBootstrap { - runtime: Arc<PiSessionRuntime>, - native_session_id: String, - _session_file: Option<String>, -} - -#[derive(Debug, Clone)] -enum CodexRequestResult { - Response(Value), - Error(codex_schema::JsonrpcErrorError), -} - -pub(crate) struct SessionSubscription { - pub(crate) initial_events: Vec<UniversalEvent>, - pub(crate) receiver: broadcast::Receiver<UniversalEvent>, -} - -#[derive(Debug, Clone)] -pub(crate) struct PendingPermissionInfo { - pub session_id: String, - pub permission_id: String, - pub action: String, - pub metadata: Option<Value>, -} - -#[derive(Debug, Clone)] -pub(crate) struct PendingQuestionInfo { - pub session_id: String, - pub question_id: String, - pub prompt: String, - pub options: Vec<String>, -} - -impl ManagedServer { - fn base_url(&self) -> Option<String> { - match &self.kind { - ManagedServerKind::Http { base_url } => Some(base_url.clone()), - ManagedServerKind::StdioCodex { .. } => None, - } - } - - fn status_info(&self) -> ServerStatusInfo { - let uptime_ms = self - .start_time - .map(|started| started.elapsed().as_millis() as u64); - ServerStatusInfo { - status: self.status.clone(), - base_url: self.base_url(), - uptime_ms, - restart_count: self.restart_count, - last_error: self.last_error.clone(), - } - } -} - -impl AgentServerManager { - fn new( - agent_manager: Arc<AgentManager>, - http_client: Client, - log_base_dir: PathBuf, - auto_restart: bool, - ) -> Self { - Self { - agent_manager, - servers: Mutex::new(HashMap::new()), - sessions: Mutex::new(HashMap::new()), - native_sessions: Mutex::new(HashMap::new()), - http_client, - log_base_dir, - auto_restart, - owner: std::sync::Mutex::new(None), - #[cfg(feature = "test-utils")] - restart_notifier: Mutex::new(None), - } - } - - fn set_owner(&self, owner: Weak<SessionManager>) { - *self.owner.lock().expect("owner lock") = Some(owner); - } - - #[cfg(feature = "test-utils")] - async fn set_owner_async(&self, owner: Weak<SessionManager>) { - *self.owner.lock().expect("owner lock") = Some(owner); - } - - #[cfg(feature = "test-utils")] - async fn set_restart_notifier(&self, tx: mpsc::UnboundedSender<AgentId>) { - *self.restart_notifier.lock().await = Some(tx); - } - - async fn register_session( - &self, - agent: AgentId, - session_id: &str, - native_session_id: Option<&str>, - ) { - let mut sessions = self.sessions.lock().await; - sessions - .entry(agent) - .or_insert_with(HashSet::new) - .insert(session_id.to_string()); - drop(sessions); - if let Some(native_session_id) = native_session_id { - let mut natives = self.native_sessions.lock().await; - natives - .entry(agent) - .or_insert_with(HashMap::new) - .insert(native_session_id.to_string(), session_id.to_string()); - } - } - - async fn unregister_session( - &self, - agent: AgentId, - session_id: &str, - native_session_id: Option<&str>, - ) { - let mut clear_agent = false; - let mut sessions_map = self.sessions.lock().await; - if let Some(session_set) = sessions_map.get_mut(&agent) { - session_set.remove(session_id); - if session_set.is_empty() { - sessions_map.remove(&agent); - clear_agent = true; - } - } - drop(sessions_map); - if let Some(native_session_id) = native_session_id { - let mut natives = self.native_sessions.lock().await; - if let Some(natives) = natives.get_mut(&agent) { - natives.remove(native_session_id); - } - } - if clear_agent { - let mut natives = self.native_sessions.lock().await; - natives.remove(&agent); - } - } - - async fn clear_mappings(&self, agent: AgentId) { - let mut sessions = self.sessions.lock().await; - sessions.remove(&agent); - drop(sessions); - let mut natives = self.native_sessions.lock().await; - natives.remove(&agent); - } - - async fn status_snapshot(&self) -> HashMap<AgentId, ServerStatusInfo> { - let servers = self.servers.lock().await; - servers - .iter() - .map(|(agent, server)| (*agent, server.status_info())) - .collect() - } - - async fn ensure_http_server(self: &Arc<Self>, agent: AgentId) -> Result<String, SandboxError> { - { - let servers = self.servers.lock().await; - if let Some(server) = servers.get(&agent) { - if matches!(server.status, ServerStatus::Running) { - if let Some(base_url) = server.base_url() { - return Ok(base_url); - } - } - } - } - - let (base_url, child) = self.spawn_http_server(agent).await?; - let restart_count = { - let servers = self.servers.lock().await; - servers - .get(&agent) - .map(|server| server.restart_count + 1) - .unwrap_or(0) - }; - - { - let mut servers = self.servers.lock().await; - if let Some(existing) = servers.get(&agent) { - if matches!(existing.status, ServerStatus::Running) { - if let Ok(mut guard) = child.lock() { - if let Some(child) = guard.as_mut() { - let _ = child.kill(); - } - } - if let Some(base_url) = existing.base_url() { - return Ok(base_url); - } - } - } - - servers.insert( - agent, - ManagedServer { - kind: ManagedServerKind::Http { - base_url: base_url.clone(), - }, - child: child.clone(), - status: ServerStatus::Running, - start_time: Some(Instant::now()), - restart_count, - last_error: None, - shutdown_requested: false, - instance_id: restart_count, - }, - ); - } - - if let Err(err) = self.wait_for_http_server(&base_url).await { - if let Ok(mut guard) = child.lock() { - if let Some(child) = guard.as_mut() { - let _ = child.kill(); - } - } - self.update_server_error(agent, err.to_string()).await; - return Err(err); - } - - self.spawn_monitor_task(agent, restart_count, child); - - Ok(base_url) - } - - async fn ensure_stdio_server( - self: &Arc<Self>, - agent: AgentId, - ) -> Result<(Arc<CodexServer>, Option<mpsc::UnboundedReceiver<String>>), SandboxError> { - { - let servers = self.servers.lock().await; - if let Some(server) = servers.get(&agent) { - if matches!(server.status, ServerStatus::Running) { - if let ManagedServerKind::StdioCodex { server } = &server.kind { - return Ok((server.clone(), None)); - } - } - } - } - - let (server, stdout_rx, child) = self.spawn_stdio_server(agent).await?; - let restart_count = { - let servers = self.servers.lock().await; - servers - .get(&agent) - .map(|server| server.restart_count + 1) - .unwrap_or(0) - }; - - { - let mut servers = self.servers.lock().await; - if let Some(existing) = servers.get(&agent) { - if matches!(existing.status, ServerStatus::Running) { - if let Ok(mut guard) = child.lock() { - if let Some(child) = guard.as_mut() { - let _ = child.kill(); - } - } - if let ManagedServerKind::StdioCodex { server } = &existing.kind { - return Ok((server.clone(), None)); - } - } - } - servers.insert( - agent, - ManagedServer { - kind: ManagedServerKind::StdioCodex { - server: server.clone(), - }, - child: child.clone(), - status: ServerStatus::Running, - start_time: Some(Instant::now()), - restart_count, - last_error: None, - shutdown_requested: false, - instance_id: restart_count, - }, - ); - } - - self.spawn_monitor_task(agent, restart_count, child); - - Ok((server, Some(stdout_rx))) - } - - async fn shutdown(&self) { - let mut servers = self.servers.lock().await; - for server in servers.values_mut() { - server.shutdown_requested = true; - server.status = ServerStatus::Stopped; - server.start_time = None; - if let Ok(mut guard) = server.child.lock() { - if let Some(child) = guard.as_mut() { - let _ = child.kill(); - } - } - if let ManagedServerKind::StdioCodex { server } = &server.kind { - server.clear_pending(); - server.clear_threads(); - } - } - } - - async fn wait_for_http_server(&self, base_url: &str) -> Result<(), SandboxError> { - let endpoints = ["health", "healthz", "app/agents", "agents"]; - for _ in 0..20 { - for endpoint in endpoints { - let url = format!("{base_url}/{endpoint}"); - if let Ok(response) = self.http_client.get(&url).send().await { - if response.status().is_success() { - return Ok(()); - } - } - } - sleep(Duration::from_millis(150)).await; - } - Err(SandboxError::StreamError { - message: "server health check failed".to_string(), - }) - } - - async fn spawn_http_server( - self: &Arc<Self>, - agent: AgentId, - ) -> Result<(String, Arc<std::sync::Mutex<Option<std::process::Child>>>), SandboxError> { - let manager = self.agent_manager.clone(); - let log_dir = self.log_base_dir.clone(); - let (base_url, child) = tokio::task::spawn_blocking( - move || -> Result<(String, std::process::Child), SandboxError> { - let path = manager - .resolve_binary(agent) - .map_err(|err| map_spawn_error(agent, err))?; - let port = find_available_port()?; - let mut command = std::process::Command::new(path); - let stderr = AgentServerLogs::new(log_dir, agent.as_str()).open()?; - command - .arg("serve") - .arg("--port") - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(stderr); - let child = command.spawn().map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - Ok((format!("http://127.0.0.1:{port}"), child)) - }, - ) - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })??; - - Ok((base_url, Arc::new(std::sync::Mutex::new(Some(child))))) - } - - async fn spawn_stdio_server( - self: &Arc<Self>, - agent: AgentId, - ) -> Result< ( - Arc<CodexServer>, - mpsc::UnboundedReceiver<String>, - Arc<std::sync::Mutex<Option<std::process::Child>>>, - ), - SandboxError, - > { - let manager = self.agent_manager.clone(); - let log_dir = self.log_base_dir.clone(); - let (stdin_tx, stdin_rx) = mpsc::unbounded_channel::<String>(); - let (stdout_tx, stdout_rx) = mpsc::unbounded_channel::<String>(); - - let child = - tokio::task::spawn_blocking(move || -> Result<std::process::Child, SandboxError> { - let path = manager - .resolve_binary(agent) - .map_err(|err| map_spawn_error(agent, err))?; - let mut command = std::process::Command::new(path); - let stderr = AgentServerLogs::new(log_dir, agent.as_str()).open()?; - command - .arg("app-server") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(stderr); - - let mut child = command.spawn().map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - - let stdin = child - .stdin - .take() - .ok_or_else(|| SandboxError::StreamError { - message: "codex stdin unavailable".to_string(), - })?; - let stdout = child - .stdout - .take() - .ok_or_else(|| SandboxError::StreamError { - message: "codex stdout unavailable".to_string(), - })?; - - let stdin_rx_mut = std::sync::Mutex::new(stdin_rx); - std::thread::spawn(move || { - let mut stdin = stdin; - let mut rx = stdin_rx_mut.lock().unwrap(); - while let Some(line) = rx.blocking_recv() { - if writeln!(stdin, "{line}").is_err() { - break; - } - if stdin.flush().is_err() { - break; - } - } - }); - - std::thread::spawn(move || { - let reader = BufReader::new(stdout); - for line in reader.lines() { - let Ok(line) = line else { break }; - if stdout_tx.send(line).is_err() { - break; - } - } - }); - - Ok(child) - }) - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })??; - - let server = Arc::new(CodexServer::new(stdin_tx)); - - Ok(( - server, - stdout_rx, - Arc::new(std::sync::Mutex::new(Some(child))), - )) - } - - fn spawn_monitor_task( - self: &Arc<Self>, - agent: AgentId, - instance_id: u64, - child: Arc<std::sync::Mutex<Option<std::process::Child>>>, - ) { - let manager = Arc::clone(self); - tokio::spawn(async move { - loop { - let status = { - let mut guard = match child.lock() { - Ok(guard) => guard, - Err(_) => return, - }; - match guard.as_mut() { - Some(child) => match child.try_wait() { - Ok(status) => status, - Err(_) => None, - }, - None => return, - } - }; - - if let Some(status) = status { - manager - .handle_process_exit(agent, instance_id, status) - .await; - break; - } - - sleep(Duration::from_millis(500)).await; - } - }); - } - - async fn handle_process_exit( - self: &Arc<Self>, - agent: AgentId, - instance_id: u64, - status: std::process::ExitStatus, - ) { - let exit_code = status.code(); - let message = format!("agent server exited with status {:?}", status); - let mut codex_server = None; - let mut shutdown_requested = false; - { - let mut servers = self.servers.lock().await; - if let Some(server) = servers.get_mut(&agent) { - if server.instance_id != instance_id { - return; - } - shutdown_requested = server.shutdown_requested; - server.status = if shutdown_requested { - ServerStatus::Stopped - } else { - ServerStatus::Error - }; - server.start_time = None; - if !shutdown_requested { - server.last_error = Some(message.clone()); - } - if let Ok(mut guard) = server.child.lock() { - *guard = None; - } - if let ManagedServerKind::StdioCodex { server } = &server.kind { - codex_server = Some(server.clone()); - } - } - } - - if let Some(server) = codex_server { - server.clear_pending(); - server.clear_threads(); - } - - if shutdown_requested { - self.clear_mappings(agent).await; - return; - } - - self.notify_sessions_of_error(agent, &message, exit_code) - .await; - - if self.auto_restart { - #[cfg(feature = "test-utils")] - { - if let Some(tx) = self.restart_notifier.lock().await.as_ref() { - let _ = tx.send(agent); - } - } - let manager = Arc::clone(self); - tokio::spawn(async move { - let _ = manager.ensure_server_for_restart(agent).await; - }); - } - } - - async fn ensure_server_for_restart( - self: Arc<Self>, - agent: AgentId, - ) -> Result<(), SandboxError> { - sleep(Duration::from_millis(500)).await; - match agent { - AgentId::Opencode => { - let _ = self.ensure_http_server(agent).await?; - } - AgentId::Codex => { - let (server, receiver) = self.ensure_stdio_server(agent).await?; - if let Some(stdout_rx) = receiver { - let owner = self.owner.lock().expect("owner lock").clone(); - if let Some(owner) = owner.as_ref().and_then(|weak| weak.upgrade()) { - let owner_clone = owner.clone(); - let server_clone = server.clone(); - tokio::spawn(async move { - owner_clone - .handle_codex_server_output(server_clone, stdout_rx) - .await; - }); - let _ = owner.codex_server_initialize(&server).await; - } - } - } - _ => {} - } - Ok(()) - } - - async fn notify_sessions_of_error( - &self, - agent: AgentId, - message: &str, - exit_code: Option<i32>, - ) { - let session_ids = { - let sessions = self.sessions.lock().await; - sessions - .get(&agent) - .cloned() - .unwrap_or_default() - .into_iter() - .collect::<Vec<_>>() - }; - - let owner = { self.owner.lock().expect("owner lock").clone() }; - if let Some(owner) = owner.and_then(|weak| weak.upgrade()) { - let logs = owner.read_agent_stderr(agent); - for session_id in session_ids { - owner - .record_error( - &session_id, - message.to_string(), - Some("server_exit".to_string()), - None, - ) - .await; - owner - .mark_session_ended( - &session_id, - exit_code, - message, - SessionEndReason::Error, - TerminatedBy::Daemon, - logs.clone(), - ) - .await; - } - } - - self.clear_mappings(agent).await; - } - - async fn update_server_error(&self, agent: AgentId, message: String) { - let mut servers = self.servers.lock().await; - if let Some(server) = servers.get_mut(&agent) { - server.status = ServerStatus::Error; - server.start_time = None; - server.last_error = Some(message); - } - } -} - -impl SessionManager { - fn new(agent_manager: Arc<AgentManager>) -> Self { - let log_base_dir = default_log_dir(); - let http_client = http_client::client_builder() - .build() - .expect("failed to build http client"); - let server_manager = Arc::new(AgentServerManager::new( - agent_manager.clone(), - http_client.clone(), - log_base_dir, - true, - )); - Self { - agent_manager, - sessions: Mutex::new(Vec::new()), - server_manager, - http_client, - model_catalog: Mutex::new(ModelCatalogState::default()), - } - } - - fn session_ref<'a>(sessions: &'a [SessionState], session_id: &str) -> Option<&'a SessionState> { - sessions - .iter() - .find(|session| session.session_id == session_id) - } - - fn session_mut<'a>( - sessions: &'a mut [SessionState], - session_id: &str, - ) -> Option<&'a mut SessionState> { - sessions - .iter_mut() - .find(|session| session.session_id == session_id) - } - - async fn session_working_dir(&self, session_id: &str) -> Result<PathBuf, SandboxError> { - let sessions = self.sessions.lock().await; - let session = Self::session_ref(&sessions, session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session_id.to_string(), - } - })?; - Ok(session.working_dir.clone()) - } - - pub(crate) async fn set_session_overrides( - &self, - session_id: &str, - model: Option<String>, - variant: Option<String>, - ) -> Result<(), SandboxError> { - let mut sessions = self.sessions.lock().await; - let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session_id.to_string(), - } - })?; - session.model = model; - session.variant = variant; - Ok(()) - } - - /// Read agent stderr for error diagnostics - fn read_agent_stderr(&self, agent: AgentId) -> Option<StderrOutput> { - let logs = AgentServerLogs::new(self.server_manager.log_base_dir.clone(), agent.as_str()); - logs.read_stderr() - } - - async fn shutdown(&self) { - let runtimes = { - let mut sessions = self.sessions.lock().await; - sessions - .iter_mut() - .filter_map(|session| session.pi_runtime.take()) - .collect::<Vec<_>>() - }; - for runtime in runtimes { - runtime.shutdown(); - } - self.server_manager.shutdown().await; - } - - pub(crate) async fn create_session( - self: &Arc<Self>, - session_id: String, - request: CreateSessionRequest, - ) -> Result<CreateSessionResponse, SandboxError> { - let agent_id = parse_agent_id(&request.agent)?; - { - let sessions = self.sessions.lock().await; - if sessions - .iter() - .any(|session| session.session_id == session_id) - { - return Err(SandboxError::SessionAlreadyExists { session_id }); - } - } - - if agent_id != AgentId::Mock { - let manager = self.agent_manager.clone(); - let agent_version = request.agent_version.clone(); - let agent_name = request.agent.clone(); - let install_result = tokio::task::spawn_blocking(move || { - manager.install( - agent_id, - InstallOptions { - reinstall: false, - version: agent_version, - }, - ) - }) - .await - .map_err(|err| SandboxError::InstallFailed { - agent: agent_name, - stderr: Some(err.to_string()), - })?; - install_result.map_err(|err| map_install_error(agent_id, err))?; - } - - let skill_dirs = if let Some(skills) = &request.skills { - let sources = skills.sources.clone(); - Some( - tokio::task::spawn_blocking(move || install_skill_sources(&sources)) - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })??, - ) - } else { - None - }; - - if let Some(mcp) = &request.mcp { - self.apply_mcp_config(agent_id, mcp).await?; - } - - if agent_id == AgentId::Opencode { - if let Some(skill_dirs) = skill_dirs.as_ref() { - self.apply_opencode_skills(skill_dirs).await?; - } - } - - // Resolve default model if none was explicitly provided - let request = if request.model.is_none() { - let mut request = request; - if let Ok(models_response) = self.agent_models(agent_id).await { - request.model = models_response.default_model; - } - request - } else { - request - }; - - let mut session = SessionState::new(session_id.clone(), agent_id, &request)?; - if agent_id == AgentId::Opencode { - let opencode_session_id = self.create_opencode_session().await?; - session.native_session_id = Some(opencode_session_id); - } - if agent_id == AgentId::Codex { - // Create a thread in the shared Codex app-server - let snapshot = SessionSnapshot { - session_id: session_id.clone(), - agent: agent_id, - agent_mode: session.agent_mode.clone(), - permission_mode: session.permission_mode.clone(), - model: session.model.clone(), - variant: session.variant.clone(), - native_session_id: None, - }; - let thread_id = self.create_codex_thread(&session_id, &snapshot).await?; - session.native_session_id = Some(thread_id); - } - if agent_id == AgentId::Pi { - // Pi uses one dedicated RPC process per daemon session. - // This is the canonical runtime path for Pi sessions. - let pi = self - .create_pi_session(&session_id, session.model.as_deref()) - .await?; - session.native_session_id = Some(pi.native_session_id); - session.pi_runtime = Some(pi.runtime.clone()); - if let Some(variant) = session.variant.as_deref() { - if let Err(err) = self.set_pi_thinking_level(&pi.runtime, variant).await { - pi.runtime.shutdown(); - return Err(err); - } - } - } - if agent_id == AgentId::Mock { - session.native_session_id = Some(format!("mock-{session_id}")); - } - - let metadata = json!({ - "agent": request.agent, - "agentMode": session.agent_mode, - "permissionMode": session.permission_mode, - "model": request.model, - "variant": request.variant, - }); - let started = EventConversion::new( - UniversalEventType::SessionStarted, - UniversalEventData::SessionStarted(SessionStartedData { - metadata: Some(metadata), - }), + status, + [(header::CONTENT_TYPE, "application/problem+json")], + Json(problem), ) - .synthetic() - .with_native_session(session.native_session_id.clone()); - session.record_conversions(vec![started]); - if agent_id == AgentId::Mock { - // Emit native session.started like real agents do - let native_started = EventConversion::new( - UniversalEventType::SessionStarted, - UniversalEventData::SessionStarted(SessionStartedData { - metadata: Some(json!({ "mock": true })), - }), - ) - .with_native_session(session.native_session_id.clone()); - session.record_conversions(vec![native_started]); - } - - let native_session_id = session.native_session_id.clone(); - let mut sessions = self.sessions.lock().await; - sessions.push(session); - drop(sessions); - if agent_id == AgentId::Opencode || agent_id == AgentId::Codex { - self.server_manager - .register_session(agent_id, &session_id, native_session_id.as_deref()) - .await; - } - - if agent_id == AgentId::Opencode { - self.ensure_opencode_stream(session_id).await?; - } - - Ok(CreateSessionResponse { - healthy: true, - error: None, - native_session_id, - }) - } - - async fn apply_mcp_config( - self: &Arc<Self>, - agent_id: AgentId, - mcp: &BTreeMap<String, McpServerConfig>, - ) -> Result<(), SandboxError> { - if mcp.is_empty() { - return Ok(()); - } - match agent_id { - AgentId::Claude => { - let mcp = mcp.clone(); - tokio::task::spawn_blocking(move || write_claude_mcp_config(&mcp)) - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })??; - Ok(()) - } - AgentId::Codex => { - let mcp = mcp.clone(); - tokio::task::spawn_blocking(move || write_codex_mcp_config(&mcp)) - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })??; - let server = self.ensure_codex_server().await?; - self.reload_codex_mcp(&server).await - } - AgentId::Opencode => self.apply_opencode_mcp(mcp).await, - AgentId::Amp => { - let agent_manager = self.agent_manager.clone(); - let mcp = mcp.clone(); - tokio::task::spawn_blocking(move || apply_amp_mcp_config(&agent_manager, &mcp)) - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })??; - Ok(()) - } - AgentId::Pi => Ok(()), - AgentId::Cursor => Ok(()), - AgentId::Mock => Ok(()), - } - } - - async fn apply_opencode_skills(&self, skill_dirs: &[PathBuf]) -> Result<(), SandboxError> { - if skill_dirs.is_empty() { - return Ok(()); - } - let base_url = self.ensure_opencode_server().await?; - let url = format!("{base_url}/config"); - let response = self.http_client.get(&url).send().await; - let mut existing_paths = Vec::<String>::new(); - if let Ok(response) = response { - if response.status().is_success() { - if let Ok(value) = response.json::<Value>().await { - if let Some(paths) = value - .get("skills") - .and_then(|skills| skills.get("paths")) - .and_then(Value::as_array) - { - existing_paths.extend( - paths - .iter() - .filter_map(Value::as_str) - .map(|path| path.to_string()), - ); - } - } - } - } - let mut merged = existing_paths; - for dir in skill_dirs { - let path = dir.to_string_lossy().to_string(); - if !merged.contains(&path) { - merged.push(path); - } - } - let body = json!({ "skills": { "paths": merged } }); - let response = self.http_client.patch(&url).json(&body).send().await; - let response = response.map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - if response.status().is_success() { - Ok(()) - } else { - Err(SandboxError::StreamError { - message: format!("OpenCode config update failed: {}", response.status()), - }) - } - } - - pub(crate) async fn set_session_title( - &self, - session_id: &str, - title: String, - ) -> Result<(), SandboxError> { - let mut sessions = self.sessions.lock().await; - let Some(session) = SessionManager::session_mut(&mut sessions, session_id) else { - return Err(SandboxError::SessionNotFound { - session_id: session_id.to_string(), - }); - }; - session.title = Some(title); - session.updated_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(session.updated_at); - Ok(()) - } - - async fn clear_codex_session_model_if_unavailable( - &self, - session_id: &str, - model_id: &str, - ) -> bool { - let mut sessions = self.sessions.lock().await; - let Some(session) = SessionManager::session_mut(&mut sessions, session_id) else { - return false; - }; - if session.agent == AgentId::Codex && session.model.as_deref() == Some(model_id) { - session.model = None; - session.updated_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(session.updated_at); - return true; - } - false - } - - async fn invalidate_codex_model_cache(&self) { - let mut catalog = self.model_catalog.lock().await; - catalog.models.remove(&AgentId::Codex); - } - - async fn codex_native_session_id(&self, session_id: &str) -> Option<String> { - let sessions = self.sessions.lock().await; - let session = SessionManager::session_ref(&sessions, session_id)?; - if session.agent != AgentId::Codex { - return None; - } - session.native_session_id.clone() - } - - async fn handle_codex_model_unavailable( - &self, - session_id: &str, - model_id: &str, - native_session_id: Option<String>, - ) { - tracing::warn!( - model_id = %model_id, - "codex model rejected at runtime; clearing session model and refreshing model cache" - ); - self.invalidate_codex_model_cache().await; - if !self - .clear_codex_session_model_if_unavailable(session_id, model_id) - .await - { - return; - } - let native_session_id = match native_session_id { - Some(native_session_id) => Some(native_session_id), - None => self.codex_native_session_id(session_id).await, - }; - let _ = self - .record_conversions( - session_id, - vec![codex_model_unavailable_status_event( - native_session_id, - model_id, - )], - ) - .await; - } - - pub(crate) async fn delete_session(&self, session_id: &str) -> Result<(), SandboxError> { - let (agent, native_session_id) = { - let mut sessions = self.sessions.lock().await; - let Some(index) = sessions - .iter() - .position(|session| session.session_id == session_id) - else { - return Err(SandboxError::SessionNotFound { - session_id: session_id.to_string(), - }); - }; - let session = sessions.remove(index); - (session.agent, session.native_session_id) - }; - - if agent == AgentId::Opencode || agent == AgentId::Codex { - self.server_manager - .unregister_session(agent, session_id, native_session_id.as_deref()) - .await; - } - - Ok(()) - } - - async fn agent_modes(&self, agent: AgentId) -> Result<Vec<AgentModeInfo>, SandboxError> { - if agent != AgentId::Opencode { - return Ok(agent_modes_for(agent)); - } - - match self.fetch_opencode_modes().await { - Ok(mut modes) => { - ensure_custom_mode(&mut modes); - if modes.is_empty() { - Ok(agent_modes_for(agent)) - } else { - Ok(modes) - } - } - Err(_) => Ok(agent_modes_for(agent)), - } - } - - pub(crate) async fn agent_models( - self: &Arc<Self>, - agent: AgentId, - ) -> Result<AgentModelsResponse, SandboxError> { - enum Acquisition { - Hit(AgentModelsResponse), - Wait(OwnedNotified), - Build(Arc<Notify>), - } - - loop { - let acquisition = { - let mut catalog = self.model_catalog.lock().await; - if let Some(response) = catalog.models.get(&agent) { - Acquisition::Hit(response.clone()) - } else if let Some(notify) = catalog.in_flight.get(&agent) { - Acquisition::Wait(notify.clone().notified_owned()) - } else { - let notify = Arc::new(Notify::new()); - catalog.in_flight.insert(agent, notify.clone()); - Acquisition::Build(notify) - } - }; - - match acquisition { - Acquisition::Hit(response) => return Ok(response), - Acquisition::Wait(waiting) => waiting.await, - Acquisition::Build(notify) => { - let response = self.fetch_agent_models_uncached(agent).await; - let mut catalog = self.model_catalog.lock().await; - catalog.in_flight.remove(&agent); - if let Ok(response_value) = &response { - if should_cache_agent_models(agent, response_value) { - catalog.models.insert(agent, response_value.clone()); - } - } - notify.notify_waiters(); - return response; - } - } - } - } - - async fn fetch_agent_models_uncached( - self: &Arc<Self>, - agent: AgentId, - ) -> Result<AgentModelsResponse, SandboxError> { - match agent { - AgentId::Claude => match self.fetch_claude_models().await { - Ok(response) if !response.models.is_empty() => Ok(response), - _ => Ok(claude_fallback_models()), - }, - AgentId::Codex => self.fetch_codex_models().await, - AgentId::Opencode => match self.fetch_opencode_models().await { - Ok(models) => Ok(models), - Err(_) => Ok(AgentModelsResponse { - models: Vec::new(), - default_model: None, - }), - }, - AgentId::Amp => Ok(amp_models_response()), - AgentId::Pi => match self.fetch_pi_models().await { - Ok(models) => Ok(models), - Err(_) => Ok(AgentModelsResponse { - models: Vec::new(), - default_model: None, - }), - }, - AgentId::Cursor => Ok(AgentModelsResponse { - models: Vec::new(), - default_model: None, - }), - AgentId::Mock => Ok(mock_models_response()), - } - } - - pub(crate) async fn send_message( - self: &Arc<Self>, - session_id: String, - message: String, - attachments: Vec<MessageAttachment>, - ) -> Result<(), SandboxError> { - // Use allow_ended=true and do explicit check to allow resumable agents - let session_snapshot = self.session_snapshot_for_message(&session_id).await?; - let prompt_with_attachments = format_message_with_attachments(&message, &attachments); - let prompt = if session_snapshot.agent == AgentId::Opencode { - message.clone() - } else { - prompt_with_attachments - }; - if !agent_emits_turn_started(session_snapshot.agent) { - let _ = self - .record_conversions( - &session_id, - vec![turn_started_event(None, None).synthetic()], - ) - .await; - } - if session_snapshot.agent == AgentId::Mock { - self.send_mock_message(session_id, prompt).await?; - return Ok(()); - } - if matches!( - session_snapshot.agent, - AgentId::Claude | AgentId::Amp | AgentId::Pi - ) { - let _ = self - .record_conversions(&session_id, user_message_conversions(&prompt)) - .await; - } - if session_snapshot.agent == AgentId::Opencode { - self.ensure_opencode_stream(session_id.clone()).await?; - self.send_opencode_prompt(&session_snapshot, &prompt, &attachments) - .await?; - if !agent_supports_item_started(session_snapshot.agent) { - let _ = self - .emit_synthetic_assistant_start(&session_snapshot.session_id) - .await; - } - return Ok(()); - } - if session_snapshot.agent == AgentId::Codex { - // Use the shared Codex app-server - self.send_codex_turn(&session_snapshot, &prompt).await?; - if !agent_supports_item_started(session_snapshot.agent) { - let _ = self - .emit_synthetic_assistant_start(&session_snapshot.session_id) - .await; - } - return Ok(()); - } - if session_snapshot.agent == AgentId::Pi { - // Pi bypasses generic AgentManager::spawn_streaming and stays on - // router-managed per-session RPC runtime for lifecycle isolation. - self.send_pi_prompt(&session_snapshot, &message).await?; - if !agent_supports_item_started(session_snapshot.agent) { - let _ = self - .emit_synthetic_assistant_start(&session_snapshot.session_id) - .await; - } - return Ok(()); - } - - // Reopen the session if it was ended (for resumable agents) - self.reopen_session_if_ended(&session_id).await; - - let manager = self.agent_manager.clone(); - let initial_input = if session_snapshot.agent == AgentId::Claude { - Some(claude_user_message_line(&session_snapshot, &prompt)) - } else { - None - }; - let credentials = tokio::task::spawn_blocking(move || { - let options = CredentialExtractionOptions::new(); - extract_all_credentials(&options) - }) - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - - let spawn_options = build_spawn_options(&session_snapshot, prompt.clone(), credentials); - let agent_id = session_snapshot.agent; - let spawn_result = - tokio::task::spawn_blocking(move || manager.spawn_streaming(agent_id, spawn_options)) - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - - let spawn_result = spawn_result.map_err(|err| map_spawn_error(agent_id, err))?; - if !agent_supports_item_started(session_snapshot.agent) { - let _ = self - .emit_synthetic_assistant_start(&session_snapshot.session_id) - .await; - } - - let manager = Arc::clone(self); - tokio::spawn(async move { - manager - .consume_spawn(session_id, agent_id, spawn_result, initial_input) - .await; - }); - - Ok(()) - } - - async fn emit_synthetic_assistant_start(&self, session_id: &str) -> Result<(), SandboxError> { - let conversion = { - let mut sessions = self.sessions.lock().await; - let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session_id.to_string(), - } - })?; - session.enqueue_pending_assistant_start() - }; - let _ = self - .record_conversions(session_id, vec![conversion]) - .await?; - Ok(()) - } - - /// Reopens a session that was ended by an agent process completing. - /// This allows resumable agents (Claude, Amp, OpenCode) to continue conversations. - async fn reopen_session_if_ended(&self, session_id: &str) { - let mut sessions = self.sessions.lock().await; - if let Some(session) = Self::session_mut(&mut sessions, session_id) { - if session.ended && agent_supports_resume(session.agent) { - session.ended = false; - session.ended_exit_code = None; - session.ended_message = None; - session.ended_reason = None; - session.terminated_by = None; - } - } - } - - async fn terminate_session(&self, session_id: String) -> Result<(), SandboxError> { - let mut sessions = self.sessions.lock().await; - let session = Self::session_mut(&mut sessions, &session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session_id.clone(), - } - })?; - if session.ended { - return Ok(()); - } - session.mark_ended( - None, - "terminated by daemon".to_string(), - SessionEndReason::Terminated, - TerminatedBy::Daemon, - ); - let ended = EventConversion::new( - UniversalEventType::SessionEnded, - UniversalEventData::SessionEnded(SessionEndedData { - reason: SessionEndReason::Terminated, - terminated_by: TerminatedBy::Daemon, - message: None, - exit_code: None, - stderr: None, - }), - ) - .synthetic() - .with_native_session(session.native_session_id.clone()); - session.record_conversions(vec![ended]); - let agent = session.agent; - let native_session_id = session.native_session_id.clone(); - let pi_runtime = session.pi_runtime.take(); - drop(sessions); - if let Some(runtime) = pi_runtime { - runtime.shutdown(); - } - if agent == AgentId::Opencode || agent == AgentId::Codex { - self.server_manager - .unregister_session(agent, &session_id, native_session_id.as_deref()) - .await; - } - Ok(()) - } - - async fn events( - &self, - session_id: &str, - offset: u64, - limit: Option<u64>, - include_raw: bool, - ) -> Result<EventsResponse, SandboxError> { - let sessions = self.sessions.lock().await; - let session = Self::session_ref(&sessions, session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session_id.to_string(), - } - })?; - - let mut events: Vec<UniversalEvent> = session - .events - .iter() - .filter(|event| event.sequence > offset) - .cloned() - .map(|mut event| { - if !include_raw { - event.raw = None; - } - event - }) - .collect(); - - let has_more = if let Some(limit) = limit { - let limit = limit as usize; - if events.len() > limit { - events.truncate(limit); - true - } else { - false - } - } else { - false - }; - - Ok(EventsResponse { events, has_more }) - } - - pub(crate) async fn list_sessions(&self) -> Vec<SessionInfo> { - let sessions = self.sessions.lock().await; - sessions - .iter() - .rev() - .map(|state| Self::build_session_info(state)) - .collect() - } - - pub(crate) async fn get_session_info(&self, session_id: &str) -> Option<SessionInfo> { - let sessions = self.sessions.lock().await; - Self::session_ref(&sessions, session_id).map(Self::build_session_info) - } - - fn build_session_info(state: &SessionState) -> SessionInfo { - SessionInfo { - session_id: state.session_id.clone(), - agent: state.agent.as_str().to_string(), - agent_mode: state.agent_mode.clone(), - permission_mode: state.permission_mode.clone(), - model: state.model.clone(), - variant: state.variant.clone(), - native_session_id: state.native_session_id.clone(), - ended: state.ended, - event_count: state.events.len() as u64, - created_at: state.created_at, - updated_at: state.updated_at, - directory: state.directory.clone(), - title: state.title.clone(), - mcp: state.mcp.clone(), - skills: state.skills.clone(), - } - } - - pub(crate) async fn subscribe( - &self, - session_id: &str, - offset: u64, - ) -> Result<SessionSubscription, SandboxError> { - let sessions = self.sessions.lock().await; - let session = Self::session_ref(&sessions, session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session_id.to_string(), - } - })?; - let initial_events = session - .events - .iter() - .filter(|event| event.sequence > offset) - .cloned() - .collect::<Vec<_>>(); - let receiver = session.broadcaster.subscribe(); - Ok(SessionSubscription { - initial_events, - receiver, - }) - } - - pub(crate) async fn list_pending_permissions(&self) -> Vec<PendingPermissionInfo> { - let sessions = self.sessions.lock().await; - let mut items = Vec::new(); - for session in sessions.iter() { - for (permission_id, pending) in session.pending_permissions.iter() { - items.push(PendingPermissionInfo { - session_id: session.session_id.clone(), - permission_id: permission_id.clone(), - action: pending.action.clone(), - metadata: pending.metadata.clone(), - }); - } - } - items - } - - pub(crate) async fn list_pending_questions(&self) -> Vec<PendingQuestionInfo> { - let sessions = self.sessions.lock().await; - let mut items = Vec::new(); - for session in sessions.iter() { - for (question_id, pending) in session.pending_questions.iter() { - items.push(PendingQuestionInfo { - session_id: session.session_id.clone(), - question_id: question_id.clone(), - prompt: pending.prompt.clone(), - options: pending.options.clone(), - }); - } - } - items - } - - async fn subscribe_for_turn( - &self, - session_id: &str, - ) -> Result<(SessionSnapshot, SessionSubscription), SandboxError> { - let sessions = self.sessions.lock().await; - let session = Self::session_ref(&sessions, session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session_id.to_string(), - } - })?; - if let Some(err) = session.ended_error() { - return Err(err); - } - let offset = session.next_event_sequence; - let initial_events = session - .events - .iter() - .filter(|event| event.sequence > offset) - .cloned() - .collect::<Vec<_>>(); - let receiver = session.broadcaster.subscribe(); - let subscription = SessionSubscription { - initial_events, - receiver, - }; - Ok((SessionSnapshot::from(session), subscription)) - } - - pub(crate) async fn reply_question( - &self, - session_id: &str, - question_id: &str, - answers: Vec<Vec<String>>, - ) -> Result<(), SandboxError> { - let (agent, native_session_id, pending_question, claude_sender, linked_permission) = { - let mut sessions = self.sessions.lock().await; - let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session_id.to_string(), - } - })?; - let pending = session.take_question(question_id); - if pending.is_none() { - return Err(SandboxError::InvalidRequest { - message: format!("unknown question id: {question_id}"), - }); - } - if let Some(err) = session.ended_error() { - return Err(err); - } - // For Claude, check if there's a linked AskUserQuestion/ExitPlanMode permission - let linked_perm = if session.agent == AgentId::Claude { - session.take_question_tool_permission() - } else { - None - }; - ( - session.agent, - session.native_session_id.clone(), - pending, - session.claude_sender(), - linked_perm, - ) - }; - - let response = answers.first().and_then(|inner| inner.first()).cloned(); - - if agent == AgentId::Opencode { - let agent_session_id = - native_session_id - .clone() - .ok_or_else(|| SandboxError::InvalidRequest { - message: "missing OpenCode session id".to_string(), - })?; - self.opencode_question_reply(&agent_session_id, question_id, answers.clone()) - .await?; - } else if agent == AgentId::Claude { - let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest { - message: "Claude session is not active".to_string(), - })?; - if let Some((perm_id, perm)) = &linked_permission { - // Use the permission control response to deliver the answer. - // Build updatedInput from the original input with the answers map added. - let original_input = perm - .metadata - .as_ref() - .and_then(|m| m.get("input")) - .cloned() - .unwrap_or(Value::Null); - let mut updated = match original_input { - Value::Object(map) => map, - _ => serde_json::Map::new(), - }; - // Build answers map: { "0": "selected option", "1": "another option", ... } - let answers_map: serde_json::Map<String, Value> = answers - .iter() - .enumerate() - .filter_map(|(i, inner)| { - inner - .first() - .map(|v| (i.to_string(), Value::String(v.clone()))) - }) - .collect(); - updated.insert("answers".to_string(), Value::Object(answers_map)); - - let mut response_map = serde_json::Map::new(); - response_map.insert("updatedInput".to_string(), Value::Object(updated)); - let line = - claude_control_response_line(perm_id, "allow", Value::Object(response_map)); - sender - .send(line) - .map_err(|_| SandboxError::InvalidRequest { - message: "Claude session is not active".to_string(), - })?; - } else { - // No linked permission — fall back to tool_result - let native_sid = native_session_id - .clone() - .unwrap_or_else(|| session_id.to_string()); - let response_text = response.clone().unwrap_or_default(); - let line = claude_tool_result_line(&native_sid, question_id, &response_text, false); - sender - .send(line) - .map_err(|_| SandboxError::InvalidRequest { - message: "Claude session is not active".to_string(), - })?; - } - } else { - // TODO: Forward question replies to subprocess agents. - } - - // Emit QuestionResolved - if let Some(pending) = pending_question { - let mut conversions = vec![EventConversion::new( - UniversalEventType::QuestionResolved, - UniversalEventData::Question(QuestionEventData { - question_id: question_id.to_string(), - prompt: pending.prompt, - options: pending.options, - response, - status: QuestionStatus::Answered, - }), - ) - .synthetic() - .with_native_session(native_session_id.clone())]; - - // Also emit PermissionResolved for the linked permission - if let Some((perm_id, perm)) = linked_permission { - conversions.push( - EventConversion::new( - UniversalEventType::PermissionResolved, - UniversalEventData::Permission(PermissionEventData { - permission_id: perm_id, - action: perm.action, - status: PermissionStatus::Accept, - metadata: perm.metadata, - }), - ) - .synthetic() - .with_native_session(native_session_id), - ); - } - - let _ = self.record_conversions(session_id, conversions).await; - } - - Ok(()) - } - - pub(crate) async fn reject_question( - &self, - session_id: &str, - question_id: &str, - ) -> Result<(), SandboxError> { - let (agent, native_session_id, pending_question, claude_sender, linked_permission) = { - let mut sessions = self.sessions.lock().await; - let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session_id.to_string(), - } - })?; - let pending = session.take_question(question_id); - if pending.is_none() { - return Err(SandboxError::InvalidRequest { - message: format!("unknown question id: {question_id}"), - }); - } - if let Some(err) = session.ended_error() { - return Err(err); - } - let linked_perm = if session.agent == AgentId::Claude { - session.take_question_tool_permission() - } else { - None - }; - ( - session.agent, - session.native_session_id.clone(), - pending, - session.claude_sender(), - linked_perm, - ) - }; - - if agent == AgentId::Opencode { - let agent_session_id = - native_session_id - .clone() - .ok_or_else(|| SandboxError::InvalidRequest { - message: "missing OpenCode session id".to_string(), - })?; - self.opencode_question_reject(&agent_session_id, question_id) - .await?; - } else if agent == AgentId::Claude { - let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest { - message: "Claude session is not active".to_string(), - })?; - if let Some((perm_id, _)) = &linked_permission { - // Deny via the permission control response - let mut response_map = serde_json::Map::new(); - response_map.insert( - "message".to_string(), - Value::String("Permission denied.".to_string()), - ); - let line = - claude_control_response_line(perm_id, "deny", Value::Object(response_map)); - sender - .send(line) - .map_err(|_| SandboxError::InvalidRequest { - message: "Claude session is not active".to_string(), - })?; - } else { - let native_sid = native_session_id - .clone() - .unwrap_or_else(|| session_id.to_string()); - let line = claude_tool_result_line( - &native_sid, - question_id, - "User rejected the question.", - true, - ); - sender - .send(line) - .map_err(|_| SandboxError::InvalidRequest { - message: "Claude session is not active".to_string(), - })?; - } - } else { - // TODO: Forward question rejections to subprocess agents. - } - - // Emit QuestionResolved - if let Some(pending) = pending_question { - let mut conversions = vec![EventConversion::new( - UniversalEventType::QuestionResolved, - UniversalEventData::Question(QuestionEventData { - question_id: question_id.to_string(), - prompt: pending.prompt, - options: pending.options, - response: None, - status: QuestionStatus::Rejected, - }), - ) - .synthetic() - .with_native_session(native_session_id.clone())]; - - // Also emit PermissionResolved for the linked permission - if let Some((perm_id, perm)) = linked_permission { - conversions.push( - EventConversion::new( - UniversalEventType::PermissionResolved, - UniversalEventData::Permission(PermissionEventData { - permission_id: perm_id, - action: perm.action, - status: PermissionStatus::Reject, - metadata: perm.metadata, - }), - ) - .synthetic() - .with_native_session(native_session_id), - ); - } - - let _ = self.record_conversions(session_id, conversions).await; - } - - Ok(()) - } - - pub(crate) async fn reply_permission( - self: &Arc<Self>, - session_id: &str, - permission_id: &str, - reply: PermissionReply, - ) -> Result<(), SandboxError> { - let reply_for_status = reply.clone(); - let (agent, native_session_id, pending_permission, claude_sender) = { - let mut sessions = self.sessions.lock().await; - let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session_id.to_string(), - } - })?; - let pending = session.take_permission(permission_id); - if pending.is_none() { - return Err(SandboxError::InvalidRequest { - message: format!("unknown permission id: {permission_id}"), - }); - } - if matches!(reply_for_status, PermissionReply::Always) { - if let Some(pending) = pending.as_ref() { - session - .remember_permission_allow_for_session(&pending.action, &pending.metadata); - } - } - if let Some(err) = session.ended_error() { - return Err(err); - } - ( - session.agent, - session.native_session_id.clone(), - pending, - session.claude_sender(), - ) - }; - - if agent == AgentId::Codex { - // Use the shared Codex server to send the permission reply - let server = self.ensure_codex_server().await?; - let pending = - pending_permission - .clone() - .ok_or_else(|| SandboxError::InvalidRequest { - message: "missing codex permission metadata".to_string(), - })?; - let line = codex_permission_response_line(permission_id, &pending, reply.clone())?; - server - .stdin_sender - .send(line) - .map_err(|_| SandboxError::InvalidRequest { - message: "codex server not active".to_string(), - })?; - } else if agent == AgentId::Opencode { - let agent_session_id = - native_session_id - .clone() - .ok_or_else(|| SandboxError::InvalidRequest { - message: "missing OpenCode session id".to_string(), - })?; - self.opencode_permission_reply(&agent_session_id, permission_id, reply.clone()) - .await?; - } else if agent == AgentId::Claude { - let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest { - message: "Claude session is not active".to_string(), - })?; - let metadata = pending_permission - .as_ref() - .and_then(|pending| pending.metadata.as_ref()) - .and_then(Value::as_object); - let updated_input = metadata - .and_then(|map| map.get("input")) - .cloned() - .unwrap_or(Value::Null); - - let mut response_map = serde_json::Map::new(); - match reply { - PermissionReply::Reject => { - response_map.insert( - "message".to_string(), - Value::String("Permission denied.".to_string()), - ); - } - PermissionReply::Once | PermissionReply::Always => { - if !updated_input.is_null() { - response_map.insert("updatedInput".to_string(), updated_input); - } - } - } - let response_value = Value::Object(response_map); - - let behavior = match reply { - PermissionReply::Reject => "deny", - PermissionReply::Once | PermissionReply::Always => "allow", - }; - - let line = claude_control_response_line(permission_id, behavior, response_value); - sender - .send(line) - .map_err(|_| SandboxError::InvalidRequest { - message: "Claude session is not active".to_string(), - })?; - } else { - // TODO: Forward permission replies to subprocess agents. - } - - if let Some(pending) = pending_permission { - let status = match reply_for_status { - PermissionReply::Reject => PermissionStatus::Reject, - PermissionReply::Once => PermissionStatus::Accept, - PermissionReply::Always => PermissionStatus::AcceptForSession, - }; - let resolved = EventConversion::new( - UniversalEventType::PermissionResolved, - UniversalEventData::Permission(PermissionEventData { - permission_id: permission_id.to_string(), - action: pending.action, - status, - metadata: pending.metadata, - }), - ) - .synthetic() - .with_native_session(native_session_id); - let _ = self.record_conversions(session_id, vec![resolved]).await; - } - - Ok(()) - } - - /// Gets a session snapshot for sending a new message. - /// Uses the `for_new_message` check which allows agents that support resumption - /// (Claude, Amp, OpenCode) to continue after their process exits successfully. - async fn session_snapshot_for_message( - &self, - session_id: &str, - ) -> Result<SessionSnapshot, SandboxError> { - let sessions = self.sessions.lock().await; - let session = Self::session_ref(&sessions, session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session_id.to_string(), - } - })?; - if let Some(err) = session.ended_error_for_messages(true) { - return Err(err); - } - Ok(SessionSnapshot::from(session)) - } - - async fn send_mock_message( - self: &Arc<Self>, - session_id: String, - message: String, - ) -> Result<(), SandboxError> { - let prefix = { - let mut sessions = self.sessions.lock().await; - let session = Self::session_mut(&mut sessions, &session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session_id.to_string(), - } - })?; - if let Some(err) = session.ended_error() { - return Err(err); - } - session.mock_sequence = session.mock_sequence.saturating_add(1); - format!("mock_{}", session.mock_sequence) - }; - - let mut conversions = Vec::new(); - let trimmed = message.trim(); - if !trimmed.is_empty() { - conversions.extend(mock_user_message(&prefix, trimmed)); - } - conversions.extend(mock_command_conversions(&prefix, trimmed)); - - let manager = Arc::clone(self); - tokio::spawn(async move { - manager.emit_mock_events(session_id, conversions).await; - }); - - Ok(()) - } - - async fn emit_mock_events( - self: Arc<Self>, - session_id: String, - conversions: Vec<EventConversion>, - ) { - for conversion in conversions { - if self - .record_conversions(&session_id, vec![conversion]) - .await - .is_err() - { - return; - } - sleep(Duration::from_millis(MOCK_EVENT_DELAY_MS)).await; - } - } - - async fn consume_spawn( - self: Arc<Self>, - session_id: String, - agent: AgentId, - spawn: StreamingSpawn, - initial_input: Option<String>, - ) { - let StreamingSpawn { - mut child, - stdin, - stdout, - stderr, - codex_options, - } = spawn; - let (tx, mut rx) = mpsc::unbounded_channel::<String>(); - let mut codex_state = codex_options - .filter(|_| agent == AgentId::Codex) - .map(CodexAppServerState::new); - let mut codex_sender: Option<mpsc::UnboundedSender<String>> = None; - let mut terminate_early = false; - - if let Some(stdout) = stdout { - let tx_stdout = tx.clone(); - tokio::task::spawn_blocking(move || { - read_lines(stdout, tx_stdout); - }); - } - if let Some(stderr) = stderr { - let tx_stderr = tx.clone(); - tokio::task::spawn_blocking(move || { - read_lines(stderr, tx_stderr); - }); - } - drop(tx); - - if agent == AgentId::Codex { - if let Some(stdin) = stdin { - let (writer_tx, writer_rx) = mpsc::unbounded_channel::<String>(); - codex_sender = Some(writer_tx.clone()); - { - let mut sessions = self.sessions.lock().await; - if let Some(session) = Self::session_mut(&mut sessions, &session_id) { - session.set_codex_sender(Some(writer_tx)); - } - } - tokio::task::spawn_blocking(move || { - write_lines(stdin, writer_rx); - }); - } - if let (Some(state), Some(sender)) = (codex_state.as_mut(), codex_sender.as_ref()) { - state.start(sender); - } - } else if agent == AgentId::Claude { - if let Some(stdin) = stdin { - let (writer_tx, writer_rx) = mpsc::unbounded_channel::<String>(); - { - let mut sessions = self.sessions.lock().await; - if let Some(session) = Self::session_mut(&mut sessions, &session_id) { - session.set_claude_sender(Some(writer_tx.clone())); - } - } - if let Some(initial) = initial_input { - let _ = writer_tx.send(initial); - } - tokio::task::spawn_blocking(move || { - write_lines(stdin, writer_rx); - }); - } - } - - while let Some(line) = rx.recv().await { - if agent == AgentId::Codex { - if let Some(state) = codex_state.as_mut() { - let outcome = state.handle_line(&line); - if !outcome.conversions.is_empty() { - let _ = self - .record_conversions(&session_id, outcome.conversions) - .await; - } - if outcome.should_terminate { - terminate_early = true; - break; - } - } - } else if agent == AgentId::Claude { - if let Ok(value) = serde_json::from_str::<Value>(&line) { - if value.get("type").and_then(Value::as_str) == Some("result") { - let mut sessions = self.sessions.lock().await; - if let Some(session) = Self::session_mut(&mut sessions, &session_id) { - session.set_claude_sender(None); - } - } - } - let conversions = self.parse_claude_line(&line, &session_id).await; - if !conversions.is_empty() { - let _ = self.record_conversions(&session_id, conversions).await; - } - } else { - let conversions = parse_agent_line(agent, &line, &session_id); - if !conversions.is_empty() { - let _ = self.record_conversions(&session_id, conversions).await; - } - } - } - - if agent == AgentId::Codex { - let mut sessions = self.sessions.lock().await; - if let Some(session) = Self::session_mut(&mut sessions, &session_id) { - session.set_codex_sender(None); - } - } else if agent == AgentId::Claude { - let mut sessions = self.sessions.lock().await; - if let Some(session) = Self::session_mut(&mut sessions, &session_id) { - session.set_claude_sender(None); - } - } - - if terminate_early { - let _ = child.kill(); - } - let status = tokio::task::spawn_blocking(move || child.wait()).await; - match status { - Ok(Ok(status)) if status.success() => { - if !agent_supports_resume(agent) { - let message = format!("agent exited with status {:?}", status); - self.mark_session_ended( - &session_id, - status.code(), - &message, - SessionEndReason::Completed, - TerminatedBy::Agent, - None, - ) - .await; - } - } - Ok(Ok(status)) => { - let message = format!("agent exited with status {:?}", status); - if !terminate_early { - self.record_error( - &session_id, - message.clone(), - Some("process_exit".to_string()), - None, - ) - .await; - } - let logs = self.read_agent_stderr(agent); - self.mark_session_ended( - &session_id, - status.code(), - &message, - SessionEndReason::Error, - TerminatedBy::Agent, - logs, - ) - .await; - } - Ok(Err(err)) => { - let message = format!("failed to wait for agent: {err}"); - if !terminate_early { - self.record_error( - &session_id, - message.clone(), - Some("process_wait_failed".to_string()), - None, - ) - .await; - } - let logs = self.read_agent_stderr(agent); - self.mark_session_ended( - &session_id, - None, - &message, - SessionEndReason::Error, - TerminatedBy::Daemon, - logs, - ) - .await; - } - Err(err) => { - let message = format!("failed to join agent task: {err}"); - if !terminate_early { - self.record_error( - &session_id, - message.clone(), - Some("process_wait_failed".to_string()), - None, - ) - .await; - } - let logs = self.read_agent_stderr(agent); - self.mark_session_ended( - &session_id, - None, - &message, - SessionEndReason::Error, - TerminatedBy::Daemon, - logs, - ) - .await; - } - } - } - - async fn record_conversions( - &self, - session_id: &str, - conversions: Vec<EventConversion>, - ) -> Result<Vec<UniversalEvent>, SandboxError> { - let (events, auto_approvals) = { - let mut sessions = self.sessions.lock().await; - let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session_id.to_string(), - } - })?; - let mut accept_edits_permission_ids = Vec::new(); - if session.agent == AgentId::Codex && session.permission_mode == "acceptEdits" { - for conversion in &conversions { - if conversion.event_type != UniversalEventType::PermissionRequested { - continue; - } - let UniversalEventData::Permission(data) = &conversion.data else { - continue; - }; - if is_file_change_action(&data.action) { - accept_edits_permission_ids.push(data.permission_id.clone()); - } - } - } - let events = session.record_conversions(conversions); - let mut auto_approvals = Vec::new(); - let mut seen = HashSet::new(); - for event in &events { - if event.event_type != UniversalEventType::PermissionRequested { - continue; - } - let UniversalEventData::Permission(data) = &event.data else { - continue; - }; - let cached = session.should_auto_approve_permission(&data.action, &data.metadata); - if is_question_tool_action(&data.action) || !cached { - continue; - } - if let Some(pending) = session.take_permission(&data.permission_id) { - auto_approvals.push(( - session.agent, - session.native_session_id.clone(), - session.claude_sender(), - data.permission_id.clone(), - pending, - PermissionReply::Always, - )); - seen.insert(data.permission_id.clone()); - } - } - for permission_id in accept_edits_permission_ids { - if seen.contains(&permission_id) { - continue; - } - if let Some(pending) = session.take_permission(&permission_id) { - auto_approvals.push(( - session.agent, - session.native_session_id.clone(), - session.claude_sender(), - permission_id.clone(), - pending, - PermissionReply::Always, - )); - seen.insert(permission_id); - } - } - (events, auto_approvals) - }; - - for (agent, native_session_id, claude_sender, permission_id, pending, reply) in - auto_approvals - { - let reply_for_status = reply.clone(); - let reply_result = match agent { - AgentId::Codex => { - let (server, _) = self - .server_manager - .ensure_stdio_server(AgentId::Codex) - .await?; - let line = - codex_permission_response_line(&permission_id, &pending, reply.clone())?; - server - .stdin_sender - .send(line) - .map_err(|_| SandboxError::InvalidRequest { - message: "codex server not active".to_string(), - }) - } - AgentId::Opencode => { - let agent_session_id = - native_session_id - .clone() - .ok_or_else(|| SandboxError::InvalidRequest { - message: "missing OpenCode session id".to_string(), - }); - match agent_session_id { - Ok(agent_session_id) => { - self.opencode_permission_reply( - &agent_session_id, - &permission_id, - reply.clone(), - ) - .await - } - Err(err) => Err(err), - } - } - AgentId::Claude => { - let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest { - message: "Claude session is not active".to_string(), - }); - match sender { - Ok(sender) => { - let metadata = pending.metadata.as_ref().and_then(Value::as_object); - let updated_input = metadata - .and_then(|map| map.get("input")) - .cloned() - .unwrap_or(Value::Null); - let mut response_map = serde_json::Map::new(); - match reply.clone() { - PermissionReply::Reject => { - response_map.insert( - "message".to_string(), - Value::String("Permission denied.".to_string()), - ); - } - PermissionReply::Once | PermissionReply::Always => { - if !updated_input.is_null() { - response_map - .insert("updatedInput".to_string(), updated_input); - } - } - } - let behavior = match reply.clone() { - PermissionReply::Reject => "deny", - PermissionReply::Once | PermissionReply::Always => "allow", - }; - let line = claude_control_response_line( - &permission_id, - behavior, - Value::Object(response_map), - ); - sender.send(line).map_err(|_| SandboxError::InvalidRequest { - message: "Claude session is not active".to_string(), - }) - } - Err(err) => Err(err), - } - } - _ => Ok(()), - }; - - if let Err(err) = reply_result { - tracing::warn!( - session_id, - permission_id, - ?err, - "failed to auto-approve cached permission" - ); - let mut sessions = self.sessions.lock().await; - if let Some(session) = Self::session_mut(&mut sessions, session_id) { - session - .pending_permissions - .insert(permission_id.clone(), pending.clone()); - } - continue; - } - - let resolved = EventConversion::new( - UniversalEventType::PermissionResolved, - UniversalEventData::Permission(PermissionEventData { - permission_id: permission_id.clone(), - action: pending.action, - status: match reply_for_status { - PermissionReply::Reject => PermissionStatus::Reject, - PermissionReply::Once => PermissionStatus::Accept, - PermissionReply::Always => PermissionStatus::AcceptForSession, - }, - metadata: pending.metadata, - }), - ) - .synthetic() - .with_native_session(native_session_id); - let mut sessions = self.sessions.lock().await; - if let Some(session) = Self::session_mut(&mut sessions, session_id) { - session.record_conversions(vec![resolved]); - } - } - - Ok(events) - } - - async fn parse_claude_line(&self, line: &str, session_id: &str) -> Vec<EventConversion> { - let trimmed = line.trim(); - if trimmed.is_empty() { - return Vec::new(); - } - let mut value: Value = match serde_json::from_str(trimmed) { - Ok(value) => value, - Err(err) => { - return vec![agent_unparsed( - "claude", - &err.to_string(), - Value::String(trimmed.to_string()), - )]; - } - }; - let event_type = value.get("type").and_then(Value::as_str).unwrap_or(""); - 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) { - 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; - } - } - } - } - - convert_claude::event_to_universal_with_session(&value, session_id.to_string()) - .unwrap_or_else(|err| vec![agent_unparsed("claude", &err, value)]) - } - - async fn record_error( - &self, - session_id: &str, - message: String, - kind: Option<String>, - details: Option<Value>, - ) { - let error = ErrorData { - message, - code: kind, - details, - }; - let conversion = - EventConversion::new(UniversalEventType::Error, UniversalEventData::Error(error)) - .synthetic(); - let _ = self.record_conversions(session_id, vec![conversion]).await; - } - - async fn mark_session_ended( - &self, - session_id: &str, - exit_code: Option<i32>, - message: &str, - reason: SessionEndReason, - terminated_by: TerminatedBy, - stderr: Option<StderrOutput>, - ) { - let mut sessions = self.sessions.lock().await; - if let Some(session) = Self::session_mut(&mut sessions, session_id) { - if session.ended { - return; - } - session.mark_ended( - exit_code, - message.to_string(), - reason.clone(), - terminated_by.clone(), - ); - let (error_message, error_exit_code, error_stderr) = - if reason == SessionEndReason::Error { - (Some(message.to_string()), exit_code, stderr) - } else { - (None, None, None) - }; - let ended = EventConversion::new( - UniversalEventType::SessionEnded, - UniversalEventData::SessionEnded(SessionEndedData { - reason, - terminated_by, - message: error_message, - exit_code: error_exit_code, - stderr: error_stderr, - }), - ) - .synthetic() - .with_native_session(session.native_session_id.clone()); - session.record_conversions(vec![ended]); - } - } - - async fn ensure_opencode_stream( - self: &Arc<Self>, - session_id: String, - ) -> Result<(), SandboxError> { - let native_session_id = - { - let mut sessions = self.sessions.lock().await; - let session = Self::session_mut(&mut sessions, &session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session_id.clone(), - } - })?; - if session.opencode_stream_started { - return Ok(()); - } - let native_session_id = session.native_session_id.clone().ok_or_else(|| { - SandboxError::InvalidRequest { - message: "missing OpenCode session id".to_string(), - } - })?; - session.opencode_stream_started = true; - native_session_id - }; - - let manager = Arc::clone(self); - tokio::spawn(async move { - manager - .stream_opencode_events(session_id, native_session_id) - .await; - }); - - Ok(()) - } - - async fn stream_opencode_events( - self: Arc<Self>, - session_id: String, - native_session_id: String, - ) { - let base_url = match self.ensure_opencode_server().await { - Ok(base_url) => base_url, - Err(err) => { - self.record_error( - &session_id, - format!("failed to start OpenCode server: {err}"), - Some("opencode_server".to_string()), - None, - ) - .await; - let logs = self.read_agent_stderr(AgentId::Opencode); - self.mark_session_ended( - &session_id, - None, - "opencode server unavailable", - SessionEndReason::Error, - TerminatedBy::Daemon, - logs, - ) - .await; - return; - } - }; - - let url = format!("{base_url}/event"); - let response = match self.http_client.get(url).send().await { - Ok(response) => response, - Err(err) => { - self.record_error( - &session_id, - format!("OpenCode SSE connection failed: {err}"), - Some("opencode_stream".to_string()), - None, - ) - .await; - let logs = self.read_agent_stderr(AgentId::Opencode); - self.mark_session_ended( - &session_id, - None, - "opencode sse connection failed", - SessionEndReason::Error, - TerminatedBy::Daemon, - logs, - ) - .await; - return; - } - }; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - self.record_error( - &session_id, - format!("OpenCode SSE error {status}: {body}"), - Some("opencode_stream".to_string()), - None, - ) - .await; - let logs = self.read_agent_stderr(AgentId::Opencode); - self.mark_session_ended( - &session_id, - None, - "opencode sse error", - SessionEndReason::Error, - TerminatedBy::Daemon, - logs, - ) - .await; - return; - } - - let mut accumulator = SseAccumulator::new(); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = match chunk { - Ok(chunk) => chunk, - Err(err) => { - self.record_error( - &session_id, - format!("OpenCode SSE stream error: {err}"), - Some("opencode_stream".to_string()), - None, - ) - .await; - let logs = self.read_agent_stderr(AgentId::Opencode); - self.mark_session_ended( - &session_id, - None, - "opencode sse stream error", - SessionEndReason::Error, - TerminatedBy::Daemon, - logs, - ) - .await; - return; - } - }; - let text = String::from_utf8_lossy(&chunk); - for event_payload in accumulator.push(&text) { - let value: Value = match serde_json::from_str(&event_payload) { - Ok(value) => value, - Err(err) => { - let conversion = agent_unparsed( - "opencode", - &err.to_string(), - Value::String(event_payload.clone()), - ); - let _ = self.record_conversions(&session_id, vec![conversion]).await; - continue; - } - }; - if !opencode_event_matches_session(&value, &native_session_id) { - continue; - } - // Manual type-based dispatch to bypass broken #[serde(untagged)] - // enum ordering where ServerConnected (variant #5, empty properties) - // matches all events before MessageUpdated (variant #10) gets tried. - let event_type = value.get("type").and_then(|t| t.as_str()).unwrap_or(""); - let conversions = match event_type { - "message.updated" => { - match serde_json::from_value::<opencode_schema::EventMessageUpdated>(value.clone()) { - Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::MessageUpdated(e)) { - Ok(c) => c, - Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], - }, - Err(err) => vec![agent_unparsed("opencode", &format!("message.updated: {}", err), value.clone())], - } - } - "message.part.updated" => { - match serde_json::from_value::<opencode_schema::EventMessagePartUpdated>(value.clone()) { - Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::MessagePartUpdated(e)) { - Ok(c) => c, - Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], - }, - Err(err) => vec![agent_unparsed("opencode", &format!("message.part.updated: {}", err), value.clone())], - } - } - "question.asked" => { - match serde_json::from_value::<opencode_schema::EventQuestionAsked>(value.clone()) { - Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::QuestionAsked(e)) { - Ok(c) => c, - Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], - }, - Err(err) => vec![agent_unparsed("opencode", &format!("question.asked: {}", err), value.clone())], - } - } - "permission.asked" => { - match serde_json::from_value::<opencode_schema::EventPermissionAsked>(value.clone()) { - Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::PermissionAsked(e)) { - Ok(c) => c, - Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], - }, - Err(err) => vec![agent_unparsed("opencode", &format!("permission.asked: {}", err), value.clone())], - } - } - "session.created" => { - match serde_json::from_value::<opencode_schema::EventSessionCreated>(value.clone()) { - Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::SessionCreated(e)) { - Ok(c) => c, - Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], - }, - Err(err) => vec![agent_unparsed("opencode", &format!("session.created: {}", err), value.clone())], - } - } - "session.status" => { - match serde_json::from_value::<opencode_schema::EventSessionStatus>(value.clone()) { - Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::SessionStatus(e)) { - Ok(c) => c, - Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], - }, - Err(err) => vec![agent_unparsed("opencode", &format!("session.status: {}", err), value.clone())], - } - } - "session.idle" => { - match serde_json::from_value::<opencode_schema::EventSessionIdle>(value.clone()) { - Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::SessionIdle(e)) { - Ok(c) => c, - Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], - }, - Err(err) => vec![agent_unparsed("opencode", &format!("session.idle: {}", err), value.clone())], - } - } - "session.error" => { - match serde_json::from_value::<opencode_schema::EventSessionError>(value.clone()) { - Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::SessionError(e)) { - Ok(c) => c, - Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], - }, - Err(err) => vec![agent_unparsed("opencode", &format!("session.error: {}", err), value.clone())], - } - } - // Informational events we can safely skip - "server.connected" | "server.heartbeat" | "session.updated" - | "session.diff" | "file.watcher.updated" => { - continue; - } - _ => { - vec![agent_unparsed("opencode", &format!("unknown event type: {}", event_type), value.clone())] - } - }; - let _ = self.record_conversions(&session_id, conversions).await; - } - } - } - - async fn ensure_opencode_server(&self) -> Result<String, SandboxError> { - self.server_manager - .ensure_http_server(AgentId::Opencode) - .await - } - - /// Ensures a shared Codex app-server process is running. - /// Spawns the process if not already running, sets up stdin/stdout tasks, - /// and performs the initialize handshake if needed. - async fn ensure_codex_server(self: &Arc<Self>) -> Result<Arc<CodexServer>, SandboxError> { - let (server, receiver) = self - .server_manager - .ensure_stdio_server(AgentId::Codex) - .await?; - - if let Some(stdout_rx) = receiver { - let server_for_task = server.clone(); - let self_for_task = Arc::clone(self); - tokio::spawn(async move { - self_for_task - .handle_codex_server_output(server_for_task, stdout_rx) - .await; - }); - } - - self.codex_server_initialize(&server).await?; - - Ok(server) - } - - /// Handles output from the Codex app-server, routing responses and notifications. - async fn handle_codex_server_output( - self: Arc<Self>, - server: Arc<CodexServer>, - mut stdout_rx: mpsc::UnboundedReceiver<String>, - ) { - while let Some(line) = stdout_rx.recv().await { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - let value: Value = match serde_json::from_str(trimmed) { - Ok(v) => v, - Err(_) => continue, - }; - - let message: codex_schema::JsonrpcMessage = match serde_json::from_value(value.clone()) - { - Ok(m) => m, - Err(_) => continue, - }; - - match message { - codex_schema::JsonrpcMessage::Response(response) => { - // Route response to waiting request - if let Some(id) = codex_request_id_to_i64(&response.id) { - server.take_request_session(id); - server.complete_request(id, CodexRequestResult::Response(response.result)); - } - } - codex_schema::JsonrpcMessage::Notification(_) => { - // Route notification to correct session by thread_id - if let Ok(notification) = - serde_json::from_value::<codex_schema::ServerNotification>(value.clone()) - { - if let Some(thread_id) = - codex_thread_id_from_server_notification(¬ification) - { - if let Some(session_id) = server.session_for_thread(&thread_id) { - if let codex_schema::ServerNotification::Error(params) = - ¬ification - { - if let Some(model_id) = - codex_unavailable_model_from_message(¶ms.error.message) - { - self.handle_codex_model_unavailable( - &session_id, - &model_id, - Some(thread_id.clone()), - ) - .await; - } - } - let conversions = - match convert_codex::notification_to_universal(¬ification) { - Ok(c) => c, - Err(err) => { - vec![agent_unparsed("codex", &err, value.clone())] - } - }; - let _ = self.record_conversions(&session_id, conversions).await; - } - } - } - } - codex_schema::JsonrpcMessage::Request(_) => { - // Handle server requests (permission requests) - if let Ok(request) = - serde_json::from_value::<codex_schema::ServerRequest>(value.clone()) - { - if let Some(thread_id) = codex_thread_id_from_server_request(&request) { - if let Some(session_id) = server.session_for_thread(&thread_id) { - match codex_request_to_universal(&request) { - Ok(mut conversions) => { - for conversion in &mut conversions { - conversion.raw = Some(value.clone()); - } - let _ = - self.record_conversions(&session_id, conversions).await; - } - Err(err) => { - let _ = self - .record_conversions( - &session_id, - vec![agent_unparsed("codex", &err, value.clone())], - ) - .await; - } - } - } - } - } - } - codex_schema::JsonrpcMessage::Error(error) => { - if let Some(id) = codex_request_id_to_i64(&error.id) { - let session_id = server.take_request_session(id); - server.complete_request(id, CodexRequestResult::Error(error.error.clone())); - if let Some(session_id) = session_id { - if let Some(model_id) = - codex_unavailable_model_from_rpc_error(&error.error) - { - self.handle_codex_model_unavailable(&session_id, &model_id, None) - .await; - } - let _ = self - .record_conversions( - &session_id, - vec![codex_rpc_error_to_universal(&error)], - ) - .await; - } else { - eprintln!("Codex server error: {:?}", error); - } - } else { - eprintln!("Codex server error: {:?}", error); - } - } - } - } - } - - /// Performs the initialize/initialized handshake with the Codex server. - async fn codex_server_initialize(&self, server: &CodexServer) -> Result<(), SandboxError> { - let _initialize_guard = server.initialize_lock.lock().await; - if server.is_initialized() { - return Ok(()); - } - - let id = server.next_request_id(); - let request = codex_schema::ClientRequest::Initialize { - id: codex_schema::RequestId::from(id), - params: codex_schema::InitializeParams { - client_info: codex_schema::ClientInfo { - name: "sandbox-agent".to_string(), - title: Some("sandbox-agent".to_string()), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - }, - }; - - let rx = server - .send_request(id, &request) - .ok_or_else(|| SandboxError::StreamError { - message: "failed to send initialize request".to_string(), - })?; - - // Wait for initialize response with timeout - let result = tokio::time::timeout(Duration::from_secs(30), rx).await; - match result { - Ok(Ok(CodexRequestResult::Response(_))) => { - // Send initialized notification - let notification = codex_schema::JsonrpcNotification { - method: "initialized".to_string(), - params: None, - }; - server.send_notification(¬ification); - server.set_initialized(); - Ok(()) - } - Ok(Ok(CodexRequestResult::Error(error))) => Err(codex_request_error_to_sandbox( - "initialize request failed", - &error, - )), - Ok(Err(_)) => Err(SandboxError::StreamError { - message: "initialize request cancelled".to_string(), - }), - Err(_) => Err(SandboxError::StreamError { - message: "initialize request timed out".to_string(), - }), - } - } - - async fn reload_codex_mcp(&self, server: &CodexServer) -> Result<(), SandboxError> { - let id = server.next_request_id(); - let request = codex_schema::ClientRequest::ConfigMcpServerReload { - id: codex_schema::RequestId::from(id), - params: (), - }; - let rx = server - .send_request(id, &request) - .ok_or_else(|| SandboxError::StreamError { - message: "failed to send config/mcpServer/reload request".to_string(), - })?; - let result = tokio::time::timeout(Duration::from_secs(15), rx).await; - match result { - Ok(Ok(_)) => Ok(()), - Ok(Err(_)) => Err(SandboxError::StreamError { - message: "config/mcpServer/reload request cancelled".to_string(), - }), - Err(_) => Err(SandboxError::StreamError { - message: "config/mcpServer/reload request timed out".to_string(), - }), - } - } - - /// Creates a new Codex thread/session via the shared app-server. - async fn create_codex_thread( - self: &Arc<Self>, - session_id: &str, - session: &SessionSnapshot, - ) -> Result<String, SandboxError> { - let server = self.ensure_codex_server().await?; - - let id = server.next_request_id(); - let mut params = codex_schema::ThreadStartParams::default(); - params.approval_policy = codex_approval_policy(Some(&session.permission_mode)); - params.sandbox = codex_sandbox_mode(Some(&session.permission_mode)); - params.model = session.model.clone(); - - let request = codex_schema::ClientRequest::ThreadStart { - id: codex_schema::RequestId::from(id), - params, - }; - - let rx = server - .send_request(id, &request) - .ok_or_else(|| SandboxError::StreamError { - message: "failed to send thread/start request".to_string(), - })?; - - // Wait for thread/start response - let result = tokio::time::timeout(Duration::from_secs(30), rx).await; - match result { - Ok(Ok(CodexRequestResult::Response(response))) => { - // Extract thread_id from response - let thread_id = response - .get("thread") - .and_then(|t| t.get("id")) - .and_then(Value::as_str) - .or_else(|| response.get("threadId").and_then(Value::as_str)) - .ok_or_else(|| SandboxError::StreamError { - message: "thread/start response missing thread id".to_string(), - })? - .to_string(); - - // Register thread -> session mapping - server.register_thread(thread_id.clone(), session_id.to_string()); - - Ok(thread_id) - } - Ok(Ok(CodexRequestResult::Error(error))) => Err(codex_request_error_to_sandbox( - "thread/start request failed", - &error, - )), - Ok(Err(_)) => Err(SandboxError::StreamError { - message: "thread/start request cancelled".to_string(), - }), - Err(_) => Err(SandboxError::StreamError { - message: "thread/start request timed out".to_string(), - }), - } - } - - /// Sends a turn/start request to an existing Codex thread. - async fn send_codex_turn( - self: &Arc<Self>, - session: &SessionSnapshot, - prompt: &str, - ) -> Result<(), SandboxError> { - let server = self.ensure_codex_server().await?; - - let thread_id = - session - .native_session_id - .as_ref() - .ok_or_else(|| SandboxError::InvalidRequest { - message: "missing Codex thread id".to_string(), - })?; - - let id = server.next_request_id(); - let prompt_text = codex_prompt_for_mode(prompt, Some(&session.agent_mode)); - let params = codex_schema::TurnStartParams { - approval_policy: codex_approval_policy(Some(&session.permission_mode)), - collaboration_mode: None, - cwd: None, - effort: None, - input: vec![codex_schema::UserInput::Text { - text: prompt_text, - text_elements: Vec::new(), - }], - model: session.model.clone(), - output_schema: None, - sandbox_policy: codex_sandbox_policy(Some(&session.permission_mode)), - summary: None, - thread_id: thread_id.clone(), - }; - - let request = codex_schema::ClientRequest::TurnStart { - id: codex_schema::RequestId::from(id), - params, - }; - - // Send but don't wait for response - notifications will stream back - server - .send_request_with_session(id, &request, Some(session.session_id.clone())) - .ok_or_else(|| SandboxError::StreamError { - message: "failed to send turn/start request".to_string(), - })?; - - Ok(()) - } - - fn apply_pi_model_args(command: &mut std::process::Command, model: Option<&str>) { - let Some(model) = model else { - return; - }; - if let Some((provider, model_id)) = model.split_once('/') { - command - .arg("--provider") - .arg(provider) - .arg("--model") - .arg(model_id); - return; - } - command.arg("--model").arg(model); - } - - async fn spawn_pi_runtime( - self: &Arc<Self>, - model: Option<&str>, - ) -> Result<(Arc<PiSessionRuntime>, mpsc::UnboundedReceiver<String>), SandboxError> { - let manager = self.agent_manager.clone(); - let log_dir = self.server_manager.log_base_dir.clone(); - let model = model.map(str::to_string); - let (stdin_tx, stdin_rx) = mpsc::unbounded_channel::<String>(); - let (stdout_tx, stdout_rx) = mpsc::unbounded_channel::<String>(); - - let child = - tokio::task::spawn_blocking(move || -> Result<std::process::Child, SandboxError> { - let path = manager - .resolve_binary(AgentId::Pi) - .map_err(|err| map_spawn_error(AgentId::Pi, err))?; - let mut command = std::process::Command::new(path); - let stderr = AgentServerLogs::new(log_dir, AgentId::Pi.as_str()).open()?; - command.arg("--mode").arg("rpc"); - Self::apply_pi_model_args(&mut command, model.as_deref()); - command - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(stderr); - - let mut child = command.spawn().map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - - let stdin = child - .stdin - .take() - .ok_or_else(|| SandboxError::StreamError { - message: "pi stdin unavailable".to_string(), - })?; - let stdout = child - .stdout - .take() - .ok_or_else(|| SandboxError::StreamError { - message: "pi stdout unavailable".to_string(), - })?; - - let stdin_rx_mut = std::sync::Mutex::new(stdin_rx); - std::thread::spawn(move || { - let mut stdin = stdin; - let mut rx = stdin_rx_mut.lock().unwrap(); - while let Some(line) = rx.blocking_recv() { - if writeln!(stdin, "{line}").is_err() { - break; - } - if stdin.flush().is_err() { - break; - } - } - }); - - std::thread::spawn(move || { - let reader = BufReader::new(stdout); - for line in reader.lines() { - let Ok(line) = line else { break }; - if stdout_tx.send(line).is_err() { - break; - } - } - }); - - Ok(child) - }) - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })??; - - let child = Arc::new(std::sync::Mutex::new(Some(child))); - let runtime = Arc::new(PiSessionRuntime::new(stdin_tx, child)); - Ok((runtime, stdout_rx)) - } - - fn spawn_pi_runtime_tasks( - self: &Arc<Self>, - session_id: String, - runtime: Arc<PiSessionRuntime>, - stdout_rx: mpsc::UnboundedReceiver<String>, - ) { - let output_manager = Arc::clone(self); - let output_session_id = session_id.clone(); - let output_runtime = runtime.clone(); - tokio::spawn(async move { - output_manager - .handle_pi_runtime_output(output_session_id, output_runtime, stdout_rx) - .await; - }); - - let exit_manager = Arc::clone(self); - tokio::spawn(async move { - loop { - let status = { - let mut guard = match runtime.child.lock() { - Ok(guard) => guard, - Err(_) => return, - }; - match guard.as_mut() { - Some(child) => match child.try_wait() { - Ok(status) => status, - Err(_) => None, - }, - None => return, - } - }; - - if let Some(status) = status { - if let Ok(mut guard) = runtime.child.lock() { - *guard = None; - } - exit_manager - .handle_pi_runtime_exit(&session_id, runtime.clone(), status) - .await; - break; - } - - sleep(Duration::from_millis(250)).await; - } - }); - } - - async fn is_active_pi_runtime( - &self, - session_id: &str, - runtime: &Arc<PiSessionRuntime>, - ) -> bool { - let sessions = self.sessions.lock().await; - let Some(session) = Self::session_ref(&sessions, session_id) else { - return false; - }; - session - .pi_runtime - .as_ref() - .is_some_and(|active| Arc::ptr_eq(active, runtime)) - } - - async fn session_native_session_id(&self, session_id: &str) -> Option<String> { - let sessions = self.sessions.lock().await; - Self::session_ref(&sessions, session_id) - .and_then(|session| session.native_session_id.clone()) - } - - /// Handles output from one Pi runtime process and routes events to exactly one daemon session. - async fn handle_pi_runtime_output( - self: Arc<Self>, - session_id: String, - runtime: Arc<PiSessionRuntime>, - mut stdout_rx: mpsc::UnboundedReceiver<String>, - ) { - while let Some(line) = stdout_rx.recv().await { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - let value: Value = match serde_json::from_str(trimmed) { - Ok(v) => v, - Err(err) => { - if self.is_active_pi_runtime(&session_id, &runtime).await { - self.record_pi_unparsed( - &session_id, - &err.to_string(), - Value::String(trimmed.to_string()), - ) - .await; - } - continue; - } - }; - - let message_type = value.get("type").and_then(Value::as_str).unwrap_or(""); - if message_type == "response" { - let id = value - .get("id") - .and_then(Value::as_i64) - .or_else(|| value.get("id").and_then(Value::as_str)?.parse::<i64>().ok()); - if let Some(id) = id { - runtime.complete_request(id, value.clone()); - } - continue; - } - - if !self.is_active_pi_runtime(&session_id, &runtime).await { - continue; - } - - let event: pi_schema::RpcEvent = match serde_json::from_value(value.clone()) { - Ok(event) => event, - Err(err) => { - self.record_pi_unparsed(&session_id, &err.to_string(), value.clone()) - .await; - continue; - } - }; - - let conversions = { - let mut converter = runtime.converter.lock().unwrap(); - converter.event_to_universal(&event) - }; - - let mut conversions = match conversions { - Ok(conversions) => conversions, - Err(err) => { - self.record_pi_unparsed(&session_id, &err, value.clone()) - .await; - continue; - } - }; - - let native_session_id = self.session_native_session_id(&session_id).await; - for conversion in &mut conversions { - if conversion.native_session_id.is_none() { - conversion.native_session_id = native_session_id.clone(); - } - conversion.raw = Some(value.clone()); - } - - let _ = self.record_conversions(&session_id, conversions).await; - } - } - - async fn handle_pi_runtime_exit( - &self, - session_id: &str, - runtime: Arc<PiSessionRuntime>, - status: std::process::ExitStatus, - ) { - runtime.clear_pending(); - - let should_emit_error = { - let mut sessions = self.sessions.lock().await; - let Some(session) = Self::session_mut(&mut sessions, session_id) else { - return; - }; - let Some(active_runtime) = session.pi_runtime.as_ref() else { - return; - }; - if !Arc::ptr_eq(active_runtime, &runtime) { - return; - } - session.pi_runtime = None; - !runtime.shutdown_requested() && !session.ended - }; - - if !should_emit_error { - return; - } - - let message = format!("pi rpc process exited with status {:?}", status); - self.record_error( - session_id, - message.clone(), - Some("server_exit".to_string()), - None, - ) - .await; - let logs = self.read_agent_stderr(AgentId::Pi); - self.mark_session_ended( - session_id, - status.code(), - &message, - SessionEndReason::Error, - TerminatedBy::Daemon, - logs, - ) - .await; - } - - async fn create_pi_session( - self: &Arc<Self>, - session_id: &str, - model: Option<&str>, - ) -> Result<PiSessionBootstrap, SandboxError> { - let (runtime, stdout_rx) = self.spawn_pi_runtime(model).await?; - self.spawn_pi_runtime_tasks(session_id.to_string(), runtime.clone(), stdout_rx); - - let result: Result<PiSessionBootstrap, SandboxError> = async { - let new_id = runtime.next_request_id(); - let new_request = json!({ - "type": "new_session", - "id": new_id - }); - let new_rx = runtime.send_request(new_id, &new_request).ok_or_else(|| { - SandboxError::StreamError { - message: "failed to send pi new_session request".to_string(), - } - })?; - let new_response = tokio::time::timeout(Duration::from_secs(30), new_rx).await; - let new_response = match new_response { - Ok(Ok(response)) => response, - Ok(Err(_)) => { - return Err(SandboxError::StreamError { - message: "pi new_session request cancelled".to_string(), - }) - } - Err(_) => { - return Err(SandboxError::StreamError { - message: "pi new_session request timed out".to_string(), - }) - } - }; - if new_response - .get("success") - .and_then(Value::as_bool) - .is_some_and(|success| !success) - { - return Err(SandboxError::StreamError { - message: format!("pi new_session failed: {new_response}"), - }); - } - if new_response - .get("data") - .and_then(|value| value.get("cancelled")) - .and_then(Value::as_bool) - .is_some_and(|cancelled| cancelled) - { - return Err(SandboxError::StreamError { - message: "pi new_session request cancelled".to_string(), - }); - } - - let state_id = runtime.next_request_id(); - let state_request = json!({ - "type": "get_state", - "id": state_id - }); - let state_rx = runtime - .send_request(state_id, &state_request) - .ok_or_else(|| SandboxError::StreamError { - message: "failed to send pi get_state request".to_string(), - })?; - let state_response = tokio::time::timeout(Duration::from_secs(30), state_rx).await; - let state_response = match state_response { - Ok(Ok(response)) => response, - Ok(Err(_)) => { - return Err(SandboxError::StreamError { - message: "pi get_state request cancelled".to_string(), - }) - } - Err(_) => { - return Err(SandboxError::StreamError { - message: "pi get_state request timed out".to_string(), - }) - } - }; - if state_response - .get("success") - .and_then(Value::as_bool) - .is_some_and(|success| !success) - { - return Err(SandboxError::StreamError { - message: format!("pi get_state failed: {state_response}"), - }); - } - - let state = state_response.get("data").unwrap_or(&state_response); - let native_session_id = state - .get("sessionId") - .or_else(|| state.get("session_id")) - .and_then(Value::as_str) - .ok_or_else(|| SandboxError::StreamError { - message: "pi get_state response missing session id".to_string(), - })? - .to_string(); - let session_file = state - .get("sessionFile") - .or_else(|| state.get("session_file")) - .and_then(Value::as_str) - .map(|value| value.to_string()); - - Ok(PiSessionBootstrap { - runtime: runtime.clone(), - native_session_id, - _session_file: session_file, - }) - } - .await; - - if result.is_err() { - runtime.shutdown(); - } - - result - } - - async fn send_pi_prompt( - self: &Arc<Self>, - session: &SessionSnapshot, - prompt: &str, - ) -> Result<(), SandboxError> { - let runtime = { - let sessions = self.sessions.lock().await; - let session_state = - Self::session_ref(&sessions, &session.session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session.session_id.clone(), - } - })?; - session_state - .pi_runtime - .clone() - .ok_or_else(|| SandboxError::InvalidRequest { - message: "Pi session runtime is not active".to_string(), - })? - }; - - if let Some(level) = session.variant.as_deref() { - self.set_pi_thinking_level(&runtime, level).await?; - } - - let id = runtime.next_request_id(); - let request = json!({ - "type": "prompt", - "id": id, - "message": prompt - }); - - let response_rx = - runtime - .send_request(id, &request) - .ok_or_else(|| SandboxError::StreamError { - message: "failed to send pi prompt request".to_string(), - })?; - let response = tokio::time::timeout(Duration::from_secs(30), response_rx) - .await - .map_err(|_| SandboxError::StreamError { - message: "pi prompt request timed out".to_string(), - })? - .map_err(|_| SandboxError::StreamError { - message: "pi prompt request cancelled".to_string(), - })?; - if response - .get("success") - .and_then(Value::as_bool) - .is_some_and(|success| !success) - { - let detail = response - .get("error") - .cloned() - .or_else(|| { - response - .get("data") - .and_then(|data| data.get("error")) - .cloned() - }) - .unwrap_or_else(|| response.clone()); - return Err(SandboxError::InvalidRequest { - message: format!("pi prompt failed: {detail}"), - }); - } - - Ok(()) - } - - async fn set_pi_thinking_level( - &self, - runtime: &Arc<PiSessionRuntime>, - level: &str, - ) -> Result<(), SandboxError> { - let id = runtime.next_request_id(); - let request = json!({ - "type": "set_thinking_level", - "id": id, - "level": level - }); - let response_rx = - runtime - .send_request(id, &request) - .ok_or_else(|| SandboxError::StreamError { - message: "failed to send pi set_thinking_level request".to_string(), - })?; - let response = tokio::time::timeout(Duration::from_secs(30), response_rx) - .await - .map_err(|_| SandboxError::StreamError { - message: "pi set_thinking_level request timed out".to_string(), - })? - .map_err(|_| SandboxError::StreamError { - message: "pi set_thinking_level request cancelled".to_string(), - })?; - if response - .get("success") - .and_then(Value::as_bool) - .is_some_and(|success| !success) - { - let detail = response - .get("error") - .cloned() - .or_else(|| { - response - .get("data") - .and_then(|data| data.get("error")) - .cloned() - }) - .unwrap_or_else(|| response.clone()); - return Err(SandboxError::InvalidRequest { - message: format!("pi set_thinking_level failed for '{level}': {detail}"), - }); - } - - Ok(()) - } - - async fn record_pi_unparsed(&self, session_id: &str, error: &str, raw: Value) { - let _ = self - .record_conversions(session_id, vec![agent_unparsed("pi", error, raw)]) - .await; - } - - async fn fetch_opencode_modes(&self) -> Result<Vec<AgentModeInfo>, SandboxError> { - let base_url = self.ensure_opencode_server().await?; - let endpoints = [ - format!("{base_url}/app/agents"), - format!("{base_url}/agents"), - ]; - for url in endpoints { - let response = self.http_client.get(&url).send().await; - let response = match response { - Ok(response) => response, - Err(_) => continue, - }; - if !response.status().is_success() { - continue; - } - let value: Value = response - .json() - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - let modes = parse_opencode_modes(&value); - if !modes.is_empty() { - return Ok(modes); - } - } - Err(SandboxError::StreamError { - message: "OpenCode agent modes unavailable".to_string(), - }) - } - - async fn fetch_claude_models(&self) -> Result<AgentModelsResponse, SandboxError> { - let started = Instant::now(); - let credentials = self.extract_credentials().await?; - let Some(cred) = credentials.anthropic else { - tracing::info!( - elapsed_ms = started.elapsed().as_millis() as u64, - "claude model fetch skipped (no anthropic credentials)" - ); - return Ok(AgentModelsResponse { - models: Vec::new(), - default_model: None, - }); - }; - - let headers = build_anthropic_headers(&cred)?; - let response = self - .http_client - .get(ANTHROPIC_MODELS_URL) - .headers(headers) - .send() - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if matches!(cred.auth_type, AuthType::Oauth) { - tracing::warn!( - status = %status, - elapsed_ms = started.elapsed().as_millis() as u64, - "Anthropic model list rejected OAuth credentials; using Claude OAuth fallback models" - ); - return Ok(claude_fallback_models()); - } - return Err(SandboxError::StreamError { - message: format!("Anthropic models request failed {status}: {body}"), - }); - } - - let value: Value = response - .json() - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - let data = value - .get("data") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - - let mut models = Vec::new(); - let mut default_model: Option<String> = None; - let mut default_created: Option<String> = None; - for item in data { - let Some(id) = item.get("id").and_then(Value::as_str) else { - continue; - }; - let name = item - .get("display_name") - .and_then(Value::as_str) - .map(|value| value.to_string()); - let created = item - .get("created_at") - .and_then(Value::as_str) - .map(|value| value.to_string()); - if let Some(created) = created.as_ref() { - let should_update = match default_created.as_deref() { - Some(current) => created.as_str() > current, - None => true, - }; - if should_update { - default_created = Some(created.clone()); - default_model = Some(id.to_string()); - } - } - models.push(AgentModelInfo { - id: id.to_string(), - name, - variants: None, - default_variant: None, - }); - } - models.sort_by(|a, b| a.id.cmp(&b.id)); - if default_model.is_none() { - default_model = models.first().map(|model| model.id.clone()); - } - - if models.is_empty() && matches!(cred.auth_type, AuthType::Oauth) { - tracing::warn!( - elapsed_ms = started.elapsed().as_millis() as u64, - "Anthropic model list was empty for OAuth credentials; using Claude OAuth fallback models" - ); - return Ok(claude_fallback_models()); - } - - tracing::info!( - elapsed_ms = started.elapsed().as_millis() as u64, - model_count = models.len(), - has_default = default_model.is_some(), - "claude model fetch completed" - ); - Ok(AgentModelsResponse { - models, - default_model, - }) - } - - async fn fetch_codex_models(self: &Arc<Self>) -> Result<AgentModelsResponse, SandboxError> { - let started = Instant::now(); - let server = self.ensure_codex_server().await?; - tracing::info!( - elapsed_ms = started.elapsed().as_millis() as u64, - "codex model fetch server ready" - ); - let mut models: Vec<AgentModelInfo> = Vec::new(); - let mut default_model: Option<String> = None; - let mut seen = HashSet::new(); - let mut cursor: Option<String> = None; - let mut pages: usize = 0; - - loop { - let id = server.next_request_id(); - let page_started = Instant::now(); - let request = json!({ - "jsonrpc": "2.0", - "id": id, - "method": "model/list", - "params": { - "cursor": cursor, - "limit": null - } - }); - let rx = - server - .send_request(id, &request) - .ok_or_else(|| SandboxError::StreamError { - message: "failed to send model/list request".to_string(), - })?; - - let result = - tokio::time::timeout(Duration::from_secs(CODEX_MODEL_LIST_TIMEOUT_SECS), rx).await; - let value = match result { - Ok(Ok(CodexRequestResult::Response(value))) => value, - Ok(Ok(CodexRequestResult::Error(error))) => { - tracing::warn!( - elapsed_ms = started.elapsed().as_millis() as u64, - page = pages + 1, - error = %error.message, - "codex model/list request failed" - ); - return Err(codex_request_error_to_sandbox( - "model/list request failed", - &error, - )); - } - Ok(Err(_)) => { - tracing::warn!( - elapsed_ms = started.elapsed().as_millis() as u64, - page = pages + 1, - "codex model/list request cancelled" - ); - return Err(SandboxError::StreamError { - message: "model/list request cancelled".to_string(), - }); - } - Err(_) => { - tracing::warn!( - elapsed_ms = started.elapsed().as_millis() as u64, - page = pages + 1, - timeout_secs = CODEX_MODEL_LIST_TIMEOUT_SECS, - "codex model/list request timed out" - ); - return Err(SandboxError::StreamError { - message: "model/list request timed out".to_string(), - }); - } - }; - pages += 1; - tracing::info!( - page = pages, - elapsed_ms = page_started.elapsed().as_millis() as u64, - total_elapsed_ms = started.elapsed().as_millis() as u64, - "codex model/list page fetched" - ); - - let data = value - .get("data") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - - for item in data { - let model_id = item - .get("model") - .and_then(Value::as_str) - .or_else(|| item.get("id").and_then(Value::as_str)); - let Some(model_id) = model_id else { - continue; - }; - if !seen.insert(model_id.to_string()) { - continue; - } - - let name = item - .get("displayName") - .and_then(Value::as_str) - .map(|value| value.to_string()); - - if default_model.is_none() - && item - .get("isDefault") - .and_then(Value::as_bool) - .unwrap_or(false) - { - default_model = Some(model_id.to_string()); - } - - models.push(AgentModelInfo { - id: model_id.to_string(), - name, - variants: None, - default_variant: None, - }); - } - - let next_cursor = value - .get("nextCursor") - .and_then(Value::as_str) - .map(|value| value.to_string()); - if next_cursor.is_none() { - break; - } - cursor = next_cursor; - } - - models.sort_by(|a, b| a.id.cmp(&b.id)); - if default_model.is_none() { - default_model = models.first().map(|model| model.id.clone()); - } - - tracing::info!( - elapsed_ms = started.elapsed().as_millis() as u64, - page_count = pages, - model_count = models.len(), - has_default = default_model.is_some(), - "codex model fetch completed" - ); - Ok(AgentModelsResponse { - models, - default_model, - }) - } - - async fn fetch_opencode_models(&self) -> Result<AgentModelsResponse, SandboxError> { - let started = Instant::now(); - let base_url = self.ensure_opencode_server().await?; - let endpoints = [ - format!("{base_url}/config/providers"), - format!("{base_url}/provider"), - ]; - for url in endpoints { - let endpoint_started = Instant::now(); - let response = self.http_client.get(&url).send().await; - let response = match response { - Ok(response) => response, - Err(err) => { - tracing::warn!( - url, - elapsed_ms = endpoint_started.elapsed().as_millis() as u64, - total_elapsed_ms = started.elapsed().as_millis() as u64, - ?err, - "opencode model endpoint request failed" - ); - continue; - } - }; - if !response.status().is_success() { - tracing::warn!( - url, - status = %response.status(), - elapsed_ms = endpoint_started.elapsed().as_millis() as u64, - total_elapsed_ms = started.elapsed().as_millis() as u64, - "opencode model endpoint returned non-success status" - ); - continue; - } - let value: Value = response - .json() - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - if let Some(models) = parse_opencode_models(&value) { - tracing::info!( - url, - elapsed_ms = endpoint_started.elapsed().as_millis() as u64, - total_elapsed_ms = started.elapsed().as_millis() as u64, - model_count = models.models.len(), - has_default = models.default_model.is_some(), - "opencode model fetch completed" - ); - return Ok(models); - } - tracing::warn!( - url, - elapsed_ms = endpoint_started.elapsed().as_millis() as u64, - total_elapsed_ms = started.elapsed().as_millis() as u64, - "opencode model endpoint parse returned no models" - ); - } - tracing::warn!( - elapsed_ms = started.elapsed().as_millis() as u64, - "opencode model fetch failed" - ); - Err(SandboxError::StreamError { - message: "OpenCode models unavailable".to_string(), - }) - } - - async fn fetch_pi_models(&self) -> Result<AgentModelsResponse, SandboxError> { - let binary = self - .agent_manager - .resolve_binary(AgentId::Pi) - .map_err(|err| map_spawn_error(AgentId::Pi, err))?; - let output = tokio::time::timeout( - Duration::from_secs(10), - tokio::task::spawn_blocking(move || { - std::process::Command::new(binary) - .arg("--list-models") - .output() - }), - ) - .await - .map_err(|_| SandboxError::StreamError { - message: "pi --list-models timed out".to_string(), - })? - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })? - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let message = if stderr.is_empty() { - format!("pi --list-models failed with status {}", output.status) - } else { - format!( - "pi --list-models failed with status {}: {stderr}", - output.status - ) - }; - return Err(SandboxError::StreamError { message }); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - Ok(parse_pi_models_output(&stdout)) - } - - async fn extract_credentials(&self) -> Result<ExtractedCredentials, SandboxError> { - tokio::task::spawn_blocking(move || { - let options = CredentialExtractionOptions::new(); - extract_all_credentials(&options) - }) - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - }) - } - - async fn create_opencode_session(&self) -> Result<String, SandboxError> { - let base_url = self.ensure_opencode_server().await?; - let url = format!("{base_url}/session"); - for _ in 0..10 { - let response = self.http_client.post(&url).json(&json!({})).send().await; - let response = match response { - Ok(response) => response, - Err(_) => { - sleep(Duration::from_millis(200)).await; - continue; - } - }; - if !response.status().is_success() { - sleep(Duration::from_millis(200)).await; - continue; - } - let value: Value = response - .json() - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - if let Some(id) = value.get("id").and_then(Value::as_str) { - return Ok(id.to_string()); - } - if let Some(id) = value.get("sessionId").and_then(Value::as_str) { - return Ok(id.to_string()); - } - if let Some(id) = value.get("session_id").and_then(Value::as_str) { - return Ok(id.to_string()); - } - return Err(SandboxError::StreamError { - message: format!("OpenCode session response missing id: {value}"), - }); - } - Err(SandboxError::StreamError { - message: "OpenCode session create failed after retries".to_string(), - }) - } - - async fn apply_opencode_mcp( - &self, - mcp: &BTreeMap<String, McpServerConfig>, - ) -> Result<(), SandboxError> { - if mcp.is_empty() { - return Ok(()); - } - let base_url = self.ensure_opencode_server().await?; - let url = format!("{base_url}/mcp"); - let mut existing = HashSet::new(); - if let Ok(response) = self.http_client.get(&url).send().await { - if response.status().is_success() { - if let Ok(value) = response.json::<Value>().await { - if let Some(map) = value.as_object() { - for key in map.keys() { - existing.insert(key.clone()); - } - } - } - } - } - - for (name, config) in mcp { - if existing.contains(name) { - continue; - } - let config_value = opencode_mcp_config(config)?; - let body = json!({ "name": name, "config": config_value }); - let response = self.http_client.post(&url).json(&body).send().await; - let response = response.map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - if !response.status().is_success() { - return Err(SandboxError::StreamError { - message: format!("OpenCode MCP add failed: {}", response.status()), - }); - } - } - Ok(()) - } - - async fn send_opencode_prompt( - &self, - session: &SessionSnapshot, - prompt: &str, - attachments: &[MessageAttachment], - ) -> Result<(), SandboxError> { - let base_url = self.ensure_opencode_server().await?; - let session_id = - session - .native_session_id - .as_ref() - .ok_or_else(|| SandboxError::InvalidRequest { - message: "missing OpenCode session id".to_string(), - })?; - let url = format!("{base_url}/session/{session_id}/prompt"); - let mut parts = vec![json!({ "type": "text", "text": prompt })]; - for attachment in attachments { - parts.push(opencode_file_part_input(attachment)); - } - let mut body = json!({ - "agent": session.agent_mode.clone(), - "parts": parts - }); - if let Some(model) = session.model.as_deref() { - if let Some((provider, model_id)) = model.split_once('/') { - body["model"] = json!({ - "providerID": provider, - "modelID": model_id - }); - } else { - body["model"] = json!({ "modelID": model }); - } - } - if let Some(variant) = session.variant.as_deref() { - body["variant"] = json!(variant); - } - - let response = self - .http_client - .post(url) - .json(&body) - .send() - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(SandboxError::StreamError { - message: format!("OpenCode prompt failed {status}: {body}"), - }); - } - - Ok(()) - } - - async fn opencode_question_reply( - &self, - _session_id: &str, - request_id: &str, - answers: Vec<Vec<String>>, - ) -> Result<(), SandboxError> { - let base_url = self.ensure_opencode_server().await?; - let url = format!("{base_url}/question/reply"); - let response = self - .http_client - .post(url) - .json(&json!({ - "requestID": request_id, - "answers": answers - })) - .send() - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(SandboxError::StreamError { - message: format!("OpenCode question reply failed {status}: {body}"), - }); - } - Ok(()) - } - - async fn opencode_question_reject( - &self, - _session_id: &str, - request_id: &str, - ) -> Result<(), SandboxError> { - let base_url = self.ensure_opencode_server().await?; - let url = format!("{base_url}/question/reject"); - let response = self - .http_client - .post(url) - .json(&json!({ "requestID": request_id })) - .send() - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(SandboxError::StreamError { - message: format!("OpenCode question reject failed {status}: {body}"), - }); - } - Ok(()) - } - - async fn opencode_permission_reply( - &self, - _session_id: &str, - request_id: &str, - reply: PermissionReply, - ) -> Result<(), SandboxError> { - let base_url = self.ensure_opencode_server().await?; - let url = format!("{base_url}/permission/reply"); - let response = self - .http_client - .post(url) - .json(&json!({ - "requestID": request_id, - "reply": reply - })) - .send() - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(SandboxError::StreamError { - message: format!("OpenCode permission reply failed {status}: {body}"), - }); - } - Ok(()) + .into_response() } } -async fn require_token( - State(state): State<Arc<AppState>>, - req: Request<axum::body::Body>, - next: Next, -) -> Result<Response, ApiError> { - let path = req.uri().path(); - if path == "/v1/health" || path == "/health" { - return Ok(next.run(req).await); - } - - let expected = match &state.auth.token { - Some(token) => token.as_str(), - None => return Ok(next.run(req).await), - }; - - let provided = extract_token(req.headers()); - if provided.as_deref() == Some(expected) { - Ok(next.run(req).await) - } else { - Err(SandboxError::TokenInvalid { - message: Some("missing or invalid token".to_string()), - } - .into()) - } -} - -fn extract_token(headers: &HeaderMap) -> Option<String> { - if let Some(value) = headers.get(axum::http::header::AUTHORIZATION) { - if let Ok(value) = value.to_str() { - let value = value.trim(); - if let Some((scheme, rest)) = value.split_once(' ') { - let scheme_lower = scheme.to_ascii_lowercase(); - let rest = rest.trim(); - match scheme_lower.as_str() { - "bearer" | "token" => { - return Some(rest.to_string()); - } - "basic" => { - let engines = [ - base64::engine::general_purpose::STANDARD, - base64::engine::general_purpose::STANDARD_NO_PAD, - base64::engine::general_purpose::URL_SAFE, - base64::engine::general_purpose::URL_SAFE_NO_PAD, - ]; - for engine in engines { - if let Ok(decoded) = engine.decode(rest) { - if let Ok(decoded_str) = String::from_utf8(decoded) { - if let Some((_, password)) = decoded_str.split_once(':') { - return Some(password.to_string()); - } - if !decoded_str.is_empty() { - return Some(decoded_str); - } - } - } - } - } - _ => {} - } - } - } - } - - None -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct AgentInstallRequest { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reinstall: Option<bool>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct AgentModeInfo { - pub id: String, - pub name: String, - pub description: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct AgentModelInfo { - pub id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub variants: Option<Vec<String>>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default_variant: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct AgentModelsResponse { - pub models: Vec<AgentModelInfo>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default_model: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct AgentModesResponse { - pub modes: Vec<AgentModeInfo>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct AgentCapabilities { - // TODO: add agent-agnostic tests that cover every capability flag here. - pub plan_mode: bool, - pub permissions: bool, - pub questions: bool, - pub tool_calls: bool, - pub tool_results: bool, - pub text_messages: bool, - pub images: bool, - pub file_attachments: bool, - pub session_lifecycle: bool, - pub error_events: bool, - pub reasoning: bool, - pub status: bool, - pub command_execution: bool, - pub file_changes: bool, - pub mcp_tools: bool, - pub streaming_deltas: bool, - pub item_started: bool, - /// Whether this agent supports thinking/variant modes - #[serde(default)] - pub variants: bool, - /// Whether this agent uses a shared long-running server process (vs per-turn subprocess) - pub shared_process: bool, -} - -/// Status of a shared server process for an agent -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum ServerStatus { - /// Server is running and accepting requests - Running, - /// Server is not currently running - Stopped, - /// Server is running but unhealthy - Error, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ServerStatusInfo { - pub status: ServerStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub base_url: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub uptime_ms: Option<u64>, - pub restart_count: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_error: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct AgentInfo { - pub id: String, - pub installed: bool, - /// Whether the agent's required provider credentials are available - pub credentials_available: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub version: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option<String>, - pub capabilities: AgentCapabilities, - /// Status of the shared server process (only present for agents with shared_process=true) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub server_status: Option<ServerStatusInfo>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct AgentListResponse { - pub agents: Vec<AgentInfo>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct SessionInfo { - pub session_id: String, - pub agent: String, - pub agent_mode: String, - pub permission_mode: String, - pub model: Option<String>, - pub variant: Option<String>, - pub native_session_id: Option<String>, - pub ended: bool, - pub event_count: u64, - pub created_at: i64, - pub updated_at: i64, - pub directory: Option<String>, - pub title: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mcp: Option<BTreeMap<String, McpServerConfig>>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub skills: Option<SkillsConfig>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -pub struct SessionListResponse { - pub sessions: Vec<SessionInfo>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct HealthResponse { - pub status: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct FsPathQuery { - pub path: String, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")] - pub session_id: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct FsEntriesQuery { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")] - pub session_id: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct FsSessionQuery { - #[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")] - pub session_id: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct FsDeleteQuery { - pub path: String, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")] - pub session_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub recursive: Option<bool>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct FsUploadBatchQuery { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")] - pub session_id: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum FsEntryType { - File, - Directory, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct FsEntry { - pub name: String, - pub path: String, - pub entry_type: FsEntryType, - pub size: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub modified: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct FsStat { - pub path: String, - pub entry_type: FsEntryType, - pub size: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub modified: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct FsWriteResponse { - pub path: String, - pub bytes_written: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct FsMoveRequest { - pub from: String, - pub to: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub overwrite: Option<bool>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct FsMoveResponse { - pub from: String, - pub to: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct FsActionResponse { - pub path: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct FsUploadBatchResponse { - pub paths: Vec<String>, - pub truncated: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct SkillsConfig { - pub sources: Vec<SkillSource>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct SkillSource { - #[serde(rename = "type")] - pub source_type: String, - pub source: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub skills: Option<Vec<String>>, - #[serde(default, skip_serializing_if = "Option::is_none", rename = "ref")] - pub git_ref: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subpath: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(untagged)] -pub enum McpCommand { - Command(String), - CommandWithArgs(Vec<String>), -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum McpRemoteTransport { - Http, - Sse, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct McpOAuthConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_secret: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub scope: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(untagged)] -pub enum McpOAuthConfigOrDisabled { - Config(McpOAuthConfig), - Disabled(bool), -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase", tag = "type")] -pub enum McpServerConfig { - #[serde(rename = "local", alias = "stdio")] - Local { - command: McpCommand, - #[serde(default)] - args: Vec<String>, - #[serde( - default, - skip_serializing_if = "Option::is_none", - alias = "environment" - )] - env: Option<BTreeMap<String, String>>, - #[serde(default, skip_serializing_if = "Option::is_none")] - enabled: Option<bool>, - #[serde( - default, - skip_serializing_if = "Option::is_none", - rename = "timeoutMs", - alias = "timeout" - )] - #[schema(rename = "timeoutMs")] - timeout_ms: Option<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - cwd: Option<String>, - }, - #[serde(rename = "remote", alias = "http")] - Remote { - url: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - headers: Option<BTreeMap<String, String>>, - #[serde( - default, - skip_serializing_if = "Option::is_none", - rename = "bearerTokenEnvVar", - alias = "bearerTokenEnvVar", - alias = "bearer_token_env_var" - )] - #[schema(rename = "bearerTokenEnvVar")] - bearer_token_env_var: Option<String>, - #[serde( - default, - skip_serializing_if = "Option::is_none", - rename = "envHeaders", - alias = "envHttpHeaders", - alias = "env_http_headers" - )] - #[schema(rename = "envHeaders")] - env_headers: Option<BTreeMap<String, String>>, - #[serde(default, skip_serializing_if = "Option::is_none")] - oauth: Option<McpOAuthConfigOrDisabled>, - #[serde(default, skip_serializing_if = "Option::is_none")] - enabled: Option<bool>, - #[serde( - default, - skip_serializing_if = "Option::is_none", - rename = "timeoutMs", - alias = "timeout" - )] - #[schema(rename = "timeoutMs")] - timeout_ms: Option<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - transport: Option<McpRemoteTransport>, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateSessionRequest { - pub agent: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_mode: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub permission_mode: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub variant: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_version: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub directory: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option<String>, - pub mcp: Option<BTreeMap<String, McpServerConfig>>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub skills: Option<SkillsConfig>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateSessionResponse { - pub healthy: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option<AgentError>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub native_session_id: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct MessageRequest { - pub message: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub attachments: Vec<MessageAttachment>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct MessageAttachment { - pub path: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mime: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub filename: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct EventsQuery { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub offset: Option<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub limit: Option<u64>, - #[serde( - default, - skip_serializing_if = "Option::is_none", - alias = "include_raw" - )] - pub include_raw: Option<bool>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct TurnStreamQuery { - #[serde( - default, - skip_serializing_if = "Option::is_none", - alias = "include_raw" - )] - pub include_raw: Option<bool>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct EventsResponse { - pub events: Vec<UniversalEvent>, - pub has_more: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct QuestionReplyRequest { - pub answers: Vec<Vec<String>>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct PermissionReplyRequest { - pub reply: PermissionReply, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum PermissionReply { - Once, - Always, - Reject, -} - -impl std::str::FromStr for PermissionReply { - type Err = String; - - fn from_str(value: &str) -> Result<Self, Self::Err> { - match value.to_ascii_lowercase().as_str() { - "once" => Ok(Self::Once), - "always" => Ok(Self::Always), - "reject" => Ok(Self::Reject), - _ => Err(format!("invalid permission reply: {value}")), - } - } -} - -#[utoipa::path( - post, - path = "/v1/agents/{agent}/install", - request_body = AgentInstallRequest, - responses( - (status = 204, description = "Agent installed"), - (status = 400, description = "Invalid request", body = ProblemDetails), - (status = 404, description = "Agent not found", body = ProblemDetails), - (status = 500, description = "Installation failed", body = ProblemDetails) - ), - params(("agent" = String, Path, description = "Agent id")), - tag = "agents" -)] -/// Install Agent -/// -/// Installs or updates a coding agent (e.g. claude, codex, opencode, amp). -async fn install_agent( - State(state): State<Arc<AppState>>, - Path(agent): Path<String>, - Json(request): Json<AgentInstallRequest>, -) -> Result<StatusCode, ApiError> { - let agent_id = parse_agent_id(&agent)?; - let reinstall = request.reinstall.unwrap_or(false); - let manager = state.agent_manager.clone(); - - let result = tokio::task::spawn_blocking(move || { - manager.install( - agent_id, - InstallOptions { - reinstall, - version: None, - }, - ) - }) - .await - .map_err(|err| SandboxError::InstallFailed { - agent: agent.clone(), - stderr: Some(err.to_string()), - })?; - - result.map_err(|err| map_install_error(agent_id, err))?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - get, - path = "/v1/agents/{agent}/modes", - responses( - (status = 200, description = "Available modes", body = AgentModesResponse), - (status = 400, description = "Invalid request", body = ProblemDetails) - ), - params(("agent" = String, Path, description = "Agent id")), - tag = "agents" -)] -/// List Agent Modes -/// -/// Returns the available interaction modes for an agent. -async fn get_agent_modes( - State(state): State<Arc<AppState>>, - Path(agent): Path<String>, -) -> Result<Json<AgentModesResponse>, ApiError> { - let agent_id = parse_agent_id(&agent)?; - let modes = state.session_manager.agent_modes(agent_id).await?; - Ok(Json(AgentModesResponse { modes })) -} - -#[utoipa::path( - get, - path = "/v1/agents/{agent}/models", - responses( - (status = 200, description = "Available models", body = AgentModelsResponse), - (status = 404, description = "Agent not found", body = ProblemDetails) - ), - params(("agent" = String, Path, description = "Agent id")), - tag = "agents" -)] -/// List Agent Models -/// -/// Returns the available LLM models for an agent. -async fn get_agent_models( - State(state): State<Arc<AppState>>, - Path(agent): Path<String>, -) -> Result<Json<AgentModelsResponse>, ApiError> { - let agent_id = parse_agent_id(&agent)?; - let models = state.session_manager.agent_models(agent_id).await?; - Ok(Json(models)) -} - -const SERVER_INFO: &str = "\ -This is a Sandbox Agent server. Available endpoints:\n\ - - GET / - Server info\n\ - - GET /v1/health - Health check\n\ - - GET /ui/ - Inspector UI\n\n\ -See https://sandboxagent.dev for API documentation."; - -async fn get_root() -> &'static str { - SERVER_INFO -} - -async fn not_found() -> (StatusCode, String) { - ( - StatusCode::NOT_FOUND, - format!("404 Not Found\n\n{SERVER_INFO}"), - ) +async fn get_root() -> Json<Value> { + Json(json!({ + "name": "Sandbox Agent", + "docs": "https://sandboxagent.dev" + })) } #[utoipa::path( get, path = "/v1/health", - responses((status = 200, description = "Server is healthy", body = HealthResponse)), - tag = "meta" + tag = "v1", + responses( + (status = 200, description = "Service health response", body = HealthResponse) + ) )] -/// Health Check -/// -/// Returns the server health status. -async fn get_health() -> Json<HealthResponse> { +async fn get_v1_health() -> Json<HealthResponse> { Json(HealthResponse { status: "ok".to_string(), }) @@ -6178,394 +403,385 @@ async fn get_health() -> Json<HealthResponse> { #[utoipa::path( get, path = "/v1/agents", - responses((status = 200, description = "List of available agents", body = AgentListResponse)), - tag = "agents" + tag = "v1", + params( + ("config" = Option<bool>, Query, description = "When true, include version/path/configOptions (slower)"), + ("no_cache" = Option<bool>, Query, description = "When true, bypass version cache") + ), + responses( + (status = 200, description = "List of v1 agents", body = AgentListResponse), + (status = 401, description = "Authentication required", body = ProblemDetails) + ) )] -/// List Agents -/// -/// Returns all available coding agents and their installation status. -async fn list_agents( +async fn get_v1_agents( State(state): State<Arc<AppState>>, + Query(query): Query<AgentsQuery>, ) -> Result<Json<AgentListResponse>, ApiError> { - let manager = state.agent_manager.clone(); - let server_statuses = state.session_manager.server_manager.status_snapshot().await; + let credentials = tokio::task::spawn_blocking(move || { + extract_all_credentials(&CredentialExtractionOptions::new()) + }) + .await + .map_err(|err| SandboxError::StreamError { + message: format!("failed to resolve credentials: {err}"), + })?; - let agents = - tokio::task::spawn_blocking(move || { - let credentials = extract_all_credentials(&CredentialExtractionOptions::new()); - let has_anthropic = credentials.anthropic.is_some(); - let has_openai = credentials.openai.is_some(); + let has_anthropic = credentials.anthropic.is_some(); + let has_openai = credentials.openai.is_some(); - all_agents() - .into_iter() - .map(|agent_id| { - let installed = manager.is_installed(agent_id); - let version = manager.version(agent_id).ok().flatten(); - let path = manager.resolve_binary(agent_id).ok(); - let capabilities = agent_capabilities_for(agent_id); + let instances = state.acp_proxy().list_instances().await; + let mut active_by_agent = HashMap::<AgentId, Vec<i64>>::new(); + for instance in instances { + active_by_agent + .entry(instance.agent) + .or_default() + .push(instance.created_at_ms); + } - let credentials_available = match agent_id { - AgentId::Claude | AgentId::Amp => has_anthropic, - AgentId::Codex => has_openai, - AgentId::Opencode => has_anthropic || has_openai, - AgentId::Pi => true, - AgentId::Cursor => true, - AgentId::Mock => true, - }; + let load_config = query.config.unwrap_or(false); + let no_cache = query.no_cache.unwrap_or(false); - // Add server_status for agents with shared processes - let server_status = - if capabilities.shared_process { - Some(server_statuses.get(&agent_id).cloned().unwrap_or( - ServerStatusInfo { - status: ServerStatus::Stopped, - base_url: None, - uptime_ms: None, - restart_count: 0, - last_error: None, - }, - )) - } else { - None - }; + let mut agents = Vec::new(); + for agent_id in AgentId::all().iter().copied() { + let capabilities = agent_capabilities_for(agent_id); + let installed = state.agent_manager().is_installed(agent_id); + let credentials_available = credentials_available_for(agent_id, has_anthropic, has_openai); - AgentInfo { - id: agent_id.as_str().to_string(), - installed, - credentials_available, - version, - path: path.map(|path| path.to_string_lossy().to_string()), - capabilities, - server_status, + let server_status = active_by_agent.get(&agent_id).map(|created_times| { + let uptime_ms = created_times + .iter() + .min() + .map(|created| now_ms().saturating_sub(*created) as u64); + ServerStatusInfo { + status: if created_times.is_empty() { + ServerStatus::Stopped + } else { + ServerStatus::Running + }, + uptime_ms, + } + }); + + agents.push(AgentInfo { + id: agent_id.as_str().to_string(), + installed, + credentials_available, + version: None, + path: None, + capabilities, + server_status, + config_options: None, + config_error: None, + }); + } + + if load_config { + // Resolve versions/paths (slow — subprocess calls) with caching. + // Collect agents that need a fresh lookup. + let need_lookup: Vec<(usize, AgentId)> = agents + .iter() + .enumerate() + .filter_map(|(idx, agent)| { + let agent_id = AgentId::parse(&agent.id)?; + if !no_cache { + if state.version_cache.lock().unwrap().contains_key(&agent_id) { + return None; } - }) - .collect::<Vec<_>>() - }) - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; + } + Some((idx, agent_id)) + }) + .collect(); + + if !need_lookup.is_empty() { + let mgr = state.agent_manager(); + let ids: Vec<AgentId> = need_lookup.iter().map(|(_, id)| *id).collect(); + let results = tokio::task::spawn_blocking(move || { + ids.iter() + .map(|agent_id| { + let version = mgr.version(*agent_id).ok().flatten(); + let path = mgr + .resolve_binary(*agent_id) + .ok() + .map(|p| p.to_string_lossy().to_string()); + (*agent_id, CachedAgentVersion { version, path }) + }) + .collect::<Vec<_>>() + }) + .await + .unwrap_or_default(); + + let mut cache = state.version_cache.lock().unwrap(); + for (agent_id, entry) in results { + cache.insert(agent_id, entry); + } + } + + // Apply cached version/path + hardcoded config options + let cache = state.version_cache.lock().unwrap(); + for agent in &mut agents { + let Some(agent_id) = AgentId::parse(&agent.id) else { + continue; + }; + if let Some(cached) = cache.get(&agent_id) { + agent.version = cached.version.clone(); + agent.path = cached.path.clone(); + } + let fallback = fallback_config_options(agent_id); + if !fallback.is_empty() { + agent.config_options = Some(fallback); + } + } + } Ok(Json(AgentListResponse { agents })) } #[utoipa::path( get, - path = "/v1/sessions", - responses((status = 200, description = "List of active sessions", body = SessionListResponse)), - tag = "sessions" + path = "/v1/agents/{agent}", + tag = "v1", + params( + ("agent" = String, Path, description = "Agent id"), + ("config" = Option<bool>, Query, description = "When true, include version/path/configOptions (slower)"), + ("no_cache" = Option<bool>, Query, description = "When true, bypass version cache") + ), + responses( + (status = 200, description = "Agent info", body = AgentInfo), + (status = 400, description = "Unknown agent", body = ProblemDetails), + (status = 401, description = "Authentication required", body = ProblemDetails) + ) )] -/// List Sessions -/// -/// Returns all active sessions. -async fn list_sessions( +async fn get_v1_agent( State(state): State<Arc<AppState>>, -) -> Result<Json<SessionListResponse>, ApiError> { - let sessions = state.session_manager.list_sessions().await; - Ok(Json(SessionListResponse { sessions })) + Path(agent): Path<String>, + Query(query): Query<AgentsQuery>, +) -> Result<Json<AgentInfo>, ApiError> { + let agent_id = AgentId::parse(&agent).ok_or_else(|| SandboxError::UnsupportedAgent { + agent: agent.clone(), + })?; + + let credentials = tokio::task::spawn_blocking(move || { + extract_all_credentials(&CredentialExtractionOptions::new()) + }) + .await + .map_err(|err| SandboxError::StreamError { + message: format!("failed to resolve credentials: {err}"), + })?; + + let has_anthropic = credentials.anthropic.is_some(); + let has_openai = credentials.openai.is_some(); + + let instances = state.acp_proxy().list_instances().await; + let created_times: Vec<i64> = instances + .iter() + .filter(|i| i.agent == agent_id) + .map(|i| i.created_at_ms) + .collect(); + + let capabilities = agent_capabilities_for(agent_id); + let installed = state.agent_manager().is_installed(agent_id); + let credentials_available = credentials_available_for(agent_id, has_anthropic, has_openai); + + let server_status = if created_times.is_empty() { + None + } else { + let uptime_ms = created_times + .iter() + .min() + .map(|created| now_ms().saturating_sub(*created) as u64); + Some(ServerStatusInfo { + status: ServerStatus::Running, + uptime_ms, + }) + }; + + let mut info = AgentInfo { + id: agent_id.as_str().to_string(), + installed, + credentials_available, + version: None, + path: None, + capabilities, + server_status, + config_options: None, + config_error: None, + }; + + if query.config.unwrap_or(false) { + let no_cache = query.no_cache.unwrap_or(false); + + // Version/path (cached, slow — subprocess calls) + let cached = if !no_cache { + state.version_cache.lock().unwrap().get(&agent_id).cloned() + } else { + None + }; + if let Some(cached) = cached { + info.version = cached.version; + info.path = cached.path; + } else { + let mgr = state.agent_manager(); + let aid = agent_id; + let result = tokio::task::spawn_blocking(move || { + let version = mgr.version(aid).ok().flatten(); + let path = mgr + .resolve_binary(aid) + .ok() + .map(|p| p.to_string_lossy().to_string()); + CachedAgentVersion { version, path } + }) + .await + .unwrap_or(CachedAgentVersion { + version: None, + path: None, + }); + info.version = result.version.clone(); + info.path = result.path.clone(); + state.version_cache.lock().unwrap().insert(agent_id, result); + } + + // Hardcoded config options + let fallback = fallback_config_options(agent_id); + if !fallback.is_empty() { + info.config_options = Some(fallback); + } + } + + Ok(Json(info)) } +// TODO: Re-enable ACP config probing once agent processes reliably return +// configOptions from session/new. Currently all agents return empty configOptions, +// so we use hardcoded fallbacks in fallback_config_options() instead. +// +// const CONFIG_PROBE_TIMEOUT: Duration = Duration::from_secs(15); +// +// async fn probe_agent_config( +// proxy: &Arc<AcpProxyRuntime>, +// agent_id: &str, +// ) -> Result<Vec<Value>, String> { +// let probe_id = PROBE_COUNTER.fetch_add(1, Ordering::Relaxed); +// let server_id = format!("_config_probe_{}_{}", agent_id, probe_id); +// +// let agent = AgentId::parse(agent_id).ok_or_else(|| format!("unknown agent: {agent_id}"))?; +// +// let result = tokio::time::timeout(CONFIG_PROBE_TIMEOUT, async { +// let init_payload = json!({ +// "jsonrpc": "2.0", +// "id": 1, +// "method": "initialize", +// "params": { +// "protocolVersion": 1, +// "clientCapabilities": {}, +// "clientInfo": { "name": "sandbox-agent", "version": "1.0.0" } +// } +// }); +// proxy +// .post(&server_id, Some(agent), init_payload) +// .await +// .map_err(|e| format!("initialize failed: {e}"))?; +// +// let session_payload = json!({ +// "jsonrpc": "2.0", +// "id": 2, +// "method": "session/new", +// "params": { +// "cwd": "/", +// "_meta": { "sandboxagent.dev": { "agent": agent_id } } +// } +// }); +// let outcome = proxy +// .post(&server_id, None, session_payload) +// .await +// .map_err(|e| format!("session/new failed: {e}"))?; +// +// let config_options = match outcome { +// ProxyPostOutcome::Response(value) => value +// .pointer("/result/configOptions") +// .cloned() +// .and_then(|v| serde_json::from_value::<Vec<Value>>(v).ok()) +// .unwrap_or_default(), +// ProxyPostOutcome::Accepted => Vec::new(), +// }; +// +// Ok::<Vec<Value>, String>(config_options) +// }) +// .await; +// +// let _ = tokio::time::timeout(Duration::from_secs(5), proxy.delete(&server_id)).await; +// +// match result { +// Ok(inner) => inner, +// Err(_) => Err("config probe timed out".to_string()), +// } +// } + #[utoipa::path( post, - path = "/v1/sessions/{session_id}", - request_body = CreateSessionRequest, + path = "/v1/agents/{agent}/install", + tag = "v1", + params( + ("agent" = String, Path, description = "Agent id") + ), + request_body = AgentInstallRequest, responses( - (status = 200, description = "Session created", body = CreateSessionResponse), + (status = 200, description = "Agent install result", body = AgentInstallResponse), (status = 400, description = "Invalid request", body = ProblemDetails), - (status = 409, description = "Session already exists", body = ProblemDetails) - ), - params(("session_id" = String, Path, description = "Client session id")), - tag = "sessions" + (status = 500, description = "Install failed", body = ProblemDetails) + ) )] -/// Create Session -/// -/// Creates a new agent session with the given configuration. -async fn create_session( +async fn post_v1_agent_install( State(state): State<Arc<AppState>>, - Path(session_id): Path<String>, - Json(request): Json<CreateSessionRequest>, -) -> Result<Json<CreateSessionResponse>, ApiError> { - let response = state - .session_manager - .create_session(session_id, request) - .await?; - Ok(Json(response)) -} + Path(agent): Path<String>, + Json(request): Json<AgentInstallRequest>, +) -> Result<Json<AgentInstallResponse>, ApiError> { + let agent_id = AgentId::parse(&agent).ok_or_else(|| SandboxError::UnsupportedAgent { + agent: agent.clone(), + })?; -#[utoipa::path( - post, - path = "/v1/sessions/{session_id}/messages", - request_body = MessageRequest, - responses( - (status = 204, description = "Message accepted"), - (status = 404, description = "Session not found", body = ProblemDetails) - ), - params(("session_id" = String, Path, description = "Session id")), - tag = "sessions" -)] -/// Send Message -/// -/// Sends a message to a session and returns immediately. -async fn post_message( - State(state): State<Arc<AppState>>, - Path(session_id): Path<String>, - Json(request): Json<MessageRequest>, -) -> Result<StatusCode, ApiError> { - state - .session_manager - .send_message(session_id, request.message, request.attachments) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - post, - path = "/v1/sessions/{session_id}/messages/stream", - request_body = MessageRequest, - params( - ("session_id" = String, Path, description = "Session id"), - ("include_raw" = Option<bool>, Query, description = "Include raw provider payloads") - ), - responses( - (status = 200, description = "SSE event stream"), - (status = 404, description = "Session not found", body = ProblemDetails) - ), - tag = "sessions" -)] -/// Send Message (Streaming) -/// -/// Sends a message and returns an SSE event stream of the agent's response. -async fn post_message_stream( - State(state): State<Arc<AppState>>, - Path(session_id): Path<String>, - Query(query): Query<TurnStreamQuery>, - Json(request): Json<MessageRequest>, -) -> Result<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>, ApiError> { - let include_raw = query.include_raw.unwrap_or(false); - let (snapshot, subscription) = state - .session_manager - .subscribe_for_turn(&session_id) - .await?; - state - .session_manager - .send_message(session_id, request.message, request.attachments) - .await?; - let stream = stream_turn_events(subscription, snapshot.agent, include_raw); - Ok(Sse::new(stream)) -} - -#[utoipa::path( - post, - path = "/v1/sessions/{session_id}/terminate", - params(("session_id" = String, Path, description = "Session id")), - responses( - (status = 204, description = "Session terminated"), - (status = 404, description = "Session not found", body = ProblemDetails) - ), - tag = "sessions" -)] -/// Terminate Session -/// -/// Terminates a running session and cleans up resources. -async fn terminate_session( - State(state): State<Arc<AppState>>, - Path(session_id): Path<String>, -) -> Result<StatusCode, ApiError> { - state.session_manager.terminate_session(session_id).await?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - get, - path = "/v1/sessions/{session_id}/events", - params( - ("session_id" = String, Path, description = "Session id"), - ("offset" = Option<u64>, Query, description = "Last seen event sequence (exclusive)"), - ("limit" = Option<u64>, Query, description = "Max events to return"), - ("include_raw" = Option<bool>, Query, description = "Include raw provider payloads") - ), - responses( - (status = 200, description = "Session events", body = EventsResponse), - (status = 404, description = "Session not found", body = ProblemDetails) - ), - tag = "sessions" -)] -/// Get Events -/// -/// Returns session events with optional offset-based pagination. -async fn get_events( - State(state): State<Arc<AppState>>, - Path(session_id): Path<String>, - Query(query): Query<EventsQuery>, -) -> Result<Json<EventsResponse>, ApiError> { - let offset = query.offset.unwrap_or(0); - let response = state - .session_manager - .events( - &session_id, - offset, - query.limit, - query.include_raw.unwrap_or(false), + let manager = state.agent_manager(); + let reinstall = request.reinstall.unwrap_or(false); + let install_result = tokio::task::spawn_blocking(move || { + manager.install( + agent_id, + InstallOptions { + reinstall, + version: request.agent_version, + agent_process_version: request.agent_process_version, + }, ) - .await?; - Ok(Json(response)) -} + }) + .await + .map_err(|err| SandboxError::InstallFailed { + agent, + stderr: Some(format!("installer task failed: {err}")), + })? + .map_err(|err| SandboxError::InstallFailed { + agent: agent_id.as_str().to_string(), + stderr: Some(err.to_string()), + })?; -#[utoipa::path( - get, - path = "/v1/sessions/{session_id}/events/sse", - params( - ("session_id" = String, Path, description = "Session id"), - ("offset" = Option<u64>, Query, description = "Last seen event sequence (exclusive)"), - ("include_raw" = Option<bool>, Query, description = "Include raw provider payloads") - ), - responses((status = 200, description = "SSE event stream")), - tag = "sessions" -)] -/// Subscribe to Events (SSE) -/// -/// Opens an SSE stream for real-time session events. -async fn get_events_sse( - State(state): State<Arc<AppState>>, - Path(session_id): Path<String>, - Query(query): Query<EventsQuery>, -) -> Result<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>, ApiError> { - let offset = query.offset.unwrap_or(0); - let include_raw = query.include_raw.unwrap_or(false); - let subscription = state.session_manager.subscribe(&session_id, offset).await?; - let initial_events = subscription.initial_events; - let receiver = subscription.receiver; + // Purge version cache so next ?config=true picks up the new version + state.purge_version_cache(agent_id); - let initial_stream = stream::iter(initial_events.into_iter().map(move |mut event| { - if !include_raw { - event.raw = None; - } - Ok::<Event, Infallible>(to_sse_event(event)) - })); - - let live_stream = BroadcastStream::new(receiver).filter_map(move |result| { - let include_raw = include_raw; - async move { - match result { - Ok(mut event) => { - if !include_raw { - event.raw = None; - } - Some(Ok::<Event, Infallible>(to_sse_event(event))) - } - Err(_) => None, - } - } - }); - - let stream = initial_stream.chain(live_stream); - Ok(Sse::new(stream)) -} - -#[utoipa::path( - post, - path = "/v1/sessions/{session_id}/questions/{question_id}/reply", - request_body = QuestionReplyRequest, - responses( - (status = 204, description = "Question answered"), - (status = 404, description = "Session or question not found", body = ProblemDetails) - ), - params( - ("session_id" = String, Path, description = "Session id"), - ("question_id" = String, Path, description = "Question id") - ), - tag = "sessions" -)] -/// Reply to Question -/// -/// Replies to a human-in-the-loop question from the agent. -async fn reply_question( - State(state): State<Arc<AppState>>, - Path((session_id, question_id)): Path<(String, String)>, - Json(request): Json<QuestionReplyRequest>, -) -> Result<StatusCode, ApiError> { - state - .session_manager - .reply_question(&session_id, &question_id, request.answers) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - post, - path = "/v1/sessions/{session_id}/questions/{question_id}/reject", - responses( - (status = 204, description = "Question rejected"), - (status = 404, description = "Session or question not found", body = ProblemDetails) - ), - params( - ("session_id" = String, Path, description = "Session id"), - ("question_id" = String, Path, description = "Question id") - ), - tag = "sessions" -)] -/// Reject Question -/// -/// Rejects a human-in-the-loop question from the agent. -async fn reject_question( - State(state): State<Arc<AppState>>, - Path((session_id, question_id)): Path<(String, String)>, -) -> Result<StatusCode, ApiError> { - state - .session_manager - .reject_question(&session_id, &question_id) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - post, - path = "/v1/sessions/{session_id}/permissions/{permission_id}/reply", - request_body = PermissionReplyRequest, - responses( - (status = 204, description = "Permission reply accepted"), - (status = 404, description = "Session or permission not found", body = ProblemDetails) - ), - params( - ("session_id" = String, Path, description = "Session id"), - ("permission_id" = String, Path, description = "Permission id") - ), - tag = "sessions" -)] -/// Reply to Permission -/// -/// Approves or denies a permission request from the agent. -async fn reply_permission( - State(state): State<Arc<AppState>>, - Path((session_id, permission_id)): Path<(String, String)>, - Json(request): Json<PermissionReplyRequest>, -) -> Result<StatusCode, ApiError> { - state - .session_manager - .reply_permission(&session_id, &permission_id, request.reply) - .await?; - Ok(StatusCode::NO_CONTENT) + Ok(Json(map_install_result(install_result))) } #[utoipa::path( get, path = "/v1/fs/entries", + tag = "v1", params( - ("path" = Option<String>, Query, description = "Path to list (relative or absolute)"), - ("session_id" = Option<String>, Query, description = "Session id for relative paths") + ("path" = Option<String>, Query, description = "Directory path") ), - responses((status = 200, description = "Directory listing", body = Vec<FsEntry>)), - tag = "fs" + responses( + (status = 200, description = "Directory entries", body = Vec<FsEntry>) + ) )] -/// List Directory -/// -/// Lists files and directories at the given path. -async fn fs_entries( - State(state): State<Arc<AppState>>, +async fn get_v1_fs_entries( Query(query): Query<FsEntriesQuery>, ) -> Result<Json<Vec<FsEntry>>, ApiError> { let path = query.path.unwrap_or_else(|| ".".to_string()); - let target = resolve_fs_path(&state, query.session_id.as_deref(), &path).await?; + let target = resolve_fs_path(&path)?; let metadata = fs::metadata(&target).map_err(|err| map_fs_error(&target, err))?; if !metadata.is_dir() { return Err(SandboxError::InvalidRequest { @@ -6573,6 +789,7 @@ async fn fs_entries( } .into()); } + let mut entries = Vec::new(); for entry in fs::read_dir(&target).map_err(|err| map_fs_error(&target, err))? { let entry = entry.map_err(|err| SandboxError::StreamError { @@ -6587,11 +804,10 @@ async fn fs_entries( } else { FsEntryType::File }; - let modified = metadata.modified().ok().and_then(|time| { - chrono::DateTime::<chrono::Utc>::from(time) - .to_rfc3339() - .into() - }); + let modified = metadata + .modified() + .ok() + .map(|time| chrono::DateTime::<chrono::Utc>::from(time).to_rfc3339()); entries.push(FsEntry { name: entry.file_name().to_string_lossy().to_string(), path: path.to_string_lossy().to_string(), @@ -6606,21 +822,16 @@ async fn fs_entries( #[utoipa::path( get, path = "/v1/fs/file", + tag = "v1", params( - ("path" = String, Query, description = "File path (relative or absolute)"), - ("session_id" = Option<String>, Query, description = "Session id for relative paths") + ("path" = String, Query, description = "File path") ), - responses((status = 200, description = "File content", body = Vec<u8>)), - tag = "fs" + responses( + (status = 200, description = "File content") + ) )] -/// Read File -/// -/// Reads the raw bytes of a file. -async fn fs_read_file( - State(state): State<Arc<AppState>>, - Query(query): Query<FsPathQuery>, -) -> Result<Response, ApiError> { - let target = resolve_fs_path(&state, query.session_id.as_deref(), &query.path).await?; +async fn get_v1_fs_file(Query(query): Query<FsPathQuery>) -> Result<Response, ApiError> { + let target = resolve_fs_path(&query.path)?; let metadata = fs::metadata(&target).map_err(|err| map_fs_error(&target, err))?; if !metadata.is_file() { return Err(SandboxError::InvalidRequest { @@ -6639,23 +850,20 @@ async fn fs_read_file( #[utoipa::path( put, path = "/v1/fs/file", - request_body = Vec<u8>, + tag = "v1", params( - ("path" = String, Query, description = "File path (relative or absolute)"), - ("session_id" = Option<String>, Query, description = "Session id for relative paths") + ("path" = String, Query, description = "File path") ), - responses((status = 200, description = "Write result", body = FsWriteResponse)), - tag = "fs" + request_body(content = String, description = "Raw file bytes"), + responses( + (status = 200, description = "Write result", body = FsWriteResponse) + ) )] -/// Write File -/// -/// Writes raw bytes to a file, creating it if it doesn't exist. -async fn fs_write_file( - State(state): State<Arc<AppState>>, +async fn put_v1_fs_file( Query(query): Query<FsPathQuery>, body: Bytes, ) -> Result<Json<FsWriteResponse>, ApiError> { - let target = resolve_fs_path(&state, query.session_id.as_deref(), &query.path).await?; + let target = resolve_fs_path(&query.path)?; if let Some(parent) = target.parent() { fs::create_dir_all(parent).map_err(|err| map_fs_error(parent, err))?; } @@ -6669,22 +877,19 @@ async fn fs_write_file( #[utoipa::path( delete, path = "/v1/fs/entry", + tag = "v1", params( ("path" = String, Query, description = "File or directory path"), - ("session_id" = Option<String>, Query, description = "Session id for relative paths"), - ("recursive" = Option<bool>, Query, description = "Delete directories recursively") + ("recursive" = Option<bool>, Query, description = "Delete directory recursively") ), - responses((status = 200, description = "Delete result", body = FsActionResponse)), - tag = "fs" + responses( + (status = 200, description = "Delete result", body = FsActionResponse) + ) )] -/// Delete Entry -/// -/// Deletes a file or directory. -async fn fs_delete_entry( - State(state): State<Arc<AppState>>, +async fn delete_v1_fs_entry( Query(query): Query<FsDeleteQuery>, ) -> Result<Json<FsActionResponse>, ApiError> { - let target = resolve_fs_path(&state, query.session_id.as_deref(), &query.path).await?; + let target = resolve_fs_path(&query.path)?; let metadata = fs::metadata(&target).map_err(|err| map_fs_error(&target, err))?; if metadata.is_dir() { if query.recursive.unwrap_or(false) { @@ -6703,21 +908,18 @@ async fn fs_delete_entry( #[utoipa::path( post, path = "/v1/fs/mkdir", + tag = "v1", params( - ("path" = String, Query, description = "Directory path to create"), - ("session_id" = Option<String>, Query, description = "Session id for relative paths") + ("path" = String, Query, description = "Directory path") ), - responses((status = 200, description = "Directory created", body = FsActionResponse)), - tag = "fs" + responses( + (status = 200, description = "Directory created", body = FsActionResponse) + ) )] -/// Create Directory -/// -/// Creates a directory, including any missing parent directories. -async fn fs_mkdir( - State(state): State<Arc<AppState>>, +async fn post_v1_fs_mkdir( Query(query): Query<FsPathQuery>, ) -> Result<Json<FsActionResponse>, ApiError> { - let target = resolve_fs_path(&state, query.session_id.as_deref(), &query.path).await?; + let target = resolve_fs_path(&query.path)?; fs::create_dir_all(&target).map_err(|err| map_fs_error(&target, err))?; Ok(Json(FsActionResponse { path: target.to_string_lossy().to_string(), @@ -6727,22 +929,18 @@ async fn fs_mkdir( #[utoipa::path( post, path = "/v1/fs/move", + tag = "v1", request_body = FsMoveRequest, - params(("session_id" = Option<String>, Query, description = "Session id for relative paths")), - responses((status = 200, description = "Move result", body = FsMoveResponse)), - tag = "fs" + responses( + (status = 200, description = "Move result", body = FsMoveResponse) + ) )] -/// Move Entry -/// -/// Moves or renames a file or directory. -async fn fs_move( - State(state): State<Arc<AppState>>, - Query(query): Query<FsSessionQuery>, +async fn post_v1_fs_move( Json(request): Json<FsMoveRequest>, ) -> Result<Json<FsMoveResponse>, ApiError> { - let session_id = query.session_id.as_deref(); - let from = resolve_fs_path(&state, session_id, &request.from).await?; - let to = resolve_fs_path(&state, session_id, &request.to).await?; + let from = resolve_fs_path(&request.from)?; + let to = resolve_fs_path(&request.to)?; + if to.exists() { if request.overwrite.unwrap_or(false) { let metadata = fs::metadata(&to).map_err(|err| map_fs_error(&to, err))?; @@ -6758,6 +956,7 @@ async fn fs_move( .into()); } } + if let Some(parent) = to.parent() { fs::create_dir_all(parent).map_err(|err| map_fs_error(parent, err))?; } @@ -6771,32 +970,26 @@ async fn fs_move( #[utoipa::path( get, path = "/v1/fs/stat", + tag = "v1", params( - ("path" = String, Query, description = "Path to stat"), - ("session_id" = Option<String>, Query, description = "Session id for relative paths") + ("path" = String, Query, description = "Path to stat") ), - responses((status = 200, description = "File metadata", body = FsStat)), - tag = "fs" + responses( + (status = 200, description = "Path metadata", body = FsStat) + ) )] -/// Get File Info -/// -/// Returns metadata (size, timestamps, type) for a path. -async fn fs_stat( - State(state): State<Arc<AppState>>, - Query(query): Query<FsPathQuery>, -) -> Result<Json<FsStat>, ApiError> { - let target = resolve_fs_path(&state, query.session_id.as_deref(), &query.path).await?; +async fn get_v1_fs_stat(Query(query): Query<FsPathQuery>) -> Result<Json<FsStat>, ApiError> { + let target = resolve_fs_path(&query.path)?; let metadata = fs::metadata(&target).map_err(|err| map_fs_error(&target, err))?; let entry_type = if metadata.is_dir() { FsEntryType::Directory } else { FsEntryType::File }; - let modified = metadata.modified().ok().and_then(|time| { - chrono::DateTime::<chrono::Utc>::from(time) - .to_rfc3339() - .into() - }); + let modified = metadata + .modified() + .ok() + .map(|time| chrono::DateTime::<chrono::Utc>::from(time).to_rfc3339()); Ok(Json(FsStat { path: target.to_string_lossy().to_string(), entry_type, @@ -6808,19 +1001,16 @@ async fn fs_stat( #[utoipa::path( post, path = "/v1/fs/upload-batch", - request_body = Vec<u8>, + tag = "v1", params( - ("path" = Option<String>, Query, description = "Destination directory for extraction"), - ("session_id" = Option<String>, Query, description = "Session id for relative paths") + ("path" = Option<String>, Query, description = "Destination path") ), - responses((status = 200, description = "Upload result", body = FsUploadBatchResponse)), - tag = "fs" + request_body(content = String, description = "tar archive body"), + responses( + (status = 200, description = "Upload/extract result", body = FsUploadBatchResponse) + ) )] -/// Upload Files -/// -/// Uploads a tar.gz archive and extracts it to the destination directory. -async fn fs_upload_batch( - State(state): State<Arc<AppState>>, +async fn post_v1_fs_upload_batch( headers: HeaderMap, Query(query): Query<FsUploadBatchQuery>, body: Bytes, @@ -6835,13 +1025,15 @@ async fn fs_upload_batch( } .into()); } + let path = query.path.unwrap_or_else(|| ".".to_string()); - let base = resolve_fs_path(&state, query.session_id.as_deref(), &path).await?; + let base = resolve_fs_path(&path)?; fs::create_dir_all(&base).map_err(|err| map_fs_error(&base, err))?; let mut archive = Archive::new(Cursor::new(body)); let mut extracted = Vec::new(); let mut truncated = false; + for entry in archive.entries().map_err(|err| SandboxError::StreamError { message: err.to_string(), })? { @@ -6883,5080 +1075,393 @@ async fn fs_upload_batch( })) } -fn all_agents() -> [AgentId; 7] { - [ - AgentId::Claude, - AgentId::Codex, - AgentId::Opencode, - AgentId::Amp, - AgentId::Pi, - AgentId::Cursor, - AgentId::Mock, - ] -} - -/// Returns true if the agent supports resuming a session after its process exits. -/// These agents can use --resume/--continue to continue a conversation. -fn agent_supports_resume(agent: AgentId) -> bool { - matches!( - agent, - AgentId::Claude | AgentId::Amp | AgentId::Opencode | AgentId::Codex | AgentId::Pi +#[utoipa::path( + get, + path = "/v1/config/mcp", + tag = "v1", + params( + ("directory" = String, Query, description = "Target directory"), + ("mcpName" = String, Query, description = "MCP entry name") + ), + responses( + (status = 200, description = "MCP entry", body = McpServerConfig), + (status = 404, description = "Entry not found", body = ProblemDetails) ) +)] +async fn get_v1_config_mcp( + Query(query): Query<McpConfigQuery>, +) -> Result<Json<McpServerConfig>, ApiError> { + validate_named_query(&query.directory, "directory")?; + validate_named_query(&query.mcp_name, "mcpName")?; + + let path = config_file_path(&query.directory, "mcp.json")?; + let entries: BTreeMap<String, McpServerConfig> = read_named_config_map(&path)?; + let value = + entries + .get(&query.mcp_name) + .cloned() + .ok_or_else(|| SandboxError::SessionNotFound { + session_id: format!("mcp:{}", query.mcp_name), + })?; + Ok(Json(value)) } -fn agent_supports_item_started(agent: AgentId) -> bool { - agent_capabilities_for(agent).item_started +#[utoipa::path( + put, + path = "/v1/config/mcp", + tag = "v1", + params( + ("directory" = String, Query, description = "Target directory"), + ("mcpName" = String, Query, description = "MCP entry name") + ), + request_body = McpServerConfig, + responses( + (status = 204, description = "Stored") + ) +)] +async fn put_v1_config_mcp( + Query(query): Query<McpConfigQuery>, + Json(body): Json<McpServerConfig>, +) -> Result<StatusCode, ApiError> { + validate_named_query(&query.directory, "directory")?; + validate_named_query(&query.mcp_name, "mcpName")?; + + let path = config_file_path(&query.directory, "mcp.json")?; + let mut entries: BTreeMap<String, McpServerConfig> = read_named_config_map(&path)?; + entries.insert(query.mcp_name, body); + write_named_config_map(&path, &entries)?; + Ok(StatusCode::NO_CONTENT) } -fn agent_emits_turn_started(agent: AgentId) -> bool { - matches!(agent, AgentId::Codex | AgentId::Opencode) +#[utoipa::path( + delete, + path = "/v1/config/mcp", + tag = "v1", + params( + ("directory" = String, Query, description = "Target directory"), + ("mcpName" = String, Query, description = "MCP entry name") + ), + responses( + (status = 204, description = "Deleted") + ) +)] +async fn delete_v1_config_mcp(Query(query): Query<McpConfigQuery>) -> Result<StatusCode, ApiError> { + validate_named_query(&query.directory, "directory")?; + validate_named_query(&query.mcp_name, "mcpName")?; + + let path = config_file_path(&query.directory, "mcp.json")?; + let mut entries: BTreeMap<String, McpServerConfig> = read_named_config_map(&path)?; + entries.remove(&query.mcp_name); + write_named_config_map(&path, &entries)?; + Ok(StatusCode::NO_CONTENT) } -fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { - match agent { - // Claude CLI supports tool calls/results and permission prompts via the SDK control protocol, - // but we still emit synthetic item.started events. - AgentId::Claude => AgentCapabilities { - plan_mode: false, - permissions: true, - questions: true, - tool_calls: true, - tool_results: true, - text_messages: true, - images: false, - file_attachments: false, - session_lifecycle: false, - error_events: false, - reasoning: false, - status: false, - command_execution: false, - file_changes: false, - mcp_tools: true, - streaming_deltas: true, - item_started: false, - variants: false, - shared_process: false, // per-turn subprocess with --resume - }, - AgentId::Codex => AgentCapabilities { - plan_mode: true, - permissions: true, - questions: false, - tool_calls: true, - tool_results: true, - text_messages: true, - images: true, - file_attachments: true, - session_lifecycle: true, - error_events: true, - reasoning: true, - status: true, - command_execution: true, - file_changes: true, - mcp_tools: true, - streaming_deltas: true, - item_started: true, - variants: false, - shared_process: true, // shared app-server via JSON-RPC - }, - AgentId::Opencode => AgentCapabilities { - plan_mode: false, - permissions: false, - questions: false, - tool_calls: true, - tool_results: true, - text_messages: true, - images: true, - file_attachments: true, - session_lifecycle: true, - error_events: true, - reasoning: false, - status: false, - command_execution: false, - file_changes: false, - mcp_tools: true, - streaming_deltas: true, - item_started: true, - variants: false, - shared_process: true, // shared HTTP server - }, - AgentId::Amp => AgentCapabilities { - plan_mode: false, - permissions: false, - questions: false, - tool_calls: true, - tool_results: true, - text_messages: true, - images: false, - file_attachments: false, - session_lifecycle: false, - error_events: true, - reasoning: false, - status: false, - command_execution: false, - file_changes: false, - mcp_tools: true, - streaming_deltas: false, - item_started: false, - variants: false, - shared_process: false, // per-turn subprocess with --continue - }, - AgentId::Pi => AgentCapabilities { - plan_mode: false, - permissions: false, - questions: false, - tool_calls: true, - tool_results: true, - text_messages: true, - images: true, - file_attachments: false, - session_lifecycle: false, - error_events: true, - reasoning: true, - status: true, - command_execution: false, - file_changes: false, - mcp_tools: false, - streaming_deltas: true, - item_started: true, - variants: true, - shared_process: false, // one dedicated rpc process per session - }, - AgentId::Cursor => AgentCapabilities { - plan_mode: false, - permissions: false, - questions: false, - tool_calls: false, - tool_results: false, - text_messages: true, - images: false, - file_attachments: false, - session_lifecycle: false, - error_events: false, - reasoning: false, - status: false, - command_execution: false, - file_changes: false, - mcp_tools: false, - streaming_deltas: false, - item_started: false, - variants: false, - shared_process: false, - }, - AgentId::Mock => AgentCapabilities { - plan_mode: true, - permissions: true, - questions: true, - tool_calls: true, - tool_results: true, - text_messages: true, - images: true, - file_attachments: true, - session_lifecycle: true, - error_events: true, - reasoning: true, - status: true, - command_execution: true, - file_changes: true, - mcp_tools: true, - streaming_deltas: true, - item_started: true, - variants: false, - shared_process: false, // in-memory mock (no subprocess) - }, - } +#[utoipa::path( + get, + path = "/v1/config/skills", + tag = "v1", + params( + ("directory" = String, Query, description = "Target directory"), + ("skillName" = String, Query, description = "Skill entry name") + ), + responses( + (status = 200, description = "Skills entry", body = SkillsConfig), + (status = 404, description = "Entry not found", body = ProblemDetails) + ) +)] +async fn get_v1_config_skills( + Query(query): Query<SkillsConfigQuery>, +) -> Result<Json<SkillsConfig>, ApiError> { + validate_named_query(&query.directory, "directory")?; + validate_named_query(&query.skill_name, "skillName")?; + + let path = config_file_path(&query.directory, "skills.json")?; + let entries: BTreeMap<String, SkillsConfig> = read_named_config_map(&path)?; + let value = + entries + .get(&query.skill_name) + .cloned() + .ok_or_else(|| SandboxError::SessionNotFound { + session_id: format!("skills:{}", query.skill_name), + })?; + Ok(Json(value)) } -fn parse_agent_id(agent: &str) -> Result<AgentId, SandboxError> { - AgentId::parse(agent).ok_or_else(|| SandboxError::UnsupportedAgent { - agent: agent.to_string(), - }) +#[utoipa::path( + put, + path = "/v1/config/skills", + tag = "v1", + params( + ("directory" = String, Query, description = "Target directory"), + ("skillName" = String, Query, description = "Skill entry name") + ), + request_body = SkillsConfig, + responses( + (status = 204, description = "Stored") + ) +)] +async fn put_v1_config_skills( + Query(query): Query<SkillsConfigQuery>, + Json(body): Json<SkillsConfig>, +) -> Result<StatusCode, ApiError> { + validate_named_query(&query.directory, "directory")?; + validate_named_query(&query.skill_name, "skillName")?; + + let path = config_file_path(&query.directory, "skills.json")?; + let mut entries: BTreeMap<String, SkillsConfig> = read_named_config_map(&path)?; + entries.insert(query.skill_name, body); + write_named_config_map(&path, &entries)?; + Ok(StatusCode::NO_CONTENT) } -fn agent_modes_for(agent: AgentId) -> Vec<AgentModeInfo> { - match agent { - AgentId::Opencode => vec![ - AgentModeInfo { - id: "build".to_string(), - name: "Build".to_string(), - description: "Default build mode".to_string(), - }, - AgentModeInfo { - id: "plan".to_string(), - name: "Plan".to_string(), - description: "Planning mode".to_string(), - }, - AgentModeInfo { - id: "custom".to_string(), - name: "Custom".to_string(), - description: "Any user-defined OpenCode agent name".to_string(), - }, - ], - AgentId::Codex => vec![ - AgentModeInfo { - id: "build".to_string(), - name: "Build".to_string(), - description: "Default build mode".to_string(), - }, - AgentModeInfo { - id: "plan".to_string(), - name: "Plan".to_string(), - description: "Planning mode via prompt prefix".to_string(), - }, - ], - AgentId::Claude => vec![ - AgentModeInfo { - id: "build".to_string(), - name: "Build".to_string(), - description: "Default build mode".to_string(), - }, - AgentModeInfo { - id: "plan".to_string(), - name: "Plan".to_string(), - description: "Plan mode (prompt-only)".to_string(), - }, - ], - AgentId::Amp => vec![AgentModeInfo { - id: "build".to_string(), - name: "Build".to_string(), - description: "Default build mode".to_string(), - }], - AgentId::Pi => vec![AgentModeInfo { - id: "build".to_string(), - name: "Build".to_string(), - description: "Default build mode".to_string(), - }], - AgentId::Cursor => vec![AgentModeInfo { - id: "build".to_string(), - name: "Build".to_string(), - description: "Default build mode".to_string(), - }], - AgentId::Mock => vec![ - AgentModeInfo { - id: "build".to_string(), - name: "Build".to_string(), - description: "Mock agent for UI testing".to_string(), - }, - AgentModeInfo { - id: "plan".to_string(), - name: "Plan".to_string(), - description: "Plan-only mock mode".to_string(), - }, - ], - } +#[utoipa::path( + delete, + path = "/v1/config/skills", + tag = "v1", + params( + ("directory" = String, Query, description = "Target directory"), + ("skillName" = String, Query, description = "Skill entry name") + ), + responses( + (status = 204, description = "Deleted") + ) +)] +async fn delete_v1_config_skills( + Query(query): Query<SkillsConfigQuery>, +) -> Result<StatusCode, ApiError> { + validate_named_query(&query.directory, "directory")?; + validate_named_query(&query.skill_name, "skillName")?; + + let path = config_file_path(&query.directory, "skills.json")?; + let mut entries: BTreeMap<String, SkillsConfig> = read_named_config_map(&path)?; + entries.remove(&query.skill_name); + write_named_config_map(&path, &entries)?; + Ok(StatusCode::NO_CONTENT) } -fn amp_models_response() -> AgentModelsResponse { - let models = vec![AgentModelInfo { - id: "amp-default".to_string(), - name: Some("Amp Default".to_string()), - variants: None, - default_variant: None, - }]; - AgentModelsResponse { - models, - default_model: Some("amp-default".to_string()), - } -} - -fn mock_models_response() -> AgentModelsResponse { - AgentModelsResponse { - models: vec![AgentModelInfo { - id: "mock".to_string(), - name: Some("Mock".to_string()), - variants: None, - default_variant: None, - }], - default_model: Some("mock".to_string()), - } -} - -fn should_cache_agent_models(agent: AgentId, response: &AgentModelsResponse) -> bool { - if agent == AgentId::Opencode && response.models.is_empty() { - return false; - } - true -} - -fn pi_variants() -> Vec<String> { - vec!["off", "minimal", "low", "medium", "high", "xhigh"] +#[utoipa::path( + get, + path = "/v1/acp", + tag = "v1", + responses( + (status = 200, description = "Active ACP server instances", body = AcpServerListResponse) + ) +)] +async fn get_v1_acp_servers( + State(state): State<Arc<AppState>>, +) -> Result<Json<AcpServerListResponse>, ApiError> { + let servers = state + .acp_proxy() + .list_instances() + .await .into_iter() - .map(|value| value.to_string()) - .collect() + .map(|instance| AcpServerInfo { + server_id: instance.server_id, + agent: instance.agent.as_str().to_string(), + created_at_ms: instance.created_at_ms, + }) + .collect::<Vec<_>>(); + + Ok(Json(AcpServerListResponse { servers })) } -fn parse_pi_models_output(output: &str) -> AgentModelsResponse { - let mut models = Vec::new(); - let mut seen = HashSet::new(); - let mut provider_col: Option<usize> = None; - let mut model_col: Option<usize> = None; - let mut thinking_col: Option<usize> = None; - - for line in output.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - let parts = trimmed.split_whitespace().collect::<Vec<_>>(); - if parts.len() < 2 { - continue; - } - if parts.iter().all(|part| { - part.chars() - .all(|ch| matches!(ch, '-' | '=' | '+' | '|' | ':')) - }) { - continue; - } - - let lower = parts - .iter() - .map(|part| part.to_ascii_lowercase()) - .collect::<Vec<_>>(); - if let (Some(provider_idx), Some(model_idx)) = ( - lower.iter().position(|part| part == "provider"), - lower.iter().position(|part| part == "model"), - ) { - provider_col = Some(provider_idx); - model_col = Some(model_idx); - thinking_col = lower.iter().position(|part| part == "thinking"); - continue; - } - - let provider_idx = provider_col.unwrap_or(0); - let model_idx = model_col.unwrap_or(1); - let Some(provider) = parts.get(provider_idx).copied() else { - continue; - }; - let Some(model) = parts.get(model_idx).copied() else { - continue; - }; - - if provider.chars().all(|ch| ch == '-' || ch == '=') - && model.chars().all(|ch| ch == '-' || ch == '=') - { - continue; - } - - let thinking_value = thinking_col - .and_then(|idx| parts.get(idx).copied()) - .or_else(|| { - parts.iter().rev().copied().find(|value| { - value.eq_ignore_ascii_case("yes") || value.eq_ignore_ascii_case("no") - }) - }); - let supports_thinking = - thinking_value.is_some_and(|value| value.eq_ignore_ascii_case("yes")); - let (variants, default_variant) = if supports_thinking { - (Some(pi_variants()), Some("medium".to_string())) - } else { - (Some(vec!["off".to_string()]), Some("off".to_string())) - }; - - let id = format!("{provider}/{model}"); - if !seen.insert(id.clone()) { - continue; - } - - models.push(AgentModelInfo { - id, - name: None, - variants, - default_variant, - }); - } - - models.sort_by(|a, b| a.id.cmp(&b.id)); - - AgentModelsResponse { - models, - default_model: None, - } -} - -fn parse_opencode_models(value: &Value) -> Option<AgentModelsResponse> { - let providers = value - .get("providers") - .and_then(Value::as_array) - .or_else(|| value.get("all").and_then(Value::as_array))?; - let default_map = value - .get("default") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - - let mut models = Vec::new(); - let mut provider_order = Vec::new(); - for provider in providers { - let provider_id = provider.get("id").and_then(Value::as_str)?; - provider_order.push(provider_id.to_string()); - let Some(model_map) = provider.get("models").and_then(Value::as_object) else { - continue; - }; - for (key, model) in model_map { - let model_id = model - .get("id") - .and_then(Value::as_str) - .unwrap_or(key.as_str()); - let name = model - .get("name") - .and_then(Value::as_str) - .map(|value| value.to_string()); - let mut variants = model - .get("variants") - .and_then(Value::as_object) - .map(|map| map.keys().cloned().collect::<Vec<_>>()); - if let Some(variants) = variants.as_mut() { - variants.sort(); - } - models.push(AgentModelInfo { - id: format!("{provider_id}/{model_id}"), - name, - variants, - default_variant: None, - }); - } - } - models.sort_by(|a, b| a.id.cmp(&b.id)); - - let mut default_model = None; - for provider_id in provider_order { - if let Some(model_id) = default_map.get(&provider_id).and_then(Value::as_str) { - default_model = Some(format!("{provider_id}/{model_id}")); - break; - } - } - if default_model.is_none() { - default_model = models.first().map(|model| model.id.clone()); - } - - Some(AgentModelsResponse { - models, - default_model, - }) -} - -fn normalize_agent_mode(agent: AgentId, agent_mode: Option<&str>) -> Result<String, SandboxError> { - let mode = agent_mode.unwrap_or("build"); - match agent { - AgentId::Opencode => Ok(mode.to_string()), - AgentId::Codex => match mode { - "build" | "plan" => Ok(mode.to_string()), - value => Err(SandboxError::ModeNotSupported { - agent: agent.as_str().to_string(), - mode: value.to_string(), - } - .into()), - }, - AgentId::Claude => match mode { - "build" | "plan" => Ok(mode.to_string()), - value => Err(SandboxError::ModeNotSupported { - agent: agent.as_str().to_string(), - mode: value.to_string(), - } - .into()), - }, - AgentId::Amp => match mode { - "build" => Ok("build".to_string()), - value => Err(SandboxError::ModeNotSupported { - agent: agent.as_str().to_string(), - mode: value.to_string(), - } - .into()), - }, - AgentId::Pi => match mode { - "build" => Ok("build".to_string()), - value => Err(SandboxError::ModeNotSupported { - agent: agent.as_str().to_string(), - mode: value.to_string(), - } - .into()), - }, - AgentId::Cursor => match mode { - "build" => Ok("build".to_string()), - value => Err(SandboxError::ModeNotSupported { - agent: agent.as_str().to_string(), - mode: value.to_string(), - } - .into()), - }, - AgentId::Mock => match mode { - "build" | "plan" => Ok(mode.to_string()), - value => Err(SandboxError::ModeNotSupported { - agent: agent.as_str().to_string(), - mode: value.to_string(), - } - .into()), - }, - } -} - -/// Check if the current process is running as root (uid 0) -fn is_running_as_root() -> bool { - #[cfg(unix)] - { - unsafe { libc::getuid() == 0 } - } - #[cfg(not(unix))] - { - false - } -} - -fn normalize_permission_mode( - agent: AgentId, - permission_mode: Option<&str>, -) -> Result<String, SandboxError> { - let mut mode = match permission_mode.unwrap_or("default") { - "default" | "plan" | "bypass" | "acceptEdits" => permission_mode.unwrap_or("default"), - value => { - return Err(SandboxError::InvalidRequest { - message: format!("invalid permission mode: {value}"), - } - .into()) - } - }; - if agent != AgentId::Claude && mode == "acceptEdits" && agent != AgentId::Codex { - // acceptEdits is Claude-only unless explicitly handled; treat it as a no-op for other agents. - mode = "default"; - } - if agent == AgentId::Claude { - // Claude refuses --dangerously-skip-permissions when running as root, - // which is common in container environments (Docker, Daytona, E2B). - // Return an error if user explicitly requests bypass while running as root. - if mode == "bypass" && is_running_as_root() { - return Err(SandboxError::InvalidRequest { - message: "permission mode 'bypass' is not supported when running as root (Claude refuses --dangerously-skip-permissions with root privileges)".to_string(), - } - .into()); - } - // Pass through bypass/acceptEdits/plan if explicitly requested, otherwise use default - if mode == "bypass" || mode == "acceptEdits" || mode == "plan" { - return Ok(mode.to_string()); - } - return Ok("default".to_string()); - } - let supported = match agent { - AgentId::Claude => false, - AgentId::Codex => matches!(mode, "default" | "plan" | "bypass" | "acceptEdits"), - AgentId::Amp => matches!(mode, "default" | "bypass"), - AgentId::Opencode => matches!(mode, "default" | "bypass"), - AgentId::Pi => matches!(mode, "default"), - AgentId::Cursor => matches!(mode, "default"), - AgentId::Mock => matches!(mode, "default" | "plan" | "bypass"), - }; - if !supported { - return Err(SandboxError::ModeNotSupported { - agent: agent.as_str().to_string(), - mode: mode.to_string(), +#[utoipa::path( + post, + path = "/v1/acp/{server_id}", + tag = "v1", + params( + ("server_id" = String, Path, description = "Client-defined ACP server id"), + ("agent" = Option<String>, Query, description = "Agent id required for first POST") + ), + request_body = AcpEnvelope, + responses( + (status = 200, description = "JSON-RPC response envelope", body = AcpEnvelope), + (status = 202, description = "JSON-RPC notification accepted"), + (status = 406, description = "Client does not accept JSON responses", body = ProblemDetails), + (status = 415, description = "Unsupported media type", body = ProblemDetails), + (status = 400, description = "Invalid ACP envelope", body = ProblemDetails), + (status = 404, description = "Unknown ACP server", body = ProblemDetails), + (status = 409, description = "ACP server bound to different agent", body = ProblemDetails), + (status = 504, description = "ACP agent process response timeout", body = ProblemDetails) + ) +)] +async fn post_v1_acp( + State(state): State<Arc<AppState>>, + Path(server_id): Path<String>, + Query(query): Query<AcpPostQuery>, + headers: HeaderMap, + body: Bytes, +) -> Result<Response, ApiError> { + if !content_type_is(&headers, APPLICATION_JSON) { + return Err(SandboxError::UnsupportedMediaType { + message: "content-type must be application/json".to_string(), } .into()); } - Ok(mode.to_string()) -} - -fn normalize_modes( - agent: AgentId, - agent_mode: Option<&str>, - permission_mode: Option<&str>, -) -> Result<(String, String), SandboxError> { - let agent_mode = normalize_agent_mode(agent, agent_mode)?; - let permission_mode = normalize_permission_mode(agent, permission_mode)?; - Ok((agent_mode, permission_mode)) -} - -fn map_install_error(agent: AgentId, err: ManagerError) -> SandboxError { - match err { - ManagerError::UnsupportedAgent { agent } => SandboxError::UnsupportedAgent { agent }, - ManagerError::BinaryNotFound { .. } => SandboxError::AgentNotInstalled { - agent: agent.as_str().to_string(), - }, - ManagerError::ResumeUnsupported { agent } => SandboxError::InvalidRequest { - message: format!("resume unsupported for {agent}"), - }, - ManagerError::UnsupportedRuntimePath { .. } => SandboxError::InvalidRequest { - message: err.to_string(), - }, - ManagerError::UnsupportedPlatform { .. } - | ManagerError::DownloadFailed { .. } - | ManagerError::Http(_) - | ManagerError::UrlParse(_) - | ManagerError::Io(_) - | ManagerError::ExtractFailed(_) => SandboxError::InstallFailed { - agent: agent.as_str().to_string(), - stderr: Some(err.to_string()), - }, - } -} - -fn map_spawn_error(agent: AgentId, err: ManagerError) -> SandboxError { - match err { - ManagerError::BinaryNotFound { .. } => SandboxError::AgentNotInstalled { - agent: agent.as_str().to_string(), - }, - ManagerError::ResumeUnsupported { agent } => SandboxError::InvalidRequest { - message: format!("resume unsupported for {agent}"), - }, - ManagerError::UnsupportedRuntimePath { .. } => SandboxError::InvalidRequest { - message: err.to_string(), - }, - _ => SandboxError::AgentProcessExited { - agent: agent.as_str().to_string(), - exit_code: None, - stderr: Some(err.to_string()), - }, - } -} - -fn build_spawn_options( - session: &SessionSnapshot, - prompt: String, - credentials: ExtractedCredentials, -) -> SpawnOptions { - let mut options = SpawnOptions::new(prompt); - options.model = session.model.clone(); - options.variant = session.variant.clone(); - options.agent_mode = Some(session.agent_mode.clone()); - options.permission_mode = Some(session.permission_mode.clone()); - options.session_id = session.native_session_id.clone().or_else(|| { - if session.agent == AgentId::Opencode { - Some(session.session_id.clone()) - } else { - None - } - }); - if let Some(anthropic) = credentials.anthropic { - let should_inject_claude_env = !(session.agent == AgentId::Claude - && anthropic.source == "claude-code" - && anthropic.provider == "anthropic"); - if should_inject_claude_env { - if session.agent == AgentId::Claude && anthropic.auth_type == AuthType::Oauth { - options - .env - .entry("CLAUDE_CODE_OAUTH_TOKEN".to_string()) - .or_insert(anthropic.api_key.clone()); - options - .env - .entry("ANTHROPIC_AUTH_TOKEN".to_string()) - .or_insert(anthropic.api_key); - } else { - options - .env - .entry("ANTHROPIC_API_KEY".to_string()) - .or_insert(anthropic.api_key.clone()); - options - .env - .entry("CLAUDE_API_KEY".to_string()) - .or_insert(anthropic.api_key); - } - } - } - if let Some(openai) = credentials.openai { - options - .env - .entry("OPENAI_API_KEY".to_string()) - .or_insert(openai.api_key.clone()); - options - .env - .entry("CODEX_API_KEY".to_string()) - .or_insert(openai.api_key); - } - options -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Mutex to serialize tests that change the process-global CWD. - static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - fn test_snapshot(agent: AgentId) -> SessionSnapshot { - SessionSnapshot { - session_id: "test-session".to_string(), - agent, - agent_mode: "build".to_string(), - permission_mode: "default".to_string(), - model: None, - variant: None, - native_session_id: None, + if !accept_allows(&headers, APPLICATION_JSON) { + return Err(SandboxError::NotAcceptable { + message: "accept must allow application/json".to_string(), } + .into()); } - fn claude_code_api_key_credentials() -> ExtractedCredentials { - ExtractedCredentials { - anthropic: Some(ProviderCredentials { - api_key: "sk-ant-test".to_string(), - source: "claude-code".to_string(), - auth_type: AuthType::ApiKey, - provider: "anthropic".to_string(), - }), - openai: None, - other: HashMap::new(), - } - } - - fn environment_oauth_credentials() -> ExtractedCredentials { - ExtractedCredentials { - anthropic: Some(ProviderCredentials { - api_key: "oauth-token".to_string(), - source: "environment".to_string(), - auth_type: AuthType::Oauth, - provider: "anthropic".to_string(), - }), - openai: None, - other: HashMap::new(), - } - } - - #[test] - fn build_spawn_options_skips_claude_env_for_claude_code_source() { - let options = build_spawn_options( - &test_snapshot(AgentId::Claude), - "hello".to_string(), - claude_code_api_key_credentials(), - ); - - assert!(!options.env.contains_key("ANTHROPIC_API_KEY")); - assert!(!options.env.contains_key("CLAUDE_API_KEY")); - } - - #[test] - fn build_spawn_options_keeps_anthropic_env_for_non_claude_agent() { - let options = build_spawn_options( - &test_snapshot(AgentId::Amp), - "hello".to_string(), - claude_code_api_key_credentials(), - ); - - assert_eq!( - options.env.get("ANTHROPIC_API_KEY").map(String::as_str), - Some("sk-ant-test") - ); - assert_eq!( - options.env.get("CLAUDE_API_KEY").map(String::as_str), - Some("sk-ant-test") - ); - } - - #[test] - fn build_spawn_options_uses_oauth_env_for_claude_oauth_credentials() { - let options = build_spawn_options( - &test_snapshot(AgentId::Claude), - "hello".to_string(), - environment_oauth_credentials(), - ); - - assert_eq!( - options - .env - .get("CLAUDE_CODE_OAUTH_TOKEN") - .map(String::as_str), - Some("oauth-token") - ); - assert_eq!( - options.env.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str), - Some("oauth-token") - ); - assert!(!options.env.contains_key("ANTHROPIC_API_KEY")); - assert!(!options.env.contains_key("CLAUDE_API_KEY")); - } - - #[test] - fn codex_unavailable_model_parser_handles_requested_model_message() { - let message = "The requested model 'gpt-5.3-codex' does not exist."; - assert_eq!( - codex_unavailable_model_from_message(message), - Some("gpt-5.3-codex".to_string()) - ); - } - - #[test] - fn codex_unavailable_model_parser_handles_chatgpt_account_message() { - let message = "The 'gpt-5.3-codex-NOTREAL' model is not supported when using Codex with a ChatGPT account."; - assert_eq!( - codex_unavailable_model_from_message(message), - Some("gpt-5.3-codex-NOTREAL".to_string()) - ); - } - - #[test] - fn codex_unavailable_model_parser_ignores_non_model_messages() { - let message = "Network error while contacting provider."; - assert_eq!(codex_unavailable_model_from_message(message), None); - } - - #[test] - fn codex_unavailable_model_parser_ignores_non_unavailable_model_messages() { - let message = "using model 'gpt-5.3-codex' for this turn"; - assert_eq!(codex_unavailable_model_from_message(message), None); - } - - #[test] - fn codex_unavailable_model_parser_handles_embedded_json_detail_message() { - let message = "http 400 Bad Request: Some(\"{\\\"detail\\\":\\\"The 'gpt-5.3-codex-NOTREAL' model is not supported when using Codex with a ChatGPT account.\\\"}\")"; - assert_eq!( - codex_unavailable_model_from_message(message), - Some("gpt-5.3-codex-NOTREAL".to_string()) - ); - } - - // ── Skill source tests ────────────────────────────────────────── - - fn make_skill_dir(base: &StdPath, name: &str) -> PathBuf { - let dir = base.join(name); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join("SKILL.md"), format!("# {name}")).unwrap(); - dir - } - - #[test] - fn skill_source_serde_github_roundtrip() { - let json = r#"{"type":"github","source":"rivet-dev/skills","skills":["sandbox-agent"],"ref":"main"}"#; - let source: SkillSource = serde_json::from_str(json).unwrap(); - assert_eq!(source.source_type, "github"); - assert_eq!(source.source, "rivet-dev/skills"); - assert_eq!(source.skills, Some(vec!["sandbox-agent".to_string()])); - assert_eq!(source.git_ref, Some("main".to_string())); - assert_eq!(source.subpath, None); - - let roundtrip = serde_json::to_string(&source).unwrap(); - let back: SkillSource = serde_json::from_str(&roundtrip).unwrap(); - assert_eq!(back.source_type, source.source_type); - assert_eq!(back.source, source.source); - } - - #[test] - fn skill_source_serde_local_minimal() { - let json = r#"{"type":"local","source":"/workspace/my-skill"}"#; - let source: SkillSource = serde_json::from_str(json).unwrap(); - assert_eq!(source.source_type, "local"); - assert_eq!(source.source, "/workspace/my-skill"); - assert_eq!(source.skills, None); - assert_eq!(source.git_ref, None); - assert_eq!(source.subpath, None); - } - - #[test] - fn skills_config_serde_roundtrip() { - let json = r#"{"sources":[{"type":"github","source":"owner/repo"},{"type":"local","source":"/path"}]}"#; - let config: SkillsConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.sources.len(), 2); - assert_eq!(config.sources[0].source_type, "github"); - assert_eq!(config.sources[1].source_type, "local"); - } - - #[test] - fn discover_skills_finds_root_skill() { - let tmp = tempfile::tempdir().unwrap(); - make_skill_dir(tmp.path(), "."); - // SKILL.md is directly in the search dir - fs::write(tmp.path().join("SKILL.md"), "# root skill").unwrap(); - - let found = discover_skills_in_dir(tmp.path(), None).unwrap(); - assert_eq!(found.len(), 1); - assert_eq!(found[0], tmp.path().to_path_buf()); - } - - #[test] - fn discover_skills_finds_skills_subdir() { - let tmp = tempfile::tempdir().unwrap(); - make_skill_dir(&tmp.path().join("skills"), "alpha"); - make_skill_dir(&tmp.path().join("skills"), "beta"); - - let found = discover_skills_in_dir(tmp.path(), None).unwrap(); - let names: Vec<String> = found - .iter() - .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) - .collect(); - assert!(names.contains(&"alpha".to_string())); - assert!(names.contains(&"beta".to_string())); - } - - #[test] - fn discover_skills_finds_top_level_children() { - let tmp = tempfile::tempdir().unwrap(); - make_skill_dir(tmp.path(), "my-skill"); - - let found = discover_skills_in_dir(tmp.path(), None).unwrap(); - assert_eq!(found.len(), 1); - assert!(found[0].ends_with("my-skill")); - } - - #[test] - fn discover_skills_deduplicates_children_and_skills_subdir() { - let tmp = tempfile::tempdir().unwrap(); - // Put a skill both at top level and in skills/ subdir with same name - make_skill_dir(tmp.path(), "dupe"); - make_skill_dir(&tmp.path().join("skills"), "dupe"); - - let found = discover_skills_in_dir(tmp.path(), None).unwrap(); - let dupe_count = found - .iter() - .filter(|p| p.file_name().map(|n| n == "dupe").unwrap_or(false)) - .count(); - // Both should be present since they're different paths - assert_eq!(dupe_count, 2); - } - - #[test] - fn discover_skills_respects_subpath() { - let tmp = tempfile::tempdir().unwrap(); - make_skill_dir(&tmp.path().join("nested/skills"), "deep-skill"); - // Also put a skill at root that should NOT be discovered - make_skill_dir(tmp.path(), "root-skill"); - - let found = discover_skills_in_dir(tmp.path(), Some("nested")).unwrap(); - let names: Vec<String> = found - .iter() - .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) - .collect(); - assert!(names.contains(&"deep-skill".to_string())); - assert!(!names.contains(&"root-skill".to_string())); - } - - #[test] - fn discover_skills_empty_dir_returns_empty() { - let tmp = tempfile::tempdir().unwrap(); - let found = discover_skills_in_dir(tmp.path(), None).unwrap(); - assert!(found.is_empty()); - } - - #[test] - fn discover_skills_missing_subpath_returns_error() { - let tmp = tempfile::tempdir().unwrap(); - let result = discover_skills_in_dir(tmp.path(), Some("nonexistent")); - assert!(result.is_err()); - } - - #[test] - fn discover_skills_ignores_non_skill_dirs() { - let tmp = tempfile::tempdir().unwrap(); - // Create a directory without SKILL.md - fs::create_dir_all(tmp.path().join("not-a-skill")).unwrap(); - fs::write(tmp.path().join("not-a-skill/README.md"), "# readme").unwrap(); - // Create an actual skill - make_skill_dir(tmp.path(), "real-skill"); - - let found = discover_skills_in_dir(tmp.path(), None).unwrap(); - assert_eq!(found.len(), 1); - assert!(found[0].ends_with("real-skill")); - } - - #[test] - fn resolve_skill_source_local_absolute() { - let tmp = tempfile::tempdir().unwrap(); - let skill = make_skill_dir(tmp.path(), "my-skill"); - let source = SkillSource { - source_type: "local".to_string(), - source: skill.to_string_lossy().to_string(), - skills: None, - git_ref: None, - subpath: None, - }; - let result = resolve_skill_source(&source, tmp.path()).unwrap(); - assert_eq!(result, skill); - } - - #[test] - fn resolve_skill_source_local_relative() { - let tmp = tempfile::tempdir().unwrap(); - make_skill_dir(tmp.path(), "my-skill"); - let source = SkillSource { - source_type: "local".to_string(), - source: "my-skill".to_string(), - skills: None, - git_ref: None, - subpath: None, - }; - let result = resolve_skill_source(&source, tmp.path()).unwrap(); - assert_eq!(result, tmp.path().join("my-skill")); - } - - #[test] - fn resolve_skill_source_local_missing_dir_errors() { - let tmp = tempfile::tempdir().unwrap(); - let source = SkillSource { - source_type: "local".to_string(), - source: "/nonexistent/path".to_string(), - skills: None, - git_ref: None, - subpath: None, - }; - let result = resolve_skill_source(&source, tmp.path()); - assert!(result.is_err()); - } - - #[test] - fn resolve_skill_source_unsupported_type_errors() { - let tmp = tempfile::tempdir().unwrap(); - let source = SkillSource { - source_type: "s3".to_string(), - source: "bucket/key".to_string(), - skills: None, - git_ref: None, - subpath: None, - }; - let result = resolve_skill_source(&source, tmp.path()); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("unsupported"), - "expected 'unsupported' in: {msg}" - ); - } - - #[test] - fn install_skill_sources_local_single() { - let _lock = CWD_LOCK.lock().unwrap(); - let tmp = tempfile::tempdir().unwrap(); - make_skill_dir(tmp.path(), "alpha"); - - let sources = vec![SkillSource { - source_type: "local".to_string(), - source: tmp.path().join("alpha").to_string_lossy().to_string(), - skills: None, - git_ref: None, - subpath: None, - }]; - - // Run from a temp working directory so symlinks go there - let work = tempfile::tempdir().unwrap(); - let prev = std::env::current_dir().unwrap(); - std::env::set_current_dir(work.path()).unwrap(); - let result = install_skill_sources(&sources); - std::env::set_current_dir(prev).unwrap(); - - let dirs = result.unwrap(); - assert_eq!(dirs.len(), 1); - // Verify symlinks were created - for root in SKILL_ROOTS { - let link = work.path().join(root).join("alpha"); - assert!(link.exists(), "expected skill link at {}", link.display()); - assert!(link.join("SKILL.md").exists()); - } - } - - #[test] - fn install_skill_sources_filters_by_name() { - let _lock = CWD_LOCK.lock().unwrap(); - let tmp = tempfile::tempdir().unwrap(); - // Repo-like layout with skills/ subdir containing two skills - make_skill_dir(&tmp.path().join("skills"), "wanted"); - make_skill_dir(&tmp.path().join("skills"), "unwanted"); - - let sources = vec![SkillSource { - source_type: "local".to_string(), - source: tmp.path().to_string_lossy().to_string(), - skills: Some(vec!["wanted".to_string()]), - git_ref: None, - subpath: None, - }]; - - let work = tempfile::tempdir().unwrap(); - let prev = std::env::current_dir().unwrap(); - std::env::set_current_dir(work.path()).unwrap(); - let result = install_skill_sources(&sources); - std::env::set_current_dir(prev).unwrap(); - - let dirs = result.unwrap(); - assert_eq!(dirs.len(), 1); - assert!(dirs[0].ends_with("wanted")); - } - - #[test] - fn install_skill_sources_errors_when_filter_matches_nothing() { - let _lock = CWD_LOCK.lock().unwrap(); - let tmp = tempfile::tempdir().unwrap(); - make_skill_dir(&tmp.path().join("skills"), "alpha"); - - let sources = vec![SkillSource { - source_type: "local".to_string(), - source: tmp.path().to_string_lossy().to_string(), - skills: Some(vec!["nonexistent".to_string()]), - git_ref: None, - subpath: None, - }]; - - let work = tempfile::tempdir().unwrap(); - let prev = std::env::current_dir().unwrap(); - std::env::set_current_dir(work.path()).unwrap(); - let result = install_skill_sources(&sources); - std::env::set_current_dir(prev).unwrap(); - - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("no skills found"), - "expected 'no skills found' in: {msg}" - ); - } - - #[test] - fn install_skill_sources_multiple_sources() { - let _lock = CWD_LOCK.lock().unwrap(); - let tmp1 = tempfile::tempdir().unwrap(); - let tmp2 = tempfile::tempdir().unwrap(); - make_skill_dir(tmp1.path(), "skill-a"); - make_skill_dir(tmp2.path(), "skill-b"); - - let sources = vec![ - SkillSource { - source_type: "local".to_string(), - source: tmp1.path().join("skill-a").to_string_lossy().to_string(), - skills: None, - git_ref: None, - subpath: None, - }, - SkillSource { - source_type: "local".to_string(), - source: tmp2.path().join("skill-b").to_string_lossy().to_string(), - skills: None, - git_ref: None, - subpath: None, - }, - ]; - - let work = tempfile::tempdir().unwrap(); - let prev = std::env::current_dir().unwrap(); - std::env::set_current_dir(work.path()).unwrap(); - let result = install_skill_sources(&sources); - std::env::set_current_dir(prev).unwrap(); - - let dirs = result.unwrap(); - assert_eq!(dirs.len(), 2); - } - - #[test] - fn install_skill_sources_deduplicates_same_skill() { - let _lock = CWD_LOCK.lock().unwrap(); - let tmp = tempfile::tempdir().unwrap(); - let skill = make_skill_dir(tmp.path(), "shared"); - let path_str = skill.to_string_lossy().to_string(); - - let sources = vec![ - SkillSource { - source_type: "local".to_string(), - source: path_str.clone(), - skills: None, - git_ref: None, - subpath: None, - }, - SkillSource { - source_type: "local".to_string(), - source: path_str, - skills: None, - git_ref: None, - subpath: None, - }, - ]; - - let work = tempfile::tempdir().unwrap(); - let prev = std::env::current_dir().unwrap(); - std::env::set_current_dir(work.path()).unwrap(); - let result = install_skill_sources(&sources); - std::env::set_current_dir(prev).unwrap(); - - let dirs = result.unwrap(); - assert_eq!(dirs.len(), 1, "duplicate skill should be deduplicated"); - } - - #[test] - fn ensure_skill_link_replaces_dangling_symlink() { - let work = tempfile::tempdir().unwrap(); - let dest = work.path().join("test-link"); - - // Create a dangling symlink (target doesn't exist) - #[cfg(unix)] - std::os::unix::fs::symlink("/nonexistent/target", &dest).unwrap(); - #[cfg(windows)] - std::os::windows::fs::symlink_dir("/nonexistent/target", &dest).unwrap(); - - assert!(dest.symlink_metadata().is_ok(), "symlink should exist"); - assert!(!dest.exists(), "symlink target should not exist (dangling)"); - - // Create a real skill directory as the new target - let skill = tempfile::tempdir().unwrap(); - std::fs::write(skill.path().join("SKILL.md"), "# Test").unwrap(); - - // ensure_skill_link should replace the dangling symlink - let result = ensure_skill_link(skill.path(), &dest); - assert!( - result.is_ok(), - "should replace dangling symlink: {result:?}" - ); - assert!(dest.exists(), "link should now point to valid target"); - assert!(dest.join("SKILL.md").exists()); - } - - #[test] - fn download_github_zip_extracts_correctly() { - use std::io::Write; - - // Build a zip in memory with GitHub-style prefix directory - let buf = Vec::new(); - let cursor = std::io::Cursor::new(buf); - let mut zip_writer = zip::ZipWriter::new(cursor); - - let options = - zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored); - - // GitHub wraps all content under "owner-repo-sha/" - zip_writer - .add_directory("owner-repo-abc123/", options) - .unwrap(); - - zip_writer - .start_file("owner-repo-abc123/SKILL.md", options) - .unwrap(); - zip_writer.write_all(b"# Test Skill").unwrap(); - - zip_writer - .add_directory("owner-repo-abc123/sub/", options) - .unwrap(); - - zip_writer - .start_file("owner-repo-abc123/sub/nested.txt", options) - .unwrap(); - zip_writer.write_all(b"nested content").unwrap(); - - let zip_bytes = zip_writer.finish().unwrap().into_inner(); - - // Extract using the same logic as download_github_zip (minus HTTP) - let work = tempfile::tempdir().unwrap(); - let dest = work.path().join("test-skill"); - - let reader = std::io::Cursor::new(&zip_bytes); - let mut archive = zip::ZipArchive::new(reader).unwrap(); - - // Detect prefix - let prefix = { - let first = archive.by_index(0).unwrap(); - let name = first.name().to_string(); - match name.find('/') { - Some(pos) => name[..=pos].to_string(), - None => String::new(), - } - }; - - fs::create_dir_all(&dest).unwrap(); - - for i in 0..archive.len() { - let mut file = archive.by_index(i).unwrap(); - let full_name = file.name().to_string(); - - let relative = if !prefix.is_empty() && full_name.starts_with(&prefix) { - &full_name[prefix.len()..] - } else { - &full_name - }; - - if relative.is_empty() { - continue; - } - - let out_path = dest.join(relative); - if !out_path.starts_with(&dest) { - continue; - } - - if file.is_dir() { - fs::create_dir_all(&out_path).unwrap(); - } else { - if let Some(parent) = out_path.parent() { - fs::create_dir_all(parent).unwrap(); - } - let mut out_file = fs::File::create(&out_path).unwrap(); - std::io::copy(&mut file, &mut out_file).unwrap(); - } - } - - // Verify files were extracted without the prefix directory - assert!( - dest.join("SKILL.md").exists(), - "SKILL.md should exist at root" - ); - assert_eq!( - fs::read_to_string(dest.join("SKILL.md")).unwrap(), - "# Test Skill" - ); - assert!( - dest.join("sub/nested.txt").exists(), - "nested file should exist" - ); - assert_eq!( - fs::read_to_string(dest.join("sub/nested.txt")).unwrap(), - "nested content" - ); - // Ensure no prefix directory leaked through - assert!( - !dest.join("owner-repo-abc123").exists(), - "prefix dir should be stripped" - ); - } -} - -fn install_skill_sources(sources: &[SkillSource]) -> Result<Vec<PathBuf>, SandboxError> { - let cwd = std::env::current_dir().map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - let mut skill_dirs = Vec::new(); - - for source in sources { - let base_dir = resolve_skill_source(source, &cwd)?; - let discovered = discover_skills_in_dir(&base_dir, source.subpath.as_deref())?; - - let filtered: Vec<PathBuf> = if let Some(filter) = &source.skills { - discovered - .into_iter() - .filter(|p| { - p.file_name() - .map(|n| filter.iter().any(|f| f == n.to_string_lossy().as_ref())) - .unwrap_or(false) - }) - .collect() - } else { - discovered - }; - - if filtered.is_empty() { - let filter_msg = source - .skills - .as_ref() - .map(|f| format!(" (filter: {})", f.join(", "))) - .unwrap_or_default(); - return Err(SandboxError::InvalidRequest { - message: format!( - "no skills found in {} ({}){filter_msg}", - source.source, source.source_type - ), - }); - } - - for skill_path in &filtered { - let canonical = - fs::canonicalize(skill_path).map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - if !skill_dirs.contains(&canonical) { - skill_dirs.push(canonical.clone()); - } - let skill_name = canonical - .file_name() - .ok_or_else(|| SandboxError::InvalidRequest { - message: format!("invalid skill directory: {}", canonical.display()), - })? - .to_string_lossy() - .to_string(); - for root in SKILL_ROOTS { - let dest = cwd.join(root).join(&skill_name); - ensure_skill_link(&canonical, &dest)?; - } - } - } - - Ok(skill_dirs) -} - -fn skills_cache_dir() -> Result<PathBuf, SandboxError> { - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .map_err(|_| SandboxError::StreamError { - message: "cannot determine home directory".to_string(), - })?; - let cache = PathBuf::from(home).join(".sandbox-agent/skills-cache"); - fs::create_dir_all(&cache).map_err(|err| SandboxError::StreamError { - message: format!("failed to create skills cache: {err}"), - })?; - Ok(cache) -} - -fn download_github_zip( - owner_repo: &str, - cache_name: &str, - git_ref: Option<&str>, -) -> Result<PathBuf, SandboxError> { - let cache = skills_cache_dir()?; - let dest = cache.join(cache_name); - - // Remove existing cache dir if present (no .git state to preserve) - if dest.is_dir() { - fs::remove_dir_all(&dest).map_err(|err| SandboxError::StreamError { - message: format!("failed to remove old skills cache: {err}"), - })?; - } - - let git_ref = git_ref.unwrap_or("HEAD"); - let url = format!( - "https://api.github.com/repos/{}/zipball/{}", - owner_repo, git_ref - ); - - let client = reqwest::blocking::Client::new(); - let response = client - .get(&url) - .header("User-Agent", "sandbox-agent") - .send() - .map_err(|err| SandboxError::StreamError { - message: format!("failed to download github zip for {owner_repo}: {err}"), + let payload = + serde_json::from_slice::<Value>(&body).map_err(|err| SandboxError::InvalidRequest { + message: format!("invalid JSON body: {err}"), })?; - if !response.status().is_success() { - return Err(SandboxError::StreamError { - message: format!( - "github zip download failed for {owner_repo}: HTTP {}", - response.status() - ), - }); - } - - let bytes = response.bytes().map_err(|err| SandboxError::StreamError { - message: format!("failed to read github zip response: {err}"), - })?; - - let reader = std::io::Cursor::new(bytes); - let mut archive = zip::ZipArchive::new(reader).map_err(|err| SandboxError::StreamError { - message: format!("failed to open github zip archive: {err}"), - })?; - - // GitHub zipball wraps contents in a {owner}-{repo}-{sha}/ prefix directory. - // Detect this prefix from the first entry and strip it during extraction. - let prefix = { - let first = archive - .by_index(0) - .map_err(|err| SandboxError::StreamError { - message: format!("failed to read zip entry: {err}"), - })?; - let name = first.name().to_string(); - // The first entry is typically the top-level directory itself (e.g. "owner-repo-sha/") - match name.find('/') { - Some(pos) => name[..=pos].to_string(), - None => String::new(), + let bootstrap_agent = match query.agent { + Some(agent) => { + Some( + AgentId::parse(&agent).ok_or_else(|| SandboxError::UnsupportedAgent { + agent: agent.clone(), + })?, + ) } + None => None, }; - fs::create_dir_all(&dest).map_err(|err| SandboxError::StreamError { - message: format!("failed to create skills cache dir: {err}"), - })?; - - for i in 0..archive.len() { - let mut file = archive - .by_index(i) - .map_err(|err| SandboxError::StreamError { - message: format!("failed to read zip entry: {err}"), - })?; - - let full_name = file.name().to_string(); - - // Strip the GitHub prefix directory - let relative = if !prefix.is_empty() && full_name.starts_with(&prefix) { - &full_name[prefix.len()..] - } else { - &full_name - }; - - // Skip the prefix directory entry itself and empty paths - if relative.is_empty() { - continue; - } - - let out_path = dest.join(relative); - - // Prevent path traversal - if !out_path.starts_with(&dest) { - continue; - } - - if file.is_dir() { - fs::create_dir_all(&out_path).map_err(|err| SandboxError::StreamError { - message: format!("failed to create directory: {err}"), - })?; - } else { - if let Some(parent) = out_path.parent() { - fs::create_dir_all(parent).map_err(|err| SandboxError::StreamError { - message: format!("failed to create parent directory: {err}"), - })?; - } - let mut out_file = - fs::File::create(&out_path).map_err(|err| SandboxError::StreamError { - message: format!("failed to create file: {err}"), - })?; - std::io::copy(&mut file, &mut out_file).map_err(|err| SandboxError::StreamError { - message: format!("failed to write file: {err}"), - })?; - } - } - - Ok(dest) -} - -fn clone_or_update_repo( - url: &str, - cache_name: &str, - git_ref: Option<&str>, -) -> Result<PathBuf, SandboxError> { - let cache = skills_cache_dir()?; - let dest = cache.join(cache_name); - - if dest.join(".git").is_dir() { - // Update existing clone - let mut cmd = std::process::Command::new("git"); - cmd.arg("-C").arg(&dest).arg("pull").arg("--ff-only"); - let output = cmd.output().map_err(|err| SandboxError::StreamError { - message: format!("git pull failed: {err}"), - })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - tracing::warn!("git pull failed for {cache_name}, re-cloning: {stderr}"); - fs::remove_dir_all(&dest).map_err(|err| SandboxError::StreamError { - message: format!("failed to remove stale cache: {err}"), - })?; - return clone_or_update_repo(url, cache_name, git_ref); - } - } else { - // Fresh clone - let mut cmd = std::process::Command::new("git"); - cmd.arg("clone").arg("--depth").arg("1"); - if let Some(r) = git_ref { - cmd.arg("--branch").arg(r); - } - cmd.arg(url).arg(&dest); - let output = cmd.output().map_err(|err| SandboxError::StreamError { - message: format!("git clone failed: {err}"), - })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(SandboxError::StreamError { - message: format!("git clone failed for {url}: {stderr}"), - }); - } - } - - Ok(dest) -} - -fn resolve_skill_source(source: &SkillSource, cwd: &StdPath) -> Result<PathBuf, SandboxError> { - match source.source_type.as_str() { - "github" => { - let cache_name = source.source.replace('/', "-"); - download_github_zip(&source.source, &cache_name, source.git_ref.as_deref()) - } - "git" => { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - let mut hasher = DefaultHasher::new(); - source.source.hash(&mut hasher); - let hash = format!("{:016x}", hasher.finish()); - clone_or_update_repo(&source.source, &hash, source.git_ref.as_deref()) - } - "local" => { - let mut path = PathBuf::from(&source.source); - if path.is_relative() { - path = cwd.join(path); - } - if !path.is_dir() { - return Err(SandboxError::InvalidRequest { - message: format!("local skill directory not found: {}", path.display()), - }); - } - Ok(path) - } - other => Err(SandboxError::InvalidRequest { - message: format!("unsupported skill source type: {other}"), - }), + match state + .acp_proxy() + .post(&server_id, bootstrap_agent, payload) + .await? + { + ProxyPostOutcome::Response(value) => Ok((StatusCode::OK, Json(value)).into_response()), + ProxyPostOutcome::Accepted => Ok(StatusCode::ACCEPTED.into_response()), } } -fn discover_skills_in_dir( - base: &StdPath, - subpath: Option<&str>, -) -> Result<Vec<PathBuf>, SandboxError> { - let search_dir = if let Some(sub) = subpath { - base.join(sub) - } else { - base.to_path_buf() - }; - - if !search_dir.is_dir() { - return Err(SandboxError::InvalidRequest { - message: format!("skill search directory not found: {}", search_dir.display()), - }); - } - - let mut skills = Vec::new(); - - // Check if the search dir itself is a skill - if search_dir.join("SKILL.md").exists() { - skills.push(search_dir.clone()); - } - - // Check skills/ subdirectory - let skills_subdir = search_dir.join("skills"); - if skills_subdir.is_dir() { - if let Ok(entries) = fs::read_dir(&skills_subdir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() && path.join("SKILL.md").exists() { - skills.push(path); - } - } +#[utoipa::path( + get, + path = "/v1/acp/{server_id}", + tag = "v1", + params( + ("server_id" = String, Path, description = "Client-defined ACP server id") + ), + responses( + (status = 200, description = "SSE stream of ACP envelopes"), + (status = 406, description = "Client does not accept SSE responses", body = ProblemDetails), + (status = 404, description = "Unknown ACP server", body = ProblemDetails), + (status = 400, description = "Invalid request", body = ProblemDetails) + ) +)] +async fn get_v1_acp( + State(state): State<Arc<AppState>>, + Path(server_id): Path<String>, + headers: HeaderMap, +) -> Result<Sse<PinBoxSseStream>, ApiError> { + if !accept_allows(&headers, TEXT_EVENT_STREAM) { + return Err(SandboxError::NotAcceptable { + message: "accept must allow text/event-stream".to_string(), } + .into()); } - // Check immediate children of search dir (for repos with skills at top level) - if let Ok(entries) = fs::read_dir(&search_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() && path.join("SKILL.md").exists() && !skills.contains(&path) { - skills.push(path); - } - } - } + let last_event_id = parse_last_event_id(&headers)?; + let stream = state.acp_proxy().sse(&server_id, last_event_id).await?; - Ok(skills) -} - -fn ensure_skill_link(target: &StdPath, dest: &StdPath) -> Result<(), SandboxError> { - if dest.exists() { - if dest.is_dir() && dest.join("SKILL.md").exists() { - return Ok(()); - } - if let Ok(link_target) = fs::read_link(dest) { - if link_target == target { - return Ok(()); - } - } - return Err(SandboxError::InvalidRequest { - message: format!("skill path conflict: {} already exists", dest.display()), - }); - } - // Remove dangling symlinks (exists() follows symlinks and returns false for dangling ones) - if dest.symlink_metadata().is_ok() { - let _ = fs::remove_file(dest); - } - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent).map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - } - if let Err(err) = create_symlink_dir(target, dest) { - copy_dir_recursive(target, dest).map_err(|copy_err| SandboxError::StreamError { - message: format!("{err}; fallback copy failed: {copy_err}"), - })?; - } - Ok(()) -} - -#[cfg(unix)] -fn create_symlink_dir(target: &StdPath, dest: &StdPath) -> std::io::Result<()> { - std::os::unix::fs::symlink(target, dest) -} - -#[cfg(windows)] -fn create_symlink_dir(target: &StdPath, dest: &StdPath) -> std::io::Result<()> { - std::os::windows::fs::symlink_dir(target, dest) -} - -#[cfg(not(any(unix, windows)))] -fn create_symlink_dir(_target: &StdPath, _dest: &StdPath) -> std::io::Result<()> { - Err(std::io::Error::new( - std::io::ErrorKind::Other, - "symlinks unsupported", + Ok(Sse::new(stream).keep_alive( + KeepAlive::new() + .interval(Duration::from_secs(15)) + .text("heartbeat"), )) } -fn copy_dir_recursive(src: &StdPath, dest: &StdPath) -> std::io::Result<()> { - fs::create_dir_all(dest)?; - for entry in fs::read_dir(src)? { - let entry = entry?; - let file_type = entry.file_type()?; - let src_path = entry.path(); - let dest_path = dest.join(entry.file_name()); - if file_type.is_dir() { - copy_dir_recursive(&src_path, &dest_path)?; - } else { - fs::copy(&src_path, &dest_path)?; - } - } - Ok(()) -} - -fn write_claude_mcp_config(mcp: &BTreeMap<String, McpServerConfig>) -> Result<(), SandboxError> { - let cwd = std::env::current_dir().map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - let path = cwd.join(".mcp.json"); - let mut root = if path.exists() { - let text = fs::read_to_string(&path).map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - serde_json::from_str::<Value>(&text).map_err(|err| SandboxError::InvalidRequest { - message: format!("invalid .mcp.json: {err}"), - })? - } else { - Value::Object(Map::new()) - }; - let Some(object) = root.as_object_mut() else { - return Err(SandboxError::InvalidRequest { - message: "invalid .mcp.json: expected object".to_string(), - }); - }; - let servers = object - .entry("mcpServers") - .or_insert_with(|| Value::Object(Map::new())); - let Some(server_map) = servers.as_object_mut() else { - return Err(SandboxError::InvalidRequest { - message: "invalid .mcp.json: mcpServers must be an object".to_string(), - }); - }; - for (name, config) in mcp { - server_map.insert(name.clone(), claude_mcp_entry(config)?); - } - fs::write( - &path, - serde_json::to_string_pretty(&root).map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?, +#[utoipa::path( + delete, + path = "/v1/acp/{server_id}", + tag = "v1", + params( + ("server_id" = String, Path, description = "Client-defined ACP server id") + ), + responses( + (status = 204, description = "ACP server closed") ) - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; +)] +async fn delete_v1_acp( + State(state): State<Arc<AppState>>, + Path(server_id): Path<String>, +) -> Result<StatusCode, ApiError> { + state.acp_proxy().delete(&server_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +fn validate_named_query(value: &str, field_name: &str) -> Result<(), SandboxError> { + if value.trim().is_empty() { + return Err(SandboxError::InvalidRequest { + message: format!("missing required '{field_name}' query parameter"), + }); + } Ok(()) } -fn write_codex_mcp_config(mcp: &BTreeMap<String, McpServerConfig>) -> Result<(), SandboxError> { - let cwd = std::env::current_dir().map_err(|err| SandboxError::StreamError { +fn config_file_path(directory: &str, filename: &str) -> Result<PathBuf, SandboxError> { + if directory.trim().is_empty() { + return Err(SandboxError::InvalidRequest { + message: "missing required 'directory' query parameter".to_string(), + }); + } + + let base_dir = PathBuf::from(directory); + let root = if base_dir.is_absolute() { + base_dir + } else { + std::env::current_dir() + .map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })? + .join(base_dir) + }; + + Ok(root.join(".sandbox-agent").join("config").join(filename)) +} + +fn read_named_config_map<T>(path: &StdPath) -> Result<BTreeMap<String, T>, SandboxError> +where + T: DeserializeOwned, +{ + if !path.exists() { + return Ok(BTreeMap::new()); + } + + let text = fs::read_to_string(path).map_err(|err| SandboxError::StreamError { message: err.to_string(), })?; - let path = cwd.join(".codex").join("config.toml"); - let mut doc = if path.exists() { - let text = fs::read_to_string(&path).map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - text.parse::<DocumentMut>() - .map_err(|err| SandboxError::InvalidRequest { - message: format!("invalid Codex config.toml: {err}"), - })? - } else { - DocumentMut::new() - }; - let mcp_item = doc - .entry("mcp_servers") - .or_insert(Item::Table(Table::new())); - let mcp_table = mcp_item - .as_table_mut() - .ok_or_else(|| SandboxError::InvalidRequest { - message: "invalid Codex config.toml: mcp_servers must be a table".to_string(), - })?; - for (name, config) in mcp { - let table = codex_mcp_table(config)?; - mcp_table.insert(name, Item::Table(table)); + + if text.trim().is_empty() { + return Ok(BTreeMap::new()); } + + serde_json::from_str::<BTreeMap<String, T>>(&text).map_err(|err| SandboxError::InvalidRequest { + message: format!("invalid config file {}: {err}", path.display()), + }) +} + +fn write_named_config_map<T>( + path: &StdPath, + values: &BTreeMap<String, T>, +) -> Result<(), SandboxError> +where + T: Serialize, +{ if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|err| SandboxError::StreamError { message: err.to_string(), })?; } - fs::write(&path, doc.to_string()).map_err(|err| SandboxError::StreamError { + + let body = serde_json::to_string_pretty(values).map_err(|err| SandboxError::StreamError { message: err.to_string(), })?; - Ok(()) -} -fn apply_amp_mcp_config( - agent_manager: &AgentManager, - mcp: &BTreeMap<String, McpServerConfig>, -) -> Result<(), SandboxError> { - let path = agent_manager.resolve_binary(AgentId::Amp).map_err(|_| { - SandboxError::AgentNotInstalled { - agent: "amp".to_string(), - } - })?; - let cwd = std::env::current_dir().map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - for (name, config) in mcp { - let mut cmd = Command::new(&path); - cmd.current_dir(&cwd); - cmd.arg("mcp").arg("add").arg(name); - match config { - McpServerConfig::Local { command, args, .. } => { - let (cmd_name, cmd_args) = mcp_command_parts(command, args)?; - cmd.arg("--").arg(cmd_name).args(cmd_args); - } - McpServerConfig::Remote { - url, - headers, - bearer_token_env_var, - env_headers, - .. - } => { - let merged = merged_headers( - headers.as_ref(), - bearer_token_env_var.as_ref(), - env_headers.as_ref(), - ); - for (key, value) in merged { - cmd.arg("--header").arg(format!("{key}: {value}")); - } - cmd.arg(url); - } - } - let output = cmd.output().map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - if !output.status.success() { - return Err(SandboxError::StreamError { - message: format!("amp mcp add failed for {name}: {}", output.status), - }); - } - } - Ok(()) -} - -fn opencode_mcp_config(config: &McpServerConfig) -> Result<Value, SandboxError> { - match config { - McpServerConfig::Local { - command, - args, - env, - enabled, - timeout_ms, - .. - } => { - let (cmd_name, cmd_args) = mcp_command_parts(command, args)?; - let mut map = Map::new(); - map.insert("type".to_string(), Value::String("local".to_string())); - let mut command_parts = vec![Value::String(cmd_name)]; - command_parts.extend(cmd_args.into_iter().map(Value::String)); - map.insert("command".to_string(), Value::Array(command_parts)); - if let Some(env) = env { - map.insert("environment".to_string(), json!(env)); - } - if let Some(enabled) = enabled { - map.insert("enabled".to_string(), Value::Bool(*enabled)); - } - if let Some(timeout) = timeout_ms { - map.insert( - "timeout".to_string(), - Value::Number(serde_json::Number::from(*timeout)), - ); - } - Ok(Value::Object(map)) - } - McpServerConfig::Remote { - url, - headers, - bearer_token_env_var, - env_headers, - oauth, - enabled, - timeout_ms, - .. - } => { - let mut map = Map::new(); - map.insert("type".to_string(), Value::String("remote".to_string())); - map.insert("url".to_string(), Value::String(url.clone())); - let merged = merged_headers( - headers.as_ref(), - bearer_token_env_var.as_ref(), - env_headers.as_ref(), - ); - if !merged.is_empty() { - map.insert("headers".to_string(), json!(merged)); - } - if let Some(oauth) = oauth { - map.insert( - "oauth".to_string(), - serde_json::to_value(oauth).map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?, - ); - } - if let Some(enabled) = enabled { - map.insert("enabled".to_string(), Value::Bool(*enabled)); - } - if let Some(timeout) = timeout_ms { - map.insert( - "timeout".to_string(), - Value::Number(serde_json::Number::from(*timeout)), - ); - } - Ok(Value::Object(map)) - } - } -} - -fn claude_mcp_entry(config: &McpServerConfig) -> Result<Value, SandboxError> { - match config { - McpServerConfig::Local { - command, args, env, .. - } => { - let (cmd_name, cmd_args) = mcp_command_parts(command, args)?; - let mut map = Map::new(); - map.insert("command".to_string(), Value::String(cmd_name)); - if !cmd_args.is_empty() { - map.insert( - "args".to_string(), - Value::Array(cmd_args.into_iter().map(Value::String).collect()), - ); - } - if let Some(env) = env { - map.insert("env".to_string(), json!(env)); - } - Ok(Value::Object(map)) - } - McpServerConfig::Remote { - url, - headers, - bearer_token_env_var, - env_headers, - transport, - .. - } => { - let mut map = Map::new(); - let transport = transport.clone().unwrap_or(McpRemoteTransport::Http); - map.insert( - "type".to_string(), - Value::String( - match transport { - McpRemoteTransport::Http => "http", - McpRemoteTransport::Sse => "sse", - } - .to_string(), - ), - ); - map.insert("url".to_string(), Value::String(url.clone())); - let merged = merged_headers( - headers.as_ref(), - bearer_token_env_var.as_ref(), - env_headers.as_ref(), - ); - if !merged.is_empty() { - map.insert("headers".to_string(), json!(merged)); - } - Ok(Value::Object(map)) - } - } -} - -fn codex_mcp_table(config: &McpServerConfig) -> Result<Table, SandboxError> { - let mut table = Table::new(); - match config { - McpServerConfig::Local { - command, - args, - env, - enabled, - timeout_ms, - .. - } => { - let (cmd_name, cmd_args) = mcp_command_parts(command, args)?; - table.insert("command", value(cmd_name)); - if !cmd_args.is_empty() { - let mut array = Array::new(); - for arg in cmd_args { - array.push(arg); - } - table.insert("args", value(array)); - } - if let Some(env) = env { - let mut env_table = Table::new(); - for (key, val) in env { - env_table.insert(key, value(val.clone())); - } - table.insert("env", Item::Table(env_table)); - } - if let Some(enabled) = enabled { - table.insert("enabled", value(*enabled)); - } - if let Some(timeout) = timeout_ms { - let seconds = (*timeout + 999) / 1000; - table.insert("tool_timeout_sec", value(seconds as i64)); - } - } - McpServerConfig::Remote { - url, - headers, - bearer_token_env_var, - env_headers, - enabled, - timeout_ms, - .. - } => { - table.insert("url", value(url.clone())); - if let Some(headers) = headers { - let mut header_table = Table::new(); - for (key, val) in headers { - header_table.insert(key, value(val.clone())); - } - table.insert("http_headers", Item::Table(header_table)); - } - if let Some(env_headers) = env_headers { - let mut header_table = Table::new(); - for (key, val) in env_headers { - header_table.insert(key, value(val.clone())); - } - table.insert("env_http_headers", Item::Table(header_table)); - } - if let Some(bearer) = bearer_token_env_var { - table.insert("bearer_token_env_var", value(bearer.clone())); - } - if let Some(enabled) = enabled { - table.insert("enabled", value(*enabled)); - } - if let Some(timeout) = timeout_ms { - let seconds = (*timeout + 999) / 1000; - table.insert("tool_timeout_sec", value(seconds as i64)); - } - } - } - Ok(table) -} - -fn mcp_command_parts( - command: &McpCommand, - args: &[String], -) -> Result<(String, Vec<String>), SandboxError> { - match command { - McpCommand::Command(value) => Ok((value.clone(), args.to_vec())), - McpCommand::CommandWithArgs(values) => { - if values.is_empty() { - return Err(SandboxError::InvalidRequest { - message: "mcp command cannot be empty".to_string(), - }); - } - let mut iter = values.iter(); - let cmd = iter.next().map(|value| value.to_string()).ok_or_else(|| { - SandboxError::InvalidRequest { - message: "mcp command cannot be empty".to_string(), - } - })?; - let mut cmd_args = iter.map(|value| value.to_string()).collect::<Vec<_>>(); - cmd_args.extend(args.iter().cloned()); - Ok((cmd, cmd_args)) - } - } -} - -fn merged_headers( - headers: Option<&BTreeMap<String, String>>, - bearer_token_env_var: Option<&String>, - env_headers: Option<&BTreeMap<String, String>>, -) -> BTreeMap<String, String> { - let mut merged = headers.cloned().unwrap_or_default(); - if let Some(env_var) = bearer_token_env_var { - merged - .entry("Authorization".to_string()) - .or_insert_with(|| format!("Bearer ${env_var}")); - } - if let Some(env_headers) = env_headers { - for (key, value) in env_headers { - merged - .entry(key.clone()) - .or_insert_with(|| format!("${value}")); - } - } - merged -} - -async fn resolve_fs_path( - state: &Arc<AppState>, - session_id: Option<&str>, - raw_path: &str, -) -> Result<PathBuf, SandboxError> { - let path = PathBuf::from(raw_path); - if path.is_absolute() { - return Ok(path); - } - let root = resolve_fs_root(state, session_id).await?; - let relative = sanitize_relative_path(&path)?; - Ok(root.join(relative)) -} - -async fn resolve_fs_root( - state: &Arc<AppState>, - session_id: Option<&str>, -) -> Result<PathBuf, SandboxError> { - if let Some(session_id) = session_id { - return state.session_manager.session_working_dir(session_id).await; - } - let home = dirs::home_dir().ok_or_else(|| SandboxError::InvalidRequest { - message: "home directory unavailable".to_string(), - })?; - Ok(home) -} - -fn sanitize_relative_path(path: &StdPath) -> Result<PathBuf, SandboxError> { - use std::path::Component; - let mut sanitized = PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::Normal(value) => sanitized.push(value), - Component::ParentDir | Component::RootDir | Component::Prefix(_) => { - return Err(SandboxError::InvalidRequest { - message: format!("invalid relative path: {}", path.display()), - }); - } - } - } - Ok(sanitized) -} - -fn map_fs_error(path: &StdPath, err: std::io::Error) -> SandboxError { - if err.kind() == std::io::ErrorKind::NotFound { - SandboxError::InvalidRequest { - message: format!("path not found: {}", path.display()), - } - } else { - SandboxError::StreamError { - message: err.to_string(), - } - } -} - -fn format_message_with_attachments(message: &str, attachments: &[MessageAttachment]) -> String { - if attachments.is_empty() { - return message.to_string(); - } - let mut combined = String::new(); - combined.push_str(message); - combined.push_str("\n\nAttachments:\n"); - for attachment in attachments { - combined.push_str("- "); - combined.push_str(&attachment.path); - combined.push('\n'); - } - combined -} - -fn opencode_file_part_input(attachment: &MessageAttachment) -> Value { - let path = attachment.path.as_str(); - let url = if path.starts_with("file://") { - path.to_string() - } else { - format!("file://{path}") - }; - let filename = attachment.filename.clone().or_else(|| { - let clean = path.strip_prefix("file://").unwrap_or(path); - StdPath::new(clean) - .file_name() - .map(|name| name.to_string_lossy().to_string()) - }); - let mut map = serde_json::Map::new(); - map.insert("type".to_string(), json!("file")); - map.insert( - "mime".to_string(), - json!(attachment - .mime - .clone() - .unwrap_or_else(|| "application/octet-stream".to_string())), - ); - map.insert("url".to_string(), json!(url)); - if let Some(filename) = filename { - map.insert("filename".to_string(), json!(filename)); - } - Value::Object(map) -} - -fn claude_input_session_id(session: &SessionSnapshot) -> String { - session - .native_session_id - .clone() - .unwrap_or_else(|| session.session_id.clone()) -} - -fn claude_user_message_line(session: &SessionSnapshot, message: &str) -> String { - let session_id = claude_input_session_id(session); - serde_json::json!({ - "type": "user", - "message": { - "role": "user", - "content": message, - }, - "parent_tool_use_id": null, - "session_id": session_id, - }) - .to_string() -} - -fn claude_tool_result_line( - session_id: &str, - tool_use_id: &str, - content: &str, - is_error: bool, -) -> String { - serde_json::json!({ - "type": "user", - "message": { - "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": tool_use_id, - "content": content, - "is_error": is_error, - }], - }, - "parent_tool_use_id": null, - "session_id": session_id, - }) - .to_string() -} - -fn claude_control_response_line(request_id: &str, behavior: &str, response: Value) -> String { - let mut response_obj = serde_json::Map::new(); - response_obj.insert("behavior".to_string(), Value::String(behavior.to_string())); - if let Some(message) = response.get("message") { - response_obj.insert("message".to_string(), message.clone()); - } - if let Some(updated_input) = response.get("updatedInput") { - response_obj.insert("updatedInput".to_string(), updated_input.clone()); - } - if let Some(updated_permissions) = response.get("updatedPermissions") { - response_obj.insert( - "updatedPermissions".to_string(), - updated_permissions.clone(), - ); - } - if let Some(interrupt) = response.get("interrupt") { - response_obj.insert("interrupt".to_string(), interrupt.clone()); - } - - serde_json::json!({ - "type": "control_response", - "response": { - "subtype": "success", - "request_id": request_id, - "response": Value::Object(response_obj), - } - }) - .to_string() -} - -/// Returns true if the given action name corresponds to a question tool -/// (AskUserQuestion or ExitPlanMode in any casing convention). -pub(crate) fn is_question_tool_action(action: &str) -> bool { - matches!( - action, - "AskUserQuestion" - | "ask_user_question" - | "askUserQuestion" - | "ask-user-question" - | "ExitPlanMode" - | "exit_plan_mode" - | "exitPlanMode" - | "exit-plan-mode" - ) -} - -fn is_file_change_action(action: &str) -> bool { - matches!(action, "fileChange" | "file_change" | "file-change") - || action.eq_ignore_ascii_case("filechange") -} - -fn permission_cache_keys(action: &str, metadata: &Option<Value>) -> Vec<String> { - let mut keys = Vec::new(); - push_permission_cache_key(&mut keys, action); - if let Some(metadata) = metadata.as_ref().and_then(Value::as_object) { - if let Some(permission) = metadata.get("permission").and_then(Value::as_str) { - push_permission_cache_key(&mut keys, permission); - } - if let Some(kind) = metadata.get("codexRequestKind").and_then(Value::as_str) { - push_permission_cache_key(&mut keys, kind); - } - if let Some(tool_name) = metadata - .get("toolName") - .or_else(|| metadata.get("tool_name")) - .and_then(Value::as_str) - { - push_permission_cache_key(&mut keys, tool_name); - } - } - keys.sort_unstable(); - keys.dedup(); - keys -} - -fn push_permission_cache_key(keys: &mut Vec<String>, raw: &str) { - let raw = raw.trim(); - if raw.is_empty() { - return; - } - keys.push(raw.to_string()); - if let Some(category) = permission_action_category(raw) { - keys.push(category); - } -} - -fn permission_action_category(action: &str) -> Option<String> { - let first = action.split_whitespace().next().unwrap_or(action); - let stripped = first - .split_once(':') - .map(|(prefix, _)| prefix) - .unwrap_or(first) - .trim(); - if stripped.is_empty() { - return None; - } - if stripped - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.') - { - Some(stripped.to_ascii_lowercase()) - } else { - None - } -} - -fn read_lines<R: std::io::Read>(reader: R, sender: mpsc::UnboundedSender<String>) { - let mut reader = BufReader::new(reader); - let mut line = String::new(); - loop { - line.clear(); - match reader.read_line(&mut line) { - Ok(0) => break, - Ok(_) => { - let trimmed = line.trim_end_matches(&['\r', '\n'][..]).to_string(); - if sender.send(trimmed).is_err() { - break; - } - } - Err(_) => break, - } - } -} - -fn write_lines(mut stdin: std::process::ChildStdin, mut receiver: mpsc::UnboundedReceiver<String>) { - while let Some(line) = receiver.blocking_recv() { - if writeln!(stdin, "{line}").is_err() { - break; - } - if stdin.flush().is_err() { - break; - } - } -} - -#[derive(Default)] -struct CodexLineOutcome { - conversions: Vec<EventConversion>, - should_terminate: bool, -} - -struct CodexAppServerState { - init_id: Option<String>, - thread_start_id: Option<String>, - init_done: bool, - thread_start_sent: bool, - turn_start_sent: bool, - thread_id: Option<String>, - next_id: i64, - prompt: String, - model: Option<String>, - cwd: Option<String>, - approval_policy: Option<codex_schema::AskForApproval>, - sandbox_mode: Option<codex_schema::SandboxMode>, - sandbox_policy: Option<codex_schema::SandboxPolicy>, - sender: Option<mpsc::UnboundedSender<String>>, -} - -impl CodexAppServerState { - fn new(options: SpawnOptions) -> Self { - let prompt = codex_prompt_for_mode(&options.prompt, options.agent_mode.as_deref()); - let cwd = options - .working_dir - .as_ref() - .map(|path| path.to_string_lossy().to_string()); - Self { - init_id: None, - thread_start_id: None, - init_done: false, - thread_start_sent: false, - turn_start_sent: false, - thread_id: None, - next_id: 1, - prompt, - model: options.model.clone(), - cwd, - approval_policy: codex_approval_policy(options.permission_mode.as_deref()), - sandbox_mode: codex_sandbox_mode(options.permission_mode.as_deref()), - sandbox_policy: codex_sandbox_policy(options.permission_mode.as_deref()), - sender: None, - } - } - - fn start(&mut self, sender: &mpsc::UnboundedSender<String>) { - self.sender = Some(sender.clone()); - let request_id = self.next_request_id(); - self.init_id = Some(request_id.to_string()); - let request = codex_schema::ClientRequest::Initialize { - id: request_id, - params: codex_schema::InitializeParams { - client_info: codex_schema::ClientInfo { - name: "sandbox-agent".to_string(), - title: Some("sandbox-agent".to_string()), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - }, - }; - self.send_json(&request); - } - - fn handle_line(&mut self, line: &str) -> CodexLineOutcome { - let trimmed = line.trim(); - if trimmed.is_empty() { - return CodexLineOutcome::default(); - } - let value: Value = match serde_json::from_str(trimmed) { - Ok(value) => value, - Err(err) => { - return CodexLineOutcome { - conversions: vec![agent_unparsed( - "codex", - &err.to_string(), - Value::String(trimmed.to_string()), - )], - should_terminate: false, - }; - } - }; - let message: codex_schema::JsonrpcMessage = match serde_json::from_value(value.clone()) { - Ok(message) => message, - Err(err) => { - return CodexLineOutcome { - conversions: vec![agent_unparsed("codex", &err.to_string(), value)], - should_terminate: false, - }; - } - }; - - match message { - codex_schema::JsonrpcMessage::Response(response) => { - self.handle_response(&response); - CodexLineOutcome::default() - } - codex_schema::JsonrpcMessage::Notification(_) => { - if let Ok(notification) = - serde_json::from_value::<codex_schema::ServerNotification>(value.clone()) - { - self.maybe_capture_thread_id(¬ification); - let should_terminate = matches!( - notification, - codex_schema::ServerNotification::TurnCompleted(_) - | codex_schema::ServerNotification::Error(_) - ); - if codex_should_emit_notification(¬ification) { - match convert_codex::notification_to_universal(¬ification) { - Ok(conversions) => CodexLineOutcome { - conversions, - should_terminate, - }, - Err(err) => CodexLineOutcome { - conversions: vec![agent_unparsed("codex", &err, value)], - should_terminate, - }, - } - } else { - CodexLineOutcome { - conversions: Vec::new(), - should_terminate, - } - } - } else { - CodexLineOutcome { - conversions: vec![agent_unparsed("codex", "invalid notification", value)], - should_terminate: false, - } - } - } - codex_schema::JsonrpcMessage::Request(_) => { - if let Ok(request) = - serde_json::from_value::<codex_schema::ServerRequest>(value.clone()) - { - match codex_request_to_universal(&request) { - Ok(mut conversions) => { - for conversion in &mut conversions { - conversion.raw = Some(value.clone()); - } - CodexLineOutcome { - conversions, - should_terminate: false, - } - } - Err(err) => CodexLineOutcome { - conversions: vec![agent_unparsed("codex", &err, value)], - should_terminate: false, - }, - } - } else { - CodexLineOutcome { - conversions: vec![agent_unparsed("codex", "invalid request", value)], - should_terminate: false, - } - } - } - codex_schema::JsonrpcMessage::Error(error) => CodexLineOutcome { - conversions: vec![codex_rpc_error_to_universal(&error)], - should_terminate: true, - }, - } - } - - fn handle_response(&mut self, response: &codex_schema::JsonrpcResponse) { - let response_id = response.id.to_string(); - if !self.init_done { - if self.init_id.as_ref().is_some_and(|id| id == &response_id) { - self.init_done = true; - self.send_initialized(); - self.send_thread_start(); - } - return; - } - if self.thread_id.is_none() - && self - .thread_start_id - .as_ref() - .is_some_and(|id| id == &response_id) - { - self.send_turn_start(); - } - } - - fn maybe_capture_thread_id(&mut self, notification: &codex_schema::ServerNotification) { - if self.thread_id.is_some() { - return; - } - let thread_id = match notification { - codex_schema::ServerNotification::ThreadStarted(params) => { - Some(params.thread.id.clone()) - } - codex_schema::ServerNotification::TurnStarted(params) => Some(params.thread_id.clone()), - codex_schema::ServerNotification::TurnCompleted(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemStarted(params) => Some(params.thread_id.clone()), - codex_schema::ServerNotification::ItemCompleted(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemAgentMessageDelta(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemReasoningTextDelta(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemReasoningSummaryTextDelta(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemCommandExecutionOutputDelta(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemFileChangeOutputDelta(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemMcpToolCallProgress(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ThreadTokenUsageUpdated(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::TurnDiffUpdated(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::TurnPlanUpdated(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemCommandExecutionTerminalInteraction(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemReasoningSummaryPartAdded(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ThreadCompacted(params) => { - Some(params.thread_id.clone()) - } - _ => None, - }; - if let Some(thread_id) = thread_id { - self.thread_id = Some(thread_id); - self.send_turn_start(); - } - } - - fn send_initialized(&self) { - let notification = codex_schema::JsonrpcNotification { - method: "initialized".to_string(), - params: None, - }; - self.send_json(¬ification); - } - - fn send_thread_start(&mut self) { - if self.thread_start_sent { - return; - } - let request_id = self.next_request_id(); - self.thread_start_id = Some(request_id.to_string()); - let mut params = codex_schema::ThreadStartParams::default(); - params.approval_policy = self.approval_policy; - params.sandbox = self.sandbox_mode; - params.model = self.model.clone(); - params.cwd = self.cwd.clone(); - let request = codex_schema::ClientRequest::ThreadStart { - id: request_id, - params, - }; - self.thread_start_sent = true; - self.send_json(&request); - } - - fn send_turn_start(&mut self) { - if self.turn_start_sent { - return; - } - let thread_id = match self.thread_id.clone() { - Some(thread_id) => thread_id, - None => return, - }; - let request_id = self.next_request_id(); - let params = codex_schema::TurnStartParams { - approval_policy: self.approval_policy, - collaboration_mode: None, - cwd: self.cwd.clone(), - effort: None, - input: vec![codex_schema::UserInput::Text { - text: self.prompt.clone(), - text_elements: Vec::new(), - }], - model: self.model.clone(), - output_schema: None, - sandbox_policy: self.sandbox_policy.clone(), - summary: None, - thread_id, - }; - let request = codex_schema::ClientRequest::TurnStart { - id: request_id, - params, - }; - self.turn_start_sent = true; - self.send_json(&request); - } - - fn next_request_id(&mut self) -> codex_schema::RequestId { - let id = self.next_id; - self.next_id += 1; - codex_schema::RequestId::from(id) - } - - fn send_json<T: Serialize>(&self, payload: &T) { - let Some(sender) = self.sender.as_ref() else { - return; - }; - let Ok(line) = serde_json::to_string(payload) else { - return; - }; - let _ = sender.send(line); - } -} - -fn codex_prompt_for_mode(prompt: &str, mode: Option<&str>) -> String { - match mode { - Some("plan") => format!("Make a plan before acting.\n\n{prompt}"), - _ => prompt.to_string(), - } -} - -fn codex_approval_policy(mode: Option<&str>) -> Option<codex_schema::AskForApproval> { - match mode { - Some("plan") => Some(codex_schema::AskForApproval::Untrusted), - Some("bypass") => Some(codex_schema::AskForApproval::Never), - _ => None, - } -} - -fn codex_sandbox_mode(mode: Option<&str>) -> Option<codex_schema::SandboxMode> { - match mode { - Some("plan") => Some(codex_schema::SandboxMode::ReadOnly), - Some("bypass") => Some(codex_schema::SandboxMode::DangerFullAccess), - _ => None, - } -} - -fn codex_sandbox_policy(mode: Option<&str>) -> Option<codex_schema::SandboxPolicy> { - match mode { - Some("plan") => Some(codex_schema::SandboxPolicy::ReadOnly), - Some("bypass") => Some(codex_schema::SandboxPolicy::DangerFullAccess), - _ => None, - } -} - -fn codex_should_emit_notification(notification: &codex_schema::ServerNotification) -> bool { - let _ = notification; - true -} - -/// Extracts thread_id from a Codex server notification. -fn codex_thread_id_from_server_notification( - notification: &codex_schema::ServerNotification, -) -> Option<String> { - match notification { - codex_schema::ServerNotification::ThreadStarted(params) => Some(params.thread.id.clone()), - codex_schema::ServerNotification::TurnStarted(params) => Some(params.thread_id.clone()), - codex_schema::ServerNotification::TurnCompleted(params) => Some(params.thread_id.clone()), - codex_schema::ServerNotification::ItemStarted(params) => Some(params.thread_id.clone()), - codex_schema::ServerNotification::ItemCompleted(params) => Some(params.thread_id.clone()), - codex_schema::ServerNotification::ItemAgentMessageDelta(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemReasoningTextDelta(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemReasoningSummaryTextDelta(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemCommandExecutionOutputDelta(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemFileChangeOutputDelta(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemMcpToolCallProgress(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ThreadTokenUsageUpdated(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::TurnDiffUpdated(params) => Some(params.thread_id.clone()), - codex_schema::ServerNotification::TurnPlanUpdated(params) => Some(params.thread_id.clone()), - codex_schema::ServerNotification::ItemCommandExecutionTerminalInteraction(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ItemReasoningSummaryPartAdded(params) => { - Some(params.thread_id.clone()) - } - codex_schema::ServerNotification::ThreadCompacted(params) => Some(params.thread_id.clone()), - _ => None, - } -} - -/// Extracts thread_id from a Codex server request. -fn codex_thread_id_from_server_request(request: &codex_schema::ServerRequest) -> Option<String> { - match request { - codex_schema::ServerRequest::ItemCommandExecutionRequestApproval { params, .. } => { - Some(params.thread_id.clone()) - } - codex_schema::ServerRequest::ItemFileChangeRequestApproval { params, .. } => { - Some(params.thread_id.clone()) - } - _ => None, - } -} - -fn codex_request_to_universal( - request: &codex_schema::ServerRequest, -) -> Result<Vec<EventConversion>, String> { - match request { - codex_schema::ServerRequest::ItemCommandExecutionRequestApproval { id, params } => { - let mut metadata = serde_json::Map::new(); - metadata.insert( - "codexRequestKind".to_string(), - Value::String("commandExecution".to_string()), - ); - metadata.insert( - "codexRequestId".to_string(), - serde_json::to_value(id).unwrap_or(Value::Null), - ); - metadata.insert( - "threadId".to_string(), - Value::String(params.thread_id.clone()), - ); - metadata.insert("turnId".to_string(), Value::String(params.turn_id.clone())); - metadata.insert("itemId".to_string(), Value::String(params.item_id.clone())); - if let Some(command) = params.command.as_ref() { - metadata.insert("command".to_string(), Value::String(command.clone())); - } - if let Some(reason) = params.reason.as_ref() { - metadata.insert("reason".to_string(), Value::String(reason.clone())); - } - let permission = PermissionEventData { - permission_id: id.to_string(), - action: "commandExecution".to_string(), - status: PermissionStatus::Requested, - metadata: Some(Value::Object(metadata)), - }; - Ok(vec![EventConversion::new( - UniversalEventType::PermissionRequested, - UniversalEventData::Permission(permission), - ) - .with_native_session(Some(params.thread_id.clone()))]) - } - codex_schema::ServerRequest::ItemFileChangeRequestApproval { id, params } => { - let mut metadata = serde_json::Map::new(); - metadata.insert( - "codexRequestKind".to_string(), - Value::String("fileChange".to_string()), - ); - metadata.insert( - "codexRequestId".to_string(), - serde_json::to_value(id).unwrap_or(Value::Null), - ); - metadata.insert( - "threadId".to_string(), - Value::String(params.thread_id.clone()), - ); - metadata.insert("turnId".to_string(), Value::String(params.turn_id.clone())); - metadata.insert("itemId".to_string(), Value::String(params.item_id.clone())); - if let Some(reason) = params.reason.as_ref() { - metadata.insert("reason".to_string(), Value::String(reason.clone())); - } - if let Some(grant_root) = params.grant_root.as_ref() { - metadata.insert("grantRoot".to_string(), Value::String(grant_root.clone())); - } - let permission = PermissionEventData { - permission_id: id.to_string(), - action: "fileChange".to_string(), - status: PermissionStatus::Requested, - metadata: Some(Value::Object(metadata)), - }; - Ok(vec![EventConversion::new( - UniversalEventType::PermissionRequested, - UniversalEventData::Permission(permission), - ) - .with_native_session(Some(params.thread_id.clone()))]) - } - _ => Err("unsupported codex request".to_string()), - } -} - -fn codex_rpc_error_to_universal(error: &codex_schema::JsonrpcError) -> EventConversion { - let data = ErrorData { - message: error.error.message.clone(), - code: Some("jsonrpc.error".to_string()), - details: serde_json::to_value(error).ok(), - }; - EventConversion::new(UniversalEventType::Error, UniversalEventData::Error(data)) -} - -fn codex_request_error_to_sandbox( - context: &str, - error: &codex_schema::JsonrpcErrorError, -) -> SandboxError { - SandboxError::StreamError { - message: format!("{context}: {} (code {})", error.message, error.code), - } -} - -fn codex_model_unavailable_status_event( - native_session_id: Option<String>, - model_id: &str, -) -> EventConversion { - EventConversion::new( - UniversalEventType::ItemCompleted, - UniversalEventData::Item(ItemEventData { - item: UniversalItem { - item_id: String::new(), - native_item_id: None, - parent_id: None, - kind: ItemKind::Status, - role: Some(ItemRole::System), - content: vec![ContentPart::Status { - label: "codex.model.unavailable".to_string(), - detail: Some(format!( - "Model '{}' was rejected by provider; falling back to default for this session.", - model_id - )), - }], - status: ItemStatus::Completed, - }, - }), - ) - .synthetic() - .with_native_session(native_session_id) -} - -fn codex_unavailable_model_from_message(message: &str) -> Option<String> { - let normalized = message.to_ascii_lowercase(); - if !normalized.contains("model") { - return None; - } - let is_known_unavailable_shape = normalized.contains("does not exist") - || normalized.contains("model_not_found") - || normalized.contains("requested model") - || normalized.contains("not supported when using codex with a chatgpt account"); - if !is_known_unavailable_shape { - return None; - } - for token in extract_quoted_tokens(message, '\'') - .into_iter() - .chain(extract_quoted_tokens(message, '"').into_iter()) - { - if is_likely_model_id(token) { - return Some(token.to_string()); - } - } - None -} - -fn codex_unavailable_model_from_rpc_error( - error: &codex_schema::JsonrpcErrorError, -) -> Option<String> { - codex_unavailable_model_from_message(&error.message).or_else(|| { - error - .data - .as_ref() - .and_then(|data| codex_unavailable_model_from_message(&data.to_string())) - }) -} - -fn extract_quoted_tokens<'a>(message: &'a str, quote: char) -> Vec<&'a str> { - let mut out = Vec::new(); - let mut start: Option<usize> = None; - for (idx, ch) in message.char_indices() { - if ch != quote { - continue; - } - if let Some(open) = start.take() { - if open < idx { - out.push(&message[open..idx]); - } - } else { - start = Some(idx + ch.len_utf8()); - } - } - out -} - -fn is_likely_model_id(candidate: &str) -> bool { - if candidate.len() < 3 || candidate.len() > 128 { - return false; - } - if candidate.chars().any(|ch| ch.is_whitespace()) { - return false; - } - if !candidate - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) - { - return false; - } - candidate.contains('-') -} - -fn codex_permission_response_line( - permission_id: &str, - pending: &PendingPermission, - reply: PermissionReply, -) -> Result<String, SandboxError> { - let metadata = pending.metadata.clone().unwrap_or(Value::Null); - let request_id = codex_request_id_from_metadata(&metadata) - .or_else(|| codex_request_id_from_string(permission_id)) - .ok_or_else(|| SandboxError::InvalidRequest { - message: "invalid codex permission request id".to_string(), - })?; - let request_kind = metadata - .get("codexRequestKind") - .and_then(Value::as_str) - .unwrap_or(""); - let response_value = match request_kind { - "commandExecution" => { - let decision = codex_command_decision_for_reply(reply); - let response = codex_schema::CommandExecutionRequestApprovalResponse { decision }; - serde_json::to_value(response).map_err(|err| SandboxError::InvalidRequest { - message: err.to_string(), - })? - } - "fileChange" => { - let decision = codex_file_change_decision_for_reply(reply); - let response = codex_schema::FileChangeRequestApprovalResponse { decision }; - serde_json::to_value(response).map_err(|err| SandboxError::InvalidRequest { - message: err.to_string(), - })? - } - _ => { - return Err(SandboxError::InvalidRequest { - message: "unsupported codex permission request".to_string(), - }); - } - }; - let response = codex_schema::JsonrpcResponse { - id: request_id, - result: response_value, - }; - serde_json::to_string(&response).map_err(|err| SandboxError::InvalidRequest { + fs::write(path, body).map_err(|err| SandboxError::StreamError { message: err.to_string(), }) } -fn codex_request_id_from_metadata(metadata: &Value) -> Option<codex_schema::RequestId> { - let metadata = metadata.as_object()?; - let value = metadata.get("codexRequestId")?; - codex_request_id_from_value(value) -} - -fn codex_request_id_from_string(value: &str) -> Option<codex_schema::RequestId> { - if let Ok(number) = value.parse::<i64>() { - return Some(codex_schema::RequestId::from(number)); - } - Some(codex_schema::RequestId::Variant0(value.to_string())) -} - -fn codex_request_id_from_value(value: &Value) -> Option<codex_schema::RequestId> { - match value { - Value::String(value) => Some(codex_schema::RequestId::Variant0(value.clone())), - Value::Number(value) => value.as_i64().map(codex_schema::RequestId::from), - _ => None, - } -} - -/// Extracts i64 from a RequestId (for matching request/response pairs). -fn codex_request_id_to_i64(id: &codex_schema::RequestId) -> Option<i64> { - match id { - codex_schema::RequestId::Variant1(n) => Some(*n), - codex_schema::RequestId::Variant0(s) => s.parse().ok(), - } -} - -fn codex_command_decision_for_reply( - reply: PermissionReply, -) -> codex_schema::CommandExecutionApprovalDecision { - match reply { - PermissionReply::Once => codex_schema::CommandExecutionApprovalDecision::Accept, - PermissionReply::Always => codex_schema::CommandExecutionApprovalDecision::AcceptForSession, - PermissionReply::Reject => codex_schema::CommandExecutionApprovalDecision::Decline, - } -} - -fn codex_file_change_decision_for_reply( - reply: PermissionReply, -) -> codex_schema::FileChangeApprovalDecision { - match reply { - PermissionReply::Once => codex_schema::FileChangeApprovalDecision::Accept, - PermissionReply::Always => codex_schema::FileChangeApprovalDecision::AcceptForSession, - PermissionReply::Reject => codex_schema::FileChangeApprovalDecision::Decline, - } -} - -fn parse_agent_line(agent: AgentId, line: &str, session_id: &str) -> Vec<EventConversion> { - let trimmed = line.trim(); - if trimmed.is_empty() { - return Vec::new(); - } - let value: Value = match serde_json::from_str(trimmed) { - Ok(value) => value, - Err(err) => { - return vec![agent_unparsed( - agent.as_str(), - &err.to_string(), - Value::String(trimmed.to_string()), - )]; - } - }; - match agent { - AgentId::Claude => { - convert_claude::event_to_universal_with_session(&value, session_id.to_string()) - .unwrap_or_else(|err| vec![agent_unparsed("claude", &err, value)]) - } - AgentId::Codex => match serde_json::from_value(value.clone()) { - Ok(notification) => convert_codex::notification_to_universal(¬ification) - .unwrap_or_else(|err| vec![agent_unparsed("codex", &err, value)]), - Err(err) => vec![agent_unparsed("codex", &err.to_string(), value)], - }, - AgentId::Opencode => match serde_json::from_value(value.clone()) { - Ok(event) => convert_opencode::event_to_universal(&event) - .unwrap_or_else(|err| vec![agent_unparsed("opencode", &err, value)]), - Err(err) => vec![agent_unparsed("opencode", &err.to_string(), value)], - }, - AgentId::Amp => match serde_json::from_value(value.clone()) { - Ok(event) => convert_amp::event_to_universal(&event) - .unwrap_or_else(|err| vec![agent_unparsed("amp", &err, value)]), - Err(err) => vec![agent_unparsed("amp", &err.to_string(), value)], - }, - AgentId::Pi => match serde_json::from_value(value.clone()) { - Ok(event) => convert_pi::event_to_universal(&event) - .unwrap_or_else(|err| vec![agent_unparsed("pi", &err, value)]), - Err(err) => vec![agent_unparsed("pi", &err.to_string(), value)], - }, - AgentId::Cursor => vec![agent_unparsed( - "cursor", - "cursor agent does not parse streaming output", - value, - )], - AgentId::Mock => vec![agent_unparsed( - "mock", - "mock agent does not parse streaming output", - value, - )], - } -} - -fn opencode_event_matches_session(value: &Value, session_id: &str) -> bool { - match extract_opencode_session_id(value) { - Some(id) => id == session_id, - None => false, - } -} - -fn extract_opencode_session_id(value: &Value) -> Option<String> { - if let Some(id) = value.get("session_id").and_then(Value::as_str) { - return Some(id.to_string()); - } - if let Some(id) = value.get("sessionID").and_then(Value::as_str) { - return Some(id.to_string()); - } - if let Some(id) = value.get("sessionId").and_then(Value::as_str) { - return Some(id.to_string()); - } - if let Some(id) = extract_nested_string(value, &["properties", "sessionID"]) { - return Some(id); - } - if let Some(id) = extract_nested_string(value, &["properties", "part", "sessionID"]) { - return Some(id); - } - if let Some(id) = extract_nested_string(value, &["session", "id"]) { - return Some(id); - } - if let Some(id) = extract_nested_string(value, &["properties", "session", "id"]) { - return Some(id); - } - None -} - -fn extract_nested_string(value: &Value, path: &[&str]) -> Option<String> { - let mut current = value; - for key in path { - if let Ok(index) = key.parse::<usize>() { - current = current.get(index)?; - } else { - current = current.get(*key)?; - } - } - current.as_str().map(|s| s.to_string()) -} - -#[cfg(test)] -mod pi_model_parser_tests { - use super::*; - - #[test] - fn parse_pi_models_output_parses_variants_from_thinking_column() { - let output = r#" -provider model aliases thinking -openai gpt-4.1 gpt-4.1-latest yes -anthropic claude-sonnet-4-5-20250929 sonnet no -"#; - - let parsed = parse_pi_models_output(output); - let anthropic = parsed - .models - .iter() - .find(|model| model.id == "anthropic/claude-sonnet-4-5-20250929") - .expect("anthropic model"); - assert_eq!( - anthropic.variants.as_deref(), - Some(&["off".to_string()][..]) - ); - assert_eq!(anthropic.default_variant.as_deref(), Some("off")); - - let openai = parsed - .models - .iter() - .find(|model| model.id == "openai/gpt-4.1") - .expect("openai model"); - assert_eq!(openai.variants.as_ref(), Some(&pi_variants())); - assert_eq!(openai.default_variant.as_deref(), Some("medium")); - assert_eq!(parsed.default_model, None); - } - - #[test] - fn parse_pi_models_output_skips_blank_header_separator_and_malformed_rows() { - let output = r#" -provider model aliases thinking --------- ----- ------- -------- - -openai -malformed-row -groq llama-3.3-70b-versatile alias no -"#; - - let parsed = parse_pi_models_output(output); - let models = parsed - .models - .iter() - .map(|model| (model.id.as_str(), model.default_variant.as_deref())) - .collect::<Vec<_>>(); - - assert_eq!(models, vec![("groq/llama-3.3-70b-versatile", Some("off"))]); - } - - #[test] - fn parse_pi_models_output_handles_model_ids_with_slashes() { - let output = "openrouter qwen/qwen3-32b alias yes"; - - let parsed = parse_pi_models_output(output); - let models = parsed - .models - .iter() - .map(|model| { - ( - model.id.as_str(), - model.variants.clone().unwrap_or_default(), - model.default_variant.as_deref(), - ) - }) - .collect::<Vec<_>>(); - - assert_eq!( - models, - vec![("openrouter/qwen/qwen3-32b", pi_variants(), Some("medium"))] - ); - } - - #[test] - fn parse_pi_models_output_deduplicates_and_sorts_stably() { - let output = r#" -zeta z-model yes -alpha a-model no -zeta z-model no -beta b-model yes -alpha a-model yes -"#; - - let parsed = parse_pi_models_output(output); - let ids = parsed - .models - .iter() - .map(|model| model.id.as_str()) - .collect::<Vec<_>>(); - - assert_eq!(ids, vec!["alpha/a-model", "beta/b-model", "zeta/z-model"]); - assert_eq!(parsed.default_model, None); - } - - #[test] - fn parse_pi_models_output_defaults_to_off_when_thinking_column_missing() { - let output = r#" -provider model aliases -openai gpt-4.1 gpt-4.1-latest -"#; - - let parsed = parse_pi_models_output(output); - assert_eq!(parsed.models.len(), 1); - assert_eq!( - parsed.models[0].variants.as_deref(), - Some(&["off".to_string()][..]) - ); - assert_eq!(parsed.models[0].default_variant.as_deref(), Some("off")); - } -} - -#[cfg(test)] -mod agent_capabilities_tests { - use super::*; - - #[test] - fn pi_capabilities_enable_variants() { - assert!(agent_capabilities_for(AgentId::Pi).variants); - } -} - -#[cfg(test)] -mod runtime_contract_tests { - use super::*; - - #[test] - fn map_spawn_error_maps_unsupported_runtime_path_to_invalid_request() { - let error = map_spawn_error( - AgentId::Pi, - ManagerError::UnsupportedRuntimePath { - agent: AgentId::Pi, - operation: "spawn_streaming", - recommended_path: "router-managed per-session RPC runtime", - }, - ); - match error { - SandboxError::InvalidRequest { message } => { - assert!(message.contains("spawn_streaming"), "{message}"); - assert!( - message.contains("router-managed per-session RPC runtime"), - "{message}" - ); - } - other => panic!("expected InvalidRequest, got {other:?}"), - } - } -} - -#[cfg(test)] -mod pi_runtime_tests { - use super::*; - use tempfile::TempDir; - - fn test_request(agent: AgentId) -> CreateSessionRequest { - CreateSessionRequest { - agent: agent.as_str().to_string(), - agent_mode: None, - permission_mode: Some("default".to_string()), - model: None, - variant: None, - agent_version: None, - } - } - - #[test] - fn pi_model_args_with_provider_and_model() { - let mut command = std::process::Command::new("pi"); - SessionManager::apply_pi_model_args(&mut command, Some("openai/gpt-5.2-codex")); - let args = command - .get_args() - .map(|arg| arg.to_string_lossy().to_string()) - .collect::<Vec<_>>(); - assert_eq!( - args, - vec!["--provider", "openai", "--model", "gpt-5.2-codex"] - ); - } - - #[test] - fn pi_model_args_with_slashes_in_model_id() { - let mut command = std::process::Command::new("pi"); - SessionManager::apply_pi_model_args( - &mut command, - Some("openrouter/meta-llama/llama-3.1-8b-instruct"), - ); - let args = command - .get_args() - .map(|arg| arg.to_string_lossy().to_string()) - .collect::<Vec<_>>(); - assert_eq!( - args, - vec![ - "--provider", - "openrouter", - "--model", - "meta-llama/llama-3.1-8b-instruct" - ] - ); - } - - #[test] - fn pi_model_args_with_model_only() { - let mut command = std::process::Command::new("pi"); - SessionManager::apply_pi_model_args(&mut command, Some("gpt-5.2-codex")); - let args = command - .get_args() - .map(|arg| arg.to_string_lossy().to_string()) - .collect::<Vec<_>>(); - assert_eq!(args, vec!["--model", "gpt-5.2-codex"]); - } - - async fn setup_pi_session_with_stdin( - session_id: &str, - ) -> ( - Arc<SessionManager>, - Arc<PiSessionRuntime>, - mpsc::UnboundedReceiver<String>, - TempDir, - ) { - let temp_dir = TempDir::new().expect("temp dir"); - let agent_manager = Arc::new(AgentManager::new(temp_dir.path()).expect("agent manager")); - let http_client = Client::builder().no_proxy().build().expect("http client"); - let server_manager = Arc::new(AgentServerManager::new( - agent_manager.clone(), - http_client.clone(), - temp_dir.path().join("logs"), - false, - )); - let session_manager = Arc::new(SessionManager { - agent_manager, - sessions: Mutex::new(Vec::new()), - server_manager, - http_client, - }); - session_manager - .server_manager - .set_owner(Arc::downgrade(&session_manager)); - - let (stdin_tx, stdin_rx) = mpsc::unbounded_channel::<String>(); - let runtime = Arc::new(PiSessionRuntime::new( - stdin_tx, - Arc::new(std::sync::Mutex::new(None)), - )); - - let mut session = SessionState::new( - session_id.to_string(), - AgentId::Pi, - &test_request(AgentId::Pi), - ) - .expect("session"); - session.native_session_id = Some(format!("native-{session_id}")); - session.pi_runtime = Some(runtime.clone()); - session_manager.sessions.lock().await.push(session); - - (session_manager, runtime, stdin_rx, temp_dir) - } - - async fn setup_pi_session( - session_id: &str, - ) -> (Arc<SessionManager>, Arc<PiSessionRuntime>, TempDir) { - let (session_manager, runtime, _stdin_rx, temp_dir) = - setup_pi_session_with_stdin(session_id).await; - (session_manager, runtime, temp_dir) - } - - #[tokio::test] - async fn pi_runtime_correlates_multiple_inflight_requests() { - let (stdin_tx, mut stdin_rx) = mpsc::unbounded_channel::<String>(); - let runtime = PiSessionRuntime::new(stdin_tx, Arc::new(std::sync::Mutex::new(None))); - - let id1 = runtime.next_request_id(); - let id2 = runtime.next_request_id(); - let rx1 = runtime - .send_request(id1, &json!({ "type": "one", "id": id1 })) - .expect("request 1"); - let rx2 = runtime - .send_request(id2, &json!({ "type": "two", "id": id2 })) - .expect("request 2"); - - let line1 = stdin_rx.recv().await.expect("line1"); - let line2 = stdin_rx.recv().await.expect("line2"); - assert!(line1.contains("\"id\":1") || line2.contains("\"id\":1")); - assert!(line1.contains("\"id\":2") || line2.contains("\"id\":2")); - - runtime.complete_request(id2, json!({ "type": "response", "id": id2, "ok": true })); - runtime.complete_request(id1, json!({ "type": "response", "id": id1, "ok": true })); - - let result2 = rx2.await.expect("response 2"); - let result1 = rx1.await.expect("response 1"); - assert_eq!(result2.get("id").and_then(Value::as_i64), Some(id2)); - assert_eq!(result1.get("id").and_then(Value::as_i64), Some(id1)); - } - - #[tokio::test] - async fn pi_set_thinking_level_sends_rpc_and_waits_for_success() { - let (session_manager, runtime, mut stdin_rx, _temp_dir) = - setup_pi_session_with_stdin("pi-thinking-success").await; - let runtime_for_task = runtime.clone(); - let manager_for_task = session_manager.clone(); - let task = tokio::spawn(async move { - manager_for_task - .set_pi_thinking_level(&runtime_for_task, "high") - .await - }); - - let line = stdin_rx.recv().await.expect("set_thinking_level request"); - let request: Value = serde_json::from_str(&line).expect("json request"); - assert_eq!( - request.get("type").and_then(Value::as_str), - Some("set_thinking_level") - ); - assert_eq!(request.get("level").and_then(Value::as_str), Some("high")); - let request_id = request - .get("id") - .and_then(Value::as_i64) - .expect("request id"); - runtime.complete_request( - request_id, - json!({ - "type": "response", - "id": request_id, - "success": true - }), - ); - - task.await.expect("join").expect("set_thinking_level ok"); - } - - #[tokio::test] - async fn pi_set_thinking_level_maps_explicit_rpc_error_to_invalid_request() { - let (session_manager, runtime, mut stdin_rx, _temp_dir) = - setup_pi_session_with_stdin("pi-thinking-error").await; - let runtime_for_task = runtime.clone(); - let manager_for_task = session_manager.clone(); - let task = tokio::spawn(async move { - manager_for_task - .set_pi_thinking_level(&runtime_for_task, "invalid-level") - .await - }); - - let line = stdin_rx.recv().await.expect("set_thinking_level request"); - let request: Value = serde_json::from_str(&line).expect("json request"); - let request_id = request - .get("id") - .and_then(Value::as_i64) - .expect("request id"); - runtime.complete_request( - request_id, - json!({ - "type": "response", - "id": request_id, - "success": false, - "error": "unsupported level" - }), - ); - - let err = task - .await - .expect("join") - .expect_err("set_thinking_level should fail"); - match err { - SandboxError::InvalidRequest { message } => { - assert!(message.contains("unsupported level"), "{message}"); - } - other => panic!("expected InvalidRequest, got {other:?}"), - } - } - - #[tokio::test] - async fn send_pi_prompt_reapplies_variant_before_prompt() { - let (session_manager, runtime, mut stdin_rx, _temp_dir) = - setup_pi_session_with_stdin("pi-prompt-variant").await; - let snapshot = SessionSnapshot { - session_id: "pi-prompt-variant".to_string(), - agent: AgentId::Pi, - agent_mode: "build".to_string(), - permission_mode: "default".to_string(), - model: None, - variant: Some("high".to_string()), - native_session_id: Some("native-pi-prompt-variant".to_string()), - }; - let manager_for_task = session_manager.clone(); - let task = - tokio::spawn(async move { manager_for_task.send_pi_prompt(&snapshot, "Hello").await }); - - let set_line = stdin_rx.recv().await.expect("set_thinking_level request"); - let set_request: Value = serde_json::from_str(&set_line).expect("json request"); - assert_eq!( - set_request.get("type").and_then(Value::as_str), - Some("set_thinking_level") - ); - let set_id = set_request - .get("id") - .and_then(Value::as_i64) - .expect("set id"); - runtime.complete_request( - set_id, - json!({ - "type": "response", - "id": set_id, - "success": true - }), - ); - - let prompt_line = stdin_rx.recv().await.expect("prompt request"); - let prompt_request: Value = serde_json::from_str(&prompt_line).expect("json request"); - assert_eq!( - prompt_request.get("type").and_then(Value::as_str), - Some("prompt") - ); - assert_eq!( - prompt_request.get("message").and_then(Value::as_str), - Some("Hello") - ); - let prompt_id = prompt_request - .get("id") - .and_then(Value::as_i64) - .expect("prompt id"); - runtime.complete_request( - prompt_id, - json!({ - "type": "response", - "id": prompt_id, - "success": true - }), - ); - - task.await.expect("join").expect("send_pi_prompt ok"); - } - - #[tokio::test] - async fn send_pi_prompt_maps_explicit_rpc_error_to_invalid_request() { - let (session_manager, runtime, mut stdin_rx, _temp_dir) = - setup_pi_session_with_stdin("pi-prompt-error").await; - let snapshot = SessionSnapshot { - session_id: "pi-prompt-error".to_string(), - agent: AgentId::Pi, - agent_mode: "build".to_string(), - permission_mode: "default".to_string(), - model: None, - variant: None, - native_session_id: Some("native-pi-prompt-error".to_string()), - }; - let manager_for_task = session_manager.clone(); - let task = - tokio::spawn(async move { manager_for_task.send_pi_prompt(&snapshot, "Hello").await }); - - let prompt_line = stdin_rx.recv().await.expect("prompt request"); - let prompt_request: Value = serde_json::from_str(&prompt_line).expect("json request"); - assert_eq!( - prompt_request.get("type").and_then(Value::as_str), - Some("prompt") - ); - let prompt_id = prompt_request - .get("id") - .and_then(Value::as_i64) - .expect("prompt id"); - runtime.complete_request( - prompt_id, - json!({ - "type": "response", - "id": prompt_id, - "success": false, - "error": "turn already in progress" - }), - ); - - let err = task - .await - .expect("join") - .expect_err("send_pi_prompt should fail"); - match err { - SandboxError::InvalidRequest { message } => { - assert!(message.contains("turn already in progress"), "{message}"); - } - other => panic!("expected InvalidRequest, got {other:?}"), - } - } - - #[tokio::test] - async fn send_pi_prompt_reports_cancelled_response() { - let (session_manager, runtime, mut stdin_rx, _temp_dir) = - setup_pi_session_with_stdin("pi-prompt-cancelled").await; - let snapshot = SessionSnapshot { - session_id: "pi-prompt-cancelled".to_string(), - agent: AgentId::Pi, - agent_mode: "build".to_string(), - permission_mode: "default".to_string(), - model: None, - variant: None, - native_session_id: Some("native-pi-prompt-cancelled".to_string()), - }; - let manager_for_task = session_manager.clone(); - let task = tokio::spawn(async move { - manager_for_task - .send_pi_prompt(&snapshot, "This should cancel") - .await - }); - - let _ = stdin_rx.recv().await.expect("prompt request"); - runtime.clear_pending(); - - let err = task - .await - .expect("join") - .expect_err("send_pi_prompt should fail"); - match err { - SandboxError::StreamError { message } => { - assert!(message.contains("pi prompt request cancelled"), "{message}"); - } - other => panic!("expected StreamError, got {other:?}"), - } - } - - #[tokio::test] - async fn pi_runtime_output_non_json_emits_agent_unparsed() { - let (session_manager, runtime, _temp_dir) = setup_pi_session("pi-unparsed").await; - let (tx, rx) = mpsc::unbounded_channel::<String>(); - tx.send("not-json".to_string()).expect("send malformed"); - drop(tx); - - session_manager - .clone() - .handle_pi_runtime_output("pi-unparsed".to_string(), runtime, rx) - .await; - - let events = session_manager - .events("pi-unparsed", 0, None, true) - .await - .expect("events") - .events; - assert!(events.iter().any(|event| { - event.event_type == UniversalEventType::AgentUnparsed - && matches!(event.source, EventSource::Daemon) - && event.synthetic - })); - } - - #[tokio::test] - async fn pi_runtime_converter_continuity_for_incremental_deltas() { - let (session_manager, runtime, _temp_dir) = setup_pi_session("pi-delta").await; - let (tx, rx) = mpsc::unbounded_channel::<String>(); - let lines = [ - json!({ - "type": "message_update", - "assistantMessageEvent": { "type": "text_delta", "delta": "Hel" } - }), - json!({ - "type": "message_update", - "assistantMessageEvent": { "type": "text_delta", "delta": "lo" } - }), - json!({ - "type": "message_update", - "assistantMessageEvent": { "type": "done" } - }), - ]; - for line in lines { - tx.send(line.to_string()).expect("send line"); - } - drop(tx); - - session_manager - .clone() - .handle_pi_runtime_output("pi-delta".to_string(), runtime, rx) - .await; - - let events = session_manager - .events("pi-delta", 0, None, true) - .await - .expect("events") - .events; - let deltas = events - .iter() - .filter_map(|event| match &event.data { - UniversalEventData::ItemDelta(delta) => { - Some((delta.item_id.clone(), delta.delta.clone())) - } - _ => None, - }) - .collect::<Vec<_>>(); - assert_eq!(deltas.len(), 2); - assert_eq!(deltas[0].0, deltas[1].0, "deltas should share one item"); - assert_eq!(deltas[0].1, "Hel"); - assert_eq!(deltas[1].1, "lo"); - - let completed = events.iter().find_map(|event| match &event.data { - UniversalEventData::Item(item) - if event.event_type == UniversalEventType::ItemCompleted => - { - Some(item.item.clone()) - } - _ => None, - }); - let completed = completed.expect("completed item"); - assert_eq!(completed.kind, ItemKind::Message); - let text = completed - .content - .iter() - .find_map(|part| match part { - ContentPart::Text { text } => Some(text.clone()), - _ => None, - }) - .unwrap_or_default(); - assert_eq!(text, "Hello"); - } -} - -#[cfg(feature = "test-utils")] -pub mod test_utils { - use super::*; - use std::process::Command; - use tempfile::TempDir; - - pub struct TestHarness { - session_manager: Arc<SessionManager>, - _temp_dir: TempDir, - } - - impl TestHarness { - pub async fn new() -> Self { - let temp_dir = TempDir::new().expect("temp dir"); - let agent_manager = - Arc::new(AgentManager::new(temp_dir.path()).expect("agent manager")); - let session_manager = Arc::new(SessionManager::new(agent_manager)); - session_manager - .server_manager - .set_owner_async(Arc::downgrade(&session_manager)) - .await; - Self { - session_manager, - _temp_dir: temp_dir, - } - } - - pub async fn register_session( - &self, - agent: AgentId, - session_id: &str, - native_session_id: Option<&str>, - ) { - self.session_manager - .server_manager - .register_session(agent, session_id, native_session_id) - .await; - } - - pub async fn unregister_session( - &self, - agent: AgentId, - session_id: &str, - native_session_id: Option<&str>, - ) { - self.session_manager - .server_manager - .unregister_session(agent, session_id, native_session_id) - .await; - } - - pub async fn has_session_mapping(&self, agent: AgentId, session_id: &str) -> bool { - let sessions = self.session_manager.server_manager.sessions.lock().await; - sessions - .get(&agent) - .map(|set| set.contains(session_id)) - .unwrap_or(false) - } - - pub async fn native_mapping( - &self, - agent: AgentId, - native_session_id: &str, - ) -> Option<String> { - let natives = self - .session_manager - .server_manager - .native_sessions - .lock() - .await; - natives - .get(&agent) - .and_then(|map| map.get(native_session_id)) - .cloned() - } - - pub async fn insert_session( - &self, - session_id: &str, - agent: AgentId, - native_session_id: Option<&str>, - ) { - let request = CreateSessionRequest { - agent: agent.as_str().to_string(), - agent_mode: None, - permission_mode: None, - model: None, - variant: None, - agent_version: None, - directory: None, - title: None, - mcp: None, - skills: None, - }; - let mut session = - SessionState::new(session_id.to_string(), agent, &request).expect("session"); - session.native_session_id = native_session_id.map(|id| id.to_string()); - self.session_manager.sessions.lock().await.push(session); - } - - pub async fn insert_stdio_server( - &self, - agent: AgentId, - child: Option<std::process::Child>, - instance_id: u64, - ) -> Arc<std::sync::Mutex<Option<std::process::Child>>> { - let (stdin_tx, _stdin_rx) = mpsc::unbounded_channel::<String>(); - let server = Arc::new(CodexServer::new(stdin_tx)); - let child = Arc::new(std::sync::Mutex::new(child)); - self.session_manager - .server_manager - .servers - .lock() - .await - .insert( - agent, - ManagedServer { - kind: ManagedServerKind::StdioCodex { server }, - child: child.clone(), - status: ServerStatus::Running, - start_time: Some(Instant::now()), - restart_count: 0, - last_error: None, - shutdown_requested: false, - instance_id, - }, - ); - child - } - - pub async fn insert_http_server(&self, agent: AgentId, instance_id: u64) { - self.session_manager - .server_manager - .servers - .lock() - .await - .insert( - agent, - ManagedServer { - kind: ManagedServerKind::Http { - base_url: "http://127.0.0.1:1".to_string(), - }, - child: Arc::new(std::sync::Mutex::new(None)), - status: ServerStatus::Running, - start_time: Some(Instant::now()), - restart_count: 0, - last_error: None, - shutdown_requested: false, - instance_id, - }, - ); - } - - pub async fn handle_process_exit( - &self, - agent: AgentId, - instance_id: u64, - status: std::process::ExitStatus, - ) { - self.session_manager - .server_manager - .handle_process_exit(agent, instance_id, status) - .await; - } - - pub async fn shutdown(&self) { - self.session_manager.shutdown().await; - } - - pub async fn server_status(&self, agent: AgentId) -> Option<ServerStatus> { - let servers = self.session_manager.server_manager.servers.lock().await; - servers.get(&agent).map(|server| server.status.clone()) - } - - pub async fn server_last_error(&self, agent: AgentId) -> Option<String> { - let servers = self.session_manager.server_manager.servers.lock().await; - servers - .get(&agent) - .and_then(|server| server.last_error.clone()) - } - - pub async fn session_ended(&self, session_id: &str) -> bool { - let sessions = self.session_manager.sessions.lock().await; - sessions - .iter() - .find(|session| session.session_id == session_id) - .map(|session| session.ended) - .unwrap_or(false) - } - - pub async fn session_end_reason(&self, session_id: &str) -> Option<SessionEndReason> { - let sessions = self.session_manager.sessions.lock().await; - sessions - .iter() - .find(|session| session.session_id == session_id) - .and_then(|session| session.ended_reason.clone()) - } - - pub async fn set_restart_notifier(&self, tx: mpsc::UnboundedSender<AgentId>) { - self.session_manager - .server_manager - .set_restart_notifier(tx) - .await; - } - } - - pub fn spawn_sleep_process() -> std::process::Child { - #[cfg(windows)] - { - Command::new("cmd") - .args(["/C", "ping", "127.0.0.1", "-n", "60"]) - .spawn() - .expect("spawn sleep") - } - #[cfg(not(windows))] - { - Command::new("sh") - .args(["-c", "sleep 60"]) - .spawn() - .expect("spawn sleep") - } - } - - #[cfg(unix)] - pub fn exit_status(code: i32) -> std::process::ExitStatus { - use std::os::unix::process::ExitStatusExt; - std::process::ExitStatus::from_raw(code << 8) - } - - #[cfg(windows)] - pub fn exit_status(code: i32) -> std::process::ExitStatus { - use std::os::windows::process::ExitStatusExt; - std::process::ExitStatus::from_raw(code as u32) - } -} - -fn default_log_dir() -> PathBuf { - dirs::data_dir() - .map(|dir| dir.join("sandbox-agent").join("logs").join("servers")) - .unwrap_or_else(|| { - PathBuf::from(".") - .join(".sandbox-agent") - .join("logs") - .join("servers") - }) -} - -fn find_available_port() -> Result<u16, SandboxError> { - for port in 4200..=4300 { - if TcpListener::bind(("127.0.0.1", port)).is_ok() { - return Ok(port); - } - } - Err(SandboxError::StreamError { - message: "no available OpenCode port".to_string(), - }) -} - -struct SseAccumulator { - buffer: String, - data_lines: Vec<String>, -} - -impl SseAccumulator { - fn new() -> Self { - Self { - buffer: String::new(), - data_lines: Vec::new(), - } - } - - fn push(&mut self, chunk: &str) -> Vec<String> { - self.buffer.push_str(chunk); - let mut events = Vec::new(); - while let Some(pos) = self.buffer.find('\n') { - let mut line = self.buffer[..pos].to_string(); - self.buffer.drain(..=pos); - if line.ends_with('\r') { - line.pop(); - } - if line.is_empty() { - if !self.data_lines.is_empty() { - events.push(self.data_lines.join("\n")); - self.data_lines.clear(); - } - continue; - } - if let Some(data) = line.strip_prefix("data:") { - self.data_lines.push(data.trim_start().to_string()); - } - } - events - } -} - -fn parse_opencode_modes(value: &Value) -> Vec<AgentModeInfo> { - let mut modes = Vec::new(); - let mut seen = HashSet::new(); - - let items = value - .as_array() - .or_else(|| value.get("agents").and_then(Value::as_array)) - .or_else(|| value.get("data").and_then(Value::as_array)); - - let Some(items) = items else { return modes }; - - for item in items { - let id = item - .get("id") - .and_then(Value::as_str) - .or_else(|| item.get("slug").and_then(Value::as_str)) - .or_else(|| item.get("name").and_then(Value::as_str)); - let Some(id) = id else { continue }; - if !seen.insert(id.to_string()) { - continue; - } - let name = item - .get("name") - .and_then(Value::as_str) - .unwrap_or(id) - .to_string(); - let description = item - .get("description") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); - modes.push(AgentModeInfo { - id: id.to_string(), - name, - description, - }); - } - - modes -} - -fn ensure_custom_mode(modes: &mut Vec<AgentModeInfo>) { - if modes.iter().any(|mode| mode.id == "custom") { - return; - } - modes.push(AgentModeInfo { - id: "custom".to_string(), - name: "Custom".to_string(), - description: "Any user-defined OpenCode agent name".to_string(), - }); -} - -fn text_delta_from_parts(parts: &[ContentPart]) -> Option<String> { - let mut delta = String::new(); - for part in parts { - if let ContentPart::Text { text } = part { - if !delta.is_empty() { - delta.push_str("\n"); - } - delta.push_str(text); - } - } - if delta.is_empty() { - None - } else { - Some(delta) - } -} - -const MOCK_OK_PROMPT: &str = "Reply with exactly the single word OK."; -const MOCK_FIRST_PROMPT: &str = "Reply with exactly the word FIRST."; -const MOCK_SECOND_PROMPT: &str = "Reply with exactly the word SECOND."; -const MOCK_PERMISSION_PROMPT: &str = "List files in the current directory using available tools."; -const MOCK_TOOL_PROMPT: &str = - "Use the bash tool to run `ls` in the current directory. Do not answer without using the tool."; -const MOCK_QUESTION_PROMPT: &str = - "Use the AskUserQuestion tool to ask exactly one yes/no question, then wait for a reply. Do not answer yourself."; -const MOCK_QUESTION_PROMPT_ALT: &str = - "Call the AskUserQuestion tool with exactly one yes/no question and wait for a reply. Do not answer yourself."; -const MOCK_REASONING_PROMPT: &str = "Answer briefly and include your reasoning."; -const MOCK_STATUS_PROMPT: &str = "Provide a short status update."; - -fn mock_command_conversions(prefix: &str, input: &str) -> Vec<EventConversion> { - let trimmed = input.trim(); - if trimmed.is_empty() { - return vec![]; - } - let mut events = mock_command_events(prefix, trimmed); - if should_append_turn_ended(&events) { - events.push(turn_ended_event(None, None).synthetic()); - } - events -} - -fn should_append_turn_ended(events: &[EventConversion]) -> bool { - let Some(last) = events.last() else { - return false; - }; - !matches!( - last.event_type, - UniversalEventType::SessionEnded - | UniversalEventType::PermissionRequested - | UniversalEventType::QuestionRequested - ) -} - -fn mock_command_events(prefix: &str, trimmed: &str) -> Vec<EventConversion> { - if trimmed.eq_ignore_ascii_case(MOCK_OK_PROMPT) { - return mock_assistant_message(format!("{prefix}_ok"), "OK".to_string()); - } - if trimmed.eq_ignore_ascii_case(MOCK_FIRST_PROMPT) { - return mock_assistant_message(format!("{prefix}_first"), "FIRST".to_string()); - } - if trimmed.eq_ignore_ascii_case(MOCK_SECOND_PROMPT) { - return mock_assistant_message(format!("{prefix}_second"), "SECOND".to_string()); - } - if trimmed.eq_ignore_ascii_case(MOCK_REASONING_PROMPT) { - return mock_assistant_rich(prefix); - } - if trimmed.eq_ignore_ascii_case(MOCK_STATUS_PROMPT) { - return mock_status_sequence(prefix); - } - if trimmed.eq_ignore_ascii_case(MOCK_PERMISSION_PROMPT) { - return mock_permission_request(prefix); - } - if trimmed.eq_ignore_ascii_case(MOCK_TOOL_PROMPT) { - let mut events = Vec::new(); - events.extend(mock_permission_request(prefix)); - events.extend(mock_tool_sequence(prefix)); - return events; - } - if trimmed.eq_ignore_ascii_case(MOCK_QUESTION_PROMPT) - || trimmed.eq_ignore_ascii_case(MOCK_QUESTION_PROMPT_ALT) - { - return mock_question_request(prefix); - } - - let mut parts = trimmed.split_whitespace(); - let command = parts.next().unwrap_or("").to_lowercase(); - let rest = parts.collect::<Vec<_>>().join(" "); - - let mut marker_index = 0_u32; - match command.as_str() { - "help" => mock_help_message(prefix), - "demo" => { - let mut events = Vec::new(); - events.extend(mock_marker( - prefix, - &mut marker_index, - "Next: system message (system role).", - )); - events.extend(mock_system_message(prefix)); - events.extend(mock_marker( - prefix, - &mut marker_index, - "Next: assistant message with deltas, reasoning, and JSON content parts.", - )); - events.extend(mock_assistant_rich(prefix)); - events.extend(mock_marker( - prefix, - &mut marker_index, - "Next: status item updates.", - )); - events.extend(mock_status_sequence(prefix)); - events.extend(mock_marker( - prefix, - &mut marker_index, - "Next: markdown rendering + streaming markdown deltas.", - )); - events.extend(mock_markdown_sequence(prefix)); - events.extend(mock_marker( - prefix, - &mut marker_index, - "Next: tool call item.", - )); - events.extend(mock_tool_sequence(prefix)); - events.extend(mock_marker( - prefix, - &mut marker_index, - "Next: image output content part.", - )); - events.extend(mock_image_sequence(prefix)); - events.extend(mock_marker( - prefix, - &mut marker_index, - "Next: unknown item kind.", - )); - events.extend(mock_unknown_sequence(prefix)); - events.extend(mock_marker( - prefix, - &mut marker_index, - "Next: permission requests (pending).", - )); - events.extend(mock_permission_requests(prefix)); - events.extend(mock_marker( - prefix, - &mut marker_index, - "Next: question requests (pending).", - )); - events.extend(mock_question_requests(prefix)); - events.extend(mock_marker( - prefix, - &mut marker_index, - "Next: error and agent.unparsed events.", - )); - events.extend(mock_error_sequence(prefix)); - events - } - "markdown" => mock_markdown_sequence(prefix), - "tool" | "tools" | "tooling" => mock_tool_sequence(prefix), - "status" => mock_status_sequence(prefix), - "image" => mock_image_sequence(prefix), - "unknown" => mock_unknown_sequence(prefix), - "permission" | "permissions" => mock_permission_requests(prefix), - "question" | "questions" => mock_question_requests(prefix), - "error" => mock_error_sequence(prefix), - "unparsed" => mock_unparsed_sequence(prefix), - "end" | "ended" | "session.end" => mock_session_end_sequence(prefix), - "echo" | "say" => { - if rest.is_empty() { - mock_assistant_message( - format!("{prefix}_echo"), - "Tell me what to say after `echo`.".to_string(), - ) - } else { - mock_assistant_message(format!("{prefix}_echo"), rest) - } - } - _ => mock_assistant_message(format!("{prefix}_reply"), trimmed.to_string()), - } -} - -fn mock_help_message(prefix: &str) -> Vec<EventConversion> { - let message = [ - "Mock agent commands:", - "- demo: run a full UI coverage sequence with markers.", - "- markdown: streaming markdown fixture.", - "- tool: tool call + tool result with file refs.", - "- status: status item updates.", - "- image: message with image content part.", - "- unknown: item.kind=unknown example.", - "- permission: permission requests (pending).", - "- question: question requests (pending).", - "- error: error + agent.unparsed events.", - "- unparsed: emit agent.unparsed only.", - "- end: emit session.ended.", - "", - "Any other text will be echoed as an assistant message.", - ] - .join("\n"); - mock_assistant_message(format!("{prefix}_help"), message) -} - -fn mock_user_message(prefix: &str, text: &str) -> Vec<EventConversion> { - let native_item_id = format!("{prefix}_user"); - let content = vec![ContentPart::Text { - text: text.to_string(), - }]; - vec![ - mock_item_event( - UniversalEventType::ItemStarted, - mock_item( - native_item_id.clone(), - ItemKind::Message, - ItemRole::User, - ItemStatus::InProgress, - content.clone(), - ), - ), - mock_item_event( - UniversalEventType::ItemCompleted, - mock_item( - native_item_id, - ItemKind::Message, - ItemRole::User, - ItemStatus::Completed, - content, - ), - ), - ] -} - -fn user_message_conversions(text: &str) -> Vec<EventConversion> { - let id = USER_MESSAGE_COUNTER.fetch_add(1, Ordering::Relaxed); - let native_item_id = format!("user_{id}"); - let content = vec![ContentPart::Text { - text: text.to_string(), - }]; - vec![ - EventConversion::new( - UniversalEventType::ItemStarted, - UniversalEventData::Item(ItemEventData { - item: UniversalItem { - item_id: String::new(), - native_item_id: Some(native_item_id.clone()), - parent_id: None, - kind: ItemKind::Message, - role: Some(ItemRole::User), - content: content.clone(), - status: ItemStatus::InProgress, - }, - }), - ) - .synthetic(), - EventConversion::new( - UniversalEventType::ItemCompleted, - UniversalEventData::Item(ItemEventData { - item: UniversalItem { - item_id: String::new(), - native_item_id: Some(native_item_id), - parent_id: None, - kind: ItemKind::Message, - role: Some(ItemRole::User), - content, - status: ItemStatus::Completed, - }, - }), - ) - .synthetic(), - ] -} - -fn mock_assistant_message(native_item_id: String, text: String) -> Vec<EventConversion> { - let content = vec![ContentPart::Text { text }]; - vec![ - mock_item_event( - UniversalEventType::ItemStarted, - mock_item( - native_item_id.clone(), - ItemKind::Message, - ItemRole::Assistant, - ItemStatus::InProgress, - content.clone(), - ), - ), - mock_item_event( - UniversalEventType::ItemCompleted, - mock_item( - native_item_id, - ItemKind::Message, - ItemRole::Assistant, - ItemStatus::Completed, - content, - ), - ), - ] -} - -fn mock_system_message(prefix: &str) -> Vec<EventConversion> { - let native_item_id = format!("{prefix}_system"); - let content = vec![ContentPart::Text { - text: "System ready for mock events.".to_string(), - }]; - vec![ - mock_item_event( - UniversalEventType::ItemStarted, - mock_item( - native_item_id.clone(), - ItemKind::System, - ItemRole::System, - ItemStatus::InProgress, - content.clone(), - ), - ), - mock_item_event( - UniversalEventType::ItemCompleted, - mock_item( - native_item_id, - ItemKind::System, - ItemRole::System, - ItemStatus::Completed, - content, - ), - ), - ] -} - -fn mock_assistant_rich(prefix: &str) -> Vec<EventConversion> { - let native_item_id = format!("{prefix}_assistant_rich"); - let parts = vec![ - ContentPart::Text { - text: "Mock assistant response with rich content.".to_string(), - }, - ContentPart::Reasoning { - text: "Public reasoning for display.".to_string(), - visibility: ReasoningVisibility::Public, - }, - ContentPart::Reasoning { - text: "Private reasoning hidden by default.".to_string(), - visibility: ReasoningVisibility::Private, - }, - ContentPart::Json { - json: json!({ - "stage": "analysis", - "ok": true - }), - }, - ]; - let mut events = vec![mock_item_event( - UniversalEventType::ItemStarted, - mock_item( - native_item_id.clone(), - ItemKind::Message, - ItemRole::Assistant, - ItemStatus::InProgress, - parts.clone(), - ), - )]; - events.push(mock_delta(native_item_id.clone(), "Mock assistant ")); - events.push(mock_delta(native_item_id.clone(), "streaming delta.")); - events.push(mock_item_event( - UniversalEventType::ItemCompleted, - mock_item( - native_item_id, - ItemKind::Message, - ItemRole::Assistant, - ItemStatus::Completed, - parts, - ), - )); - events -} - -fn mock_status_sequence(prefix: &str) -> Vec<EventConversion> { - let native_item_id = format!("{prefix}_status"); - vec![ - mock_item_event( - UniversalEventType::ItemStarted, - mock_item( - native_item_id.clone(), - ItemKind::Status, - ItemRole::Assistant, - ItemStatus::InProgress, - vec![ContentPart::Status { - label: "Indexing".to_string(), - detail: Some("2 files".to_string()), - }], - ), - ), - mock_item_event( - UniversalEventType::ItemCompleted, - mock_item( - native_item_id, - ItemKind::Status, - ItemRole::Assistant, - ItemStatus::Completed, - vec![ContentPart::Status { - label: "Indexing".to_string(), - detail: Some("Done".to_string()), - }], - ), - ), - ] -} - -fn mock_markdown_sequence(prefix: &str) -> Vec<EventConversion> { - let native_item_id = format!("{prefix}_markdown"); - let markdown_text = [ - "# Markdown Demo", - "", - "**Bold**, *italic*, ***bold-italic***, and ~~strikethrough~~.", - "", - "Inline code: `const x = 1`.", - "", - "## Lists", - "- Item one", - " - Nested item", - "- Item two", - "", - "1. First", - "2. Second", - "", - "> Blockquote with **bold** text.", - "", - "## Code", - "```ts", - "const answer: number = 42;", - "console.log(answer);", - "```", - "", - "## Table", - "| Column A | Column B |", - "|:---------|---------:|", - "| Left | Right |", - "| Alpha | Beta |", - "", - "## Link", - "[Example](https://example.com)", - "", - "---", - "", - "End of markdown demo.", - ] - .join("\n"); - let markdown_parts = vec![ContentPart::Text { - text: markdown_text.clone(), - }]; - - let mut events = Vec::new(); - events.push(mock_item_event( - UniversalEventType::ItemStarted, - mock_item( - native_item_id.clone(), - ItemKind::Message, - ItemRole::Assistant, - ItemStatus::InProgress, - vec![ContentPart::Text { - text: "Markdown demo (streaming follows)...".to_string(), - }], - ), - )); - let markdown_deltas = [ - "# Markdown Demo\n\n**Bo", - "ld**, *ita", - "lic*, ***bold-", - "italic***, and ~~stri", - "kethrough~~.\n\nInline code: `const x = 1`.\n\n## Lists\n- Item one\n - Nested item\n- Item two\n\n1.", - " First\n2. Second\n\n> Blockquote with **bold** text.\n\n## Code\n```t", - "s\nconst answer: number = 42;\nconsole.log(answer);\n```\n\n## Table\n| Column A | Column B |\n|:---------|---------:|\n| Left", - " | Right |\n| Alpha | Beta |\n\n## Link\n[Example](https://example.com)\n\n---\n\nEnd of markdown demo.", - ]; - for chunk in markdown_deltas { - events.push(mock_delta(native_item_id.clone(), chunk)); - } - events.push(mock_item_event( - UniversalEventType::ItemCompleted, - mock_item( - native_item_id, - ItemKind::Message, - ItemRole::Assistant, - ItemStatus::Completed, - markdown_parts, - ), - )); - events -} - -fn mock_tool_sequence(prefix: &str) -> Vec<EventConversion> { - let tool_call_native = format!("{prefix}_tool_call"); - let tool_result_native = format!("{prefix}_tool_result"); - let call_id = format!("{prefix}_call"); - let tool_call_part = ContentPart::ToolCall { - name: "mock.search".to_string(), - arguments: "{\"query\":\"example\"}".to_string(), - call_id: call_id.clone(), - }; - let mut events = Vec::new(); - events.push(mock_item_event( - UniversalEventType::ItemStarted, - mock_item( - tool_call_native.clone(), - ItemKind::ToolCall, - ItemRole::Assistant, - ItemStatus::InProgress, - vec![tool_call_part.clone()], - ), - )); - events.push(mock_item_event( - UniversalEventType::ItemCompleted, - mock_item( - tool_call_native, - ItemKind::ToolCall, - ItemRole::Assistant, - ItemStatus::Completed, - vec![tool_call_part], - ), - )); - - let tool_result_parts = vec![ - ContentPart::ToolResult { - call_id: call_id.clone(), - output: "mock search results".to_string(), - }, - ContentPart::FileRef { - path: format!("{prefix}/readme.md"), - action: FileAction::Read, - diff: None, - }, - ContentPart::FileRef { - path: format!("{prefix}/output.txt"), - action: FileAction::Write, - diff: Some("+mock output\n".to_string()), - }, - ContentPart::FileRef { - path: format!("{prefix}/patch.txt"), - action: FileAction::Patch, - diff: Some("@@ -1,1 +1,1 @@\n-old\n+new\n".to_string()), - }, - ]; - events.push(mock_item_event( - UniversalEventType::ItemStarted, - mock_item( - tool_result_native.clone(), - ItemKind::ToolResult, - ItemRole::Tool, - ItemStatus::InProgress, - tool_result_parts.clone(), - ), - )); - events.push(mock_item_event( - UniversalEventType::ItemCompleted, - mock_item( - tool_result_native, - ItemKind::ToolResult, - ItemRole::Tool, - ItemStatus::Failed, - tool_result_parts, - ), - )); - - events -} - -fn mock_image_sequence(prefix: &str) -> Vec<EventConversion> { - let native_item_id = format!("{prefix}_image"); - let image_parts = vec![ - ContentPart::Text { - text: "Here is a mock image output.".to_string(), - }, - ContentPart::Image { - path: format!("{prefix}/image.png"), - mime: Some("image/png".to_string()), - }, - ]; - vec![ - mock_item_event( - UniversalEventType::ItemStarted, - mock_item( - native_item_id.clone(), - ItemKind::Message, - ItemRole::Assistant, - ItemStatus::InProgress, - image_parts.clone(), - ), - ), - mock_item_event( - UniversalEventType::ItemCompleted, - mock_item( - native_item_id, - ItemKind::Message, - ItemRole::Assistant, - ItemStatus::Completed, - image_parts, - ), - ), - ] -} - -fn mock_unknown_sequence(prefix: &str) -> Vec<EventConversion> { - let native_item_id = format!("{prefix}_unknown"); - vec![ - mock_item_event( - UniversalEventType::ItemStarted, - mock_item( - native_item_id.clone(), - ItemKind::Unknown, - ItemRole::Assistant, - ItemStatus::InProgress, - vec![ContentPart::Text { - text: "Unknown item kind example.".to_string(), - }], - ), - ), - mock_item_event( - UniversalEventType::ItemCompleted, - mock_item( - native_item_id, - ItemKind::Unknown, - ItemRole::Assistant, - ItemStatus::Completed, - vec![ContentPart::Text { - text: "Unknown item kind example.".to_string(), - }], - ), - ), - ] -} - -fn mock_permission_request(prefix: &str) -> Vec<EventConversion> { - let permission_id = format!("{prefix}_permission"); - let metadata = json!({ - "codexRequestKind": "commandExecution", - "command": "ls" - }); - vec![EventConversion::new( - UniversalEventType::PermissionRequested, - UniversalEventData::Permission(PermissionEventData { - permission_id, - action: "command_execution".to_string(), - status: PermissionStatus::Requested, - metadata: Some(metadata), - }), - )] -} - -fn mock_question_request(prefix: &str) -> Vec<EventConversion> { - let question_id = format!("{prefix}_question"); - vec![EventConversion::new( - UniversalEventType::QuestionRequested, - UniversalEventData::Question(QuestionEventData { - question_id, - prompt: "Proceed?".to_string(), - options: vec!["Yes".to_string(), "No".to_string()], - response: None, - status: QuestionStatus::Requested, - }), - )] -} - -fn mock_permission_requests(prefix: &str) -> Vec<EventConversion> { - let permission_id = format!("{prefix}_permission"); - let permission_deny_id = format!("{prefix}_permission_denied"); - let permission_metadata = json!({ - "codexRequestKind": "commandExecution", - "command": "echo mock" - }); - let permission_metadata_deny = json!({ - "codexRequestKind": "fileChange", - "path": format!("{prefix}/deny.txt") - }); - vec![ - EventConversion::new( - UniversalEventType::PermissionRequested, - UniversalEventData::Permission(PermissionEventData { - permission_id: permission_id, - action: "command_execution".to_string(), - status: PermissionStatus::Requested, - metadata: Some(permission_metadata), - }), - ), - EventConversion::new( - UniversalEventType::PermissionRequested, - UniversalEventData::Permission(PermissionEventData { - permission_id: permission_deny_id, - action: "file_change".to_string(), - status: PermissionStatus::Requested, - metadata: Some(permission_metadata_deny), - }), - ), - ] -} - -fn mock_question_requests(prefix: &str) -> Vec<EventConversion> { - let question_id = format!("{prefix}_question"); - let question_reject_id = format!("{prefix}_question_reject"); - vec![ - EventConversion::new( - UniversalEventType::QuestionRequested, - UniversalEventData::Question(QuestionEventData { - question_id, - prompt: "Choose a color".to_string(), - options: vec!["Red".to_string(), "Blue".to_string()], - response: None, - status: QuestionStatus::Requested, - }), - ), - EventConversion::new( - UniversalEventType::QuestionRequested, - UniversalEventData::Question(QuestionEventData { - question_id: question_reject_id, - prompt: "Allow mock experiment?".to_string(), - options: vec!["Yes".to_string(), "No".to_string()], - response: None, - status: QuestionStatus::Requested, - }), - ), - ] -} - -fn mock_error_sequence(_prefix: &str) -> Vec<EventConversion> { - vec![ - EventConversion::new( - UniversalEventType::Error, - UniversalEventData::Error(ErrorData { - message: "Mock error event.".to_string(), - code: Some("mock_error".to_string()), - details: Some(json!({ "mock": true })), - }), - ) - .synthetic(), - agent_unparsed( - "mock.stream", - "unsupported payload", - json!({ "raw": "mock" }), - ), - ] -} - -fn mock_unparsed_sequence(_prefix: &str) -> Vec<EventConversion> { - vec![agent_unparsed( - "mock.stream", - "unsupported payload", - json!({ "raw": "mock" }), - )] -} - -fn mock_session_end_sequence(_prefix: &str) -> Vec<EventConversion> { - vec![EventConversion::new( - UniversalEventType::SessionEnded, - UniversalEventData::SessionEnded(SessionEndedData { - reason: SessionEndReason::Completed, - terminated_by: TerminatedBy::Agent, - message: None, - exit_code: None, - stderr: None, - }), - ) - .synthetic()] -} - -fn mock_item( - native_item_id: String, - kind: ItemKind, - role: ItemRole, - status: ItemStatus, - content: Vec<ContentPart>, -) -> UniversalItem { - UniversalItem { - item_id: String::new(), - native_item_id: Some(native_item_id), - parent_id: None, - kind, - role: Some(role), - content, - status, - } -} - -fn mock_item_event(event_type: UniversalEventType, item: UniversalItem) -> EventConversion { - EventConversion::new(event_type, UniversalEventData::Item(ItemEventData { item })) -} - -fn mock_marker(prefix: &str, marker_index: &mut u32, message: &str) -> Vec<EventConversion> { - *marker_index = marker_index.saturating_add(1); - let native_item_id = format!("{prefix}_marker_{marker_index}"); - let content = vec![ContentPart::Text { - text: message.to_string(), - }]; - vec![ - mock_item_event( - UniversalEventType::ItemStarted, - mock_item( - native_item_id.clone(), - ItemKind::Message, - ItemRole::Assistant, - ItemStatus::InProgress, - content.clone(), - ), - ), - mock_item_event( - UniversalEventType::ItemCompleted, - mock_item( - native_item_id, - ItemKind::Message, - ItemRole::Assistant, - ItemStatus::Completed, - content, - ), - ), - ] -} - -fn mock_delta(native_item_id: String, delta: &str) -> EventConversion { - EventConversion::new( - UniversalEventType::ItemDelta, - UniversalEventData::ItemDelta(ItemDeltaData { - item_id: String::new(), - native_item_id: Some(native_item_id), - delta: delta.to_string(), - }), - ) -} - -fn agent_unparsed(location: &str, error: &str, raw: Value) -> EventConversion { - EventConversion::new( - UniversalEventType::AgentUnparsed, - UniversalEventData::AgentUnparsed(AgentUnparsedData { - error: error.to_string(), - location: location.to_string(), - raw_hash: None, - }), - ) - .synthetic() - .with_raw(Some(raw)) -} - -fn now_rfc3339() -> String { - time::OffsetDateTime::now_utc() - .format(&time::format_description::well_known::Rfc3339) - .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) -} - -struct TurnStreamState { - initial_events: VecDeque<UniversalEvent>, - receiver: broadcast::Receiver<UniversalEvent>, - include_raw: bool, - done: bool, - agent: AgentId, -} - -fn stream_turn_events( - subscription: SessionSubscription, - agent: AgentId, - include_raw: bool, -) -> impl futures::Stream<Item = Result<Event, Infallible>> { - let state = TurnStreamState { - initial_events: VecDeque::from(subscription.initial_events), - receiver: subscription.receiver, - include_raw, - done: false, - agent, - }; - stream::unfold(state, |mut state| async move { - if state.done { - return None; - } - - let mut event = if let Some(event) = state.initial_events.pop_front() { - event - } else { - loop { - match state.receiver.recv().await { - Ok(event) => break event, - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => return None, - } - } - }; - - if !state.include_raw { - event.raw = None; - } - - if is_turn_terminal(&event, state.agent) { - state.done = true; - } - - Some((Ok::<Event, Infallible>(to_sse_event(event)), state)) - }) -} - -fn is_turn_terminal(event: &UniversalEvent, _agent: AgentId) -> bool { - match event.event_type { - UniversalEventType::TurnEnded - | UniversalEventType::SessionEnded - | UniversalEventType::Error - | UniversalEventType::AgentUnparsed - | UniversalEventType::PermissionRequested - | UniversalEventType::QuestionRequested => true, - _ => false, - } -} - -fn to_sse_event(event: UniversalEvent) -> Event { - Event::default() - .json_data(&event) - .unwrap_or_else(|_| Event::default().data("{}")) -} - -#[derive(Clone, Debug)] -struct SessionSnapshot { - session_id: String, - agent: AgentId, - agent_mode: String, - permission_mode: String, - model: Option<String>, - variant: Option<String>, - native_session_id: Option<String>, -} - -impl From<&SessionState> for SessionSnapshot { - fn from(session: &SessionState) -> Self { - Self { - session_id: session.session_id.clone(), - agent: session.agent, - agent_mode: session.agent_mode.clone(), - permission_mode: session.permission_mode.clone(), - model: session.model.clone(), - variant: session.variant.clone(), - native_session_id: session.native_session_id.clone(), - } - } -} - -pub fn add_token_header(headers: &mut HeaderMap, token: &str) { - let value = format!("Bearer {token}"); - if let Ok(header) = HeaderValue::from_str(&value) { - headers.insert(axum::http::header::AUTHORIZATION, header); - } -} - -fn build_anthropic_headers( - credentials: &ProviderCredentials, -) -> Result<reqwest::header::HeaderMap, SandboxError> { - let mut headers = reqwest::header::HeaderMap::new(); - match credentials.auth_type { - AuthType::ApiKey => { - let value = - reqwest::header::HeaderValue::from_str(&credentials.api_key).map_err(|_| { - SandboxError::StreamError { - message: "invalid anthropic api key header".to_string(), - } - })?; - headers.insert("x-api-key", value); - } - AuthType::Oauth => { - let value = format!("Bearer {}", credentials.api_key); - let header = reqwest::header::HeaderValue::from_str(&value).map_err(|_| { - SandboxError::StreamError { - message: "invalid anthropic oauth header".to_string(), - } - })?; - headers.insert(reqwest::header::AUTHORIZATION, header); - } - } - headers.insert( - "anthropic-version", - reqwest::header::HeaderValue::from_static(ANTHROPIC_VERSION), - ); - Ok(headers) +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0) } diff --git a/server/packages/sandbox-agent/src/telemetry.rs b/server/packages/sandbox-agent/src/telemetry.rs index 11d6430..9e3079e 100644 --- a/server/packages/sandbox-agent/src/telemetry.rs +++ b/server/packages/sandbox-agent/src/telemetry.rs @@ -11,7 +11,6 @@ use serde::Serialize; use time::OffsetDateTime; use tokio::time::Instant; -use crate::http_client; static TELEMETRY_ENABLED: AtomicBool = AtomicBool::new(false); const TELEMETRY_URL: &str = "https://tc.rivet.dev"; @@ -83,7 +82,7 @@ pub fn log_enabled_message() { pub fn spawn_telemetry_task() { tokio::spawn(async move { - let client = match http_client::client_builder() + let client = match Client::builder() .timeout(Duration::from_millis(TELEMETRY_TIMEOUT_MS)) .build() { diff --git a/server/packages/sandbox-agent/src/universal_events.rs b/server/packages/sandbox-agent/src/universal_events.rs index 5ab1237..212535c 100644 --- a/server/packages/sandbox-agent/src/universal_events.rs +++ b/server/packages/sandbox-agent/src/universal_events.rs @@ -3,15 +3,6 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use utoipa::ToSchema; -pub use sandbox_agent_extracted_agent_schemas::{amp, claude, codex, opencode, pi}; - -pub mod agents; - -pub use agents::{ - amp as convert_amp, claude as convert_claude, codex as convert_codex, - opencode as convert_opencode, pi as convert_pi, -}; - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] pub struct UniversalEvent { pub event_id: String, @@ -87,13 +78,10 @@ pub struct SessionStartedData { pub struct SessionEndedData { pub reason: SessionEndReason, pub terminated_by: TerminatedBy, - /// Error message when reason is Error #[serde(default, skip_serializing_if = "Option::is_none")] pub message: Option<String>, - /// Process exit code when reason is Error #[serde(default, skip_serializing_if = "Option::is_none")] pub exit_code: Option<i32>, - /// Agent stderr output when reason is Error #[serde(default, skip_serializing_if = "Option::is_none")] pub stderr: Option<StderrOutput>, } @@ -116,15 +104,11 @@ pub enum TurnPhase { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] pub struct StderrOutput { - /// First N lines of stderr (if truncated) or full stderr (if not truncated) #[serde(default, skip_serializing_if = "Option::is_none")] pub head: Option<String>, - /// Last N lines of stderr (only present if truncated) #[serde(default, skip_serializing_if = "Option::is_none")] pub tail: Option<String>, - /// Whether the output was truncated pub truncated: bool, - /// Total number of lines in stderr #[serde(default, skip_serializing_if = "Option::is_none")] pub total_lines: Option<usize>, } @@ -226,7 +210,7 @@ pub enum ItemKind { Unknown, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[serde(rename_all = "snake_case")] pub enum ItemRole { User, @@ -235,7 +219,7 @@ pub enum ItemRole { Tool, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[serde(rename_all = "snake_case")] pub enum ItemStatus { InProgress, @@ -294,93 +278,3 @@ pub enum ReasoningVisibility { Public, Private, } - -#[derive(Debug, Clone)] -pub struct EventConversion { - pub event_type: UniversalEventType, - pub data: UniversalEventData, - pub native_session_id: Option<String>, - pub source: EventSource, - pub synthetic: bool, - pub raw: Option<Value>, -} - -impl EventConversion { - pub fn new(event_type: UniversalEventType, data: UniversalEventData) -> Self { - Self { - event_type, - data, - native_session_id: None, - source: EventSource::Agent, - synthetic: false, - raw: None, - } - } - - pub fn with_native_session(mut self, session_id: Option<String>) -> Self { - self.native_session_id = session_id; - self - } - - pub fn with_raw(mut self, raw: Option<Value>) -> Self { - self.raw = raw; - self - } - - pub fn synthetic(mut self) -> Self { - self.synthetic = true; - self.source = EventSource::Daemon; - self - } - - pub fn with_source(mut self, source: EventSource) -> Self { - self.source = source; - self - } -} - -pub fn turn_started_event(turn_id: Option<String>, metadata: Option<Value>) -> EventConversion { - EventConversion::new( - UniversalEventType::TurnStarted, - UniversalEventData::Turn(TurnEventData { - phase: TurnPhase::Started, - turn_id, - metadata, - }), - ) -} - -pub fn turn_ended_event(turn_id: Option<String>, metadata: Option<Value>) -> EventConversion { - EventConversion::new( - UniversalEventType::TurnEnded, - UniversalEventData::Turn(TurnEventData { - phase: TurnPhase::Ended, - turn_id, - metadata, - }), - ) -} - -pub fn item_from_text(role: ItemRole, text: String) -> UniversalItem { - UniversalItem { - item_id: String::new(), - native_item_id: None, - parent_id: None, - kind: ItemKind::Message, - role: Some(role), - content: vec![ContentPart::Text { text }], - status: ItemStatus::Completed, - } -} - -pub fn item_from_parts(role: ItemRole, kind: ItemKind, parts: Vec<ContentPart>) -> UniversalItem { - UniversalItem { - item_id: String::new(), - native_item_id: None, - parent_id: None, - kind, - role: Some(role), - content: parts, - status: ItemStatus::Completed, - } -} diff --git a/server/packages/universal-agent-schema/src/agents/pi.rs b/server/packages/universal-agent-schema/src/agents/pi.rs deleted file mode 100644 index 7aa9932..0000000 --- a/server/packages/universal-agent-schema/src/agents/pi.rs +++ /dev/null @@ -1,769 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use serde_json::Value; - -use crate::pi as schema; -use crate::{ - ContentPart, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus, - ReasoningVisibility, UniversalEventData, UniversalEventType, UniversalItem, -}; - -#[derive(Default)] -pub struct PiEventConverter { - tool_result_buffers: HashMap<String, String>, - tool_result_started: HashSet<String>, - message_completed: HashSet<String>, - message_errors: HashSet<String>, - message_reasoning: HashMap<String, String>, - message_text: HashMap<String, String>, - last_message_id: Option<String>, - message_started: HashSet<String>, - message_counter: u64, -} - -impl PiEventConverter { - pub fn event_value_to_universal( - &mut self, - raw: &Value, - ) -> Result<Vec<EventConversion>, String> { - let event_type = raw - .get("type") - .and_then(Value::as_str) - .ok_or_else(|| "missing event type".to_string())?; - let native_session_id = extract_session_id(raw); - - let conversions = match event_type { - "message_start" => self.message_start(raw), - "message_update" => self.message_update(raw), - "message_end" => self.message_end(raw), - "tool_execution_start" => self.tool_execution_start(raw), - "tool_execution_update" => self.tool_execution_update(raw), - "tool_execution_end" => self.tool_execution_end(raw), - "agent_start" - | "agent_end" - | "turn_start" - | "turn_end" - | "auto_compaction" - | "auto_compaction_start" - | "auto_compaction_end" - | "auto_retry" - | "auto_retry_start" - | "auto_retry_end" - | "hook_error" => Ok(vec![status_event(event_type, raw)]), - "extension_ui_request" | "extension_ui_response" | "extension_error" => { - Ok(vec![status_event(event_type, raw)]) - } - other => Err(format!("unsupported Pi event type: {other}")), - }?; - - Ok(conversions - .into_iter() - .map(|conversion| attach_metadata(conversion, &native_session_id, raw)) - .collect()) - } - - fn next_synthetic_message_id(&mut self) -> String { - self.message_counter += 1; - format!("pi_msg_{}", self.message_counter) - } - - fn ensure_message_id(&mut self, message_id: Option<String>) -> String { - if let Some(id) = message_id { - self.last_message_id = Some(id.clone()); - return id; - } - if let Some(id) = self.last_message_id.clone() { - return id; - } - let id = self.next_synthetic_message_id(); - self.last_message_id = Some(id.clone()); - id - } - - fn ensure_message_started(&mut self, message_id: &str) -> Option<EventConversion> { - if !self.message_started.insert(message_id.to_string()) { - return None; - } - let item = UniversalItem { - item_id: String::new(), - native_item_id: Some(message_id.to_string()), - parent_id: None, - kind: ItemKind::Message, - role: Some(ItemRole::Assistant), - content: Vec::new(), - status: ItemStatus::InProgress, - }; - Some( - EventConversion::new( - UniversalEventType::ItemStarted, - UniversalEventData::Item(ItemEventData { item }), - ) - .synthetic(), - ) - } - - fn clear_last_message_id(&mut self, message_id: Option<&str>) { - if message_id.is_none() || self.last_message_id.as_deref() == message_id { - self.last_message_id = None; - } - } - - pub fn event_to_universal( - &mut self, - event: &schema::RpcEvent, - ) -> Result<Vec<EventConversion>, String> { - let raw = serde_json::to_value(event).map_err(|err| err.to_string())?; - self.event_value_to_universal(&raw) - } - - fn message_start(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> { - let message = raw.get("message"); - if is_user_role(message) { - return Ok(Vec::new()); - } - let message_id = self.ensure_message_id(extract_message_id(raw)); - self.message_completed.remove(&message_id); - self.message_started.insert(message_id.clone()); - let content = message.and_then(parse_message_content).unwrap_or_default(); - let entry = self.message_text.entry(message_id.clone()).or_default(); - for part in &content { - if let ContentPart::Text { text } = part { - entry.push_str(text); - } - } - let item = UniversalItem { - item_id: String::new(), - native_item_id: Some(message_id), - parent_id: None, - kind: ItemKind::Message, - role: Some(ItemRole::Assistant), - content, - status: ItemStatus::InProgress, - }; - Ok(vec![EventConversion::new( - UniversalEventType::ItemStarted, - UniversalEventData::Item(ItemEventData { item }), - )]) - } - - fn message_update(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> { - let assistant_event = raw - .get("assistantMessageEvent") - .or_else(|| raw.get("assistant_message_event")) - .ok_or_else(|| "missing assistantMessageEvent".to_string())?; - let event_type = assistant_event - .get("type") - .and_then(Value::as_str) - .unwrap_or(""); - let message_id = extract_message_id(raw) - .or_else(|| extract_message_id(assistant_event)) - .or_else(|| self.last_message_id.clone()); - - match event_type { - "start" => { - if let Some(id) = message_id { - self.last_message_id = Some(id); - } - Ok(Vec::new()) - } - "text_start" | "text_delta" | "text_end" => { - let Some(delta) = extract_delta_text(assistant_event) else { - return Ok(Vec::new()); - }; - let message_id = self.ensure_message_id(message_id); - let entry = self.message_text.entry(message_id.clone()).or_default(); - entry.push_str(&delta); - let mut conversions = Vec::new(); - if let Some(start) = self.ensure_message_started(&message_id) { - conversions.push(start); - } - conversions.push(item_delta(Some(message_id), delta)); - Ok(conversions) - } - "thinking_start" | "thinking_delta" | "thinking_end" => { - let Some(delta) = extract_delta_text(assistant_event) else { - return Ok(Vec::new()); - }; - let message_id = self.ensure_message_id(message_id); - let entry = self - .message_reasoning - .entry(message_id.clone()) - .or_default(); - entry.push_str(&delta); - let mut conversions = Vec::new(); - if let Some(start) = self.ensure_message_started(&message_id) { - conversions.push(start); - } - conversions.push(item_delta(Some(message_id), delta)); - Ok(conversions) - } - "toolcall_start" - | "toolcall_delta" - | "toolcall_end" - | "toolcall_args_start" - | "toolcall_args_delta" - | "toolcall_args_end" => Ok(Vec::new()), - "done" => { - let message_id = self.ensure_message_id(message_id); - if self.message_errors.remove(&message_id) { - self.message_text.remove(&message_id); - self.message_reasoning.remove(&message_id); - self.message_started.remove(&message_id); - self.clear_last_message_id(Some(&message_id)); - return Ok(Vec::new()); - } - if self.message_completed.contains(&message_id) { - self.clear_last_message_id(Some(&message_id)); - return Ok(Vec::new()); - } - let message = raw - .get("message") - .or_else(|| assistant_event.get("message")); - let conversion = self.complete_message(Some(message_id.clone()), message); - self.message_completed.insert(message_id.clone()); - self.clear_last_message_id(Some(&message_id)); - Ok(vec![conversion]) - } - "error" => { - let message_id = self.ensure_message_id(message_id); - if self.message_completed.contains(&message_id) { - self.clear_last_message_id(Some(&message_id)); - return Ok(Vec::new()); - } - let error_text = assistant_event - .get("error") - .or_else(|| raw.get("error")) - .map(value_to_string) - .unwrap_or_else(|| "Pi message error".to_string()); - self.message_reasoning.remove(&message_id); - self.message_text.remove(&message_id); - self.message_errors.insert(message_id.clone()); - self.message_started.remove(&message_id); - self.message_completed.insert(message_id.clone()); - self.clear_last_message_id(Some(&message_id)); - let item = UniversalItem { - item_id: String::new(), - native_item_id: Some(message_id), - parent_id: None, - kind: ItemKind::Message, - role: Some(ItemRole::Assistant), - content: vec![ContentPart::Text { text: error_text }], - status: ItemStatus::Failed, - }; - Ok(vec![EventConversion::new( - UniversalEventType::ItemCompleted, - UniversalEventData::Item(ItemEventData { item }), - )]) - } - other => Err(format!("unsupported assistantMessageEvent: {other}")), - } - } - - fn message_end(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> { - let message = raw.get("message"); - if is_user_role(message) { - return Ok(Vec::new()); - } - let message_id = self - .ensure_message_id(extract_message_id(raw).or_else(|| self.last_message_id.clone())); - if self.message_errors.remove(&message_id) { - self.message_text.remove(&message_id); - self.message_reasoning.remove(&message_id); - self.message_started.remove(&message_id); - self.clear_last_message_id(Some(&message_id)); - return Ok(Vec::new()); - } - if self.message_completed.contains(&message_id) { - self.clear_last_message_id(Some(&message_id)); - return Ok(Vec::new()); - } - let conversion = self.complete_message(Some(message_id.clone()), message); - self.message_completed.insert(message_id.clone()); - self.clear_last_message_id(Some(&message_id)); - Ok(vec![conversion]) - } - - fn complete_message( - &mut self, - message_id: Option<String>, - message: Option<&Value>, - ) -> EventConversion { - let mut content = message.and_then(parse_message_content).unwrap_or_default(); - let failed = message_is_failed(message); - let message_error_text = extract_message_error_text(message); - - if let Some(id) = message_id.clone() { - if content.is_empty() { - if let Some(text) = self.message_text.remove(&id) { - if !text.is_empty() { - content.push(ContentPart::Text { text }); - } - } - } else { - self.message_text.remove(&id); - } - - if let Some(reasoning) = self.message_reasoning.remove(&id) { - if !reasoning.trim().is_empty() - && !content - .iter() - .any(|part| matches!(part, ContentPart::Reasoning { .. })) - { - content.push(ContentPart::Reasoning { - text: reasoning, - visibility: ReasoningVisibility::Private, - }); - } - } - self.message_started.remove(&id); - } - - if failed && content.is_empty() { - if let Some(text) = message_error_text { - content.push(ContentPart::Text { text }); - } - } - - let item = UniversalItem { - item_id: String::new(), - native_item_id: message_id, - parent_id: None, - kind: ItemKind::Message, - role: Some(ItemRole::Assistant), - content, - status: if failed { - ItemStatus::Failed - } else { - ItemStatus::Completed - }, - }; - EventConversion::new( - UniversalEventType::ItemCompleted, - UniversalEventData::Item(ItemEventData { item }), - ) - } - - fn tool_execution_start(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> { - let tool_call_id = - extract_tool_call_id(raw).ok_or_else(|| "missing toolCallId".to_string())?; - let tool_name = extract_tool_name(raw).unwrap_or_else(|| "tool".to_string()); - let arguments = raw - .get("args") - .or_else(|| raw.get("arguments")) - .map(value_to_string) - .unwrap_or_else(|| "{}".to_string()); - let item = UniversalItem { - item_id: String::new(), - native_item_id: Some(tool_call_id.clone()), - parent_id: None, - kind: ItemKind::ToolCall, - role: Some(ItemRole::Assistant), - content: vec![ContentPart::ToolCall { - name: tool_name, - arguments, - call_id: tool_call_id, - }], - status: ItemStatus::InProgress, - }; - Ok(vec![EventConversion::new( - UniversalEventType::ItemStarted, - UniversalEventData::Item(ItemEventData { item }), - )]) - } - - fn tool_execution_update(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> { - let tool_call_id = match extract_tool_call_id(raw) { - Some(id) => id, - None => return Ok(Vec::new()), - }; - let partial = match raw - .get("partialResult") - .or_else(|| raw.get("partial_result")) - { - Some(value) => value_to_string(value), - None => return Ok(Vec::new()), - }; - let prior = self - .tool_result_buffers - .get(&tool_call_id) - .cloned() - .unwrap_or_default(); - let delta = delta_from_partial(&prior, &partial); - self.tool_result_buffers - .insert(tool_call_id.clone(), partial); - - let mut conversions = Vec::new(); - if self.tool_result_started.insert(tool_call_id.clone()) { - let item = UniversalItem { - item_id: String::new(), - native_item_id: Some(tool_call_id.clone()), - parent_id: None, - kind: ItemKind::ToolResult, - role: Some(ItemRole::Tool), - content: vec![ContentPart::ToolResult { - call_id: tool_call_id.clone(), - output: String::new(), - }], - status: ItemStatus::InProgress, - }; - conversions.push( - EventConversion::new( - UniversalEventType::ItemStarted, - UniversalEventData::Item(ItemEventData { item }), - ) - .synthetic(), - ); - } - - if !delta.is_empty() { - conversions.push( - EventConversion::new( - UniversalEventType::ItemDelta, - UniversalEventData::ItemDelta(ItemDeltaData { - item_id: String::new(), - native_item_id: Some(tool_call_id.clone()), - delta, - }), - ) - .synthetic(), - ); - } - - Ok(conversions) - } - - fn tool_execution_end(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> { - let tool_call_id = - extract_tool_call_id(raw).ok_or_else(|| "missing toolCallId".to_string())?; - self.tool_result_buffers.remove(&tool_call_id); - self.tool_result_started.remove(&tool_call_id); - - let output = raw - .get("result") - .and_then(extract_result_content) - .unwrap_or_default(); - let is_error = raw.get("isError").and_then(Value::as_bool).unwrap_or(false); - let item = UniversalItem { - item_id: String::new(), - native_item_id: Some(tool_call_id.clone()), - parent_id: None, - kind: ItemKind::ToolResult, - role: Some(ItemRole::Tool), - content: vec![ContentPart::ToolResult { - call_id: tool_call_id, - output, - }], - status: if is_error { - ItemStatus::Failed - } else { - ItemStatus::Completed - }, - }; - Ok(vec![EventConversion::new( - UniversalEventType::ItemCompleted, - UniversalEventData::Item(ItemEventData { item }), - )]) - } -} - -pub fn event_to_universal(event: &schema::RpcEvent) -> Result<Vec<EventConversion>, String> { - PiEventConverter::default().event_to_universal(event) -} - -pub fn event_value_to_universal(raw: &Value) -> Result<Vec<EventConversion>, String> { - PiEventConverter::default().event_value_to_universal(raw) -} - -fn attach_metadata( - conversion: EventConversion, - native_session_id: &Option<String>, - raw: &Value, -) -> EventConversion { - conversion - .with_native_session(native_session_id.clone()) - .with_raw(Some(raw.clone())) -} - -fn status_event(label: &str, raw: &Value) -> EventConversion { - let detail = raw - .get("error") - .or_else(|| raw.get("message")) - .map(value_to_string); - let item = UniversalItem { - item_id: String::new(), - native_item_id: None, - parent_id: None, - kind: ItemKind::Status, - role: Some(ItemRole::System), - content: vec![ContentPart::Status { - label: pi_status_label(label), - detail, - }], - status: ItemStatus::Completed, - }; - EventConversion::new( - UniversalEventType::ItemCompleted, - UniversalEventData::Item(ItemEventData { item }), - ) -} - -fn pi_status_label(label: &str) -> String { - match label { - "turn_end" => "turn.completed".to_string(), - "agent_end" => "session.idle".to_string(), - _ => format!("pi.{label}"), - } -} - -fn item_delta(message_id: Option<String>, delta: String) -> EventConversion { - EventConversion::new( - UniversalEventType::ItemDelta, - UniversalEventData::ItemDelta(ItemDeltaData { - item_id: String::new(), - native_item_id: message_id, - delta, - }), - ) -} - -fn is_user_role(message: Option<&Value>) -> bool { - message - .and_then(|msg| msg.get("role")) - .and_then(Value::as_str) - .is_some_and(|role| role == "user") -} - -fn extract_session_id(value: &Value) -> Option<String> { - extract_string(value, &["sessionId"]) - .or_else(|| extract_string(value, &["session_id"])) - .or_else(|| extract_string(value, &["session", "id"])) - .or_else(|| extract_string(value, &["message", "sessionId"])) -} - -fn extract_message_id(value: &Value) -> Option<String> { - extract_string(value, &["messageId"]) - .or_else(|| extract_string(value, &["message_id"])) - .or_else(|| extract_string(value, &["message", "id"])) - .or_else(|| extract_string(value, &["message", "messageId"])) - .or_else(|| extract_string(value, &["assistantMessageEvent", "messageId"])) -} - -fn extract_tool_call_id(value: &Value) -> Option<String> { - extract_string(value, &["toolCallId"]).or_else(|| extract_string(value, &["tool_call_id"])) -} - -fn extract_tool_name(value: &Value) -> Option<String> { - extract_string(value, &["toolName"]).or_else(|| extract_string(value, &["tool_name"])) -} - -fn extract_string(value: &Value, path: &[&str]) -> Option<String> { - let mut current = value; - for key in path { - current = current.get(*key)?; - } - current.as_str().map(|value| value.to_string()) -} - -fn extract_delta_text(event: &Value) -> Option<String> { - if let Some(value) = event.get("delta") { - return Some(value_to_string(value)); - } - if let Some(value) = event.get("text") { - return Some(value_to_string(value)); - } - if let Some(value) = event.get("partial") { - return extract_text_from_value(value); - } - if let Some(value) = event.get("content") { - return extract_text_from_value(value); - } - None -} - -fn extract_text_from_value(value: &Value) -> Option<String> { - if let Some(text) = value.as_str() { - return Some(text.to_string()); - } - if let Some(text) = value.get("text").and_then(Value::as_str) { - return Some(text.to_string()); - } - if let Some(text) = value.get("content").and_then(Value::as_str) { - return Some(text.to_string()); - } - None -} - -fn extract_result_content(value: &Value) -> Option<String> { - let content = value.get("content").and_then(Value::as_str); - let text = value.get("text").and_then(Value::as_str); - content - .or(text) - .map(|value| value.to_string()) - .or_else(|| Some(value_to_string(value))) -} - -fn parse_message_content(message: &Value) -> Option<Vec<ContentPart>> { - if let Some(text) = message.as_str() { - return Some(vec![ContentPart::Text { - text: text.to_string(), - }]); - } - let content_value = message - .get("content") - .or_else(|| message.get("text")) - .or_else(|| message.get("value"))?; - let mut parts = Vec::new(); - match content_value { - Value::String(text) => parts.push(ContentPart::Text { text: text.clone() }), - Value::Array(items) => { - for item in items { - if let Some(part) = content_part_from_value(item) { - parts.push(part); - } - } - } - Value::Object(_) => { - if let Some(part) = content_part_from_value(content_value) { - parts.push(part); - } - } - _ => {} - } - Some(parts) -} - -fn message_is_failed(message: Option<&Value>) -> bool { - message - .and_then(|value| { - value - .get("stopReason") - .or_else(|| value.get("stop_reason")) - .and_then(Value::as_str) - }) - .is_some_and(|reason| reason == "error" || reason == "aborted") -} - -fn extract_message_error_text(message: Option<&Value>) -> Option<String> { - let value = message?; - - if let Some(text) = value - .get("errorMessage") - .or_else(|| value.get("error_message")) - .and_then(Value::as_str) - { - let trimmed = text.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - - let error = value.get("error")?; - if let Some(text) = error.as_str() { - let trimmed = text.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - if let Some(text) = error - .get("errorMessage") - .or_else(|| error.get("error_message")) - .or_else(|| error.get("message")) - .and_then(Value::as_str) - { - let trimmed = text.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - - None -} - -fn content_part_from_value(value: &Value) -> Option<ContentPart> { - if let Some(text) = value.as_str() { - return Some(ContentPart::Text { - text: text.to_string(), - }); - } - let part_type = value.get("type").and_then(Value::as_str); - match part_type { - Some("text") | Some("markdown") => { - extract_text_from_value(value).map(|text| ContentPart::Text { text }) - } - Some("thinking") | Some("reasoning") => { - extract_text_from_value(value).map(|text| ContentPart::Reasoning { - text, - visibility: ReasoningVisibility::Private, - }) - } - Some("image") => value - .get("path") - .or_else(|| value.get("url")) - .and_then(|path| { - path.as_str().map(|path| ContentPart::Image { - path: path.to_string(), - mime: value - .get("mime") - .or_else(|| value.get("mimeType")) - .and_then(Value::as_str) - .map(|mime| mime.to_string()), - }) - }), - Some("tool_call") | Some("toolcall") => { - let name = value - .get("name") - .and_then(Value::as_str) - .unwrap_or("tool") - .to_string(); - let arguments = value - .get("arguments") - .or_else(|| value.get("args")) - .map(value_to_string) - .unwrap_or_else(|| "{}".to_string()); - let call_id = value - .get("call_id") - .or_else(|| value.get("callId")) - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - Some(ContentPart::ToolCall { - name, - arguments, - call_id, - }) - } - Some("tool_result") => { - let call_id = value - .get("call_id") - .or_else(|| value.get("callId")) - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let output = value - .get("output") - .or_else(|| value.get("content")) - .map(value_to_string) - .unwrap_or_default(); - Some(ContentPart::ToolResult { call_id, output }) - } - _ => Some(ContentPart::Json { - json: value.clone(), - }), - } -} - -fn value_to_string(value: &Value) -> String { - if let Some(text) = value.as_str() { - text.to_string() - } else { - value.to_string() - } -} - -fn delta_from_partial(previous: &str, next: &str) -> String { - if next.starts_with(previous) { - next[previous.len()..].to_string() - } else { - next.to_string() - } -} diff --git a/server/packages/universal-agent-schema/tests/pi_conversion.rs b/server/packages/universal-agent-schema/tests/pi_conversion.rs deleted file mode 100644 index eb4cd72..0000000 --- a/server/packages/universal-agent-schema/tests/pi_conversion.rs +++ /dev/null @@ -1,414 +0,0 @@ -use sandbox_agent_universal_agent_schema::convert_pi::PiEventConverter; -use sandbox_agent_universal_agent_schema::pi as pi_schema; -use sandbox_agent_universal_agent_schema::{ - ContentPart, ItemKind, ItemRole, ItemStatus, UniversalEventData, UniversalEventType, -}; -use serde_json::json; - -fn parse_event(value: serde_json::Value) -> pi_schema::RpcEvent { - serde_json::from_value(value).expect("pi event") -} - -#[test] -fn pi_message_flow_converts() { - let mut converter = PiEventConverter::default(); - - let start_event = parse_event(json!({ - "type": "message_start", - "sessionId": "session-1", - "messageId": "msg-1", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "Hello" }] - } - })); - let start_events = converter - .event_to_universal(&start_event) - .expect("start conversions"); - assert_eq!(start_events[0].event_type, UniversalEventType::ItemStarted); - if let UniversalEventData::Item(item) = &start_events[0].data { - assert_eq!(item.item.kind, ItemKind::Message); - assert_eq!(item.item.role, Some(ItemRole::Assistant)); - assert_eq!(item.item.status, ItemStatus::InProgress); - } else { - panic!("expected item event"); - } - - let update_event = parse_event(json!({ - "type": "message_update", - "sessionId": "session-1", - "messageId": "msg-1", - "assistantMessageEvent": { "type": "text_delta", "delta": " world" } - })); - let update_events = converter - .event_to_universal(&update_event) - .expect("update conversions"); - assert_eq!(update_events[0].event_type, UniversalEventType::ItemDelta); - - let end_event = parse_event(json!({ - "type": "message_end", - "sessionId": "session-1", - "messageId": "msg-1", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "Hello world" }] - } - })); - let end_events = converter - .event_to_universal(&end_event) - .expect("end conversions"); - assert_eq!(end_events[0].event_type, UniversalEventType::ItemCompleted); - if let UniversalEventData::Item(item) = &end_events[0].data { - assert_eq!(item.item.kind, ItemKind::Message); - assert_eq!(item.item.role, Some(ItemRole::Assistant)); - assert_eq!(item.item.status, ItemStatus::Completed); - } else { - panic!("expected item event"); - } -} - -#[test] -fn pi_user_message_echo_is_skipped() { - let mut converter = PiEventConverter::default(); - - // Pi may echo the user message as a message_start with role "user". - // The daemon already records synthetic user events, so the converter - // must skip these to avoid a duplicate assistant-looking bubble. - let start_event = parse_event(json!({ - "type": "message_start", - "sessionId": "session-1", - "messageId": "user-msg-1", - "message": { - "role": "user", - "content": [{ "type": "text", "text": "hello!" }] - } - })); - let events = converter - .event_to_universal(&start_event) - .expect("user message_start should not error"); - assert!( - events.is_empty(), - "user message_start should produce no events, got {}", - events.len() - ); - - let end_event = parse_event(json!({ - "type": "message_end", - "sessionId": "session-1", - "messageId": "user-msg-1", - "message": { - "role": "user", - "content": [{ "type": "text", "text": "hello!" }] - } - })); - let events = converter - .event_to_universal(&end_event) - .expect("user message_end should not error"); - assert!( - events.is_empty(), - "user message_end should produce no events, got {}", - events.len() - ); - - // A subsequent assistant message should still work normally. - let assistant_start = parse_event(json!({ - "type": "message_start", - "sessionId": "session-1", - "messageId": "msg-1", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "Hello! How can I help?" }] - } - })); - let events = converter - .event_to_universal(&assistant_start) - .expect("assistant message_start"); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, UniversalEventType::ItemStarted); - if let UniversalEventData::Item(item) = &events[0].data { - assert_eq!(item.item.role, Some(ItemRole::Assistant)); - } else { - panic!("expected item event"); - } -} - -#[test] -fn pi_tool_execution_converts_with_partial_deltas() { - let mut converter = PiEventConverter::default(); - - let start_event = parse_event(json!({ - "type": "tool_execution_start", - "sessionId": "session-1", - "toolCallId": "call-1", - "toolName": "bash", - "args": { "command": "ls" } - })); - let start_events = converter - .event_to_universal(&start_event) - .expect("tool start"); - assert_eq!(start_events[0].event_type, UniversalEventType::ItemStarted); - if let UniversalEventData::Item(item) = &start_events[0].data { - assert_eq!(item.item.kind, ItemKind::ToolCall); - assert_eq!(item.item.role, Some(ItemRole::Assistant)); - match &item.item.content[0] { - ContentPart::ToolCall { name, .. } => assert_eq!(name, "bash"), - _ => panic!("expected tool call content"), - } - } - - let update_event = parse_event(json!({ - "type": "tool_execution_update", - "sessionId": "session-1", - "toolCallId": "call-1", - "partialResult": "foo" - })); - let update_events = converter - .event_to_universal(&update_event) - .expect("tool update"); - assert!(update_events - .iter() - .any(|event| event.event_type == UniversalEventType::ItemDelta)); - - let update_event2 = parse_event(json!({ - "type": "tool_execution_update", - "sessionId": "session-1", - "toolCallId": "call-1", - "partialResult": "foobar" - })); - let update_events2 = converter - .event_to_universal(&update_event2) - .expect("tool update 2"); - let delta = update_events2 - .iter() - .find_map(|event| match &event.data { - UniversalEventData::ItemDelta(data) => Some(data.delta.clone()), - _ => None, - }) - .unwrap_or_default(); - assert_eq!(delta, "bar"); - - let end_event = parse_event(json!({ - "type": "tool_execution_end", - "sessionId": "session-1", - "toolCallId": "call-1", - "result": { "type": "text", "content": "done" }, - "isError": false - })); - let end_events = converter.event_to_universal(&end_event).expect("tool end"); - assert_eq!(end_events[0].event_type, UniversalEventType::ItemCompleted); - if let UniversalEventData::Item(item) = &end_events[0].data { - assert_eq!(item.item.kind, ItemKind::ToolResult); - assert_eq!(item.item.role, Some(ItemRole::Tool)); - match &item.item.content[0] { - ContentPart::ToolResult { output, .. } => assert_eq!(output, "done"), - _ => panic!("expected tool result content"), - } - } -} - -#[test] -fn pi_unknown_event_returns_error() { - let mut converter = PiEventConverter::default(); - let event = parse_event(json!({ - "type": "unknown_event", - "sessionId": "session-1" - })); - assert!(converter.event_to_universal(&event).is_err()); -} - -#[test] -fn pi_turn_and_agent_end_emit_terminal_status_labels() { - let mut converter = PiEventConverter::default(); - - let turn_end = parse_event(json!({ - "type": "turn_end", - "sessionId": "session-1" - })); - let turn_events = converter - .event_to_universal(&turn_end) - .expect("turn_end conversions"); - assert_eq!(turn_events[0].event_type, UniversalEventType::ItemCompleted); - if let UniversalEventData::Item(item) = &turn_events[0].data { - assert_eq!(item.item.kind, ItemKind::Status); - assert!( - matches!( - item.item.content.first(), - Some(ContentPart::Status { label, .. }) if label == "turn.completed" - ), - "turn_end should map to turn.completed status" - ); - } else { - panic!("expected item event"); - } - - let agent_end = parse_event(json!({ - "type": "agent_end", - "sessionId": "session-1" - })); - let agent_events = converter - .event_to_universal(&agent_end) - .expect("agent_end conversions"); - assert_eq!( - agent_events[0].event_type, - UniversalEventType::ItemCompleted - ); - if let UniversalEventData::Item(item) = &agent_events[0].data { - assert_eq!(item.item.kind, ItemKind::Status); - assert!( - matches!( - item.item.content.first(), - Some(ContentPart::Status { label, .. }) if label == "session.idle" - ), - "agent_end should map to session.idle status" - ); - } else { - panic!("expected item event"); - } -} - -#[test] -fn pi_message_done_completes_without_message_end() { - let mut converter = PiEventConverter::default(); - - let start_event = parse_event(json!({ - "type": "message_start", - "sessionId": "session-1", - "messageId": "msg-1", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "Hello" }] - } - })); - let _start_events = converter - .event_to_universal(&start_event) - .expect("start conversions"); - - let update_event = parse_event(json!({ - "type": "message_update", - "sessionId": "session-1", - "messageId": "msg-1", - "assistantMessageEvent": { "type": "text_delta", "delta": " world" } - })); - let _update_events = converter - .event_to_universal(&update_event) - .expect("update conversions"); - - let done_event = parse_event(json!({ - "type": "message_update", - "sessionId": "session-1", - "messageId": "msg-1", - "assistantMessageEvent": { "type": "done" } - })); - let done_events = converter - .event_to_universal(&done_event) - .expect("done conversions"); - assert_eq!(done_events[0].event_type, UniversalEventType::ItemCompleted); - if let UniversalEventData::Item(item) = &done_events[0].data { - assert_eq!(item.item.status, ItemStatus::Completed); - assert!( - matches!(item.item.content.get(0), Some(ContentPart::Text { text }) if text == "Hello world") - ); - } else { - panic!("expected item event"); - } -} - -#[test] -fn pi_message_done_then_message_end_does_not_double_complete() { - let mut converter = PiEventConverter::default(); - - let start_event = parse_event(json!({ - "type": "message_start", - "sessionId": "session-1", - "messageId": "msg-1", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "Hello" }] - } - })); - let _ = converter - .event_to_universal(&start_event) - .expect("start conversions"); - - let update_event = parse_event(json!({ - "type": "message_update", - "sessionId": "session-1", - "messageId": "msg-1", - "assistantMessageEvent": { "type": "text_delta", "delta": " world" } - })); - let _ = converter - .event_to_universal(&update_event) - .expect("update conversions"); - - let done_event = parse_event(json!({ - "type": "message_update", - "sessionId": "session-1", - "messageId": "msg-1", - "assistantMessageEvent": { "type": "done" } - })); - let done_events = converter - .event_to_universal(&done_event) - .expect("done conversions"); - assert_eq!(done_events.len(), 1); - assert_eq!(done_events[0].event_type, UniversalEventType::ItemCompleted); - - let end_event = parse_event(json!({ - "type": "message_end", - "sessionId": "session-1", - "messageId": "msg-1", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "Hello world" }] - } - })); - let end_events = converter - .event_to_universal(&end_event) - .expect("end conversions"); - assert!( - end_events.is_empty(), - "message_end after done should not emit a second completion" - ); -} - -#[test] -fn pi_message_end_error_surfaces_failed_status_and_error_text() { - let mut converter = PiEventConverter::default(); - - let start_event = parse_event(json!({ - "type": "message_start", - "sessionId": "session-1", - "messageId": "msg-err", - "message": { - "role": "assistant", - "content": [] - } - })); - let _ = converter - .event_to_universal(&start_event) - .expect("start conversions"); - - let end_raw = json!({ - "type": "message_end", - "sessionId": "session-1", - "messageId": "msg-err", - "message": { - "role": "assistant", - "content": [], - "stopReason": "error", - "errorMessage": "Connection error." - } - }); - let end_events = converter - .event_value_to_universal(&end_raw) - .expect("end conversions"); - - assert_eq!(end_events[0].event_type, UniversalEventType::ItemCompleted); - if let UniversalEventData::Item(item) = &end_events[0].data { - assert_eq!(item.item.status, ItemStatus::Failed); - assert!( - matches!(item.item.content.first(), Some(ContentPart::Text { text }) if text == "Connection error.") - ); - } else { - panic!("expected item event"); - } -}