341 lines
15 KiB
Markdown
341 lines
15 KiB
Markdown
# ACP Terminal API 沙箱化方案
|
||
|
||
> 版本: 1.3.1 | 日期: 2026-04-10 | 状态: 已实现(含 sandbox 抽象层同步)
|
||
|
||
## 问题背景
|
||
|
||
Windows 受限 token (Restricted Token) 沙箱无法在受限进程内创建子进程(EPERM),
|
||
而 ACP 引擎(claude-code-acp-ts、qimingcode)需要 spawn 子进程来执行 bash 命令和 MCP 服务器。
|
||
进程级沙箱化(`serve` 模式)在 Windows 上不可行。
|
||
|
||
## Deep Research 关键发现
|
||
|
||
### claude-code-acp-ts 使用 ACP Terminal API
|
||
|
||
**claude-code-acp-ts 的 bash 工具通过 ACP `terminal/create` 协议方法执行命令**,
|
||
而非直接使用 Node.js `child_process`。
|
||
|
||
源码证据(`~/.qimingclaw/node_modules/claude-code-acp-ts/dist/mcp-server.js:424-432`):
|
||
|
||
```javascript
|
||
if (!agent.clientCapabilities?.terminal || !agent.client.createTerminal) {
|
||
throw new Error("unreachable");
|
||
}
|
||
const handle = await agent.client.createTerminal({
|
||
command: input.command,
|
||
env: [{ name: "CLAUDECODE", value: "1" }],
|
||
sessionId,
|
||
outputByteLimit: 32000,
|
||
});
|
||
```
|
||
|
||
### qimingcode 使用内部 bash 执行
|
||
|
||
qimingcode(基于 opencode)不使用 Terminal API,而是内部直接执行 bash 命令。
|
||
通过 `OPENCODE_CONFIG_CONTENT.sandbox` 配置实现逐命令沙箱化(已在之前 PR 中实现)。
|
||
|
||
### 结论
|
||
|
||
| 引擎 | Bash 执行方式 | 沙箱化方案 |
|
||
|------|-------------|-----------|
|
||
| claude-code | ACP `terminal/create` | Client 端 Terminal Manager + helper.exe |
|
||
| qimingcode | 内部 child_process | `OPENCODE_CONFIG_CONTENT.sandbox` |
|
||
|
||
## 方案设计
|
||
|
||
### 架构
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ Electron Client (AcpEngine) │
|
||
│ │
|
||
│ clientCapabilities: { terminal: true } │
|
||
│ │
|
||
│ buildClientHandler(): │
|
||
│ ├─ sessionUpdate ──→ handleAcpSessionUpdate() │
|
||
│ ├─ requestPermission ──→ handlePermissionRequest() │
|
||
│ │ └─ strictPermissionGuard.ts │
|
||
│ └─ ...terminalManager.getClientHandlers() │
|
||
│ ├─ createTerminal → SandboxInvoker + helper/direct │
|
||
│ ├─ terminalOutput → output buffer │
|
||
│ ├─ waitForExit → exit promise │
|
||
│ ├─ killTerminal → process.kill() │
|
||
│ └─ releaseTerminal → cleanup │
|
||
│ │
|
||
│ qimingcode: OPENCODE_CONFIG_CONTENT.sandbox (独立路径) │
|
||
└─────────────────────────────────────────────────────────┘
|
||
│ │
|
||
▼ ▼
|
||
claude-code-acp-ts qimingcode acp
|
||
(terminal/create) (internal bash)
|
||
```
|
||
|
||
### ACP Terminal API 协议
|
||
|
||
| 方法 | 方向 | 说明 |
|
||
|------|------|------|
|
||
| `terminal/create` | Agent → Client | 创建终端,执行命令 |
|
||
| `terminal/output` | Agent → Client | 获取当前输出 |
|
||
| `terminal/wait_for_exit` | Agent → Client | 等待命令完成 |
|
||
| `terminal/kill` | Agent → Client | 终止命令 |
|
||
| `terminal/release` | Agent → Client | 释放资源 |
|
||
|
||
Agent 必须检查 `clientCapabilities.terminal === true` 才能使用。
|
||
|
||
### 执行流程 (claude-code on Windows)
|
||
|
||
1. Agent 调用 `terminal/create` 发送命令(如 `npm test`)
|
||
2. `AcpTerminalManager.createTerminal()` 接收请求
|
||
3. Windows 下:通过 `SandboxInvoker.buildInvocation()` 构建包装命令
|
||
```
|
||
qiming-sandbox-helper.exe run --mode read-only --cwd <cwd> --policy-json {...} -- npm test
|
||
```
|
||
4. macOS/Linux 下:直接 `spawn(command, args)`
|
||
5. 返回 `terminalId` 给 Agent
|
||
6. Agent 调用 `terminal/output` 获取输出(从 buffer 读取)
|
||
7. Agent 调用 `terminal/wait_for_exit` 等待完成
|
||
8. Agent 调用 `terminal/release` 释放资源
|
||
|
||
### Sandbox Helper JSON 输出
|
||
|
||
`qiming-sandbox-helper.exe run` 返回 JSON:
|
||
```json
|
||
{
|
||
"exit_code": 0,
|
||
"stdout": "test output...",
|
||
"stderr": "",
|
||
"timed_out": false
|
||
}
|
||
```
|
||
|
||
`AcpTerminalManager` 解析此 JSON,提取 `stdout` + `stderr` 放入 output buffer。
|
||
|
||
## 变更文件
|
||
|
||
| 文件 | 变更类型 | 说明 |
|
||
|------|----------|------|
|
||
| `acpTerminalManager.ts` | 修改 | Terminal 生命周期管理;Windows 走 `SandboxInvoker(run)`;平台判断改为 `PlatformAdapter` |
|
||
| `acpClient.ts` | 修改 | ACP 侧 sandbox 启动链细节同步(含平台分支统一入口) |
|
||
| `strictPermissionGuard.ts` | 修改 | strict 写入路径门控继续生效,平台来源统一 |
|
||
| `SandboxInvoker.ts` | 修改 | strict 语义对齐:macOS strict 不含 startup chain;Windows run strict 仅首个 writable root |
|
||
| `platformAdapter.ts` | **新建** | 跨平台统一抽象层(平台能力、命令探测、helper/bwrap 路径解析、推荐后端) |
|
||
| `platformAdapter.test.ts` | **新建** | `PlatformAdapter` 单测覆盖 |
|
||
| `shellEnv.ts` | 修改 | 命令探测与 PATH 分隔符统一走 `PlatformAdapter` |
|
||
| `policy.ts` | 修改 | 平台/后端推荐与 helper 路径解析统一走 `PlatformAdapter` |
|
||
| `SandboxManager.ts` | 修改 | 平台分支入口统一走 `PlatformAdapter` |
|
||
| `serviceBootstrap.ts` | 修改 | 平台识别统一走 `PlatformAdapter` |
|
||
| `processTree.ts` | 修改 | 进程树清理平台分支统一走 `PlatformAdapter` |
|
||
|
||
### 不变的文件
|
||
|
||
| 文件 | 原因 |
|
||
|------|------|
|
||
| `windows-sandbox-helper/main.rs` | `run` 模式已可用 |
|
||
| `sandboxProcessWrapper.ts` | 仍用于 qimingcode env-var 注入 |
|
||
|
||
## 关键设计决策
|
||
|
||
### 1. getClientHandlers() 模式
|
||
|
||
`AcpTerminalManager` 提供 `getClientHandlers()` 方法返回 handler 对象,
|
||
`acpEngine.ts` 通过 spread 操作符注入:
|
||
|
||
```typescript
|
||
private buildClientHandler(): AcpClientHandler {
|
||
return {
|
||
sessionUpdate: ...,
|
||
requestPermission: ...,
|
||
// 一行注入所有 terminal handlers
|
||
...(this.terminalManager?.getClientHandlers() ?? {}),
|
||
};
|
||
}
|
||
```
|
||
|
||
**优点**:减少代码侵入,terminal 逻辑完全封装在 manager 中。
|
||
|
||
### 2. JSON 解析 vs 直接输出
|
||
|
||
Windows sandbox helper `run` 模式返回 JSON 封装的输出。
|
||
`AcpTerminalManager` 在 `spawnProcess()` 中区分两种模式:
|
||
- `parseJson=true`(Windows sandbox):收集完整 JSON,解析后提取 stdout/stderr
|
||
- `parseJson=false`(直接执行):实时流式收集 stdout/stderr
|
||
|
||
### 3. outputByteLimit
|
||
|
||
遵循 ACP 规范:当输出超过 `outputByteLimit` 时,从开头截断保留最新输出。
|
||
标记 `truncated: true` 以通知 Agent。
|
||
|
||
> **注意**:当前实现使用 `string.length` 而非字节数。对于 ASCII 输出两者一致,
|
||
> 对于 CJK 多字节字符,`string.length`(UTF-16 code units)可能小于实际字节数。
|
||
> 实际影响有限,因为 claude-code 设置 `outputByteLimit: 32000`。
|
||
|
||
### 4. 沙箱参数来源
|
||
|
||
`writablePaths`、`networkEnabled` 和 `mode` 从 sandbox 配置传入,而非硬编码:
|
||
|
||
```typescript
|
||
this.terminalManager = new AcpTerminalManager({
|
||
windowsSandboxHelperPath: sandboxConfig.windowsSandboxHelperPath,
|
||
windowsSandboxMode: sandboxConfig.windowsSandboxMode,
|
||
networkEnabled: sandboxConfig.networkEnabled ?? true,
|
||
writablePaths: sandboxConfig.projectWorkspaceDir
|
||
? [sandboxConfig.projectWorkspaceDir]
|
||
: [],
|
||
mode: sandboxConfig.mode,
|
||
});
|
||
```
|
||
|
||
`createTerminal` 不会把 `cwd` 追加到 `writablePaths`;而是执行以下策略:
|
||
- `cwd` 在可写根内:直接使用
|
||
- `cwd` 缺失或越界:回退到首个可写根(workspace-first)
|
||
|
||
### 5. 竞态防护
|
||
|
||
Terminal 在 spawn 之前注册到 Map,避免快速退出的进程导致 "Terminal not found" 错误:
|
||
|
||
```typescript
|
||
// 注册在前,spawn 在后
|
||
this.terminals.set(terminalId, session);
|
||
this.spawnProcess(session, ...);
|
||
```
|
||
|
||
### 6. 会话级终端清理
|
||
|
||
当 `abortSession()` 取消会话时,自动终止该会话关联的所有终端进程:
|
||
|
||
```typescript
|
||
if (this.terminalManager) {
|
||
await this.terminalManager.releaseForSession(sessionId);
|
||
}
|
||
```
|
||
|
||
`releaseForSession()` 遍历所有 terminal,按 sessionId 过滤后逐一 kill + release。
|
||
|
||
### 7. 并发限制
|
||
|
||
默认最多 50 个并发终端,防止失控的 Agent 耗尽系统资源:
|
||
|
||
```typescript
|
||
private static readonly MAX_CONCURRENT = 50;
|
||
|
||
async createTerminal(...) {
|
||
if (this.terminals.size >= AcpTerminalManager.MAX_CONCURRENT) {
|
||
throw new Error(
|
||
`Terminal limit reached (${AcpTerminalManager.MAX_CONCURRENT}). Release existing terminals first.`
|
||
);
|
||
}
|
||
// ...
|
||
}
|
||
```
|
||
|
||
## 与现有系统的关系
|
||
|
||
### macOS/Linux 进程级沙箱
|
||
|
||
在 macOS/Linux 上,ACP 引擎进程仍通过 seatbelt/bwrap 进行进程级沙箱化。
|
||
Terminal API 执行的命令也在此沙箱内运行,无需额外包装。
|
||
|
||
### qimingcode env-var 沙箱
|
||
|
||
qimingcode 不使用 Terminal API,其 bash 执行通过 `OPENCODE_CONFIG_CONTENT.sandbox`
|
||
配置路由到 sandbox helper。这是独立的代码路径,不受 Terminal API 实现影响。
|
||
|
||
另外,在 `strict` 模式下,`qimingcode` 的 ACP `requestPermission` 路径会额外经过
|
||
`strictPermissionGuard.ts`:
|
||
- 仅识别写入类请求应用路径门控(非写入请求跳过)
|
||
- 允许写入根目录:`workspace` + `temp(TMP/TEMP/TMPDIR)` + `isolatedHome` + `isolatedHome/tmp`(strict 下不含 `appData`)
|
||
- 路径缺失时 fail-closed(拒绝)
|
||
- 写入类权限仅选择 `allow_once`(不走 `allow_always`)
|
||
|
||
同时,`qimingcode` warmup 复用现在对 sandbox policy 变更敏感:
|
||
- warmup 启动时记录 `QIMING_AGENT_WARMUP_SANDBOX_POLICY_FP`
|
||
- 复用前比对当前 policy 指纹
|
||
- 旧 warmup 缺少该标记或指纹不一致时,立即放弃复用并冷启动
|
||
- 确保 sandbox mode/policy 配置修改后对“下一次新会话”立即生效
|
||
|
||
## 验证步骤
|
||
|
||
1. 启动 Electron 开发模式
|
||
2. 使用 claude-code 引擎发送包含 bash 命令的 prompt
|
||
3. 检查日志中 `createTerminal` 调用(而非 EPERM 错误)
|
||
4. 确认 Windows 下命令通过 `qiming-sandbox-helper.exe run` 执行
|
||
5. 确认 `terminalOutput` 返回正确输出
|
||
6. 确认 `waitForExit` 返回正确退出码
|
||
7. 确认 qimingcode 行为不受影响
|
||
8. 确认 macOS/Linux 沙箱行为不受影响
|
||
9. 验证 abort 后终端进程被正确清理
|
||
10. 验证并发超过 50 时抛出限制错误
|
||
11. (qimingcode + strict)验证 ACP 调试日志:
|
||
- `strict writable roots snapshot`(每个 session 一次)
|
||
- `strict permission evaluation`
|
||
- `strict permission skipped (non-write request)`
|
||
- `strict write permission blocked`
|
||
- `strict write permission allowed_once`
|
||
12. (warmup)验证 sandbox policy 相关调试日志:
|
||
- `warmup sandbox policy snapshot`
|
||
- `warmup sandbox policy compatibility check`
|
||
- 发生策略变更时出现:`Sandbox policy changed, skipping warmup reuse`
|
||
|
||
## 安全注意事项
|
||
|
||
### SandboxMode 对 Windows per-command 的影响
|
||
|
||
`SandboxMode`(strict / compat / permissive)影响 Windows 沙箱行为,包括 `run` 和 `serve` 子命令:
|
||
|
||
#### `run` 子命令(per-command,claude-code bash)
|
||
|
||
| Mode | `writable_roots` | Token 限制 | APPDATA |
|
||
|------|-----------------|-----------|---------|
|
||
| **strict** | 仅首个传入路径(workspace-first) | WRITE_RESTRICTED 保持启用 | 不包含 |
|
||
| **compat**(默认) | 全部传入路径 | WRITE_RESTRICTED 保持启用 | 包含 |
|
||
| **permissive** | 全部传入路径 | `--no-write-restricted` 放松 token | 包含 |
|
||
|
||
#### `serve` 子命令(进程级,qimingcode 整个进程)
|
||
|
||
| Mode | WRITE_RESTRICTED | 可写路径 | 说明 |
|
||
|------|-----------------|---------|------|
|
||
| **strict** | `--write-restricted` 启用 | workspace + TEMP/TMP | 最小写入面,APPDATA 不可写 |
|
||
| **compat** | `--write-restricted` 启用 | workspace + TEMP/TMP + APPDATA/LOCALAPPDATA | 引擎基础设施可写 |
|
||
| **permissive** | 不传递 flag(默认 false) | 无限制 | 仅受限 token,无写入保护 |
|
||
|
||
> **关键变更 (v1.2.0)**: `serve` 子命令新增 `--write-restricted` CLI flag。
|
||
> strict/compat 模式下启用,permissive 模式下禁用。
|
||
> 这修复了 qimingcode 内部 bash 工具可绕过沙箱写入 Desktop 的问题。
|
||
> APPDATA/LOCALAPPDATA 的包含/排除由 Rust helper 的 `compute_allow_paths()`
|
||
> 根据 policy JSON 中的 `sandbox_mode` 字段统一控制。
|
||
|
||
> 对 `qimingcode` 来说,strict 下除了 helper 的 `writable_roots` 外,ACP 权限层还会执行
|
||
> `strictPermissionGuard` 二次门控(写入路径必须落在 workspace/temp/isolatedHome)。
|
||
|
||
### macOS/Linux 上的 per-command 沙箱
|
||
|
||
当前 `AcpTerminalManager` 仅在 Windows 上启用 per-command 沙箱包装。
|
||
macOS/Linux 上 terminal 命令直接执行(进程级沙箱由 seatbelt/bwrap 在引擎级别提供)。
|
||
|
||
### 跨平台 SandboxMode 差异
|
||
|
||
| 平台 | strict | compat | permissive |
|
||
|------|--------|--------|-----------|
|
||
| Linux (bwrap) | 最小 ro-bind(`/usr`, `/bin`, `/sbin`, `/lib`, `/lib64`, `/etc`, `/opt`, `/usr/local`) | 全局 ro-bind `/` | 完整 rw bind,无 namespace 隔离 |
|
||
| macOS (seatbelt) | 仅命令本身在 exec allowlist(不含 startup chain) | 命令 + startup chain 在 exec allowlist | 全局 file-write + unrestricted process-exec |
|
||
| Windows (helper `run`) | `writable_roots` 仅首个路径 + WRITE_RESTRICTED | `writable_roots` 全部路径 + WRITE_RESTRICTED | `writable_roots` 全部路径 + `--no-write-restricted` |
|
||
| Windows (helper `serve`) | `--write-restricted`,仅 workspace + TEMP/TMP | `--write-restricted`,workspace + TEMP/TMP + APPDATA | 无 WRITE_RESTRICTED(进程级不限制写入) |
|
||
|
||
### Windows 非 sandbox 路径的 shell 注入
|
||
|
||
当 sandbox 未启用且平台为 Windows 时,`spawnProcess` 使用 `shell: true`。
|
||
如果 Agent 发送的命令包含 shell 元字符,它们会被解释。
|
||
安全边界在 sandbox helper 级别(受限 token),sandbox 未启用时命令以用户权限运行。
|
||
|
||
### 无命令白名单/黑名单
|
||
|
||
Terminal Manager 执行 Agent 发送的所有命令,安全边界在 sandbox helper 级别。
|
||
如果 sandbox 不可用,命令以完整用户权限运行。
|
||
|
||
## 参考
|
||
|
||
- [ACP Terminal API 规范](https://agentclientprotocol.com/protocol/terminals)
|
||
- [ACP SDK 源码](node_modules/@agentclientprotocol/sdk/dist/acp.js)
|
||
- [Codex Windows Sandbox](https://github.com/openai/codex) — 参考架构
|
||
- [windows-sandbox-helper](../../crates/windows-sandbox-helper/src/main.rs) — Rust helper 源码
|