mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 12:03:53 +00:00
Merge origin/main into sa-processes
This commit is contained in:
commit
0171e33873
61 changed files with 3140 additions and 840 deletions
7
.github/workflows/ci.yaml
vendored
7
.github/workflows/ci.yaml
vendored
|
|
@ -14,15 +14,16 @@ jobs:
|
|||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@main
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: pnpm install
|
||||
- run: npm install -g tsx
|
||||
- name: Run checks
|
||||
run: ./scripts/release/main.ts --version 0.0.0 --check
|
||||
run: ./scripts/release/main.ts --version 0.0.0 --only-steps run-ci-checks
|
||||
- name: Run ACP v1 server tests
|
||||
run: |
|
||||
cargo test -p sandbox-agent-agent-management
|
||||
|
|
@ -31,5 +32,3 @@ jobs:
|
|||
cargo test -p sandbox-agent --lib
|
||||
- name: Run SDK tests
|
||||
run: pnpm --dir sdks/typescript test
|
||||
- name: Run Inspector browser E2E
|
||||
run: pnpm --filter @sandbox-agent/inspector test:agent-browser
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@
|
|||
- `acp-http-client`: protocol-pure ACP-over-HTTP (`/v1/acp`) with no Sandbox-specific HTTP helpers.
|
||||
- `sandbox-agent`: `SandboxAgent` SDK wrapper that combines ACP session operations with Sandbox control-plane and filesystem helpers.
|
||||
- `SandboxAgent` entry points are `SandboxAgent.connect(...)` and `SandboxAgent.start(...)`.
|
||||
- Stable Sandbox session methods are `createSession`, `resumeSession`, `resumeOrCreateSession`, `destroySession`, `sendSessionMethod`, and `onSessionEvent`.
|
||||
- `Session` helpers are `prompt(...)`, `send(...)`, and `onEvent(...)`.
|
||||
- Stable Sandbox session methods are `createSession`, `resumeSession`, `resumeOrCreateSession`, `destroySession`, `sendSessionMethod`, `onSessionEvent`, `setSessionMode`, `setSessionModel`, `setSessionThoughtLevel`, `setSessionConfigOption`, `getSessionConfigOptions`, and `getSessionModes`.
|
||||
- `Session` helpers are `prompt(...)`, `send(...)`, `onEvent(...)`, `setMode(...)`, `setModel(...)`, `setThoughtLevel(...)`, `setConfigOption(...)`, `getConfigOptions()`, and `getModes()`.
|
||||
- Cleanup is `sdk.dispose()`.
|
||||
|
||||
### Docs Source Of Truth
|
||||
|
|
@ -86,6 +86,8 @@
|
|||
- Regenerate `docs/openapi.json` when HTTP contracts change.
|
||||
- Keep `docs/inspector.mdx` and `docs/sdks/typescript.mdx` aligned with implementation.
|
||||
- Append blockers/decisions to `research/acp/friction.md` during ACP work.
|
||||
- `docs/agent-capabilities.mdx` lists models/modes/thought levels per agent. Update it when adding a new agent or changing `fallback_config_options`. If its "Last updated" date is >2 weeks old, re-run `cd scripts/agent-configs && npx tsx dump.ts` and update the doc to match. Source data: `scripts/agent-configs/resources/*.json` and hardcoded entries in `server/packages/sandbox-agent/src/router/support.rs` (`fallback_config_options`).
|
||||
- Some agent models are gated by subscription (e.g. Claude `opus`). The live report only shows models available to the current credentials. The static doc and JSON resource files should list all known models regardless of subscription tier.
|
||||
- TypeScript SDK tests should run against a real running server/runtime over real `/v1` HTTP APIs, typically using the real `mock` agent for deterministic behavior.
|
||||
- Do not use Vitest fetch/transport mocks to simulate server functionality in TypeScript SDK tests.
|
||||
|
||||
|
|
|
|||
16
Cargo.toml
16
Cargo.toml
|
|
@ -3,7 +3,7 @@ resolver = "2"
|
|||
members = ["server/packages/*", "gigacode"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
edition = "2021"
|
||||
authors = [ "Rivet Gaming, LLC <developer@rivet.gg>" ]
|
||||
license = "Apache-2.0"
|
||||
|
|
@ -12,13 +12,13 @@ description = "Universal API for automatic coding agents in sandboxes. Supports
|
|||
|
||||
[workspace.dependencies]
|
||||
# Internal crates
|
||||
sandbox-agent = { version = "0.2.1", path = "server/packages/sandbox-agent" }
|
||||
sandbox-agent-error = { version = "0.2.1", path = "server/packages/error" }
|
||||
sandbox-agent-agent-management = { version = "0.2.1", path = "server/packages/agent-management" }
|
||||
sandbox-agent-agent-credentials = { version = "0.2.1", path = "server/packages/agent-credentials" }
|
||||
sandbox-agent-opencode-adapter = { version = "0.2.1", path = "server/packages/opencode-adapter" }
|
||||
sandbox-agent-opencode-server-manager = { version = "0.2.1", path = "server/packages/opencode-server-manager" }
|
||||
acp-http-adapter = { version = "0.2.1", path = "server/packages/acp-http-adapter" }
|
||||
sandbox-agent = { version = "0.2.2", path = "server/packages/sandbox-agent" }
|
||||
sandbox-agent-error = { version = "0.2.2", path = "server/packages/error" }
|
||||
sandbox-agent-agent-management = { version = "0.2.2", path = "server/packages/agent-management" }
|
||||
sandbox-agent-agent-credentials = { version = "0.2.2", path = "server/packages/agent-credentials" }
|
||||
sandbox-agent-opencode-adapter = { version = "0.2.2", path = "server/packages/opencode-adapter" }
|
||||
sandbox-agent-opencode-server-manager = { version = "0.2.2", path = "server/packages/opencode-server-manager" }
|
||||
acp-http-adapter = { version = "0.2.2", path = "server/packages/acp-http-adapter" }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
|
|
|||
127
docs/agent-capabilities.mdx
Normal file
127
docs/agent-capabilities.mdx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
---
|
||||
title: "Agent Capabilities"
|
||||
description: "Models, modes, and thought levels supported by each agent."
|
||||
---
|
||||
|
||||
Capabilities are subject to change as the agents are updated. See [Agent Sessions](/agent-sessions) for full session configuration API details.
|
||||
|
||||
|
||||
<Info>
|
||||
_Last updated: March 5th, 2026. See [Generating a live report](#generating-a-live-report) for up-to-date reference._
|
||||
</Info>
|
||||
|
||||
## Claude
|
||||
|
||||
| Category | Values |
|
||||
|----------|--------|
|
||||
| **Models** | `default`, `sonnet`, `opus`, `haiku` |
|
||||
| **Modes** | `default`, `acceptEdits`, `plan`, `dontAsk`, `bypassPermissions` |
|
||||
| **Thought levels** | Unsupported |
|
||||
|
||||
### Configuring Effort Level For Claude
|
||||
|
||||
Claude does not natively support changing effort level after a session starts, so configure it in the filesystem before creating the session.
|
||||
|
||||
```ts
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
|
||||
const cwd = "/path/to/workspace";
|
||||
await mkdir(path.join(cwd, ".claude"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(cwd, ".claude", "settings.json"),
|
||||
JSON.stringify({ effortLevel: "high" }, null, 2),
|
||||
);
|
||||
|
||||
const sdk = await SandboxAgent.connect({ baseUrl: "http://127.0.0.1:2468" });
|
||||
await sdk.createSession({
|
||||
agent: "claude",
|
||||
sessionInit: { cwd, mcpServers: [] },
|
||||
});
|
||||
```
|
||||
|
||||
<Accordion title="Supported file locations (highest precedence last)">
|
||||
|
||||
1. `~/.claude/settings.json`
|
||||
2. `<session cwd>/.claude/settings.json`
|
||||
3. `<session cwd>/.claude/settings.local.json`
|
||||
|
||||
</Accordion>
|
||||
|
||||
## Codex
|
||||
|
||||
| Category | Values |
|
||||
|----------|--------|
|
||||
| **Models** | `gpt-5.3-codex` (default), `gpt-5.3-codex-spark`, `gpt-5.2-codex`, `gpt-5.1-codex-max`, `gpt-5.2`, `gpt-5.1-codex-mini` |
|
||||
| **Modes** | `read-only` (default), `auto`, `full-access` |
|
||||
| **Thought levels** | `low`, `medium`, `high` (default), `xhigh` |
|
||||
|
||||
## OpenCode
|
||||
|
||||
| Category | Values |
|
||||
|----------|--------|
|
||||
| **Models** | See below |
|
||||
| **Modes** | `build` (default), `plan` |
|
||||
| **Thought levels** | Unsupported |
|
||||
|
||||
<Accordion title="See all models">
|
||||
|
||||
| Provider | Models |
|
||||
|----------|--------|
|
||||
| **Anthropic** | `anthropic/claude-3-5-haiku-20241022`, `anthropic/claude-3-5-haiku-latest`, `anthropic/claude-3-5-sonnet-20240620`, `anthropic/claude-3-5-sonnet-20241022`, `anthropic/claude-3-7-sonnet-20250219`, `anthropic/claude-3-7-sonnet-latest`, `anthropic/claude-3-haiku-20240307`, `anthropic/claude-3-opus-20240229`, `anthropic/claude-3-sonnet-20240229`, `anthropic/claude-haiku-4-5`, `anthropic/claude-haiku-4-5-20251001`, `anthropic/claude-opus-4-0`, `anthropic/claude-opus-4-1`, `anthropic/claude-opus-4-1-20250805`, `anthropic/claude-opus-4-20250514`, `anthropic/claude-opus-4-5`, `anthropic/claude-opus-4-5-20251101`, `anthropic/claude-opus-4-6`, `anthropic/claude-sonnet-4-0`, `anthropic/claude-sonnet-4-20250514`, `anthropic/claude-sonnet-4-5`, `anthropic/claude-sonnet-4-5-20250929` |
|
||||
| **OpenAI** | `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.3-codex` |
|
||||
| **Cerebras** | `cerebras/gpt-oss-120b`, `cerebras/qwen-3-235b-a22b-instruct-2507`, `cerebras/zai-glm-4.7` |
|
||||
| **OpenCode Zen** | `opencode/big-pickle`, `opencode/claude-3-5-haiku`, `opencode/claude-haiku-4-5`, `opencode/claude-opus-4-1`, `opencode/claude-opus-4-5`, `opencode/claude-opus-4-6`, `opencode/claude-sonnet-4`, `opencode/claude-sonnet-4-5`, `opencode/gemini-3-flash`, `opencode/gemini-3-pro` (default), `opencode/glm-4.6`, `opencode/glm-4.7`, `opencode/gpt-5`, `opencode/gpt-5-codex`, `opencode/gpt-5-nano`, `opencode/gpt-5.1`, `opencode/gpt-5.1-codex`, `opencode/gpt-5.1-codex-max`, `opencode/gpt-5.1-codex-mini`, `opencode/gpt-5.2`, `opencode/gpt-5.2-codex`, `opencode/kimi-k2`, `opencode/kimi-k2-thinking`, `opencode/kimi-k2.5`, `opencode/kimi-k2.5-free`, `opencode/minimax-m2.1`, `opencode/minimax-m2.1-free`, `opencode/trinity-large-preview-free` |
|
||||
|
||||
</Accordion>
|
||||
|
||||
## Cursor
|
||||
|
||||
| Category | Values |
|
||||
|----------|--------|
|
||||
| **Models** | See below |
|
||||
| **Modes** | Unsupported |
|
||||
| **Thought levels** | Unsupported |
|
||||
|
||||
<Accordion title="See all models">
|
||||
|
||||
| Group | Models |
|
||||
|-------|--------|
|
||||
| **Auto** | `auto` |
|
||||
| **Composer** | `composer-1.5`, `composer-1` |
|
||||
| **GPT-5.3 Codex** | `gpt-5.3-codex`, `gpt-5.3-codex-low`, `gpt-5.3-codex-high`, `gpt-5.3-codex-xhigh`, `gpt-5.3-codex-fast`, `gpt-5.3-codex-low-fast`, `gpt-5.3-codex-high-fast`, `gpt-5.3-codex-xhigh-fast` |
|
||||
| **GPT-5.2** | `gpt-5.2`, `gpt-5.2-high`, `gpt-5.2-codex`, `gpt-5.2-codex-low`, `gpt-5.2-codex-high`, `gpt-5.2-codex-xhigh`, `gpt-5.2-codex-fast`, `gpt-5.2-codex-low-fast`, `gpt-5.2-codex-high-fast`, `gpt-5.2-codex-xhigh-fast` |
|
||||
| **GPT-5.1** | `gpt-5.1-high`, `gpt-5.1-codex-max`, `gpt-5.1-codex-max-high` |
|
||||
| **Claude** | `opus-4.6-thinking` (default), `opus-4.6`, `opus-4.5`, `opus-4.5-thinking`, `sonnet-4.5`, `sonnet-4.5-thinking` |
|
||||
| **Other** | `gemini-3-pro`, `gemini-3-flash`, `grok` |
|
||||
|
||||
</Accordion>
|
||||
|
||||
## Amp
|
||||
|
||||
| Category | Values |
|
||||
|----------|--------|
|
||||
| **Models** | `amp-default` |
|
||||
| **Modes** | `default`, `bypass` |
|
||||
| **Thought levels** | Unsupported |
|
||||
|
||||
## Pi
|
||||
|
||||
| Category | Values |
|
||||
|----------|--------|
|
||||
| **Models** | `default` |
|
||||
| **Modes** | Unsupported |
|
||||
| **Thought levels** | Unsupported |
|
||||
|
||||
## Generating a live report
|
||||
|
||||
Requires a running Sandbox Agent server. `--endpoint` defaults to `http://127.0.0.1:2468`.
|
||||
|
||||
```bash
|
||||
sandbox-agent api agents report
|
||||
```
|
||||
|
||||
<Note>
|
||||
The live report reflects what the agent adapter returns for the current credentials. Some models may be gated by subscription (e.g. Claude's `opus` requires a paid plan) and will not appear in the report if the credentials don't have access.
|
||||
</Note>
|
||||
|
|
@ -82,6 +82,49 @@ if (sessions.items.length > 0) {
|
|||
}
|
||||
```
|
||||
|
||||
## Configure model, mode, and thought level
|
||||
|
||||
Set the model, mode, or thought level on a session at creation time or after:
|
||||
|
||||
```ts
|
||||
// At creation time
|
||||
const session = await sdk.createSession({
|
||||
agent: "codex",
|
||||
model: "gpt-5.3-codex",
|
||||
mode: "auto",
|
||||
thoughtLevel: "high",
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// After creation
|
||||
await session.setModel("gpt-5.2-codex");
|
||||
await session.setMode("full-access");
|
||||
await session.setThoughtLevel("medium");
|
||||
```
|
||||
|
||||
Query available modes:
|
||||
|
||||
```ts
|
||||
const modes = await session.getModes();
|
||||
console.log(modes?.currentModeId, modes?.availableModes);
|
||||
```
|
||||
|
||||
### Advanced config options
|
||||
|
||||
For config options beyond model, mode, and thought level, use `getConfigOptions` to discover what the agent supports and `setConfigOption` to set any option by ID:
|
||||
|
||||
```ts
|
||||
const options = await session.getConfigOptions();
|
||||
for (const opt of options) {
|
||||
console.log(opt.id, opt.category, opt.type);
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
await session.setConfigOption("some-agent-option", "value");
|
||||
```
|
||||
|
||||
## Destroy a session
|
||||
|
||||
```ts
|
||||
|
|
|
|||
59
docs/cli.mdx
59
docs/cli.mdx
|
|
@ -167,6 +167,65 @@ Shared option:
|
|||
|
||||
```bash
|
||||
sandbox-agent api agents list [--endpoint <URL>]
|
||||
sandbox-agent api agents report [--endpoint <URL>]
|
||||
sandbox-agent api agents install <AGENT> [--reinstall] [--endpoint <URL>]
|
||||
```
|
||||
|
||||
#### api agents list
|
||||
|
||||
List all agents and their install status.
|
||||
|
||||
```bash
|
||||
sandbox-agent api agents list
|
||||
```
|
||||
|
||||
#### api agents report
|
||||
|
||||
Emit a JSON report of available models, modes, and thought levels for every agent. Calls `GET /v1/agents?config=true` and groups each agent's config options by category.
|
||||
|
||||
```bash
|
||||
sandbox-agent api agents report --endpoint http://127.0.0.1:2468 | jq .
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```json
|
||||
{
|
||||
"generatedAtMs": 1740000000000,
|
||||
"endpoint": "http://127.0.0.1:2468",
|
||||
"agents": [
|
||||
{
|
||||
"id": "claude",
|
||||
"installed": true,
|
||||
"models": {
|
||||
"currentValue": "default",
|
||||
"values": [
|
||||
{ "value": "default", "name": "Default" },
|
||||
{ "value": "sonnet", "name": "Sonnet" },
|
||||
{ "value": "opus", "name": "Opus" },
|
||||
{ "value": "haiku", "name": "Haiku" }
|
||||
]
|
||||
},
|
||||
"modes": {
|
||||
"currentValue": "default",
|
||||
"values": [
|
||||
{ "value": "default", "name": "Default" },
|
||||
{ "value": "acceptEdits", "name": "Accept Edits" },
|
||||
{ "value": "plan", "name": "Plan" },
|
||||
{ "value": "dontAsk", "name": "Don't Ask" },
|
||||
{ "value": "bypassPermissions", "name": "Bypass Permissions" }
|
||||
]
|
||||
},
|
||||
"thoughtLevels": { "values": [] }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
See [Agent Capabilities](/agent-capabilities) for a full reference of supported models, modes, and thought levels per agent.
|
||||
|
||||
#### api agents install
|
||||
|
||||
```bash
|
||||
sandbox-agent api agents install codex --reinstall
|
||||
```
|
||||
|
|
|
|||
|
|
@ -16,17 +16,11 @@ docker run --rm -p 3000:3000 \
|
|||
-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
|
||||
-e OPENAI_API_KEY="$OPENAI_API_KEY" \
|
||||
alpine:latest sh -c "\
|
||||
apk add --no-cache curl ca-certificates libstdc++ libgcc bash && \
|
||||
apk add --no-cache curl ca-certificates libstdc++ libgcc bash nodejs npm && \
|
||||
curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh && \
|
||||
sandbox-agent install-agent claude && \
|
||||
sandbox-agent install-agent codex && \
|
||||
sandbox-agent server --no-token --host 0.0.0.0 --port 3000"
|
||||
```
|
||||
|
||||
<Note>
|
||||
Alpine is required for some agent binaries that target musl libc.
|
||||
</Note>
|
||||
|
||||
## TypeScript with dockerode
|
||||
|
||||
```typescript
|
||||
|
|
@ -37,17 +31,18 @@ const docker = new Docker();
|
|||
const PORT = 3000;
|
||||
|
||||
const container = await docker.createContainer({
|
||||
Image: "alpine:latest",
|
||||
Image: "node:22-bookworm-slim",
|
||||
Cmd: ["sh", "-c", [
|
||||
"apk add --no-cache curl ca-certificates libstdc++ libgcc bash",
|
||||
"apt-get update",
|
||||
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6",
|
||||
"rm -rf /var/lib/apt/lists/*",
|
||||
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
|
||||
"sandbox-agent install-agent claude",
|
||||
"sandbox-agent install-agent codex",
|
||||
`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`,
|
||||
].join(" && ")],
|
||||
Env: [
|
||||
`ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`,
|
||||
`OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`,
|
||||
`CODEX_API_KEY=${process.env.CODEX_API_KEY}`,
|
||||
].filter(Boolean),
|
||||
ExposedPorts: { [`${PORT}/tcp`]: {} },
|
||||
HostConfig: {
|
||||
|
|
@ -61,7 +56,7 @@ await container.start();
|
|||
const baseUrl = `http://127.0.0.1:${PORT}`;
|
||||
const sdk = await SandboxAgent.connect({ baseUrl });
|
||||
|
||||
const session = await sdk.createSession({ agent: "claude" });
|
||||
const session = await sdk.createSession({ agent: "codex" });
|
||||
await session.prompt([{ type: "text", text: "Summarize this repository." }]);
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@
|
|||
{
|
||||
"group": "Reference",
|
||||
"pages": [
|
||||
"agent-capabilities",
|
||||
"cli",
|
||||
"inspector",
|
||||
"opencode-compatibility",
|
||||
|
|
|
|||
|
|
@ -1595,13 +1595,96 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/v1/processes/{id}/terminal/resize": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"summary": "Resize a process terminal.",
|
||||
"description": "Sets the PTY window size (columns and rows) for a tty-mode process and\nsends SIGWINCH so the child process can adapt.",
|
||||
"operationId": "post_v1_process_terminal_resize",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "Process ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProcessTerminalResizeRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Resize accepted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProcessTerminalResizeResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Unknown process",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Not a terminal process",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"501": {
|
||||
"description": "Process API unsupported on this platform",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/processes/{id}/terminal/ws": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"summary": "Open an interactive WebSocket terminal session.",
|
||||
"description": "Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts\n`access_token` query param for browser-based auth (WebSocket API cannot\nsend custom headers). Uses the `channel.k8s.io` binary subprotocol:\nchannel 0 stdin, channel 1 stdout, channel 3 status JSON, channel 4 resize,\nand channel 255 close.",
|
||||
"description": "Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts\n`access_token` query param for browser-based auth (WebSocket API cannot\nsend custom headers). Streams raw PTY output as binary frames and accepts\nJSON control frames for input, resize, and close.",
|
||||
"operationId": "get_v1_process_terminal_ws",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -2674,6 +2757,44 @@
|
|||
"exited"
|
||||
]
|
||||
},
|
||||
"ProcessTerminalResizeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cols",
|
||||
"rows"
|
||||
],
|
||||
"properties": {
|
||||
"cols": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
},
|
||||
"rows": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProcessTerminalResizeResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cols",
|
||||
"rows"
|
||||
],
|
||||
"properties": {
|
||||
"cols": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
},
|
||||
"rows": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ServerStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ const sdk = await SandboxAgent.connect({
|
|||
});
|
||||
```
|
||||
|
||||
`SandboxAgent.connect(...)` now waits for `/v1/health` by default before other SDK requests proceed. To disable that gate, pass `waitForHealth: false`. To keep the default gate but fail after a bounded wait, pass `waitForHealth: { timeoutMs: 120_000 }`. To cancel the startup wait early, pass `signal: abortController.signal`.
|
||||
|
||||
With a custom fetch handler (for example, proxying requests inside Workers):
|
||||
|
||||
```ts
|
||||
|
|
@ -47,6 +49,19 @@ const sdk = await SandboxAgent.connect({
|
|||
});
|
||||
```
|
||||
|
||||
With an abort signal for the startup health gate:
|
||||
|
||||
```ts
|
||||
const controller = new AbortController();
|
||||
|
||||
const sdk = await SandboxAgent.connect({
|
||||
baseUrl: "http://127.0.0.1:2468",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
controller.abort();
|
||||
```
|
||||
|
||||
With persistence:
|
||||
|
||||
```ts
|
||||
|
|
@ -100,6 +115,25 @@ await restored.prompt([{ type: "text", text: "Continue from previous context." }
|
|||
await sdk.destroySession(restored.id);
|
||||
```
|
||||
|
||||
## Session configuration
|
||||
|
||||
Set model, mode, or thought level at creation or on an existing session:
|
||||
|
||||
```ts
|
||||
const session = await sdk.createSession({
|
||||
agent: "codex",
|
||||
model: "gpt-5.3-codex",
|
||||
});
|
||||
|
||||
await session.setModel("gpt-5.2-codex");
|
||||
await session.setMode("auto");
|
||||
|
||||
const options = await session.getConfigOptions();
|
||||
const modes = await session.getModes();
|
||||
```
|
||||
|
||||
See [Agent Sessions](/agent-sessions) for full details on config options and error handling.
|
||||
|
||||
## Events
|
||||
|
||||
Subscribe to live events:
|
||||
|
|
@ -170,14 +204,6 @@ Parameters:
|
|||
- `token` (optional): Bearer token for authenticated servers
|
||||
- `headers` (optional): Additional request headers
|
||||
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls
|
||||
- `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and ACP session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait
|
||||
- `signal` (optional): aborts the startup `/v1/health` wait used by `connect()`
|
||||
|
||||
## Types
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AgentInfo,
|
||||
HealthResponse,
|
||||
SessionEvent,
|
||||
SessionRecord,
|
||||
} from "sandbox-agent";
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { SimpleBox } from "@boxlite-ai/boxlite";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||
import { setupImage, OCI_DIR } from "./setup-image.ts";
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
|
|
@ -26,9 +26,7 @@ if (result.exitCode !== 0) throw new Error(`Failed to start server: ${result.std
|
|||
|
||||
const baseUrl = "http://localhost:3000";
|
||||
|
||||
console.log("Waiting for server...");
|
||||
await waitForHealth({ baseUrl });
|
||||
|
||||
console.log("Connecting to server...");
|
||||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
type ProviderName,
|
||||
} from "computesdk";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
|
|
@ -116,9 +116,6 @@ export async function setupComputeSdkSandboxAgent(): Promise<{
|
|||
|
||||
const baseUrl = await sandbox.getUrl({ port: PORT });
|
||||
|
||||
console.log("Waiting for server...");
|
||||
await waitForHealth({ baseUrl });
|
||||
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await sandbox.destroy();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Daytona, Image } from "@daytonaio/sdk";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||
|
||||
const daytona = new Daytona();
|
||||
|
||||
|
|
@ -25,9 +25,7 @@ await sandbox.process.executeCommand(
|
|||
|
||||
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
|
||||
|
||||
console.log("Waiting for server...");
|
||||
await waitForHealth({ baseUrl });
|
||||
|
||||
console.log("Connecting to server...");
|
||||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Daytona } from "@daytonaio/sdk";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||
|
||||
const daytona = new Daytona();
|
||||
|
||||
|
|
@ -30,9 +30,7 @@ await sandbox.process.executeCommand(
|
|||
|
||||
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
|
||||
|
||||
console.log("Waiting for server...");
|
||||
await waitForHealth({ baseUrl });
|
||||
|
||||
console.log("Connecting to server...");
|
||||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import Docker from "dockerode";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||
|
||||
const IMAGE = "alpine:latest";
|
||||
const IMAGE = "node:22-bookworm-slim";
|
||||
const PORT = 3000;
|
||||
const agent = detectAgent();
|
||||
const codexAuthPath = process.env.HOME ? path.join(process.env.HOME, ".codex", "auth.json") : null;
|
||||
const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath)
|
||||
? [`${codexAuthPath}:/root/.codex/auth.json:ro`]
|
||||
: [];
|
||||
|
||||
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||
|
||||
|
|
@ -24,29 +31,30 @@ console.log("Starting container...");
|
|||
const container = await docker.createContainer({
|
||||
Image: IMAGE,
|
||||
Cmd: ["sh", "-c", [
|
||||
"apk add --no-cache curl ca-certificates libstdc++ libgcc bash",
|
||||
"apt-get update",
|
||||
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6",
|
||||
"rm -rf /var/lib/apt/lists/*",
|
||||
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
|
||||
"sandbox-agent install-agent claude",
|
||||
"sandbox-agent install-agent codex",
|
||||
`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`,
|
||||
].join(" && ")],
|
||||
Env: [
|
||||
process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "",
|
||||
process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "",
|
||||
process.env.CODEX_API_KEY ? `CODEX_API_KEY=${process.env.CODEX_API_KEY}` : "",
|
||||
].filter(Boolean),
|
||||
ExposedPorts: { [`${PORT}/tcp`]: {} },
|
||||
HostConfig: {
|
||||
AutoRemove: true,
|
||||
PortBindings: { [`${PORT}/tcp`]: [{ HostPort: `${PORT}` }] },
|
||||
Binds: bindMounts,
|
||||
},
|
||||
});
|
||||
await container.start();
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${PORT}`;
|
||||
await waitForHealth({ baseUrl });
|
||||
|
||||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
|
||||
const session = await client.createSession({ agent, sessionInit: { cwd: "/root", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
|
||||
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Sandbox } from "@e2b/code-interpreter";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||
|
||||
const envs: Record<string, string> = {};
|
||||
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
||||
|
|
@ -27,9 +27,7 @@ await sandbox.commands.run("sandbox-agent server --no-token --host 0.0.0.0 --por
|
|||
|
||||
const baseUrl = `https://${sandbox.getHost(3000)}`;
|
||||
|
||||
console.log("Waiting for server...");
|
||||
await waitForHealth({ baseUrl });
|
||||
|
||||
console.log("Connecting to server...");
|
||||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/user", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import fs from "node:fs";
|
|||
import path from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { waitForHealth } from "./sandbox-agent-client.ts";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const EXAMPLE_IMAGE = "sandbox-agent-examples:latest";
|
||||
|
|
@ -173,7 +172,7 @@ async function ensureExampleImage(_docker: Docker): Promise<string> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Start a Docker container running sandbox-agent and wait for it to be healthy.
|
||||
* Start a Docker container running sandbox-agent.
|
||||
* Registers SIGINT/SIGTERM handlers for cleanup.
|
||||
*/
|
||||
export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<DockerSandbox> {
|
||||
|
|
@ -275,18 +274,8 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
|
|||
}
|
||||
const baseUrl = `http://127.0.0.1:${mappedHostPort}`;
|
||||
|
||||
try {
|
||||
await waitForHealth({ baseUrl });
|
||||
} catch (err) {
|
||||
stopStartupLogs();
|
||||
console.error(" Container logs:");
|
||||
for (const chunk of logChunks) {
|
||||
process.stderr.write(` ${chunk}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
stopStartupLogs();
|
||||
console.log(` Ready (${baseUrl})`);
|
||||
console.log(` Started (${baseUrl})`);
|
||||
|
||||
const cleanup = async () => {
|
||||
stopStartupLogs();
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@
|
|||
* Provides minimal helpers for connecting to and interacting with sandbox-agent servers.
|
||||
*/
|
||||
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.replace(/\/+$/, "");
|
||||
}
|
||||
|
|
@ -74,41 +72,6 @@ export function buildHeaders({
|
|||
return headers;
|
||||
}
|
||||
|
||||
export async function waitForHealth({
|
||||
baseUrl,
|
||||
token,
|
||||
extraHeaders,
|
||||
timeoutMs = 120_000,
|
||||
}: {
|
||||
baseUrl: string;
|
||||
token?: string;
|
||||
extraHeaders?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const headers = buildHeaders({ token, extraHeaders });
|
||||
const response = await fetch(`${normalized}/v1/health`, { headers });
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data?.status === "ok") {
|
||||
return;
|
||||
}
|
||||
lastError = new Error(`Unexpected health response: ${JSON.stringify(data)}`);
|
||||
} else {
|
||||
lastError = new Error(`Health check failed: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await delay(500);
|
||||
}
|
||||
throw (lastError ?? new Error("Timed out waiting for /v1/health")) as Error;
|
||||
}
|
||||
|
||||
export function generateSessionId(): string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let id = "session-";
|
||||
|
|
@ -144,4 +107,3 @@ export function detectAgent(): string {
|
|||
}
|
||||
return "claude";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Sandbox } from "@vercel/sandbox";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||
|
||||
const envs: Record<string, string> = {};
|
||||
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
||||
|
|
@ -38,9 +38,7 @@ await sandbox.runCommand({
|
|||
|
||||
const baseUrl = sandbox.domain(3000);
|
||||
|
||||
console.log("Waiting for server...");
|
||||
await waitForHealth({ baseUrl });
|
||||
|
||||
console.log("Connecting to server...");
|
||||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/vercel-sandbox", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ fn main() {
|
|||
}
|
||||
|
||||
fn run() -> Result<(), CliError> {
|
||||
let started = std::time::Instant::now();
|
||||
let cli = GigacodeCli::parse();
|
||||
let config = CliConfig {
|
||||
token: cli.token,
|
||||
|
|
@ -34,5 +35,18 @@ fn run() -> Result<(), CliError> {
|
|||
eprintln!("failed to init logging: {err}");
|
||||
return Err(err);
|
||||
}
|
||||
run_command(&command, &config)
|
||||
tracing::info!(
|
||||
command = ?command,
|
||||
startup_ms = started.elapsed().as_millis() as u64,
|
||||
"gigacode.run: command starting"
|
||||
);
|
||||
let command_started = std::time::Instant::now();
|
||||
let result = run_command(&command, &config);
|
||||
tracing::info!(
|
||||
command = ?command,
|
||||
command_ms = command_started.elapsed().as_millis() as u64,
|
||||
total_ms = started.elapsed().as_millis() as u64,
|
||||
"gigacode.run: command exited"
|
||||
);
|
||||
result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/**
|
||||
* Fetches model/mode lists from agent backends and writes them to resources/.
|
||||
* Fetches model/mode/thought-level lists from agent backends and writes them
|
||||
* to resources/.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx dump.ts # Dump all agents
|
||||
|
|
@ -10,11 +11,24 @@
|
|||
* Claude — Anthropic API (GET /v1/models?beta=true). Extracts API key from
|
||||
* ANTHROPIC_API_KEY env. Falls back to aliases (default, sonnet, opus, haiku)
|
||||
* on 401/403 or missing credentials.
|
||||
* Modes are hardcoded (discovered by ACP session/set_mode probing).
|
||||
* Claude does not implement session/set_config_option at all.
|
||||
* Codex — Codex app-server JSON-RPC (model/list over stdio, paginated).
|
||||
* Modes and thought levels are hardcoded (discovered from Codex's
|
||||
* ACP session/new configOptions response).
|
||||
* OpenCode — OpenCode HTTP server (GET {base_url}/config/providers, fallback /provider).
|
||||
* Model IDs formatted as {provider_id}/{model_id}.
|
||||
* Model IDs formatted as {provider_id}/{model_id}. Modes hardcoded.
|
||||
* Cursor — `cursor-agent models` CLI command. Parses the text output.
|
||||
*
|
||||
* Derivation of hardcoded values:
|
||||
* When agents don't expose modes/thought levels through their model listing
|
||||
* APIs, we discover them by ACP probing against a running sandbox-agent server:
|
||||
* 1. Create an ACP session via session/new and inspect the configOptions and
|
||||
* modes fields in the response.
|
||||
* 2. Test session/set_mode with candidate mode IDs.
|
||||
* 3. Test session/set_config_option with candidate config IDs and values.
|
||||
* See /tmp/probe-agents.sh or /tmp/probe-agents.ts for example probe scripts.
|
||||
*
|
||||
* Output goes to resources/ alongside this script. These JSON files are committed
|
||||
* to the repo and included in the sandbox-agent binary at compile time via include_str!.
|
||||
*/
|
||||
|
|
@ -37,11 +51,19 @@ interface ModeEntry {
|
|||
description?: string;
|
||||
}
|
||||
|
||||
interface ThoughtLevelEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface AgentModelList {
|
||||
defaultModel: string;
|
||||
models: ModelEntry[];
|
||||
defaultMode?: string;
|
||||
modes?: ModeEntry[];
|
||||
defaultThoughtLevel?: string;
|
||||
thoughtLevels?: ThoughtLevelEntry[];
|
||||
}
|
||||
|
||||
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -100,8 +122,13 @@ function writeList(agent: string, list: AgentModelList) {
|
|||
const filePath = path.join(RESOURCES_DIR, `${agent}.json`);
|
||||
fs.writeFileSync(filePath, JSON.stringify(list, null, 2) + "\n");
|
||||
const modeCount = list.modes?.length ?? 0;
|
||||
const thoughtCount = list.thoughtLevels?.length ?? 0;
|
||||
const extras = [
|
||||
modeCount ? `${modeCount} modes` : null,
|
||||
thoughtCount ? `${thoughtCount} thought levels` : null,
|
||||
].filter(Boolean).join(", ");
|
||||
console.log(
|
||||
` Wrote ${list.models.length} models${modeCount ? `, ${modeCount} modes` : ""} to ${filePath} (default: ${list.defaultModel})`
|
||||
` Wrote ${list.models.length} models${extras ? `, ${extras}` : ""} to ${filePath} (default: ${list.defaultModel})`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -110,14 +137,28 @@ function writeList(agent: string, list: AgentModelList) {
|
|||
const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/models?beta=true";
|
||||
const ANTHROPIC_VERSION = "2023-06-01";
|
||||
|
||||
// Claude v0.20.0 (@zed-industries/claude-agent-acp) returns configOptions and
|
||||
// modes from session/new. Models and modes below match the ACP adapter source.
|
||||
// Note: `opus` is gated by subscription — it may not appear in session/new for
|
||||
// all credentials, but exists in the SDK model list. Thought levels are supported
|
||||
// by the Claude SDK (effort levels: low/medium/high/max for opus-4-6 and
|
||||
// sonnet-4-6) but the ACP adapter does not expose them as configOptions yet.
|
||||
const CLAUDE_FALLBACK: AgentModelList = {
|
||||
defaultModel: "default",
|
||||
models: [
|
||||
{ id: "default", name: "Default (recommended)" },
|
||||
{ id: "opus", name: "Opus" },
|
||||
{ id: "default", name: "Default" },
|
||||
{ id: "sonnet", name: "Sonnet" },
|
||||
{ id: "opus", name: "Opus" },
|
||||
{ id: "haiku", name: "Haiku" },
|
||||
],
|
||||
defaultMode: "default",
|
||||
modes: [
|
||||
{ id: "default", name: "Default" },
|
||||
{ id: "acceptEdits", name: "Accept Edits" },
|
||||
{ id: "plan", name: "Plan" },
|
||||
{ id: "dontAsk", name: "Don't Ask" },
|
||||
{ id: "bypassPermissions", name: "Bypass Permissions" },
|
||||
],
|
||||
};
|
||||
|
||||
async function dumpClaude() {
|
||||
|
|
@ -185,6 +226,9 @@ async function dumpClaude() {
|
|||
writeList("claude", {
|
||||
defaultModel: defaultModel ?? models[0]?.id ?? "default",
|
||||
models,
|
||||
// Modes from Claude ACP adapter v0.20.0 session/new response.
|
||||
defaultMode: "default",
|
||||
modes: CLAUDE_FALLBACK.modes,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -277,9 +321,26 @@ async function dumpCodex() {
|
|||
|
||||
models.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
// Codex modes and thought levels come from its ACP session/new configOptions
|
||||
// response (category: "mode" and category: "thought_level"). The model/list
|
||||
// RPC only returns models, so modes/thought levels are hardcoded here based
|
||||
// on probing Codex's session/new response.
|
||||
writeList("codex", {
|
||||
defaultModel: defaultModel ?? models[0]?.id ?? "",
|
||||
models,
|
||||
defaultMode: "read-only",
|
||||
modes: [
|
||||
{ id: "read-only", name: "Read Only", description: "Codex can read files in the current workspace. Approval is required to edit files or access the internet." },
|
||||
{ id: "auto", name: "Default", description: "Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files." },
|
||||
{ id: "full-access", name: "Full Access", description: "Codex can edit files outside this workspace and access the internet without asking for approval." },
|
||||
],
|
||||
defaultThoughtLevel: "high",
|
||||
thoughtLevels: [
|
||||
{ id: "low", name: "Low", description: "Fast responses with lighter reasoning" },
|
||||
{ id: "medium", name: "Medium", description: "Balances speed and reasoning depth for everyday tasks" },
|
||||
{ id: "high", name: "High", description: "Greater reasoning depth for complex problems" },
|
||||
{ id: "xhigh", name: "Xhigh", description: "Extra high reasoning depth for complex problems" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,19 +3,42 @@
|
|||
"models": [
|
||||
{
|
||||
"id": "default",
|
||||
"name": "Default (recommended)"
|
||||
},
|
||||
{
|
||||
"id": "opus",
|
||||
"name": "Opus"
|
||||
"name": "Default"
|
||||
},
|
||||
{
|
||||
"id": "sonnet",
|
||||
"name": "Sonnet"
|
||||
},
|
||||
{
|
||||
"id": "opus",
|
||||
"name": "Opus"
|
||||
},
|
||||
{
|
||||
"id": "haiku",
|
||||
"name": "Haiku"
|
||||
}
|
||||
],
|
||||
"defaultMode": "default",
|
||||
"modes": [
|
||||
{
|
||||
"id": "default",
|
||||
"name": "Default"
|
||||
},
|
||||
{
|
||||
"id": "acceptEdits",
|
||||
"name": "Accept Edits"
|
||||
},
|
||||
{
|
||||
"id": "plan",
|
||||
"name": "Plan"
|
||||
},
|
||||
{
|
||||
"id": "dontAsk",
|
||||
"name": "Don't Ask"
|
||||
},
|
||||
{
|
||||
"id": "bypassPermissions",
|
||||
"name": "Bypass Permissions"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,24 +2,62 @@
|
|||
"defaultModel": "gpt-5.3-codex",
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-5.1-codex-max",
|
||||
"name": "gpt-5.1-codex-max"
|
||||
"id": "gpt-5.3-codex",
|
||||
"name": "gpt-5.3-codex"
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.1-codex-mini",
|
||||
"name": "gpt-5.1-codex-mini"
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.2",
|
||||
"name": "gpt-5.2"
|
||||
"id": "gpt-5.3-codex-spark",
|
||||
"name": "GPT-5.3-Codex-Spark"
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.2-codex",
|
||||
"name": "gpt-5.2-codex"
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.3-codex",
|
||||
"name": "gpt-5.3-codex"
|
||||
"id": "gpt-5.1-codex-max",
|
||||
"name": "gpt-5.1-codex-max"
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.2",
|
||||
"name": "gpt-5.2"
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.1-codex-mini",
|
||||
"name": "gpt-5.1-codex-mini"
|
||||
}
|
||||
],
|
||||
"defaultMode": "read-only",
|
||||
"modes": [
|
||||
{
|
||||
"id": "read-only",
|
||||
"name": "Read Only"
|
||||
},
|
||||
{
|
||||
"id": "auto",
|
||||
"name": "Default"
|
||||
},
|
||||
{
|
||||
"id": "full-access",
|
||||
"name": "Full Access"
|
||||
}
|
||||
],
|
||||
"defaultThoughtLevel": "high",
|
||||
"thoughtLevels": [
|
||||
{
|
||||
"id": "low",
|
||||
"name": "Low"
|
||||
},
|
||||
{
|
||||
"id": "medium",
|
||||
"name": "Medium"
|
||||
},
|
||||
{
|
||||
"id": "high",
|
||||
"name": "High"
|
||||
},
|
||||
{
|
||||
"id": "xhigh",
|
||||
"name": "Xhigh"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "acp-http-client",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "Protocol-faithful ACP JSON-RPC over streamable HTTP client.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/cli-shared",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "Shared helpers for sandbox-agent CLI and SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/cli",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "CLI for sandbox-agent - run AI coding agents in sandboxes",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/cli-darwin-arm64",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "sandbox-agent CLI binary for macOS ARM64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/cli-darwin-x64",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "sandbox-agent CLI binary for macOS x64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/cli-linux-arm64",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "sandbox-agent CLI binary for Linux arm64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/cli-linux-x64",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "sandbox-agent CLI binary for Linux x64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/cli-win32-x64",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "sandbox-agent CLI binary for Windows x64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/gigacode",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "Gigacode CLI (sandbox-agent with OpenCode attach by default)",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/gigacode-darwin-arm64",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "gigacode CLI binary for macOS arm64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/gigacode-darwin-x64",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "gigacode CLI binary for macOS x64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/gigacode-linux-arm64",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "gigacode CLI binary for Linux arm64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/gigacode-linux-x64",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "gigacode CLI binary for Linux x64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/gigacode-win32-x64",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "gigacode CLI binary for Windows x64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/persist-indexeddb",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "IndexedDB persistence driver for the Sandbox Agent TypeScript SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/persist-postgres",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "PostgreSQL persistence driver for the Sandbox Agent TypeScript SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/persist-rivet",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "Rivet Actor persistence driver for the Sandbox Agent TypeScript SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/persist-sqlite",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "SQLite persistence driver for the Sandbox Agent TypeScript SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "sandbox-agent",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, and Amp.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -141,14 +141,21 @@ export interface paths {
|
|||
*/
|
||||
post: operations["post_v1_process_stop"];
|
||||
};
|
||||
"/v1/processes/{id}/terminal/resize": {
|
||||
/**
|
||||
* Resize a process terminal.
|
||||
* @description Sets the PTY window size (columns and rows) for a tty-mode process and
|
||||
* sends SIGWINCH so the child process can adapt.
|
||||
*/
|
||||
post: operations["post_v1_process_terminal_resize"];
|
||||
};
|
||||
"/v1/processes/{id}/terminal/ws": {
|
||||
/**
|
||||
* Open an interactive WebSocket terminal session.
|
||||
* @description Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts
|
||||
* `access_token` query param for browser-based auth (WebSocket API cannot
|
||||
* send custom headers). Uses the `channel.k8s.io` binary subprotocol:
|
||||
* channel 0 stdin, channel 1 stdout, channel 3 status JSON, channel 4 resize,
|
||||
* and channel 255 close.
|
||||
* send custom headers). Streams raw PTY output as binary frames and accepts
|
||||
* JSON control frames for input, resize, and close.
|
||||
*/
|
||||
get: operations["get_v1_process_terminal_ws"];
|
||||
};
|
||||
|
|
@ -423,6 +430,18 @@ export interface components {
|
|||
};
|
||||
/** @enum {string} */
|
||||
ProcessState: "running" | "exited";
|
||||
ProcessTerminalResizeRequest: {
|
||||
/** Format: int32 */
|
||||
cols: number;
|
||||
/** Format: int32 */
|
||||
rows: number;
|
||||
};
|
||||
ProcessTerminalResizeResponse: {
|
||||
/** Format: int32 */
|
||||
cols: number;
|
||||
/** Format: int32 */
|
||||
rows: number;
|
||||
};
|
||||
/** @enum {string} */
|
||||
ServerStatus: "running" | "stopped";
|
||||
ServerStatusInfo: {
|
||||
|
|
@ -1324,13 +1343,62 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Resize a process terminal.
|
||||
* @description Sets the PTY window size (columns and rows) for a tty-mode process and
|
||||
* sends SIGWINCH so the child process can adapt.
|
||||
*/
|
||||
post_v1_process_terminal_resize: {
|
||||
parameters: {
|
||||
path: {
|
||||
/** @description Process ID */
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProcessTerminalResizeRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Resize accepted */
|
||||
200: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProcessTerminalResizeResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid request */
|
||||
400: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Unknown process */
|
||||
404: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Not a terminal process */
|
||||
409: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Process API unsupported on this platform */
|
||||
501: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Open an interactive WebSocket terminal session.
|
||||
* @description Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts
|
||||
* `access_token` query param for browser-based auth (WebSocket API cannot
|
||||
* send custom headers). Uses the `channel.k8s.io` binary subprotocol:
|
||||
* channel 0 stdin, channel 1 stdout, channel 3 status JSON, channel 4 resize,
|
||||
* and channel 255 close.
|
||||
* send custom headers). Streams raw PTY output as binary frames and accepts
|
||||
* JSON control frames for input, resize, and close.
|
||||
*/
|
||||
get_v1_process_terminal_ws: {
|
||||
parameters: {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
export {
|
||||
LiveAcpConnection,
|
||||
ProcessTerminalSession,
|
||||
SandboxAgent,
|
||||
SandboxAgentError,
|
||||
Session,
|
||||
UnsupportedSessionCategoryError,
|
||||
UnsupportedSessionConfigOptionError,
|
||||
UnsupportedSessionValueError,
|
||||
} from "./client.ts";
|
||||
|
||||
export { AcpRpcError } from "acp-http-client";
|
||||
|
|
@ -11,12 +13,12 @@ export { AcpRpcError } from "acp-http-client";
|
|||
export { buildInspectorUrl } from "./inspector.ts";
|
||||
|
||||
export type {
|
||||
SandboxAgentHealthWaitOptions,
|
||||
AgentQueryOptions,
|
||||
ProcessLogFollowQuery,
|
||||
ProcessLogListener,
|
||||
ProcessLogSubscription,
|
||||
ProcessTerminalConnectOptions,
|
||||
ProcessTerminalSessionOptions,
|
||||
ProcessTerminalWebSocketUrlOptions,
|
||||
SandboxAgentConnectOptions,
|
||||
SandboxAgentStartOptions,
|
||||
|
|
@ -30,7 +32,6 @@ export type { InspectorUrlOptions } from "./inspector.ts";
|
|||
|
||||
export {
|
||||
InMemorySessionPersistDriver,
|
||||
TerminalChannel,
|
||||
} from "./types.ts";
|
||||
|
||||
export type {
|
||||
|
|
@ -75,16 +76,18 @@ export type {
|
|||
ProcessRunResponse,
|
||||
ProcessSignalQuery,
|
||||
ProcessState,
|
||||
ProcessTerminalClientFrame,
|
||||
ProcessTerminalErrorFrame,
|
||||
ProcessTerminalExitFrame,
|
||||
ProcessTerminalReadyFrame,
|
||||
ProcessTerminalResizeRequest,
|
||||
ProcessTerminalResizeResponse,
|
||||
ProcessTerminalServerFrame,
|
||||
SessionEvent,
|
||||
SessionPersistDriver,
|
||||
SessionRecord,
|
||||
SkillsConfig,
|
||||
SkillsConfigQuery,
|
||||
TerminalErrorStatus,
|
||||
TerminalExitStatus,
|
||||
TerminalReadyStatus,
|
||||
TerminalResizePayload,
|
||||
TerminalStatusMessage,
|
||||
} from "./types.ts";
|
||||
|
||||
export type {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import type { AnyMessage, NewSessionRequest } from "acp-http-client";
|
||||
import type {
|
||||
AnyMessage,
|
||||
NewSessionRequest,
|
||||
SessionConfigOption,
|
||||
SessionModeState,
|
||||
} from "acp-http-client";
|
||||
import type { components, operations } from "./generated/openapi.ts";
|
||||
|
||||
export type ProblemDetails = components["schemas"]["ProblemDetails"];
|
||||
|
|
@ -46,40 +51,43 @@ export type ProcessRunRequest = JsonRequestBody<operations["post_v1_processes_ru
|
|||
export type ProcessRunResponse = JsonResponse<operations["post_v1_processes_run"], 200>;
|
||||
export type ProcessSignalQuery = QueryParams<operations["post_v1_process_stop"]>;
|
||||
export type ProcessState = components["schemas"]["ProcessState"];
|
||||
export type ProcessTerminalResizeRequest = JsonRequestBody<operations["post_v1_process_terminal_resize"]>;
|
||||
export type ProcessTerminalResizeResponse = JsonResponse<operations["post_v1_process_terminal_resize"], 200>;
|
||||
|
||||
export const TerminalChannel = {
|
||||
stdin: 0,
|
||||
stdout: 1,
|
||||
stderr: 2,
|
||||
status: 3,
|
||||
resize: 4,
|
||||
close: 255,
|
||||
} as const;
|
||||
export type ProcessTerminalClientFrame =
|
||||
| {
|
||||
type: "input";
|
||||
data: string;
|
||||
encoding?: string;
|
||||
}
|
||||
| {
|
||||
type: "resize";
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
| {
|
||||
type: "close";
|
||||
};
|
||||
|
||||
export interface TerminalReadyStatus {
|
||||
export interface ProcessTerminalReadyFrame {
|
||||
type: "ready";
|
||||
processId: string;
|
||||
}
|
||||
|
||||
export interface TerminalExitStatus {
|
||||
export interface ProcessTerminalExitFrame {
|
||||
type: "exit";
|
||||
exitCode?: number | null;
|
||||
}
|
||||
|
||||
export interface TerminalErrorStatus {
|
||||
export interface ProcessTerminalErrorFrame {
|
||||
type: "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type TerminalStatusMessage =
|
||||
| TerminalReadyStatus
|
||||
| TerminalExitStatus
|
||||
| TerminalErrorStatus;
|
||||
|
||||
export interface TerminalResizePayload {
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
export type ProcessTerminalServerFrame =
|
||||
| ProcessTerminalReadyFrame
|
||||
| ProcessTerminalExitFrame
|
||||
| ProcessTerminalErrorFrame;
|
||||
|
||||
export interface SessionRecord {
|
||||
id: string;
|
||||
|
|
@ -89,6 +97,8 @@ export interface SessionRecord {
|
|||
createdAt: number;
|
||||
destroyedAt?: number;
|
||||
sessionInit?: Omit<NewSessionRequest, "_meta">;
|
||||
configOptions?: SessionConfigOption[];
|
||||
modes?: SessionModeState | null;
|
||||
}
|
||||
|
||||
export type SessionEventSender = "client" | "agent";
|
||||
|
|
@ -228,6 +238,12 @@ function cloneSessionRecord(session: SessionRecord): SessionRecord {
|
|||
sessionInit: session.sessionInit
|
||||
? (JSON.parse(JSON.stringify(session.sessionInit)) as SessionRecord["sessionInit"])
|
||||
: undefined,
|
||||
configOptions: session.configOptions
|
||||
? (JSON.parse(JSON.stringify(session.configOptions)) as SessionRecord["configOptions"])
|
||||
: undefined,
|
||||
modes: session.modes
|
||||
? (JSON.parse(JSON.stringify(session.modes)) as SessionRecord["modes"])
|
||||
: session.modes,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,29 @@
|
|||
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export function prepareMockAgentDataHome(dataHome: string): void {
|
||||
const installDir = join(dataHome, "sandbox-agent", "bin");
|
||||
const processDir = join(installDir, "agent_processes");
|
||||
mkdirSync(processDir, { recursive: true });
|
||||
function candidateInstallDirs(dataHome: string): string[] {
|
||||
const dirs = [join(dataHome, "sandbox-agent", "bin")];
|
||||
if (process.platform === "darwin") {
|
||||
dirs.push(join(dataHome, "Library", "Application Support", "sandbox-agent", "bin"));
|
||||
} else if (process.platform === "win32") {
|
||||
dirs.push(join(dataHome, "AppData", "Roaming", "sandbox-agent", "bin"));
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
const runner = process.platform === "win32"
|
||||
? join(processDir, "mock-acp.cmd")
|
||||
: join(processDir, "mock-acp");
|
||||
|
||||
const scriptFile = process.platform === "win32"
|
||||
? join(processDir, "mock-acp.js")
|
||||
: runner;
|
||||
export function prepareMockAgentDataHome(dataHome: string): Record<string, string> {
|
||||
const runtimeEnv: Record<string, string> = {};
|
||||
if (process.platform === "darwin") {
|
||||
runtimeEnv.HOME = dataHome;
|
||||
runtimeEnv.XDG_DATA_HOME = join(dataHome, ".local", "share");
|
||||
} else if (process.platform === "win32") {
|
||||
runtimeEnv.USERPROFILE = dataHome;
|
||||
runtimeEnv.APPDATA = join(dataHome, "AppData", "Roaming");
|
||||
runtimeEnv.LOCALAPPDATA = join(dataHome, "AppData", "Local");
|
||||
} else {
|
||||
runtimeEnv.HOME = dataHome;
|
||||
runtimeEnv.XDG_DATA_HOME = dataHome;
|
||||
}
|
||||
|
||||
const nodeScript = String.raw`#!/usr/bin/env node
|
||||
const { createInterface } = require("node:readline");
|
||||
|
|
@ -127,14 +138,29 @@ rl.on("line", (line) => {
|
|||
});
|
||||
`;
|
||||
|
||||
writeFileSync(scriptFile, nodeScript);
|
||||
for (const installDir of candidateInstallDirs(dataHome)) {
|
||||
const processDir = join(installDir, "agent_processes");
|
||||
mkdirSync(processDir, { recursive: true });
|
||||
|
||||
if (process.platform === "win32") {
|
||||
writeFileSync(runner, `@echo off\r\nnode "${scriptFile}" %*\r\n`);
|
||||
const runner = process.platform === "win32"
|
||||
? join(processDir, "mock-acp.cmd")
|
||||
: join(processDir, "mock-acp");
|
||||
|
||||
const scriptFile = process.platform === "win32"
|
||||
? join(processDir, "mock-acp.js")
|
||||
: runner;
|
||||
|
||||
writeFileSync(scriptFile, nodeScript);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
writeFileSync(runner, `@echo off\r\nnode "${scriptFile}" %*\r\n`);
|
||||
}
|
||||
|
||||
chmodSync(scriptFile, 0o755);
|
||||
if (process.platform === "win32") {
|
||||
chmodSync(runner, 0o755);
|
||||
}
|
||||
}
|
||||
|
||||
chmodSync(scriptFile, 0o755);
|
||||
if (process.platform === "win32") {
|
||||
chmodSync(runner, 0o755);
|
||||
}
|
||||
return runtimeEnv;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,6 +136,22 @@ function writeTarChecksum(buffer: Buffer, checksum: number): void {
|
|||
buffer[155] = 0x20;
|
||||
}
|
||||
|
||||
function decodeSocketPayload(data: unknown): string {
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return Buffer.from(data).toString("utf8");
|
||||
}
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
|
||||
}
|
||||
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
||||
throw new Error("Blob socket payloads are not supported in this test");
|
||||
}
|
||||
throw new Error(`Unsupported socket payload type: ${typeof data}`);
|
||||
}
|
||||
|
||||
function decodeProcessLogData(data: string, encoding: string): string {
|
||||
if (encoding === "base64") {
|
||||
return Buffer.from(data, "base64").toString("utf8");
|
||||
|
|
@ -158,15 +174,13 @@ describe("Integration: TypeScript SDK flat session API", () => {
|
|||
|
||||
beforeAll(async () => {
|
||||
dataHome = mkdtempSync(join(tmpdir(), "sdk-integration-"));
|
||||
prepareMockAgentDataHome(dataHome);
|
||||
const agentEnv = prepareMockAgentDataHome(dataHome);
|
||||
|
||||
handle = await spawnSandboxAgent({
|
||||
enabled: true,
|
||||
log: "silent",
|
||||
timeoutMs: 30000,
|
||||
env: {
|
||||
XDG_DATA_HOME: dataHome,
|
||||
},
|
||||
env: agentEnv,
|
||||
});
|
||||
baseUrl = handle.baseUrl;
|
||||
token = handle.token;
|
||||
|
|
@ -323,6 +337,111 @@ describe("Integration: TypeScript SDK flat session API", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("waits for health before non-ACP HTTP helpers", async () => {
|
||||
const defaultFetch = globalThis.fetch;
|
||||
if (!defaultFetch) {
|
||||
throw new Error("Global fetch is not available in this runtime.");
|
||||
}
|
||||
|
||||
let healthAttempts = 0;
|
||||
const seenPaths: string[] = [];
|
||||
const customFetch: typeof fetch = async (input, init) => {
|
||||
const outgoing = new Request(input, init);
|
||||
const parsed = new URL(outgoing.url);
|
||||
seenPaths.push(parsed.pathname);
|
||||
|
||||
if (parsed.pathname === "/v1/health") {
|
||||
healthAttempts += 1;
|
||||
if (healthAttempts < 3) {
|
||||
return new Response("warming up", { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
const forwardedUrl = new URL(`${parsed.pathname}${parsed.search}`, baseUrl);
|
||||
const forwarded = new Request(forwardedUrl.toString(), outgoing);
|
||||
return defaultFetch(forwarded);
|
||||
};
|
||||
|
||||
const sdk = await SandboxAgent.connect({
|
||||
token,
|
||||
fetch: customFetch,
|
||||
});
|
||||
|
||||
const agents = await sdk.listAgents();
|
||||
expect(Array.isArray(agents.agents)).toBe(true);
|
||||
expect(healthAttempts).toBe(3);
|
||||
|
||||
const firstAgentsRequest = seenPaths.indexOf("/v1/agents");
|
||||
expect(firstAgentsRequest).toBeGreaterThanOrEqual(0);
|
||||
expect(seenPaths.slice(0, firstAgentsRequest)).toEqual([
|
||||
"/v1/health",
|
||||
"/v1/health",
|
||||
"/v1/health",
|
||||
]);
|
||||
|
||||
await sdk.dispose();
|
||||
});
|
||||
|
||||
it("surfaces health timeout when a request awaits readiness", async () => {
|
||||
const customFetch: typeof fetch = async (input, init) => {
|
||||
const outgoing = new Request(input, init);
|
||||
const parsed = new URL(outgoing.url);
|
||||
|
||||
if (parsed.pathname === "/v1/health") {
|
||||
return new Response("warming up", { status: 503 });
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected request path during timeout test: ${parsed.pathname}`);
|
||||
};
|
||||
|
||||
const sdk = await SandboxAgent.connect({
|
||||
token,
|
||||
fetch: customFetch,
|
||||
waitForHealth: { timeoutMs: 100 },
|
||||
});
|
||||
|
||||
await expect(sdk.listAgents()).rejects.toThrow("Timed out waiting for sandbox-agent health");
|
||||
await sdk.dispose();
|
||||
});
|
||||
|
||||
it("aborts the shared health wait when connect signal is aborted", async () => {
|
||||
const controller = new AbortController();
|
||||
const customFetch: typeof fetch = async (input, init) => {
|
||||
const outgoing = new Request(input, init);
|
||||
const parsed = new URL(outgoing.url);
|
||||
|
||||
if (parsed.pathname !== "/v1/health") {
|
||||
throw new Error(`Unexpected request path during abort test: ${parsed.pathname}`);
|
||||
}
|
||||
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
outgoing.signal.removeEventListener("abort", onAbort);
|
||||
reject(outgoing.signal.reason ?? new DOMException("Connect aborted", "AbortError"));
|
||||
};
|
||||
|
||||
if (outgoing.signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
|
||||
outgoing.signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
};
|
||||
|
||||
const sdk = await SandboxAgent.connect({
|
||||
token,
|
||||
fetch: customFetch,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const pending = sdk.listAgents();
|
||||
controller.abort(new DOMException("Connect aborted", "AbortError"));
|
||||
|
||||
await expect(pending).rejects.toThrow("Connect aborted");
|
||||
await sdk.dispose();
|
||||
});
|
||||
|
||||
it("restores a session on stale connection by recreating and replaying history on first prompt", async () => {
|
||||
const persist = new InMemorySessionPersistDriver({
|
||||
maxEventsPerSession: 200,
|
||||
|
|
@ -401,6 +520,127 @@ describe("Integration: TypeScript SDK flat session API", () => {
|
|||
await sdk.dispose();
|
||||
});
|
||||
|
||||
it("blocks manual session/cancel and requires destroySession", async () => {
|
||||
const sdk = await SandboxAgent.connect({
|
||||
baseUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
const session = await sdk.createSession({ agent: "mock" });
|
||||
|
||||
await expect(session.send("session/cancel")).rejects.toThrow(
|
||||
"Use destroySession(sessionId) instead.",
|
||||
);
|
||||
await expect(sdk.sendSessionMethod(session.id, "session/cancel", {})).rejects.toThrow(
|
||||
"Use destroySession(sessionId) instead.",
|
||||
);
|
||||
|
||||
const destroyed = await sdk.destroySession(session.id);
|
||||
expect(destroyed.destroyedAt).toBeDefined();
|
||||
|
||||
const reloaded = await sdk.getSession(session.id);
|
||||
expect(reloaded?.destroyedAt).toBeDefined();
|
||||
|
||||
await sdk.dispose();
|
||||
});
|
||||
|
||||
it("supports typed config helpers and createSession preconfiguration", async () => {
|
||||
const sdk = await SandboxAgent.connect({
|
||||
baseUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
const session = await sdk.createSession({
|
||||
agent: "mock",
|
||||
model: "mock",
|
||||
});
|
||||
|
||||
const options = await session.getConfigOptions();
|
||||
expect(options.some((option) => option.category === "model")).toBe(true);
|
||||
|
||||
await expect(session.setModel("unknown-model")).rejects.toThrow("does not support value");
|
||||
|
||||
await sdk.dispose();
|
||||
});
|
||||
|
||||
it("setModel happy path switches to a valid model", async () => {
|
||||
const sdk = await SandboxAgent.connect({
|
||||
baseUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
const session = await sdk.createSession({ agent: "mock" });
|
||||
await session.setModel("mock-fast");
|
||||
|
||||
const options = await session.getConfigOptions();
|
||||
const modelOption = options.find((o) => o.category === "model");
|
||||
expect(modelOption?.currentValue).toBe("mock-fast");
|
||||
|
||||
await sdk.dispose();
|
||||
});
|
||||
|
||||
it("setMode happy path switches to a valid mode", async () => {
|
||||
const sdk = await SandboxAgent.connect({
|
||||
baseUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
const session = await sdk.createSession({ agent: "mock" });
|
||||
await session.setMode("plan");
|
||||
|
||||
const modes = await session.getModes();
|
||||
expect(modes?.currentModeId).toBe("plan");
|
||||
|
||||
await sdk.dispose();
|
||||
});
|
||||
|
||||
it("setThoughtLevel happy path switches to a valid thought level", async () => {
|
||||
const sdk = await SandboxAgent.connect({
|
||||
baseUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
const session = await sdk.createSession({ agent: "mock" });
|
||||
await session.setThoughtLevel("high");
|
||||
|
||||
const options = await session.getConfigOptions();
|
||||
const thoughtOption = options.find((o) => o.category === "thought_level");
|
||||
expect(thoughtOption?.currentValue).toBe("high");
|
||||
|
||||
await sdk.dispose();
|
||||
});
|
||||
|
||||
it("setModel/setMode/setThoughtLevel can be changed multiple times", async () => {
|
||||
const sdk = await SandboxAgent.connect({
|
||||
baseUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
const session = await sdk.createSession({ agent: "mock" });
|
||||
|
||||
// Model: mock → mock-fast → mock
|
||||
await session.setModel("mock-fast");
|
||||
expect((await session.getConfigOptions()).find((o) => o.category === "model")?.currentValue).toBe("mock-fast");
|
||||
await session.setModel("mock");
|
||||
expect((await session.getConfigOptions()).find((o) => o.category === "model")?.currentValue).toBe("mock");
|
||||
|
||||
// Mode: normal → plan → normal
|
||||
await session.setMode("plan");
|
||||
expect((await session.getModes())?.currentModeId).toBe("plan");
|
||||
await session.setMode("normal");
|
||||
expect((await session.getModes())?.currentModeId).toBe("normal");
|
||||
|
||||
// Thought level: low → high → medium → low
|
||||
await session.setThoughtLevel("high");
|
||||
expect((await session.getConfigOptions()).find((o) => o.category === "thought_level")?.currentValue).toBe("high");
|
||||
await session.setThoughtLevel("medium");
|
||||
expect((await session.getConfigOptions()).find((o) => o.category === "thought_level")?.currentValue).toBe("medium");
|
||||
await session.setThoughtLevel("low");
|
||||
expect((await session.getConfigOptions()).find((o) => o.category === "thought_level")?.currentValue).toBe("low");
|
||||
|
||||
await sdk.dispose();
|
||||
});
|
||||
|
||||
it("supports MCP and skills config HTTP helpers", async () => {
|
||||
const sdk = await SandboxAgent.connect({
|
||||
baseUrl,
|
||||
|
|
@ -566,53 +806,47 @@ describe("Integration: TypeScript SDK flat session API", () => {
|
|||
});
|
||||
ttyProcessId = ttyProcess.id;
|
||||
|
||||
const wsUrl = sdk.buildProcessTerminalWebSocketUrl(ttyProcess.id);
|
||||
expect(wsUrl.startsWith("ws://") || wsUrl.startsWith("wss://")).toBe(true);
|
||||
|
||||
const session = sdk.connectProcessTerminal(ttyProcess.id, {
|
||||
WebSocket: WebSocket as unknown as typeof globalThis.WebSocket,
|
||||
});
|
||||
const readyFrames: string[] = [];
|
||||
const ttyOutput: string[] = [];
|
||||
const exitFrames: Array<number | null | undefined> = [];
|
||||
const terminalErrors: string[] = [];
|
||||
let closeCount = 0;
|
||||
|
||||
session.onReady((status) => {
|
||||
readyFrames.push(status.processId);
|
||||
});
|
||||
session.onData((bytes) => {
|
||||
ttyOutput.push(Buffer.from(bytes).toString("utf8"));
|
||||
});
|
||||
session.onExit((status) => {
|
||||
exitFrames.push(status.exitCode);
|
||||
});
|
||||
session.onError((error) => {
|
||||
terminalErrors.push(error instanceof Error ? error.message : error.message);
|
||||
});
|
||||
session.onClose(() => {
|
||||
closeCount += 1;
|
||||
});
|
||||
|
||||
await waitFor(() => readyFrames[0]);
|
||||
|
||||
session.resize({
|
||||
const resized = await sdk.resizeProcessTerminal(ttyProcess.id, {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
});
|
||||
session.sendInput("hello tty\n");
|
||||
expect(resized.cols).toBe(120);
|
||||
expect(resized.rows).toBe(40);
|
||||
|
||||
const wsUrl = sdk.buildProcessTerminalWebSocketUrl(ttyProcess.id);
|
||||
expect(wsUrl.startsWith("ws://") || wsUrl.startsWith("wss://")).toBe(true);
|
||||
|
||||
const ws = sdk.connectProcessTerminalWebSocket(ttyProcess.id, {
|
||||
WebSocket: WebSocket as unknown as typeof globalThis.WebSocket,
|
||||
});
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
const socketTextFrames: string[] = [];
|
||||
const socketBinaryFrames: string[] = [];
|
||||
ws.addEventListener("message", (event) => {
|
||||
if (typeof event.data === "string") {
|
||||
socketTextFrames.push(event.data);
|
||||
return;
|
||||
}
|
||||
socketBinaryFrames.push(decodeSocketPayload(event.data));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const joined = ttyOutput.join("");
|
||||
const ready = socketTextFrames.find((frame) => frame.includes('"type":"ready"'));
|
||||
return ready;
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: "input",
|
||||
data: "hello tty\n",
|
||||
}));
|
||||
|
||||
await waitFor(() => {
|
||||
const joined = socketBinaryFrames.join("");
|
||||
return joined.includes("hello tty") ? joined : undefined;
|
||||
});
|
||||
|
||||
session.close();
|
||||
await session.closed;
|
||||
expect(closeCount).toBeGreaterThan(0);
|
||||
expect(exitFrames).toHaveLength(0);
|
||||
expect(terminalErrors).toEqual([]);
|
||||
|
||||
ws.close();
|
||||
await waitForAsync(async () => {
|
||||
const processInfo = await sdk.getProcess(ttyProcess.id);
|
||||
return processInfo.status === "running" ? processInfo : undefined;
|
||||
|
|
|
|||
|
|
@ -93,6 +93,20 @@ fn map_error(err: AdapterError) -> Response {
|
|||
"timeout",
|
||||
"timed out waiting for agent response",
|
||||
),
|
||||
AdapterError::Exited { exit_code, stderr } => {
|
||||
let detail = if let Some(stderr) = stderr {
|
||||
format!(
|
||||
"agent process exited before responding (exit_code: {:?}, stderr: {})",
|
||||
exit_code, stderr
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"agent process exited before responding (exit_code: {:?})",
|
||||
exit_code
|
||||
)
|
||||
};
|
||||
problem(StatusCode::BAD_GATEWAY, "agent_exited", &detail)
|
||||
}
|
||||
AdapterError::Write(write) => problem(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"write_failed",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ async fn main() {
|
|||
}
|
||||
|
||||
async fn run() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let started = std::time::Instant::now();
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
|
|
@ -41,6 +42,12 @@ async fn run() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
tracing::info!(
|
||||
host = %cli.host,
|
||||
port = cli.port,
|
||||
startup_ms = started.elapsed().as_millis() as u64,
|
||||
"acp-http-adapter.run: starting server"
|
||||
);
|
||||
run_server(ServerConfig {
|
||||
host: cli.host,
|
||||
port: cli.port,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use tokio_stream::wrappers::BroadcastStream;
|
|||
use crate::registry::LaunchSpec;
|
||||
|
||||
const RING_BUFFER_SIZE: usize = 1024;
|
||||
const STDERR_TAIL_SIZE: usize = 16;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AdapterError {
|
||||
|
|
@ -33,6 +34,11 @@ pub enum AdapterError {
|
|||
Serialize(serde_json::Error),
|
||||
#[error("failed to write subprocess stdin: {0}")]
|
||||
Write(std::io::Error),
|
||||
#[error("agent process exited before responding")]
|
||||
Exited {
|
||||
exit_code: Option<i32>,
|
||||
stderr: Option<String>,
|
||||
},
|
||||
#[error("timeout waiting for response")]
|
||||
Timeout,
|
||||
}
|
||||
|
|
@ -61,6 +67,7 @@ pub struct AdapterRuntime {
|
|||
shutting_down: AtomicBool,
|
||||
spawned_at: Instant,
|
||||
first_stdout: Arc<AtomicBool>,
|
||||
stderr_tail: Arc<Mutex<VecDeque<String>>>,
|
||||
}
|
||||
|
||||
impl AdapterRuntime {
|
||||
|
|
@ -120,6 +127,7 @@ impl AdapterRuntime {
|
|||
shutting_down: AtomicBool::new(false),
|
||||
spawned_at: spawn_start,
|
||||
first_stdout: Arc::new(AtomicBool::new(false)),
|
||||
stderr_tail: Arc::new(Mutex::new(VecDeque::with_capacity(STDERR_TAIL_SIZE))),
|
||||
};
|
||||
|
||||
runtime.spawn_stdout_loop(stdout);
|
||||
|
|
@ -198,6 +206,16 @@ impl AdapterRuntime {
|
|||
"post: response channel dropped (agent process may have exited)"
|
||||
);
|
||||
self.pending.lock().await.remove(&key);
|
||||
if let Some((exit_code, stderr)) = self.try_process_exit_info().await {
|
||||
tracing::error!(
|
||||
method = %method,
|
||||
id = %key,
|
||||
exit_code = ?exit_code,
|
||||
stderr = ?stderr,
|
||||
"post: agent process exited before response channel completed"
|
||||
);
|
||||
return Err(AdapterError::Exited { exit_code, stderr });
|
||||
}
|
||||
Err(AdapterError::Timeout)
|
||||
}
|
||||
Err(_) => {
|
||||
|
|
@ -213,6 +231,16 @@ impl AdapterRuntime {
|
|||
"post: TIMEOUT waiting for agent response"
|
||||
);
|
||||
self.pending.lock().await.remove(&key);
|
||||
if let Some((exit_code, stderr)) = self.try_process_exit_info().await {
|
||||
tracing::error!(
|
||||
method = %method,
|
||||
id = %key,
|
||||
exit_code = ?exit_code,
|
||||
stderr = ?stderr,
|
||||
"post: agent process exited before timeout completed"
|
||||
);
|
||||
return Err(AdapterError::Exited { exit_code, stderr });
|
||||
}
|
||||
Err(AdapterError::Timeout)
|
||||
}
|
||||
}
|
||||
|
|
@ -445,6 +473,7 @@ impl AdapterRuntime {
|
|||
|
||||
fn spawn_stderr_loop(&self, stderr: tokio::process::ChildStderr) {
|
||||
let spawned_at = self.spawned_at;
|
||||
let stderr_tail = self.stderr_tail.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
|
|
@ -452,6 +481,13 @@ impl AdapterRuntime {
|
|||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
line_count += 1;
|
||||
{
|
||||
let mut tail = stderr_tail.lock().await;
|
||||
tail.push_back(line.clone());
|
||||
while tail.len() > STDERR_TAIL_SIZE {
|
||||
tail.pop_front();
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
line_number = line_count,
|
||||
age_ms = spawned_at.elapsed().as_millis() as u64,
|
||||
|
|
@ -560,6 +596,28 @@ impl AdapterRuntime {
|
|||
tracing::debug!(method = method, id = %id, "stdin: write+flush complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn try_process_exit_info(&self) -> Option<(Option<i32>, Option<String>)> {
|
||||
let mut child = self.child.lock().await;
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let exit_code = status.code();
|
||||
drop(child);
|
||||
let stderr = self.stderr_tail_summary().await;
|
||||
Some((exit_code, stderr))
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn stderr_tail_summary(&self) -> Option<String> {
|
||||
let tail = self.stderr_tail.lock().await;
|
||||
if tail.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(tail.iter().cloned().collect::<Vec<_>>().join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
fn id_key(value: &Value) -> String {
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ url.workspace = true
|
|||
dirs.workspace = true
|
||||
tempfile.workspace = true
|
||||
time.workspace = true
|
||||
tracing.workspace = true
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use std::fs;
|
|||
use std::io::{self, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Instant;
|
||||
|
||||
use flate2::read::GzDecoder;
|
||||
use reqwest::blocking::Client;
|
||||
|
|
@ -78,7 +79,7 @@ impl AgentId {
|
|||
|
||||
fn agent_process_registry_id(self) -> Option<&'static str> {
|
||||
match self {
|
||||
AgentId::Claude => Some("claude-code-acp"),
|
||||
AgentId::Claude => Some("claude-acp"),
|
||||
AgentId::Codex => Some("codex-acp"),
|
||||
AgentId::Opencode => Some("opencode"),
|
||||
AgentId::Amp => Some("amp-acp"),
|
||||
|
|
@ -90,7 +91,7 @@ impl AgentId {
|
|||
|
||||
fn agent_process_binary_hint(self) -> Option<&'static str> {
|
||||
match self {
|
||||
AgentId::Claude => Some("claude-code-acp"),
|
||||
AgentId::Claude => Some("claude-agent-acp"),
|
||||
AgentId::Codex => Some("codex-acp"),
|
||||
AgentId::Opencode => Some("opencode"),
|
||||
AgentId::Amp => Some("amp-acp"),
|
||||
|
|
@ -321,6 +322,14 @@ impl AgentManager {
|
|||
agent: AgentId,
|
||||
options: InstallOptions,
|
||||
) -> Result<InstallResult, AgentError> {
|
||||
let install_started = Instant::now();
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
reinstall = options.reinstall,
|
||||
native_version = ?options.version,
|
||||
agent_process_version = ?options.agent_process_version,
|
||||
"agent_manager.install: starting"
|
||||
);
|
||||
fs::create_dir_all(&self.install_dir)?;
|
||||
fs::create_dir_all(self.install_dir.join("agent_processes"))?;
|
||||
|
||||
|
|
@ -345,10 +354,20 @@ impl AgentManager {
|
|||
artifacts.push(artifact);
|
||||
}
|
||||
|
||||
Ok(InstallResult {
|
||||
let result = InstallResult {
|
||||
artifacts,
|
||||
already_installed,
|
||||
})
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
already_installed = result.already_installed,
|
||||
artifact_count = result.artifacts.len(),
|
||||
total_ms = elapsed_ms(install_started),
|
||||
"agent_manager.install: completed"
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn is_installed(&self, agent: AgentId) -> bool {
|
||||
|
|
@ -392,25 +411,41 @@ impl AgentManager {
|
|||
&self,
|
||||
agent: AgentId,
|
||||
) -> Result<AgentProcessLaunchSpec, AgentError> {
|
||||
let started = Instant::now();
|
||||
if agent == AgentId::Mock {
|
||||
return Ok(AgentProcessLaunchSpec {
|
||||
let spec = AgentProcessLaunchSpec {
|
||||
program: self.agent_process_path(agent),
|
||||
args: Vec::new(),
|
||||
env: HashMap::new(),
|
||||
source: InstallSource::Builtin,
|
||||
version: Some("builtin".to_string()),
|
||||
});
|
||||
};
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
source = ?spec.source,
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.resolve_agent_process: resolved builtin"
|
||||
);
|
||||
return Ok(spec);
|
||||
}
|
||||
|
||||
let launcher = self.agent_process_path(agent);
|
||||
if launcher.exists() {
|
||||
return Ok(AgentProcessLaunchSpec {
|
||||
let spec = AgentProcessLaunchSpec {
|
||||
program: launcher,
|
||||
args: Vec::new(),
|
||||
env: HashMap::new(),
|
||||
source: InstallSource::LocalPath,
|
||||
version: None,
|
||||
});
|
||||
};
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
source = ?spec.source,
|
||||
program = %spec.program.display(),
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.resolve_agent_process: resolved local launcher"
|
||||
);
|
||||
return Ok(spec);
|
||||
}
|
||||
|
||||
if let Some(bin) = agent.agent_process_binary_hint().and_then(find_in_path) {
|
||||
|
|
@ -419,29 +454,47 @@ impl AgentManager {
|
|||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
return Ok(AgentProcessLaunchSpec {
|
||||
let spec = AgentProcessLaunchSpec {
|
||||
program: bin,
|
||||
args,
|
||||
env: HashMap::new(),
|
||||
source: InstallSource::LocalPath,
|
||||
version: None,
|
||||
});
|
||||
};
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
source = ?spec.source,
|
||||
program = %spec.program.display(),
|
||||
args = ?spec.args,
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.resolve_agent_process: resolved PATH binary hint"
|
||||
);
|
||||
return Ok(spec);
|
||||
}
|
||||
|
||||
if agent == AgentId::Opencode {
|
||||
let native = self.resolve_binary(agent)?;
|
||||
return Ok(AgentProcessLaunchSpec {
|
||||
let spec = AgentProcessLaunchSpec {
|
||||
program: native,
|
||||
args: vec!["acp".to_string()],
|
||||
env: HashMap::new(),
|
||||
source: InstallSource::LocalPath,
|
||||
version: None,
|
||||
});
|
||||
};
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
source = ?spec.source,
|
||||
program = %spec.program.display(),
|
||||
args = ?spec.args,
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.resolve_agent_process: resolved opencode native"
|
||||
);
|
||||
return Ok(spec);
|
||||
}
|
||||
|
||||
Err(AgentError::AgentProcessNotFound {
|
||||
agent,
|
||||
hint: Some("run install to provision ACP agent process".to_string()),
|
||||
hint: Some(format!("run step 3: `sandbox-agent install-agent {agent}`")),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -454,11 +507,23 @@ impl AgentManager {
|
|||
agent: AgentId,
|
||||
options: &InstallOptions,
|
||||
) -> Result<Option<InstalledArtifact>, AgentError> {
|
||||
let started = Instant::now();
|
||||
if !options.reinstall && self.native_installed(agent) {
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_native: already installed"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let path = self.binary_path(agent);
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
path = %path.display(),
|
||||
version_override = ?options.version,
|
||||
"agent_manager.install_native: installing"
|
||||
);
|
||||
match agent {
|
||||
AgentId::Claude => install_claude(&path, self.platform, options.version.as_deref())?,
|
||||
AgentId::Codex => install_codex(&path, self.platform, options.version.as_deref())?,
|
||||
|
|
@ -474,12 +539,22 @@ impl AgentManager {
|
|||
}
|
||||
}
|
||||
|
||||
Ok(Some(InstalledArtifact {
|
||||
let artifact = InstalledArtifact {
|
||||
kind: InstalledArtifactKind::NativeAgent,
|
||||
path,
|
||||
version: self.version(agent).ok().flatten(),
|
||||
source: InstallSource::Fallback,
|
||||
}))
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
source = ?artifact.source,
|
||||
version = ?artifact.version,
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_native: completed"
|
||||
);
|
||||
|
||||
Ok(Some(artifact))
|
||||
}
|
||||
|
||||
fn install_agent_process(
|
||||
|
|
@ -487,8 +562,14 @@ impl AgentManager {
|
|||
agent: AgentId,
|
||||
options: &InstallOptions,
|
||||
) -> Result<Option<InstalledArtifact>, AgentError> {
|
||||
let started = Instant::now();
|
||||
if !options.reinstall {
|
||||
if self.agent_process_status(agent).is_some() {
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_agent_process: already installed"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
|
@ -496,22 +577,104 @@ impl AgentManager {
|
|||
if agent == AgentId::Mock {
|
||||
let path = self.agent_process_path(agent);
|
||||
write_mock_agent_process_launcher(&path)?;
|
||||
return Ok(Some(InstalledArtifact {
|
||||
let artifact = InstalledArtifact {
|
||||
kind: InstalledArtifactKind::AgentProcess,
|
||||
path,
|
||||
version: Some("builtin".to_string()),
|
||||
source: InstallSource::Builtin,
|
||||
}));
|
||||
};
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
source = ?artifact.source,
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_agent_process: installed builtin launcher"
|
||||
);
|
||||
return Ok(Some(artifact));
|
||||
}
|
||||
|
||||
if let Some(artifact) = self.install_agent_process_from_registry(agent, options)? {
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
source = ?artifact.source,
|
||||
version = ?artifact.version,
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_agent_process: installed from registry"
|
||||
);
|
||||
return Ok(Some(artifact));
|
||||
}
|
||||
|
||||
let artifact = self.install_agent_process_fallback(agent, options)?;
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
source = ?artifact.source,
|
||||
version = ?artifact.version,
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_agent_process: installed from fallback"
|
||||
);
|
||||
Ok(Some(artifact))
|
||||
}
|
||||
|
||||
fn install_npm_agent_process_package(
|
||||
&self,
|
||||
agent: AgentId,
|
||||
package: &str,
|
||||
args: &[String],
|
||||
env: &HashMap<String, String>,
|
||||
source: InstallSource,
|
||||
version: Option<String>,
|
||||
) -> Result<InstalledArtifact, AgentError> {
|
||||
let started = Instant::now();
|
||||
let root = self.agent_process_storage_dir(agent);
|
||||
if root.exists() {
|
||||
fs::remove_dir_all(&root)?;
|
||||
}
|
||||
fs::create_dir_all(&root)?;
|
||||
|
||||
let npm_install_started = Instant::now();
|
||||
install_npm_package(&root, package, agent)?;
|
||||
let npm_install_ms = elapsed_ms(npm_install_started);
|
||||
|
||||
let bin_name = agent.agent_process_binary_hint().ok_or_else(|| {
|
||||
AgentError::ExtractFailed(format!(
|
||||
"missing executable hint for agent process package: {agent}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let cmd_path = npm_bin_path(&root, bin_name);
|
||||
if !cmd_path.exists() {
|
||||
return Err(AgentError::ExtractFailed(format!(
|
||||
"installed package missing executable: {}",
|
||||
cmd_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let launcher = self.agent_process_path(agent);
|
||||
let write_started = Instant::now();
|
||||
write_exec_agent_process_launcher(&launcher, &cmd_path, args, env)?;
|
||||
let write_ms = elapsed_ms(write_started);
|
||||
let verify_started = Instant::now();
|
||||
verify_command(&launcher, &[])?;
|
||||
let verify_ms = elapsed_ms(verify_started);
|
||||
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
package = %package,
|
||||
cmd = %cmd_path.display(),
|
||||
npm_install_ms = npm_install_ms,
|
||||
write_ms = write_ms,
|
||||
verify_ms = verify_ms,
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_npm_agent_process_package: completed"
|
||||
);
|
||||
|
||||
Ok(InstalledArtifact {
|
||||
kind: InstalledArtifactKind::AgentProcess,
|
||||
path: launcher,
|
||||
version,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn agent_process_status(&self, agent: AgentId) -> Option<AgentProcessStatus> {
|
||||
if agent == AgentId::Mock {
|
||||
return Some(AgentProcessStatus {
|
||||
|
|
@ -540,59 +703,111 @@ impl AgentManager {
|
|||
agent: AgentId,
|
||||
options: &InstallOptions,
|
||||
) -> Result<Option<InstalledArtifact>, AgentError> {
|
||||
let started = Instant::now();
|
||||
let Some(registry_id) = agent.agent_process_registry_id() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
registry_id = registry_id,
|
||||
url = %self.registry_url,
|
||||
"agent_manager.install_agent_process_from_registry: fetching registry"
|
||||
);
|
||||
let fetch_started = Instant::now();
|
||||
let registry = fetch_registry(&self.registry_url)?;
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
registry_id = registry_id,
|
||||
fetch_ms = elapsed_ms(fetch_started),
|
||||
"agent_manager.install_agent_process_from_registry: registry fetched"
|
||||
);
|
||||
let Some(entry) = registry.agents.into_iter().find(|a| a.id == registry_id) else {
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
registry_id = registry_id,
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_agent_process_from_registry: missing entry"
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if let Some(npx) = entry.distribution.npx {
|
||||
let package =
|
||||
apply_npx_version_override(&npx.package, options.agent_process_version.as_deref());
|
||||
let launcher = self.agent_process_path(agent);
|
||||
write_npx_agent_process_launcher(&launcher, &package, &npx.args, &npx.env)?;
|
||||
verify_command(&launcher, &[])?;
|
||||
return Ok(Some(InstalledArtifact {
|
||||
kind: InstalledArtifactKind::AgentProcess,
|
||||
path: launcher,
|
||||
version: options
|
||||
.agent_process_version
|
||||
.clone()
|
||||
.or(entry.version)
|
||||
.or(extract_npx_version(&package)),
|
||||
source: InstallSource::Registry,
|
||||
}));
|
||||
let version = options
|
||||
.agent_process_version
|
||||
.clone()
|
||||
.or(entry.version)
|
||||
.or(extract_npx_version(&package));
|
||||
let artifact = self.install_npm_agent_process_package(
|
||||
agent,
|
||||
&package,
|
||||
&npx.args,
|
||||
&npx.env,
|
||||
InstallSource::Registry,
|
||||
version,
|
||||
)?;
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
package = %package,
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_agent_process_from_registry: npm package installed"
|
||||
);
|
||||
return Ok(Some(artifact));
|
||||
}
|
||||
|
||||
if let Some(binary) = entry.distribution.binary {
|
||||
let key = self.platform.registry_key();
|
||||
if let Some(target) = binary.get(key) {
|
||||
let archive_url = Url::parse(&target.archive)?;
|
||||
let download_started = Instant::now();
|
||||
let payload = download_bytes(&archive_url)?;
|
||||
let download_ms = elapsed_ms(download_started);
|
||||
let root = self.agent_process_storage_dir(agent);
|
||||
if root.exists() {
|
||||
fs::remove_dir_all(&root)?;
|
||||
}
|
||||
fs::create_dir_all(&root)?;
|
||||
let unpack_started = Instant::now();
|
||||
unpack_archive(&payload, &archive_url, &root)?;
|
||||
let unpack_ms = elapsed_ms(unpack_started);
|
||||
|
||||
let cmd_path = resolve_extracted_command(&root, &target.cmd)?;
|
||||
let launcher = self.agent_process_path(agent);
|
||||
let write_started = Instant::now();
|
||||
write_exec_agent_process_launcher(&launcher, &cmd_path, &target.args, &target.env)?;
|
||||
let write_ms = elapsed_ms(write_started);
|
||||
let verify_started = Instant::now();
|
||||
verify_command(&launcher, &[])?;
|
||||
let verify_ms = elapsed_ms(verify_started);
|
||||
|
||||
return Ok(Some(InstalledArtifact {
|
||||
let artifact = InstalledArtifact {
|
||||
kind: InstalledArtifactKind::AgentProcess,
|
||||
path: launcher,
|
||||
version: options.agent_process_version.clone().or(entry.version),
|
||||
source: InstallSource::Registry,
|
||||
}));
|
||||
};
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
archive_url = %archive_url,
|
||||
download_ms = download_ms,
|
||||
unpack_ms = unpack_ms,
|
||||
write_ms = write_ms,
|
||||
verify_ms = verify_ms,
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_agent_process_from_registry: binary launcher installed"
|
||||
);
|
||||
return Ok(Some(artifact));
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
registry_id = registry_id,
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_agent_process_from_registry: no compatible distribution"
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
|
|
@ -601,24 +816,44 @@ impl AgentManager {
|
|||
agent: AgentId,
|
||||
options: &InstallOptions,
|
||||
) -> Result<InstalledArtifact, AgentError> {
|
||||
let launcher = self.agent_process_path(agent);
|
||||
|
||||
match agent {
|
||||
let started = Instant::now();
|
||||
let artifact = match agent {
|
||||
AgentId::Claude => {
|
||||
let package = fallback_npx_package(
|
||||
"@zed-industries/claude-code-acp",
|
||||
"@zed-industries/claude-agent-acp",
|
||||
options.agent_process_version.as_deref(),
|
||||
);
|
||||
write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?;
|
||||
self.install_npm_agent_process_package(
|
||||
agent,
|
||||
&package,
|
||||
&[],
|
||||
&HashMap::new(),
|
||||
InstallSource::Fallback,
|
||||
options
|
||||
.agent_process_version
|
||||
.clone()
|
||||
.or(extract_npx_version(&package)),
|
||||
)?
|
||||
}
|
||||
AgentId::Codex => {
|
||||
let package = fallback_npx_package(
|
||||
"@zed-industries/codex-acp",
|
||||
options.agent_process_version.as_deref(),
|
||||
);
|
||||
write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?;
|
||||
self.install_npm_agent_process_package(
|
||||
agent,
|
||||
&package,
|
||||
&[],
|
||||
&HashMap::new(),
|
||||
InstallSource::Fallback,
|
||||
options
|
||||
.agent_process_version
|
||||
.clone()
|
||||
.or(extract_npx_version(&package)),
|
||||
)?
|
||||
}
|
||||
AgentId::Opencode => {
|
||||
let launcher = self.agent_process_path(agent);
|
||||
let native = self.resolve_binary(agent)?;
|
||||
write_exec_agent_process_launcher(
|
||||
&launcher,
|
||||
|
|
@ -626,37 +861,82 @@ impl AgentManager {
|
|||
&["acp".to_string()],
|
||||
&HashMap::new(),
|
||||
)?;
|
||||
verify_command(&launcher, &[])?;
|
||||
InstalledArtifact {
|
||||
kind: InstalledArtifactKind::AgentProcess,
|
||||
path: launcher,
|
||||
version: options.agent_process_version.clone(),
|
||||
source: InstallSource::Fallback,
|
||||
}
|
||||
}
|
||||
AgentId::Amp => {
|
||||
let package =
|
||||
fallback_npx_package("amp-acp", options.agent_process_version.as_deref());
|
||||
write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?;
|
||||
self.install_npm_agent_process_package(
|
||||
agent,
|
||||
&package,
|
||||
&[],
|
||||
&HashMap::new(),
|
||||
InstallSource::Fallback,
|
||||
options
|
||||
.agent_process_version
|
||||
.clone()
|
||||
.or(extract_npx_version(&package)),
|
||||
)?
|
||||
}
|
||||
AgentId::Pi => {
|
||||
let package =
|
||||
fallback_npx_package("pi-acp", options.agent_process_version.as_deref());
|
||||
write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?;
|
||||
self.install_npm_agent_process_package(
|
||||
agent,
|
||||
&package,
|
||||
&[],
|
||||
&HashMap::new(),
|
||||
InstallSource::Fallback,
|
||||
options
|
||||
.agent_process_version
|
||||
.clone()
|
||||
.or(extract_npx_version(&package)),
|
||||
)?
|
||||
}
|
||||
AgentId::Cursor => {
|
||||
let package = fallback_npx_package(
|
||||
"@blowmage/cursor-agent-acp",
|
||||
options.agent_process_version.as_deref(),
|
||||
);
|
||||
write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?;
|
||||
self.install_npm_agent_process_package(
|
||||
agent,
|
||||
&package,
|
||||
&[],
|
||||
&HashMap::new(),
|
||||
InstallSource::Fallback,
|
||||
options
|
||||
.agent_process_version
|
||||
.clone()
|
||||
.or(extract_npx_version(&package)),
|
||||
)?
|
||||
}
|
||||
AgentId::Mock => {
|
||||
let launcher = self.agent_process_path(agent);
|
||||
write_mock_agent_process_launcher(&launcher)?;
|
||||
InstalledArtifact {
|
||||
kind: InstalledArtifactKind::AgentProcess,
|
||||
path: launcher,
|
||||
version: options.agent_process_version.clone(),
|
||||
source: InstallSource::Fallback,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
verify_command(&launcher, &[])?;
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
source = ?artifact.source,
|
||||
version = ?artifact.version,
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_agent_process_fallback: launcher installed"
|
||||
);
|
||||
|
||||
Ok(InstalledArtifact {
|
||||
kind: InstalledArtifactKind::AgentProcess,
|
||||
path: launcher,
|
||||
version: options.agent_process_version.clone(),
|
||||
source: InstallSource::Fallback,
|
||||
})
|
||||
Ok(artifact)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -732,6 +1012,10 @@ pub enum AgentError {
|
|||
RegistryParse(String),
|
||||
#[error("command verification failed: {0}")]
|
||||
VerifyFailed(String),
|
||||
#[error(
|
||||
"npm is required to install {agent}. install npm, then run step 3: `sandbox-agent install-agent {agent}`"
|
||||
)]
|
||||
MissingNpm { agent: AgentId },
|
||||
}
|
||||
|
||||
fn fallback_npx_package(base: &str, version: Option<&str>) -> String {
|
||||
|
|
@ -779,15 +1063,36 @@ fn split_package_version(package: &str) -> Option<(&str, &str)> {
|
|||
}
|
||||
}
|
||||
|
||||
fn write_npx_agent_process_launcher(
|
||||
path: &Path,
|
||||
package: &str,
|
||||
args: &[String],
|
||||
env: &HashMap<String, String>,
|
||||
) -> Result<(), AgentError> {
|
||||
let mut command = vec!["npx".to_string(), "-y".to_string(), package.to_string()];
|
||||
command.extend(args.iter().cloned());
|
||||
write_launcher(path, &command, env)
|
||||
fn install_npm_package(root: &Path, package: &str, agent: AgentId) -> Result<(), AgentError> {
|
||||
let mut command = Command::new("npm");
|
||||
command
|
||||
.arg("install")
|
||||
.arg("--no-audit")
|
||||
.arg("--no-fund")
|
||||
.arg("--prefix")
|
||||
.arg(root)
|
||||
.arg(package)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
match command.status() {
|
||||
Ok(status) if status.success() => Ok(()),
|
||||
Ok(status) => Err(AgentError::VerifyFailed(format!(
|
||||
"npm install failed for {agent} with status {status}. run step 3: `sandbox-agent install-agent {agent}`"
|
||||
))),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Err(AgentError::MissingNpm { agent }),
|
||||
Err(err) => Err(AgentError::VerifyFailed(format!(
|
||||
"failed to execute npm for {agent}: {err}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn npm_bin_path(root: &Path, bin_name: &str) -> PathBuf {
|
||||
let mut path = root.join("node_modules").join(".bin").join(bin_name);
|
||||
if cfg!(windows) {
|
||||
path.set_extension("cmd");
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn write_exec_agent_process_launcher(
|
||||
|
|
@ -998,6 +1303,15 @@ fn install_claude(
|
|||
platform: Platform,
|
||||
version: Option<&str>,
|
||||
) -> Result<(), AgentError> {
|
||||
let started = Instant::now();
|
||||
tracing::info!(
|
||||
path = %path.display(),
|
||||
platform = ?platform,
|
||||
version_override = ?version,
|
||||
"agent_manager.install_claude: starting"
|
||||
);
|
||||
|
||||
let version_started = Instant::now();
|
||||
let version = match version {
|
||||
Some(version) => version.to_string(),
|
||||
None => {
|
||||
|
|
@ -1009,6 +1323,7 @@ fn install_claude(
|
|||
text.trim().to_string()
|
||||
}
|
||||
};
|
||||
let version_ms = elapsed_ms(version_started);
|
||||
|
||||
let platform_segment = match platform {
|
||||
Platform::LinuxX64 => "linux-x64",
|
||||
|
|
@ -1023,12 +1338,26 @@ fn install_claude(
|
|||
let url = Url::parse(&format!(
|
||||
"https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{version}/{platform_segment}/claude"
|
||||
))?;
|
||||
let download_started = Instant::now();
|
||||
let bytes = download_bytes(&url)?;
|
||||
let download_ms = elapsed_ms(download_started);
|
||||
let write_started = Instant::now();
|
||||
write_executable(path, &bytes)?;
|
||||
tracing::info!(
|
||||
version = %version,
|
||||
url = %url,
|
||||
bytes = bytes.len(),
|
||||
version_ms = version_ms,
|
||||
download_ms = download_ms,
|
||||
write_ms = elapsed_ms(write_started),
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_claude: completed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_amp(path: &Path, platform: Platform, version: Option<&str>) -> Result<(), AgentError> {
|
||||
let started = Instant::now();
|
||||
let version = match version {
|
||||
Some(version) => version.to_string(),
|
||||
None => {
|
||||
|
|
@ -1053,12 +1382,25 @@ fn install_amp(path: &Path, platform: Platform, version: Option<&str>) -> Result
|
|||
let url = Url::parse(&format!(
|
||||
"https://storage.googleapis.com/amp-public-assets-prod-0/cli/{version}/amp-{platform_segment}"
|
||||
))?;
|
||||
let download_started = Instant::now();
|
||||
let bytes = download_bytes(&url)?;
|
||||
let download_ms = elapsed_ms(download_started);
|
||||
let write_started = Instant::now();
|
||||
write_executable(path, &bytes)?;
|
||||
tracing::info!(
|
||||
version = %version,
|
||||
url = %url,
|
||||
bytes = bytes.len(),
|
||||
download_ms = download_ms,
|
||||
write_ms = elapsed_ms(write_started),
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_amp: completed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_codex(path: &Path, platform: Platform, version: Option<&str>) -> Result<(), AgentError> {
|
||||
let started = Instant::now();
|
||||
let target = match platform {
|
||||
Platform::LinuxX64 | Platform::LinuxX64Musl => "x86_64-unknown-linux-musl",
|
||||
Platform::LinuxArm64 => "aarch64-unknown-linux-musl",
|
||||
|
|
@ -1077,11 +1419,15 @@ fn install_codex(path: &Path, platform: Platform, version: Option<&str>) -> Resu
|
|||
))?,
|
||||
};
|
||||
|
||||
let download_started = Instant::now();
|
||||
let bytes = download_bytes(&url)?;
|
||||
let download_ms = elapsed_ms(download_started);
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let unpack_started = Instant::now();
|
||||
let cursor = io::Cursor::new(bytes);
|
||||
let mut archive = tar::Archive::new(GzDecoder::new(cursor));
|
||||
archive.unpack(temp_dir.path())?;
|
||||
let unpack_ms = elapsed_ms(unpack_started);
|
||||
|
||||
let expected = if cfg!(windows) {
|
||||
format!("codex-{target}.exe")
|
||||
|
|
@ -1091,7 +1437,17 @@ fn install_codex(path: &Path, platform: Platform, version: Option<&str>) -> Resu
|
|||
|
||||
let binary = find_file_recursive(temp_dir.path(), &expected)?
|
||||
.ok_or_else(|| AgentError::ExtractFailed(format!("missing {expected}")))?;
|
||||
let move_started = Instant::now();
|
||||
move_executable(&binary, path)?;
|
||||
tracing::info!(
|
||||
url = %url,
|
||||
target = target,
|
||||
download_ms = download_ms,
|
||||
unpack_ms = unpack_ms,
|
||||
move_ms = elapsed_ms(move_started),
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_codex: completed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -1100,7 +1456,15 @@ fn install_opencode(
|
|||
platform: Platform,
|
||||
version: Option<&str>,
|
||||
) -> Result<(), AgentError> {
|
||||
match platform {
|
||||
let started = Instant::now();
|
||||
tracing::info!(
|
||||
path = %path.display(),
|
||||
platform = ?platform,
|
||||
version_override = ?version,
|
||||
"agent_manager.install_opencode: starting"
|
||||
);
|
||||
|
||||
let result = match platform {
|
||||
Platform::MacosArm64 => {
|
||||
let url = match version {
|
||||
Some(version) => Url::parse(&format!(
|
||||
|
|
@ -1141,22 +1505,46 @@ fn install_opencode(
|
|||
))?,
|
||||
};
|
||||
|
||||
let download_started = Instant::now();
|
||||
let bytes = download_bytes(&url)?;
|
||||
let download_ms = elapsed_ms(download_started);
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let unpack_started = Instant::now();
|
||||
let cursor = io::Cursor::new(bytes);
|
||||
let mut archive = tar::Archive::new(GzDecoder::new(cursor));
|
||||
archive.unpack(temp_dir.path())?;
|
||||
let unpack_ms = elapsed_ms(unpack_started);
|
||||
let binary = find_file_recursive(temp_dir.path(), "opencode")
|
||||
.or_else(|_| find_file_recursive(temp_dir.path(), "opencode.exe"))?
|
||||
.ok_or_else(|| AgentError::ExtractFailed("missing opencode".to_string()))?;
|
||||
let move_started = Instant::now();
|
||||
move_executable(&binary, path)?;
|
||||
tracing::info!(
|
||||
url = %url,
|
||||
download_ms = download_ms,
|
||||
unpack_ms = unpack_ms,
|
||||
move_ms = elapsed_ms(move_started),
|
||||
"agent_manager.install_opencode: tarball extraction complete"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
|
||||
if result.is_ok() {
|
||||
tracing::info!(
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_opencode: completed"
|
||||
);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn install_zip_binary(path: &Path, url: &Url, binary_name: &str) -> Result<(), AgentError> {
|
||||
let started = Instant::now();
|
||||
let download_started = Instant::now();
|
||||
let bytes = download_bytes(url)?;
|
||||
let download_ms = elapsed_ms(download_started);
|
||||
let reader = io::Cursor::new(bytes);
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(reader).map_err(|err| AgentError::ExtractFailed(err.to_string()))?;
|
||||
|
|
@ -1173,7 +1561,16 @@ fn install_zip_binary(path: &Path, url: &Url, binary_name: &str) -> Result<(), A
|
|||
let out_path = temp_dir.path().join(binary_name);
|
||||
let mut out_file = fs::File::create(&out_path)?;
|
||||
io::copy(&mut file, &mut out_file)?;
|
||||
let move_started = Instant::now();
|
||||
move_executable(&out_path, path)?;
|
||||
tracing::info!(
|
||||
url = %url,
|
||||
binary_name = binary_name,
|
||||
download_ms = download_ms,
|
||||
move_ms = elapsed_ms(move_started),
|
||||
total_ms = elapsed_ms(started),
|
||||
"agent_manager.install_zip_binary: completed"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Err(AgentError::ExtractFailed(format!("missing {binary_name}")))
|
||||
|
|
@ -1231,6 +1628,10 @@ fn find_file_recursive(dir: &Path, filename: &str) -> Result<Option<PathBuf>, Ag
|
|||
Ok(None)
|
||||
}
|
||||
|
||||
fn elapsed_ms(start: Instant) -> u64 {
|
||||
start.elapsed().as_millis() as u64
|
||||
}
|
||||
|
||||
fn parse_version_output(output: &std::process::Output) -> Option<String> {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
|
@ -1265,6 +1666,38 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn write_fake_npm(path: &Path) {
|
||||
write_exec(
|
||||
path,
|
||||
r#"#!/usr/bin/env sh
|
||||
set -e
|
||||
prefix=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
install|--no-audit|--no-fund)
|
||||
shift
|
||||
;;
|
||||
--prefix)
|
||||
prefix="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
[ -n "$prefix" ] || exit 1
|
||||
mkdir -p "$prefix/node_modules/.bin"
|
||||
for bin in claude-code-acp codex-acp amp-acp pi-acp cursor-agent-acp; do
|
||||
echo '#!/usr/bin/env sh' > "$prefix/node_modules/.bin/$bin"
|
||||
echo 'exit 0' >> "$prefix/node_modules/.bin/$bin"
|
||||
chmod +x "$prefix/node_modules/.bin/$bin"
|
||||
done
|
||||
exit 0
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
|
|
@ -1407,7 +1840,7 @@ mod tests {
|
|||
|
||||
let bin_dir = temp_dir.path().join("bin");
|
||||
fs::create_dir_all(&bin_dir).expect("create bin dir");
|
||||
write_exec(&bin_dir.join("npx"), "#!/usr/bin/env sh\nexit 0\n");
|
||||
write_fake_npm(&bin_dir.join("npm"));
|
||||
|
||||
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let mut paths = vec![bin_dir.clone()];
|
||||
|
|
@ -1455,8 +1888,8 @@ mod tests {
|
|||
let launcher =
|
||||
fs::read_to_string(manager.agent_process_path(AgentId::Codex)).expect("launcher");
|
||||
assert!(
|
||||
launcher.contains("@example/codex-acp@9.9.9"),
|
||||
"launcher should include overridden package version"
|
||||
launcher.contains("node_modules/.bin/codex-acp"),
|
||||
"launcher should invoke installed codex executable"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1474,7 +1907,7 @@ mod tests {
|
|||
|
||||
let bin_dir = temp_dir.path().join("bin");
|
||||
fs::create_dir_all(&bin_dir).expect("create bin dir");
|
||||
write_exec(&bin_dir.join("npx"), "#!/usr/bin/env sh\nexit 0\n");
|
||||
write_fake_npm(&bin_dir.join("npm"));
|
||||
|
||||
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let mut paths = vec![bin_dir.clone()];
|
||||
|
|
@ -1496,6 +1929,39 @@ mod tests {
|
|||
assert_eq!(agent_process_artifact.source, InstallSource::Fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_returns_missing_npm_error_for_npm_backed_agents() {
|
||||
let _env_lock = env_lock().lock().expect("env lock");
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create tempdir");
|
||||
let mut manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64);
|
||||
|
||||
write_exec(
|
||||
&manager.binary_path(AgentId::Codex),
|
||||
"#!/usr/bin/env sh\nexit 0\n",
|
||||
);
|
||||
|
||||
let bin_dir = temp_dir.path().join("bin");
|
||||
fs::create_dir_all(&bin_dir).expect("create bin dir");
|
||||
|
||||
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let combined_path = std::env::join_paths([bin_dir]).expect("join PATH");
|
||||
let _path_guard = EnvVarGuard::set("PATH", &combined_path);
|
||||
|
||||
manager.registry_url = serve_registry_once(serde_json::json!({ "agents": [] }));
|
||||
|
||||
let error = manager
|
||||
.install(AgentId::Codex, InstallOptions::default())
|
||||
.expect_err("install should fail without npm");
|
||||
|
||||
match error {
|
||||
AgentError::MissingNpm { agent } => assert_eq!(agent, AgentId::Codex),
|
||||
other => panic!("expected MissingNpm, got {other:?}"),
|
||||
}
|
||||
|
||||
drop(original_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reinstall_mock_returns_agent_process_artifact() {
|
||||
let temp_dir = tempfile::tempdir().expect("create tempdir");
|
||||
|
|
@ -1522,7 +1988,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn install_pi_skips_native_and_writes_fallback_npx_launcher() {
|
||||
fn install_pi_skips_native_and_installs_fallback_npm_launcher() {
|
||||
let _env_lock = env_lock().lock().expect("env lock");
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create tempdir");
|
||||
|
|
@ -1530,7 +1996,7 @@ mod tests {
|
|||
|
||||
let bin_dir = temp_dir.path().join("bin");
|
||||
fs::create_dir_all(&bin_dir).expect("create bin dir");
|
||||
write_exec(&bin_dir.join("npx"), "#!/usr/bin/env sh\nexit 0\n");
|
||||
write_fake_npm(&bin_dir.join("npm"));
|
||||
|
||||
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let mut paths = vec![bin_dir.clone()];
|
||||
|
|
@ -1564,8 +2030,8 @@ mod tests {
|
|||
let launcher =
|
||||
fs::read_to_string(manager.agent_process_path(AgentId::Pi)).expect("read pi launcher");
|
||||
assert!(
|
||||
launcher.contains("pi-acp"),
|
||||
"pi launcher should reference pi-acp package"
|
||||
launcher.contains("node_modules/.bin/pi-acp"),
|
||||
"pi launcher should use installed pi executable"
|
||||
);
|
||||
|
||||
// resolve_agent_process should now find it.
|
||||
|
|
@ -1590,7 +2056,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn install_cursor_skips_native_and_writes_fallback_npx_launcher() {
|
||||
fn install_cursor_skips_native_and_installs_fallback_npm_launcher() {
|
||||
let _env_lock = env_lock().lock().expect("env lock");
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create tempdir");
|
||||
|
|
@ -1598,7 +2064,7 @@ mod tests {
|
|||
|
||||
let bin_dir = temp_dir.path().join("bin");
|
||||
fs::create_dir_all(&bin_dir).expect("create bin dir");
|
||||
write_exec(&bin_dir.join("npx"), "#!/usr/bin/env sh\nexit 0\n");
|
||||
write_fake_npm(&bin_dir.join("npm"));
|
||||
|
||||
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let mut paths = vec![bin_dir.clone()];
|
||||
|
|
@ -1630,8 +2096,8 @@ mod tests {
|
|||
let launcher = fs::read_to_string(manager.agent_process_path(AgentId::Cursor))
|
||||
.expect("read cursor launcher");
|
||||
assert!(
|
||||
launcher.contains("@blowmage/cursor-agent-acp"),
|
||||
"cursor launcher should reference @blowmage/cursor-agent-acp package"
|
||||
launcher.contains("node_modules/.bin/cursor-agent-acp"),
|
||||
"cursor launcher should use installed cursor executable"
|
||||
);
|
||||
|
||||
let spec = manager
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ impl AcpProxyRuntime {
|
|||
error = %err,
|
||||
"acp_proxy: POST → error"
|
||||
);
|
||||
Err(map_adapter_error(err))
|
||||
Err(map_adapter_error(err, Some(instance.agent)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -277,27 +277,28 @@ impl AcpProxyRuntime {
|
|||
server_id: &str,
|
||||
agent: AgentId,
|
||||
) -> Result<Arc<ProxyInstance>, SandboxError> {
|
||||
let start = std::time::Instant::now();
|
||||
let total_started = std::time::Instant::now();
|
||||
tracing::info!(
|
||||
server_id = server_id,
|
||||
agent = agent.as_str(),
|
||||
"create_instance: starting"
|
||||
);
|
||||
|
||||
let install_started = std::time::Instant::now();
|
||||
self.ensure_installed(agent).await?;
|
||||
let install_elapsed = start.elapsed();
|
||||
tracing::info!(
|
||||
server_id = server_id,
|
||||
agent = agent.as_str(),
|
||||
install_ms = install_elapsed.as_millis() as u64,
|
||||
install_ms = install_started.elapsed().as_millis() as u64,
|
||||
"create_instance: agent installed/verified"
|
||||
);
|
||||
|
||||
let resolve_started = std::time::Instant::now();
|
||||
let manager = self.inner.agent_manager.clone();
|
||||
let launch = tokio::task::spawn_blocking(move || manager.resolve_agent_process(agent))
|
||||
.await
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: format!("failed to resolve ACP agent process launch spec: {err}"),
|
||||
message: format!("failed to resolve agent process launch spec: {err}"),
|
||||
})?
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
|
|
@ -308,10 +309,11 @@ impl AcpProxyRuntime {
|
|||
agent = agent.as_str(),
|
||||
program = ?launch.program,
|
||||
args = ?launch.args,
|
||||
resolve_ms = start.elapsed().as_millis() as u64,
|
||||
resolve_ms = resolve_started.elapsed().as_millis() as u64,
|
||||
"create_instance: launch spec resolved, spawning"
|
||||
);
|
||||
|
||||
let spawn_started = std::time::Instant::now();
|
||||
let runtime = AdapterRuntime::start(
|
||||
LaunchSpec {
|
||||
program: launch.program,
|
||||
|
|
@ -321,12 +323,13 @@ impl AcpProxyRuntime {
|
|||
self.inner.request_timeout,
|
||||
)
|
||||
.await
|
||||
.map_err(map_adapter_error)?;
|
||||
.map_err(|err| map_adapter_error(err, Some(agent)))?;
|
||||
|
||||
let total_ms = start.elapsed().as_millis() as u64;
|
||||
let total_ms = total_started.elapsed().as_millis() as u64;
|
||||
tracing::info!(
|
||||
server_id = server_id,
|
||||
agent = agent.as_str(),
|
||||
spawn_ms = spawn_started.elapsed().as_millis() as u64,
|
||||
total_ms = total_ms,
|
||||
"create_instance: ready"
|
||||
);
|
||||
|
|
@ -340,16 +343,27 @@ impl AcpProxyRuntime {
|
|||
}
|
||||
|
||||
async fn ensure_installed(&self, agent: AgentId) -> Result<(), SandboxError> {
|
||||
let started = std::time::Instant::now();
|
||||
if self.inner.require_preinstall {
|
||||
if !self.is_ready(agent).await {
|
||||
return Err(SandboxError::AgentNotInstalled {
|
||||
agent: agent.as_str().to_string(),
|
||||
});
|
||||
}
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
total_ms = started.elapsed().as_millis() as u64,
|
||||
"ensure_installed: preinstall requirement satisfied"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.is_ready(agent).await {
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
total_ms = started.elapsed().as_millis() as u64,
|
||||
"ensure_installed: already ready"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
|
@ -363,9 +377,19 @@ impl AcpProxyRuntime {
|
|||
let _guard = lock.lock().await;
|
||||
|
||||
if self.is_ready(agent).await {
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
total_ms = started.elapsed().as_millis() as u64,
|
||||
"ensure_installed: became ready while waiting for lock"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
"ensure_installed: installing missing artifacts"
|
||||
);
|
||||
let install_started = std::time::Instant::now();
|
||||
let manager = self.inner.agent_manager.clone();
|
||||
tokio::task::spawn_blocking(move || manager.install(agent, InstallOptions::default()))
|
||||
.await
|
||||
|
|
@ -378,6 +402,12 @@ impl AcpProxyRuntime {
|
|||
stderr: Some(err.to_string()),
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
agent = agent.as_str(),
|
||||
install_ms = install_started.elapsed().as_millis() as u64,
|
||||
total_ms = started.elapsed().as_millis() as u64,
|
||||
"ensure_installed: install complete"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -432,7 +462,7 @@ impl AcpDispatch for AcpProxyRuntime {
|
|||
}
|
||||
}
|
||||
|
||||
fn map_adapter_error(err: AdapterError) -> SandboxError {
|
||||
fn map_adapter_error(err: AdapterError, agent: Option<AgentId>) -> SandboxError {
|
||||
match err {
|
||||
AdapterError::InvalidEnvelope => SandboxError::InvalidRequest {
|
||||
message: "request body must be a JSON-RPC object".to_string(),
|
||||
|
|
@ -446,6 +476,29 @@ fn map_adapter_error(err: AdapterError) -> SandboxError {
|
|||
AdapterError::Write(error) => SandboxError::StreamError {
|
||||
message: format!("failed writing to agent stdin: {error}"),
|
||||
},
|
||||
AdapterError::Exited { exit_code, stderr } => {
|
||||
if let Some(agent) = agent {
|
||||
SandboxError::AgentProcessExited {
|
||||
agent: agent.as_str().to_string(),
|
||||
exit_code,
|
||||
stderr,
|
||||
}
|
||||
} else {
|
||||
SandboxError::StreamError {
|
||||
message: if let Some(stderr) = stderr {
|
||||
format!(
|
||||
"agent process exited before responding (exit_code: {:?}, stderr: {})",
|
||||
exit_code, stderr
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"agent process exited before responding (exit_code: {:?})",
|
||||
exit_code
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
AdapterError::Spawn(error) => SandboxError::StreamError {
|
||||
message: format!("failed to start agent process: {error}"),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
|
@ -24,7 +24,7 @@ use sandbox_agent_agent_credentials::{
|
|||
ProviderCredentials,
|
||||
};
|
||||
use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use thiserror::Error;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
|
@ -220,6 +220,8 @@ pub struct AgentsArgs {
|
|||
pub enum AgentsCommand {
|
||||
/// List all agents and install status.
|
||||
List(ClientArgs),
|
||||
/// Emit JSON report of model/mode/thought options for all agents.
|
||||
Report(ClientArgs),
|
||||
/// Install or reinstall an agent.
|
||||
Install(ApiInstallAgentArgs),
|
||||
}
|
||||
|
|
@ -475,6 +477,7 @@ fn run_agents(command: &AgentsCommand, cli: &CliConfig) -> Result<(), CliError>
|
|||
let result = call_acp_extension(&ctx, ACP_EXTENSION_AGENT_LIST_METHOD, json!({}))?;
|
||||
write_stdout_line(&serde_json::to_string_pretty(&result)?)
|
||||
}
|
||||
AgentsCommand::Report(args) => run_agents_report(args, cli),
|
||||
AgentsCommand::Install(args) => {
|
||||
let ctx = ClientContext::new(cli, &args.client)?;
|
||||
let mut params = serde_json::Map::new();
|
||||
|
|
@ -498,6 +501,223 @@ fn run_agents(command: &AgentsCommand, cli: &CliConfig) -> Result<(), CliError>
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AgentListApiResponse {
|
||||
agents: Vec<AgentListApiAgent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AgentListApiAgent {
|
||||
id: String,
|
||||
installed: bool,
|
||||
#[serde(default)]
|
||||
config_error: Option<String>,
|
||||
#[serde(default)]
|
||||
config_options: Option<Vec<Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawConfigOption {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
category: Option<String>,
|
||||
#[serde(default)]
|
||||
current_value: Option<Value>,
|
||||
#[serde(default)]
|
||||
options: Vec<RawConfigOptionChoice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawConfigOptionChoice {
|
||||
#[serde(default)]
|
||||
value: Value,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AgentConfigReport {
|
||||
generated_at_ms: u128,
|
||||
endpoint: String,
|
||||
agents: Vec<AgentConfigReportEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AgentConfigReportEntry {
|
||||
id: String,
|
||||
installed: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
config_error: Option<String>,
|
||||
models: AgentConfigCategoryReport,
|
||||
modes: AgentConfigCategoryReport,
|
||||
thought_levels: AgentConfigCategoryReport,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AgentConfigCategoryReport {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
current_value: Option<String>,
|
||||
values: Vec<AgentConfigValueReport>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AgentConfigValueReport {
|
||||
value: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ConfigReportCategory {
|
||||
Model,
|
||||
Mode,
|
||||
ThoughtLevel,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CategoryAccumulator {
|
||||
current_value: Option<String>,
|
||||
values: BTreeMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
impl CategoryAccumulator {
|
||||
fn absorb(&mut self, option: &RawConfigOption) {
|
||||
if self.current_value.is_none() {
|
||||
self.current_value = config_value_to_string(option.current_value.as_ref());
|
||||
}
|
||||
|
||||
for candidate in &option.options {
|
||||
let Some(value) = config_value_to_string(Some(&candidate.value)) else {
|
||||
continue;
|
||||
};
|
||||
let name = candidate
|
||||
.name
|
||||
.as_ref()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let entry = self.values.entry(value).or_insert(None);
|
||||
if entry.is_none() && name.is_some() {
|
||||
*entry = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn into_report(mut self) -> AgentConfigCategoryReport {
|
||||
if let Some(current) = self.current_value.clone() {
|
||||
self.values.entry(current).or_insert(None);
|
||||
}
|
||||
AgentConfigCategoryReport {
|
||||
current_value: self.current_value,
|
||||
values: self
|
||||
.values
|
||||
.into_iter()
|
||||
.map(|(value, name)| AgentConfigValueReport { value, name })
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_agents_report(args: &ClientArgs, cli: &CliConfig) -> Result<(), CliError> {
|
||||
let ctx = ClientContext::new(cli, args)?;
|
||||
let response = ctx.get(&format!("{API_PREFIX}/agents?config=true"))?;
|
||||
let status = response.status();
|
||||
let text = response.text()?;
|
||||
|
||||
if !status.is_success() {
|
||||
print_error_body(&text)?;
|
||||
return Err(CliError::HttpStatus(status));
|
||||
}
|
||||
|
||||
let parsed: AgentListApiResponse = serde_json::from_str(&text)?;
|
||||
let report = build_agent_config_report(parsed, &ctx.endpoint);
|
||||
write_stdout_line(&serde_json::to_string_pretty(&report)?)
|
||||
}
|
||||
|
||||
fn build_agent_config_report(input: AgentListApiResponse, endpoint: &str) -> AgentConfigReport {
|
||||
let generated_at_ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
|
||||
let agents = input
|
||||
.agents
|
||||
.into_iter()
|
||||
.map(|agent| {
|
||||
let mut model = CategoryAccumulator::default();
|
||||
let mut mode = CategoryAccumulator::default();
|
||||
let mut thought_level = CategoryAccumulator::default();
|
||||
|
||||
for option_value in agent.config_options.unwrap_or_default() {
|
||||
let Ok(option) = serde_json::from_value::<RawConfigOption>(option_value) else {
|
||||
continue;
|
||||
};
|
||||
let Some(category) = option
|
||||
.category
|
||||
.as_deref()
|
||||
.or(option.id.as_deref())
|
||||
.and_then(classify_report_category)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match category {
|
||||
ConfigReportCategory::Model => model.absorb(&option),
|
||||
ConfigReportCategory::Mode => mode.absorb(&option),
|
||||
ConfigReportCategory::ThoughtLevel => thought_level.absorb(&option),
|
||||
}
|
||||
}
|
||||
|
||||
AgentConfigReportEntry {
|
||||
id: agent.id,
|
||||
installed: agent.installed,
|
||||
config_error: agent.config_error,
|
||||
models: model.into_report(),
|
||||
modes: mode.into_report(),
|
||||
thought_levels: thought_level.into_report(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
AgentConfigReport {
|
||||
generated_at_ms,
|
||||
endpoint: endpoint.to_string(),
|
||||
agents,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_report_category(raw: &str) -> Option<ConfigReportCategory> {
|
||||
let normalized = raw
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.replace('-', "_")
|
||||
.replace(' ', "_");
|
||||
|
||||
match normalized.as_str() {
|
||||
"model" | "model_id" => Some(ConfigReportCategory::Model),
|
||||
"mode" | "agent_mode" => Some(ConfigReportCategory::Mode),
|
||||
"thought" | "thoughtlevel" | "thought_level" | "thinking" | "thinking_level"
|
||||
| "reasoning" | "reasoning_effort" => Some(ConfigReportCategory::ThoughtLevel),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn config_value_to_string(value: Option<&Value>) -> Option<String> {
|
||||
match value {
|
||||
Some(Value::String(value)) => Some(value.clone()),
|
||||
Some(Value::Null) | None => None,
|
||||
Some(other) => Some(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn call_acp_extension(ctx: &ClientContext, method: &str, params: Value) -> Result<Value, CliError> {
|
||||
let server_id = unique_cli_server_id("cli-ext");
|
||||
let initialize_path = build_acp_server_path(&server_id, Some("mock"))?;
|
||||
|
|
@ -1219,4 +1439,96 @@ mod tests {
|
|||
.expect("build request");
|
||||
assert!(request.headers().get("last-event-id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_report_category_supports_common_aliases() {
|
||||
assert!(matches!(
|
||||
classify_report_category("model"),
|
||||
Some(ConfigReportCategory::Model)
|
||||
));
|
||||
assert!(matches!(
|
||||
classify_report_category("mode"),
|
||||
Some(ConfigReportCategory::Mode)
|
||||
));
|
||||
assert!(matches!(
|
||||
classify_report_category("thought_level"),
|
||||
Some(ConfigReportCategory::ThoughtLevel)
|
||||
));
|
||||
assert!(matches!(
|
||||
classify_report_category("reasoning_effort"),
|
||||
Some(ConfigReportCategory::ThoughtLevel)
|
||||
));
|
||||
assert!(classify_report_category("arbitrary").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_agent_config_report_extracts_model_mode_and_thought() {
|
||||
let response = AgentListApiResponse {
|
||||
agents: vec![AgentListApiAgent {
|
||||
id: "codex".to_string(),
|
||||
installed: true,
|
||||
config_error: None,
|
||||
config_options: Some(vec![
|
||||
json!({
|
||||
"id": "model",
|
||||
"category": "model",
|
||||
"currentValue": "gpt-5",
|
||||
"options": [
|
||||
{"value": "gpt-5", "name": "GPT-5"},
|
||||
{"value": "gpt-5-mini", "name": "GPT-5 mini"}
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"id": "mode",
|
||||
"category": "mode",
|
||||
"currentValue": "default",
|
||||
"options": [
|
||||
{"value": "default", "name": "Default"},
|
||||
{"value": "plan", "name": "Plan"}
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"id": "thought",
|
||||
"category": "thought_level",
|
||||
"currentValue": "medium",
|
||||
"options": [
|
||||
{"value": "low", "name": "Low"},
|
||||
{"value": "medium", "name": "Medium"},
|
||||
{"value": "high", "name": "High"}
|
||||
]
|
||||
}),
|
||||
]),
|
||||
}],
|
||||
};
|
||||
|
||||
let report = build_agent_config_report(response, "http://127.0.0.1:2468");
|
||||
let agent = report.agents.first().expect("agent report");
|
||||
|
||||
assert_eq!(agent.id, "codex");
|
||||
assert_eq!(agent.models.current_value.as_deref(), Some("gpt-5"));
|
||||
assert_eq!(agent.modes.current_value.as_deref(), Some("default"));
|
||||
assert_eq!(
|
||||
agent.thought_levels.current_value.as_deref(),
|
||||
Some("medium")
|
||||
);
|
||||
|
||||
let model_values: Vec<&str> = agent
|
||||
.models
|
||||
.values
|
||||
.iter()
|
||||
.map(|item| item.value.as_str())
|
||||
.collect();
|
||||
assert!(model_values.contains(&"gpt-5"));
|
||||
assert!(model_values.contains(&"gpt-5-mini"));
|
||||
|
||||
let thought_values: Vec<&str> = agent
|
||||
.thought_levels
|
||||
.values
|
||||
.iter()
|
||||
.map(|item| item.value.as_str())
|
||||
.collect();
|
||||
assert!(thought_values.contains(&"low"));
|
||||
assert!(thought_values.contains(&"medium"));
|
||||
assert!(thought_values.contains(&"high"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use base64::Engine;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::process::{Child, ChildStdin, Command};
|
||||
use tokio::sync::{broadcast, Mutex, RwLock, Semaphore};
|
||||
use tokio::sync::{broadcast, Mutex, RwLock};
|
||||
|
||||
use sandbox_agent_error::SandboxError;
|
||||
|
||||
|
|
@ -119,7 +119,6 @@ pub struct ProcessRuntime {
|
|||
struct ProcessRuntimeInner {
|
||||
next_id: AtomicU64,
|
||||
processes: RwLock<HashMap<String, Arc<ManagedProcess>>>,
|
||||
run_once_semaphore: Semaphore,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -183,9 +182,6 @@ impl ProcessRuntime {
|
|||
inner: Arc::new(ProcessRuntimeInner {
|
||||
next_id: AtomicU64::new(1),
|
||||
processes: RwLock::new(HashMap::new()),
|
||||
run_once_semaphore: Semaphore::new(
|
||||
ProcessRuntimeConfig::default().max_concurrent_processes,
|
||||
),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -328,14 +324,6 @@ impl ProcessRuntime {
|
|||
});
|
||||
}
|
||||
|
||||
let _permit =
|
||||
self.inner
|
||||
.run_once_semaphore
|
||||
.try_acquire()
|
||||
.map_err(|_| SandboxError::Conflict {
|
||||
message: "too many concurrent run_once operations".to_string(),
|
||||
})?;
|
||||
|
||||
let config = self.get_config().await;
|
||||
let mut timeout_ms = spec.timeout_ms.unwrap_or(config.default_run_timeout_ms);
|
||||
if timeout_ms == 0 {
|
||||
|
|
@ -358,6 +346,9 @@ impl ProcessRuntime {
|
|||
cmd.current_dir(cwd);
|
||||
}
|
||||
|
||||
if !spec.env.contains_key("TERM") {
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
}
|
||||
for (key, value) in &spec.env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
|
@ -622,10 +613,6 @@ impl ProcessRuntime {
|
|||
cmd.current_dir(cwd);
|
||||
}
|
||||
|
||||
// Default TERM for TTY processes so tools like tmux, vim, etc. work out of the box.
|
||||
if !spec.env.contains_key("TERM") {
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
}
|
||||
for (key, value) in &spec.env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,12 +50,6 @@ pub use self::types::*;
|
|||
|
||||
const APPLICATION_JSON: &str = "application/json";
|
||||
const TEXT_EVENT_STREAM: &str = "text/event-stream";
|
||||
const CHANNEL_K8S_IO_PROTOCOL: &str = "channel.k8s.io";
|
||||
const CH_STDIN: u8 = 0;
|
||||
const CH_STDOUT: u8 = 1;
|
||||
const CH_STATUS: u8 = 3;
|
||||
const CH_RESIZE: u8 = 4;
|
||||
const CH_CLOSE: u8 = 255;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum BrandingMode {
|
||||
|
|
@ -202,6 +196,10 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
|
|||
.route("/processes/:id/kill", post(post_v1_process_kill))
|
||||
.route("/processes/:id/logs", get(get_v1_process_logs))
|
||||
.route("/processes/:id/input", post(post_v1_process_input))
|
||||
.route(
|
||||
"/processes/:id/terminal/resize",
|
||||
post(post_v1_process_terminal_resize),
|
||||
)
|
||||
.route(
|
||||
"/processes/:id/terminal/ws",
|
||||
get(get_v1_process_terminal_ws),
|
||||
|
|
@ -346,6 +344,7 @@ pub async fn shutdown_servers(state: &Arc<AppState>) {
|
|||
delete_v1_process,
|
||||
get_v1_process_logs,
|
||||
post_v1_process_input,
|
||||
post_v1_process_terminal_resize,
|
||||
get_v1_process_terminal_ws,
|
||||
get_v1_config_mcp,
|
||||
put_v1_config_mcp,
|
||||
|
|
@ -395,6 +394,8 @@ pub async fn shutdown_servers(state: &Arc<AppState>) {
|
|||
ProcessInputRequest,
|
||||
ProcessInputResponse,
|
||||
ProcessSignalQuery,
|
||||
ProcessTerminalResizeRequest,
|
||||
ProcessTerminalResizeResponse,
|
||||
AcpPostQuery,
|
||||
AcpServerInfo,
|
||||
AcpServerListResponse,
|
||||
|
|
@ -1493,15 +1494,12 @@ async fn get_v1_process_logs(
|
|||
since,
|
||||
};
|
||||
|
||||
if query.follow.unwrap_or(false) {
|
||||
// Subscribe before reading history to avoid losing entries between the
|
||||
// two operations. Entries are deduplicated by sequence number below.
|
||||
let rx = runtime.subscribe_logs(&id).await?;
|
||||
let entries = runtime.logs(&id, filter).await?;
|
||||
let response_entries: Vec<ProcessLogEntry> =
|
||||
entries.iter().cloned().map(map_process_log_line).collect();
|
||||
let last_replay_seq = response_entries.last().map(|e| e.sequence).unwrap_or(0);
|
||||
let entries = runtime.logs(&id, filter).await?;
|
||||
let response_entries: Vec<ProcessLogEntry> =
|
||||
entries.iter().cloned().map(map_process_log_line).collect();
|
||||
|
||||
if query.follow.unwrap_or(false) {
|
||||
let rx = runtime.subscribe_logs(&id).await?;
|
||||
let replay_stream = stream::iter(response_entries.into_iter().map(|entry| {
|
||||
Ok::<axum::response::sse::Event, Infallible>(
|
||||
axum::response::sse::Event::default()
|
||||
|
|
@ -1517,9 +1515,6 @@ async fn get_v1_process_logs(
|
|||
async move {
|
||||
match item {
|
||||
Ok(line) => {
|
||||
if line.sequence <= last_replay_seq {
|
||||
return None;
|
||||
}
|
||||
let entry = map_process_log_line(line);
|
||||
if process_log_matches(&entry, requested_stream_copy) {
|
||||
Some(Ok(axum::response::sse::Event::default()
|
||||
|
|
@ -1544,10 +1539,6 @@ async fn get_v1_process_logs(
|
|||
return Ok(response.into_response());
|
||||
}
|
||||
|
||||
let entries = runtime.logs(&id, filter).await?;
|
||||
let response_entries: Vec<ProcessLogEntry> =
|
||||
entries.iter().cloned().map(map_process_log_line).collect();
|
||||
|
||||
Ok(Json(ProcessLogsResponse {
|
||||
process_id: id,
|
||||
stream: requested_stream,
|
||||
|
|
@ -1601,13 +1592,51 @@ async fn post_v1_process_input(
|
|||
Ok(Json(ProcessInputResponse { bytes_written }))
|
||||
}
|
||||
|
||||
/// Resize a process terminal.
|
||||
///
|
||||
/// Sets the PTY window size (columns and rows) for a tty-mode process and
|
||||
/// sends SIGWINCH so the child process can adapt.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/processes/{id}/terminal/resize",
|
||||
tag = "v1",
|
||||
params(
|
||||
("id" = String, Path, description = "Process ID")
|
||||
),
|
||||
request_body = ProcessTerminalResizeRequest,
|
||||
responses(
|
||||
(status = 200, description = "Resize accepted", body = ProcessTerminalResizeResponse),
|
||||
(status = 400, description = "Invalid request", body = ProblemDetails),
|
||||
(status = 404, description = "Unknown process", body = ProblemDetails),
|
||||
(status = 409, description = "Not a terminal process", body = ProblemDetails),
|
||||
(status = 501, description = "Process API unsupported on this platform", body = ProblemDetails)
|
||||
)
|
||||
)]
|
||||
async fn post_v1_process_terminal_resize(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<ProcessTerminalResizeRequest>,
|
||||
) -> Result<Json<ProcessTerminalResizeResponse>, ApiError> {
|
||||
if !process_api_supported() {
|
||||
return Err(process_api_not_supported().into());
|
||||
}
|
||||
|
||||
state
|
||||
.process_runtime()
|
||||
.resize_terminal(&id, body.cols, body.rows)
|
||||
.await?;
|
||||
Ok(Json(ProcessTerminalResizeResponse {
|
||||
cols: body.cols,
|
||||
rows: body.rows,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Open an interactive WebSocket terminal session.
|
||||
///
|
||||
/// Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts
|
||||
/// `access_token` query param for browser-based auth (WebSocket API cannot
|
||||
/// send custom headers). Uses the `channel.k8s.io` binary subprotocol:
|
||||
/// channel 0 stdin, channel 1 stdout, channel 3 status JSON, channel 4 resize,
|
||||
/// and channel 255 close.
|
||||
/// send custom headers). Streams raw PTY output as binary frames and accepts
|
||||
/// JSON control frames for input, resize, and close.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/processes/{id}/terminal/ws",
|
||||
|
|
@ -1643,16 +1672,23 @@ async fn get_v1_process_terminal_ws(
|
|||
}
|
||||
|
||||
Ok(ws
|
||||
.protocols([CHANNEL_K8S_IO_PROTOCOL])
|
||||
.on_upgrade(move |socket| process_terminal_ws_session(socket, runtime, id))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TerminalResizePayload {
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
enum TerminalClientFrame {
|
||||
Input {
|
||||
data: String,
|
||||
#[serde(default)]
|
||||
encoding: Option<String>,
|
||||
},
|
||||
Resize {
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
},
|
||||
Close,
|
||||
}
|
||||
|
||||
async fn process_terminal_ws_session(
|
||||
|
|
@ -1660,7 +1696,7 @@ async fn process_terminal_ws_session(
|
|||
runtime: Arc<ProcessRuntime>,
|
||||
id: String,
|
||||
) {
|
||||
let _ = send_status_json(
|
||||
let _ = send_ws_json(
|
||||
&mut socket,
|
||||
json!({
|
||||
"type": "ready",
|
||||
|
|
@ -1672,8 +1708,7 @@ async fn process_terminal_ws_session(
|
|||
let mut log_rx = match runtime.subscribe_logs(&id).await {
|
||||
Ok(rx) => rx,
|
||||
Err(err) => {
|
||||
let _ = send_status_error(&mut socket, &err.to_string()).await;
|
||||
let _ = send_close_signal(&mut socket).await;
|
||||
let _ = send_ws_error(&mut socket, &err.to_string()).await;
|
||||
let _ = socket.close().await;
|
||||
return;
|
||||
}
|
||||
|
|
@ -1684,57 +1719,43 @@ async fn process_terminal_ws_session(
|
|||
tokio::select! {
|
||||
ws_in = socket.recv() => {
|
||||
match ws_in {
|
||||
Some(Ok(Message::Binary(bytes))) => {
|
||||
let Some((&channel, payload)) = bytes.split_first() else {
|
||||
let _ = send_status_error(&mut socket, "invalid terminal frame: missing channel byte").await;
|
||||
continue;
|
||||
};
|
||||
|
||||
match channel {
|
||||
CH_STDIN => {
|
||||
let input = payload.to_vec();
|
||||
let max_input = runtime.max_input_bytes().await;
|
||||
if input.len() > max_input {
|
||||
let _ = send_status_error(&mut socket, &format!("input payload exceeds maxInputBytesPerRequest ({max_input})")).await;
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = runtime.write_input(&id, &input).await {
|
||||
let _ = send_status_error(&mut socket, &err.to_string()).await;
|
||||
}
|
||||
}
|
||||
CH_RESIZE => {
|
||||
let resize = match serde_json::from_slice::<TerminalResizePayload>(payload) {
|
||||
Ok(resize) => resize,
|
||||
Some(Ok(Message::Binary(_))) => {
|
||||
let _ = send_ws_error(&mut socket, "binary input is not supported; use text JSON frames").await;
|
||||
}
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
let parsed = serde_json::from_str::<TerminalClientFrame>(&text);
|
||||
match parsed {
|
||||
Ok(TerminalClientFrame::Input { data, encoding }) => {
|
||||
let input = match decode_input_bytes(&data, encoding.as_deref().unwrap_or("utf8")) {
|
||||
Ok(input) => input,
|
||||
Err(err) => {
|
||||
let _ = send_status_error(&mut socket, &format!("invalid resize payload: {err}")).await;
|
||||
let _ = send_ws_error(&mut socket, &err.to_string()).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = runtime
|
||||
.resize_terminal(&id, resize.cols, resize.rows)
|
||||
.await
|
||||
{
|
||||
let _ = send_status_error(&mut socket, &err.to_string()).await;
|
||||
let max_input = runtime.max_input_bytes().await;
|
||||
if input.len() > max_input {
|
||||
let _ = send_ws_error(&mut socket, &format!("input payload exceeds maxInputBytesPerRequest ({max_input})")).await;
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = runtime.write_input(&id, &input).await {
|
||||
let _ = send_ws_error(&mut socket, &err.to_string()).await;
|
||||
}
|
||||
}
|
||||
CH_CLOSE => {
|
||||
let _ = send_close_signal(&mut socket).await;
|
||||
Ok(TerminalClientFrame::Resize { cols, rows }) => {
|
||||
if let Err(err) = runtime.resize_terminal(&id, cols, rows).await {
|
||||
let _ = send_ws_error(&mut socket, &err.to_string()).await;
|
||||
}
|
||||
}
|
||||
Ok(TerminalClientFrame::Close) => {
|
||||
let _ = socket.close().await;
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
let _ = send_status_error(&mut socket, &format!("unsupported terminal channel: {channel}")).await;
|
||||
Err(err) => {
|
||||
let _ = send_ws_error(&mut socket, &format!("invalid terminal frame: {err}")).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Text(_))) => {
|
||||
let _ = send_status_error(
|
||||
&mut socket,
|
||||
"text frames are not supported; use channel.k8s.io binary frames",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Some(Ok(Message::Ping(payload))) => {
|
||||
let _ = socket.send(Message::Pong(payload)).await;
|
||||
}
|
||||
|
|
@ -1754,7 +1775,7 @@ async fn process_terminal_ws_session(
|
|||
use base64::Engine;
|
||||
BASE64_ENGINE.decode(&line.data).unwrap_or_default()
|
||||
};
|
||||
if send_channel_frame(&mut socket, CH_STDOUT, bytes).await.is_err() {
|
||||
if socket.send(Message::Binary(bytes)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1765,7 +1786,7 @@ async fn process_terminal_ws_session(
|
|||
_ = exit_poll.tick() => {
|
||||
if let Ok(snapshot) = runtime.snapshot(&id).await {
|
||||
if snapshot.status == ProcessStatus::Exited {
|
||||
let _ = send_status_json(
|
||||
let _ = send_ws_json(
|
||||
&mut socket,
|
||||
json!({
|
||||
"type": "exit",
|
||||
|
|
@ -1773,7 +1794,6 @@ async fn process_terminal_ws_session(
|
|||
}),
|
||||
)
|
||||
.await;
|
||||
let _ = send_close_signal(&mut socket).await;
|
||||
let _ = socket.close().await;
|
||||
break;
|
||||
}
|
||||
|
|
@ -1783,30 +1803,17 @@ async fn process_terminal_ws_session(
|
|||
}
|
||||
}
|
||||
|
||||
async fn send_channel_frame(
|
||||
socket: &mut WebSocket,
|
||||
channel: u8,
|
||||
payload: impl Into<Vec<u8>>,
|
||||
) -> Result<(), ()> {
|
||||
let mut frame = vec![channel];
|
||||
frame.extend(payload.into());
|
||||
async fn send_ws_json(socket: &mut WebSocket, payload: Value) -> Result<(), ()> {
|
||||
socket
|
||||
.send(Message::Binary(frame.into()))
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&payload).map_err(|_| ())?,
|
||||
))
|
||||
.await
|
||||
.map_err(|_| ())
|
||||
}
|
||||
|
||||
async fn send_status_json(socket: &mut WebSocket, payload: Value) -> Result<(), ()> {
|
||||
send_channel_frame(
|
||||
socket,
|
||||
CH_STATUS,
|
||||
serde_json::to_vec(&payload).map_err(|_| ())?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_status_error(socket: &mut WebSocket, message: &str) -> Result<(), ()> {
|
||||
send_status_json(
|
||||
async fn send_ws_error(socket: &mut WebSocket, message: &str) -> Result<(), ()> {
|
||||
send_ws_json(
|
||||
socket,
|
||||
json!({
|
||||
"type": "error",
|
||||
|
|
@ -1816,10 +1823,6 @@ async fn send_status_error(socket: &mut WebSocket, message: &str) -> Result<(),
|
|||
.await
|
||||
}
|
||||
|
||||
async fn send_close_signal(socket: &mut WebSocket) -> Result<(), ()> {
|
||||
send_channel_frame(socket, CH_CLOSE, Vec::<u8>::new()).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/config/mcp",
|
||||
|
|
|
|||
|
|
@ -71,10 +71,7 @@ fn percent_decode(input: &str) -> String {
|
|||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let (Some(hi), Some(lo)) = (
|
||||
hex_nibble(bytes[i + 1]),
|
||||
hex_nibble(bytes[i + 2]),
|
||||
) {
|
||||
if let (Some(hi), Some(lo)) = (hex_nibble(bytes[i + 1]), hex_nibble(bytes[i + 2])) {
|
||||
output.push((hi << 4) | lo);
|
||||
i += 3;
|
||||
continue;
|
||||
|
|
@ -147,6 +144,9 @@ pub(super) fn fallback_config_options(agent: AgentId) -> Vec<Value> {
|
|||
AgentId::Codex => CODEX.clone(),
|
||||
AgentId::Opencode => OPENCODE.clone(),
|
||||
AgentId::Cursor => CURSOR.clone(),
|
||||
// Amp returns empty configOptions from session/new but exposes modes via
|
||||
// the `modes` field. The model is hardcoded. Modes discovered from ACP
|
||||
// session/new response (amp-acp v0.7.0).
|
||||
AgentId::Amp => vec![
|
||||
json!({
|
||||
"id": "model",
|
||||
|
|
@ -163,12 +163,10 @@ pub(super) fn fallback_config_options(agent: AgentId) -> Vec<Value> {
|
|||
"name": "Mode",
|
||||
"category": "mode",
|
||||
"type": "select",
|
||||
"currentValue": "smart",
|
||||
"currentValue": "default",
|
||||
"options": [
|
||||
{ "value": "smart", "name": "Smart" },
|
||||
{ "value": "deep", "name": "Deep" },
|
||||
{ "value": "free", "name": "Free" },
|
||||
{ "value": "rush", "name": "Rush" }
|
||||
{ "value": "default", "name": "Default" },
|
||||
{ "value": "bypass", "name": "Bypass" }
|
||||
]
|
||||
}),
|
||||
],
|
||||
|
|
@ -182,41 +180,76 @@ pub(super) fn fallback_config_options(agent: AgentId) -> Vec<Value> {
|
|||
{ "value": "default", "name": "Default" }
|
||||
]
|
||||
})],
|
||||
AgentId::Mock => vec![json!({
|
||||
"id": "model",
|
||||
"name": "Model",
|
||||
"category": "model",
|
||||
"type": "select",
|
||||
"currentValue": "mock",
|
||||
"options": [
|
||||
{ "value": "mock", "name": "Mock" }
|
||||
]
|
||||
})],
|
||||
AgentId::Mock => vec![
|
||||
json!({
|
||||
"id": "model",
|
||||
"name": "Model",
|
||||
"category": "model",
|
||||
"type": "select",
|
||||
"currentValue": "mock",
|
||||
"options": [
|
||||
{ "value": "mock", "name": "Mock" },
|
||||
{ "value": "mock-fast", "name": "Mock Fast" }
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"id": "mode",
|
||||
"name": "Mode",
|
||||
"category": "mode",
|
||||
"type": "select",
|
||||
"currentValue": "normal",
|
||||
"options": [
|
||||
{ "value": "normal", "name": "Normal" },
|
||||
{ "value": "plan", "name": "Plan" }
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"id": "thought_level",
|
||||
"name": "Thought Level",
|
||||
"category": "thought_level",
|
||||
"type": "select",
|
||||
"currentValue": "low",
|
||||
"options": [
|
||||
{ "value": "low", "name": "Low" },
|
||||
{ "value": "medium", "name": "Medium" },
|
||||
{ "value": "high", "name": "High" }
|
||||
]
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an agent config JSON file (from `scripts/agent-configs/resources/`) into
|
||||
/// ACP `SessionConfigOption` values. The JSON format is:
|
||||
/// ```json
|
||||
/// { "defaultModel": "...", "models": [{id, name}], "defaultMode?": "...", "modes?": [{id, name}] }
|
||||
/// {
|
||||
/// "defaultModel": "...", "models": [{id, name}],
|
||||
/// "defaultMode?": "...", "modes?": [{id, name}],
|
||||
/// "defaultThoughtLevel?": "...", "thoughtLevels?": [{id, name}]
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Note: Claude and Codex don't report configOptions from `session/new`, so these
|
||||
/// JSON resource files are the source of truth for the capabilities report.
|
||||
/// Claude modes (plan, default) were discovered via manual ACP probing —
|
||||
/// `session/set_mode` works but `session/set_config_option` is not implemented.
|
||||
/// Codex modes/thought levels were discovered from its `session/new` response.
|
||||
fn parse_agent_config(json_str: &str) -> Vec<Value> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AgentConfig {
|
||||
#[serde(rename = "defaultModel")]
|
||||
default_model: String,
|
||||
models: Vec<ModelEntry>,
|
||||
models: Vec<ConfigEntry>,
|
||||
#[serde(rename = "defaultMode")]
|
||||
default_mode: Option<String>,
|
||||
modes: Option<Vec<ModeEntry>>,
|
||||
modes: Option<Vec<ConfigEntry>>,
|
||||
#[serde(rename = "defaultThoughtLevel")]
|
||||
default_thought_level: Option<String>,
|
||||
#[serde(rename = "thoughtLevels")]
|
||||
thought_levels: Option<Vec<ConfigEntry>>,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ModelEntry {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ModeEntry {
|
||||
struct ConfigEntry {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
|
@ -242,7 +275,7 @@ fn parse_agent_config(json_str: &str) -> Vec<Value> {
|
|||
"name": "Mode",
|
||||
"category": "mode",
|
||||
"type": "select",
|
||||
"currentValue": config.default_mode.unwrap_or_else(|| modes[0].id.clone()),
|
||||
"currentValue": config.default_mode.or_else(|| modes.first().map(|m| m.id.clone())).unwrap_or_default(),
|
||||
"options": modes.iter().map(|m| json!({
|
||||
"value": m.id,
|
||||
"name": m.name,
|
||||
|
|
@ -250,6 +283,20 @@ fn parse_agent_config(json_str: &str) -> Vec<Value> {
|
|||
}));
|
||||
}
|
||||
|
||||
if let Some(thought_levels) = config.thought_levels {
|
||||
options.push(json!({
|
||||
"id": "thought_level",
|
||||
"name": "Thought Level",
|
||||
"category": "thought_level",
|
||||
"type": "select",
|
||||
"currentValue": config.default_thought_level.or_else(|| thought_levels.first().map(|t| t.id.clone())).unwrap_or_default(),
|
||||
"options": thought_levels.iter().map(|t| json!({
|
||||
"value": t.id,
|
||||
"name": t.name,
|
||||
})).collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
|
||||
options
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -512,6 +512,20 @@ pub struct ProcessSignalQuery {
|
|||
pub wait_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProcessTerminalResizeRequest {
|
||||
pub cols: u16,
|
||||
pub rows: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProcessTerminalResizeResponse {
|
||||
pub cols: u16,
|
||||
pub rows: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProcessWsQuery {
|
||||
|
|
|
|||
|
|
@ -65,8 +65,8 @@ impl LiveServer {
|
|||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let server = axum::serve(listener, app.into_make_service())
|
||||
.with_graceful_shutdown(async {
|
||||
let server =
|
||||
axum::serve(listener, app.into_make_service()).with_graceful_shutdown(async {
|
||||
let _ = shutdown_rx.await;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,17 +3,8 @@ use base64::engine::general_purpose::STANDARD as BASE64;
|
|||
use base64::Engine;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
const CHANNEL_K8S_IO_PROTOCOL: &str = "channel.k8s.io";
|
||||
const CH_STDIN: u8 = 0;
|
||||
const CH_STDOUT: u8 = 1;
|
||||
const CH_STATUS: u8 = 3;
|
||||
const CH_RESIZE: u8 = 4;
|
||||
const CH_CLOSE: u8 = 255;
|
||||
|
||||
async fn wait_for_exited(test_app: &TestApp, process_id: &str) {
|
||||
for _ in 0..30 {
|
||||
let (status, _, body) = send_request(
|
||||
|
|
@ -57,19 +48,6 @@ async fn recv_ws_message(
|
|||
.expect("websocket frame")
|
||||
}
|
||||
|
||||
fn make_channel_frame(channel: u8, payload: impl AsRef<[u8]>) -> Vec<u8> {
|
||||
let payload = payload.as_ref();
|
||||
let mut frame = Vec::with_capacity(payload.len() + 1);
|
||||
frame.push(channel);
|
||||
frame.extend_from_slice(payload);
|
||||
frame
|
||||
}
|
||||
|
||||
fn parse_channel_frame(bytes: &[u8]) -> (u8, &[u8]) {
|
||||
let (&channel, payload) = bytes.split_first().expect("channel frame");
|
||||
(channel, payload)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn v1_processes_config_round_trip() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
|
|
@ -544,81 +522,54 @@ async fn v1_process_terminal_ws_e2e_is_deterministic() {
|
|||
let process_id = create_body["id"].as_str().expect("process id").to_string();
|
||||
|
||||
let ws_url = live_server.ws_url(&format!("/v1/processes/{process_id}/terminal/ws"));
|
||||
let mut ws_request = ws_url.into_client_request().expect("ws request");
|
||||
ws_request.headers_mut().insert(
|
||||
"Sec-WebSocket-Protocol",
|
||||
HeaderValue::from_static(CHANNEL_K8S_IO_PROTOCOL),
|
||||
);
|
||||
let (mut ws, response) = connect_async(ws_request).await.expect("connect websocket");
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("Sec-WebSocket-Protocol")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(CHANNEL_K8S_IO_PROTOCOL)
|
||||
);
|
||||
let (mut ws, _) = connect_async(&ws_url).await.expect("connect websocket");
|
||||
|
||||
let ready = recv_ws_message(&mut ws).await;
|
||||
let ready_bytes = ready.into_data();
|
||||
let (ready_channel, ready_payload) = parse_channel_frame(&ready_bytes);
|
||||
assert_eq!(ready_channel, CH_STATUS);
|
||||
let ready_payload: Value = serde_json::from_slice(ready_payload).expect("ready json");
|
||||
let ready_payload: Value =
|
||||
serde_json::from_str(ready.to_text().expect("ready text frame")).expect("ready json");
|
||||
assert_eq!(ready_payload["type"], "ready");
|
||||
assert_eq!(ready_payload["processId"], process_id);
|
||||
|
||||
ws.send(Message::Binary(
|
||||
make_channel_frame(CH_STDIN, b"hello from ws\n").into(),
|
||||
ws.send(Message::Text(
|
||||
json!({
|
||||
"type": "input",
|
||||
"data": "hello from ws\n"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await
|
||||
.expect("send input frame");
|
||||
|
||||
ws.send(Message::Binary(
|
||||
make_channel_frame(CH_RESIZE, br#"{"cols":120,"rows":40}"#).into(),
|
||||
))
|
||||
.await
|
||||
.expect("send resize frame");
|
||||
|
||||
let mut saw_stdout = false;
|
||||
let mut saw_binary_output = false;
|
||||
let mut saw_exit = false;
|
||||
let mut saw_close = false;
|
||||
for _ in 0..10 {
|
||||
let frame = recv_ws_message(&mut ws).await;
|
||||
match frame {
|
||||
Message::Binary(bytes) => {
|
||||
let (channel, payload) = parse_channel_frame(&bytes);
|
||||
match channel {
|
||||
CH_STDOUT => {
|
||||
let text = String::from_utf8_lossy(payload);
|
||||
if text.contains("got:hello from ws") {
|
||||
saw_stdout = true;
|
||||
}
|
||||
}
|
||||
CH_STATUS => {
|
||||
let payload: Value =
|
||||
serde_json::from_slice(payload).expect("ws status json");
|
||||
if payload["type"] == "exit" {
|
||||
saw_exit = true;
|
||||
} else {
|
||||
assert_ne!(payload["type"], "error");
|
||||
}
|
||||
}
|
||||
CH_CLOSE => {
|
||||
assert!(payload.is_empty(), "close channel payload must be empty");
|
||||
saw_close = true;
|
||||
break;
|
||||
}
|
||||
other => panic!("unexpected websocket channel: {other}"),
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
if text.contains("got:hello from ws") {
|
||||
saw_binary_output = true;
|
||||
}
|
||||
}
|
||||
Message::Text(text) => {
|
||||
let payload: Value = serde_json::from_str(&text).expect("ws json");
|
||||
if payload["type"] == "exit" {
|
||||
saw_exit = true;
|
||||
break;
|
||||
}
|
||||
assert_ne!(payload["type"], "error");
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
Message::Ping(_) | Message::Pong(_) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(saw_stdout, "expected pty stdout over websocket");
|
||||
assert!(saw_exit, "expected exit status frame over websocket");
|
||||
assert!(saw_close, "expected close channel frame over websocket");
|
||||
assert!(
|
||||
saw_binary_output,
|
||||
"expected pty binary output over websocket"
|
||||
);
|
||||
assert!(saw_exit, "expected exit control frame over websocket");
|
||||
|
||||
let _ = ws.close(None).await;
|
||||
|
||||
|
|
@ -668,38 +619,19 @@ async fn v1_process_terminal_ws_auth_e2e() {
|
|||
let auth_ws_url = live_server.ws_url(&format!(
|
||||
"/v1/processes/{process_id}/terminal/ws?access_token={token}"
|
||||
));
|
||||
let mut ws_request = auth_ws_url.into_client_request().expect("ws request");
|
||||
ws_request.headers_mut().insert(
|
||||
"Sec-WebSocket-Protocol",
|
||||
HeaderValue::from_static(CHANNEL_K8S_IO_PROTOCOL),
|
||||
);
|
||||
let (mut ws, response) = connect_async(ws_request)
|
||||
let (mut ws, _) = connect_async(&auth_ws_url)
|
||||
.await
|
||||
.expect("authenticated websocket handshake");
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("Sec-WebSocket-Protocol")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(CHANNEL_K8S_IO_PROTOCOL)
|
||||
);
|
||||
|
||||
let ready = recv_ws_message(&mut ws).await;
|
||||
let ready_bytes = ready.into_data();
|
||||
let (ready_channel, ready_payload) = parse_channel_frame(&ready_bytes);
|
||||
assert_eq!(ready_channel, CH_STATUS);
|
||||
let ready_payload: Value = serde_json::from_slice(ready_payload).expect("ready json");
|
||||
let ready_payload: Value =
|
||||
serde_json::from_str(ready.to_text().expect("ready text frame")).expect("ready json");
|
||||
assert_eq!(ready_payload["type"], "ready");
|
||||
assert_eq!(ready_payload["processId"], process_id);
|
||||
|
||||
let _ = ws
|
||||
.send(Message::Binary(make_channel_frame(CH_CLOSE, []).into()))
|
||||
.send(Message::Text(json!({ "type": "close" }).to_string()))
|
||||
.await;
|
||||
let close = recv_ws_message(&mut ws).await;
|
||||
let close_bytes = close.into_data();
|
||||
let (close_channel, close_payload) = parse_channel_frame(&close_bytes);
|
||||
assert_eq!(close_channel, CH_CLOSE);
|
||||
assert!(close_payload.is_empty());
|
||||
let _ = ws.close(None).await;
|
||||
|
||||
let kill_response = http
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue