diff --git a/.gitignore b/.gitignore index 34aab9c..4027660 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,4 @@ tests/tmp/ tests/.tmp/ *.log *.txt -.kode/ \ No newline at end of file +.kode/ diff --git a/README.md b/README.md index 47c3294..e262e8a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - **Long-Running & Resumable** - Seven-stage checkpoints with Safe-Fork-Point for crash recovery - **Multi-Agent Collaboration** - AgentPool, Room messaging, and task delegation - **Enterprise Persistence** - SQLite/PostgreSQL support with unified WAL -- **Cloud Sandbox** - [E2B](https://e2b.dev) integration for isolated remote code execution +- **Cloud Sandbox** - [E2B](https://e2b.dev) and OpenSandbox integration for isolated remote code execution - **Extensible Ecosystem** - MCP tools, custom Providers, Skills system ## Quick Start @@ -79,6 +79,15 @@ npm run example:getting-started # Minimal chat npm run example:agent-inbox # Event-driven inbox npm run example:approval # Tool approval workflow npm run example:room # Multi-agent collaboration +npm run example:opensandbox # OpenSandbox basic usage +``` + +OpenSandbox quick config: + +```bash +export OPEN_SANDBOX_API_KEY=... # optional (required only when auth is enabled) +export OPEN_SANDBOX_ENDPOINT=http://127.0.0.1:8080 # optional +export OPEN_SANDBOX_IMAGE=ubuntu # optional ``` ## Architecture for Scale @@ -142,6 +151,8 @@ See [docs/en/guides/architecture.md](./docs/en/guides/architecture.md) for detai | **Guides** | | | [Events](./docs/en/guides/events.md) | Three-channel event system | | [Tools](./docs/en/guides/tools.md) | Built-in tools & custom tools | +| [E2B Sandbox](./docs/en/guides/e2b-sandbox.md) | E2B cloud sandbox integration | +| [OpenSandbox](./docs/en/guides/opensandbox-sandbox.md) | OpenSandbox self-hosted sandbox integration | | [Skills](./docs/en/guides/skills.md) | Skills system for reusable prompts | | [Providers](./docs/en/guides/providers.md) | Model provider configuration | | [Database](./docs/en/guides/database.md) | SQLite/PostgreSQL persistence | diff --git a/README.zh-CN.md b/README.zh-CN.md index 912d1b8..3c95854 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,7 +10,7 @@ - **长时运行与恢复** - 七段断点机制,支持 Safe-Fork-Point 崩溃恢复 - **多 Agent 协作** - AgentPool、Room 消息、任务委派 - **企业级持久化** - 支持 SQLite/PostgreSQL,统一 WAL 日志 -- **云端沙箱** - 集成 [E2B](https://e2b.dev),提供隔离的远程代码执行环境 +- **云端沙箱** - 集成 [E2B](https://e2b.dev) 与 OpenSandbox,提供隔离的远程代码执行环境 - **可扩展生态** - MCP 工具、自定义 Provider、Skills 系统 ## 快速开始 @@ -79,6 +79,15 @@ npm run example:getting-started # 最简对话 npm run example:agent-inbox # 事件驱动收件箱 npm run example:approval # 工具审批流程 npm run example:room # 多Agent协作 +npm run example:opensandbox # OpenSandbox 基础使用 +``` + +OpenSandbox 快速配置: + +```bash +export OPEN_SANDBOX_API_KEY=... # 可选(仅在服务开启鉴权时需要) +export OPEN_SANDBOX_ENDPOINT=http://127.0.0.1:8080 # 可选 +export OPEN_SANDBOX_IMAGE=ubuntu # 可选 ``` ## 支持的 Provider @@ -102,6 +111,8 @@ npm run example:room # 多Agent协作 | **使用指南** | | | [事件系统](./docs/zh-CN/guides/events.md) | 三通道事件系统 | | [工具系统](./docs/zh-CN/guides/tools.md) | 内置工具与自定义工具 | +| [E2B 沙箱](./docs/zh-CN/guides/e2b-sandbox.md) | E2B 云端沙箱接入 | +| [OpenSandbox 沙箱](./docs/zh-CN/guides/opensandbox-sandbox.md) | OpenSandbox 自托管沙箱接入 | | [Skills 系统](./docs/zh-CN/guides/skills.md) | Skills 可复用提示词系统 | | [Provider 配置](./docs/zh-CN/guides/providers.md) | 模型 Provider 配置 | | [数据库存储](./docs/zh-CN/guides/database.md) | SQLite/PostgreSQL 持久化 | diff --git a/docs/en/guides/opensandbox-sandbox.md b/docs/en/guides/opensandbox-sandbox.md new file mode 100644 index 0000000..3577e4c --- /dev/null +++ b/docs/en/guides/opensandbox-sandbox.md @@ -0,0 +1,185 @@ +# OpenSandbox Guide + +KODE SDK supports OpenSandbox as a sandbox backend for isolated command execution and file operations. + +--- + +## Overview + +| Feature | Description | +|---------|-------------| +| **Deployment** | Self-hosted OpenSandbox server | +| **Runtime** | Container-based isolated execution environment | +| **Lifecycle** | Create/connect/dispose by `sandboxId` | +| **Compatibility** | Works with existing `bash_*` and `fs_*` tools | + +### When to Use OpenSandbox vs E2B vs Local + +| Scenario | Recommended | +|----------|-------------| +| You need self-hosted control in your own infra | OpenSandbox | +| You want fully managed cloud sandbox | E2B | +| Local development and offline debugging | Local Sandbox | + +--- + +## Prerequisites + +1. Docker daemon is running and can pull required images. +2. OpenSandbox server is running (for example on `http://127.0.0.1:8080`). +3. If your server enables auth, prepare an API key. + +Optional environment variables: + +```bash +export OPEN_SANDBOX_API_KEY=... # optional, only when auth is enabled +export OPEN_SANDBOX_ENDPOINT=http://127.0.0.1:8080 # optional +export OPEN_SANDBOX_IMAGE=ubuntu # optional +``` + +--- + +## Quick Start + +### Create and Use a Sandbox + +```typescript +import { OpenSandbox } from '@shareai-lab/kode-sdk'; + +const sandbox = new OpenSandbox({ + kind: 'opensandbox', + apiKey: process.env.OPEN_SANDBOX_API_KEY, + endpoint: process.env.OPEN_SANDBOX_ENDPOINT, + image: process.env.OPEN_SANDBOX_IMAGE || 'ubuntu', + timeoutMs: 600_000, + execTimeoutMs: 120_000, + useServerProxy: false, + watch: { mode: 'polling', pollIntervalMs: 1000 }, + lifecycle: { disposeAction: 'kill' }, +}); + +await sandbox.init(); +console.log('sandboxId:', sandbox.getSandboxId()); + +const result = await sandbox.exec('echo "hello opensandbox"'); +console.log(result.code, result.stdout.trim()); + +await sandbox.fs.write('demo.txt', 'hello from opensandbox'); +const content = await sandbox.fs.read('demo.txt'); +console.log(content.trim()); + +await sandbox.dispose(); +``` + +--- + +## Configuration + +### OpenSandboxOptions + +```typescript +interface OpenSandboxOptions { + kind: 'opensandbox'; + apiKey?: string; + endpoint?: string; + domain?: string; + protocol?: 'http' | 'https'; + sandboxId?: string; + image?: string; + template?: string; // alias of image in current implementation + workDir?: string; // default '/workspace' + timeoutMs?: number; + execTimeoutMs?: number; + requestTimeoutSeconds?: number; + useServerProxy?: boolean; // default false + env?: Record; + metadata?: Record; + resource?: Record; + networkPolicy?: Record; + skipHealthCheck?: boolean; + readyTimeoutSeconds?: number; + healthCheckPollingInterval?: number; + watch?: { + mode?: 'native' | 'polling' | 'off'; // default 'polling' + pollIntervalMs?: number; // default 1000 + }; + lifecycle?: { + disposeAction?: 'close' | 'kill'; // default 'kill' + }; +} +``` + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `OPEN_SANDBOX_API_KEY` | Optional API key (required only when server auth is enabled) | +| `OPEN_SANDBOX_ENDPOINT` | OpenSandbox server endpoint | +| `OPEN_SANDBOX_IMAGE` | Default image when creating a new sandbox | + +--- + +## Agent Integration + +### Use Sandbox Config (recommended) + +```typescript +const agent = await Agent.create({ + templateId: 'coder', + sandbox: { + kind: 'opensandbox', + endpoint: process.env.OPEN_SANDBOX_ENDPOINT, + apiKey: process.env.OPEN_SANDBOX_API_KEY, + image: 'debian:latest', + lifecycle: { disposeAction: 'kill' }, + }, +}, deps); +``` + +When you pass sandbox config, `SandboxFactory.createAsync()` initializes OpenSandbox automatically. + +### Use Sandbox Instance + +```typescript +const sandbox = new OpenSandbox({ kind: 'opensandbox', endpoint: 'http://127.0.0.1:8080' }); +await sandbox.init(); + +const agent = await Agent.create({ templateId: 'coder', sandbox }, deps); +``` + +When you pass sandbox instance directly, call `sandbox.init()` yourself before `Agent.create()`. + +### Resume with `sandboxId` + +```typescript +const sandbox = new OpenSandbox({ kind: 'opensandbox', endpoint: 'http://127.0.0.1:8080' }); +await sandbox.init(); +const id = sandbox.getSandboxId(); + +const restored = new OpenSandbox({ + kind: 'opensandbox', + endpoint: 'http://127.0.0.1:8080', + sandboxId: id, +}); +await restored.init(); +``` + +--- + +## Watch and Lifecycle Semantics + +1. `watch.mode='native'` uses `inotifywait` in the sandbox container. +2. If `inotifywait` is unavailable or native stream exits unexpectedly, the SDK auto-falls back to polling mode. +3. `watch.mode='off'` disables file watch registration. +4. `disposeAction='kill'` performs `kill()` first, then `close()`. +5. `disposeAction='close'` only closes the connection. +6. Polling watch is level-triggered by mtime delta and may coalesce multiple writes within one polling interval. +7. Polling watch does not guarantee one callback per write operation; treat events as "file changed" hints. + +--- + +## Troubleshooting + +1. `DOCKER::SANDBOX_IMAGE_PULL_FAILED` or `DOCKER::SANDBOX_EXECD_START_FAILED`: Docker cannot pull required images (`image` and `opensandbox/execd`). +2. Verify OpenSandbox server endpoint is reachable from the SDK process. +3. If you use a proxy, verify Docker daemon proxy and OpenSandbox server network settings separately. diff --git a/docs/en/reference/api.md b/docs/en/reference/api.md index a93bdb8..1e35dc0 100644 --- a/docs/en/reference/api.md +++ b/docs/en/reference/api.md @@ -719,6 +719,39 @@ new E2BSandbox(options?: E2BSandboxOptions) --- +## OpenSandbox + +Self-hosted sandbox powered by [OpenSandbox](https://www.npmjs.com/package/@alibaba-group/opensandbox) for isolated code execution. + +### Constructor + +```typescript +new OpenSandbox(options: OpenSandboxOptions) +``` + +### Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `init()` | `async init(): Promise` | Initialize (create or connect) sandbox | +| `exec(cmd, opts?)` | `async exec(cmd: string, opts?: { timeoutMs?: number }): Promise` | Execute a command | +| `dispose()` | `async dispose(): Promise` | Dispose sandbox by lifecycle policy | +| `getSandboxId()` | `getSandboxId(): string \| undefined` | Get sandbox ID for persistence | +| `isRunning()` | `async isRunning(): Promise` | Check if sandbox is alive | +| `watchFiles(paths, listener)` | `async watchFiles(...): Promise` | Watch file changes (polling fallback supported) | +| `unwatchFiles(id)` | `unwatchFiles(id: string): void` | Stop watching | +| `getOpenSandbox()` | `getOpenSandbox(): OpenSandboxClient` | Access underlying OpenSandbox client | + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `kind` | `'opensandbox'` | Sandbox type identifier | +| `workDir` | `string` | Working directory path | +| `fs` | `SandboxFS` | File system operations | + +--- + ## E2BTemplateBuilder Static utility for building custom E2B sandbox templates. @@ -747,3 +780,5 @@ static async exists(alias: string, opts?: { apiKey?: string }): Promise - [Types Reference](./types.md) - [Events Reference](./events-reference.md) - [Guides](../guides/events.md) +- [E2B Sandbox Guide](../guides/e2b-sandbox.md) +- [OpenSandbox Guide](../guides/opensandbox-sandbox.md) diff --git a/docs/en/reference/types.md b/docs/en/reference/types.md index baf29ba..88866e1 100644 --- a/docs/en/reference/types.md +++ b/docs/en/reference/types.md @@ -443,7 +443,7 @@ interface SandboxConfig { ### SandboxKind ```typescript -type SandboxKind = 'local' | 'docker' | 'remote'; +type SandboxKind = 'local' | 'docker' | 'k8s' | 'remote' | 'vfs' | 'e2b' | 'opensandbox'; ``` --- @@ -516,6 +516,50 @@ interface E2BTemplateConfig { --- +## OpenSandbox Types + +### OpenSandboxWatchMode + +```typescript +type OpenSandboxWatchMode = 'native' | 'polling' | 'off'; +``` + +### OpenSandboxOptions + +```typescript +interface OpenSandboxOptions { + kind: 'opensandbox'; + apiKey?: string; + endpoint?: string; + domain?: string; + protocol?: 'http' | 'https'; + sandboxId?: string; + image?: string; + template?: string; + workDir?: string; + timeoutMs?: number; + execTimeoutMs?: number; + requestTimeoutSeconds?: number; + useServerProxy?: boolean; + env?: Record; + metadata?: Record; + resource?: Record; + networkPolicy?: Record; + skipHealthCheck?: boolean; + readyTimeoutSeconds?: number; + healthCheckPollingInterval?: number; + watch?: { + mode?: OpenSandboxWatchMode; + pollIntervalMs?: number; + }; + lifecycle?: { + disposeAction?: 'close' | 'kill'; + }; +} +``` + +--- + ## References - [API Reference](./api.md) diff --git a/docs/zh-CN/guides/opensandbox-sandbox.md b/docs/zh-CN/guides/opensandbox-sandbox.md new file mode 100644 index 0000000..94dcb00 --- /dev/null +++ b/docs/zh-CN/guides/opensandbox-sandbox.md @@ -0,0 +1,185 @@ +# OpenSandbox 沙箱指南 + +KODE SDK 支持 OpenSandbox 作为沙箱后端,用于隔离命令执行和文件操作。 + +--- + +## 概述 + +| 特性 | 说明 | +|------|------| +| **部署方式** | 自建 OpenSandbox 服务 | +| **运行时** | 基于容器的隔离执行环境 | +| **生命周期** | 基于 `sandboxId` 的创建/连接/销毁 | +| **兼容性** | 可直接复用 `bash_*` 与 `fs_*` 工具 | + +### OpenSandbox / E2B / Local 的选择 + +| 场景 | 推荐 | +|------|------| +| 需要在自有基础设施内自托管 | OpenSandbox | +| 需要全托管云沙箱 | E2B | +| 本地开发与离线调试 | Local Sandbox | + +--- + +## 前置条件 + +1. Docker daemon 已启动,且可拉取所需镜像。 +2. OpenSandbox server 已启动(例如 `http://127.0.0.1:8080`)。 +3. 如果服务启用了鉴权,准备 API Key。 + +可选环境变量: + +```bash +export OPEN_SANDBOX_API_KEY=... # 可选,仅服务启用鉴权时需要 +export OPEN_SANDBOX_ENDPOINT=http://127.0.0.1:8080 # 可选 +export OPEN_SANDBOX_IMAGE=ubuntu # 可选 +``` + +--- + +## 快速开始 + +### 创建并使用沙箱 + +```typescript +import { OpenSandbox } from '@shareai-lab/kode-sdk'; + +const sandbox = new OpenSandbox({ + kind: 'opensandbox', + apiKey: process.env.OPEN_SANDBOX_API_KEY, + endpoint: process.env.OPEN_SANDBOX_ENDPOINT, + image: process.env.OPEN_SANDBOX_IMAGE || 'ubuntu', + timeoutMs: 600_000, + execTimeoutMs: 120_000, + useServerProxy: false, + watch: { mode: 'polling', pollIntervalMs: 1000 }, + lifecycle: { disposeAction: 'kill' }, +}); + +await sandbox.init(); +console.log('sandboxId:', sandbox.getSandboxId()); + +const result = await sandbox.exec('echo "hello opensandbox"'); +console.log(result.code, result.stdout.trim()); + +await sandbox.fs.write('demo.txt', 'hello from opensandbox'); +const content = await sandbox.fs.read('demo.txt'); +console.log(content.trim()); + +await sandbox.dispose(); +``` + +--- + +## 配置项 + +### OpenSandboxOptions + +```typescript +interface OpenSandboxOptions { + kind: 'opensandbox'; + apiKey?: string; + endpoint?: string; + domain?: string; + protocol?: 'http' | 'https'; + sandboxId?: string; + image?: string; + template?: string; // 当前实现中与 image 同义 + workDir?: string; // 默认 '/workspace' + timeoutMs?: number; + execTimeoutMs?: number; + requestTimeoutSeconds?: number; + useServerProxy?: boolean; // 默认 false + env?: Record; + metadata?: Record; + resource?: Record; + networkPolicy?: Record; + skipHealthCheck?: boolean; + readyTimeoutSeconds?: number; + healthCheckPollingInterval?: number; + watch?: { + mode?: 'native' | 'polling' | 'off'; // 默认 'polling' + pollIntervalMs?: number; // 默认 1000 + }; + lifecycle?: { + disposeAction?: 'close' | 'kill'; // 默认 'kill' + }; +} +``` + +### 环境变量 + +| 变量 | 说明 | +|------|------| +| `OPEN_SANDBOX_API_KEY` | 可选 API Key(仅服务启用鉴权时需要) | +| `OPEN_SANDBOX_ENDPOINT` | OpenSandbox server 地址 | +| `OPEN_SANDBOX_IMAGE` | 创建新沙箱时使用的默认镜像 | + +--- + +## Agent 集成 + +### 使用沙箱配置(推荐) + +```typescript +const agent = await Agent.create({ + templateId: 'coder', + sandbox: { + kind: 'opensandbox', + endpoint: process.env.OPEN_SANDBOX_ENDPOINT, + apiKey: process.env.OPEN_SANDBOX_API_KEY, + image: 'debian:latest', + lifecycle: { disposeAction: 'kill' }, + }, +}, deps); +``` + +传入沙箱配置时,`SandboxFactory.createAsync()` 会自动完成 OpenSandbox 初始化。 + +### 使用沙箱实例 + +```typescript +const sandbox = new OpenSandbox({ kind: 'opensandbox', endpoint: 'http://127.0.0.1:8080' }); +await sandbox.init(); + +const agent = await Agent.create({ templateId: 'coder', sandbox }, deps); +``` + +传入沙箱实例时,需要在 `Agent.create()` 前手动调用 `sandbox.init()`。 + +### 基于 `sandboxId` 的恢复 + +```typescript +const sandbox = new OpenSandbox({ kind: 'opensandbox', endpoint: 'http://127.0.0.1:8080' }); +await sandbox.init(); +const id = sandbox.getSandboxId(); + +const restored = new OpenSandbox({ + kind: 'opensandbox', + endpoint: 'http://127.0.0.1:8080', + sandboxId: id, +}); +await restored.init(); +``` + +--- + +## Watch 与生命周期语义 + +1. `watch.mode='native'` 会在沙箱容器内使用 `inotifywait`。 +2. 若 `inotifywait` 不可用,或 native 流异常退出,SDK 会自动回退到 polling。 +3. `watch.mode='off'` 会关闭文件监听注册。 +4. `disposeAction='kill'` 先执行 `kill()`,再执行 `close()`。 +5. `disposeAction='close'` 仅关闭连接。 +6. polling 监听基于 mtime 变化,轮询间隔内的多次写入可能被合并为一次事件。 +7. polling 不保证“一次写入对应一次回调”,应将事件视为“文件已变化提示”。 + +--- + +## 排障建议 + +1. `DOCKER::SANDBOX_IMAGE_PULL_FAILED` 或 `DOCKER::SANDBOX_EXECD_START_FAILED`:Docker 无法拉取必须镜像(业务镜像与 `opensandbox/execd`)。 +2. 确认 SDK 进程能访问 OpenSandbox server 地址。 +3. 若使用代理,请分别检查 Docker daemon 代理与 OpenSandbox server 的网络配置。 diff --git a/docs/zh-CN/reference/api.md b/docs/zh-CN/reference/api.md index 81343a0..7a03824 100644 --- a/docs/zh-CN/reference/api.md +++ b/docs/zh-CN/reference/api.md @@ -719,6 +719,39 @@ new E2BSandbox(options?: E2BSandboxOptions) --- +## OpenSandbox + +基于 [OpenSandbox](https://www.npmjs.com/package/@alibaba-group/opensandbox) 的自托管沙箱,用于隔离代码执行。 + +### 构造函数 + +```typescript +new OpenSandbox(options: OpenSandboxOptions) +``` + +### 方法 + +| 方法 | 签名 | 说明 | +|------|------|------| +| `init()` | `async init(): Promise` | 初始化(创建或连接)沙箱 | +| `exec(cmd, opts?)` | `async exec(cmd: string, opts?: { timeoutMs?: number }): Promise` | 执行命令 | +| `dispose()` | `async dispose(): Promise` | 按生命周期策略释放沙箱 | +| `getSandboxId()` | `getSandboxId(): string \| undefined` | 获取沙箱 ID(用于持久化) | +| `isRunning()` | `async isRunning(): Promise` | 检查沙箱是否运行中 | +| `watchFiles(paths, listener)` | `async watchFiles(...): Promise` | 监听文件变更(支持 polling 回退) | +| `unwatchFiles(id)` | `unwatchFiles(id: string): void` | 停止监听 | +| `getOpenSandbox()` | `getOpenSandbox(): OpenSandboxClient` | 获取底层 OpenSandbox 客户端 | + +### 属性 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `kind` | `'opensandbox'` | 沙箱类型标识 | +| `workDir` | `string` | 工作目录路径 | +| `fs` | `SandboxFS` | 文件系统操作 | + +--- + ## E2BTemplateBuilder 构建自定义 E2B 沙箱模板的静态工具类。 @@ -747,3 +780,5 @@ static async exists(alias: string, opts?: { apiKey?: string }): Promise - [类型参考](./types.md) - [事件参考](./events-reference.md) - [使用指南](../guides/events.md) +- [E2B 沙箱指南](../guides/e2b-sandbox.md) +- [OpenSandbox 沙箱指南](../guides/opensandbox-sandbox.md) diff --git a/docs/zh-CN/reference/types.md b/docs/zh-CN/reference/types.md index 25d07af..bf727b3 100644 --- a/docs/zh-CN/reference/types.md +++ b/docs/zh-CN/reference/types.md @@ -443,7 +443,7 @@ interface SandboxConfig { ### SandboxKind ```typescript -type SandboxKind = 'local' | 'docker' | 'remote'; +type SandboxKind = 'local' | 'docker' | 'k8s' | 'remote' | 'vfs' | 'e2b' | 'opensandbox'; ``` --- @@ -516,6 +516,50 @@ interface E2BTemplateConfig { --- +## OpenSandbox 类型 + +### OpenSandboxWatchMode + +```typescript +type OpenSandboxWatchMode = 'native' | 'polling' | 'off'; +``` + +### OpenSandboxOptions + +```typescript +interface OpenSandboxOptions { + kind: 'opensandbox'; + apiKey?: string; + endpoint?: string; + domain?: string; + protocol?: 'http' | 'https'; + sandboxId?: string; + image?: string; + template?: string; + workDir?: string; + timeoutMs?: number; + execTimeoutMs?: number; + requestTimeoutSeconds?: number; + useServerProxy?: boolean; + env?: Record; + metadata?: Record; + resource?: Record; + networkPolicy?: Record; + skipHealthCheck?: boolean; + readyTimeoutSeconds?: number; + healthCheckPollingInterval?: number; + watch?: { + mode?: OpenSandboxWatchMode; + pollIntervalMs?: number; + }; + lifecycle?: { + disposeAction?: 'close' | 'kill'; + }; +} +``` + +--- + ## 参考资料 - [API 参考](./api.md) diff --git a/examples/opensandbox-usage.ts b/examples/opensandbox-usage.ts new file mode 100644 index 0000000..652ea90 --- /dev/null +++ b/examples/opensandbox-usage.ts @@ -0,0 +1,56 @@ +import './shared/load-env'; + +import { OpenSandbox } from '@shareai-lab/kode-sdk'; + +function printUsage() { + console.log(` +OpenSandbox 示例 +================ + +环境变量: + OPEN_SANDBOX_API_KEY=... # 可选(服务开启鉴权时需要) + OPEN_SANDBOX_ENDPOINT=http://127.0.0.1:8080 # 可选 + OPEN_SANDBOX_IMAGE=ubuntu # 可选 + +运行: + npm run example:opensandbox +`); +} + +async function main() { + printUsage(); + + const sandbox = new OpenSandbox({ + kind: 'opensandbox', + apiKey: process.env.OPEN_SANDBOX_API_KEY, + endpoint: process.env.OPEN_SANDBOX_ENDPOINT, + image: process.env.OPEN_SANDBOX_IMAGE || 'ubuntu', + timeoutMs: 10 * 60 * 1000, + execTimeoutMs: 120000, + useServerProxy: false, + watch: { mode: 'polling', pollIntervalMs: 1000 }, + lifecycle: { disposeAction: 'kill' }, + }); + + await sandbox.init(); + console.log(`sandboxId: ${sandbox.getSandboxId()}`); + + const shell = await sandbox.exec('echo "hello opensandbox"'); + console.log(`exec code=${shell.code}`); + console.log(shell.stdout.trim()); + + await sandbox.fs.write('demo.txt', 'hello from opensandbox'); + const text = await sandbox.fs.read('demo.txt'); + console.log(`file content: ${text.trim()}`); + + const files = await sandbox.fs.glob('**/*.txt'); + console.log(`txt files: ${files.join(', ')}`); + + await sandbox.dispose(); + console.log('disposed'); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json index c5ae4ee..ccf84f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,14 @@ "version": "2.7.0", "license": "MIT", "dependencies": { + "@alibaba-group/opensandbox": "~0.1.4", "@modelcontextprotocol/sdk": "~1.22.0", "ajv": "^8.17.1", "better-sqlite3": "^12.6.2", "dotenv": "^16.4.5", "e2b": "^2.10.3", "fast-glob": "^3.3.2", + "minimatch": "^10.2.4", "pg": "^8.17.2", "undici": "^7.18.2", "zod": "^4.3.5", @@ -32,6 +34,19 @@ "node": ">=18.0.0" } }, + "node_modules/@alibaba-group/opensandbox": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@alibaba-group/opensandbox/-/opensandbox-0.1.4.tgz", + "integrity": "sha512-hTgzsBRYCoNM5A3cM0Rgsq4q996pWzHNk2MDClhXsPoeg7ArYXkqYJAylh2c2iM8dV6IB7PwCe7/y9eeYn9kOA==", + "license": "Apache-2.0", + "dependencies": { + "openapi-fetch": "^0.14.1", + "undici": "^7.18.2" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.11.0", "resolved": "https://registry.npmmirror.com/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", @@ -72,27 +87,6 @@ "node": ">=12" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -394,6 +388,15 @@ "dev": true, "license": "MIT" }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", @@ -472,6 +475,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1465,15 +1480,15 @@ } }, "node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" diff --git a/package.json b/package.json index fc12c7f..34a4643 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "example:room": "ts-node examples/03-room-collab.ts", "example:scheduler": "ts-node examples/04-scheduler-watch.ts", "example:nextjs": "ts-node examples/nextjs-api-route.ts", + "example:opensandbox": "ts-node examples/opensandbox-usage.ts", "example:openrouter": "ts-node examples/05-openrouter-complete.ts", "example:openrouter-stream": "ts-node examples/06-openrouter-stream.ts", "example:openrouter-agent": "ts-node examples/07-openrouter-agent.ts", @@ -42,12 +43,14 @@ "author": "", "license": "MIT", "dependencies": { + "@alibaba-group/opensandbox": "~0.1.4", "@modelcontextprotocol/sdk": "~1.22.0", "ajv": "^8.17.1", "better-sqlite3": "^12.6.2", "dotenv": "^16.4.5", "e2b": "^2.10.3", "fast-glob": "^3.3.2", + "minimatch": "^10.2.4", "pg": "^8.17.2", "undici": "^7.18.2", "zod": "^4.3.5", diff --git a/src/core/agent.ts b/src/core/agent.ts index f0e6c9a..2f32f12 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -348,15 +348,26 @@ export class Agent { const template = deps.templateRegistry.get(config.templateId); - const sandboxConfig: SandboxConfig | undefined = + const sandboxConfigSource: SandboxConfig | undefined = config.sandbox && 'kind' in config.sandbox ? (config.sandbox as SandboxConfig) : (template.sandbox as SandboxConfig | undefined); + const sandboxConfig: SandboxConfig | undefined = sandboxConfigSource + ? { ...sandboxConfigSource } + : undefined; const sandbox = typeof config.sandbox === 'object' && 'exec' in config.sandbox ? (config.sandbox as Sandbox) : await deps.sandboxFactory.createAsync(sandboxConfig || { kind: 'local', workDir: process.cwd() }); + // OpenSandbox creates sandbox id at runtime; persist it for strict resume. + if (sandboxConfig?.kind === 'opensandbox' && typeof (sandbox as any).getSandboxId === 'function') { + const sandboxId = (sandbox as any).getSandboxId(); + if (sandboxId) { + sandboxConfig.sandboxId = sandboxId; + } + } + const model = config.model ? config.model : config.modelConfig diff --git a/src/index.ts b/src/index.ts index 7858eb1..18ee26f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,8 @@ export { PostgresStore } from './infra/db/postgres/postgres-store'; export { Sandbox, LocalSandbox, SandboxKind } from './infra/sandbox'; export { E2BSandbox, E2BFS, E2BTemplateBuilder } from './infra/e2b'; export type { E2BSandboxOptions, E2BTemplateConfig } from './infra/e2b'; +export { OpenSandbox, OpenSandboxFS } from './infra/opensandbox'; +export type { OpenSandboxOptions, OpenSandboxWatchMode } from './infra/opensandbox'; export { ModelProvider, ModelConfig, diff --git a/src/infra/opensandbox/index.ts b/src/infra/opensandbox/index.ts new file mode 100644 index 0000000..1209f49 --- /dev/null +++ b/src/infra/opensandbox/index.ts @@ -0,0 +1,3 @@ +export { OpenSandbox } from './opensandbox-sandbox'; +export { OpenSandboxFS } from './opensandbox-fs'; +export type { OpenSandboxOptions, OpenSandboxWatchMode } from './types'; diff --git a/src/infra/opensandbox/opensandbox-fs.ts b/src/infra/opensandbox/opensandbox-fs.ts new file mode 100644 index 0000000..061e79a --- /dev/null +++ b/src/infra/opensandbox/opensandbox-fs.ts @@ -0,0 +1,128 @@ +import path from 'path'; +import { minimatch } from 'minimatch'; +import type { Sandbox as OpenSandboxClient } from '@alibaba-group/opensandbox'; +import { SandboxFS } from '../sandbox'; + +export interface OpenSandboxFSHost { + workDir: string; + getOpenSandbox(): OpenSandboxClient; +} + +export class OpenSandboxFS implements SandboxFS { + constructor(private readonly host: OpenSandboxFSHost) {} + + resolve(p: string): string { + if (path.posix.isAbsolute(p)) return path.posix.normalize(p); + return path.posix.normalize(path.posix.join(this.host.workDir, p)); + } + + isInside(_p: string): boolean { + // OpenSandbox runtime is already isolated at container/sandbox level. + return true; + } + + async read(p: string): Promise { + const sandbox = this.host.getOpenSandbox(); + const resolved = this.resolve(p); + return await sandbox.files.readFile(resolved); + } + + async write(p: string, content: string): Promise { + const sandbox = this.host.getOpenSandbox(); + const resolved = this.resolve(p); + const dir = path.posix.dirname(resolved); + if (dir && dir !== '/') { + await sandbox.files.createDirectories([{ path: dir, mode: 0o755 }]).catch(() => undefined); + } + await sandbox.files.writeFiles([{ path: resolved, data: content }]); + } + + temp(name?: string): string { + const tempName = name || `temp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + return path.posix.join('/tmp', tempName); + } + + async stat(p: string): Promise<{ mtimeMs: number }> { + const sandbox = this.host.getOpenSandbox(); + const resolved = this.resolve(p); + const info = await sandbox.files.getFileInfo([resolved]); + const fileInfo = info[resolved] || Object.values(info)[0]; + if (!fileInfo) { + throw new Error(`File not found: ${resolved}`); + } + + const mtime = this.toTimestamp( + (fileInfo as any).modifiedAt ?? + (fileInfo as any).modified_at ?? + (fileInfo as any).mtime ?? + (fileInfo as any).updatedAt + ); + + return { mtimeMs: mtime ?? Date.now() }; + } + + async glob( + pattern: string, + opts?: { cwd?: string; ignore?: string[]; dot?: boolean; absolute?: boolean } + ): Promise { + const sandbox = this.host.getOpenSandbox(); + const searchRoot = opts?.cwd ? this.resolve(opts.cwd) : this.host.workDir; + const items = await sandbox.files.search({ + path: searchRoot, + pattern, + }); + + const includeDot = opts?.dot ?? false; + const ignore = opts?.ignore || []; + + const matched = items + .map((item) => this.resolve(String(item.path || ''))) + .filter((entry) => { + if (!entry) return false; + if (!includeDot && this.hasDotPath(entry)) return false; + if (ignore.length === 0) return true; + + const relToRoot = path.posix.relative(searchRoot, entry); + const relToWorkDir = path.posix.relative(this.host.workDir, entry); + return !ignore.some((rule) => { + return ( + this.matchGlob(rule, relToRoot) || + this.matchGlob(rule, relToWorkDir) || + this.matchGlob(rule, entry) + ); + }); + }); + + if (opts?.absolute) { + return matched; + } + + return matched.map((entry) => path.posix.relative(this.host.workDir, entry)); + } + + private hasDotPath(entry: string): boolean { + const normalized = entry.replace(/\\/g, '/'); + return normalized.split('/').some((seg) => seg.startsWith('.') && seg.length > 1); + } + + private toTimestamp(value: unknown): number | undefined { + if (value == null) return undefined; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (value instanceof Date) return value.getTime(); + if (typeof value === 'string') { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; + } + + private matchGlob(pattern: string, target: string): boolean { + const normalizedPattern = pattern.replace(/\\/g, '/'); + const normalizedTarget = target.replace(/\\/g, '/'); + return minimatch(normalizedTarget, normalizedPattern, { + dot: true, + nocase: false, + matchBase: false, + }); + } +} diff --git a/src/infra/opensandbox/opensandbox-sandbox.ts b/src/infra/opensandbox/opensandbox-sandbox.ts new file mode 100644 index 0000000..ebe059f --- /dev/null +++ b/src/infra/opensandbox/opensandbox-sandbox.ts @@ -0,0 +1,402 @@ +import { + ConnectionConfig, + Sandbox as OpenSandboxClient, + type ConnectionConfigOptions, + type SandboxConnectOptions, + type SandboxCreateOptions, +} from '@alibaba-group/opensandbox'; +import { Sandbox, SandboxExecResult, SandboxKind } from '../sandbox'; +import { logger } from '../../utils/logger'; +import { OpenSandboxFS } from './opensandbox-fs'; +import { OpenSandboxOptions, OpenSandboxWatchMode } from './types'; + +interface PollWatcher { + kind: 'polling'; + timer: NodeJS.Timeout; + paths: string[]; + lastMtimes: Map; + polling: boolean; + pending: boolean; +} + +interface NativeWatcher { + kind: 'native'; + paths: string[]; + abortController: AbortController; + streamTask: Promise; +} + +type ActiveWatcher = PollWatcher | NativeWatcher; + +export class OpenSandbox implements Sandbox { + kind: SandboxKind = 'opensandbox'; + workDir: string; + fs: OpenSandboxFS; + + private sandbox: OpenSandboxClient | null = null; + private readonly options: OpenSandboxOptions; + private readonly watchers = new Map(); + private readonly watchMode: OpenSandboxWatchMode; + private readonly pollIntervalMs: number; + private readonly disposeAction: 'close' | 'kill'; + + constructor(options: OpenSandboxOptions) { + this.options = { ...options }; + this.workDir = options.workDir || '/workspace'; + this.fs = new OpenSandboxFS(this); + this.watchMode = options.watch?.mode || 'polling'; + this.pollIntervalMs = Math.max(100, options.watch?.pollIntervalMs ?? 1000); + this.disposeAction = options.lifecycle?.disposeAction || 'kill'; + } + + async init(): Promise { + if (this.sandbox) return; + + const connectionConfig = this.buildConnectionConfig(); + + if (this.options.sandboxId) { + const connectOptions: SandboxConnectOptions = { + sandboxId: this.options.sandboxId, + connectionConfig, + skipHealthCheck: this.options.skipHealthCheck, + readyTimeoutSeconds: this.options.readyTimeoutSeconds, + healthCheckPollingInterval: this.options.healthCheckPollingInterval, + }; + this.sandbox = await OpenSandboxClient.connect(connectOptions); + } else { + const createOptions: SandboxCreateOptions = { + connectionConfig, + image: this.options.image || this.options.template || 'ubuntu', + timeoutSeconds: Math.max(1, Math.ceil((this.options.timeoutMs ?? 10 * 60 * 1000) / 1000)), + env: this.options.env, + metadata: this.options.metadata, + resource: this.options.resource, + networkPolicy: this.options.networkPolicy as any, + skipHealthCheck: this.options.skipHealthCheck, + readyTimeoutSeconds: this.options.readyTimeoutSeconds, + healthCheckPollingInterval: this.options.healthCheckPollingInterval, + }; + this.sandbox = await OpenSandboxClient.create(createOptions); + } + + // Persist resolved sandbox id for Agent resume metadata. + this.options.sandboxId = this.sandbox.id; + + // Best-effort workdir bootstrap. + if (this.workDir && this.workDir !== '/') { + await this.sandbox.commands + .run(`mkdir -p ${quoteShell(this.workDir)}`, { + workingDirectory: '/', + timeoutSeconds: 10, + }) + .catch(() => undefined); + } + } + + getOpenSandbox(): OpenSandboxClient { + if (!this.sandbox) { + throw new Error('OpenSandbox not initialized. Call init() first.'); + } + return this.sandbox; + } + + getSandboxId(): string | undefined { + return this.sandbox?.id || this.options.sandboxId; + } + + async isRunning(): Promise { + try { + const info = await this.getOpenSandbox().getInfo(); + const state = String((info as any)?.status?.state || '').toLowerCase(); + return state === 'running'; + } catch { + return false; + } + } + + async exec(cmd: string, opts?: { timeoutMs?: number }): Promise { + const sandbox = this.getOpenSandbox(); + const timeoutMs = this.resolveExecTimeoutMs(opts); + const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000)); + + try { + const execution = await sandbox.commands.run(cmd, { + workingDirectory: this.workDir, + timeoutSeconds, + }); + + const stdout = execution.logs.stdout.map((m) => m.text).join(''); + let stderr = execution.logs.stderr.map((m) => m.text).join(''); + let code = execution.error ? 1 : 0; + + if (execution.id) { + try { + const status = await sandbox.commands.getCommandStatus(execution.id); + if (typeof status.exitCode === 'number') { + code = status.exitCode; + } else if (status.running === false && status.error) { + code = 1; + } + } catch { + // keep fallback code when status API is unavailable + } + } + + if (execution.error && !stderr) { + const traces = Array.isArray(execution.error.traceback) ? execution.error.traceback.join('\n') : ''; + stderr = [execution.error.name, execution.error.value, traces].filter(Boolean).join('\n'); + } + + return { code, stdout, stderr }; + } catch (error: any) { + return { + code: 1, + stdout: '', + stderr: error?.message || String(error), + }; + } + } + + async watchFiles( + paths: string[], + listener: (event: { path: string; mtimeMs: number }) => void + ): Promise { + if (this.watchMode === 'off') { + return `watch-disabled-${Date.now()}`; + } + + const id = `opensandbox-watch-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const resolved = Array.from(new Set(paths.map((p) => this.fs.resolve(p)))); + + if (this.watchMode === 'native') { + const nativeStarted = await this.startNativeWatcher(id, resolved, listener); + if (nativeStarted) { + return id; + } + logger.warn('[OpenSandbox] native watch unavailable, falling back to polling mode.'); + } + + await this.startPollingWatcher(id, resolved, listener); + return id; + } + + unwatchFiles(id: string): void { + const watcher = this.watchers.get(id); + if (!watcher) return; + + if (watcher.kind === 'polling') { + clearInterval(watcher.timer); + } else { + watcher.abortController.abort(); + } + + this.watchers.delete(id); + } + + async dispose(): Promise { + for (const id of Array.from(this.watchers.keys())) { + this.unwatchFiles(id); + } + + if (!this.sandbox) return; + + let disposeError: unknown; + const sandbox = this.sandbox; + this.sandbox = null; + + if (this.disposeAction === 'kill') { + try { + await sandbox.kill(); + } catch (error) { + disposeError = error; + } + } + + try { + await sandbox.close(); + } catch (error) { + disposeError = disposeError || error; + } + + if (disposeError) { + throw disposeError; + } + } + + private buildConnectionConfig(): ConnectionConfig { + const config: ConnectionConfigOptions = { + apiKey: this.options.apiKey, + domain: this.options.endpoint || this.options.domain, + protocol: this.options.protocol, + requestTimeoutSeconds: this.options.requestTimeoutSeconds, + useServerProxy: this.options.useServerProxy ?? false, + }; + return new ConnectionConfig(config); + } + + private async pollWatcher( + id: string, + listener: (event: { path: string; mtimeMs: number }) => void + ): Promise { + const watcher = this.watchers.get(id); + if (!watcher || watcher.kind !== 'polling' || watcher.polling) return; + watcher.polling = true; + + try { + do { + watcher.pending = false; + + for (const path of watcher.paths) { + if (!this.watchers.has(id)) { + return; + } + + const current = await this.safeMtime(path); + const previous = watcher.lastMtimes.get(path); + watcher.lastMtimes.set(path, current); + + if (previous === undefined && current === undefined) continue; + if (previous === current) continue; + + listener({ path, mtimeMs: current ?? Date.now() }); + } + } while (this.watchers.has(id) && watcher.pending); + } finally { + if (this.watchers.get(id) === watcher) { + watcher.polling = false; + } + } + } + + private async safeMtime(path: string): Promise { + try { + const stat = await this.fs.stat(path); + return stat.mtimeMs; + } catch { + return undefined; + } + } + + private resolveExecTimeoutMs(opts?: { timeoutMs?: number }): number { + return opts?.timeoutMs ?? this.options.execTimeoutMs ?? this.options.timeoutMs ?? 120000; + } + + private async startPollingWatcher( + id: string, + paths: string[], + listener: (event: { path: string; mtimeMs: number }) => void + ): Promise { + const lastMtimes = new Map(); + for (const p of paths) { + lastMtimes.set(p, await this.safeMtime(p)); + } + + const watcher: PollWatcher = { + kind: 'polling', + timer: setInterval(() => { + const current = this.watchers.get(id); + if (!current || current.kind !== 'polling') return; + current.pending = true; + if (!current.polling) { + void this.pollWatcher(id, listener); + } + }, this.pollIntervalMs), + paths, + lastMtimes, + polling: false, + pending: true, + }; + + this.watchers.set(id, watcher); + void this.pollWatcher(id, listener); + } + + private async startNativeWatcher( + id: string, + paths: string[], + listener: (event: { path: string; mtimeMs: number }) => void + ): Promise { + const probe = await this.exec('command -v inotifywait >/dev/null 2>&1 && echo __KODE_INOTIFY_READY__', { + timeoutMs: 5000, + }); + if (probe.code !== 0 || !probe.stdout.includes('__KODE_INOTIFY_READY__')) { + return false; + } + + const sandbox = this.getOpenSandbox(); + const abortController = new AbortController(); + const nativeWatchCommand = buildNativeWatchCommand(paths); + let stdoutBuffer = ''; + + const streamTask = (async () => { + try { + for await (const event of sandbox.commands.runStream( + nativeWatchCommand, + { workingDirectory: this.workDir }, + abortController.signal + )) { + if (abortController.signal.aborted) { + break; + } + if (event.type !== 'stdout' || typeof event.text !== 'string') { + continue; + } + + stdoutBuffer += event.text; + let lineBreak = stdoutBuffer.indexOf('\n'); + while (lineBreak >= 0) { + const line = stdoutBuffer.slice(0, lineBreak).trim(); + stdoutBuffer = stdoutBuffer.slice(lineBreak + 1); + if (line) { + listener({ path: line, mtimeMs: Date.now() }); + } + lineBreak = stdoutBuffer.indexOf('\n'); + } + } + + const tail = stdoutBuffer.trim(); + if (tail) { + listener({ path: tail, mtimeMs: Date.now() }); + } + } catch (error) { + if (!abortController.signal.aborted) { + logger.warn('[OpenSandbox] native watch stream failed, fallback to polling.', error); + } + } finally { + const current = this.watchers.get(id); + if (!current || current.kind !== 'native' || current.abortController !== abortController) { + return; + } + + this.watchers.delete(id); + if (!abortController.signal.aborted) { + try { + await this.startPollingWatcher(id, paths, listener); + logger.warn('[OpenSandbox] native watch stopped, switched to polling mode.'); + } catch (error) { + logger.warn('[OpenSandbox] failed to start polling fallback after native watch exit.', error); + } + } + } + })(); + + const watcher: NativeWatcher = { + kind: 'native', + paths, + abortController, + streamTask, + }; + this.watchers.set(id, watcher); + return true; + } +} + +function quoteShell(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function buildNativeWatchCommand(paths: string[]): string { + const targets = paths.map((p) => quoteShell(p)).join(' '); + const script = `exec inotifywait -m -e modify,create,delete,move --format '%w%f' -- ${targets}`; + return `sh -lc ${quoteShell(script)}`; +} diff --git a/src/infra/opensandbox/types.ts b/src/infra/opensandbox/types.ts new file mode 100644 index 0000000..60249e0 --- /dev/null +++ b/src/infra/opensandbox/types.ts @@ -0,0 +1,31 @@ +export type OpenSandboxWatchMode = 'native' | 'polling' | 'off'; + +export interface OpenSandboxOptions { + kind: 'opensandbox'; + apiKey?: string; + endpoint?: string; + domain?: string; + protocol?: 'http' | 'https'; + sandboxId?: string; + image?: string; + template?: string; + workDir?: string; + timeoutMs?: number; + execTimeoutMs?: number; + requestTimeoutSeconds?: number; + useServerProxy?: boolean; + env?: Record; + metadata?: Record; + resource?: Record; + networkPolicy?: Record; + skipHealthCheck?: boolean; + readyTimeoutSeconds?: number; + healthCheckPollingInterval?: number; + watch?: { + mode?: OpenSandboxWatchMode; + pollIntervalMs?: number; + }; + lifecycle?: { + disposeAction?: 'close' | 'kill'; + }; +} diff --git a/src/infra/sandbox-factory.ts b/src/infra/sandbox-factory.ts index 538ec24..9d052fd 100644 --- a/src/infra/sandbox-factory.ts +++ b/src/infra/sandbox-factory.ts @@ -1,6 +1,7 @@ import { Sandbox, SandboxKind, LocalSandbox, LocalSandboxOptions } from './sandbox'; import { E2BSandbox } from './e2b/e2b-sandbox'; import { E2BSandboxOptions } from './e2b/types'; +import { OpenSandbox, OpenSandboxOptions } from './opensandbox'; export type SandboxFactoryFn = (config: Record) => Sandbox; @@ -10,6 +11,7 @@ export class SandboxFactory { constructor() { this.factories.set('local', (config) => new LocalSandbox(config as LocalSandboxOptions)); this.factories.set('e2b', (config) => new E2BSandbox(config as E2BSandboxOptions)); + this.factories.set('opensandbox', (config) => new OpenSandbox(config as OpenSandboxOptions)); } register(kind: SandboxKind, factory: SandboxFactoryFn): void { @@ -29,6 +31,9 @@ export class SandboxFactory { if (config.kind === 'e2b' && sandbox instanceof E2BSandbox) { await sandbox.init(); } + if (config.kind === 'opensandbox' && sandbox instanceof OpenSandbox) { + await sandbox.init(); + } return sandbox; } } diff --git a/src/infra/sandbox.ts b/src/infra/sandbox.ts index 8980906..ae2e6df 100644 --- a/src/infra/sandbox.ts +++ b/src/infra/sandbox.ts @@ -1,4 +1,4 @@ -export type SandboxKind = 'local' | 'docker' | 'k8s' | 'remote' | 'vfs' | 'e2b'; +export type SandboxKind = 'local' | 'docker' | 'k8s' | 'remote' | 'vfs' | 'e2b' | 'opensandbox'; export interface SandboxFS { resolve(path: string): string; diff --git a/tests/e2e/scenarios/opensandbox-sandbox.test.ts b/tests/e2e/scenarios/opensandbox-sandbox.test.ts new file mode 100644 index 0000000..703f0f5 --- /dev/null +++ b/tests/e2e/scenarios/opensandbox-sandbox.test.ts @@ -0,0 +1,121 @@ +import { TestRunner, expect } from '../../helpers/utils'; +import fs from 'fs'; +import path from 'path'; +import { OpenSandbox } from '../../../src/infra/opensandbox'; + +const runner = new TestRunner('E2E - OpenSandbox'); + +function loadEnv(key: string): string | undefined { + const filePath = path.resolve(process.cwd(), '.env.test'); + if (fs.existsSync(filePath)) { + const content = fs.readFileSync(filePath, 'utf-8'); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx === -1) continue; + const k = trimmed.slice(0, idx).trim(); + let v = trimmed.slice(idx + 1).trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { + v = v.slice(1, -1); + } + if (k === key) return v; + } + } + return process.env[key]; +} + +const apiKey = loadEnv('OPEN_SANDBOX_API_KEY'); +const endpoint = loadEnv('OPEN_SANDBOX_ENDPOINT') || loadEnv('OPEN_SANDBOX_DOMAIN'); +const protocol = (loadEnv('OPEN_SANDBOX_PROTOCOL') as 'http' | 'https' | undefined) || undefined; +const image = loadEnv('OPEN_SANDBOX_IMAGE') || 'ubuntu'; +const enabled = loadEnv('OPEN_SANDBOX_E2E') === '1'; + +if (!enabled) { + runner.skip('OpenSandbox E2E 跳过:设置 OPEN_SANDBOX_E2E=1 后执行'); +} else { + let sandbox: OpenSandbox; + let sandboxId: string | undefined; + + runner + .beforeAll(async () => { + sandbox = new OpenSandbox({ + kind: 'opensandbox', + apiKey, + endpoint, + protocol, + image, + timeoutMs: 10 * 60 * 1000, + execTimeoutMs: 120000, + useServerProxy: false, + watch: { mode: 'off' }, + lifecycle: { disposeAction: 'kill' }, + }); + await sandbox.init(); + sandboxId = sandbox.getSandboxId(); + }) + .afterAll(async () => { + if (sandbox) { + await sandbox.dispose(); + } + }) + .test('创建成功并返回 sandboxId', async () => { + expect.toBeTruthy(sandboxId); + }) + .test('基本命令执行', async () => { + const result = await sandbox.exec('echo "hello opensandbox"'); + expect.toEqual(result.code, 0); + expect.toContain(result.stdout, 'hello opensandbox'); + }) + .test('文件写入与读取', async () => { + const path = `e2e-${Date.now()}.txt`; + await sandbox.fs.write(path, 'hello from e2e'); + const content = await sandbox.fs.read(path); + expect.toEqual(content.trim(), 'hello from e2e'); + }) + .test('glob 可检索 txt 文件', async () => { + const path = `glob-${Date.now()}.txt`; + await sandbox.fs.write(path, 'glob test'); + const files = await sandbox.fs.glob('**/*.txt'); + const hasTarget = files.some((p) => p.endsWith(path)); + expect.toEqual(hasTarget, true); + }) + .test('使用 sandboxId 连接已存在沙箱', async () => { + if (!sandboxId) { + throw new Error('sandboxId is empty'); + } + const restored = new OpenSandbox({ + kind: 'opensandbox', + apiKey, + endpoint, + protocol, + sandboxId, + useServerProxy: false, + watch: { mode: 'off' }, + lifecycle: { disposeAction: 'close' }, + }); + await restored.init(); + const result = await restored.exec('echo "restore ok"'); + expect.toEqual(result.code, 0); + expect.toContain(result.stdout, 'restore ok'); + await restored.dispose(); + }); +} + +export async function run() { + return await runner.run(); +} + +if (require.main === module) { + run() + .then((result) => { + if (result.output) { + console.log(result.output); + } + process.exit(result.failed > 0 ? 1 : 0); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/unit/core/agent.test.ts b/tests/unit/core/agent.test.ts index ae53ea5..1b73fcf 100644 --- a/tests/unit/core/agent.test.ts +++ b/tests/unit/core/agent.test.ts @@ -3,11 +3,16 @@ */ import { Agent } from '../../../src/core/agent'; -import { createUnitTestAgent } from '../../helpers/setup'; +import path from 'path'; +import fs from 'fs'; +import { createUnitTestAgent, ensureCleanDir } from '../../helpers/setup'; import { TestRunner, expect } from '../../helpers/utils'; import { ContentBlock } from '../../../src/core/types'; import { Hooks } from '../../../src/core/hooks'; import { ModelResponse } from '../../../src/infra/provider'; +import { JSONStore, AgentTemplateRegistry, SandboxFactory, ToolRegistry } from '../../../src'; +import { MockProvider } from '../../mock-provider'; +import { TEST_ROOT } from '../../helpers/fixtures'; const runner = new TestRunner('Agent核心功能'); @@ -160,6 +165,66 @@ runner await cleanup(); }) + .test('OpenSandbox sandboxId 持久化不修改模板 sandbox 配置对象', async () => { + const workDir = path.join(TEST_ROOT, `agent-opensb-work-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`); + const storeDir = path.join(TEST_ROOT, `agent-opensb-store-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`); + ensureCleanDir(workDir); + ensureCleanDir(storeDir); + + const store = new JSONStore(storeDir); + const templates = new AgentTemplateRegistry(); + const tools = new ToolRegistry(); + const sandboxFactory = new SandboxFactory(); + const templateSandbox: Record = { + kind: 'opensandbox', + endpoint: 'http://127.0.0.1:8080', + image: 'debian:latest', + }; + + templates.register({ + id: 'opensb-template', + systemPrompt: 'test opensandbox sandboxId persistence', + tools: [], + permission: { mode: 'auto' }, + sandbox: templateSandbox, + }); + + const fakeSandbox = { + kind: 'opensandbox', + workDir, + fs: { + resolve: (p: string) => (path.isAbsolute(p) ? p : path.resolve(workDir, p)), + isInside: () => true, + read: async () => '', + write: async () => undefined, + temp: () => path.resolve(workDir, '.tmp'), + stat: async () => ({ mtimeMs: Date.now() }), + glob: async () => [], + }, + exec: async () => ({ code: 0, stdout: '', stderr: '' }), + getSandboxId: () => 'sbx-test-123', + dispose: async () => undefined, + }; + sandboxFactory.register('opensandbox', () => fakeSandbox as any); + + const agent = await Agent.create({ + templateId: 'opensb-template', + model: new MockProvider([{ text: 'ok' }]), + }, { + store, + templateRegistry: templates, + sandboxFactory, + toolRegistry: tools, + modelFactory: () => new MockProvider([{ text: 'ok' }]), + }); + + expect.toEqual((templateSandbox as any).sandboxId, undefined); + expect.toEqual((agent as any).sandboxConfig?.sandboxId, 'sbx-test-123'); + + await (agent as any).sandbox?.dispose?.(); + fs.rmSync(workDir, { recursive: true, force: true }); + fs.rmSync(storeDir, { recursive: true, force: true }); + }) .test('中断执行', async () => { const { agent, cleanup } = await createUnitTestAgent({ diff --git a/tests/unit/infra/opensandbox-fs.test.ts b/tests/unit/infra/opensandbox-fs.test.ts new file mode 100644 index 0000000..fd81c70 --- /dev/null +++ b/tests/unit/infra/opensandbox-fs.test.ts @@ -0,0 +1,141 @@ +import { TestRunner, expect } from '../../helpers/utils'; +import { OpenSandboxFS, OpenSandboxFSHost } from '../../../src/infra/opensandbox/opensandbox-fs'; + +const runner = new TestRunner('OpenSandboxFS'); + +function createMockHost(workDir = '/workspace') { + const state = { + files: new Map(), + mtime: new Map(), + searched: [] as Array<{ path: string; pattern?: string }>, + }; + + const sandbox = { + files: { + readFile: async (path: string) => { + if (!state.files.has(path)) { + throw new Error(`File not found: ${path}`); + } + return state.files.get(path)!; + }, + writeFiles: async (entries: Array<{ path: string; data?: any }>) => { + for (const entry of entries) { + state.files.set(entry.path, String(entry.data ?? '')); + state.mtime.set(entry.path, Date.now()); + } + }, + createDirectories: async (_entries: any[]) => undefined, + getFileInfo: async (paths: string[]) => { + const out: Record = {}; + for (const p of paths) { + if (state.files.has(p)) { + out[p] = { + path: p, + modifiedAt: new Date(state.mtime.get(p) || Date.now()), + }; + } + } + return out; + }, + search: async (entry: { path: string; pattern?: string }) => { + state.searched.push(entry); + return Array.from(state.files.keys()) + .filter((p) => p.startsWith(entry.path)) + .map((p) => ({ path: p })); + }, + }, + }; + + const host: OpenSandboxFSHost = { + workDir, + getOpenSandbox: () => sandbox as any, + }; + + return { host, state }; +} + +runner + .test('resolve 处理相对/绝对路径', async () => { + const { host } = createMockHost('/workspace'); + const fs = new OpenSandboxFS(host); + expect.toEqual(fs.resolve('a/b.txt'), '/workspace/a/b.txt'); + expect.toEqual(fs.resolve('/tmp/a.txt'), '/tmp/a.txt'); + }) + .test('read/write 可读写文件内容', async () => { + const { host } = createMockHost('/workspace'); + const fs = new OpenSandboxFS(host); + await fs.write('a.txt', 'hello'); + const text = await fs.read('a.txt'); + expect.toEqual(text, 'hello'); + }) + .test('stat 返回 mtimeMs', async () => { + const { host } = createMockHost('/workspace'); + const fs = new OpenSandboxFS(host); + await fs.write('mtime.txt', 'x'); + const stat = await fs.stat('mtime.txt'); + expect.toBeTruthy(stat.mtimeMs > 0); + }) + .test('glob 支持 absolute=false 返回相对路径', async () => { + const { host } = createMockHost('/workspace'); + const fs = new OpenSandboxFS(host); + await fs.write('a.txt', '1'); + await fs.write('dir/b.txt', '2'); + const paths = await fs.glob('**/*.txt'); + expect.toContain(paths, 'a.txt'); + expect.toContain(paths, 'dir/b.txt'); + }) + .test('glob 支持 ignore', async () => { + const { host } = createMockHost('/workspace'); + const fs = new OpenSandboxFS(host); + await fs.write('keep/a.txt', '1'); + await fs.write('skip/b.txt', '2'); + const paths = await fs.glob('**/*.txt', { ignore: ['skip/**'] }); + expect.toContain(paths, 'keep/a.txt'); + expect.toBeFalsy(paths.includes('skip/b.txt')); + }) + .test('glob ignore 支持 brace 模式', async () => { + const { host } = createMockHost('/workspace'); + const fs = new OpenSandboxFS(host); + await fs.write('src/a.test.ts', '1'); + await fs.write('src/b.test.js', '2'); + await fs.write('src/c.ts', '3'); + const paths = await fs.glob('**/*', { ignore: ['**/*.test.{ts,js}'] }); + expect.toBeFalsy(paths.includes('src/a.test.ts')); + expect.toBeFalsy(paths.includes('src/b.test.js')); + expect.toContain(paths, 'src/c.ts'); + }) + .test('glob dot 选项控制隐藏文件可见性', async () => { + const { host } = createMockHost('/workspace'); + const fs = new OpenSandboxFS(host); + await fs.write('.secret.txt', '1'); + await fs.write('normal.txt', '2'); + const hiddenOff = await fs.glob('**/*.txt'); + expect.toBeFalsy(hiddenOff.includes('.secret.txt')); + const hiddenOn = await fs.glob('**/*.txt', { dot: true }); + expect.toContain(hiddenOn, '.secret.txt'); + expect.toContain(hiddenOn, 'normal.txt'); + }) + .test('glob 支持 absolute=true', async () => { + const { host } = createMockHost('/workspace'); + const fs = new OpenSandboxFS(host); + await fs.write('abs.txt', '1'); + const paths = await fs.glob('**/*.txt', { absolute: true }); + expect.toContain(paths, '/workspace/abs.txt'); + }) + .test('temp 生成 /tmp 路径', async () => { + const { host } = createMockHost('/workspace'); + const fs = new OpenSandboxFS(host); + const path = fs.temp('file.txt'); + expect.toEqual(path, '/tmp/file.txt'); + }); + +export async function run() { + return await runner.run(); +} + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/tests/unit/infra/opensandbox-sandbox.test.ts b/tests/unit/infra/opensandbox-sandbox.test.ts new file mode 100644 index 0000000..9e33e50 --- /dev/null +++ b/tests/unit/infra/opensandbox-sandbox.test.ts @@ -0,0 +1,223 @@ +import { TestRunner, expect } from '../../helpers/utils'; +import { OpenSandbox } from '../../../src/infra/opensandbox/opensandbox-sandbox'; + +const runner = new TestRunner('OpenSandbox (Mock)'); + +function createMockSandbox() { + const files = new Map(); + let killed = false; + let closed = false; + let nativeReady = false; + let nativeEvents: string[] = []; + + return { + setNativeWatcher(opts: { ready: boolean; events?: string[] }) { + nativeReady = opts.ready; + nativeEvents = opts.events || []; + }, + id: 'sbx-test-001', + commands: { + run: async (cmd: string, _opts?: any) => { + if (cmd.includes('__KODE_INOTIFY_READY__')) { + return { + id: 'cmd-native-probe', + logs: { + stdout: nativeReady ? [{ text: '__KODE_INOTIFY_READY__\n', timestamp: Date.now() }] : [], + stderr: [], + }, + result: [], + }; + } + if (cmd === 'echo hello') { + return { + id: 'cmd-1', + logs: { stdout: [{ text: 'hello\n', timestamp: Date.now() }], stderr: [] }, + result: [], + }; + } + if (cmd === 'boom') { + throw new Error('command failed'); + } + return { + id: 'cmd-2', + logs: { stdout: [], stderr: [] }, + result: [], + error: { name: 'RuntimeError', value: 'failed', timestamp: Date.now(), traceback: [] }, + }; + }, + runStream: async function* (_cmd: string, _opts?: any, signal?: AbortSignal) { + for (const path of nativeEvents) { + yield { type: 'stdout', text: `${path}\n`, timestamp: Date.now() } as any; + } + while (!signal?.aborted) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + }, + getCommandStatus: async (_id: string) => ({ exitCode: 0 }), + }, + files: { + readFile: async (path: string) => { + const entry = files.get(path); + if (!entry) throw new Error(`File not found: ${path}`); + return entry.content; + }, + writeFiles: async (entries: Array<{ path: string; data?: any }>) => { + for (const entry of entries) { + files.set(entry.path, { content: String(entry.data ?? ''), mtimeMs: Date.now() }); + } + }, + createDirectories: async (_entries: any[]) => undefined, + getFileInfo: async (paths: string[]) => { + const out: Record = {}; + for (const p of paths) { + const entry = files.get(p); + if (entry) { + out[p] = { path: p, modifiedAt: new Date(entry.mtimeMs) }; + } + } + return out; + }, + search: async (entry: { path: string }) => { + return Array.from(files.keys()) + .filter((p) => p.startsWith(entry.path)) + .map((path) => ({ path })); + }, + }, + getInfo: async () => ({ status: { state: 'Running' } }), + kill: async () => { + killed = true; + }, + close: async () => { + closed = true; + }, + _state: { + files, + isKilled: () => killed, + isClosed: () => closed, + touch(path: string) { + const prev = files.get(path); + files.set(path, { content: prev?.content || '', mtimeMs: Date.now() }); + }, + }, + }; +} + +runner + .test('kind 和默认参数正确', async () => { + const sandbox = new OpenSandbox({ kind: 'opensandbox' }); + expect.toEqual(sandbox.kind, 'opensandbox'); + expect.toEqual(sandbox.workDir, '/workspace'); + }) + .test('exec 返回成功结果', async () => { + const sandbox = new OpenSandbox({ kind: 'opensandbox' }); + (sandbox as any).sandbox = createMockSandbox(); + const result = await sandbox.exec('echo hello'); + expect.toEqual(result.code, 0); + expect.toContain(result.stdout, 'hello'); + }) + .test('exec 捕获异常并返回 code=1', async () => { + const sandbox = new OpenSandbox({ kind: 'opensandbox' }); + (sandbox as any).sandbox = createMockSandbox(); + const result = await sandbox.exec('boom'); + expect.toEqual(result.code, 1); + expect.toContain(result.stderr, 'command failed'); + }) + .test('watch mode=off 时返回 disabled id', async () => { + const sandbox = new OpenSandbox({ + kind: 'opensandbox', + watch: { mode: 'off' }, + }); + (sandbox as any).sandbox = createMockSandbox(); + const id = await sandbox.watchFiles(['a.txt'], () => undefined); + expect.toContain(id, 'watch-disabled-'); + }) + .test('polling watcher 能感知 mtime 变化', async () => { + const mock = createMockSandbox(); + await mock.files.writeFiles([{ path: '/workspace/a.txt', data: '1' }]); + + const sandbox = new OpenSandbox({ + kind: 'opensandbox', + watch: { mode: 'polling', pollIntervalMs: 50 }, + }); + (sandbox as any).sandbox = mock; + + let changed = false; + const id = await sandbox.watchFiles(['a.txt'], () => { + changed = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 80)); + mock._state.touch('/workspace/a.txt'); + await new Promise((resolve) => setTimeout(resolve, 120)); + + sandbox.unwatchFiles(id); + expect.toEqual(changed, true); + }) + .test('native watcher 可用时会收到流式文件变更事件', async () => { + const mock = createMockSandbox(); + mock.setNativeWatcher({ + ready: true, + events: ['/workspace/native-a.txt', '/workspace/native-b.txt'], + }); + + const sandbox = new OpenSandbox({ + kind: 'opensandbox', + watch: { mode: 'native', pollIntervalMs: 50 }, + }); + (sandbox as any).sandbox = mock; + + const changed: string[] = []; + const id = await sandbox.watchFiles(['native-a.txt'], (event) => { + changed.push(event.path); + }); + + await new Promise((resolve) => setTimeout(resolve, 80)); + sandbox.unwatchFiles(id); + + expect.toContain(changed, '/workspace/native-a.txt'); + expect.toContain(changed, '/workspace/native-b.txt'); + }) + .test('native watcher 不可用时会自动回退到 polling', async () => { + const mock = createMockSandbox(); + mock.setNativeWatcher({ ready: false }); + await mock.files.writeFiles([{ path: '/workspace/fallback.txt', data: '1' }]); + + const sandbox = new OpenSandbox({ + kind: 'opensandbox', + watch: { mode: 'native', pollIntervalMs: 50 }, + }); + (sandbox as any).sandbox = mock; + + let changed = false; + const id = await sandbox.watchFiles(['fallback.txt'], () => { + changed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 80)); + mock._state.touch('/workspace/fallback.txt'); + await new Promise((resolve) => setTimeout(resolve, 120)); + + sandbox.unwatchFiles(id); + expect.toEqual(changed, true); + }) + .test('disposeAction=kill 时会 kill + close', async () => { + const mock = createMockSandbox(); + const sandbox = new OpenSandbox({ + kind: 'opensandbox', + lifecycle: { disposeAction: 'kill' }, + }); + (sandbox as any).sandbox = mock; + await sandbox.dispose(); + expect.toEqual(mock._state.isKilled(), true); + expect.toEqual(mock._state.isClosed(), true); + }); + +export async function run() { + return await runner.run(); +} + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/tests/unit/infra/sandbox-factory.test.ts b/tests/unit/infra/sandbox-factory.test.ts index 1c65d4a..49f32e7 100644 --- a/tests/unit/infra/sandbox-factory.test.ts +++ b/tests/unit/infra/sandbox-factory.test.ts @@ -1,5 +1,6 @@ import { SandboxFactory } from '../../../src/infra/sandbox-factory'; import { LocalSandbox } from '../../../src/infra/sandbox'; +import { OpenSandbox } from '../../../src/infra/opensandbox'; import { TestRunner, expect } from '../../helpers/utils'; const runner = new TestRunner('SandboxFactory'); @@ -21,6 +22,12 @@ runner const sandbox = factory.create({ kind: 'vfs' }); expect.toEqual(sandbox, dummy); }) + .test('默认注册 opensandbox', async () => { + const factory = new SandboxFactory(); + const sandbox = factory.create({ kind: 'opensandbox' } as any); + expect.toBeTruthy(sandbox instanceof OpenSandbox); + expect.toEqual(sandbox.kind, 'opensandbox'); + }) .test('未注册类型会抛出错误', async () => { const factory = new SandboxFactory();