feat: add opencode compatibility layer (#68)

This commit is contained in:
Nathan Flurry 2026-02-04 13:43:05 -08:00 committed by GitHub
parent cc5a9e0d73
commit ef3e811c94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 18163 additions and 310 deletions

View file

@ -64,10 +64,22 @@ Universal schema guidance:
- `sandbox-agent api sessions reject-question``POST /v1/sessions/{sessionId}/questions/{questionId}/reject`
- `sandbox-agent api sessions reply-permission``POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply`
## OpenCode CLI (Experimental)
`sandbox-agent opencode` starts a sandbox-agent server and attaches an OpenCode session (uses `/opencode`).
## Post-Release Testing
After cutting a release, verify the release works correctly. Run `/project:post-release-testing` to execute the testing agent.
## OpenCode Compatibility Tests
The OpenCode compatibility suite lives at `server/packages/sandbox-agent/tests/opencode-compat` and validates the `@opencode-ai/sdk` against the `/opencode` API. Run it with:
```bash
SANDBOX_AGENT_SKIP_INSPECTOR=1 pnpm --filter @sandbox-agent/opencode-compat-tests test
```
## Git Commits
- Do not include any co-authors in commit messages (no `Co-Authored-By` lines)

View file

@ -68,6 +68,7 @@ zip = { version = "0.6", default-features = false, features = ["deflate"] }
url = "2.5"
regress = "0.10"
include_dir = "0.7"
base64 = "0.22"
# Code generation (build deps)
typify = "0.4"

View file

@ -29,6 +29,12 @@ sandbox-agent server [OPTIONS]
sandbox-agent server --token "$TOKEN" --port 3000
```
Server logs are redirected to a daily log file under the sandbox-agent data directory (for example, `~/.local/share/sandbox-agent/logs`). Override with `SANDBOX_AGENT_LOG_DIR`, or set `SANDBOX_AGENT_LOG_STDOUT=1` to keep logs on stdout/stderr.
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)
---
## Install Agent (Local)
@ -49,6 +55,31 @@ sandbox-agent install-agent claude --reinstall
---
## OpenCode (Experimental)
Start a sandbox-agent server and attach an OpenCode session (uses `opencode attach`):
```bash
sandbox-agent opencode [OPTIONS]
```
| Option | Default | Description |
|--------|---------|-------------|
| `-t, --token <TOKEN>` | - | Authentication token for all requests |
| `-n, --no-token` | - | Disable authentication (local dev only) |
| `-H, --host <HOST>` | `127.0.0.1` | Host to bind to |
| `-p, --port <PORT>` | `2468` | Port to bind to |
| `--session-title <TITLE>` | - | Title for the OpenCode session |
| `--opencode-bin <PATH>` | - | Override `opencode` binary path |
```bash
sandbox-agent opencode --token "$TOKEN"
```
Requires the `opencode` binary to be installed (or set `OPENCODE_BIN` / `--opencode-bin`).
---
## Credentials
### Extract

View file

@ -61,11 +61,12 @@
},
{
"group": "Reference",
"pages": [
"cli",
"inspector",
"session-transcript-schema",
"cors",
"pages": [
"cli",
"opencode-compatibility",
"inspector",
"session-transcript-schema",
"cors",
{
"group": "AI",
"pages": ["ai/skill", "ai/llms-txt"]

View file

@ -0,0 +1,108 @@
---
title: "OpenCode Compatibility"
description: "OpenCode endpoint coverage for /opencode."
---
Sandbox Agent exposes OpenCode-compatible endpoints under `/opencode`.
Authentication matches `/v1`: if a token is configured, requests must include `Authorization: Bearer <token>`.
| Method | Path | Status | Notes | Tests |
|---|---|---|---|---|
| GET | /agent | Stubbed | Single stub agent entry. | E2E: openapi-coverage |
| DELETE | /auth/{providerID} | Stubbed | | E2E: openapi-coverage |
| PUT | /auth/{providerID} | Stubbed | | E2E: openapi-coverage |
| GET | /command | Stubbed | | E2E: openapi-coverage |
| GET | /config | Stubbed | Returns/echoes config payloads. | E2E: openapi-coverage |
| PATCH | /config | Stubbed | Returns/echoes config payloads. | E2E: openapi-coverage |
| GET | /config/providers | Stubbed | Returns/echoes config payloads. | E2E: openapi-coverage |
| GET | /event | SSE stub | Emits compat events for session/message/pty updates only. | E2E: openapi-coverage, events |
| GET | /experimental/resource | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage |
| GET | /experimental/tool | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage |
| GET | /experimental/tool/ids | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage |
| DELETE | /experimental/worktree | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage |
| GET | /experimental/worktree | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage |
| POST | /experimental/worktree | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage |
| POST | /experimental/worktree/reset | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage |
| GET | /file | Stubbed | Returns empty lists/content; no filesystem access. | E2E: openapi-coverage |
| GET | /file/content | Stubbed | Returns empty lists/content; no filesystem access. | E2E: openapi-coverage |
| GET | /file/status | Stubbed | Returns empty lists/content; no filesystem access. | E2E: openapi-coverage |
| GET | /find | Stubbed | Returns empty results. | E2E: openapi-coverage |
| GET | /find/file | Stubbed | Returns empty results. | E2E: openapi-coverage |
| GET | /find/symbol | Stubbed | Returns empty results. | E2E: openapi-coverage |
| GET | /formatter | Stubbed | | E2E: openapi-coverage |
| GET | /global/config | Stubbed | | E2E: openapi-coverage |
| PATCH | /global/config | Stubbed | | E2E: openapi-coverage |
| POST | /global/dispose | Stubbed | | E2E: openapi-coverage |
| GET | /global/event | SSE stub | Wraps compat events in GlobalEvent; no external sources. | E2E: openapi-coverage, events |
| GET | /global/health | Stubbed | | E2E: openapi-coverage |
| POST | /instance/dispose | Stubbed | | E2E: openapi-coverage |
| POST | /log | Stubbed | | E2E: openapi-coverage |
| GET | /lsp | Stubbed | | E2E: openapi-coverage |
| GET | /mcp | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage |
| POST | /mcp | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage |
| DELETE | /mcp/{name}/auth | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage |
| POST | /mcp/{name}/auth | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage |
| POST | /mcp/{name}/auth/authenticate | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage |
| POST | /mcp/{name}/auth/callback | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage |
| POST | /mcp/{name}/connect | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage |
| POST | /mcp/{name}/disconnect | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage |
| GET | /path | Derived stub | | E2E: openapi-coverage |
| GET | /permission | Stubbed | | E2E: openapi-coverage, permissions |
| POST | /permission/{requestID}/reply | Stubbed | | E2E: openapi-coverage, permissions |
| GET | /project | Derived stub | | E2E: openapi-coverage |
| PATCH | /project/{projectID} | Derived stub | | E2E: openapi-coverage |
| GET | /project/current | Derived stub | | E2E: openapi-coverage |
| GET | /provider | Stubbed | Returns empty provider metadata. | E2E: openapi-coverage |
| POST | /provider/{providerID}/oauth/authorize | Stubbed | Returns empty provider metadata. | E2E: openapi-coverage |
| POST | /provider/{providerID}/oauth/callback | Stubbed | Returns empty provider metadata. | E2E: openapi-coverage |
| GET | /provider/auth | Stubbed | Returns empty provider metadata. | E2E: openapi-coverage |
| GET | /pty | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage |
| POST | /pty | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage |
| DELETE | /pty/{ptyID} | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage |
| GET | /pty/{ptyID} | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage |
| PUT | /pty/{ptyID} | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage |
| GET | /pty/{ptyID}/connect | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage |
| GET | /question | Stubbed | | E2E: openapi-coverage, permissions |
| POST | /question/{requestID}/reject | Stubbed | | E2E: openapi-coverage, permissions |
| POST | /question/{requestID}/reply | Stubbed | | E2E: openapi-coverage, permissions |
| GET | /session | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, session |
| POST | /session | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, session |
| DELETE | /session/{sessionID} | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, session |
| GET | /session/{sessionID} | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, session |
| PATCH | /session/{sessionID} | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, session |
| POST | /session/{sessionID}/abort | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, messaging |
| GET | /session/{sessionID}/children | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
| POST | /session/{sessionID}/command | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
| GET | /session/{sessionID}/diff | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
| POST | /session/{sessionID}/fork | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
| POST | /session/{sessionID}/init | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
| GET | /session/{sessionID}/message | Stateful stub | In-memory messages; assistant replies are stubbed. | E2E: openapi-coverage, messaging, events |
| POST | /session/{sessionID}/message | Stateful stub | In-memory messages; assistant replies are stubbed. | E2E: openapi-coverage, messaging, events |
| GET | /session/{sessionID}/message/{messageID} | Stateful stub | In-memory messages; assistant replies are stubbed. | E2E: openapi-coverage, messaging |
| DELETE | /session/{sessionID}/message/{messageID}/part/{partID} | Stateful stub | In-memory messages; assistant replies are stubbed. | E2E: openapi-coverage, messaging |
| PATCH | /session/{sessionID}/message/{messageID}/part/{partID} | Stateful stub | In-memory messages; assistant replies are stubbed. | E2E: openapi-coverage, messaging |
| POST | /session/{sessionID}/permissions/{permissionID} | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
| POST | /session/{sessionID}/prompt_async | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, messaging |
| POST | /session/{sessionID}/revert | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
| DELETE | /session/{sessionID}/share | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
| POST | /session/{sessionID}/share | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
| POST | /session/{sessionID}/shell | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
| POST | /session/{sessionID}/summarize | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
| GET | /session/{sessionID}/todo | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
| POST | /session/{sessionID}/unrevert | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage |
| GET | /session/status | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, events |
| GET | /skill | Stubbed | Returns empty skills list. | E2E: openapi-coverage |
| POST | /tui/append-prompt | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
| POST | /tui/clear-prompt | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
| GET | /tui/control/next | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
| POST | /tui/control/response | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
| POST | /tui/execute-command | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
| POST | /tui/open-help | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
| POST | /tui/open-models | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
| POST | /tui/open-sessions | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
| POST | /tui/open-themes | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
| POST | /tui/publish | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
| POST | /tui/select-session | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
| POST | /tui/show-toast | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
| POST | /tui/submit-prompt | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage |
| GET | /vcs | Derived stub | | E2E: openapi-coverage |

553
research/agents/openclaw.md Normal file
View file

@ -0,0 +1,553 @@
# OpenClaw (formerly Clawdbot) Research
Research notes on OpenClaw's architecture, API, and automation patterns for integration with sandbox-agent.
## Overview
- **Provider**: Multi-provider (Anthropic, OpenAI, etc. via Pi agent)
- **Execution Method**: WebSocket Gateway + HTTP APIs
- **Session Persistence**: Session Key (string) + Session ID (UUID)
- **SDK**: No official SDK - uses WebSocket/HTTP protocol directly
- **Binary**: `clawdbot` (npm global install or local)
- **Default Port**: 18789 (WebSocket + HTTP multiplex)
## Architecture
OpenClaw is architected differently from other coding agents (Claude Code, Codex, OpenCode, Amp):
```
┌─────────────────────────────────────┐
│ Gateway Service │ ws://127.0.0.1:18789
│ (long-running daemon) │ http://127.0.0.1:18789
│ │
│ ┌─────────────────────────────┐ │
│ │ Pi Agent (embedded RPC) │ │
│ │ - Tool execution │ │
│ │ - Model routing │ │
│ │ - Session management │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
├── WebSocket (full control plane)
├── HTTP /v1/chat/completions (OpenAI-compatible)
├── HTTP /v1/responses (OpenResponses-compatible)
├── HTTP /tools/invoke (single tool invocation)
└── HTTP /hooks/agent (webhook triggers)
```
**Key Difference**: OpenClaw runs as a **daemon** that owns the agent runtime. Other agents (Claude, Codex, Amp) spawn a subprocess per turn. OpenClaw is more similar to OpenCode's server model but with a persistent gateway.
## Automation Methods (Priority Order)
### 1. WebSocket Gateway Protocol (Recommended)
Full-featured bidirectional control with streaming events.
#### Connection Handshake
```typescript
// Connect to Gateway
const ws = new WebSocket("ws://127.0.0.1:18789");
// First frame MUST be connect request
ws.send(JSON.stringify({
type: "req",
id: "1",
method: "connect",
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: "gateway-client", // or custom client id
version: "1.0.0",
platform: "linux",
mode: "backend"
},
role: "operator",
scopes: ["operator.admin"],
caps: [],
auth: { token: "YOUR_GATEWAY_TOKEN" }
}
}));
// Expect hello-ok response
// { type: "res", id: "1", ok: true, payload: { type: "hello-ok", ... } }
```
#### Agent Request
```typescript
// Send agent turn request
const runId = crypto.randomUUID();
ws.send(JSON.stringify({
type: "req",
id: runId,
method: "agent",
params: {
message: "Your prompt here",
idempotencyKey: runId,
sessionKey: "agent:main:main", // or custom session key
thinking: "low", // optional: low|medium|high
deliver: false, // don't send to messaging channel
timeout: 300000 // 5 minute timeout
}
}));
```
#### Response Flow (Two-Stage)
```typescript
// Stage 1: Immediate ack
// { type: "res", id: "...", ok: true, payload: { runId, status: "accepted", acceptedAt: 1234567890 } }
// Stage 2: Streaming events
// { type: "event", event: "agent", payload: { runId, seq: 1, stream: "output", data: {...} } }
// { type: "event", event: "agent", payload: { runId, seq: 2, stream: "tool", data: {...} } }
// ...
// Stage 3: Final response (same id as request)
// { type: "res", id: "...", ok: true, payload: { runId, status: "ok", summary: "completed", result: {...} } }
```
### 2. OpenAI-Compatible HTTP API
For simple integration with tools expecting OpenAI Chat Completions.
**Enable in config:**
```json5
{
gateway: {
http: {
endpoints: {
chatCompletions: { enabled: true }
}
}
}
}
```
**Request:**
```bash
curl -X POST http://127.0.0.1:18789/v1/chat/completions \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"model": "clawdbot:main",
"messages": [{"role": "user", "content": "Hello"}],
"stream": true
}'
```
**Model Format:**
- `model: "clawdbot:<agentId>"` (e.g., `"clawdbot:main"`)
- `model: "agent:<agentId>"` (alias)
### 3. OpenResponses HTTP API
For clients that speak OpenResponses (item-based input, function tools).
**Enable in config:**
```json5
{
gateway: {
http: {
endpoints: {
responses: { enabled: true }
}
}
}
}
```
**Request:**
```bash
curl -X POST http://127.0.0.1:18789/v1/responses \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"model": "clawdbot:main",
"input": "Hello",
"stream": true
}'
```
### 4. Webhooks (Fire-and-Forget)
For event-driven automation without maintaining a connection.
**Enable in config:**
```json5
{
hooks: {
enabled: true,
token: "webhook-secret",
path: "/hooks"
}
}
```
**Request:**
```bash
curl -X POST http://127.0.0.1:18789/hooks/agent \
-H "Authorization: Bearer webhook-secret" \
-H "Content-Type: application/json" \
-d '{
"message": "Run this task",
"name": "Automation",
"sessionKey": "hook:automation:task-123",
"deliver": false,
"timeoutSeconds": 120
}'
```
**Response:** `202 Accepted` (async run started)
### 5. CLI Subprocess
For simple one-off automation (similar to Claude Code pattern).
```bash
clawdbot agent --message "Your prompt" --session-key "automation:task"
```
## Session Management
### Session Key Format
```
agent:<agentId>:<sessionType>
agent:main:main # Main agent, main session
agent:main:subagent:abc # Subagent session
agent:beta:main # Beta agent, main session
hook:email:msg-123 # Webhook-spawned session
global # Legacy global session
```
### Session Operations (WebSocket)
```typescript
// List sessions
{ type: "req", id: "...", method: "sessions.list", params: { limit: 50, activeMinutes: 120 } }
// Resolve session info
{ type: "req", id: "...", method: "sessions.resolve", params: { key: "agent:main:main" } }
// Patch session settings
{ type: "req", id: "...", method: "sessions.patch", params: {
key: "agent:main:main",
thinkingLevel: "medium",
model: "anthropic/claude-sonnet-4-20250514"
}}
// Reset session (clear history)
{ type: "req", id: "...", method: "sessions.reset", params: { key: "agent:main:main" } }
// Delete session
{ type: "req", id: "...", method: "sessions.delete", params: { key: "agent:main:main" } }
// Compact session (summarize history)
{ type: "req", id: "...", method: "sessions.compact", params: { key: "agent:main:main" } }
```
### Session CLI
```bash
clawdbot sessions # List sessions
clawdbot sessions --active 120 # Active in last 2 hours
clawdbot sessions --json # JSON output
```
## Streaming Events
### Event Format
```typescript
interface AgentEvent {
runId: string; // Correlates to request
seq: number; // Monotonically increasing per run
stream: string; // Event category
ts: number; // Unix timestamp (ms)
data: Record<string, unknown>; // Event-specific payload
}
```
### Stream Types
| Stream | Description |
|--------|-------------|
| `output` | Text output chunks |
| `tool` | Tool invocation/result |
| `thinking` | Extended thinking content |
| `status` | Run status changes |
| `error` | Error information |
### Event Categories
| Event Type | Payload |
|------------|---------|
| `assistant.delta` | `{ text: "..." }` |
| `tool.start` | `{ name: "Read", input: {...} }` |
| `tool.result` | `{ name: "Read", result: "..." }` |
| `thinking.delta` | `{ text: "..." }` |
| `run.complete` | `{ summary: "..." }` |
| `run.error` | `{ error: "..." }` |
## Token Usage / Cost Tracking
OpenClaw tracks tokens per response and supports cost estimation.
### In-Chat Commands
```
/status # Session model, context usage, last response tokens, estimated cost
/usage off|tokens|full # Toggle per-response usage footer
/usage cost # Local cost summary from session logs
```
### Configuration
Token costs are configured per model:
```json5
{
models: {
providers: {
anthropic: {
models: [{
id: "claude-sonnet-4-20250514",
cost: {
input: 3.00, // USD per 1M tokens
output: 15.00,
cacheRead: 0.30,
cacheWrite: 3.75
}
}]
}
}
}
}
```
### Programmatic Access
Token usage is included in agent response payloads:
```typescript
// In final response or streaming events
{
usage: {
inputTokens: 1234,
outputTokens: 567,
cacheReadTokens: 890,
cacheWriteTokens: 123
}
}
```
## Authentication
### Gateway Token
```bash
# Environment variable
CLAWDBOT_GATEWAY_TOKEN=your-secret-token
# Or config file
{
gateway: {
auth: {
mode: "token",
token: "your-secret-token"
}
}
}
```
### HTTP Requests
```
Authorization: Bearer YOUR_TOKEN
```
### WebSocket Connect
```typescript
{
params: {
auth: { token: "YOUR_TOKEN" }
}
}
```
## Status Sync
### Health Check
```typescript
// WebSocket
{ type: "req", id: "...", method: "health", params: {} }
// HTTP
curl http://127.0.0.1:18789/health # Basic health
clawdbot health --json # Detailed health
```
### Status Response
```typescript
{
ok: boolean;
linkedChannel?: string;
models?: { available: string[] };
agents?: { configured: string[] };
presence?: PresenceEntry[];
uptimeMs?: number;
}
```
### Presence Events
The gateway pushes presence updates to all connected clients:
```typescript
// Event
{ type: "event", event: "presence", payload: { entries: [...], stateVersion: {...} } }
```
## Comparison with Other Agents
| Aspect | OpenClaw | Claude Code | Codex | OpenCode | Amp |
|--------|----------|-------------|-------|----------|-----|
| **Process Model** | Daemon | Subprocess | Server | Server | Subprocess |
| **Protocol** | WebSocket + HTTP | CLI JSONL | JSON-RPC stdio | HTTP + SSE | CLI JSONL |
| **Session Resume** | Session Key | `--resume` | Thread ID | Session ID | `--continue` |
| **Multi-Turn** | Same session key | Same session ID | Same thread | Same session | Same session |
| **Streaming** | WS events + SSE | JSONL | Notifications | SSE | JSONL |
| **HITL** | No | No (headless) | No (SDK) | Yes (SSE) | No |
| **SDK** | None (protocol) | None (CLI) | Yes | Yes | Closed |
### Key Differences
1. **Daemon Model**: OpenClaw runs as a persistent gateway service, not a per-turn subprocess
2. **Multi-Protocol**: Supports WebSocket, OpenAI-compatible HTTP, OpenResponses, and webhooks
3. **Channel Integration**: Built-in WhatsApp/Telegram/Discord/iMessage support
4. **Node System**: Mobile/desktop nodes can connect for camera, canvas, location, etc.
5. **No HITL**: Like Claude/Codex/Amp, permissions are configured upfront, not interactive
## Integration Patterns for sandbox-agent
### Recommended: Persistent WebSocket Connection
```typescript
class OpenClawDriver {
private ws: WebSocket;
private pending = new Map<string, { resolve, reject }>();
async connect(url: string, token: string) {
this.ws = new WebSocket(url);
await this.handshake(token);
this.ws.on("message", (data) => this.handleMessage(JSON.parse(data)));
}
async runAgent(params: {
message: string;
sessionKey?: string;
thinking?: string;
}): Promise<AgentResult> {
const runId = crypto.randomUUID();
const events: AgentEvent[] = [];
return new Promise((resolve, reject) => {
this.pending.set(runId, { resolve, reject, events });
this.ws.send(JSON.stringify({
type: "req",
id: runId,
method: "agent",
params: {
message: params.message,
sessionKey: params.sessionKey ?? "agent:main:main",
thinking: params.thinking,
deliver: false,
idempotencyKey: runId
}
}));
});
}
private handleMessage(frame: GatewayFrame) {
if (frame.type === "event" && frame.event === "agent") {
const pending = this.pending.get(frame.payload.runId);
if (pending) pending.events.push(frame.payload);
} else if (frame.type === "res") {
const pending = this.pending.get(frame.id);
if (pending && frame.payload?.status === "ok") {
pending.resolve({ result: frame.payload, events: pending.events });
this.pending.delete(frame.id);
} else if (pending && frame.payload?.status === "error") {
pending.reject(new Error(frame.payload.summary));
this.pending.delete(frame.id);
}
// Ignore "accepted" acks
}
}
}
```
### Alternative: HTTP API (Simpler)
```typescript
async function runOpenClawPrompt(prompt: string, sessionKey?: string) {
const response = await fetch("http://127.0.0.1:18789/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.CLAWDBOT_GATEWAY_TOKEN}`,
"Content-Type": "application/json",
"x-clawdbot-session-key": sessionKey ?? "automation:sandbox"
},
body: JSON.stringify({
model: "clawdbot:main",
messages: [{ role: "user", content: prompt }],
stream: false
})
});
return response.json();
}
```
## Configuration for sandbox-agent Integration
Recommended config for automated use:
```json5
{
gateway: {
port: 18789,
auth: {
mode: "token",
token: "${CLAWDBOT_GATEWAY_TOKEN}"
},
http: {
endpoints: {
chatCompletions: { enabled: true },
responses: { enabled: true }
}
}
},
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4-20250514"
},
thinking: { level: "low" },
workspace: "${HOME}/workspace"
}
}
}
```
## Notes
- OpenClaw is significantly more complex than other agents due to its gateway architecture
- The multi-protocol support (WS, OpenAI, OpenResponses, webhooks) provides flexibility
- Session management is richer (labels, spawn tracking, model/thinking overrides)
- No SDK means direct protocol implementation is required
- The daemon model means connection lifecycle management is important (reconnects, etc.)
- Agent responses are two-stage: immediate ack + final result (handle both)
- Tool policy filtering is configurable per agent/session/group

View file

@ -0,0 +1,54 @@
# OpenCode TUI Test Plan (Tmux)
This plan captures OpenCode TUI output and sends input via tmux so we can validate `/opencode` end-to-end.
## Prereqs
- `opencode` installed and on PATH.
- `tmux` installed (e.g., `/home/linuxbrew/.linuxbrew/bin/tmux`).
- Local sandbox-agent binary built.
## Environment
- `SANDBOX_AGENT_LOG_DIR=/path` to set server log dir
- `SANDBOX_AGENT_LOG_STDOUT=1` to keep logs on stdout/stderr
- `SANDBOX_AGENT_LOG_HTTP=0` to disable request logs
- `SANDBOX_AGENT_LOG_HTTP_HEADERS=1` to include request headers (Authorization redacted)
- `RUST_LOG=...` for trace filtering
## Steps
1. Build and run the server using the local binary:
```bash
SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo build -p sandbox-agent
SANDBOX_AGENT_LOG_HTTP_HEADERS=1 ./target/debug/sandbox-agent server \
--host 127.0.0.1 --port 2468 --token "$TOKEN"
```
2. Create a session via the OpenCode API:
```bash
SESSION_JSON=$(curl -sS -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{}' \
http://127.0.0.1:2468/opencode/session)
SESSION_ID=$(node -e "const v=JSON.parse(process.env.SESSION_JSON||'{}');process.stdout.write(v.id||'');")
```
3. Start the OpenCode TUI in tmux:
```bash
tmux new-session -d -s opencode \
"opencode attach http://127.0.0.1:2468/opencode --session $SESSION_ID --password $TOKEN"
```
4. Send a prompt:
```bash
tmux send-keys -t opencode:0.0 "hello" C-m
```
5. Capture output:
```bash
tmux capture-pane -pt opencode:0.0 -S -200 > /tmp/opencode-screen.txt
```
6. Inspect server logs for requests:
```bash
tail -n 200 ~/.local/share/sandbox-agent/logs/log-$(date +%m-%d-%y)
```
7. Repeat after adjusting `/opencode` stubs until the TUI displays responses.
## Notes
- Tmux captures terminal output only. GUI outputs require screenshots or logs.
- If OpenCode connects to another host/port, logs will show no requests.
- If the prompt stays in the input box, use `C-m` to submit (plain `Enter` may not trigger send in tmux).

View file

@ -0,0 +1,82 @@
# OpenCode Web Customization & Local Run Notes
## Local Web UI (pointing at `/opencode`)
This uses the OpenCode web app from `~/misc/opencode/packages/app` and points it at the
Sandbox Agent OpenCode-compatible API. The OpenCode JS SDK emits **absolute** paths
(`"/global/event"`, `"/session/:id/message"`, etc.), so any base URL path is discarded.
To keep the UI working, sandbox-agent now exposes the OpenCode router at both
`/opencode/*` and the root (`/global/*`, `/session/*`, ...).
### 1) Start sandbox-agent (OpenCode compat)
```bash
cd /home/nathan/sandbox-agent.feat-opencode-compat
SANDBOX_AGENT_SKIP_INSPECTOR=1 SANDBOX_AGENT_LOG_STDOUT=1 \
./target/debug/sandbox-agent server --no-token --host 127.0.0.1 --port 2468 \
--cors-allow-origin http://127.0.0.1:5173 \
> /tmp/sandbox-agent-opencode.log 2>&1 &
```
Logs:
```bash
tail -f /tmp/sandbox-agent-opencode.log
```
### 2) Start OpenCode web app (dev)
```bash
cd /home/nathan/misc/opencode/packages/app
VITE_OPENCODE_SERVER_HOST=127.0.0.1 VITE_OPENCODE_SERVER_PORT=2468 \
/home/nathan/.bun/bin/bun run dev -- --host 127.0.0.1 --port 5173 \
> /tmp/opencode-web.log 2>&1 &
```
Logs:
```bash
tail -f /tmp/opencode-web.log
```
### 3) Open the UI
```
http://127.0.0.1:5173/
```
The app should connect to `http://127.0.0.1:2468` by default in dev (via
`VITE_OPENCODE_SERVER_HOST/PORT`). If you see a “Could not connect to server”
error, verify the sandbox-agent process is running and reachable on port 2468.
### Notes
- The web UI uses `VITE_OPENCODE_SERVER_HOST` and `VITE_OPENCODE_SERVER_PORT` to
pick the OpenCode server in dev mode (see `packages/app/src/app.tsx`).
- When running in production, the app defaults to `window.location.origin` for
the server URL. If you need a different target, you must configure it via the
in-app “Switch server” dialog or change the build config.
- If you see a connect error in the web app, check CORS. By default, sandbox-agent
allows no origins. You must pass `--cors-allow-origin` for the dev server URL.
- The OpenCode provider list now exposes a `sandbox-agent` provider with models
for each agent (defaulting to `mock`). Use the provider/model selector in the UI
to pick the backing agent instead of environment variables.
## Dev Server Learnings (Feb 4, 2026)
- The browser **cannot** reach `http://127.0.0.1:2468` unless the web UI is on the
same machine. If the UI is loaded from `http://100.94.102.49:5173`, the server
must be reachable at `http://100.94.102.49:2468`.
- The OpenCode JS SDK uses absolute paths, so a base URL path (like
`http://host:port/opencode`) is ignored. This means the server must expose
OpenCode routes at the **root** (`/global/*`, `/session/*`, ...), even if it
also exposes them under `/opencode/*`.
- CORS must allow the UI origin. Example:
```bash
./target/debug/sandbox-agent server --no-token --host 0.0.0.0 --port 2468 \
--cors-allow-origin http://100.94.102.49:5173
```
- Binding the dev servers to `0.0.0.0` is required for remote access. Verify
`ss -ltnp | grep ':2468'` and `ss -ltnp | grep ':5173'`.
- If the UI throws “No default model found”, it usually means the `/provider`
response lacks a providerID → modelID default mapping for a connected provider.

View file

@ -282,6 +282,14 @@
},
"deletions": {
"type": "number"
},
"status": {
"type": "string",
"enum": [
"added",
"deleted",
"modified"
]
}
},
"required": [
@ -777,6 +785,60 @@
"text"
]
},
"SubtaskPart": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"sessionID": {
"type": "string"
},
"messageID": {
"type": "string"
},
"type": {
"type": "string",
"const": "subtask"
},
"prompt": {
"type": "string"
},
"description": {
"type": "string"
},
"agent": {
"type": "string"
},
"model": {
"type": "object",
"properties": {
"providerID": {
"type": "string"
},
"modelID": {
"type": "string"
}
},
"required": [
"providerID",
"modelID"
]
},
"command": {
"type": "string"
}
},
"required": [
"id",
"sessionID",
"messageID",
"type",
"prompt",
"description",
"agent"
]
},
"ReasoningPart": {
"type": "object",
"properties": {
@ -1541,58 +1603,7 @@
"$ref": "#/definitions/TextPart"
},
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"sessionID": {
"type": "string"
},
"messageID": {
"type": "string"
},
"type": {
"type": "string",
"const": "subtask"
},
"prompt": {
"type": "string"
},
"description": {
"type": "string"
},
"agent": {
"type": "string"
},
"model": {
"type": "object",
"properties": {
"providerID": {
"type": "string"
},
"modelID": {
"type": "string"
}
},
"required": [
"providerID",
"modelID"
]
},
"command": {
"type": "string"
}
},
"required": [
"id",
"sessionID",
"messageID",
"type",
"prompt",
"description",
"agent"
]
"$ref": "#/definitions/SubtaskPart"
},
{
"$ref": "#/definitions/ReasoningPart"
@ -3085,55 +3096,6 @@
"payload"
]
},
"BadRequestError": {
"type": "object",
"properties": {
"data": {},
"errors": {
"type": "array",
"items": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
"success": {
"type": "boolean",
"const": false
}
},
"required": [
"data",
"errors",
"success"
]
},
"NotFoundError": {
"type": "object",
"properties": {
"name": {
"type": "string",
"const": "NotFoundError"
},
"data": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": [
"message"
]
}
},
"required": [
"name",
"data"
]
},
"KeybindsConfig": {
"description": "Custom keybind configurations",
"type": "object",
@ -3634,6 +3596,10 @@
"description": "Enable mDNS service discovery",
"type": "boolean"
},
"mdnsDomain": {
"description": "Custom domain name for mDNS service (default: opencode.local)",
"type": "string"
},
"cors": {
"description": "Additional domains to allow for CORS",
"type": "array",
@ -3729,6 +3695,9 @@
},
"doom_loop": {
"$ref": "#/definitions/PermissionActionConfig"
},
"skill": {
"$ref": "#/definitions/PermissionRuleConfig"
}
},
"additionalProperties": {
@ -3746,6 +3715,10 @@
"model": {
"type": "string"
},
"variant": {
"description": "Default model variant for this agent (applies only when using the agent's configured model).",
"type": "string"
},
"temperature": {
"type": "number"
},
@ -3792,9 +3765,25 @@
"additionalProperties": {}
},
"color": {
"description": "Hex color code for the agent (e.g., #FF5733)",
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$"
"description": "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",
"anyOf": [
{
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$"
},
{
"type": "string",
"enum": [
"primary",
"secondary",
"accent",
"success",
"warning",
"error",
"info"
]
}
]
},
"steps": {
"description": "Maximum number of agentic iterations before forcing text-only response",
@ -4296,6 +4285,19 @@
]
}
},
"skills": {
"description": "Additional skill folder paths",
"type": "object",
"properties": {
"paths": {
"description": "Additional paths to skill folders",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"watcher": {
"type": "object",
"properties": {
@ -4618,73 +4620,6 @@
"experimental": {
"type": "object",
"properties": {
"hook": {
"type": "object",
"properties": {
"file_edited": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"command"
]
}
}
},
"session_completed": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"command"
]
}
}
}
},
"chatMaxRetries": {
"description": "Number of retries for chat completions on failure",
"type": "number"
},
"disable_paste_summary": {
"type": "boolean"
},
@ -4718,6 +4653,134 @@
},
"additionalProperties": false
},
"BadRequestError": {
"type": "object",
"properties": {
"data": {},
"errors": {
"type": "array",
"items": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
"success": {
"type": "boolean",
"const": false
}
},
"required": [
"data",
"errors",
"success"
]
},
"OAuth": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "oauth"
},
"refresh": {
"type": "string"
},
"access": {
"type": "string"
},
"expires": {
"type": "number"
},
"accountId": {
"type": "string"
},
"enterpriseUrl": {
"type": "string"
}
},
"required": [
"type",
"refresh",
"access",
"expires"
]
},
"ApiAuth": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "api"
},
"key": {
"type": "string"
}
},
"required": [
"type",
"key"
]
},
"WellKnownAuth": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "wellknown"
},
"key": {
"type": "string"
},
"token": {
"type": "string"
}
},
"required": [
"type",
"key",
"token"
]
},
"Auth": {
"anyOf": [
{
"$ref": "#/definitions/OAuth"
},
{
"$ref": "#/definitions/ApiAuth"
},
{
"$ref": "#/definitions/WellKnownAuth"
}
]
},
"NotFoundError": {
"type": "object",
"properties": {
"name": {
"type": "string",
"const": "NotFoundError"
},
"data": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": [
"message"
]
}
},
"required": [
"name",
"data"
]
},
"Model": {
"type": "object",
"properties": {
@ -5431,7 +5494,10 @@
"properties": {
"type": {
"type": "string",
"const": "text"
"enum": [
"text",
"binary"
]
},
"content": {
"type": "string"
@ -5682,8 +5748,13 @@
"model": {
"type": "string"
},
"mcp": {
"type": "boolean"
"source": {
"type": "string",
"enum": [
"command",
"mcp",
"skill"
]
},
"template": {
"anyOf": [
@ -5761,6 +5832,9 @@
"providerID"
]
},
"variant": {
"type": "string"
},
"prompt": {
"type": "string"
},
@ -5837,85 +5911,6 @@
"extensions",
"enabled"
]
},
"OAuth": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "oauth"
},
"refresh": {
"type": "string"
},
"access": {
"type": "string"
},
"expires": {
"type": "number"
},
"accountId": {
"type": "string"
},
"enterpriseUrl": {
"type": "string"
}
},
"required": [
"type",
"refresh",
"access",
"expires"
]
},
"ApiAuth": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "api"
},
"key": {
"type": "string"
}
},
"required": [
"type",
"key"
]
},
"WellKnownAuth": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "wellknown"
},
"key": {
"type": "string"
},
"token": {
"type": "string"
}
},
"required": [
"type",
"key",
"token"
]
},
"Auth": {
"anyOf": [
{
"$ref": "#/definitions/OAuth"
},
{
"$ref": "#/definitions/ApiAuth"
},
{
"$ref": "#/definitions/WellKnownAuth"
}
]
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,13 @@
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { join } from "path";
import { fetchWithCache } from "./cache.js";
import { createNormalizedSchema, openApiToJsonSchema, type NormalizedSchema } from "./normalize.js";
import type { JSONSchema7 } from "json-schema";
const OPENAPI_URL =
"https://raw.githubusercontent.com/sst/opencode/dev/packages/sdk/openapi.json";
const OPENAPI_URLS = [
"https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/sdk/openapi.json",
"https://raw.githubusercontent.com/sst/opencode/dev/packages/sdk/openapi.json",
];
// Key schemas we want to extract
const TARGET_SCHEMAS = [
@ -19,16 +23,40 @@ const TARGET_SCHEMAS = [
"ErrorPart",
];
const OPENAPI_ARTIFACT_DIR = join(import.meta.dirname, "..", "artifacts", "openapi");
const OPENAPI_ARTIFACT_PATH = join(OPENAPI_ARTIFACT_DIR, "opencode.json");
interface OpenAPISpec {
components?: {
schemas?: Record<string, unknown>;
};
}
function writeOpenApiArtifact(specText: string): void {
if (!existsSync(OPENAPI_ARTIFACT_DIR)) {
mkdirSync(OPENAPI_ARTIFACT_DIR, { recursive: true });
}
writeFileSync(OPENAPI_ARTIFACT_PATH, specText);
console.log(` [wrote] ${OPENAPI_ARTIFACT_PATH}`);
}
export async function extractOpenCodeSchema(): Promise<NormalizedSchema> {
console.log("Extracting OpenCode schema from OpenAPI spec...");
const specText = await fetchWithCache(OPENAPI_URL);
let specText: string | null = null;
let lastError: Error | null = null;
for (const url of OPENAPI_URLS) {
try {
specText = await fetchWithCache(url);
break;
} catch (error) {
lastError = error as Error;
}
}
if (!specText) {
throw lastError ?? new Error("Failed to fetch OpenCode OpenAPI spec");
}
writeOpenApiArtifact(specText);
const spec: OpenAPISpec = JSON.parse(specText);
if (!spec.components?.schemas) {

View file

@ -25,6 +25,7 @@ futures.workspace = true
reqwest.workspace = true
dirs.workspace = true
time.workspace = true
chrono.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
tower-http.workspace = true
@ -34,11 +35,15 @@ tracing.workspace = true
tracing-logfmt.workspace = true
tracing-subscriber.workspace = true
include_dir.workspace = true
base64.workspace = true
tempfile = { workspace = true, optional = true }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.52", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console"] }
[dev-dependencies]
http-body-util.workspace = true
insta.workspace = true

View file

@ -2,6 +2,8 @@
mod agent_server_logs;
pub mod credentials;
pub mod opencode_compat;
pub mod router;
pub mod server_logs;
pub mod telemetry;
pub mod ui;

View file

@ -1,7 +1,9 @@
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use std::process::{Child, Command as ProcessCommand, Stdio};
use std::sync::Arc;
use std::time::{Duration, Instant};
use clap::{Args, Parser, Subcommand};
@ -20,6 +22,7 @@ use sandbox_agent::router::{
AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse,
SessionListResponse,
};
use sandbox_agent::server_logs::ServerLogs;
use sandbox_agent::telemetry;
use sandbox_agent::ui;
use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions};
@ -28,7 +31,7 @@ use sandbox_agent_agent_management::credentials::{
ProviderCredentials,
};
use serde::Serialize;
use serde_json::Value;
use serde_json::{json, Value};
use thiserror::Error;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
@ -36,6 +39,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilte
const API_PREFIX: &str = "/v1";
const DEFAULT_HOST: &str = "127.0.0.1";
const DEFAULT_PORT: u16 = 2468;
const LOGS_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
#[derive(Parser, Debug)]
#[command(name = "sandbox-agent", bin_name = "sandbox-agent")]
@ -58,6 +62,8 @@ enum Command {
Server(ServerArgs),
/// Call the HTTP API without writing client code.
Api(ApiArgs),
/// EXPERIMENTAL: Start a sandbox-agent server and attach an OpenCode session.
Opencode(OpencodeArgs),
/// Install or reinstall an agent without running the server.
InstallAgent(InstallAgentArgs),
/// Inspect locally discovered credentials.
@ -94,6 +100,21 @@ struct ApiArgs {
command: ApiCommand,
}
#[derive(Args, Debug)]
struct OpencodeArgs {
#[arg(long, short = 'H', default_value = DEFAULT_HOST)]
host: String,
#[arg(long, short = 'p', default_value_t = DEFAULT_PORT)]
port: u16,
#[arg(long)]
session_title: Option<String>,
#[arg(long)]
opencode_bin: Option<PathBuf>,
}
#[derive(Args, Debug)]
struct CredentialsArgs {
#[command(subcommand)]
@ -349,8 +370,11 @@ enum CliError {
}
fn main() {
init_logging();
let cli = Cli::parse();
if let Err(err) = init_logging(&cli) {
eprintln!("failed to init logging: {err}");
std::process::exit(1);
}
let result = match &cli.command {
Command::Server(args) => run_server(&cli, args),
@ -363,7 +387,11 @@ fn main() {
}
}
fn init_logging() {
fn init_logging(cli: &Cli) -> Result<(), CliError> {
if matches!(cli.command, Command::Server(_)) {
maybe_redirect_server_logs();
}
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::registry()
.with(filter)
@ -373,6 +401,7 @@ fn init_logging() {
.with_writer(std::io::stderr),
)
.init();
Ok(())
}
fn run_server(cli: &Cli, server: &ServerArgs) -> Result<(), CliError> {
@ -434,12 +463,33 @@ fn default_install_dir() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("bin"))
}
fn default_server_log_dir() -> PathBuf {
if let Ok(dir) = std::env::var("SANDBOX_AGENT_LOG_DIR") {
return PathBuf::from(dir);
}
dirs::data_dir()
.map(|dir| dir.join("sandbox-agent").join("logs"))
.unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("logs"))
}
fn maybe_redirect_server_logs() {
if std::env::var("SANDBOX_AGENT_LOG_STDOUT").is_ok() {
return;
}
let log_dir = default_server_log_dir();
if let Err(err) = ServerLogs::new(log_dir, LOGS_RETENTION).start_sync() {
eprintln!("failed to redirect logs: {err}");
}
}
fn run_client(command: &Command, cli: &Cli) -> Result<(), CliError> {
match command {
Command::Server(_) => Err(CliError::Server(
"server subcommand must be invoked as `sandbox-agent server`".to_string(),
)),
Command::Api(subcommand) => run_api(&subcommand.command, cli),
Command::Opencode(args) => run_opencode(cli, args),
Command::InstallAgent(args) => install_agent_local(args),
Command::Credentials(subcommand) => run_credentials(&subcommand.command),
}
@ -452,6 +502,53 @@ fn run_api(command: &ApiCommand, cli: &Cli) -> Result<(), CliError> {
}
}
fn run_opencode(cli: &Cli, args: &OpencodeArgs) -> Result<(), CliError> {
write_stderr_line("experimental: opencode subcommand may change without notice")?;
let token = if cli.no_token {
None
} else {
Some(cli.token.clone().ok_or(CliError::MissingToken)?)
};
let mut server_child = spawn_sandbox_agent_server(cli, args, token.as_deref())?;
let base_url = format!("http://{}:{}", args.host, args.port);
wait_for_health(&mut server_child, &base_url, token.as_deref())?;
let session_id = create_opencode_session(&base_url, token.as_deref(), args.session_title.as_deref())?;
write_stdout_line(&format!("OpenCode session: {session_id}"))?;
let attach_url = format!("{base_url}/opencode");
let opencode_bin = resolve_opencode_bin(args.opencode_bin.as_ref());
let mut opencode_cmd = ProcessCommand::new(opencode_bin);
opencode_cmd
.arg("attach")
.arg(&attach_url)
.arg("--session")
.arg(&session_id)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
if let Some(token) = token.as_deref() {
opencode_cmd.arg("--password").arg(token);
}
let status = opencode_cmd.status().map_err(|err| {
terminate_child(&mut server_child);
CliError::Server(format!("failed to start opencode: {err}"))
})?;
terminate_child(&mut server_child);
if !status.success() {
return Err(CliError::Server(format!(
"opencode exited with status {status}"
)));
}
Ok(())
}
fn run_agents(command: &AgentsCommand, cli: &Cli) -> Result<(), CliError> {
match command {
AgentsCommand::List(args) => {
@ -607,6 +704,113 @@ fn run_sessions(command: &SessionsCommand, cli: &Cli) -> Result<(), CliError> {
}
}
fn spawn_sandbox_agent_server(
cli: &Cli,
args: &OpencodeArgs,
token: Option<&str>,
) -> Result<Child, CliError> {
let exe = std::env::current_exe()?;
let mut cmd = ProcessCommand::new(exe);
cmd.arg("server")
.arg("--host")
.arg(&args.host)
.arg("--port")
.arg(args.port.to_string())
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
if cli.no_token {
cmd.arg("--no-token");
} else if let Some(token) = token {
cmd.arg("--token").arg(token);
}
cmd.spawn().map_err(CliError::from)
}
fn wait_for_health(
server_child: &mut Child,
base_url: &str,
token: Option<&str>,
) -> Result<(), CliError> {
let client = HttpClient::builder().build()?;
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if let Some(status) = server_child.try_wait()? {
return Err(CliError::Server(format!(
"sandbox-agent exited before becoming healthy ({status})"
)));
}
let url = format!("{base_url}/v1/health");
let mut request = client.get(&url);
if let Some(token) = token {
request = request.bearer_auth(token);
}
match request.send() {
Ok(response) if response.status().is_success() => return Ok(()),
_ => {
std::thread::sleep(Duration::from_millis(200));
}
}
}
Err(CliError::Server(
"timed out waiting for sandbox-agent health".to_string(),
))
}
fn create_opencode_session(
base_url: &str,
token: Option<&str>,
title: Option<&str>,
) -> Result<String, CliError> {
let client = HttpClient::builder().build()?;
let url = format!("{base_url}/opencode/session");
let body = if let Some(title) = title {
json!({ "title": title })
} else {
json!({})
};
let mut request = client.post(&url).json(&body);
if let Ok(directory) = std::env::current_dir() {
request = request.header("x-opencode-directory", directory.to_string_lossy().to_string());
}
if let Some(token) = token {
request = request.bearer_auth(token);
}
let response = request.send()?;
let status = response.status();
let text = response.text()?;
if !status.is_success() {
print_error_body(&text)?;
return Err(CliError::HttpStatus(status));
}
let body: Value = serde_json::from_str(&text)?;
let session_id = body
.get("id")
.and_then(|value| value.as_str())
.ok_or_else(|| CliError::Server("opencode session missing id".to_string()))?;
Ok(session_id.to_string())
}
fn resolve_opencode_bin(explicit: Option<&PathBuf>) -> PathBuf {
if let Some(path) = explicit {
return path.clone();
}
if let Ok(path) = std::env::var("OPENCODE_BIN") {
return PathBuf::from(path);
}
PathBuf::from("opencode")
}
fn terminate_child(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
}
fn run_credentials(command: &CredentialsCommand) -> Result<(), CliError> {
match command {
CredentialsCommand::Extract(args) => {

File diff suppressed because it is too large Load diff

View file

@ -34,9 +34,12 @@ use tokio::sync::{broadcast, mpsc, oneshot, Mutex};
use tokio::time::sleep;
use tokio_stream::wrappers::BroadcastStream;
use tower_http::trace::TraceLayer;
use base64::Engine;
use tracing::Span;
use utoipa::{Modify, OpenApi, ToSchema};
use crate::agent_server_logs::AgentServerLogs;
use crate::opencode_compat::{build_opencode_router, OpenCodeAppState};
use crate::ui;
use sandbox_agent_agent_management::agents::{
AgentError as ManagerError, AgentId, AgentManager, InstallOptions, SpawnOptions, StreamingSpawn,
@ -68,6 +71,10 @@ impl AppState {
session_manager,
}
}
pub(crate) fn session_manager(&self) -> Arc<SessionManager> {
self.session_manager.clone()
}
}
#[derive(Debug, Clone)]
@ -126,16 +133,78 @@ 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 mut router = Router::new()
.route("/", get(get_root))
.nest("/v1", v1_router)
.nest("/opencode", opencode_router)
.merge(opencode_root_router)
.fallback(not_found);
if ui::is_enabled() {
router = router.merge(ui::router());
}
(router.layer(TraceLayer::new_for_http()), shared)
let http_logging = match std::env::var("SANDBOX_AGENT_LOG_HTTP") {
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()
.make_span_with(move |req: &Request<_>| {
if include_headers {
let mut headers = Vec::new();
for (name, value) in req.headers().iter() {
let name_str = name.as_str();
let display_value = if name_str.eq_ignore_ascii_case("authorization") {
"<redacted>".to_string()
} else {
value.to_str().unwrap_or("<binary>").to_string()
};
headers.push((name_str.to_string(), display_value));
}
tracing::info_span!(
"http.request",
method = %req.method(),
uri = %req.uri(),
headers = ?headers
)
} else {
tracing::info_span!(
"http.request",
method = %req.method(),
uri = %req.uri()
)
}
})
.on_request(|_req: &Request<_>, span: &Span| {
tracing::info!(parent: span, "request");
})
.on_response(|res: &Response<_>, latency: Duration, span: &Span| {
tracing::info!(
parent: span,
status = %res.status(),
latency_ms = latency.as_millis()
);
});
router = router.layer(trace_layer);
}
(router, shared)
}
pub async fn shutdown_servers(state: &Arc<AppState>) {
@ -744,7 +813,7 @@ struct AgentServerManager {
}
#[derive(Debug)]
struct SessionManager {
pub(crate) struct SessionManager {
agent_manager: Arc<AgentManager>,
sessions: Mutex<Vec<SessionState>>,
server_manager: Arc<AgentServerManager>,
@ -847,9 +916,25 @@ impl CodexServer {
}
}
struct SessionSubscription {
initial_events: Vec<UniversalEvent>,
receiver: broadcast::Receiver<UniversalEvent>,
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 {
@ -1477,7 +1562,7 @@ impl SessionManager {
logs.read_stderr()
}
async fn create_session(
pub(crate) async fn create_session(
self: &Arc<Self>,
session_id: String,
request: CreateSessionRequest,
@ -1604,7 +1689,7 @@ impl SessionManager {
}
}
async fn send_message(
pub(crate) async fn send_message(
self: &Arc<Self>,
session_id: String,
message: String,
@ -1819,7 +1904,7 @@ impl SessionManager {
.collect()
}
async fn subscribe(
pub(crate) async fn subscribe(
&self,
session_id: &str,
offset: u64,
@ -1843,6 +1928,38 @@ impl SessionManager {
})
}
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,
@ -1871,7 +1988,7 @@ impl SessionManager {
Ok((SessionSnapshot::from(session), subscription))
}
async fn reply_question(
pub(crate) async fn reply_question(
&self,
session_id: &str,
question_id: &str,
@ -1949,7 +2066,7 @@ impl SessionManager {
Ok(())
}
async fn reject_question(
pub(crate) async fn reject_question(
&self,
session_id: &str,
question_id: &str,
@ -2028,7 +2145,7 @@ impl SessionManager {
Ok(())
}
async fn reply_permission(
pub(crate) async fn reply_permission(
self: &Arc<Self>,
session_id: &str,
permission_id: &str,
@ -3291,11 +3408,35 @@ 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(stripped) = value.strip_prefix("Bearer ") {
return Some(stripped.to_string());
}
if let Some(stripped) = value.strip_prefix("Token ") {
return Some(stripped.to_string());
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);
}
}
}
}
}
_ => {}
}
}
}
}

View file

@ -0,0 +1,9 @@
#[cfg(unix)]
mod unix;
#[cfg(windows)]
mod windows;
#[cfg(unix)]
pub use unix::ServerLogs;
#[cfg(windows)]
pub use windows::ServerLogs;

View file

@ -0,0 +1,103 @@
use std::os::unix::io::AsRawFd;
use std::path::PathBuf;
use chrono::{Datelike, Duration, TimeDelta, TimeZone, Utc};
pub struct ServerLogs {
path: PathBuf,
retention: Duration,
last_rotation: chrono::DateTime<Utc>,
next_rotation: chrono::DateTime<Utc>,
}
impl ServerLogs {
pub fn new(path: PathBuf, retention: std::time::Duration) -> Self {
Self {
path,
retention: chrono::Duration::from_std(retention).expect("invalid retention duration"),
last_rotation: Utc.timestamp_opt(0, 0).unwrap(),
next_rotation: Utc.timestamp_opt(0, 0).unwrap(),
}
}
pub fn start_sync(mut self) -> Result<std::thread::JoinHandle<()>, std::io::Error> {
std::fs::create_dir_all(&self.path)?;
self.rotate_sync()?;
Ok(std::thread::spawn(|| self.run_sync()))
}
fn run_sync(mut self) {
loop {
let now = Utc::now();
if self.next_rotation - now > Duration::seconds(5) {
std::thread::sleep(
(self.next_rotation - now - Duration::seconds(5))
.max(TimeDelta::default())
.to_std()
.expect("bad duration"),
);
} else if now.ordinal() != self.last_rotation.ordinal() {
if let Err(err) = self.rotate_sync() {
tracing::error!(?err, "failed logs rotation");
}
} else {
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
}
fn rotate_sync(&mut self) -> Result<(), std::io::Error> {
self.last_rotation = Utc::now();
self.next_rotation = Utc.from_utc_datetime(
&(self
.last_rotation
.date_naive()
.and_hms_opt(0, 0, 0)
.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::Other, "invalid date")
})?
+ Duration::days(1)),
);
let file_name = format!("log-{}", self.last_rotation.format("%m-%d-%y"));
let path = self.path.join(file_name);
let log_file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.append(true)
.open(&path)?;
let log_fd = log_file.as_raw_fd();
unsafe {
libc::dup2(log_fd, libc::STDOUT_FILENO);
libc::dup2(log_fd, libc::STDERR_FILENO);
}
self.prune_sync()
}
fn prune_sync(&self) -> Result<(), std::io::Error> {
let mut entries = std::fs::read_dir(&self.path)?;
let mut pruned = 0;
while let Some(entry) = entries.next() {
let entry = entry?;
let metadata = entry.metadata()?;
let modified = chrono::DateTime::<Utc>::from(metadata.modified()?);
if modified < Utc::now() - self.retention {
pruned += 1;
let _ = std::fs::remove_file(entry.path());
}
}
if pruned != 0 {
tracing::debug!("pruned {pruned} log files");
}
Ok(())
}
}

View file

@ -0,0 +1,131 @@
use std::path::PathBuf;
use chrono::{Datelike, Duration, TimeDelta, TimeZone, Utc};
use windows::{
core::PCSTR,
Win32::{
Foundation::{HANDLE, INVALID_HANDLE_VALUE},
Storage::FileSystem::{
CreateFileA, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_WRITE, FILE_SHARE_READ, OPEN_ALWAYS,
},
System::Console::{SetStdHandle, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE},
},
};
pub struct ServerLogs {
path: PathBuf,
retention: Duration,
last_rotation: chrono::DateTime<Utc>,
next_rotation: chrono::DateTime<Utc>,
}
impl ServerLogs {
pub fn new(path: PathBuf, retention: std::time::Duration) -> Self {
Self {
path,
retention: chrono::Duration::from_std(retention).expect("invalid retention duration"),
last_rotation: Utc.timestamp_opt(0, 0).unwrap(),
next_rotation: Utc.timestamp_opt(0, 0).unwrap(),
}
}
pub fn start_sync(mut self) -> Result<std::thread::JoinHandle<()>, std::io::Error> {
std::fs::create_dir_all(&self.path)?;
self.rotate_sync()?;
Ok(std::thread::spawn(|| self.run_sync()))
}
fn run_sync(mut self) {
loop {
let now = Utc::now();
if self.next_rotation - now > Duration::seconds(5) {
std::thread::sleep(
(self.next_rotation - now - Duration::seconds(5))
.max(TimeDelta::default())
.to_std()
.expect("bad duration"),
);
} else if now.ordinal() != self.last_rotation.ordinal() {
if let Err(err) = self.rotate_sync() {
tracing::error!(?err, "failed logs rotation");
}
} else {
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
}
fn rotate_sync(&mut self) -> Result<(), std::io::Error> {
self.last_rotation = Utc::now();
self.next_rotation = Utc.from_utc_datetime(
&(self
.last_rotation
.date_naive()
.and_hms_opt(0, 0, 0)
.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::Other, "invalid date")
})?
+ Duration::days(1)),
);
let file_name = format!("log-{}", self.last_rotation.format("%m-%d-%y"));
let path = self.path.join(file_name);
let path_str = path
.to_str()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "invalid path"))?;
let path_cstr = std::ffi::CString::new(path_str)
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?;
unsafe {
let file_handle = CreateFileA(
PCSTR(path_cstr.as_ptr() as *const u8),
FILE_GENERIC_WRITE.0,
FILE_SHARE_READ,
None,
OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
HANDLE(std::ptr::null_mut()),
)
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?;
if file_handle == INVALID_HANDLE_VALUE {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
"failed to create log file",
));
}
SetStdHandle(STD_OUTPUT_HANDLE, file_handle)
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?;
SetStdHandle(STD_ERROR_HANDLE, file_handle)
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?;
}
self.prune_sync()
}
fn prune_sync(&self) -> Result<(), std::io::Error> {
let mut entries = std::fs::read_dir(&self.path)?;
let mut pruned = 0;
while let Some(entry) = entries.next() {
let entry = entry?;
let metadata = entry.metadata()?;
let modified = chrono::DateTime::<Utc>::from(metadata.modified()?);
if modified < Utc::now() - self.retention {
pruned += 1;
let _ = std::fs::remove_file(entry.path());
}
}
if pruned != 0 {
tracing::debug!("pruned {pruned} log files");
}
Ok(())
}
}

View file

@ -0,0 +1,81 @@
# OpenCode Compatibility Tests
These tests verify that sandbox-agent exposes OpenCode-compatible API endpoints under `/opencode` and that they are usable with the official [`@opencode-ai/sdk`](https://www.npmjs.com/package/@opencode-ai/sdk) TypeScript SDK.
## Purpose
The goal is to enable sandbox-agent to be used as a drop-in replacement for OpenCode's server, allowing tools and integrations built for OpenCode to work seamlessly with sandbox-agent.
## Test Coverage
The tests cover the following OpenCode API surfaces:
### Session Management (`session.test.ts`)
- `POST /session` - Create a new session
- `GET /session` - List all sessions
- `GET /session/{id}` - Get session details
- `PATCH /session/{id}` - Update session properties
- `DELETE /session/{id}` - Delete a session
### Messaging (`messaging.test.ts`)
- `POST /session/{id}/message` - Send a prompt to the session
- `POST /session/{id}/prompt_async` - Send async prompt
- `GET /session/{id}/message` - List messages
- `GET /session/{id}/message/{messageID}` - Get specific message
- `POST /session/{id}/abort` - Abort session
### Event Streaming (`events.test.ts`)
- `GET /event` - Subscribe to all events (SSE)
- `GET /global/event` - Subscribe to global events (SSE)
- `GET /session/status` - Get session status
### Permissions (`permissions.test.ts`)
- `POST /session/{id}/permissions/{permissionID}` - Respond to permission request
### OpenAPI Coverage (Rust)
- `cargo test -p sandbox-agent --test opencode_openapi`
- Compares the Utoipa-generated OpenCode spec against `resources/agent-schemas/artifacts/openapi/opencode.json`
## Running Tests
```bash
# From this directory
pnpm test
# Or from the workspace root
pnpm --filter @sandbox-agent/opencode-compat-tests test
```
## Prerequisites
1. Build the sandbox-agent binary:
```bash
cargo build -p sandbox-agent
```
2. Or set `SANDBOX_AGENT_BIN` environment variable to point to a pre-built binary.
## Test Approach
Each test:
1. Spawns a fresh sandbox-agent instance on a unique port
2. Uses `createOpencodeClient` from `@opencode-ai/sdk` to connect
3. Tests the OpenCode-compatible endpoints
4. Cleans up the server instance
This ensures tests are isolated and can run in parallel.
## Current Status
These tests validate the `/opencode` compatibility layer and should pass when the endpoints are mounted and responding with OpenCode-compatible shapes.
## Implementation Notes
To make sandbox-agent OpenCode-compatible, the following needs to be implemented:
1. **OpenCode API Routes** - Exposed under `/opencode`
2. **Request/Response Mapping** - OpenCode response shapes with stubbed data where needed
3. **SSE Event Streaming** - OpenCode event format for SSE
4. **Permission Handling** - Accepts OpenCode permission replies
See the OpenCode SDK types at `/home/nathan/misc/opencode/packages/sdk/js/src/gen/types.gen.ts` for the expected API shapes.

View file

@ -0,0 +1,148 @@
/**
* Tests for OpenCode-compatible event streaming endpoints.
*
* These tests verify that sandbox-agent exposes OpenCode-compatible SSE event
* endpoints that can be used with the official OpenCode SDK.
*
* Expected endpoints:
* - GET /event - Subscribe to all events (SSE)
* - GET /global/event - Subscribe to global events (SSE)
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
describe("OpenCode-compatible Event Streaming", () => {
let handle: SandboxAgentHandle;
let client: OpencodeClient;
beforeAll(async () => {
await buildSandboxAgent();
});
beforeEach(async () => {
handle = await spawnSandboxAgent({ opencodeCompat: true });
client = createOpencodeClient({
baseUrl: `${handle.baseUrl}/opencode`,
headers: { Authorization: `Bearer ${handle.token}` },
});
});
afterEach(async () => {
await handle?.dispose();
});
describe("event.subscribe", () => {
it("should connect to SSE endpoint", async () => {
// The event.subscribe returns an SSE stream
const response = await client.event.subscribe();
expect(response).toBeDefined();
expect((response as any).stream).toBeDefined();
});
it("should receive session.created event when session is created", async () => {
const events: any[] = [];
// Start listening for events
const eventStream = await client.event.subscribe();
// Set up event collection with timeout
const collectEvents = new Promise<void>((resolve) => {
const timeout = setTimeout(resolve, 5000);
(async () => {
try {
for await (const event of (eventStream as any).stream) {
events.push(event);
if (event.type === "session.created") {
clearTimeout(timeout);
resolve();
break;
}
}
} catch {
// Stream ended or errored
}
})();
});
// Create a session
await client.session.create({ body: { title: "Event Test" } });
await collectEvents;
// Should have received at least one session.created event
const sessionCreatedEvent = events.find((e) => e.type === "session.created");
expect(sessionCreatedEvent).toBeDefined();
});
it("should receive message.part.updated events during prompt", async () => {
const events: any[] = [];
// Create a session first
const session = await client.session.create();
const sessionId = session.data?.id!;
// Start listening for events
const eventStream = await client.event.subscribe();
const collectEvents = new Promise<void>((resolve) => {
const timeout = setTimeout(resolve, 10000);
(async () => {
try {
for await (const event of (eventStream as any).stream) {
events.push(event);
// Look for message part updates or completion
if (
event.type === "message.part.updated" ||
event.type === "session.idle"
) {
if (events.length >= 3) {
clearTimeout(timeout);
resolve();
break;
}
}
}
} catch {
// Stream ended
}
})();
});
// Send a prompt
await client.session.prompt({
path: { id: sessionId },
body: {
model: { providerID: "sandbox-agent", modelID: "mock" },
parts: [{ type: "text", text: "Say hello" }],
},
});
await collectEvents;
// Should have received some events
expect(events.length).toBeGreaterThan(0);
});
});
describe("global.event", () => {
it("should connect to global SSE endpoint", async () => {
const response = await client.global.event();
expect(response).toBeDefined();
});
});
describe("session.status", () => {
it("should return session status", async () => {
const session = await client.session.create();
const sessionId = session.data?.id!;
const response = await client.session.status();
expect(response.data).toBeDefined();
});
});
});

View file

@ -0,0 +1,237 @@
/**
* Utilities for spawning sandbox-agent for OpenCode compatibility testing.
* Mirrors the patterns from sdks/typescript/src/spawn.ts
*/
import { spawn, type ChildProcess } from "node:child_process";
import { createServer, type AddressInfo, type Server } from "node:net";
import { existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { randomBytes } from "node:crypto";
const __dirname = dirname(fileURLToPath(import.meta.url));
export interface SandboxAgentHandle {
baseUrl: string;
token: string;
child: ChildProcess;
dispose: () => Promise<void>;
}
/**
* Find the sandbox-agent binary in common locations
*/
function findBinary(): string | null {
// Check environment variable first
if (process.env.SANDBOX_AGENT_BIN) {
const path = process.env.SANDBOX_AGENT_BIN;
if (existsSync(path)) {
return path;
}
}
// Check cargo build outputs (relative to tests/opencode-compat/helpers)
const cargoPaths = [
resolve(__dirname, "../../../../../../target/debug/sandbox-agent"),
resolve(__dirname, "../../../../../../target/release/sandbox-agent"),
];
for (const p of cargoPaths) {
if (existsSync(p)) {
return p;
}
}
return null;
}
/**
* Get a free port on the given host
*/
async function getFreePort(host: string): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.unref();
server.on("error", reject);
server.listen(0, host, () => {
const address = server.address() as AddressInfo;
server.close(() => resolve(address.port));
});
});
}
/**
* Wait for the server to become healthy
*/
async function waitForHealth(
baseUrl: string,
token: string,
timeoutMs: number,
child: ChildProcess
): Promise<void> {
const start = Date.now();
let lastError: string | undefined;
while (Date.now() - start < timeoutMs) {
if (child.exitCode !== null) {
throw new Error("sandbox-agent exited before becoming healthy");
}
try {
const response = await fetch(`${baseUrl}/v1/health`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
return;
}
lastError = `status ${response.status}`;
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`Timed out waiting for sandbox-agent health (${lastError ?? "unknown"})`);
}
/**
* Wait for child process to exit
*/
async function waitForExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
if (child.exitCode !== null) {
return true;
}
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), timeoutMs);
child.once("exit", () => {
clearTimeout(timer);
resolve(true);
});
});
}
export interface SpawnOptions {
host?: string;
port?: number;
token?: string;
timeoutMs?: number;
env?: Record<string, string>;
/** Enable OpenCode compatibility mode */
opencodeCompat?: boolean;
}
/**
* Spawn a sandbox-agent instance for testing.
* Each test should spawn its own instance on a unique port.
*/
export async function spawnSandboxAgent(options: SpawnOptions = {}): Promise<SandboxAgentHandle> {
const binaryPath = findBinary();
if (!binaryPath) {
throw new Error(
"sandbox-agent binary not found. Run 'cargo build -p sandbox-agent' first or set SANDBOX_AGENT_BIN."
);
}
const host = options.host ?? "127.0.0.1";
const port = options.port ?? (await getFreePort(host));
const token = options.token ?? randomBytes(24).toString("hex");
const timeoutMs = options.timeoutMs ?? 30_000;
const args = ["server", "--host", host, "--port", String(port), "--token", token];
const compatEnv = {
OPENCODE_COMPAT_FIXED_TIME_MS: "1700000000000",
OPENCODE_COMPAT_DIRECTORY: "/workspace",
OPENCODE_COMPAT_WORKTREE: "/workspace",
OPENCODE_COMPAT_HOME: "/home/opencode",
OPENCODE_COMPAT_STATE: "/state/opencode",
OPENCODE_COMPAT_CONFIG: "/config/opencode",
OPENCODE_COMPAT_BRANCH: "main",
};
const child = spawn(binaryPath, args, {
stdio: "pipe",
env: {
...process.env,
...compatEnv,
...(options.env ?? {}),
},
});
// Collect stderr for debugging
let stderr = "";
child.stderr?.on("data", (chunk) => {
const text = chunk.toString();
stderr += text;
if (process.env.SANDBOX_AGENT_TEST_LOGS) {
process.stderr.write(text);
}
});
if (process.env.SANDBOX_AGENT_TEST_LOGS) {
child.stdout?.on("data", (chunk) => {
process.stderr.write(chunk.toString());
});
}
const baseUrl = `http://${host}:${port}`;
try {
await waitForHealth(baseUrl, token, timeoutMs, child);
} catch (err) {
child.kill("SIGKILL");
if (stderr) {
throw new Error(`${err}. Stderr: ${stderr}`);
}
throw err;
}
const dispose = async () => {
if (child.exitCode !== null) {
return;
}
child.kill("SIGTERM");
const exited = await waitForExit(child, 5_000);
if (!exited) {
child.kill("SIGKILL");
}
};
return { baseUrl, token, child, dispose };
}
/**
* Build the sandbox-agent binary if it doesn't exist
*/
export async function buildSandboxAgent(): Promise<void> {
const binaryPath = findBinary();
if (binaryPath) {
console.log(`sandbox-agent binary found at: ${binaryPath}`);
return;
}
console.log("Building sandbox-agent...");
const projectRoot = resolve(__dirname, "../../../../../..");
return new Promise((resolve, reject) => {
const proc = spawn("cargo", ["build", "-p", "sandbox-agent"], {
cwd: projectRoot,
stdio: "inherit",
env: {
...process.env,
SANDBOX_AGENT_SKIP_INSPECTOR: "1",
},
});
proc.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`cargo build failed with code ${code}`));
}
});
proc.on("error", reject);
});
}

