构建浏览器沙盒版 SWE Agent
终端版的 AI 编程助手有个天然的限制:它跑在你的机器上,操作你的真实文件系统,用你的用户权限执行命令。别人想试用一下,得先装好整套环境。如果你做过一个终端 Agent,大概率想过这个问题——要是打开浏览器就能用就好了。
这就是浏览器沙盒 Agent 要做的事。用户在网页上聊天,实际的代码执行发生在一个远程服务器上的 Docker 容器里。不需要本地安装,碰不到真实文件系统,不存在"我机器上能跑啊"的问题。
终端版的 AI 编程助手有个天然的限制:它跑在你的机器上,操作你的真实文件系统,用你的用户权限执行命令。别人想试用一下,得先装好整套环境。如果你做过一个终端 Agent,大概率想过这个问题——要是打开浏览器就能用就好了。
这就是浏览器沙盒 Agent 要做的事。用户在网页上聊天,实际的代码执行发生在一个远程服务器上的 Docker 容器里。不需要本地安装,碰不到真实文件系统,不存在"我机器上能跑啊"的问题。
核心架构说起来很简单:
浏览器 → 网页聊天界面 → WebSocket → 服务器进程 → Docker 沙盒容器
(隔离文件系统 + Shell)终端 Agent 读你本地文件、在你机器上跑命令。沙盒 Agent 做同样的事——但在一个按需创建的容器里执行,会话结束就销毁。
| 问题 | 终端版 | 沙盒版 |
|---|---|---|
| 文件安全 | 直接操作用户文件 | 操作隔离沙盒 |
| 代码执行 | 直接执行系统命令 | 沙盒内受限执行 |
| 远程协作 | 需 Bridge/SSH | 浏览器 URL 分享 |
| 环境一致性 | 依赖用户本地环境 | 标准化容器环境 |
| 资源限制 | 使用用户全部资源 | 可控制 CPU/内存上限 |
完整系统布局如下:
┌──────────────────────────────────────────────────────────┐
│ 浏览器 (React) │
│ <App> │
│ ├─ <ConversationPanel> │
│ │ ├─ <MessageList> │
│ │ └─ <MessageInput> │
│ ├─ <FileExplorer> 沙盒文件树 │
│ ├─ <Terminal> 容器实时终端 │
│ └─ <ToolCallUI> 工具调用实时可视化 │
└───────────────────────┬──────────────────────────────────┘
│ WebSocket (wss://)
▼
┌──────────────────────────────────────────────────────────┐
│ 服务器 (Node.js/Bun) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ HTTP Server │ │ WebSocket │ │ Session │ │
│ │ (Hono/Express)│ │ Server (WS) │ │ Manager │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ┌──────┴─────────────────┴──────────────────┴───────┐ │
│ │ QueryEngine + Agent Loop │ │
│ │ (复用终端版的引擎和工具系统) │ │
│ └──────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────────────────────┴─────────────────────────────┐ │
│ │ Sandbox Manager │ │
│ │ ┌────────────┐ ┌────────────┐ ┌─────────────┐ │ │
│ │ │ Docker │ │ 临时文件系统 │ │ 资源限制器 │ │ │
│ │ │ Provider │ │ Ephemeral │ │ Resource │ │ │
│ │ └────────────┘ └────────────┘ └─────────────┘ │ │
│ └──────────────────────┬─────────────────────────────┘ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ Docker Container │ │
│ │ (Ubuntu 22.04) │ │
│ │ ├─ /workspace/ │ │
│ │ ├─ bash │ │
│ │ ├─ git, node 等 │ │
│ │ └─ 隔离文件系统 │ │
│ └────────────────────┘ │
└──────────────────────────────────────────────────────────┘关键思路是:核心 Agent 循环是共享的。终端版和沙盒版用同一个 QueryEngine、同一个 buildTool 工厂、同一套工具执行管道。只有工具的具体实现变了——不再操作本地系统,而是发送命令到 Docker 容器里执行。
Docker 容器是平衡安全性和可用性的最佳选择:
| 方案 | 隔离性 | 开销 | 适用场景 |
|---|---|---|---|
| Docker | 良好 | 低 | 默认首选 |
| gVisor | 更强 | 中等 | 高安全部署 |
| Firecracker 微 VM | 最强 | 高 | 多租户 SaaS |
| WebContainer | 有限 | 无(浏览器) | 轻量演示 |
生产级沙盒需要多层防御:
1. 网络隔离
├─ 默认禁止对外出站连接
├─ 白名单允许域名(npm、git、pypi)
└─ 可配置代理出口
2. 文件系统隔离
├─ 容器内只有 /workspace
├─ 无法访问宿主机文件
└─ 会话结束自动销毁
3. 资源限制
├─ CPU:默认 1 核,可扩至 4 核
├─ 内存:默认 1GB,可扩至 8GB
├─ 磁盘:默认 5GB
├─ 命令超时:10 秒
└─ 总运行时长:1 小时
4. 命令过滤
├─ 禁止:网络扫描、加密挖矿、提权
├─ 限制:curl/wget 白名单
└─ 监控:实时审计日志
5. 清理机制
├─ 闲置 30 分钟自动销毁
├─ 资源强制回收
└─ 所有数据不可恢复删除将 Docker SDK 包装成简单的生命周期管理:
class DockerSandbox {
private container: Docker.Container | null = null
async create(image = 'swe-agent:base'): Promise<void> {
this.container = await docker.createContainer({
Image: image,
WorkingDir: '/workspace',
Cmd: ['/bin/bash', '-c', 'tail -f /dev/null'],
HostConfig: {
Memory: 1024 * 1024 * 1024, // 1GB
NanoCpus: 1_000_000_000, // 1 CPU
ReadonlyRootfs: true, // 只读根文件系统
NetworkMode: 'none', // 默认无网络
AutoRemove: true, // 停止自动删除
Binds: [`${tempDir}:/workspace:rw`],
},
})
await this.container.start()
}
async execCommand(command: string, timeout = 10_000) {
const exec = await this.container!.exec({
Cmd: ['bash', '-c', command],
AttachStdout: true,
AttachStderr: true,
})
return collectOutput(exec, timeout)
}
async destroy(): Promise<void> {
await this.container?.stop({ t: 0 })
}
}基础 Docker 镜像很简单——Ubuntu 22.04 加上常用开发工具:git、curl、Node.js、Python、bun、ripgrep。容器内以非 root 用户(swe)运行。
| 组件 | 选择 | 理由 |
|---|---|---|
| HTTP 框架 | Hono 或 Express | 轻量、成熟 |
| WebSocket | ws 库 |
原生、最小依赖 |
| 沙盒管理 | dockerode |
Docker API 绑定 |
| 数据库 | SQLite 或 PostgreSQL | 会话持久化 |
| 认证 | JWT + Session | 无状态 API 令牌 |
| Agent 核心 | 复用终端版 | 同一套引擎和工具 |
POST /api/sessions 创建会话(启动沙盒)
GET /api/sessions/:id 获取会话详情
POST /api/sessions/:id/query 发送消息
GET /api/sessions/:id/status 获取会话状态
DELETE /api/sessions/:id 销毁会话
WS /ws/sessions/:id 流式通信最巧妙的地方在于需要改动的代码非常少。终端 Agent 的工具在本地执行,沙盒版只需要把执行目标换一下:
// 沙盒 Bash 工具 —— 在容器内执行命令
const SandboxBashTool = buildTool({
name: 'Bash',
inputSchema: bashInputSchema,
async call({ command, timeout }, context) {
const sandbox = context.getSandbox() // 当前容器实例
const result = await sandbox.execCommand(command, timeout)
return { data: result }
},
})
// 沙盒文件读取 —— 在容器内执行 cat
const SandboxFileReadTool = buildTool({
name: 'Read',
inputSchema: fileReadInputSchema,
async call({ file_path }, context) {
const sandbox = context.getSandbox()
const result = await sandbox.execCommand(`cat "${file_path}"`)
return { data: { content: result.stdout } }
},
})每个会话绑定一个容器实例、一个 Agent 引擎和用户数据:
class SessionManager {
private sessions = new Map<string, Session>()
async createSession(userId: string, options?: SessionOptions) {
// 1. 创建 Docker 容器
const sandbox = await createSandbox(options?.sandboxConfig)
// 2. 创建指向该沙盒的 Agent 引擎
const engine = new QueryEngine({
cwd: '/workspace',
tools: createSandboxToolsFor(sandbox),
})
// 3. 存储会话记录
const session = {
id: randomUUID(), userId, sandbox, engine,
status: 'active', createdAt: new Date(),
}
this.sessions.set(session.id, session)
// 4. 闲置 30 分钟自动销毁
startIdleTimer(session.id, 30 * 60 * 1000,
() => this.destroySession(session.id))
return session
}
async destroySession(sessionId: string) {
const session = this.sessions.get(sessionId)
if (!session) return
await session.sandbox.destroy()
this.sessions.delete(sessionId)
}
}网页 UI 镜像了终端 REPL 的功能,但交互更丰富:
<App>
├── <Sidebar>
│ ├── <SessionList>
│ └── <UserMenu>
│
├── <ChatPanel>
│ ├── <MessageList>
│ │ ├── <UserMessage>
│ │ ├── <AssistantMessage>
│ │ │ └── <StreamingText> 打字机效果
│ │ ├── <ToolCallBlock>
│ │ │ ├── <ToolCallHeader>
│ │ │ ├── <ToolCallInput>
│ │ │ └── <ToolCallResult>
│ │ └── <SystemMessage>
│ │
│ └── <MessageInput>
│
├── <FilePanel>
│ ├── <FileExplorer> 沙盒文件树
│ └── <FilePreview>
│
└── <TerminalPanel>
└── <Terminal> (xterm.js)布局分成三个面板:聊天面板(左)、文件浏览器(中)、实时终端(右)。文件浏览器和终端都通过服务器连接到沙盒容器,实时反映容器内的状态。
前端通过单个 WebSocket 连接与服务器通信:
function useSessionWebSocket(sessionId: string) {
const [connectionStatus, setConnectionStatus] =
useState<'connecting' | 'connected' | 'disconnected'>('disconnected')
const connect = useCallback(() => {
const ws = new WebSocket(`wss://server.com/ws/sessions/${sessionId}`)
ws.onopen = () => setConnectionStatus('connected')
ws.onclose = () => setConnectionStatus('disconnected')
return ws
}, [sessionId])
const sendMessage = useCallback((content: string) => {
const ws = wsRef.current
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'query', content }))
}
}, [])
// 断线自动重连
useEffect(() => {
if (connectionStatus === 'disconnected') {
const timer = setTimeout(connect, 1000)
return () => clearTimeout(timer)
}
}, [connectionStatus, connect])
return { connect, sendMessage, connectionStatus }
}不要一次性显示全部响应,而是用打字机效果逐字渲染:
function StreamingText({ content }: { content: string }) {
const [displayed, setDisplayed] = useState('')
const indexRef = useRef(0)
useEffect(() => {
if (!content) return
const timer = setInterval(() => {
if (indexRef.current < content.length) {
setDisplayed(prev => prev + content[indexRef.current]!)
indexRef.current++
} else {
clearInterval(timer)
}
}, 10)
return () => clearInterval(timer)
}, [content])
return (
<div className="prose whitespace-pre-wrap">
{displayed}
<span className="animate-pulse">▊</span>
</div>
)
}浏览器和服务器之间的所有通信通过每个会话的单条 WebSocket 连接进行。协议采用简单的 JSON 消息:
// 客户端 → 服务器
type ClientMessage =
| { type: 'query'; content: string }
| { type: 'cancel' }
| { type: 'permission_response'; requestId: string; behavior: 'allow' | 'deny' | 'always_allow' }
| { type: 'keepalive' }
// 服务器 → 客户端
type ServerMessage =
| { type: 'stream_start'; messageId: string }
| { type: 'text_delta'; delta: string }
| { type: 'text_done' }
| { type: 'tool_use'; toolUseId: string; toolName: string; input: object }
| { type: 'tool_result_start'; toolUseId: string }
| { type: 'tool_result_delta'; toolUseId: string; delta: string }
| { type: 'tool_result_done'; toolUseId: string; isError: boolean }
| { type: 'permission_request'; requestId: string; toolName: string; input: object }
| { type: 'error'; message: string }
| { type: 'done'; reason: string }
| { type: 'keepalive' }服务端 WebSocket 处理器管理完整生命周期:
wss.on('connection', (ws, req) => {
const sessionId = extractSessionId(req.url!)
const session = sessionManager.get(sessionId)
ws.on('message', async (data) => {
const msg = JSON.parse(data.toString())
switch (msg.type) {
case 'query':
for await (const event of handleQuery(sessionId, msg.content)) {
if (ws.readyState !== WebSocket.OPEN) break
ws.send(JSON.stringify(event))
}
break
case 'cancel':
session.abortController?.abort()
break
case 'permission_response':
session.permissionResolver?.resolve(msg)
break
}
})
})采用 JWT 令牌的简单认证流程:
POST /api/auth/login → { token, expiresIn }
POST /api/sessions → Authorization: Bearer <token>
→ { sessionId, wsUrl }
WS /ws/sessions/:id → 连接时验证令牌不同套餐层级有不同的资源限制:
const FREE_LIMITS = {
maxSessionsPerDay: 5,
maxDurationPerSession: 30 * 60 * 1000, // 30 分钟
maxCommandsPerSession: 100,
sandboxMemory: 512, // MB
sandboxCpu: 0.5, // 核
}
const PRO_LIMITS = {
maxSessionsPerDay: 50,
maxDurationPerSession: 4 * 60 * 60 * 1000, // 4 小时
maxCommandsPerSession: 2000,
sandboxMemory: 4096, // MB
sandboxCpu: 4,
}如果你想自己动手实现,以下是分阶段计划:
HTTP API 服务器 + Docker 沙盒管理器 + 会话 CRUD + 基础 JWT 认证。
复用终端版的 QueryEngine。创建沙盒适配工具(在容器内执行的 Bash、FileRead、FileWrite)。
消息协议的实时流式传输。支持取消请求和权限请求/响应通道。
Vite + React 项目搭建。聊天面板、WebSocket 客户端、打字机效果文本渲染、工具调用 UI 卡片、响应式布局。
读取容器目录结构的文件树组件。xterm.js 实时终端。文件预览与语法高亮。文件变更自动刷新。
命令过滤、资源限制、超时机制、网络隔离、镜像签名验证。
会话历史、断线重连、Token 用量显示、权限对话框 UI、暗色/亮色主题、移动端适配。
Docker 化部署、CI/CD、沙盒预热、横向扩展、监控日志、速率限制。
预创建容器消除冷启动延迟:
class SandboxPool {
private pool: DockerSandbox[] = []
private minSize = 5
async acquire(): Promise<DockerSandbox> {
if (this.pool.length > 0) return this.pool.pop()!
return DockerSandbox.create()
}
async release(sandbox: DockerSandbox): Promise<void> {
await sandbox.cleanup()
if (this.pool.length < this.maxSize) this.pool.push(sandbox)
}
async warmUp(): Promise<void> {
while (this.pool.length < this.minSize) {
this.pool.push(await DockerSandbox.create())
}
}
}多个用户可以连接到同一个沙盒会话——适合结对调试或演示:
class CollaborativeSession extends Session {
participants: Map<string, Participant> = new Map()
addParticipant(userId: string, ws: WebSocket) {
this.broadcast({ type: 'participant_joined', userId })
}
broadcast(message: ServerMessage, excludeUserId?: string) {
for (const [uid, p] of this.participants) {
if (uid === excludeUserId) continue
p.ws.send(JSON.stringify(message))
}
}
}对于不需要完整 Docker 隔离的轻量场景,StackBlitz 的 WebContainer 技术可以在浏览器中通过 Service Worker 运行 Node.js。无需管理服务器,但受限于浏览器运行时的能力——不支持 Docker、无法运行任意二进制文件、内存受限。
两个版本共享核心,但在边界处有所分化:
终端 Agent 沙盒 Agent
───────────── ─────────────
本地文件系统 隔离沙盒文件系统
系统 Shell 容器内 Shell
用户电脑资源 服务器资源
本地信任模型 JWT 认证 + 配额
Ink 终端 UI React 网页 UI真正的优势在于代码复用:QueryEngine、buildTool 工厂、消息类型和 Agent 循环逻辑在两个版本中完全相同。只有工具实现和 UI 层不同。如果你已经构建过一个终端 Agent,那浏览器沙盒版已经完成了约 60%。