mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 03:00:48 +00:00
feat: sprites support
This commit is contained in:
parent
9cd9252725
commit
5da35e6dfa
35 changed files with 746 additions and 1257 deletions
|
|
@ -20,7 +20,7 @@
|
||||||
- For HTTP/CLI docs/examples, source of truth is:
|
- For HTTP/CLI docs/examples, source of truth is:
|
||||||
- `server/packages/sandbox-agent/src/router.rs`
|
- `server/packages/sandbox-agent/src/router.rs`
|
||||||
- `server/packages/sandbox-agent/src/cli.rs`
|
- `server/packages/sandbox-agent/src/cli.rs`
|
||||||
- Keep docs aligned to implemented endpoints/commands only (for example ACP under `/v1/acp`, not legacy `/v1/sessions` APIs).
|
- Keep docs aligned to implemented endpoints/commands only (for example ACP under `/v1/acp`, not legacy session REST APIs).
|
||||||
|
|
||||||
## Change Tracking
|
## Change Tracking
|
||||||
|
|
||||||
|
|
@ -78,4 +78,3 @@
|
||||||
- `scripts/release/main.ts`
|
- `scripts/release/main.ts`
|
||||||
- `scripts/release/promote-artifacts.ts`
|
- `scripts/release/promote-artifacts.ts`
|
||||||
- `scripts/release/sdk.ts`
|
- `scripts/release/sdk.ts`
|
||||||
- `scripts/sandbox-testing/test-sandbox.ts`
|
|
||||||
|
|
|
||||||
20
examples/sprites/package.json
Normal file
20
examples/sprites/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"name": "@sandbox-agent/example-sprites",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "tsx src/index.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fly/sprites": "latest",
|
||||||
|
"@sandbox-agent/example-shared": "workspace:*",
|
||||||
|
"sandbox-agent": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "latest",
|
||||||
|
"tsx": "latest",
|
||||||
|
"typescript": "latest",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
examples/sprites/src/index.ts
Normal file
21
examples/sprites/src/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { SandboxAgent } from "sandbox-agent";
|
||||||
|
import { sprites } from "sandbox-agent/sprites";
|
||||||
|
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
if (process.env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
||||||
|
if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||||
|
|
||||||
|
const client = await SandboxAgent.start({
|
||||||
|
sandbox: sprites({
|
||||||
|
token: process.env.SPRITES_API_KEY ?? process.env.SPRITE_TOKEN ?? process.env.SPRITES_TOKEN,
|
||||||
|
env,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`UI: ${client.inspectorUrl}`);
|
||||||
|
console.log(await client.getHealth());
|
||||||
|
|
||||||
|
process.once("SIGINT", async () => {
|
||||||
|
await client.destroySandbox();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
34
examples/sprites/tests/sprites.test.ts
Normal file
34
examples/sprites/tests/sprites.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { SandboxAgent } from "sandbox-agent";
|
||||||
|
import { sprites } from "sandbox-agent/sprites";
|
||||||
|
|
||||||
|
const shouldRun = Boolean(process.env.SPRITES_API_KEY || process.env.SPRITE_TOKEN || process.env.SPRITES_TOKEN);
|
||||||
|
const timeoutMs = Number.parseInt(process.env.SANDBOX_TEST_TIMEOUT_MS || "", 10) || 300_000;
|
||||||
|
|
||||||
|
const testFn = shouldRun ? it : it.skip;
|
||||||
|
|
||||||
|
describe("sprites provider", () => {
|
||||||
|
testFn(
|
||||||
|
"starts sandbox-agent and responds to /v1/health",
|
||||||
|
async () => {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
if (process.env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
||||||
|
if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||||
|
|
||||||
|
const sdk = await SandboxAgent.start({
|
||||||
|
sandbox: sprites({
|
||||||
|
token: process.env.SPRITES_API_KEY ?? process.env.SPRITE_TOKEN ?? process.env.SPRITES_TOKEN,
|
||||||
|
env,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const health = await sdk.getHealth();
|
||||||
|
expect(health.status).toBe("ok");
|
||||||
|
} finally {
|
||||||
|
await sdk.destroySandbox();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
});
|
||||||
17
examples/sprites/tsconfig.json
Normal file
17
examples/sprites/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "**/*.test.ts"]
|
||||||
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ Upgrading backend integration from legacy sandbox-agent session endpoints to `sa
|
||||||
|
|
||||||
### Friction / Issue
|
### Friction / Issue
|
||||||
|
|
||||||
`0.2.0` no longer exposes `/v1/sessions` endpoints used by the backend integration; direct session create/status polling via legacy REST paths returns `404`.
|
`0.2.0` no longer exposes the legacy session REST endpoints used by the backend integration; direct session create/status polling via those paths returns `404`.
|
||||||
|
|
||||||
### Attempted Fix / Workaround
|
### Attempted Fix / Workaround
|
||||||
|
|
||||||
|
|
@ -65,5 +65,5 @@ Upgrading backend integration from legacy sandbox-agent session endpoints to `sa
|
||||||
|
|
||||||
### Outcome
|
### Outcome
|
||||||
|
|
||||||
- Backend no longer depends on removed `/v1/sessions` endpoints.
|
- Backend no longer depends on removed legacy session REST endpoints.
|
||||||
- Daytona flow is aligned with `sandbox-agent 0.2.0` runtime and SDK usage.
|
- Daytona flow is aligned with `sandbox-agent 0.2.0` runtime and SDK usage.
|
||||||
|
|
|
||||||
91
pnpm-lock.yaml
generated
91
pnpm-lock.yaml
generated
|
|
@ -450,6 +450,31 @@ importers:
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
examples/sprites:
|
||||||
|
dependencies:
|
||||||
|
'@fly/sprites':
|
||||||
|
specifier: latest
|
||||||
|
version: 0.0.1
|
||||||
|
'@sandbox-agent/example-shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../shared
|
||||||
|
sandbox-agent:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../sdks/typescript
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: latest
|
||||||
|
version: 25.5.0
|
||||||
|
tsx:
|
||||||
|
specifier: latest
|
||||||
|
version: 4.21.0
|
||||||
|
typescript:
|
||||||
|
specifier: latest
|
||||||
|
version: 6.0.2
|
||||||
|
vitest:
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
|
||||||
examples/vercel:
|
examples/vercel:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sandbox-agent/example-shared':
|
'@sandbox-agent/example-shared':
|
||||||
|
|
@ -531,7 +556,7 @@ importers:
|
||||||
version: 1.3.10
|
version: 1.3.10
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.5.0
|
specifier: ^8.5.0
|
||||||
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.2)
|
||||||
|
|
||||||
foundry/packages/client:
|
foundry/packages/client:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -553,7 +578,7 @@ importers:
|
||||||
version: 19.2.14
|
version: 19.2.14
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.5.0
|
specifier: ^8.5.0
|
||||||
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.2)
|
||||||
|
|
||||||
foundry/packages/frontend:
|
foundry/packages/frontend:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -614,7 +639,7 @@ importers:
|
||||||
version: 0.1.27(@types/react@19.2.14)(react@19.2.4)
|
version: 0.1.27(@types/react@19.2.14)(react@19.2.4)
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.5.0
|
specifier: ^8.5.0
|
||||||
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.2)
|
||||||
vite:
|
vite:
|
||||||
specifier: ^7.1.3
|
specifier: ^7.1.3
|
||||||
version: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
|
version: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
|
@ -633,7 +658,7 @@ importers:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.5.0
|
specifier: ^8.5.0
|
||||||
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.2)
|
||||||
|
|
||||||
frontend/packages/inspector:
|
frontend/packages/inspector:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -771,20 +796,6 @@ importers:
|
||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
scripts/sandbox-testing:
|
|
||||||
dependencies:
|
|
||||||
'@daytonaio/sdk':
|
|
||||||
specifier: latest
|
|
||||||
version: 0.151.0(ws@8.19.0)
|
|
||||||
'@e2b/code-interpreter':
|
|
||||||
specifier: latest
|
|
||||||
version: 2.3.3
|
|
||||||
devDependencies:
|
|
||||||
'@types/node':
|
|
||||||
specifier: latest
|
|
||||||
version: 25.5.0
|
|
||||||
tsx:
|
|
||||||
specifier: latest
|
|
||||||
version: 4.21.0
|
version: 4.21.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: latest
|
specifier: latest
|
||||||
|
|
@ -988,6 +999,9 @@ importers:
|
||||||
'@e2b/code-interpreter':
|
'@e2b/code-interpreter':
|
||||||
specifier: '>=1.0.0'
|
specifier: '>=1.0.0'
|
||||||
version: 2.3.3
|
version: 2.3.3
|
||||||
|
'@fly/sprites':
|
||||||
|
specifier: '>=0.0.1'
|
||||||
|
version: 0.0.1
|
||||||
'@types/dockerode':
|
'@types/dockerode':
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.0.1
|
version: 4.0.1
|
||||||
|
|
@ -2487,6 +2501,10 @@ packages:
|
||||||
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
|
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
'@fly/sprites@0.0.1':
|
||||||
|
resolution: {integrity: sha512-1s+dIVi/pTMP4Aj4Mkg+4LoZ/+a0Kp6l9piPRxvpgEKm11b/eRiZgJwVytwAHeI/vtg2fuwcFExjtXOEfny/TA==}
|
||||||
|
engines: {node: '>=24.0.0'}
|
||||||
|
|
||||||
'@grpc/grpc-js@1.14.3':
|
'@grpc/grpc-js@1.14.3':
|
||||||
resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==}
|
resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==}
|
||||||
engines: {node: '>=12.10.0'}
|
engines: {node: '>=12.10.0'}
|
||||||
|
|
@ -6937,6 +6955,11 @@ packages:
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
typescript@6.0.2:
|
||||||
|
resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==}
|
||||||
|
engines: {node: '>=14.17'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
ufo@1.6.3:
|
ufo@1.6.3:
|
||||||
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
|
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
|
||||||
|
|
||||||
|
|
@ -8975,6 +8998,8 @@ snapshots:
|
||||||
|
|
||||||
'@fastify/busboy@2.1.1': {}
|
'@fastify/busboy@2.1.1': {}
|
||||||
|
|
||||||
|
'@fly/sprites@0.0.1': {}
|
||||||
|
|
||||||
'@grpc/grpc-js@1.14.3':
|
'@grpc/grpc-js@1.14.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@grpc/proto-loader': 0.8.0
|
'@grpc/proto-loader': 0.8.0
|
||||||
|
|
@ -14144,6 +14169,34 @@ snapshots:
|
||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
|
tsup@8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.2):
|
||||||
|
dependencies:
|
||||||
|
bundle-require: 5.1.0(esbuild@0.27.3)
|
||||||
|
cac: 6.7.14
|
||||||
|
chokidar: 4.0.3
|
||||||
|
consola: 3.4.2
|
||||||
|
debug: 4.4.3
|
||||||
|
esbuild: 0.27.3
|
||||||
|
fix-dts-default-cjs-exports: 1.0.1
|
||||||
|
joycon: 3.1.1
|
||||||
|
picocolors: 1.1.1
|
||||||
|
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
resolve-from: 5.0.0
|
||||||
|
rollup: 4.56.0
|
||||||
|
source-map: 0.7.6
|
||||||
|
sucrase: 3.35.1
|
||||||
|
tinyexec: 0.3.2
|
||||||
|
tinyglobby: 0.2.15
|
||||||
|
tree-kill: 1.2.2
|
||||||
|
optionalDependencies:
|
||||||
|
postcss: 8.5.6
|
||||||
|
typescript: 6.0.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- jiti
|
||||||
|
- supports-color
|
||||||
|
- tsx
|
||||||
|
- yaml
|
||||||
|
|
||||||
tsx@4.21.0:
|
tsx@4.21.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.27.3
|
esbuild: 0.27.3
|
||||||
|
|
@ -14194,6 +14247,8 @@ snapshots:
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
typescript@6.0.2: {}
|
||||||
|
|
||||||
ufo@1.6.3: {}
|
ufo@1.6.3: {}
|
||||||
|
|
||||||
ultrahtml@1.6.0: {}
|
ultrahtml@1.6.0: {}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,5 @@ packages:
|
||||||
- "sdks/gigacode/platforms/*"
|
- "sdks/gigacode/platforms/*"
|
||||||
- "resources/vercel-ai-sdk-schemas"
|
- "resources/vercel-ai-sdk-schemas"
|
||||||
- "scripts/release"
|
- "scripts/release"
|
||||||
- "scripts/sandbox-testing"
|
|
||||||
- "examples/*"
|
- "examples/*"
|
||||||
- "server/packages/sandbox-agent/tests/opencode-compat"
|
- "server/packages/sandbox-agent/tests/opencode-compat"
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ Update this file continuously during the migration.
|
||||||
- Date: 2026-02-10
|
- Date: 2026-02-10
|
||||||
- Area: Session lifecycle surface
|
- Area: Session lifecycle surface
|
||||||
- Issue: ACP stable does not include v1-equivalent methods for session listing, explicit session termination/delete, or event-log polling.
|
- Issue: ACP stable does not include v1-equivalent methods for session listing, explicit session termination/delete, or event-log polling.
|
||||||
- Impact: Direct lift-and-shift of `/v1/sessions`, `/terminate`, and `/events` polling is not possible with ACP core only.
|
- Impact: Direct lift-and-shift of the legacy session REST list, terminate, and event-polling behavior is not possible with ACP core only.
|
||||||
- Proposed direction: Define `_sandboxagent/session/*` extension methods for these control operations, while keeping core prompt flow on standard ACP methods.
|
- Proposed direction: Define `_sandboxagent/session/*` extension methods for these control operations, while keeping core prompt flow on standard ACP methods.
|
||||||
- Decision: Open.
|
- Decision: Open.
|
||||||
- Owner: Unassigned.
|
- Owner: Unassigned.
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,4 @@ This tracks legacy inspector behaviors that do not yet have full parity on ACP v
|
||||||
5. TDOO: Agent mode discovery before creating a session is not implemented (inspector currently returns cached-or-empty mode lists).
|
5. TDOO: Agent mode discovery before creating a session is not implemented (inspector currently returns cached-or-empty mode lists).
|
||||||
6. TDOO: Agent model discovery before creating a session is not implemented (inspector currently returns cached-or-empty model lists).
|
6. TDOO: Agent model discovery before creating a session is not implemented (inspector currently returns cached-or-empty model lists).
|
||||||
7. TDOO: Session listing only reflects sessions created by this inspector client instance (not full server/global session inventory).
|
7. TDOO: Session listing only reflects sessions created by this inspector client instance (not full server/global session inventory).
|
||||||
8. TDOO: Event history shown in inspector is synthesized from ACP traffic handled by the inspector compatibility layer, not the old canonical `/v1/sessions/*/events` backend history.
|
8. TDOO: Event history shown in inspector is synthesized from ACP traffic handled by the inspector compatibility layer, not the old canonical session-events backend history.
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ Static v1 endpoints today:
|
||||||
|
|
||||||
- `GET /v1/agents`
|
- `GET /v1/agents`
|
||||||
- `POST /v1/agents/:agent/install`
|
- `POST /v1/agents/:agent/install`
|
||||||
- `GET /v1/sessions`
|
- legacy session list endpoint
|
||||||
- `GET /v1/sessions/:session_id`
|
- legacy session detail endpoint
|
||||||
- `GET /v1/fs/entries`
|
- `GET /v1/fs/entries`
|
||||||
- `GET /v1/fs/file`
|
- `GET /v1/fs/file`
|
||||||
- `PUT /v1/fs/file`
|
- `PUT /v1/fs/file`
|
||||||
|
|
@ -76,8 +76,8 @@ Interpretation for clients: all agent/session operations and non-binary filesyst
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET /v1/agents` | `_sandboxagent/agent/list` | Response keeps current `AgentListResponse` shape for low migration risk. |
|
| `GET /v1/agents` | `_sandboxagent/agent/list` | Response keeps current `AgentListResponse` shape for low migration risk. |
|
||||||
| `POST /v1/agents/:agent/install` | `_sandboxagent/agent/install` | Params include `agent`, `reinstall`, `agentVersion`, `agentProcessVersion`. |
|
| `POST /v1/agents/:agent/install` | `_sandboxagent/agent/install` | Params include `agent`, `reinstall`, `agentVersion`, `agentProcessVersion`. |
|
||||||
| `GET /v1/sessions` | `_sandboxagent/session/list` | Return current `SessionListResponse` shape (not ACP unstable list shape). |
|
| legacy session list endpoint | `_sandboxagent/session/list` | Return current `SessionListResponse` shape (not ACP unstable list shape). |
|
||||||
| `GET /v1/sessions/:session_id` | `_sandboxagent/session/get` | Return current `SessionInfo` shape; error on missing session. |
|
| legacy session detail endpoint | `_sandboxagent/session/get` | Return current `SessionInfo` shape; error on missing session. |
|
||||||
| `GET /v1/fs/entries` | `_sandboxagent/fs/list_entries` | Preserve path + optional `sessionId` resolution semantics. |
|
| `GET /v1/fs/entries` | `_sandboxagent/fs/list_entries` | Preserve path + optional `sessionId` resolution semantics. |
|
||||||
| `GET /v1/fs/file` | keep HTTP + `_sandboxagent/fs/read_file` | HTTP is primary because responses may require large streaming reads; ACP variant exists for compatibility/smaller payloads. |
|
| `GET /v1/fs/file` | keep HTTP + `_sandboxagent/fs/read_file` | HTTP is primary because responses may require large streaming reads; ACP variant exists for compatibility/smaller payloads. |
|
||||||
| `PUT /v1/fs/file` | keep HTTP + `_sandboxagent/fs/write_file` | HTTP is primary for large binary writes; ACP variant exists for compatibility/smaller payloads. |
|
| `PUT /v1/fs/file` | keep HTTP + `_sandboxagent/fs/write_file` | HTTP is primary for large binary writes; ACP variant exists for compatibility/smaller payloads. |
|
||||||
|
|
@ -143,7 +143,7 @@ Package boundary after migration:
|
||||||
|
|
||||||
- `acp-http-client` remains protocol-pure ACP transport and generic `extMethod`/`extNotification`.
|
- `acp-http-client` remains protocol-pure ACP transport and generic `extMethod`/`extNotification`.
|
||||||
- `sandbox-agent` remains the typed wrapper that maps convenience methods to `_sandboxagent/...` extension methods.
|
- `sandbox-agent` remains the typed wrapper that maps convenience methods to `_sandboxagent/...` extension methods.
|
||||||
- No direct `/v1/agents*`, `/v1/sessions*`, or non-binary `/v1/fs/*` fetches in SDK runtime code.
|
- No direct legacy agents/session REST fetches or non-binary `/v1/fs/*` fetches in SDK runtime code.
|
||||||
- Binary file transfer keeps direct HTTP fetches on the three endpoints listed above.
|
- Binary file transfer keeps direct HTTP fetches on the three endpoints listed above.
|
||||||
- SDK policy: prefer HTTP for `readFsFile`/`writeFsFile`/`uploadFsBatch` even if ACP extension variants exist.
|
- SDK policy: prefer HTTP for `readFsFile`/`writeFsFile`/`uploadFsBatch` even if ACP extension variants exist.
|
||||||
|
|
||||||
|
|
@ -184,17 +184,17 @@ Alternative (optional): introduce a runtime-only control connection mode that do
|
||||||
- TypeScript SDK (`sdks/typescript/src/client.ts`):
|
- TypeScript SDK (`sdks/typescript/src/client.ts`):
|
||||||
- Repoint `listAgents`, `installAgent`, `listSessions`, `getSession`, `listFsEntries`, `deleteFsEntry`, `mkdirFs`, `moveFs`, and `statFs` to ACP extension calls.
|
- Repoint `listAgents`, `installAgent`, `listSessions`, `getSession`, `listFsEntries`, `deleteFsEntry`, `mkdirFs`, `moveFs`, and `statFs` to ACP extension calls.
|
||||||
- Keep `readFsFile`, `writeFsFile`, and `uploadFsBatch` on HTTP endpoints.
|
- Keep `readFsFile`, `writeFsFile`, and `uploadFsBatch` on HTTP endpoints.
|
||||||
- Remove direct runtime fetch usage for `/v1/agents*`, `/v1/sessions*`, and non-binary `/v1/fs/*`.
|
- Remove direct runtime fetch usage for legacy agents/session REST endpoints and non-binary `/v1/fs/*`.
|
||||||
- Keep method names stable for callers.
|
- Keep method names stable for callers.
|
||||||
- Move these methods to connected-only semantics (`NotConnectedError` when disconnected).
|
- Move these methods to connected-only semantics (`NotConnectedError` when disconnected).
|
||||||
- CLI (`server/packages/sandbox-agent/src/cli.rs`):
|
- CLI (`server/packages/sandbox-agent/src/cli.rs`):
|
||||||
- Make `api agents list/install` call ACP extension methods (via ACP post flow), not direct `/v1/agents*` HTTP calls.
|
- Make `api agents list/install` call ACP extension methods (via ACP post flow), not direct legacy agent HTTP calls.
|
||||||
- Inspector flow/docs:
|
- Inspector flow/docs:
|
||||||
- Stop depending on `GET /v1/agents` in startup path; use ACP extension instead.
|
- Stop depending on `GET /v1/agents` in startup path; use ACP extension instead.
|
||||||
|
|
||||||
### Phase 3: Remove Static Endpoints (Except Health + Binary FS Transfer)
|
### Phase 3: Remove Static Endpoints (Except Health + Binary FS Transfer)
|
||||||
|
|
||||||
- Remove route registrations for `/v1/agents*`, `/v1/sessions*`, `/v1/fs/entries`, `/v1/fs/entry`, `/v1/fs/mkdir`, `/v1/fs/move`, `/v1/fs/stat` from `router.rs`.
|
- Remove route registrations for legacy agent/session REST endpoints and `/v1/fs/entries`, `/v1/fs/entry`, `/v1/fs/mkdir`, `/v1/fs/move`, `/v1/fs/stat` from `router.rs`.
|
||||||
- Keep `/v1/health`, `/v1/rpc`, `GET /v1/fs/file`, `PUT /v1/fs/file`, and `POST /v1/fs/upload-batch`.
|
- Keep `/v1/health`, `/v1/rpc`, `GET /v1/fs/file`, `PUT /v1/fs/file`, and `POST /v1/fs/upload-batch`.
|
||||||
- Optional short deprecation period: convert removed routes to `410 Gone` with explicit extension method in `detail`.
|
- Optional short deprecation period: convert removed routes to `410 Gone` with explicit extension method in `detail`.
|
||||||
|
|
||||||
|
|
@ -237,6 +237,6 @@ Inspector:
|
||||||
|
|
||||||
## Open Decisions
|
## Open Decisions
|
||||||
|
|
||||||
1. Should removed `/v1/agents*`, `/v1/sessions*`, and non-binary `/v1/fs/*` return `410` for one release or be dropped immediately?
|
1. Should removed legacy agent/session REST endpoints and non-binary `/v1/fs/*` return `410` for one release or be dropped immediately?
|
||||||
2. Do we keep a strict response-shape parity layer for session/file methods, or normalize to ACP-native shapes?
|
2. Do we keep a strict response-shape parity layer for session/file methods, or normalize to ACP-native shapes?
|
||||||
3. Should `/` service-root remain as informational HTTP, or be treated as out-of-scope for this “only health static + binary fs transfer” policy?
|
3. Should `/` service-root remain as informational HTTP, or be treated as out-of-scope for this “only health static + binary fs transfer” policy?
|
||||||
|
|
|
||||||
|
|
@ -59,11 +59,11 @@ struct PendingQuestion {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## v1 HTTP Endpoints (from `router.rs`)
|
## Legacy Session REST Endpoints (from `router.rs`)
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /v1/sessions/{session_id}/questions/{question_id}/reply -> 204 No Content
|
session question reply endpoint -> 204 No Content
|
||||||
POST /v1/sessions/{session_id}/questions/{question_id}/reject -> 204 No Content
|
session question reject endpoint -> 204 No Content
|
||||||
```
|
```
|
||||||
|
|
||||||
### `reply_question` handler
|
### `reply_question` handler
|
||||||
|
|
@ -122,7 +122,7 @@ Key flow:
|
||||||
|
|
||||||
1. Agent emits `question.requested` event with `QuestionEventData { status: Requested, question_id, prompt, options }`
|
1. Agent emits `question.requested` event with `QuestionEventData { status: Requested, question_id, prompt, options }`
|
||||||
2. Client renders question UI
|
2. Client renders question UI
|
||||||
3. Client calls `POST /v1/sessions/{id}/questions/{qid}/reply` with `{ answers: [["selected"]] }` or `POST .../reject`
|
3. Client calls the legacy session question reply or reject endpoint with `{ answers: [["selected"]] }`
|
||||||
4. System emits `question.resolved` event with `QuestionEventData { status: Answered, response: Some("...") }` or `{ status: Rejected }`
|
4. System emits `question.resolved` event with `QuestionEventData { status: Answered, response: Some("...") }` or `{ status: Rejected }`
|
||||||
|
|
||||||
## v1 Agent Capability
|
## v1 Agent Capability
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
v1 had explicit session termination (`POST /v1/sessions/{id}/terminate`). v1 only has `session/cancel` (turn cancellation, not session kill) and `DELETE /v1/rpc` (connection close, not session termination). Need explicit session destroy/terminate semantics.
|
The legacy session REST API had an explicit terminate endpoint. ACP only has `session/cancel` (turn cancellation, not session kill) and `DELETE /v1/rpc` (connection close, not session termination). Need explicit session destroy/terminate semantics.
|
||||||
|
|
||||||
## Current v1 State
|
## Current v1 State
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ v1 had explicit session termination (`POST /v1/sessions/{id}/terminate`). v1 onl
|
||||||
### HTTP Endpoint
|
### HTTP Endpoint
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /v1/sessions/{id}/terminate
|
legacy session terminate endpoint
|
||||||
```
|
```
|
||||||
|
|
||||||
### Handler (from `router.rs`)
|
### Handler (from `router.rs`)
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ Returned `AgentModelsResponse` with full model list including variants.
|
||||||
### Session Creation with Variant
|
### Session Creation with Variant
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /v1/sessions
|
legacy session create endpoint
|
||||||
```
|
```
|
||||||
|
|
||||||
Body included `variant: Option<String>` to select a specific model variant at session creation time.
|
Body included `variant: Option<String>` to select a specific model variant at session creation time.
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ pub struct UniversalEvent {
|
||||||
### v1 Usage
|
### v1 Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /v1/sessions/{id}/events?include_raw=true
|
legacy event polling endpoint with `include_raw=true`
|
||||||
```
|
```
|
||||||
|
|
||||||
When `include_raw=true`, each `UniversalEvent` included the verbatim JSON the agent process emitted before normalization into the universal schema.
|
When `include_raw=true`, each `UniversalEvent` included the verbatim JSON the agent process emitted before normalization into the universal schema.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
# Feature 16: Session Info
|
# Feature 16: Session Info
|
||||||
|
|
||||||
**Implementation approach:** New HTTP endpoints (`GET /v1/sessions`, `GET /v1/sessions/{id}`)
|
**Implementation approach:** New session-info HTTP endpoints
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
v1 `SessionInfo` tracked `event_count`, `created_at`, `updated_at`, and full `mcp` config. v1 has session data in the ACP runtime's `MetaSession` struct but no HTTP endpoints to query it. Add REST endpoints for session listing and detail.
|
v1 `SessionInfo` tracked `event_count`, `created_at`, `updated_at`, and full `mcp` config. v1 has session data in the ACP runtime's `MetaSession` struct but no HTTP endpoints to query it. Add HTTP endpoints for session listing and detail.
|
||||||
|
|
||||||
## Current v1 State
|
## Current v1 State
|
||||||
|
|
||||||
|
|
@ -117,8 +117,8 @@ fn build_session_info(state: &SessionState) -> SessionInfo {
|
||||||
### New HTTP Endpoints
|
### New HTTP Endpoints
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /v1/sessions -> SessionListResponse
|
session list endpoint -> SessionListResponse
|
||||||
GET /v1/sessions/{id} -> SessionInfo
|
session detail endpoint -> SessionInfo
|
||||||
```
|
```
|
||||||
|
|
||||||
These are control-plane HTTP endpoints (not ACP), providing session visibility without requiring an active ACP connection.
|
These are control-plane HTTP endpoints (not ACP), providing session visibility without requiring an active ACP connection.
|
||||||
|
|
@ -156,7 +156,7 @@ Need to add:
|
||||||
|
|
||||||
| File | Change |
|
| File | Change |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `server/packages/sandbox-agent/src/router.rs` | Add `GET /v1/sessions` and `GET /v1/sessions/{id}` handlers; add response types |
|
| `server/packages/sandbox-agent/src/router.rs` | Add session list and session detail handlers; add response types |
|
||||||
| `server/packages/sandbox-agent/src/acp_runtime/mod.rs` | Add `created_at` to `MetaSession`; add `ended` tracking; expose `list_sessions()` and `get_session()` public methods |
|
| `server/packages/sandbox-agent/src/acp_runtime/mod.rs` | Add `created_at` to `MetaSession`; add `ended` tracking; expose `list_sessions()` and `get_session()` public methods |
|
||||||
| `sdks/typescript/src/client.ts` | Add `listSessions()` and `getSession(id)` methods |
|
| `sdks/typescript/src/client.ts` | Add `listSessions()` and `getSession(id)` methods |
|
||||||
| `server/packages/sandbox-agent/tests/v1_api.rs` | Add session listing and detail tests |
|
| `server/packages/sandbox-agent/tests/v1_api.rs` | Add session listing and detail tests |
|
||||||
|
|
@ -165,6 +165,6 @@ Need to add:
|
||||||
|
|
||||||
| Doc | Change |
|
| Doc | Change |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
| `docs/openapi.json` | Add `/v1/sessions` and `/v1/sessions/{id}` endpoint specs |
|
| `docs/openapi.json` | Add session list and session detail endpoint specs |
|
||||||
| `docs/cli.mdx` | Add CLI `sessions list` and `sessions info` commands |
|
| `docs/cli.mdx` | Add CLI `sessions list` and `sessions info` commands |
|
||||||
| `docs/sdks/typescript.mdx` | Document session listing SDK methods |
|
| `docs/sdks/typescript.mdx` | Document session listing SDK methods |
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ When an agent process terminates with an error:
|
||||||
### Session Info Integration
|
### Session Info Integration
|
||||||
|
|
||||||
Termination metadata should be accessible via:
|
Termination metadata should be accessible via:
|
||||||
- `GET /v1/sessions/{id}` (Feature #16) — include `terminationInfo` in response when session has ended
|
- the session info endpoint (Feature #16) — include `terminationInfo` in response when session has ended
|
||||||
- `session/list` ACP response — include termination status in session entries
|
- `session/list` ACP response — include termination status in session entries
|
||||||
|
|
||||||
### Files to Modify
|
### Files to Modify
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ Session-level features that build on Phase A runtime tracking.
|
||||||
|
|
||||||
| Order | Feature | Spec | Approach | Effort |
|
| Order | Feature | Spec | Approach | Effort |
|
||||||
|:-----:|--------------------------------------------------------------|:----:|------------------------------------------------------|:------:|
|
|:-----:|--------------------------------------------------------------|:----:|------------------------------------------------------|:------:|
|
||||||
| B1 | [Session Info](./16-session-info.md) | #16 | New `GET /v1/sessions` and `GET /v1/sessions/{id}` | Medium |
|
| B1 | [Session Info](./16-session-info.md) | #16 | New session info HTTP endpoints | Medium |
|
||||||
| B2 | [Session Termination](./07-session-termination.md) | #7 | Idempotent `_sandboxagent/session/terminate` | Medium |
|
| B2 | [Session Termination](./07-session-termination.md) | #7 | Idempotent `_sandboxagent/session/terminate` | Medium |
|
||||||
| B3 | [Error Termination Metadata](./17-error-termination-metadata.md) | #17 | Stderr capture + `_sandboxagent/session/ended` event | Medium |
|
| B3 | [Error Termination Metadata](./17-error-termination-metadata.md) | #17 | Stderr capture + `_sandboxagent/session/ended` event | Medium |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,16 @@
|
||||||
| /v1/fs/stat | UNIMPLEMENTED |
|
| /v1/fs/stat | UNIMPLEMENTED |
|
||||||
| /v1/fs/upload-batch | UNIMPLEMENTED |
|
| /v1/fs/upload-batch | UNIMPLEMENTED |
|
||||||
| /v1/health | UNIMPLEMENTED |
|
| /v1/health | UNIMPLEMENTED |
|
||||||
| /v1/sessions | session/list (UNSTABLE) |
|
| legacy session list route | session/list (UNSTABLE) |
|
||||||
| /v1/sessions/{session_id} | session/new \| session/load \| session/resume (UNSTABLE) |
|
| legacy session create/load/resume route | session/new \| session/load \| session/resume (UNSTABLE) |
|
||||||
| /v1/sessions/{session_id}/events | UNIMPLEMENTED |
|
| legacy session events polling route | UNIMPLEMENTED |
|
||||||
| /v1/sessions/{session_id}/events/sse | session/update (notification stream) |
|
| legacy session events SSE route | session/update (notification stream) |
|
||||||
| /v1/sessions/{session_id}/messages | session/prompt |
|
| legacy session prompt route | session/prompt |
|
||||||
| /v1/sessions/{session_id}/messages/stream | session/prompt + session/update notifications |
|
| legacy session prompt + stream route | session/prompt + session/update notifications |
|
||||||
| /v1/sessions/{session_id}/permissions/{permission_id}/reply | session/request_permission response |
|
| legacy permission reply route | session/request_permission response |
|
||||||
| /v1/sessions/{session_id}/questions/{question_id}/reject | UNIMPLEMENTED |
|
| legacy question reject route | UNIMPLEMENTED |
|
||||||
| /v1/sessions/{session_id}/questions/{question_id}/reply | UNIMPLEMENTED |
|
| legacy question reply route | UNIMPLEMENTED |
|
||||||
| /v1/sessions/{session_id}/terminate | session/cancel (turn cancellation only) |
|
| legacy session terminate route | session/cancel (turn cancellation only) |
|
||||||
| AgentCapabilities | initialize.result.agentCapabilities |
|
| AgentCapabilities | initialize.result.agentCapabilities |
|
||||||
| AgentCapabilities.commandExecution | UNIMPLEMENTED |
|
| AgentCapabilities.commandExecution | UNIMPLEMENTED |
|
||||||
| AgentCapabilities.errorEvents | UNIMPLEMENTED |
|
| AgentCapabilities.errorEvents | UNIMPLEMENTED |
|
||||||
|
|
@ -427,7 +427,7 @@
|
||||||
|
|
||||||
- `UNIMPLEMENTED` means there is no ACP-standard field/method with equivalent semantics in `schema.unstable.json`; implementation would require ACP extension methods (`_...`) and/or `_meta` payloads.
|
- `UNIMPLEMENTED` means there is no ACP-standard field/method with equivalent semantics in `schema.unstable.json`; implementation would require ACP extension methods (`_...`) and/or `_meta` payloads.
|
||||||
- Rows mapped to `_meta[...]` are ACP-compatible extensions, not standard interoperable ACP fields; both sides must agree on names and semantics.
|
- Rows mapped to `_meta[...]` are ACP-compatible extensions, not standard interoperable ACP fields; both sides must agree on names and semantics.
|
||||||
- Legacy event polling (`/v1/sessions/{session_id}/events`) has no ACP equivalent; ACP is stream-first via `session/update` notifications over streamable HTTP.
|
- Legacy event polling has no ACP equivalent; ACP is stream-first via `session/update` notifications over streamable HTTP.
|
||||||
- Session lifecycle differs: ACP has `session/new`, `session/load`, `session/resume` (UNSTABLE), and `session/fork` (UNSTABLE), but no standard explicit "close session" method.
|
- Session lifecycle differs: ACP has `session/new`, `session/load`, `session/resume` (UNSTABLE), and `session/fork` (UNSTABLE), but no standard explicit "close session" method.
|
||||||
- Permission handling is request/response (`session/request_permission`) tied to JSON-RPC request IDs; it does not use standalone REST reply endpoints.
|
- Permission handling is request/response (`session/request_permission`) tied to JSON-RPC request IDs; it does not use standalone REST reply endpoints.
|
||||||
- Question/answer HITL flow in the old schema has no standard ACP equivalent today (separate from permission prompts).
|
- Question/answer HITL flow in the old schema has no standard ACP equivalent today (separate from permission prompts).
|
||||||
|
|
|
||||||
|
|
@ -233,8 +233,6 @@ Non-ACP endpoints retained in v1:
|
||||||
- `GET /v1/health`
|
- `GET /v1/health`
|
||||||
- `GET /v1/agents` (capabilities + install status)
|
- `GET /v1/agents` (capabilities + install status)
|
||||||
- `POST /v1/agents/{agent}/install`
|
- `POST /v1/agents/{agent}/install`
|
||||||
- `GET /v1/sessions`
|
|
||||||
- `GET /v1/sessions/{id}`
|
|
||||||
- `GET /v1/fs/file`
|
- `GET /v1/fs/file`
|
||||||
- `PUT /v1/fs/file`
|
- `PUT /v1/fs/file`
|
||||||
- `POST /v1/fs/upload-batch`
|
- `POST /v1/fs/upload-batch`
|
||||||
|
|
|
||||||
|
|
@ -54,16 +54,16 @@ Extension namespace used in this spec:
|
||||||
| `POST /v1/fs/move` | `POST /v1/fs/move` | HTTP platform API | Port v1 behavior. |
|
| `POST /v1/fs/move` | `POST /v1/fs/move` | HTTP platform API | Port v1 behavior. |
|
||||||
| `GET /v1/fs/stat` | `GET /v1/fs/stat` | HTTP platform API | Port v1 behavior. |
|
| `GET /v1/fs/stat` | `GET /v1/fs/stat` | HTTP platform API | Port v1 behavior. |
|
||||||
| `POST /v1/fs/upload-batch` | `POST /v1/fs/upload-batch` | HTTP platform API | Tar upload/extract behavior from v1. |
|
| `POST /v1/fs/upload-batch` | `POST /v1/fs/upload-batch` | HTTP platform API | Tar upload/extract behavior from v1. |
|
||||||
| `GET /v1/sessions` | `GET /v1/sessions` | HTTP control-plane | Session inventory without ACP connection requirement. |
|
| legacy session list route | session/list | HTTP control-plane | Session inventory without ACP connection requirement. |
|
||||||
| `POST /v1/sessions/{session_id}` | `session/new` | Standard | Path `session_id` becomes alias in `_meta["sandboxagent.dev"].requestedSessionId`. |
|
| legacy session create route | `session/new` | Standard | Path `session_id` becomes alias in `_meta["sandboxagent.dev"].requestedSessionId`. |
|
||||||
| `POST /v1/sessions/{session_id}/messages` | `session/prompt` | Standard | Asynchronous behavior comes from transport (request + stream). |
|
| legacy session prompt route | `session/prompt` | Standard | Asynchronous behavior comes from transport (request + stream). |
|
||||||
| `POST /v1/sessions/{session_id}/messages/stream` | `session/prompt` + consume `session/update` on SSE | Standard | Streaming is transport-level, not a distinct ACP method. |
|
| legacy session prompt + stream route | `session/prompt` + consume `session/update` on SSE | Standard | Streaming is transport-level, not a distinct ACP method. |
|
||||||
| `POST /v1/sessions/{session_id}/terminate` | `_sandboxagent/session/terminate` | Extension | Idempotent termination semantics distinct from `DELETE /v1/rpc`. |
|
| legacy session terminate route | `_sandboxagent/session/terminate` | Extension | Idempotent termination semantics distinct from `DELETE /v1/rpc`. |
|
||||||
| `GET /v1/sessions/{session_id}/events` | `_sandboxagent/session/events` (poll view over ACP stream) | Extension | Optional compatibility helper; canonical v1 is stream consumption. |
|
| legacy event polling route | `_sandboxagent/session/events` (poll view over ACP stream) | Extension | Optional compatibility helper; canonical v1 is stream consumption. |
|
||||||
| `GET /v1/sessions/{session_id}/events/sse` | `GET /v1/rpc` SSE stream | Standard transport | Filter by sessionId client-side or via connection/session binding. |
|
| legacy event SSE route | `GET /v1/rpc` SSE stream | Standard transport | Filter by sessionId client-side or via connection/session binding. |
|
||||||
| `POST /v1/sessions/{session_id}/permissions/{permission_id}/reply` | JSON-RPC response to pending `session/request_permission` request id | Standard | Bridge `permission_id` to request `id` in transport state. |
|
| legacy permission reply route | JSON-RPC response to pending `session/request_permission` request id | Standard | Bridge `permission_id` to request `id` in transport state. |
|
||||||
| `POST /v1/sessions/{session_id}/questions/{question_id}/reply` | JSON-RPC response to pending `_sandboxagent/session/request_question` | Extension | ACP stable has no generic question/HITL request method. |
|
| legacy question reply route | JSON-RPC response to pending `_sandboxagent/session/request_question` | Extension | ACP stable has no generic question/HITL request method. |
|
||||||
| `POST /v1/sessions/{session_id}/questions/{question_id}/reject` | JSON-RPC response to pending `_sandboxagent/session/request_question` | Extension | Encode rejection in response outcome. |
|
| legacy question reject route | JSON-RPC response to pending `_sandboxagent/session/request_question` | Extension | Encode rejection in response outcome. |
|
||||||
|
|
||||||
### 3.1 `CreateSessionRequest` field mapping
|
### 3.1 `CreateSessionRequest` field mapping
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@ const VERSION_REFERENCE_FILES = [
|
||||||
"scripts/release/main.ts",
|
"scripts/release/main.ts",
|
||||||
"scripts/release/promote-artifacts.ts",
|
"scripts/release/promote-artifacts.ts",
|
||||||
"scripts/release/sdk.ts",
|
"scripts/release/sdk.ts",
|
||||||
"scripts/sandbox-testing/test-sandbox.ts",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function updateVersion(opts: ReleaseOpts) {
|
export async function updateVersion(opts: ReleaseOpts) {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@sandbox-agent/testing",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"test": "tsx test-sandbox.ts",
|
|
||||||
"test:docker": "tsx test-sandbox.ts docker",
|
|
||||||
"test:daytona": "tsx test-sandbox.ts daytona",
|
|
||||||
"test:mock": "tsx test-sandbox.ts docker --agent=mock",
|
|
||||||
"test:verbose": "tsx test-sandbox.ts docker --verbose"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@daytonaio/sdk": "latest",
|
|
||||||
"@e2b/code-interpreter": "latest"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "latest",
|
|
||||||
"tsx": "latest",
|
|
||||||
"typescript": "latest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,720 +0,0 @@
|
||||||
#!/usr/bin/env npx tsx
|
|
||||||
/**
|
|
||||||
* Sandbox Testing Script
|
|
||||||
*
|
|
||||||
* Tests sandbox-agent on various cloud sandbox providers.
|
|
||||||
* Usage: npx tsx test-sandbox.ts [provider] [options]
|
|
||||||
*
|
|
||||||
* Providers: daytona, e2b, docker
|
|
||||||
*
|
|
||||||
* Options:
|
|
||||||
* --skip-build Skip cargo build step
|
|
||||||
* --use-release Use pre-built release binary from releases.rivet.dev
|
|
||||||
* --agent <name> Test specific agent (claude, codex, mock)
|
|
||||||
* --skip-agent-install Skip pre-installing agents (tests on-demand install like Daytona example)
|
|
||||||
* --keep-alive Don't cleanup sandbox after test
|
|
||||||
* --verbose Show all logs
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { execSync, spawn } from "node:child_process";
|
|
||||||
import { existsSync, readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
||||||
import { homedir, tmpdir } from "node:os";
|
|
||||||
import { join, dirname } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const ROOT_DIR = join(__dirname, "../..");
|
|
||||||
const SERVER_DIR = join(ROOT_DIR, "server");
|
|
||||||
|
|
||||||
// Parse args
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const provider = args.find((a) => !a.startsWith("--")) || "docker";
|
|
||||||
const skipBuild = args.includes("--skip-build");
|
|
||||||
const useRelease = args.includes("--use-release");
|
|
||||||
const skipAgentInstall = args.includes("--skip-agent-install");
|
|
||||||
const keepAlive = args.includes("--keep-alive");
|
|
||||||
const verbose = args.includes("--verbose");
|
|
||||||
const agentArg = args.find((a) => a.startsWith("--agent="))?.split("=")[1];
|
|
||||||
|
|
||||||
// Colors
|
|
||||||
const log = {
|
|
||||||
info: (msg: string) => console.log(`\x1b[34m[INFO]\x1b[0m ${msg}`),
|
|
||||||
success: (msg: string) => console.log(`\x1b[32m[OK]\x1b[0m ${msg}`),
|
|
||||||
error: (msg: string) => console.log(`\x1b[31m[ERROR]\x1b[0m ${msg}`),
|
|
||||||
warn: (msg: string) => console.log(`\x1b[33m[WARN]\x1b[0m ${msg}`),
|
|
||||||
debug: (msg: string) => verbose && console.log(`\x1b[90m[DEBUG]\x1b[0m ${msg}`),
|
|
||||||
section: (msg: string) => console.log(`\n\x1b[1m=== ${msg} ===\x1b[0m`),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Credentials extraction using sandbox-agent CLI
|
|
||||||
function extractCredentials(): { anthropicApiKey?: string; openaiApiKey?: string } {
|
|
||||||
// First check environment variables
|
|
||||||
const envCreds = {
|
|
||||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
|
||||||
openaiApiKey: process.env.OPENAI_API_KEY,
|
|
||||||
};
|
|
||||||
|
|
||||||
// If both are set in env, use them
|
|
||||||
if (envCreds.anthropicApiKey && envCreds.openaiApiKey) {
|
|
||||||
return envCreds;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to extract using sandbox-agent CLI
|
|
||||||
try {
|
|
||||||
const binaryPath = join(ROOT_DIR, "target/release/sandbox-agent");
|
|
||||||
const debugBinaryPath = join(ROOT_DIR, "target/debug/sandbox-agent");
|
|
||||||
const binary = existsSync(binaryPath) ? binaryPath : existsSync(debugBinaryPath) ? debugBinaryPath : null;
|
|
||||||
|
|
||||||
if (binary) {
|
|
||||||
const output = execSync(`${binary} credentials extract-env --export`, {
|
|
||||||
encoding: "utf-8",
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse export statements: export KEY="value"
|
|
||||||
for (const line of output.split("\n")) {
|
|
||||||
const match = line.match(/^export (\w+)="(.*)"/);
|
|
||||||
if (match) {
|
|
||||||
const [, key, value] = match;
|
|
||||||
if (key === "ANTHROPIC_API_KEY" && !envCreds.anthropicApiKey) {
|
|
||||||
envCreds.anthropicApiKey = value;
|
|
||||||
} else if (key === "OPENAI_API_KEY" && !envCreds.openaiApiKey) {
|
|
||||||
envCreds.openaiApiKey = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.debug(`Extracted credentials via sandbox-agent CLI`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
log.debug(`Failed to extract credentials via CLI: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return envCreds;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAnthropicApiKey(): string | undefined {
|
|
||||||
return extractCredentials().anthropicApiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOpenAiApiKey(): string | undefined {
|
|
||||||
return extractCredentials().openaiApiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build sandbox-agent
|
|
||||||
async function buildSandboxAgent(): Promise<string> {
|
|
||||||
log.section("Building sandbox-agent");
|
|
||||||
|
|
||||||
if (useRelease) {
|
|
||||||
log.info("Using pre-built release from releases.rivet.dev");
|
|
||||||
return "RELEASE";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Binary is in workspace root target dir, not server target dir
|
|
||||||
const binaryPath = join(ROOT_DIR, "target/release/sandbox-agent");
|
|
||||||
|
|
||||||
if (skipBuild) {
|
|
||||||
if (!existsSync(binaryPath)) {
|
|
||||||
throw new Error(`Binary not found at ${binaryPath}. Run without --skip-build.`);
|
|
||||||
}
|
|
||||||
log.info(`Using existing binary: ${binaryPath}`);
|
|
||||||
return binaryPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Running cargo build --release...");
|
|
||||||
try {
|
|
||||||
execSync("cargo build --release -p sandbox-agent", {
|
|
||||||
cwd: ROOT_DIR,
|
|
||||||
stdio: verbose ? "inherit" : "pipe",
|
|
||||||
});
|
|
||||||
log.success(`Built: ${binaryPath}`);
|
|
||||||
return binaryPath;
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(`Build failed: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider interface
|
|
||||||
interface SandboxProvider {
|
|
||||||
name: string;
|
|
||||||
requiredEnv: string[];
|
|
||||||
create(opts: { envVars: Record<string, string> }): Promise<Sandbox>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Sandbox {
|
|
||||||
id: string;
|
|
||||||
exec(cmd: string): Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
|
||||||
upload(localPath: string, remotePath: string): Promise<void>;
|
|
||||||
getBaseUrl(port: number): Promise<string>;
|
|
||||||
cleanup(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Docker provider
|
|
||||||
// Uses Ubuntu because Claude Code and sandbox-agent are glibc binaries
|
|
||||||
const dockerProvider: SandboxProvider = {
|
|
||||||
name: "docker",
|
|
||||||
requiredEnv: [],
|
|
||||||
async create({ envVars }) {
|
|
||||||
const id = `sandbox-test-${Date.now()}`;
|
|
||||||
const envArgs = Object.entries(envVars)
|
|
||||||
.map(([k, v]) => `-e ${k}=${v}`)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
log.info(`Creating Docker container: ${id}`);
|
|
||||||
execSync(`docker run -d --name ${id} ${envArgs} -p 0:3000 ubuntu:22.04 tail -f /dev/null`, { stdio: verbose ? "inherit" : "pipe" });
|
|
||||||
|
|
||||||
// Install dependencies
|
|
||||||
execSync(`docker exec ${id} sh -c "apt-get update && apt-get install -y curl ca-certificates"`, {
|
|
||||||
stdio: verbose ? "inherit" : "pipe",
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
async exec(cmd) {
|
|
||||||
try {
|
|
||||||
const stdout = execSync(`docker exec ${id} sh -c "${cmd.replace(/"/g, '\\"')}"`, {
|
|
||||||
encoding: "utf-8",
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
return { stdout, stderr: "", exitCode: 0 };
|
|
||||||
} catch (err: any) {
|
|
||||||
return { stdout: err.stdout || "", stderr: err.stderr || "", exitCode: err.status || 1 };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async upload(localPath, remotePath) {
|
|
||||||
execSync(`docker cp "${localPath}" ${id}:${remotePath}`, { stdio: verbose ? "inherit" : "pipe" });
|
|
||||||
execSync(`docker exec ${id} chmod +x ${remotePath}`, { stdio: verbose ? "inherit" : "pipe" });
|
|
||||||
},
|
|
||||||
async getBaseUrl(port) {
|
|
||||||
const portMapping = execSync(`docker port ${id} ${port}`, { encoding: "utf-8" }).trim();
|
|
||||||
const hostPort = portMapping.split(":").pop();
|
|
||||||
return `http://localhost:${hostPort}`;
|
|
||||||
},
|
|
||||||
async cleanup() {
|
|
||||||
log.info(`Cleaning up container: ${id}`);
|
|
||||||
execSync(`docker rm -f ${id}`, { stdio: "pipe" });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Daytona provider
|
|
||||||
const daytonaProvider: SandboxProvider = {
|
|
||||||
name: "daytona",
|
|
||||||
requiredEnv: ["DAYTONA_API_KEY"],
|
|
||||||
async create({ envVars }) {
|
|
||||||
const { Daytona } = await import("@daytonaio/sdk");
|
|
||||||
const daytona = new Daytona();
|
|
||||||
|
|
||||||
log.info("Creating Daytona sandbox...");
|
|
||||||
// NOTE: Tier 1/2 sandboxes have restricted network that cannot be overridden
|
|
||||||
// networkAllowList requires CIDR notation (IP ranges), not domain names
|
|
||||||
const sandbox = await daytona.create({
|
|
||||||
image: "ubuntu:22.04",
|
|
||||||
envVars,
|
|
||||||
});
|
|
||||||
const id = sandbox.id;
|
|
||||||
|
|
||||||
// Install curl
|
|
||||||
await sandbox.process.executeCommand("apt-get update && apt-get install -y curl ca-certificates");
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
async exec(cmd) {
|
|
||||||
const result = await sandbox.process.executeCommand(cmd);
|
|
||||||
// Daytona SDK returns: { exitCode, result: string, artifacts: { stdout: string } }
|
|
||||||
return {
|
|
||||||
stdout: result.result || "",
|
|
||||||
stderr: "",
|
|
||||||
exitCode: result.exitCode ?? 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async upload(localPath, remotePath) {
|
|
||||||
const content = readFileSync(localPath);
|
|
||||||
// Daytona SDK signature: uploadFile(Buffer, remotePath)
|
|
||||||
await sandbox.fs.uploadFile(content, remotePath);
|
|
||||||
await sandbox.process.executeCommand(`chmod +x ${remotePath}`);
|
|
||||||
},
|
|
||||||
async getBaseUrl(port) {
|
|
||||||
const preview = await sandbox.getSignedPreviewUrl(port, 4 * 60 * 60);
|
|
||||||
return preview.url;
|
|
||||||
},
|
|
||||||
async cleanup() {
|
|
||||||
log.info(`Cleaning up Daytona sandbox: ${id}`);
|
|
||||||
await sandbox.delete(60);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// E2B provider
|
|
||||||
const e2bProvider: SandboxProvider = {
|
|
||||||
name: "e2b",
|
|
||||||
requiredEnv: ["E2B_API_KEY"],
|
|
||||||
async create({ envVars }) {
|
|
||||||
const { Sandbox } = await import("@e2b/code-interpreter");
|
|
||||||
|
|
||||||
log.info("Creating E2B sandbox...");
|
|
||||||
let sandbox;
|
|
||||||
try {
|
|
||||||
sandbox = await Sandbox.create({
|
|
||||||
allowInternetAccess: true,
|
|
||||||
envs: envVars,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
log.error(`E2B sandbox creation failed: ${err.message || err}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const id = sandbox.sandboxId;
|
|
||||||
|
|
||||||
// Install curl (E2B uses Debian which has glibc, sandbox-agent will auto-detect)
|
|
||||||
const installResult = await sandbox.commands.run("sudo apt-get update && sudo apt-get install -y curl ca-certificates");
|
|
||||||
log.debug(`Install output: ${installResult.stdout} ${installResult.stderr}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
async exec(cmd) {
|
|
||||||
const result = await sandbox.commands.run(cmd);
|
|
||||||
return {
|
|
||||||
stdout: result.stdout || "",
|
|
||||||
stderr: result.stderr || "",
|
|
||||||
exitCode: result.exitCode,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async upload(localPath, remotePath) {
|
|
||||||
const content = readFileSync(localPath);
|
|
||||||
await sandbox.files.write(remotePath, content);
|
|
||||||
await sandbox.commands.run(`chmod +x ${remotePath}`);
|
|
||||||
},
|
|
||||||
async getBaseUrl(port) {
|
|
||||||
return `https://${sandbox.getHost(port)}`;
|
|
||||||
},
|
|
||||||
async cleanup() {
|
|
||||||
log.info(`Cleaning up E2B sandbox: ${id}`);
|
|
||||||
await sandbox.kill();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get provider
|
|
||||||
function getProvider(name: string): SandboxProvider {
|
|
||||||
switch (name) {
|
|
||||||
case "docker":
|
|
||||||
return dockerProvider;
|
|
||||||
case "daytona":
|
|
||||||
return daytonaProvider;
|
|
||||||
case "e2b":
|
|
||||||
return e2bProvider;
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown provider: ${name}. Available: docker, daytona, e2b`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install sandbox-agent in sandbox
|
|
||||||
async function installSandboxAgent(sandbox: Sandbox, binaryPath: string): Promise<void> {
|
|
||||||
log.section("Installing sandbox-agent");
|
|
||||||
|
|
||||||
if (binaryPath === "RELEASE") {
|
|
||||||
log.info("Installing from releases.rivet.dev...");
|
|
||||||
const result = await sandbox.exec("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.5.x/install.sh | sh");
|
|
||||||
log.debug(`Install output: ${result.stdout}`);
|
|
||||||
if (result.exitCode !== 0) {
|
|
||||||
throw new Error(`Install failed: ${result.stderr}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.info(`Uploading local binary: ${binaryPath}`);
|
|
||||||
await sandbox.upload(binaryPath, "/usr/local/bin/sandbox-agent");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify installation
|
|
||||||
const version = await sandbox.exec("sandbox-agent --version");
|
|
||||||
log.success(`Installed: ${version.stdout.trim()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install agents
|
|
||||||
async function installAgents(sandbox: Sandbox, agents: string[]): Promise<void> {
|
|
||||||
log.section("Installing agents");
|
|
||||||
|
|
||||||
for (const agent of agents) {
|
|
||||||
log.info(`Installing ${agent}...`);
|
|
||||||
|
|
||||||
if (agent === "claude" || agent === "codex") {
|
|
||||||
const result = await sandbox.exec(`sandbox-agent install-agent ${agent}`);
|
|
||||||
if (result.exitCode !== 0) throw new Error(`Failed to install ${agent}: ${result.stderr}`);
|
|
||||||
log.success(`Installed ${agent}`);
|
|
||||||
} else if (agent === "mock") {
|
|
||||||
// Mock agent is built into sandbox-agent, no install needed
|
|
||||||
log.info("Mock agent is built-in, skipping install");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start server and check health
|
|
||||||
async function startServerAndCheckHealth(sandbox: Sandbox): Promise<string> {
|
|
||||||
log.section("Starting server");
|
|
||||||
|
|
||||||
// Start server in background
|
|
||||||
await sandbox.exec("nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &");
|
|
||||||
log.info("Server started in background");
|
|
||||||
|
|
||||||
// Get base URL
|
|
||||||
const baseUrl = await sandbox.getBaseUrl(3000);
|
|
||||||
log.info(`Base URL: ${baseUrl}`);
|
|
||||||
|
|
||||||
// Wait for health
|
|
||||||
log.info("Waiting for health check...");
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${baseUrl}/v1/health`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.status === "ok") {
|
|
||||||
log.success("Health check passed!");
|
|
||||||
return baseUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show logs on failure
|
|
||||||
const logs = await sandbox.exec("cat /tmp/sandbox-agent.log");
|
|
||||||
log.error("Server logs:\n" + logs.stdout);
|
|
||||||
throw new Error("Health check failed after 30 seconds");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send a message and wait for response, auto-approving permissions
|
|
||||||
// Returns the response text
|
|
||||||
async function sendMessage(baseUrl: string, sessionId: string, message: string): Promise<string> {
|
|
||||||
log.info(`Sending message: "${message.slice(0, 60)}${message.length > 60 ? "..." : ""}"`);
|
|
||||||
const msgRes = await fetch(`${baseUrl}/v1/sessions/${sessionId}/messages/stream`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ message }),
|
|
||||||
});
|
|
||||||
if (!msgRes.ok || !msgRes.body) {
|
|
||||||
throw new Error(`Failed to send message: ${await msgRes.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process SSE stream
|
|
||||||
const reader = msgRes.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
let responseText = "";
|
|
||||||
let receivedText = false;
|
|
||||||
let hasError = false;
|
|
||||||
let errorMessage = "";
|
|
||||||
let pendingPermission: string | null = null;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split("\n");
|
|
||||||
buffer = lines.pop() || "";
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.startsWith("data: ")) continue;
|
|
||||||
const data = line.slice(6);
|
|
||||||
if (data === "[DONE]") continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const event = JSON.parse(data);
|
|
||||||
log.debug(`Event: ${event.type}`);
|
|
||||||
|
|
||||||
if (event.type === "item.delta") {
|
|
||||||
const delta = event.data?.delta;
|
|
||||||
const text = typeof delta === "string" ? delta : delta?.text || "";
|
|
||||||
if (text) {
|
|
||||||
if (!receivedText) {
|
|
||||||
log.info("Receiving response...");
|
|
||||||
receivedText = true;
|
|
||||||
}
|
|
||||||
process.stdout.write(text);
|
|
||||||
responseText += text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle permission requests - auto-approve
|
|
||||||
if (event.type === "permission.requested") {
|
|
||||||
const permissionId = event.data?.permission_id;
|
|
||||||
if (permissionId) {
|
|
||||||
pendingPermission = permissionId;
|
|
||||||
log.info(`Permission requested (${permissionId}), auto-approving...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "error") {
|
|
||||||
hasError = true;
|
|
||||||
errorMessage = event.data?.message || JSON.stringify(event.data);
|
|
||||||
log.error(`Error event: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "agent.unparsed") {
|
|
||||||
hasError = true;
|
|
||||||
errorMessage = `Agent unparsed: ${JSON.stringify(event.data)}`;
|
|
||||||
log.error(errorMessage);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have a pending permission, approve it
|
|
||||||
if (pendingPermission) {
|
|
||||||
const permId = pendingPermission;
|
|
||||||
pendingPermission = null;
|
|
||||||
try {
|
|
||||||
const approveRes = await fetch(`${baseUrl}/v1/sessions/${sessionId}/permissions/${permId}/reply`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ reply: "once" }),
|
|
||||||
});
|
|
||||||
if (approveRes.ok) {
|
|
||||||
log.success(`Permission ${permId} approved`);
|
|
||||||
} else {
|
|
||||||
log.warn(`Failed to approve permission: ${await approveRes.text()}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(`Error approving permission: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (receivedText) {
|
|
||||||
console.log(); // newline after response
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasError) {
|
|
||||||
throw new Error(`Agent returned error: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseText;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test agent interaction
|
|
||||||
async function testAgent(baseUrl: string, agent: string, message: string): Promise<void> {
|
|
||||||
log.section(`Testing ${agent} agent`);
|
|
||||||
|
|
||||||
const sessionId = crypto.randomUUID();
|
|
||||||
|
|
||||||
// Create session
|
|
||||||
log.info(`Creating session ${sessionId}...`);
|
|
||||||
const createRes = await fetch(`${baseUrl}/v1/sessions/${sessionId}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ agent }),
|
|
||||||
});
|
|
||||||
if (!createRes.ok) {
|
|
||||||
throw new Error(`Failed to create session: ${await createRes.text()}`);
|
|
||||||
}
|
|
||||||
log.success("Session created");
|
|
||||||
|
|
||||||
const response = await sendMessage(baseUrl, sessionId, message);
|
|
||||||
if (!response) {
|
|
||||||
throw new Error("No response received from agent");
|
|
||||||
}
|
|
||||||
log.success("Received response from agent");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that agent can actually modify files and run commands
|
|
||||||
async function testAgentActions(baseUrl: string, agent: string, sandbox: Sandbox): Promise<void> {
|
|
||||||
log.section(`Testing ${agent} agent actions (file + command)`);
|
|
||||||
|
|
||||||
const sessionId = crypto.randomUUID();
|
|
||||||
const testFile = "/tmp/sandbox-test-file.txt";
|
|
||||||
const expectedContent = "Hello from sandbox test!";
|
|
||||||
|
|
||||||
// For Claude running as root in containers, we must use default permission mode
|
|
||||||
// and handle permissions via the API (bypass mode is not supported as root).
|
|
||||||
// For other agents, we can use bypass mode.
|
|
||||||
const permissionMode = agent === "claude" ? "default" : "bypass";
|
|
||||||
log.info(`Creating session ${sessionId} with permissionMode=${permissionMode}...`);
|
|
||||||
const createRes = await fetch(`${baseUrl}/v1/sessions/${sessionId}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ agent, permissionMode }),
|
|
||||||
});
|
|
||||||
if (!createRes.ok) {
|
|
||||||
throw new Error(`Failed to create session: ${await createRes.text()}`);
|
|
||||||
}
|
|
||||||
log.success("Session created");
|
|
||||||
|
|
||||||
// Ask agent to create a file
|
|
||||||
const fileMessage = `Create a file at ${testFile} with exactly this content (no quotes, no extra text): ${expectedContent}`;
|
|
||||||
await sendMessage(baseUrl, sessionId, fileMessage);
|
|
||||||
|
|
||||||
// Wait for agent to complete action after permission approval
|
|
||||||
log.info("Waiting for agent to complete action...");
|
|
||||||
await new Promise((r) => setTimeout(r, 5000));
|
|
||||||
|
|
||||||
// Verify file was created
|
|
||||||
log.info("Verifying file was created...");
|
|
||||||
const fileCheck = await sandbox.exec(`cat ${testFile} 2>&1`);
|
|
||||||
if (fileCheck.exitCode !== 0) {
|
|
||||||
throw new Error(`File was not created: ${fileCheck.stderr || fileCheck.stdout}`);
|
|
||||||
}
|
|
||||||
if (!fileCheck.stdout.includes("Hello from sandbox test")) {
|
|
||||||
throw new Error(`File content mismatch. Expected "${expectedContent}", got "${fileCheck.stdout.trim()}"`);
|
|
||||||
}
|
|
||||||
log.success(`File created with correct content: "${fileCheck.stdout.trim()}"`);
|
|
||||||
|
|
||||||
// Ask agent to run a command and create output
|
|
||||||
const cmdMessage = `Run this command and tell me the output: echo "command-test-$(date +%s)" > /tmp/cmd-output.txt && cat /tmp/cmd-output.txt`;
|
|
||||||
await sendMessage(baseUrl, sessionId, cmdMessage);
|
|
||||||
|
|
||||||
// Verify command was executed
|
|
||||||
log.info("Verifying command was executed...");
|
|
||||||
const cmdCheck = await sandbox.exec("cat /tmp/cmd-output.txt 2>&1");
|
|
||||||
if (cmdCheck.exitCode !== 0) {
|
|
||||||
throw new Error(`Command output file not found: ${cmdCheck.stderr || cmdCheck.stdout}`);
|
|
||||||
}
|
|
||||||
if (!cmdCheck.stdout.includes("command-test-")) {
|
|
||||||
throw new Error(`Command output mismatch. Expected "command-test-*", got "${cmdCheck.stdout.trim()}"`);
|
|
||||||
}
|
|
||||||
log.success(`Command executed successfully: "${cmdCheck.stdout.trim()}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check environment diagnostics
|
|
||||||
async function checkEnvironment(sandbox: Sandbox): Promise<void> {
|
|
||||||
log.section("Environment diagnostics");
|
|
||||||
|
|
||||||
const checks = [
|
|
||||||
{ name: "Environment variables", cmd: "env | grep -E 'ANTHROPIC|OPENAI|CLAUDE|CODEX' | sed 's/=.*/=<set>/'" },
|
|
||||||
// Check both /root (Alpine) and /home/user (E2B/Debian) paths
|
|
||||||
{
|
|
||||||
name: "Agent binaries",
|
|
||||||
cmd: "ls -la ~/.local/share/sandbox-agent/bin/ 2>/dev/null || ls -la /root/.local/share/sandbox-agent/bin/ 2>/dev/null || ls -la /home/user/.local/share/sandbox-agent/bin/ 2>/dev/null || echo 'No agents installed'",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Claude version",
|
|
||||||
cmd: "~/.local/share/sandbox-agent/bin/claude --version 2>&1 || /root/.local/share/sandbox-agent/bin/claude --version 2>&1 || echo 'Claude not installed'",
|
|
||||||
},
|
|
||||||
{ name: "sandbox-agent version", cmd: "sandbox-agent --version 2>/dev/null || echo 'Not installed'" },
|
|
||||||
{ name: "Server process", cmd: "pgrep -a sandbox-agent 2>/dev/null || ps aux | grep sandbox-agent | grep -v grep || echo 'Not running'" },
|
|
||||||
{ name: "Server logs (last 50 lines)", cmd: "tail -50 /tmp/sandbox-agent.log 2>/dev/null || echo 'No logs'" },
|
|
||||||
{
|
|
||||||
name: "Network: api.anthropic.com",
|
|
||||||
cmd: "curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 https://api.anthropic.com/v1/messages 2>&1 || echo 'UNREACHABLE'",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Network: api.openai.com",
|
|
||||||
cmd: "curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 https://api.openai.com/v1/models 2>&1 || echo 'UNREACHABLE'",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { name, cmd } of checks) {
|
|
||||||
const result = await sandbox.exec(cmd);
|
|
||||||
console.log(`\n\x1b[1m${name}:\x1b[0m`);
|
|
||||||
console.log(result.stdout || "(empty)");
|
|
||||||
if (result.stderr) console.log(`stderr: ${result.stderr}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main
|
|
||||||
async function main() {
|
|
||||||
log.section(`Sandbox Testing (provider: ${provider})`);
|
|
||||||
|
|
||||||
// Check credentials
|
|
||||||
const anthropicKey = getAnthropicApiKey();
|
|
||||||
const openaiKey = getOpenAiApiKey();
|
|
||||||
|
|
||||||
log.info(`Anthropic API key: ${anthropicKey ? "found" : "not found"}`);
|
|
||||||
log.info(`OpenAI API key: ${openaiKey ? "found" : "not found"}`);
|
|
||||||
|
|
||||||
// Determine which agents to test
|
|
||||||
let agents: string[];
|
|
||||||
if (agentArg) {
|
|
||||||
agents = [agentArg];
|
|
||||||
} else if (anthropicKey) {
|
|
||||||
agents = ["claude"];
|
|
||||||
} else if (openaiKey) {
|
|
||||||
agents = ["codex"];
|
|
||||||
} else {
|
|
||||||
agents = ["mock"];
|
|
||||||
log.warn("No API keys found, using mock agent only");
|
|
||||||
}
|
|
||||||
log.info(`Agents to test: ${agents.join(", ")}`);
|
|
||||||
|
|
||||||
// Get provider
|
|
||||||
const prov = getProvider(provider);
|
|
||||||
|
|
||||||
// Check required env vars
|
|
||||||
for (const envVar of prov.requiredEnv) {
|
|
||||||
if (!process.env[envVar]) {
|
|
||||||
throw new Error(`Missing required environment variable: ${envVar}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build
|
|
||||||
const binaryPath = await buildSandboxAgent();
|
|
||||||
|
|
||||||
// Create sandbox
|
|
||||||
log.section(`Creating ${prov.name} sandbox`);
|
|
||||||
const envVars: Record<string, string> = {};
|
|
||||||
if (anthropicKey) envVars.ANTHROPIC_API_KEY = anthropicKey;
|
|
||||||
if (openaiKey) envVars.OPENAI_API_KEY = openaiKey;
|
|
||||||
|
|
||||||
const sandbox = await prov.create({ envVars });
|
|
||||||
log.success(`Created sandbox: ${sandbox.id}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Install sandbox-agent
|
|
||||||
await installSandboxAgent(sandbox, binaryPath);
|
|
||||||
|
|
||||||
// Install agents (unless --skip-agent-install to test on-demand install like Daytona example)
|
|
||||||
if (skipAgentInstall) {
|
|
||||||
log.info("Skipping agent pre-install (testing on-demand installation)");
|
|
||||||
} else {
|
|
||||||
await installAgents(sandbox, agents);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check environment
|
|
||||||
await checkEnvironment(sandbox);
|
|
||||||
|
|
||||||
// Start server and check health
|
|
||||||
const baseUrl = await startServerAndCheckHealth(sandbox);
|
|
||||||
|
|
||||||
// Test each agent
|
|
||||||
for (const agent of agents) {
|
|
||||||
// Basic response test
|
|
||||||
const message = agent === "mock" ? "hello" : "Say hello in 10 words or less";
|
|
||||||
await testAgent(baseUrl, agent, message);
|
|
||||||
|
|
||||||
// For real agents, also test file/command actions with permission handling.
|
|
||||||
// Claude uses default permission mode and we auto-approve via API.
|
|
||||||
// Other agents can use bypass mode.
|
|
||||||
if (agent !== "mock") {
|
|
||||||
await testAgentActions(baseUrl, agent, sandbox);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.section("All tests passed!");
|
|
||||||
|
|
||||||
if (keepAlive) {
|
|
||||||
log.info(`Sandbox ${sandbox.id} is still running. Press Ctrl+C to cleanup.`);
|
|
||||||
log.info(`Base URL: ${await sandbox.getBaseUrl(3000)}`);
|
|
||||||
await new Promise(() => {}); // Wait forever
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
log.error(`Test failed: ${err}`);
|
|
||||||
|
|
||||||
// Show diagnostics on failure
|
|
||||||
try {
|
|
||||||
await checkEnvironment(sandbox);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (!keepAlive) {
|
|
||||||
await sandbox.cleanup();
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!keepAlive) {
|
|
||||||
await sandbox.cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
log.error(err.message || err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -46,12 +46,17 @@
|
||||||
"./computesdk": {
|
"./computesdk": {
|
||||||
"types": "./dist/providers/computesdk.d.ts",
|
"types": "./dist/providers/computesdk.d.ts",
|
||||||
"import": "./dist/providers/computesdk.js"
|
"import": "./dist/providers/computesdk.js"
|
||||||
|
},
|
||||||
|
"./sprites": {
|
||||||
|
"types": "./dist/providers/sprites.d.ts",
|
||||||
|
"import": "./dist/providers/sprites.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@cloudflare/sandbox": ">=0.1.0",
|
"@cloudflare/sandbox": ">=0.1.0",
|
||||||
"@daytonaio/sdk": ">=0.12.0",
|
"@daytonaio/sdk": ">=0.12.0",
|
||||||
"@e2b/code-interpreter": ">=1.0.0",
|
"@e2b/code-interpreter": ">=1.0.0",
|
||||||
|
"@fly/sprites": ">=0.0.1",
|
||||||
"@vercel/sandbox": ">=0.1.0",
|
"@vercel/sandbox": ">=0.1.0",
|
||||||
"dockerode": ">=4.0.0",
|
"dockerode": ">=4.0.0",
|
||||||
"get-port": ">=7.0.0",
|
"get-port": ">=7.0.0",
|
||||||
|
|
@ -68,6 +73,9 @@
|
||||||
"@e2b/code-interpreter": {
|
"@e2b/code-interpreter": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"@fly/sprites": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"@vercel/sandbox": {
|
"@vercel/sandbox": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
|
@ -104,6 +112,7 @@
|
||||||
"@cloudflare/sandbox": ">=0.1.0",
|
"@cloudflare/sandbox": ">=0.1.0",
|
||||||
"@daytonaio/sdk": ">=0.12.0",
|
"@daytonaio/sdk": ">=0.12.0",
|
||||||
"@e2b/code-interpreter": ">=1.0.0",
|
"@e2b/code-interpreter": ">=1.0.0",
|
||||||
|
"@fly/sprites": ">=0.0.1",
|
||||||
"@types/dockerode": "^4.0.0",
|
"@types/dockerode": "^4.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
|
|
||||||
|
|
@ -147,3 +147,9 @@ export type {
|
||||||
SandboxAgentSpawnLogMode,
|
SandboxAgentSpawnLogMode,
|
||||||
SandboxAgentSpawnOptions,
|
SandboxAgentSpawnOptions,
|
||||||
} from "./spawn.ts";
|
} from "./spawn.ts";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
SpritesProviderOptions,
|
||||||
|
SpritesCreateOverrides,
|
||||||
|
SpritesClientOverrides,
|
||||||
|
} from "./providers/sprites.ts";
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { DEFAULT_SANDBOX_AGENT_IMAGE, buildServerStartCommand } from "./shared.t
|
||||||
|
|
||||||
const DEFAULT_AGENT_PORT = 3000;
|
const DEFAULT_AGENT_PORT = 3000;
|
||||||
const DEFAULT_PREVIEW_TTL_SECONDS = 4 * 60 * 60;
|
const DEFAULT_PREVIEW_TTL_SECONDS = 4 * 60 * 60;
|
||||||
|
const DEFAULT_CWD = "/home/sandbox";
|
||||||
|
|
||||||
type DaytonaCreateParams = NonNullable<Parameters<Daytona["create"]>[0]>;
|
type DaytonaCreateParams = NonNullable<Parameters<Daytona["create"]>[0]>;
|
||||||
|
|
||||||
|
|
@ -13,6 +14,7 @@ export interface DaytonaProviderOptions {
|
||||||
create?: DaytonaCreateOverrides | (() => DaytonaCreateOverrides | Promise<DaytonaCreateOverrides>);
|
create?: DaytonaCreateOverrides | (() => DaytonaCreateOverrides | Promise<DaytonaCreateOverrides>);
|
||||||
image?: DaytonaCreateParams["image"];
|
image?: DaytonaCreateParams["image"];
|
||||||
agentPort?: number;
|
agentPort?: number;
|
||||||
|
cwd?: string;
|
||||||
previewTtlSeconds?: number;
|
previewTtlSeconds?: number;
|
||||||
deleteTimeoutSeconds?: number;
|
deleteTimeoutSeconds?: number;
|
||||||
}
|
}
|
||||||
|
|
@ -26,12 +28,13 @@ async function resolveCreateOptions(value: DaytonaProviderOptions["create"]): Pr
|
||||||
export function daytona(options: DaytonaProviderOptions = {}): SandboxProvider {
|
export function daytona(options: DaytonaProviderOptions = {}): SandboxProvider {
|
||||||
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
|
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
|
||||||
const image = options.image ?? DEFAULT_SANDBOX_AGENT_IMAGE;
|
const image = options.image ?? DEFAULT_SANDBOX_AGENT_IMAGE;
|
||||||
|
const cwd = options.cwd ?? DEFAULT_CWD;
|
||||||
const previewTtlSeconds = options.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
|
const previewTtlSeconds = options.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
|
||||||
const client = new Daytona();
|
const client = new Daytona();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "daytona",
|
name: "daytona",
|
||||||
defaultCwd: "/home/daytona",
|
defaultCwd: cwd,
|
||||||
async create(): Promise<string> {
|
async create(): Promise<string> {
|
||||||
const createOpts = await resolveCreateOptions(options.create);
|
const createOpts = await resolveCreateOptions(options.create);
|
||||||
const sandbox = await client.create({
|
const sandbox = await client.create({
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts";
|
||||||
|
|
||||||
const DEFAULT_AGENT_PORT = 3000;
|
const DEFAULT_AGENT_PORT = 3000;
|
||||||
const DEFAULT_TIMEOUT_MS = 3_600_000;
|
const DEFAULT_TIMEOUT_MS = 3_600_000;
|
||||||
|
const SANDBOX_AGENT_PATH_EXPORT = 'export PATH="/usr/local/bin:$HOME/.local/bin:$PATH"';
|
||||||
|
|
||||||
type E2BCreateOverrides = Omit<Partial<SandboxBetaCreateOpts>, "timeoutMs" | "autoPause">;
|
type E2BCreateOverrides = Omit<Partial<SandboxBetaCreateOpts>, "timeoutMs" | "autoPause">;
|
||||||
type E2BConnectOverrides = Omit<Partial<SandboxConnectOpts>, "timeoutMs">;
|
type E2BConnectOverrides = Omit<Partial<SandboxConnectOpts>, "timeoutMs">;
|
||||||
|
|
@ -35,6 +36,11 @@ async function resolveTemplate(value: E2BTemplateOverride | undefined): Promise<
|
||||||
return typeof value === "function" ? await value() : value;
|
return typeof value === "function" ? await value() : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildShellCommand(command: string, strict = false): string {
|
||||||
|
const strictPrefix = strict ? "set -euo pipefail; " : "";
|
||||||
|
return `bash -lc '${strictPrefix}${SANDBOX_AGENT_PATH_EXPORT}; ${command}'`;
|
||||||
|
}
|
||||||
|
|
||||||
export function e2b(options: E2BProviderOptions = {}): SandboxProvider {
|
export function e2b(options: E2BProviderOptions = {}): SandboxProvider {
|
||||||
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
|
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
|
||||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
|
@ -56,15 +62,15 @@ export function e2b(options: E2BProviderOptions = {}): SandboxProvider {
|
||||||
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
await Sandbox.betaCreate({ allowInternetAccess: true, ...restCreateOpts, timeoutMs, autoPause } as any);
|
await Sandbox.betaCreate({ allowInternetAccess: true, ...restCreateOpts, timeoutMs, autoPause } as any);
|
||||||
|
|
||||||
await sandbox.commands.run(`curl -fsSL ${SANDBOX_AGENT_INSTALL_SCRIPT} | sh`).then((r) => {
|
await sandbox.commands.run(buildShellCommand(`curl -fsSL ${SANDBOX_AGENT_INSTALL_SCRIPT} | sh`, true)).then((r) => {
|
||||||
if (r.exitCode !== 0) throw new Error(`e2b install failed:\n${r.stderr}`);
|
if (r.exitCode !== 0) throw new Error(`e2b install failed:\n${r.stderr}`);
|
||||||
});
|
});
|
||||||
for (const agent of DEFAULT_AGENTS) {
|
for (const agent of DEFAULT_AGENTS) {
|
||||||
await sandbox.commands.run(`sandbox-agent install-agent ${agent}`).then((r) => {
|
await sandbox.commands.run(buildShellCommand(`sandbox-agent install-agent ${agent}`)).then((r) => {
|
||||||
if (r.exitCode !== 0) throw new Error(`e2b agent install failed: ${agent}\n${r.stderr}`);
|
if (r.exitCode !== 0) throw new Error(`e2b agent install failed: ${agent}\n${r.stderr}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await sandbox.commands.run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, { background: true, timeoutMs: 0 });
|
await sandbox.commands.run(buildShellCommand(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`), { background: true, timeoutMs: 0 });
|
||||||
|
|
||||||
return sandbox.sandboxId;
|
return sandbox.sandboxId;
|
||||||
},
|
},
|
||||||
|
|
@ -100,7 +106,7 @@ export function e2b(options: E2BProviderOptions = {}): SandboxProvider {
|
||||||
async ensureServer(sandboxId: string): Promise<void> {
|
async ensureServer(sandboxId: string): Promise<void> {
|
||||||
const connectOpts = await resolveOptions(options.connect, sandboxId);
|
const connectOpts = await resolveOptions(options.connect, sandboxId);
|
||||||
const sandbox = await Sandbox.connect(sandboxId, { ...connectOpts, timeoutMs } as SandboxConnectOpts);
|
const sandbox = await Sandbox.connect(sandboxId, { ...connectOpts, timeoutMs } as SandboxConnectOpts);
|
||||||
await sandbox.commands.run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, { background: true, timeoutMs: 0 });
|
await sandbox.commands.run(buildShellCommand(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`), { background: true, timeoutMs: 0 });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
export const DEFAULT_SANDBOX_AGENT_IMAGE = "rivetdev/sandbox-agent:0.5.0-rc.2-full";
|
export const SANDBOX_AGENT_VERSION = "0.5.0-rc.2";
|
||||||
export const SANDBOX_AGENT_INSTALL_SCRIPT = "https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh";
|
export const DEFAULT_SANDBOX_AGENT_IMAGE = `rivetdev/sandbox-agent:${SANDBOX_AGENT_VERSION}-full`;
|
||||||
|
export const SANDBOX_AGENT_INSTALL_SCRIPT = `https://releases.rivet.dev/sandbox-agent/${SANDBOX_AGENT_VERSION}/install.sh`;
|
||||||
|
export const SANDBOX_AGENT_NPX_SPEC = `@sandbox-agent/cli@${SANDBOX_AGENT_VERSION}`;
|
||||||
export const DEFAULT_AGENTS = ["claude", "codex"] as const;
|
export const DEFAULT_AGENTS = ["claude", "codex"] as const;
|
||||||
|
|
||||||
export function buildServerStartCommand(port: number): string {
|
export function buildServerStartCommand(port: number): string {
|
||||||
|
|
|
||||||
267
sdks/typescript/src/providers/sprites.ts
Normal file
267
sdks/typescript/src/providers/sprites.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
import { ExecError, SpritesClient, type ClientOptions as SpritesClientOptions, type SpriteConfig } from "@fly/sprites";
|
||||||
|
import { SandboxDestroyedError } from "../client.ts";
|
||||||
|
import type { SandboxProvider } from "./types.ts";
|
||||||
|
import { SANDBOX_AGENT_NPX_SPEC } from "./shared.ts";
|
||||||
|
|
||||||
|
const DEFAULT_AGENT_PORT = 8080;
|
||||||
|
const DEFAULT_SERVICE_NAME = "sandbox-agent";
|
||||||
|
const DEFAULT_NAME_PREFIX = "sandbox-agent";
|
||||||
|
const DEFAULT_SERVICE_START_DURATION = "10m";
|
||||||
|
|
||||||
|
export interface SpritesCreateOverrides {
|
||||||
|
name?: string;
|
||||||
|
config?: SpriteConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpritesClientOverrides = Partial<SpritesClientOptions>;
|
||||||
|
|
||||||
|
export interface SpritesProviderOptions {
|
||||||
|
token?: string | (() => string | Promise<string>);
|
||||||
|
client?: SpritesClientOverrides | (() => SpritesClientOverrides | Promise<SpritesClientOverrides>);
|
||||||
|
create?: SpritesCreateOverrides | (() => SpritesCreateOverrides | Promise<SpritesCreateOverrides>);
|
||||||
|
env?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
|
||||||
|
installAgents?: readonly string[];
|
||||||
|
agentPort?: number;
|
||||||
|
serviceName?: string;
|
||||||
|
serviceStartDuration?: string;
|
||||||
|
namePrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpritesSandboxProvider = SandboxProvider & {
|
||||||
|
getToken(sandboxId: string): Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SpritesService {
|
||||||
|
cmd?: string;
|
||||||
|
args?: string[];
|
||||||
|
http_port?: number | null;
|
||||||
|
state?: {
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveValue<T>(value: T | (() => T | Promise<T>) | undefined, fallback: T): Promise<T> {
|
||||||
|
if (value === undefined) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (typeof value === "function") {
|
||||||
|
return await (value as () => T | Promise<T>)();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveToken(value: SpritesProviderOptions["token"]): Promise<string> {
|
||||||
|
const token = await resolveValue(value, process.env.SPRITES_API_KEY ?? process.env.SPRITE_TOKEN ?? process.env.SPRITES_TOKEN ?? "");
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("sprites provider requires a token. Set SPRITES_API_KEY (or SPRITE_TOKEN) or pass `token`.");
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSpritesClient(token: string, options: SpritesClientOverrides): SpritesClient {
|
||||||
|
return new SpritesClient(token, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSpriteName(prefix: string): string {
|
||||||
|
const suffix =
|
||||||
|
typeof globalThis.crypto?.randomUUID === "function"
|
||||||
|
? globalThis.crypto.randomUUID().slice(0, 8)
|
||||||
|
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
return `${prefix}-${suffix}`.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSpriteNotFoundError(error: unknown): boolean {
|
||||||
|
return error instanceof Error && error.message.startsWith("Sprite not found:");
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellQuote(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildServiceCommand(env: Record<string, string>, port: number): string {
|
||||||
|
const exportParts: string[] = [];
|
||||||
|
for (const [key, value] of Object.entries(env)) {
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
||||||
|
throw new Error(`sprites provider received an invalid environment variable name: ${key}`);
|
||||||
|
}
|
||||||
|
exportParts.push(`export ${key}=${shellQuote(value)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
exportParts.push(`exec npx -y ${SANDBOX_AGENT_NPX_SPEC} server --no-token --host 0.0.0.0 --port ${port}`);
|
||||||
|
return exportParts.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSpriteCommand(sprite: ReturnType<SpritesClient["sprite"]>, file: string, args: string[], env?: Record<string, string>): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await sprite.execFile(file, args, env ? { env } : undefined);
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(`sprites command failed: ${file} ${args.join(" ")}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ExecError) {
|
||||||
|
throw new Error(
|
||||||
|
`sprites command failed: ${file} ${args.join(" ")} (exit ${error.exitCode})\nstdout:\n${String(error.stdout)}\nstderr:\n${String(error.stderr)}`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchService(client: SpritesClient, spriteName: string, serviceName: string): Promise<SpritesService | undefined> {
|
||||||
|
const response = await fetch(`${client.baseURL}/v1/sprites/${encodeURIComponent(spriteName)}/services/${encodeURIComponent(serviceName)}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${client.token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`sprites service lookup failed (status ${response.status}): ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as SpritesService;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertService(client: SpritesClient, spriteName: string, serviceName: string, port: number, command: string): Promise<void> {
|
||||||
|
const existing = await fetchService(client, spriteName, serviceName);
|
||||||
|
const expectedArgs = ["-lc", command];
|
||||||
|
const isCurrent = existing?.cmd === "bash" && existing.http_port === port && JSON.stringify(existing.args ?? []) === JSON.stringify(expectedArgs);
|
||||||
|
if (isCurrent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${client.baseURL}/v1/sprites/${encodeURIComponent(spriteName)}/services/${encodeURIComponent(serviceName)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${client.token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
cmd: "bash",
|
||||||
|
args: expectedArgs,
|
||||||
|
http_port: port,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`sprites service upsert failed (status ${response.status}): ${await response.text()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServiceIfNeeded(client: SpritesClient, spriteName: string, serviceName: string, duration: string): Promise<void> {
|
||||||
|
const existing = await fetchService(client, spriteName, serviceName);
|
||||||
|
if (existing?.state?.status === "running" || existing?.state?.status === "starting") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${client.baseURL}/v1/sprites/${encodeURIComponent(spriteName)}/services/${encodeURIComponent(serviceName)}/start?duration=${encodeURIComponent(duration)}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${client.token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`sprites service start failed (status ${response.status}): ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureService(
|
||||||
|
client: SpritesClient,
|
||||||
|
spriteName: string,
|
||||||
|
serviceName: string,
|
||||||
|
port: number,
|
||||||
|
duration: string,
|
||||||
|
env: Record<string, string>,
|
||||||
|
): Promise<void> {
|
||||||
|
const command = buildServiceCommand(env, port);
|
||||||
|
await upsertService(client, spriteName, serviceName, port, command);
|
||||||
|
await startServiceIfNeeded(client, spriteName, serviceName, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sprites(options: SpritesProviderOptions = {}): SandboxProvider {
|
||||||
|
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
|
||||||
|
const serviceName = options.serviceName ?? DEFAULT_SERVICE_NAME;
|
||||||
|
const serviceStartDuration = options.serviceStartDuration ?? DEFAULT_SERVICE_START_DURATION;
|
||||||
|
const namePrefix = options.namePrefix ?? DEFAULT_NAME_PREFIX;
|
||||||
|
const installAgents = [...(options.installAgents ?? [])];
|
||||||
|
|
||||||
|
const getClient = async (): Promise<SpritesClient> => {
|
||||||
|
const token = await resolveToken(options.token);
|
||||||
|
const clientOptions = await resolveValue(options.client, {});
|
||||||
|
return createSpritesClient(token, clientOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getServerEnv = async (): Promise<Record<string, string>> => {
|
||||||
|
return await resolveValue(options.env, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const provider: SpritesSandboxProvider = {
|
||||||
|
name: "sprites",
|
||||||
|
defaultCwd: "/home/sprite",
|
||||||
|
async create(): Promise<string> {
|
||||||
|
const client = await getClient();
|
||||||
|
const createOptions = await resolveValue(options.create, {});
|
||||||
|
const spriteName = createOptions.name ?? generateSpriteName(namePrefix);
|
||||||
|
const sprite = await client.createSprite(spriteName, createOptions.config);
|
||||||
|
|
||||||
|
const serverEnv = await getServerEnv();
|
||||||
|
for (const agent of installAgents) {
|
||||||
|
await runSpriteCommand(sprite, "bash", ["-lc", `npx -y ${SANDBOX_AGENT_NPX_SPEC} install-agent ${agent}`], serverEnv);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureService(client, spriteName, serviceName, agentPort, serviceStartDuration, serverEnv);
|
||||||
|
return sprite.name;
|
||||||
|
},
|
||||||
|
async destroy(sandboxId: string): Promise<void> {
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.deleteSprite(sandboxId);
|
||||||
|
} catch (error) {
|
||||||
|
if (isSpriteNotFoundError(error) || (error instanceof Error && error.message.includes("status 404"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async reconnect(sandboxId: string): Promise<void> {
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.getSprite(sandboxId);
|
||||||
|
} catch (error) {
|
||||||
|
if (isSpriteNotFoundError(error)) {
|
||||||
|
throw new SandboxDestroyedError(sandboxId, "sprites", { cause: error });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getUrl(sandboxId: string): Promise<string> {
|
||||||
|
const client = await getClient();
|
||||||
|
const sprite = await client.getSprite(sandboxId);
|
||||||
|
const url = (sprite as { url?: string }).url;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(`sprites API did not return a URL for sprite: ${sandboxId}`);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
},
|
||||||
|
async ensureServer(sandboxId: string): Promise<void> {
|
||||||
|
const client = await getClient();
|
||||||
|
await ensureService(client, sandboxId, serviceName, agentPort, serviceStartDuration, await getServerEnv());
|
||||||
|
},
|
||||||
|
async getToken(): Promise<string> {
|
||||||
|
return await resolveToken(options.token);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,12 @@ const computeSdkMocks = vi.hoisted(() => ({
|
||||||
getById: vi.fn(),
|
getById: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const spritesMocks = vi.hoisted(() => ({
|
||||||
|
createSprite: vi.fn(),
|
||||||
|
getSprite: vi.fn(),
|
||||||
|
deleteSprite: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@e2b/code-interpreter", () => ({
|
vi.mock("@e2b/code-interpreter", () => ({
|
||||||
NotFoundError: e2bMocks.MockNotFoundError,
|
NotFoundError: e2bMocks.MockNotFoundError,
|
||||||
Sandbox: {
|
Sandbox: {
|
||||||
|
|
@ -58,9 +64,26 @@ vi.mock("computesdk", () => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@fly/sprites", () => ({
|
||||||
|
SpritesClient: class MockSpritesClient {
|
||||||
|
readonly token: string;
|
||||||
|
readonly baseURL: string;
|
||||||
|
|
||||||
|
constructor(token: string, options: { baseURL?: string } = {}) {
|
||||||
|
this.token = token;
|
||||||
|
this.baseURL = options.baseURL ?? "https://api.sprites.dev";
|
||||||
|
}
|
||||||
|
|
||||||
|
createSprite = spritesMocks.createSprite;
|
||||||
|
getSprite = spritesMocks.getSprite;
|
||||||
|
deleteSprite = spritesMocks.deleteSprite;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
import { e2b } from "../src/providers/e2b.ts";
|
import { e2b } from "../src/providers/e2b.ts";
|
||||||
import { modal } from "../src/providers/modal.ts";
|
import { modal } from "../src/providers/modal.ts";
|
||||||
import { computesdk } from "../src/providers/computesdk.ts";
|
import { computesdk } from "../src/providers/computesdk.ts";
|
||||||
|
import { sprites } from "../src/providers/sprites.ts";
|
||||||
|
|
||||||
function createFetch(): typeof fetch {
|
function createFetch(): typeof fetch {
|
||||||
return async () => new Response(null, { status: 200 });
|
return async () => new Response(null, { status: 200 });
|
||||||
|
|
@ -110,6 +133,9 @@ beforeEach(() => {
|
||||||
modalMocks.sandboxFromId.mockReset();
|
modalMocks.sandboxFromId.mockReset();
|
||||||
computeSdkMocks.create.mockReset();
|
computeSdkMocks.create.mockReset();
|
||||||
computeSdkMocks.getById.mockReset();
|
computeSdkMocks.getById.mockReset();
|
||||||
|
spritesMocks.createSprite.mockReset();
|
||||||
|
spritesMocks.getSprite.mockReset();
|
||||||
|
spritesMocks.deleteSprite.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SandboxAgent provider lifecycle", () => {
|
describe("SandboxAgent provider lifecycle", () => {
|
||||||
|
|
@ -308,7 +334,7 @@ describe("modal provider", () => {
|
||||||
|
|
||||||
expect(modalMocks.appsFromName).toHaveBeenCalledWith("custom-app", { createIfMissing: true });
|
expect(modalMocks.appsFromName).toHaveBeenCalledWith("custom-app", { createIfMissing: true });
|
||||||
expect(modalMocks.imageFromRegistry).toHaveBeenCalledWith("python:3.12-slim");
|
expect(modalMocks.imageFromRegistry).toHaveBeenCalledWith("python:3.12-slim");
|
||||||
expect(image.dockerfileCommands).toHaveBeenCalled();
|
expect(image.dockerfileCommands).not.toHaveBeenCalled();
|
||||||
expect(modalMocks.sandboxCreate).toHaveBeenCalledWith(
|
expect(modalMocks.sandboxCreate).toHaveBeenCalledWith(
|
||||||
app,
|
app,
|
||||||
image,
|
image,
|
||||||
|
|
@ -347,3 +373,139 @@ describe("computesdk provider", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sprites provider", () => {
|
||||||
|
it("creates a sprite, installs sandbox-agent, and configures the managed service", async () => {
|
||||||
|
const sprite = {
|
||||||
|
name: "sprite-1",
|
||||||
|
execFile: vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })),
|
||||||
|
};
|
||||||
|
spritesMocks.createSprite.mockResolvedValue(sprite);
|
||||||
|
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 }))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ state: { status: "stopped" } }), { status: 200 }))
|
||||||
|
.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const provider = sprites({
|
||||||
|
token: "sprite-token",
|
||||||
|
create: {
|
||||||
|
name: "sprite-1",
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
OPENAI_API_KEY: "test'value",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(provider.create()).resolves.toBe("sprite-1");
|
||||||
|
|
||||||
|
expect(spritesMocks.createSprite).toHaveBeenCalledWith("sprite-1", undefined);
|
||||||
|
expect(sprite.execFile).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const putCall = fetchMock.mock.calls.find(([url, init]) => String(url).includes("/services/sandbox-agent") && init?.method === "PUT");
|
||||||
|
expect(putCall).toBeDefined();
|
||||||
|
expect(String(putCall?.[0])).toContain("/v1/sprites/sprite-1/services/sandbox-agent");
|
||||||
|
expect(putCall?.[1]?.headers).toMatchObject({
|
||||||
|
Authorization: "Bearer sprite-token",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
const serviceRequest = JSON.parse(String(putCall?.[1]?.body)) as { args: string[] };
|
||||||
|
expect(serviceRequest.args[1]).toContain("exec npx -y @sandbox-agent/cli@0.5.0-rc.2 server --no-token --host 0.0.0.0 --port 8080");
|
||||||
|
expect(serviceRequest.args[1]).toContain("OPENAI_API_KEY='test'\\''value'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("optionally installs agents through npx when requested", async () => {
|
||||||
|
const sprite = {
|
||||||
|
name: "sprite-1",
|
||||||
|
execFile: vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })),
|
||||||
|
};
|
||||||
|
spritesMocks.createSprite.mockResolvedValue(sprite);
|
||||||
|
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 }))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ state: { status: "stopped" } }), { status: 200 }))
|
||||||
|
.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const provider = sprites({
|
||||||
|
token: "sprite-token",
|
||||||
|
create: { name: "sprite-1" },
|
||||||
|
env: { OPENAI_API_KEY: "test" },
|
||||||
|
installAgents: ["claude", "codex"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await provider.create();
|
||||||
|
|
||||||
|
expect(sprite.execFile).toHaveBeenCalledWith("bash", ["-lc", "npx -y @sandbox-agent/cli@0.5.0-rc.2 install-agent claude"], {
|
||||||
|
env: { OPENAI_API_KEY: "test" },
|
||||||
|
});
|
||||||
|
expect(sprite.execFile).toHaveBeenCalledWith("bash", ["-lc", "npx -y @sandbox-agent/cli@0.5.0-rc.2 install-agent codex"], {
|
||||||
|
env: { OPENAI_API_KEY: "test" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the sprite URL and provider token for authenticated access", async () => {
|
||||||
|
spritesMocks.getSprite.mockResolvedValue({
|
||||||
|
name: "sprite-1",
|
||||||
|
url: "https://sprite-1.sprites.app",
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider = sprites({
|
||||||
|
token: "sprite-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(provider.getUrl?.("sprite-1")).resolves.toBe("https://sprite-1.sprites.app");
|
||||||
|
await expect((provider as SandboxProvider & { getToken: (sandboxId: string) => Promise<string> }).getToken("sprite-1")).resolves.toBe("sprite-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps missing reconnect targets to SandboxDestroyedError", async () => {
|
||||||
|
spritesMocks.getSprite.mockRejectedValue(new Error("Sprite not found: missing-sprite"));
|
||||||
|
const provider = sprites({
|
||||||
|
token: "sprite-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(provider.reconnect?.("missing-sprite")).rejects.toBeInstanceOf(SandboxDestroyedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips starting the service when the desired service is already running", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
cmd: "bash",
|
||||||
|
args: ["-lc", "exec npx -y @sandbox-agent/cli@0.5.0-rc.2 server --no-token --host 0.0.0.0 --port 8080"],
|
||||||
|
http_port: 8080,
|
||||||
|
state: { status: "running" },
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
cmd: "bash",
|
||||||
|
args: ["-lc", "exec npx -y @sandbox-agent/cli@0.5.0-rc.2 server --no-token --host 0.0.0.0 --port 8080"],
|
||||||
|
http_port: 8080,
|
||||||
|
state: { status: "running" },
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const provider = sprites({
|
||||||
|
token: "sprite-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
await provider.ensureServer?.("sprite-1");
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fetchMock.mock.calls.every(([, init]) => init?.method === "GET")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { daytona } from "../src/providers/daytona.ts";
|
||||||
import { vercel } from "../src/providers/vercel.ts";
|
import { vercel } from "../src/providers/vercel.ts";
|
||||||
import { modal } from "../src/providers/modal.ts";
|
import { modal } from "../src/providers/modal.ts";
|
||||||
import { computesdk } from "../src/providers/computesdk.ts";
|
import { computesdk } from "../src/providers/computesdk.ts";
|
||||||
|
import { sprites } from "../src/providers/sprites.ts";
|
||||||
import { prepareMockAgentDataHome } from "./helpers/mock-agent.ts";
|
import { prepareMockAgentDataHome } from "./helpers/mock-agent.ts";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
@ -47,7 +48,7 @@ function isModuleAvailable(name: string): boolean {
|
||||||
_require.resolve(name);
|
_require.resolve(name);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return existsSync(resolve(__dirname, "../node_modules", ...name.split("/"), "package.json"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,6 +70,8 @@ interface ProviderEntry {
|
||||||
name: string;
|
name: string;
|
||||||
/** Human-readable reasons this provider can't run, or empty if ready. */
|
/** Human-readable reasons this provider can't run, or empty if ready. */
|
||||||
skipReasons: string[];
|
skipReasons: string[];
|
||||||
|
/** Human-readable reasons session tests can't run, or empty if ready. */
|
||||||
|
sessionSkipReasons?: string[];
|
||||||
/** Return a fresh provider instance for a single test. */
|
/** Return a fresh provider instance for a single test. */
|
||||||
createProvider: () => SandboxProvider;
|
createProvider: () => SandboxProvider;
|
||||||
/** Optional per-provider setup (e.g. create temp dirs). Returns cleanup fn. */
|
/** Optional per-provider setup (e.g. create temp dirs). Returns cleanup fn. */
|
||||||
|
|
@ -79,6 +82,8 @@ interface ProviderEntry {
|
||||||
startTimeoutMs?: number;
|
startTimeoutMs?: number;
|
||||||
/** Some providers (e.g. local) can verify the sandbox is gone after destroy. */
|
/** Some providers (e.g. local) can verify the sandbox is gone after destroy. */
|
||||||
canVerifyDestroyedSandbox?: boolean;
|
canVerifyDestroyedSandbox?: boolean;
|
||||||
|
/** Working directory to use for createSession/prompt tests. */
|
||||||
|
sessionCwd?: string;
|
||||||
/**
|
/**
|
||||||
* Whether session tests (createSession, prompt) should run.
|
* Whether session tests (createSession, prompt) should run.
|
||||||
* The mock agent only works with local provider (requires mock-acp process binary).
|
* The mock agent only works with local provider (requires mock-acp process binary).
|
||||||
|
|
@ -92,6 +97,10 @@ function missingEnvVars(...vars: string[]): string[] {
|
||||||
return missing.length > 0 ? [`missing env: ${missing.join(", ")}`] : [];
|
return missing.length > 0 ? [`missing env: ${missing.join(", ")}`] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function missingAnyEnvVars(...vars: string[]): string[] {
|
||||||
|
return vars.some((v) => Boolean(process.env[v])) ? [] : [`missing env: one of ${vars.join(", ")}`];
|
||||||
|
}
|
||||||
|
|
||||||
function missingModules(...modules: string[]): string[] {
|
function missingModules(...modules: string[]): string[] {
|
||||||
const missing = modules.filter((m) => !isModuleAvailable(m));
|
const missing = modules.filter((m) => !isModuleAvailable(m));
|
||||||
return missing.length > 0 ? [`missing npm packages: ${missing.join(", ")}`] : [];
|
return missing.length > 0 ? [`missing npm packages: ${missing.join(", ")}`] : [];
|
||||||
|
|
@ -116,6 +125,7 @@ function buildProviders(): ProviderEntry[] {
|
||||||
skipReasons: [],
|
skipReasons: [],
|
||||||
agent: "mock",
|
agent: "mock",
|
||||||
canVerifyDestroyedSandbox: true,
|
canVerifyDestroyedSandbox: true,
|
||||||
|
sessionCwd: process.cwd(),
|
||||||
sessionTestsEnabled: true,
|
sessionTestsEnabled: true,
|
||||||
setup() {
|
setup() {
|
||||||
dataHome = mkdtempSync(join(tmpdir(), "sdk-provider-local-"));
|
dataHome = mkdtempSync(join(tmpdir(), "sdk-provider-local-"));
|
||||||
|
|
@ -165,7 +175,6 @@ function buildProviders(): ProviderEntry[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- e2b ---
|
// --- e2b ---
|
||||||
// Session tests disabled: see docker comment above (ACP protocol mismatch).
|
|
||||||
{
|
{
|
||||||
entries.push({
|
entries.push({
|
||||||
name: "e2b",
|
name: "e2b",
|
||||||
|
|
@ -173,7 +182,9 @@ function buildProviders(): ProviderEntry[] {
|
||||||
agent: "claude",
|
agent: "claude",
|
||||||
startTimeoutMs: 300_000,
|
startTimeoutMs: 300_000,
|
||||||
canVerifyDestroyedSandbox: false,
|
canVerifyDestroyedSandbox: false,
|
||||||
sessionTestsEnabled: false,
|
sessionSkipReasons: missingEnvVars("ANTHROPIC_API_KEY"),
|
||||||
|
sessionCwd: "/home/user",
|
||||||
|
sessionTestsEnabled: true,
|
||||||
createProvider() {
|
createProvider() {
|
||||||
return e2b({
|
return e2b({
|
||||||
create: { envs: collectApiKeys() },
|
create: { envs: collectApiKeys() },
|
||||||
|
|
@ -183,7 +194,6 @@ function buildProviders(): ProviderEntry[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- daytona ---
|
// --- daytona ---
|
||||||
// Session tests disabled: see docker comment above (ACP protocol mismatch).
|
|
||||||
{
|
{
|
||||||
entries.push({
|
entries.push({
|
||||||
name: "daytona",
|
name: "daytona",
|
||||||
|
|
@ -191,7 +201,9 @@ function buildProviders(): ProviderEntry[] {
|
||||||
agent: "claude",
|
agent: "claude",
|
||||||
startTimeoutMs: 300_000,
|
startTimeoutMs: 300_000,
|
||||||
canVerifyDestroyedSandbox: false,
|
canVerifyDestroyedSandbox: false,
|
||||||
sessionTestsEnabled: false,
|
sessionSkipReasons: missingEnvVars("ANTHROPIC_API_KEY"),
|
||||||
|
sessionCwd: "/home/sandbox",
|
||||||
|
sessionTestsEnabled: true,
|
||||||
createProvider() {
|
createProvider() {
|
||||||
return daytona({
|
return daytona({
|
||||||
create: { envVars: collectApiKeys() },
|
create: { envVars: collectApiKeys() },
|
||||||
|
|
@ -201,7 +213,6 @@ function buildProviders(): ProviderEntry[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- vercel ---
|
// --- vercel ---
|
||||||
// Session tests disabled: see docker comment above (ACP protocol mismatch).
|
|
||||||
{
|
{
|
||||||
entries.push({
|
entries.push({
|
||||||
name: "vercel",
|
name: "vercel",
|
||||||
|
|
@ -219,7 +230,6 @@ function buildProviders(): ProviderEntry[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- modal ---
|
// --- modal ---
|
||||||
// Session tests disabled: see docker comment above (ACP protocol mismatch).
|
|
||||||
{
|
{
|
||||||
entries.push({
|
entries.push({
|
||||||
name: "modal",
|
name: "modal",
|
||||||
|
|
@ -227,9 +237,12 @@ function buildProviders(): ProviderEntry[] {
|
||||||
agent: "claude",
|
agent: "claude",
|
||||||
startTimeoutMs: 300_000,
|
startTimeoutMs: 300_000,
|
||||||
canVerifyDestroyedSandbox: false,
|
canVerifyDestroyedSandbox: false,
|
||||||
sessionTestsEnabled: false,
|
sessionSkipReasons: missingEnvVars("ANTHROPIC_API_KEY"),
|
||||||
|
sessionCwd: "/root",
|
||||||
|
sessionTestsEnabled: true,
|
||||||
createProvider() {
|
createProvider() {
|
||||||
return modal({
|
return modal({
|
||||||
|
image: process.env.SANDBOX_AGENT_MODAL_IMAGE,
|
||||||
create: { secrets: collectApiKeys() },
|
create: { secrets: collectApiKeys() },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -237,7 +250,6 @@ function buildProviders(): ProviderEntry[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- computesdk ---
|
// --- computesdk ---
|
||||||
// Session tests disabled: see docker comment above (ACP protocol mismatch).
|
|
||||||
{
|
{
|
||||||
entries.push({
|
entries.push({
|
||||||
name: "computesdk",
|
name: "computesdk",
|
||||||
|
|
@ -254,6 +266,28 @@ function buildProviders(): ProviderEntry[] {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- sprites ---
|
||||||
|
{
|
||||||
|
entries.push({
|
||||||
|
name: "sprites",
|
||||||
|
skipReasons: [...missingAnyEnvVars("SPRITES_API_KEY", "SPRITE_TOKEN", "SPRITES_TOKEN"), ...missingModules("@fly/sprites")],
|
||||||
|
agent: "claude",
|
||||||
|
startTimeoutMs: 300_000,
|
||||||
|
canVerifyDestroyedSandbox: false,
|
||||||
|
sessionSkipReasons: missingEnvVars("ANTHROPIC_API_KEY"),
|
||||||
|
sessionCwd: "/home/sprite",
|
||||||
|
sessionTestsEnabled: true,
|
||||||
|
createProvider() {
|
||||||
|
return sprites({
|
||||||
|
token: process.env.SPRITES_API_KEY ?? process.env.SPRITE_TOKEN ?? process.env.SPRITES_TOKEN,
|
||||||
|
env: collectApiKeys(),
|
||||||
|
installAgents: ["claude"],
|
||||||
|
serviceStartDuration: "10m",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -375,7 +409,7 @@ function providerSuite(entry: ProviderEntry) {
|
||||||
|
|
||||||
// -- session tests (require working agent) --
|
// -- session tests (require working agent) --
|
||||||
|
|
||||||
const sessionIt = entry.sessionTestsEnabled ? it : it.skip;
|
const sessionIt = entry.sessionTestsEnabled && (entry.sessionSkipReasons?.length ?? 0) === 0 ? it : it.skip;
|
||||||
|
|
||||||
sessionIt(
|
sessionIt(
|
||||||
"creates sessions with persisted sandboxId",
|
"creates sessions with persisted sandboxId",
|
||||||
|
|
@ -383,7 +417,7 @@ function providerSuite(entry: ProviderEntry) {
|
||||||
const persist = new InMemorySessionPersistDriver();
|
const persist = new InMemorySessionPersistDriver();
|
||||||
sdk = await SandboxAgent.start({ sandbox: entry.createProvider(), persist });
|
sdk = await SandboxAgent.start({ sandbox: entry.createProvider(), persist });
|
||||||
|
|
||||||
const session = await sdk.createSession({ agent: entry.agent });
|
const session = await sdk.createSession({ agent: entry.agent, cwd: entry.sessionCwd });
|
||||||
const record = await persist.getSession(session.id);
|
const record = await persist.getSession(session.id);
|
||||||
|
|
||||||
expect(record?.sandboxId).toBe(sdk.sandboxId);
|
expect(record?.sandboxId).toBe(sdk.sandboxId);
|
||||||
|
|
@ -396,7 +430,7 @@ function providerSuite(entry: ProviderEntry) {
|
||||||
async () => {
|
async () => {
|
||||||
sdk = await SandboxAgent.start({ sandbox: entry.createProvider() });
|
sdk = await SandboxAgent.start({ sandbox: entry.createProvider() });
|
||||||
|
|
||||||
const session = await sdk.createSession({ agent: entry.agent });
|
const session = await sdk.createSession({ agent: entry.agent, cwd: entry.sessionCwd });
|
||||||
const events: unknown[] = [];
|
const events: unknown[] = [];
|
||||||
const off = session.onEvent((event) => {
|
const off = session.onEvent((event) => {
|
||||||
events.push(event);
|
events.push(event);
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,21 @@ export default defineConfig({
|
||||||
"src/providers/cloudflare.ts",
|
"src/providers/cloudflare.ts",
|
||||||
"src/providers/modal.ts",
|
"src/providers/modal.ts",
|
||||||
"src/providers/computesdk.ts",
|
"src/providers/computesdk.ts",
|
||||||
|
"src/providers/sprites.ts",
|
||||||
],
|
],
|
||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
dts: true,
|
dts: true,
|
||||||
clean: true,
|
clean: true,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
external: ["@cloudflare/sandbox", "@daytonaio/sdk", "@e2b/code-interpreter", "@vercel/sandbox", "dockerode", "get-port", "modal", "computesdk"],
|
external: [
|
||||||
|
"@cloudflare/sandbox",
|
||||||
|
"@daytonaio/sdk",
|
||||||
|
"@e2b/code-interpreter",
|
||||||
|
"@fly/sprites",
|
||||||
|
"@vercel/sandbox",
|
||||||
|
"dockerode",
|
||||||
|
"get-port",
|
||||||
|
"modal",
|
||||||
|
"computesdk",
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -101,25 +101,23 @@ Each session tracks:
|
||||||
### Lifecycle
|
### Lifecycle
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /v1/sessions/{sessionId} Create session, auto-install agent
|
POST /v1/acp/{serverId}?agent=... initialize ACP server, auto-install agent
|
||||||
↓
|
↓
|
||||||
POST /v1/sessions/{id}/messages Spawn agent subprocess, stream output
|
POST /v1/acp/{serverId} session/new
|
||||||
POST /v1/sessions/{id}/messages/stream Post and stream a single turn
|
POST /v1/acp/{serverId} session/prompt
|
||||||
↓
|
↓
|
||||||
GET /v1/sessions/{id}/events Poll for new events (offset-based)
|
GET /v1/acp/{serverId} Subscribe to ACP SSE stream
|
||||||
GET /v1/sessions/{id}/events/sse Subscribe to SSE stream
|
|
||||||
↓
|
↓
|
||||||
POST .../questions/{id}/reply Answer agent question
|
JSON-RPC response envelopes Answer questions / reply to permissions
|
||||||
POST .../permissions/{id}/reply Grant/deny permission request
|
|
||||||
↓
|
↓
|
||||||
(agent process terminates) Session marked as ended
|
DELETE /v1/acp/{serverId} Close ACP server
|
||||||
```
|
```
|
||||||
|
|
||||||
### Event Streaming
|
### Event Streaming
|
||||||
|
|
||||||
- Events are stored in memory per session and assigned a monotonically increasing `id`.
|
- ACP envelopes are stored in memory per server and assigned a monotonically increasing SSE `id`.
|
||||||
- `/events` returns a slice of events by offset/limit.
|
- `GET /v1/acp/{serverId}` replays buffered envelopes and then streams live updates.
|
||||||
- `/events/sse` streams new events from the same offset semantics.
|
- Clients continue turns by POSTing ACP JSON-RPC requests to the same server id.
|
||||||
|
|
||||||
When a message is sent:
|
When a message is sent:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,410 +0,0 @@
|
||||||
// Pi RPC integration tests (gated via SANDBOX_TEST_PI + PATH).
|
|
||||||
include!("../common/http.rs");
|
|
||||||
|
|
||||||
fn pi_test_config() -> Option<TestAgentConfig> {
|
|
||||||
let configs = match test_agents_from_env() {
|
|
||||||
Ok(configs) => configs,
|
|
||||||
Err(err) => {
|
|
||||||
eprintln!("Skipping Pi RPC integration test: {err}");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
configs
|
|
||||||
.into_iter()
|
|
||||||
.find(|config| config.agent == AgentId::Pi)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_pi_session_with_native(app: &Router, session_id: &str) -> String {
|
|
||||||
let payload = create_pi_session(app, session_id, None, None).await;
|
|
||||||
let native_session_id = payload
|
|
||||||
.get("native_session_id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
assert!(
|
|
||||||
!native_session_id.is_empty(),
|
|
||||||
"expected native_session_id for pi session"
|
|
||||||
);
|
|
||||||
native_session_id
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_pi_session(
|
|
||||||
app: &Router,
|
|
||||||
session_id: &str,
|
|
||||||
model: Option<&str>,
|
|
||||||
variant: Option<&str>,
|
|
||||||
) -> Value {
|
|
||||||
let mut body = Map::new();
|
|
||||||
body.insert("agent".to_string(), json!("pi"));
|
|
||||||
body.insert(
|
|
||||||
"permissionMode".to_string(),
|
|
||||||
json!(test_permission_mode(AgentId::Pi)),
|
|
||||||
);
|
|
||||||
if let Some(model) = model {
|
|
||||||
body.insert("model".to_string(), json!(model));
|
|
||||||
}
|
|
||||||
if let Some(variant) = variant {
|
|
||||||
body.insert("variant".to_string(), json!(variant));
|
|
||||||
}
|
|
||||||
let (status, payload) = send_json(
|
|
||||||
app,
|
|
||||||
Method::POST,
|
|
||||||
&format!("/v1/sessions/{session_id}"),
|
|
||||||
Some(Value::Object(body)),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(status, StatusCode::OK, "create pi session");
|
|
||||||
payload
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_pi_models(app: &Router) -> Vec<Value> {
|
|
||||||
let (status, payload) = send_json(app, Method::GET, "/v1/agents/pi/models", None).await;
|
|
||||||
assert_eq!(status, StatusCode::OK, "pi models endpoint");
|
|
||||||
payload
|
|
||||||
.get("models")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn model_variant_ids(model: &Value) -> Vec<&str> {
|
|
||||||
model
|
|
||||||
.get("variants")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.map(|values| values.iter().filter_map(Value::as_str).collect::<Vec<_>>())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_strictly_increasing_sequences(events: &[Value], label: &str) {
|
|
||||||
let mut last_sequence = 0u64;
|
|
||||||
for event in events {
|
|
||||||
let sequence = event
|
|
||||||
.get("sequence")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.expect("missing sequence");
|
|
||||||
assert!(
|
|
||||||
sequence > last_sequence,
|
|
||||||
"{label}: sequence did not increase (prev {last_sequence}, next {sequence})"
|
|
||||||
);
|
|
||||||
last_sequence = sequence;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_all_events_for_session(events: &[Value], session_id: &str) {
|
|
||||||
for event in events {
|
|
||||||
let event_session_id = event
|
|
||||||
.get("session_id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
assert_eq!(
|
|
||||||
event_session_id, session_id,
|
|
||||||
"cross-session event detected in {session_id}: {event}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_item_started_ids_unique(events: &[Value], label: &str) {
|
|
||||||
let mut ids = std::collections::HashSet::new();
|
|
||||||
for event in events {
|
|
||||||
let event_type = event
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if event_type != "item.started" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(item_id) = event
|
|
||||||
.get("data")
|
|
||||||
.and_then(|data| data.get("item"))
|
|
||||||
.and_then(|item| item.get("item_id"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
ids.insert(item_id.to_string()),
|
|
||||||
"{label}: duplicate item.started id {item_id}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
||||||
async fn pi_rpc_session_and_stream() {
|
|
||||||
let Some(config) = pi_test_config() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let app = TestApp::new();
|
|
||||||
let _guard = apply_credentials(&config.credentials);
|
|
||||||
install_agent(&app.app, config.agent).await;
|
|
||||||
|
|
||||||
let session_id = "pi-rpc-session";
|
|
||||||
let _native_session_id = create_pi_session_with_native(&app.app, session_id).await;
|
|
||||||
|
|
||||||
let events = read_turn_stream_events(&app.app, session_id, Duration::from_secs(120)).await;
|
|
||||||
assert!(!events.is_empty(), "no events from pi stream");
|
|
||||||
assert!(
|
|
||||||
!events.iter().any(is_unparsed_event),
|
|
||||||
"agent.unparsed event encountered"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
should_stop(&events),
|
|
||||||
"turn stream did not reach a terminal event"
|
|
||||||
);
|
|
||||||
assert_strictly_increasing_sequences(&events, "pi_rpc_session_and_stream");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
||||||
async fn pi_variant_high_applies_for_thinking_model() {
|
|
||||||
let Some(config) = pi_test_config() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let app = TestApp::new();
|
|
||||||
let _guard = apply_credentials(&config.credentials);
|
|
||||||
install_agent(&app.app, config.agent).await;
|
|
||||||
|
|
||||||
let models = fetch_pi_models(&app.app).await;
|
|
||||||
let thinking_model = models.iter().find_map(|model| {
|
|
||||||
let model_id = model.get("id").and_then(Value::as_str)?;
|
|
||||||
let variants = model_variant_ids(model);
|
|
||||||
if variants.contains(&"high") {
|
|
||||||
Some(model_id.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let Some(model_id) = thinking_model else {
|
|
||||||
eprintln!("Skipping PI variant thinking-model test: no model advertises high");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let session_id = "pi-variant-thinking-high";
|
|
||||||
create_pi_session(&app.app, session_id, Some(&model_id), Some("high")).await;
|
|
||||||
|
|
||||||
let events = read_turn_stream_events(&app.app, session_id, Duration::from_secs(120)).await;
|
|
||||||
assert!(
|
|
||||||
!events.is_empty(),
|
|
||||||
"no events from pi thinking-variant stream"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!events.iter().any(is_unparsed_event),
|
|
||||||
"agent.unparsed event encountered for thinking-variant session"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
should_stop(&events),
|
|
||||||
"thinking-variant turn stream did not reach a terminal event"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
||||||
async fn pi_variant_high_on_non_thinking_model_uses_pi_native_clamping() {
|
|
||||||
let Some(config) = pi_test_config() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let app = TestApp::new();
|
|
||||||
let _guard = apply_credentials(&config.credentials);
|
|
||||||
install_agent(&app.app, config.agent).await;
|
|
||||||
|
|
||||||
let models = fetch_pi_models(&app.app).await;
|
|
||||||
let non_thinking_model = models.iter().find_map(|model| {
|
|
||||||
let model_id = model.get("id").and_then(Value::as_str)?;
|
|
||||||
let variants = model_variant_ids(model);
|
|
||||||
if variants == vec!["off"] {
|
|
||||||
Some(model_id.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let Some(model_id) = non_thinking_model else {
|
|
||||||
eprintln!("Skipping PI non-thinking variant test: no off-only model reported");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let session_id = "pi-variant-nonthinking-high";
|
|
||||||
create_pi_session(&app.app, session_id, Some(&model_id), Some("high")).await;
|
|
||||||
|
|
||||||
let events = read_turn_stream_events(&app.app, session_id, Duration::from_secs(120)).await;
|
|
||||||
assert!(
|
|
||||||
!events.is_empty(),
|
|
||||||
"no events from pi non-thinking variant stream"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!events.iter().any(is_unparsed_event),
|
|
||||||
"agent.unparsed event encountered for non-thinking variant session"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
should_stop(&events),
|
|
||||||
"non-thinking variant turn stream did not reach a terminal event"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
||||||
async fn pi_parallel_sessions_turns() {
|
|
||||||
let Some(config) = pi_test_config() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let app = TestApp::new();
|
|
||||||
let _guard = apply_credentials(&config.credentials);
|
|
||||||
install_agent(&app.app, config.agent).await;
|
|
||||||
|
|
||||||
let session_a = "pi-parallel-a";
|
|
||||||
let session_b = "pi-parallel-b";
|
|
||||||
create_pi_session_with_native(&app.app, session_a).await;
|
|
||||||
create_pi_session_with_native(&app.app, session_b).await;
|
|
||||||
|
|
||||||
let app_a = app.app.clone();
|
|
||||||
let app_b = app.app.clone();
|
|
||||||
let send_a = send_message(&app_a, session_a);
|
|
||||||
let send_b = send_message(&app_b, session_b);
|
|
||||||
tokio::join!(send_a, send_b);
|
|
||||||
|
|
||||||
let app_a = app.app.clone();
|
|
||||||
let app_b = app.app.clone();
|
|
||||||
let poll_a = poll_events_until(&app_a, session_a, Duration::from_secs(120));
|
|
||||||
let poll_b = poll_events_until(&app_b, session_b, Duration::from_secs(120));
|
|
||||||
let (events_a, events_b) = tokio::join!(poll_a, poll_b);
|
|
||||||
|
|
||||||
assert!(!events_a.is_empty(), "no events for session A");
|
|
||||||
assert!(!events_b.is_empty(), "no events for session B");
|
|
||||||
assert!(
|
|
||||||
should_stop(&events_a),
|
|
||||||
"session A did not reach a terminal event"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
should_stop(&events_b),
|
|
||||||
"session B did not reach a terminal event"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!events_a.iter().any(is_unparsed_event),
|
|
||||||
"session A encountered agent.unparsed"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!events_b.iter().any(is_unparsed_event),
|
|
||||||
"session B encountered agent.unparsed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
||||||
async fn pi_event_isolation() {
|
|
||||||
let Some(config) = pi_test_config() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let app = TestApp::new();
|
|
||||||
let _guard = apply_credentials(&config.credentials);
|
|
||||||
install_agent(&app.app, config.agent).await;
|
|
||||||
|
|
||||||
let session_a = "pi-isolation-a";
|
|
||||||
let session_b = "pi-isolation-b";
|
|
||||||
create_pi_session_with_native(&app.app, session_a).await;
|
|
||||||
create_pi_session_with_native(&app.app, session_b).await;
|
|
||||||
|
|
||||||
let app_a = app.app.clone();
|
|
||||||
let app_b = app.app.clone();
|
|
||||||
let send_a = send_message(&app_a, session_a);
|
|
||||||
let send_b = send_message(&app_b, session_b);
|
|
||||||
tokio::join!(send_a, send_b);
|
|
||||||
|
|
||||||
let app_a = app.app.clone();
|
|
||||||
let app_b = app.app.clone();
|
|
||||||
let poll_a = poll_events_until(&app_a, session_a, Duration::from_secs(120));
|
|
||||||
let poll_b = poll_events_until(&app_b, session_b, Duration::from_secs(120));
|
|
||||||
let (events_a, events_b) = tokio::join!(poll_a, poll_b);
|
|
||||||
|
|
||||||
assert!(should_stop(&events_a), "session A did not complete");
|
|
||||||
assert!(should_stop(&events_b), "session B did not complete");
|
|
||||||
assert_all_events_for_session(&events_a, session_a);
|
|
||||||
assert_all_events_for_session(&events_b, session_b);
|
|
||||||
assert_strictly_increasing_sequences(&events_a, "session A");
|
|
||||||
assert_strictly_increasing_sequences(&events_b, "session B");
|
|
||||||
assert_item_started_ids_unique(&events_a, "session A");
|
|
||||||
assert_item_started_ids_unique(&events_b, "session B");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
||||||
async fn pi_terminate_one_session_does_not_affect_other() {
|
|
||||||
let Some(config) = pi_test_config() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let app = TestApp::new();
|
|
||||||
let _guard = apply_credentials(&config.credentials);
|
|
||||||
install_agent(&app.app, config.agent).await;
|
|
||||||
|
|
||||||
let session_a = "pi-terminate-a";
|
|
||||||
let session_b = "pi-terminate-b";
|
|
||||||
create_pi_session_with_native(&app.app, session_a).await;
|
|
||||||
create_pi_session_with_native(&app.app, session_b).await;
|
|
||||||
|
|
||||||
let terminate_status = send_status(
|
|
||||||
&app.app,
|
|
||||||
Method::POST,
|
|
||||||
&format!("/v1/sessions/{session_a}/terminate"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(
|
|
||||||
terminate_status,
|
|
||||||
StatusCode::NO_CONTENT,
|
|
||||||
"terminate session A"
|
|
||||||
);
|
|
||||||
|
|
||||||
send_message(&app.app, session_b).await;
|
|
||||||
let events_b = poll_events_until(&app.app, session_b, Duration::from_secs(120)).await;
|
|
||||||
assert!(!events_b.is_empty(), "no events for session B");
|
|
||||||
assert!(
|
|
||||||
should_stop(&events_b),
|
|
||||||
"session B did not complete after A terminated"
|
|
||||||
);
|
|
||||||
|
|
||||||
let events_a = poll_events_until(&app.app, session_a, Duration::from_secs(10)).await;
|
|
||||||
assert!(
|
|
||||||
events_a.iter().any(|event| {
|
|
||||||
event
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|ty| ty == "session.ended")
|
|
||||||
}),
|
|
||||||
"session A missing session.ended after terminate"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
||||||
async fn pi_runtime_restart_scope() {
|
|
||||||
let Some(config) = pi_test_config() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let app = TestApp::new();
|
|
||||||
let _guard = apply_credentials(&config.credentials);
|
|
||||||
install_agent(&app.app, config.agent).await;
|
|
||||||
|
|
||||||
let session_a = "pi-restart-scope-a";
|
|
||||||
let session_b = "pi-restart-scope-b";
|
|
||||||
create_pi_session_with_native(&app.app, session_a).await;
|
|
||||||
create_pi_session_with_native(&app.app, session_b).await;
|
|
||||||
|
|
||||||
let terminate_status = send_status(
|
|
||||||
&app.app,
|
|
||||||
Method::POST,
|
|
||||||
&format!("/v1/sessions/{session_a}/terminate"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(
|
|
||||||
terminate_status,
|
|
||||||
StatusCode::NO_CONTENT,
|
|
||||||
"terminate session A to stop only its runtime"
|
|
||||||
);
|
|
||||||
|
|
||||||
send_message(&app.app, session_b).await;
|
|
||||||
let events_b = poll_events_until(&app.app, session_b, Duration::from_secs(120)).await;
|
|
||||||
assert!(
|
|
||||||
should_stop(&events_b),
|
|
||||||
"session B did not continue after A stopped"
|
|
||||||
);
|
|
||||||
assert_all_events_for_session(&events_b, session_b);
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue