Article Detail

构建浏览器沙盒版 SWE Agent

2026-05-14MDX POCzh

构建浏览器沙盒版 SWE Agent

终端版的 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 沙盒管理器实现

将 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 核心 复用终端版 同一套引擎和工具

API 路由

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 客户端集成

前端通过单个 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 通信协议

浏览器和服务器之间的所有通信通过每个会话的单条 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,
}

迭代路线图

如果你想自己动手实现,以下是分阶段计划:

阶段 1:基础后端(第 1-5 天)

HTTP API 服务器 + Docker 沙盒管理器 + 会话 CRUD + 基础 JWT 认证。

阶段 2:Agent 集成(第 6-10 天)

复用终端版的 QueryEngine。创建沙盒适配工具(在容器内执行的 Bash、FileRead、FileWrite)。

阶段 3:WebSocket 流式通信(第 11-14 天)

消息协议的实时流式传输。支持取消请求和权限请求/响应通道。

阶段 4:前端基础(第 15-20 天)

Vite + React 项目搭建。聊天面板、WebSocket 客户端、打字机效果文本渲染、工具调用 UI 卡片、响应式布局。

阶段 5:沙盒可视化(第 21-25 天)

读取容器目录结构的文件树组件。xterm.js 实时终端。文件预览与语法高亮。文件变更自动刷新。

阶段 6:安全强化(第 26-30 天)

命令过滤、资源限制、超时机制、网络隔离、镜像签名验证。

阶段 7:用户体验(第 31-35 天)

会话历史、断线重连、Token 用量显示、权限对话框 UI、暗色/亮色主题、移动端适配。

阶段 8:生产部署(第 36-40 天)

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))
    }
  }
}

WebContainer 替代方案

对于不需要完整 Docker 隔离的轻量场景,StackBlitz 的 WebContainer 技术可以在浏览器中通过 Service Worker 运行 Node.js。无需管理服务器,但受限于浏览器运行时的能力——不支持 Docker、无法运行任意二进制文件、内存受限。

与终端版的关系

两个版本共享核心,但在边界处有所分化:

终端 Agent                      沙盒 Agent
─────────────                   ─────────────
本地文件系统                     隔离沙盒文件系统
系统 Shell                      容器内 Shell
用户电脑资源                     服务器资源
本地信任模型                     JWT 认证 + 配额
Ink 终端 UI                     React 网页 UI

真正的优势在于代码复用:QueryEnginebuildTool 工厂、消息类型和 Agent 循环逻辑在两个版本中完全相同。只有工具实现和 UI 层不同。如果你已经构建过一个终端 Agent,那浏览器沙盒版已经完成了约 60%。