View file

@ -0,0 +1,170 @@
/**
* Tests for OpenCode-compatible messaging endpoints.
*
* These tests verify that sandbox-agent exposes OpenCode-compatible message/prompt
* endpoints that can be used with the official OpenCode SDK.
*
* Expected endpoints:
* - POST /session/{id}/message - Send a prompt to the session
* - GET /session/{id}/message - List messages in a session
* - GET /session/{id}/message/{messageID} - Get a specific message
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
describe("OpenCode-compatible Messaging API", () => {
let handle: SandboxAgentHandle;
let client: OpencodeClient;
let sessionId: string;
beforeAll(async () => {
await buildSandboxAgent();
});
beforeEach(async () => {
handle = await spawnSandboxAgent({ opencodeCompat: true });
client = createOpencodeClient({
baseUrl: `${handle.baseUrl}/opencode`,
headers: { Authorization: `Bearer ${handle.token}` },
});
// Create a session for messaging tests
const session = await client.session.create();
sessionId = session.data?.id!;
expect(sessionId).toBeDefined();
});
afterEach(async () => {
await handle?.dispose();
});
describe("session.prompt", () => {
it("should send a message to the session", async () => {
const response = await client.session.prompt({
path: { id: sessionId },
body: {
model: { providerID: "sandbox-agent", modelID: "mock" },
parts: [{ type: "text", text: "Hello, world!" }],
},
});
// The response should return a message or acknowledgement
expect(response.error).toBeUndefined();
});
it("should accept text-only prompt", async () => {
const response = await client.session.prompt({
path: { id: sessionId },
body: {
model: { providerID: "sandbox-agent", modelID: "mock" },
parts: [{ type: "text", text: "Say hello" }],
},
});
expect(response.error).toBeUndefined();
});
});
describe("session.promptAsync", () => {
it("should send async prompt and return immediately", async () => {
const response = await client.session.promptAsync({
path: { id: sessionId },
body: {
model: { providerID: "sandbox-agent", modelID: "mock" },
parts: [{ type: "text", text: "Process this asynchronously" }],
},
});
// Should return quickly without waiting for completion
expect(response.error).toBeUndefined();
});
});
describe("session.messages", () => {
it("should return empty list for new session", async () => {
const response = await client.session.messages({
path: { id: sessionId },
});
expect(response.data).toBeDefined();
expect(Array.isArray(response.data)).toBe(true);
});
it("should list messages after sending a prompt", async () => {
await client.session.prompt({
path: { id: sessionId },
body: {
model: { providerID: "sandbox-agent", modelID: "mock" },
parts: [{ type: "text", text: "Test message" }],
},
});
const response = await client.session.messages({
path: { id: sessionId },
});
expect(response.data).toBeDefined();
expect(response.data?.length).toBeGreaterThan(0);
});
});
describe("session.message (get specific)", () => {
it("should retrieve a specific message by ID", async () => {
// Send a prompt first
await client.session.prompt({
path: { id: sessionId },
body: {
model: { providerID: "sandbox-agent", modelID: "mock" },
parts: [{ type: "text", text: "Test" }],
},
});
// Get messages to find a message ID
const messagesResponse = await client.session.messages({
path: { id: sessionId },
});
const messageId = messagesResponse.data?.[0]?.id;
if (messageId) {
const response = await client.session.message({
path: { id: sessionId, messageID: messageId },
});
expect(response.data).toBeDefined();
expect(response.data?.id).toBe(messageId);
}
});
});
describe("session.abort", () => {
it("should abort an in-progress session", async () => {
// Start an async prompt
await client.session.promptAsync({
path: { id: sessionId },
body: {
model: { providerID: "sandbox-agent", modelID: "mock" },
parts: [{ type: "text", text: "Long running task" }],
},
});
// Abort the session
const response = await client.session.abort({
path: { id: sessionId },
});
expect(response.error).toBeUndefined();
});
it("should handle abort on idle session gracefully", async () => {
// Abort without starting any work
const response = await client.session.abort({
path: { id: sessionId },
});
// Should not error, even if there's nothing to abort
expect(response.error).toBeUndefined();
});
});
});

View file

@ -0,0 +1,18 @@
{
"name": "@sandbox-agent/opencode-compat-tests",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"dependencies": {
"@opencode-ai/sdk": "^1.1.21"
}
}

View file

@ -0,0 +1,97 @@
/**
* Tests for OpenCode-compatible permission endpoints.
*
* These tests verify that sandbox-agent exposes OpenCode-compatible permission
* handling endpoints that can be used with the official OpenCode SDK.
*
* Expected endpoints:
* - POST /session/{id}/permissions/{permissionID} - Respond to a permission request
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2";
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
describe("OpenCode-compatible Permission API", () => {
let handle: SandboxAgentHandle;
let client: OpencodeClient;
let sessionId: string;
beforeAll(async () => {
await buildSandboxAgent();
});
beforeEach(async () => {
handle = await spawnSandboxAgent({ opencodeCompat: true });
client = createOpencodeClient({
baseUrl: `${handle.baseUrl}/opencode`,
headers: { Authorization: `Bearer ${handle.token}` },
});
// Create a session
const session = await client.session.create();
sessionId = session.data?.id!;
expect(sessionId).toBeDefined();
});
afterEach(async () => {
await handle?.dispose();
});
const permissionPrompt = "permission";
async function waitForPermissionRequest(timeoutMs = 10_000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const list = await client.permission.list();
const request = list.data?.[0];
if (request) {
return request;
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error("Timed out waiting for permission request");
}
describe("permission.reply (global)", () => {
it("should receive permission.asked and reply via global endpoint", async () => {
await client.session.prompt({
sessionID: sessionId,
model: { providerID: "sandbox-agent", modelID: "mock" },
parts: [{ type: "text", text: permissionPrompt }],
});
const asked = await waitForPermissionRequest();
const requestId = asked?.id;
expect(requestId).toBeDefined();
const response = await client.permission.reply({
requestID: requestId,
reply: "once",
});
expect(response.error).toBeUndefined();
});
});
describe("postSessionIdPermissionsPermissionId (session)", () => {
it("should accept permission response for a session", async () => {
await client.session.prompt({
sessionID: sessionId,
model: { providerID: "sandbox-agent", modelID: "mock" },
parts: [{ type: "text", text: permissionPrompt }],
});
const asked = await waitForPermissionRequest();
const requestId = asked?.id;
expect(requestId).toBeDefined();
const response = await client.permission.respond({
sessionID: sessionId,
permissionID: requestId,
response: "allow",
});
expect(response.error).toBeUndefined();
});
});
});

View file

@ -0,0 +1,83 @@
/**
* Tests for OpenCode-compatible question endpoints.
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2";
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
describe("OpenCode-compatible Question API", () => {
let handle: SandboxAgentHandle;
let client: OpencodeClient;
let sessionId: string;
beforeAll(async () => {
await buildSandboxAgent();
});
beforeEach(async () => {
handle = await spawnSandboxAgent({ opencodeCompat: true });
client = createOpencodeClient({
baseUrl: `${handle.baseUrl}/opencode`,
headers: { Authorization: `Bearer ${handle.token}` },
});
const session = await client.session.create();
sessionId = session.data?.id!;
expect(sessionId).toBeDefined();
});
afterEach(async () => {
await handle?.dispose();
});
const questionPrompt = "question";
async function waitForQuestionRequest(timeoutMs = 10_000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const list = await client.question.list();
const request = list.data?.[0];
if (request) {
return request;
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error("Timed out waiting for question request");
}
it("should ask a question and accept a reply", async () => {
await client.session.prompt({
sessionID: sessionId,
model: { providerID: "sandbox-agent", modelID: "mock" },
parts: [{ type: "text", text: questionPrompt }],
});
const asked = await waitForQuestionRequest();
const requestId = asked?.id;
expect(requestId).toBeDefined();
const replyResponse = await client.question.reply({
requestID: requestId,
answers: [["Yes"]],
});
expect(replyResponse.error).toBeUndefined();
});
it("should allow rejecting a question", async () => {
await client.session.prompt({
sessionID: sessionId,
model: { providerID: "sandbox-agent", modelID: "mock" },
parts: [{ type: "text", text: questionPrompt }],
});
const asked = await waitForQuestionRequest();
const requestId = asked?.id;
expect(requestId).toBeDefined();
const rejectResponse = await client.question.reject({
requestID: requestId,
});
expect(rejectResponse.error).toBeUndefined();
});
});

View file

@ -0,0 +1,148 @@
/**
* Tests for OpenCode-compatible session management endpoints.
*
* These tests verify that sandbox-agent exposes OpenCode-compatible API endpoints
* that can be used with the official OpenCode SDK.
*
* Expected endpoints:
* - POST /session - Create a new session
* - GET /session - List all sessions
* - GET /session/{id} - Get session details
* - PATCH /session/{id} - Update session properties
* - DELETE /session/{id} - Delete a session
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
describe("OpenCode-compatible Session API", () => {
let handle: SandboxAgentHandle;
let client: OpencodeClient;
beforeAll(async () => {
// Build the binary if needed
await buildSandboxAgent();
});
beforeEach(async () => {
// Spawn a fresh sandbox-agent instance for each test
handle = await spawnSandboxAgent({ opencodeCompat: true });
client = createOpencodeClient({
baseUrl: `${handle.baseUrl}/opencode`,
headers: { Authorization: `Bearer ${handle.token}` },
});
});
afterEach(async () => {
await handle?.dispose();
});
describe("session.create", () => {
it("should create a new session", async () => {
const response = await client.session.create();
expect(response.data).toBeDefined();
expect(response.data?.id).toBeDefined();
expect(typeof response.data?.id).toBe("string");
expect(response.data?.id.length).toBeGreaterThan(0);
});
it("should create session with custom title", async () => {
const response = await client.session.create({
body: { title: "Test Session" },
});
expect(response.data).toBeDefined();
expect(response.data?.title).toBe("Test Session");
});
it("should assign unique IDs to each session", async () => {
const session1 = await client.session.create();
const session2 = await client.session.create();
expect(session1.data?.id).not.toBe(session2.data?.id);
});
});
describe("session.list", () => {
it("should return empty list when no sessions exist", async () => {
const response = await client.session.list();
expect(response.data).toBeDefined();
expect(Array.isArray(response.data)).toBe(true);
expect(response.data?.length).toBe(0);
});
it("should list created sessions", async () => {
// Create some sessions
await client.session.create({ body: { title: "Session 1" } });
await client.session.create({ body: { title: "Session 2" } });
const response = await client.session.list();
expect(response.data).toBeDefined();
expect(response.data?.length).toBe(2);
});
});
describe("session.get", () => {
it("should retrieve session by ID", async () => {
const created = await client.session.create({ body: { title: "Test" } });
const sessionId = created.data?.id;
expect(sessionId).toBeDefined();
const response = await client.session.get({ path: { id: sessionId! } });
expect(response.data).toBeDefined();
expect(response.data?.id).toBe(sessionId);
expect(response.data?.title).toBe("Test");
});
it("should return error for non-existent session", async () => {
const response = await client.session.get({
path: { id: "non-existent-session-id" },
});
expect(response.error).toBeDefined();
});
});
describe("session.update", () => {
it("should update session title", async () => {
const created = await client.session.create({ body: { title: "Original" } });
const sessionId = created.data?.id!;
await client.session.update({
path: { id: sessionId },
body: { title: "Updated" },
});
const response = await client.session.get({ path: { id: sessionId } });
expect(response.data?.title).toBe("Updated");
});
});
describe("session.delete", () => {
it("should delete a session", async () => {
const created = await client.session.create();
const sessionId = created.data?.id!;
await client.session.delete({ path: { id: sessionId } });
const response = await client.session.get({ path: { id: sessionId } });
expect(response.error).toBeDefined();
});
it("should not affect other sessions when one is deleted", async () => {
const session1 = await client.session.create({ body: { title: "Keep" } });
const session2 = await client.session.create({ body: { title: "Delete" } });
await client.session.delete({ path: { id: session2.data?.id! } });
const response = await client.session.get({ path: { id: session1.data?.id! } });
expect(response.data).toBeDefined();
expect(response.data?.title).toBe("Keep");
});
});
});

View file

@ -0,0 +1,85 @@
/**
* Tests for OpenCode-compatible tool calls and file actions.
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
describe("OpenCode-compatible Tool + File Actions", () => {
let handle: SandboxAgentHandle;
let client: OpencodeClient;
let sessionId: string;
beforeAll(async () => {
await buildSandboxAgent();
});
beforeEach(async () => {
handle = await spawnSandboxAgent({ opencodeCompat: true });
client = createOpencodeClient({
baseUrl: `${handle.baseUrl}/opencode`,
headers: { Authorization: `Bearer ${handle.token}` },
});
const session = await client.session.create();
sessionId = session.data?.id!;
expect(sessionId).toBeDefined();
});
afterEach(async () => {
await handle?.dispose();
});
it("should emit tool and file parts plus file.edited events", async () => {
const eventStream = await client.event.subscribe();
const tracker = {
tool: false,
file: false,
edited: false,
};
const waiter = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Timed out waiting for tool events")), 15_000);
(async () => {
try {
for await (const event of (eventStream as any).stream) {
if (event.type === "message.part.updated") {
const part = event.properties?.part;
if (part?.type === "tool") {
tracker.tool = true;
}
if (part?.type === "file") {
tracker.file = true;
}
}
if (event.type === "file.edited") {
tracker.edited = true;
}
if (tracker.tool && tracker.file && tracker.edited) {
clearTimeout(timeout);
resolve();
break;
}
}
} catch (err) {
clearTimeout(timeout);
reject(err);
}
})();
});
await client.session.prompt({
path: { id: sessionId },
body: {
model: { providerID: "sandbox-agent", modelID: "mock" },
parts: [{ type: "text", text: "tool" }],
},
});
await waiter;
expect(tracker.tool).toBe(true);
expect(tracker.file).toBe(true);
expect(tracker.edited).toBe(true);
});
});

View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": false,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["node"]
},
"include": ["**/*.ts"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
import { resolve } from "node:path";
import { realpathSync } from "node:fs";
// Resolve the actual SDK path through pnpm's symlink structure
function resolveSdkPath(): string {
try {
// Try to resolve through the local node_modules symlink
const localPath = resolve(__dirname, "node_modules/@opencode-ai/sdk");
const realPath = realpathSync(localPath);
return resolve(realPath, "dist");
} catch {
// Fallback to root node_modules
return resolve(__dirname, "../../../../../node_modules/@opencode-ai/sdk/dist");
}
}
export default defineConfig({
test: {
include: ["**/*.test.ts"],
testTimeout: 60_000,
hookTimeout: 60_000,
},
resolve: {
alias: {
// Work around SDK publishing issue where exports point to src/ instead of dist/
"@opencode-ai/sdk": resolveSdkPath(),
},
},
});

View file

@ -0,0 +1,73 @@
use std::collections::BTreeSet;
use std::fs;
use std::path::PathBuf;
use sandbox_agent::opencode_compat::OpenCodeApiDoc;
use serde_json::Value;
use utoipa::OpenApi;
fn collect_path_methods(spec: &Value) -> BTreeSet<String> {
let mut methods = BTreeSet::new();
let Some(paths) = spec.get("paths").and_then(|value| value.as_object()) else {
return methods;
};
for (path, item) in paths {
let Some(item) = item.as_object() else {
continue;
};
for method in [
"get", "post", "put", "patch", "delete", "options", "head", "trace",
] {
if item.contains_key(method) {
methods.insert(format!("{} {}", method.to_uppercase(), path));
}
}
}
methods
}
fn official_spec_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../resources/agent-schemas/artifacts/openapi/opencode.json")
}
#[test]
fn opencode_openapi_matches_official_paths() {
let official_path = official_spec_path();
let official_json = fs::read_to_string(&official_path)
.unwrap_or_else(|err| panic!("failed to read official OpenCode spec at {official_path:?}: {err}"));
let official: Value =
serde_json::from_str(&official_json).expect("official OpenCode spec is not valid JSON");
let ours = OpenCodeApiDoc::openapi();
let ours_value = serde_json::to_value(&ours).expect("failed to serialize OpenCode OpenAPI");
let official_methods = collect_path_methods(&official);
let our_methods = collect_path_methods(&ours_value);
let missing: Vec<_> = official_methods
.difference(&our_methods)
.cloned()
.collect();
let extra: Vec<_> = our_methods
.difference(&official_methods)
.cloned()
.collect();
if !missing.is_empty() || !extra.is_empty() {
let mut message = String::new();
if !missing.is_empty() {
message.push_str("Missing endpoints (present in official spec, absent in ours):\n");
for endpoint in &missing {
message.push_str(&format!("- {endpoint}\n"));
}
}
if !extra.is_empty() {
message.push_str("Extra endpoints (present in ours, absent in official spec):\n");
for endpoint in &extra {
message.push_str(&format!("- {endpoint}\n"));
}
}
panic!("{message}");
}
}

View file

@ -43,7 +43,7 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
let (session_id, message_id) = part_session_message(part);
match part {
schema::Part::Variant0(text_part) => {
schema::Part::TextPart(text_part) => {
let schema::TextPart { text, .. } = text_part;
let delta_text = delta.as_ref().unwrap_or(&text).clone();
let stub = stub_message_item(&message_id, ItemRole::Assistant);
@ -68,7 +68,7 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
.with_raw(raw.clone()),
);
}
schema::Part::Variant2(reasoning_part) => {
schema::Part::ReasoningPart(reasoning_part) => {
let delta_text = delta
.as_ref()
.cloned()
@ -95,7 +95,7 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
.with_raw(raw.clone()),
);
}
schema::Part::Variant3(file_part) => {
schema::Part::FilePart(file_part) => {
let file_content = file_part_to_content(file_part);
let item = UniversalItem {
item_id: String::new(),
@ -115,7 +115,7 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
.with_raw(raw.clone()),
);
}
schema::Part::Variant4(tool_part) => {
schema::Part::ToolPart(tool_part) => {
let tool_events = tool_part_to_events(&tool_part, &message_id);
for event in tool_events {
events.push(
@ -125,9 +125,9 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
);
}
}
schema::Part::Variant1 { .. } => {
let detail =
serde_json::to_string(part).unwrap_or_else(|_| "subtask".to_string());
schema::Part::SubtaskPart(subtask_part) => {
let detail = serde_json::to_string(subtask_part)
.unwrap_or_else(|_| "subtask".to_string());
let item = status_item("subtask", Some(detail));
events.push(
EventConversion::new(
@ -138,13 +138,13 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
.with_raw(raw.clone()),
);
}
schema::Part::Variant5(_)
| schema::Part::Variant6(_)
| schema::Part::Variant7(_)
| schema::Part::Variant8(_)
| schema::Part::Variant9(_)
| schema::Part::Variant10(_)
| schema::Part::Variant11(_) => {
schema::Part::StepStartPart(_)
| schema::Part::StepFinishPart(_)
| schema::Part::SnapshotPart(_)
| schema::Part::PatchPart(_)
| schema::Part::AgentPart(_)
| schema::Part::RetryPart(_)
| schema::Part::CompactionPart(_) => {
let detail = serde_json::to_string(part).unwrap_or_else(|_| "part".to_string());
let item = status_item("part.updated", Some(detail));
events.push(
@ -306,52 +306,51 @@ fn message_to_item(message: &schema::Message) -> (UniversalItem, bool, Option<St
fn part_session_message(part: &schema::Part) -> (Option<String>, String) {
match part {
schema::Part::Variant0(text_part) => (
schema::Part::TextPart(text_part) => (
Some(text_part.session_id.clone()),
text_part.message_id.clone(),
),
schema::Part::Variant1 {
session_id,
message_id,
..
} => (Some(session_id.clone()), message_id.clone()),
schema::Part::Variant2(reasoning_part) => (
schema::Part::SubtaskPart(subtask_part) => (
Some(subtask_part.session_id.clone()),
subtask_part.message_id.clone(),
),
schema::Part::ReasoningPart(reasoning_part) => (
Some(reasoning_part.session_id.clone()),
reasoning_part.message_id.clone(),
),
schema::Part::Variant3(file_part) => (
schema::Part::FilePart(file_part) => (
Some(file_part.session_id.clone()),
file_part.message_id.clone(),
),
schema::Part::Variant4(tool_part) => (
schema::Part::ToolPart(tool_part) => (
Some(tool_part.session_id.clone()),
tool_part.message_id.clone(),
),
schema::Part::Variant5(step_part) => (
schema::Part::StepStartPart(step_part) => (
Some(step_part.session_id.clone()),
step_part.message_id.clone(),
),
schema::Part::Variant6(step_part) => (
schema::Part::StepFinishPart(step_part) => (
Some(step_part.session_id.clone()),
step_part.message_id.clone(),
),
schema::Part::Variant7(snapshot_part) => (
schema::Part::SnapshotPart(snapshot_part) => (
Some(snapshot_part.session_id.clone()),
snapshot_part.message_id.clone(),
),
schema::Part::Variant8(patch_part) => (
schema::Part::PatchPart(patch_part) => (
Some(patch_part.session_id.clone()),
patch_part.message_id.clone(),
),
schema::Part::Variant9(agent_part) => (
schema::Part::AgentPart(agent_part) => (
Some(agent_part.session_id.clone()),
agent_part.message_id.clone(),
),
schema::Part::Variant10(retry_part) => (
schema::Part::RetryPart(retry_part) => (
Some(retry_part.session_id.clone()),
retry_part.message_id.clone(),
),
schema::Part::Variant11(compaction_part) => (
schema::Part::CompactionPart(compaction_part) => (
Some(compaction_part.session_id.clone()),
compaction_part.message_id.clone(),
),