- AI Agent
- 人工智能
- 代码智能体
- 交互助手
【免费下载链接】openchamber
Agentic Development Environment based on OpenCode AI agent
OpenChamber 的“隔离空间(isolated spaces)”功能让 Agent 在一个容器副本中工作,而 Host 与空间之间的所有请求都由一层薄薄的Dispatcher转发。本文基于仓库docs/isolated-spaces/stage-0/e2-dispatcher-recognition.md这份 2026-09-19 的纯代码阅读实验(stage-0 实验 E2)展开,完整盘点 UI 到服务器之间的每一条请求通道,论证为什么“服务端读请求内容来猜归属”(方案 A)不可行,并给出最终采纳的“客户端在 URL 上加/api/spaces/<id>/前缀”(方案 B)的落地规则、两条服务端守卫与特殊情形处理表。读完后你将对 Dispatcher 的寻址边界、UI 侧runtimeFetch与 SDK 包装器的改造点,以及“守卫只拒绝、永不路由”这一安全原则形成完整认识。
一、背景:Dispatcher 要解决什么问题
在 OpenChamber 的隔离空间设计中(见 DESIGN.md),每个 space 是一个独立容器,内部运行与 Host 相同的一对进程:OpenChamber server + OpenCode。Host 与 space 之间只通过 space 内的 OpenChamber server 通信,空间内的代码路径在两侧保持一致,形如/spaces/<id>/<repo>。
Dispatcher是位于现有服务器前的一层薄代理(DESIGN.md):它把“属于某个 space 的请求”原样转发给该 space 内的服务器。于是最核心的问题随之而来——Dispatcher 如何识别一个请求属于哪个 space?
本实验(stage-0 的 E2)只做代码阅读、没有运行任何东西,结论基于对仓库全量请求通道的盘点。文档约定的路径缩写沿用如下:ui/指packages/ui/src/,server/指packages/web/server/;SDK 行号针对@opencode-ai/sdk1.18.31 的dist/v2/client.js。
二、两个候选方案:服务端识别(A)与客户端寻址(B)
- 方案 A:服务端识别——Dispatcher 像中间人一样嗅探请求里的 query、header、JSON body、id,从而推断请求该发给哪个 space,然后“整体转发(forward whole)”。
- 方案 B:客户端寻址——由客户端(UI)在调用时自行把目录映射成 URL 前缀
/api/spaces/<id>/,Dispatcher 只认这一个前缀。
实验的Verdict明确选择 B,理由可以压缩成三条:
- 只匹配一个东西:Dispatcher 唯一识别依据是 URL 前缀
/api/spaces/<id>/。它从不读目录、不读 body、不读 id 来决定请求去向。 - A 作为“整体转发”根本不成立:终端 socket 是宿主与所有空间共用的单一多路复用 socket;dev tunnel 只携带端口号;大约十个路由族要么把目录放在 JSON body 里、要么完全不携带。
- B 的成本极低:因为 UI 的每一个请求都已经经过两个接缝(
runtimeFetch与 runtime URL 解析器),ui/与packages/web/src中不存在裸写的fetch('/api...')。服务器端只需加两条小型守卫,任何漏网的调用点都会响亮地失败,绝不会到达错误的地方。
三、请求通道完整盘点(Inventory)
实验对 UI 到服务器的每一条通道逐一登记,核心结论是:目录信息如何传递,各通道并不一致。下表是文档的完整盘点(路径已转为仓库根相对路径):
| 通道 | 位置 | 调用点是否知道目录 | 当前如何传递目录 |
|---|---|---|---|
| SDK 客户端工厂 | packages/ui/src/lib/opencode/client.ts#L285-L355,所有调用都以Request形式经过runtimeFetch | n/a | base URL 在客户端创建时固定(client.ts的createRuntimeOpencodeClient),runtime 切换时重建 |
| SDK 作用域客户端 | packages/ui/src/lib/opencode/client.ts#L543-L553 | 总是 | SDK 设置x-opencode-directory头,URI 编码(SDKclient.js对应逻辑;本仓库包装器在client.ts中以encodeURIComponent写入,见 packages/ui/src/lib/opencode/client.ts#L269)。GET/HEAD 时 SDK 将其移入?directory= |
无作用域客户端 + 每次调用传directory | 约 40 处client.ts调用点、packages/ui/src/sync/session-actions.ts | 尽力而为:requestDirectory ?? currentDirectory,两者都空则省略 | ?directory=query |
| 不带 directory 参数的 SDK 调用 | auth.set/remove、global.*、experimental.controlPlane.moveSession、provider 页面 | 无 | 无 |
| Git | packages/ui/src/lib/gitApiHttp.ts#L135-L144(唯一buildUrl助手,68 处调用) | 总是 | ?directory= |
| 文件(web RuntimeAPI) | packages/web/src/api/files.ts#L64-L67 及各方法 | 路径总是有;header 是环境当前目录(packages/web/src/api/index.ts) | ?path=或 JSON bodypath,外加原始(未编码)x-opencode-directory |
| 文件上传 | packages/web/src/api/files.ts#L233-L246 | 有 | ?path=,body 是流式application/octet-stream二进制 |
| 终端 HTTP | packages/ui/src/lib/terminalApi.ts(create 用 bodycwd、list 用?cwd=、/api/terminal/:id/...、touch的 body 是 id 列表) | UI store 以目录为键(packages/ui/src/stores/useTerminalStore.ts) | 混杂:body、query、仅 id |
| 终端 WebSocket | packages/ui/src/lib/terminalApi.ts#L185,单例TerminalTransport | 无 | 一个 socket,terminal id 在每个帧内部 |
| 全局事件 WebSocket 与 SSE | packages/ui/src/sync/event-pipeline.ts | n/a | 所有目录共用一条流,每个事件自带 directory 字段 |
| Dev tunnel | packages/ui/src/lib/browser/devTunnel.ts#L39-L40 | 无 | 仅?port= |
| Dev servers、probe、空闲端口 | packages/ui/src/lib/browser/devServers.ts、packages/ui/src/lib/detectDevServer.ts | 无 | 无 |
| 听写、guests、通知、OpenChamber 事件 | dictation-client.ts、guests/surface-client.ts、useWebNotificationStream.ts、openchamberEvents.ts | n/a | Host 服务,从不属于 space |
| 认证资源 | packages/ui/src/apps/MobileFilesSurface.tsx 等(/api/fs/raw、/api/fs/serve/...)、markdownImageAssets.ts、packages/ui/src/lib/runtime-url.ts#L157 | 路径总是有 | ?path=或路径段,外加oc_url_token |
| 以 session 为键的 OpenChamber 路由 | 见下一节 | 来自 session 记录 | URL 路径中的 id |
盘点总数:378 个runtimeFetch(调用分布在 107 个文件中;直接使用解析器的开启者有 5 个 WebSocket、2 个 SSE、5 个资源 URL 构造器。这个“所有请求都经过runtimeFetch”的事实是方案 B 之所以便宜的根本前提——我在源码中确认,packages/ui/src/lib/runtime-fetch.ts#L263-L329 的runtimeFetch是唯一的统一出口,且installRuntimeFetchBridge甚至把window.fetch也收编进来(packages/ui/src/lib/runtime-fetch.ts#L333-L399)。
四、没有目录信息的请求:问题清单
表三列出的请求族,是方案 A 必须逐个“猜”的对象,也是文档最想暴露的脆弱点:
| 请求 | 证据 | 当前靠什么标识目标 |
|---|---|---|
/api/terminal/ws | terminalApi.ts;帧attach、write携带s: sessionId | 每帧 terminal id。Host 与 space 的终端共用同一 socket |
/api/terminal/:id/resize、appearance、restart、DELETE、force-kill、touch | terminalApi.ts | terminal id。touch发送的批次可跨越 Host 与多个 space |
/api/dev-tunnel?port= | devTunnel.ts;服务器连接127.0.0.1:port(server/lib/dev-tunnel/runtime.js) | 端口。端口 3000 可能同时存在于 Host 和每个 space |
/api/dev-servers、/api/system/probe-url | devServers.ts | 无 |
| 目录未知时的 session 调用 | session-actions.ts回退到当前目录;getSessionReplyClient回退到无作用域客户端;rejectQuestion只使用环境目录 | 猜测。猜错今天就会打到 Host 上 |
| 权限与提问回复 | session-actions.ts | 请求目录 → session 目录 → 当前目录 |
| 消息队列 | ui/stores/messageQueueStore.ts(/api/message-queue/sessions/:id、全局快照) | session id。目录只在 JSON 项内部 |
| 自动批准 | ui/stores/permissionStore.ts | session id,目录只在 JSON body 中 |
| Goals、message-sent、图片授权 | sessionGoalActions.ts、session-ui-store.ts、markdownImageAssets.ts | session id |
| 批量归档 | ui/sync/session-archive-batch.ts | 目录只在 JSON body 中 |
| Host 级快照 | /api/sessions/status、/api/session-activity、GET /api/message-queue、GET /api/permission-auto-accept | 无。这些是聚合数据,不是 space 请求 |
注意最后一行划出的界限:聚合型快照不是 space 请求,它们永远留在 Host,由 Host 在会话合并层处理(见 DESIGN.md 的“Dispatcher, sessions, events”一节)。
五、为什么方案 A(服务端嗅探)不可行
文档逐条列出了方案 A 的成本,每一条都在源码中得到印证:
- 要读四处位置。Dispatcher 必须同时读 query(
directory/path/cwd)、header、JSON body 和 id。OpenChamber 自有路由挂载了最大50 MB的express.json解析器(packages/web/server/lib/opencode/core-routes.js#L1088-L1117),而 OpenCode 代理路由会跳过解析器让 body 直接流式转发(packages/web/server/lib/opencode/core-routes.js#L1118-L1119)。嗅探 body 意味着“先解析再代理再重新序列化”,这已经不是“整体转发”了。 - 终端 socket 无法整体转发。一个 socket 同时承载 Host 与 space 终端的帧(帧格式见 packages/ui/src/lib/terminalApi.ts#L11-L14,
attach/write帧内s: sessionId)。Dispatcher 必须按 terminal id 拆分帧,并维护一个 terminal→space 索引。 - session/terminal 键路由需要 id→space 的 Host 索引。这个索引本质是“不可信空间数据的缓存”,一旦过期或缺失,Dispatcher 只能猜;恶意 space 还能冒用别的 space 的 session id。
- dev tunnel 与
/api/dev-servers没有任何可嗅探的东西。它们需要新增参数——而那本身就是方案 B。 - 歧义案例成堆。文件 API 的 header 是环境目录,可能与
path不一致(packages/web/src/api/files.ts#L64-L67);x-opencode-directory的编码方式有三种——SDK 侧 URI 编码、files API 原始编码、非 Latin-1 字符由 packages/ui/src/lib/runtime-fetch.ts#L131-L157 做标记编码(补x-opencode-directory-encoding: uri);而且没有目录时 Host 会静默回退到settings.lastDirectory(packages/web/server/lib/opencode/project-directory-runtime.js#L91-L103),所以“没有目录”等于“上次浏览过什么就用什么”——这是方案 A 最致命的一条:一个空间路径在 Host 上根本不存在,validateDirectoryPath(packages/web/server/lib/opencode/project-directory-runtime.js#L36)会先 stat 目录,space 请求必须在它之前被转发(DESIGN.md)。 - 需要嗅探的挂载点散落四处:HTTP 入口(
validateDirectoryPath之前,6 个文件 9 个调用点)以及四个独立的 upgrade 处理器(server/lib/terminal/runtime.js、dev-tunnel/runtime.js、dictation/runtime.js、realtime-proxy.js)。
六、方案 B:一条前缀规则的三处落点
方案 B 的核心是一个纯函数spaceRoute(directory),放在新的ui/lib/spaces/模块中:把路径与 space 根目录比对、把 id 对照 known-spaces store 校验,返回/api/spaces/<id>或什么都不返回。它只应用在三个地方:
runtimeFetch接受directory选项,在buildRuntimeFetchUrl(packages/ui/src/lib/runtime-fetch.ts#L95)和extractRelayPath(packages/ui/src/lib/runtime-fetch.ts#L184)之前把/api/x重写为/api/spaces/<id>/x。网络分支与 relay 分支必须使用同一条被重写后的路径。- SDK fetch 包装器(packages/ui/src/lib/opencode/client.ts#L290-L355)读取 SDK 自己放到请求上的目录:GET 用
?directory=,其余用x-opencode-directory。这是SDK 契约而不是猜测,因此作用域客户端与约 40 个逐调用传目录的站点零改动自动覆盖。 spacePath(path, directory)助手,供 WebSocket 与资源 URL 的调用方在调用解析器之前使用。
运行时切换规则:什么都不缓存
前缀只是路径上的一个段,base URL 仍在调用时由解析器解析;known-spaces store 以 runtime key 为键,在 runtime 切换流程中重置。这一点与现有代码完全合拍:scopedClients本就在重连时被清空——packages/ui/src/lib/opencode/client.ts#L522-L535 的reconnectToRuntimeBaseUrl重建客户端的同时执行this.scopedClients.clear()。
需要改动的调用点
约 150 个(378 个中的一部分,涉及约 25 个文件)会碰目录或 session。助手把改动量压到最低:
gitApiHttp.buildUrl:68 处调用,一处编辑(packages/ui/src/lib/gitApiHttp.ts#L135-L144);files.ts:11 处调用,从path推导目录(packages/web/src/api/files.ts#L64-L67);terminalApi.command助手;- 已经传
x-opencode-directory的 config stores。
Host-only 路由族完全不用改:GitHub、Linear、客户端认证、语音、tunnel 设置、guests、主题、passkeys。
终端改造
把现在的单例TerminalTransport(默认 socket 地址见 packages/ui/src/lib/terminalApi.ts#L185)替换为按目标(Host 或某个 space id)各建一个TerminalTransport,目标来自终端所属目录(useTerminalStore的目录键)。touch按目标拆分。
传输层零改造
- relay HTTP 白名单接受任何
/api/路径(packages/web/server/lib/relay/tunnel-host.js#L23-L28); - tunnel 原样传递
pathname?search(packages/ui/src/lib/relay/tunnel-payloads.ts#L32-L36); shouldResolveApiPath接受/api/(packages/ui/src/lib/runtime-fetch.ts#L11-L13)。
所以 HTTP 与 SSE 在 web、Electron、hosted mobile、Capacitor 上都不需要传输层改动。唯一要动的是白名单。
WebSocket 与 URL-token 白名单
这些路径是精确匹配列表,新前缀必须按“形状”加入它们(就像GUEST_SURFACE_WS_PATH的正则形状一样,见 packages/web/server/lib/relay/tunnel-host.js#L37-L40):
ALLOWED_WS_PATHS(tunnel-host.js#L30-L40);isUrlAuthWebSocketPath(server/lib/ui-auth/ui-auth.js);isUrlAuthReadableHttpPath,为/api/spaces/<id>/fs/raw准备(ui-auth.js);- Electron 实时代理(
server/lib/realtime-proxy.js)。
真正需要加前缀形状的只有三条路径:terminal/ws、dev-tunnel、fs/raw。
认证顺序
Host 先认证用户,然后 Dispatcher 剥掉 cookies、bearer 与oc_url_tokenquery 参数,再换上该 space 的 token。这不破坏 relay 规则:relay Host 依旧不注入任何凭据,Dispatcher 是认证之后的独立一跳。
七、推荐规则与两条服务端守卫
文档给出的实施规则一锤定音:
一个请求属于某个 space,当且仅当它的路径以
/api/spaces/<id>/开头,且<id>在 space manager 的 label 派生列表中。Dispatcher 认证用户、剥掉前缀与用户的凭据、换上该 space 的 token,然后原封不动流式转发其余部分。除此之外它什么都不读。UI 侧,每个目录作用域或 session 作用域的请求都要点名自己的目录,由一个函数在调用时把目录变成前缀。
守卫只拒绝、永不路由:
- 守卫 1:剥掉前缀后,如果
?directory=或x-opencode-directory仍然存在,且不在该 space 根目录之下,回答400。 - 守卫 2:一个没有前缀的请求,如果它的
directoryquery 或 header 位于 spaces 根目录之下,在validateDirectoryPath之前、在lastDirectory回退之前,得到一个稳定的 4xx。
两条守卫共同兜住了“约 150 个调用点是估算而非全量审计”这个缺口:任何漏改的调用点都会以明确失败暴露,而不是悄悄打到错误的目标。
八、特殊情形处理表
| 情形 | 处理 |
|---|---|
终端 socket 与/api/terminal/:id/* | 每目标一个传输。socket 位于/api/spaces/<id>/terminal/ws。目标来自useTerminalStore中终端的目录 |
| Dev tunnel、dev servers、probe | 调用方传发起面板的目录。/api/spaces/<id>/dev-tunnel?port=。Electronrelay-dev-tunnel.mjs与server/lib/dev-tunnel/client.js需要同样的参数 |
| session 键路由(queue、auto-accept、goals、message-sent、图片授权、回复) | 目录取自 session 记录。没有服务器确认的目录就令该动作失败;只要存在任何 space,就不得回退到当前目录 |
全局事件、session 列表、/api/sessions/status、queue 与 auto-accept 快照 | 不分发。由 Host 按 DESIGN.md 合并。Host 必须丢弃目录在/spaces/<那个 id>/之外的任何 space 记录或事件,绝不允许 space 覆盖 Host 的 session id |
前缀下的/api/fs/serve与/api/preview/proxy | Dispatcher 中直接拒绝。space 的文件不得在应用源下渲染成页面。fs/raw以nosniff和附件或仅图片 content-type 转发 |
跨越边界的moveSession、worktree 与 git-integrate 动作 | 源与目标 target 不同时拒绝 |
auth.set、provider 页面、settings、GitHub、Linear、voice、guests | 仅 Host,永不加前缀 |
/api/projects/:id/* | Host settings 路由。space 项目的图标发现是后续决策 |
| VS Code | 没有 space manager。助手返回 “unsupported”,UI 隐藏 spaces(这是 DESIGN.md 产品决策 16 的落地:VS Code 永不获得该特性) |
九、安全性质:目标由 URL 命名,而不是由响应决定
文档给出了该方案的安全论证,值得原样保留:
目标由已认证用户的客户端写在 URL 中,id 对照运行时 labels 校验,space token 只由该 id 决定。space 返回的任何东西都不能改变请求的去向,因为 Dispatcher 不读 id、不读 body。一条说谎的 session 记录最多让 UI 去调用产生它的那个 space,或触发守卫 1 失败。
也就是说,这个设计把“信任”压缩到了 URL 前缀 + 认证用户 + label 列表三个点上,空间内部的任何输出(事件、session 记录、JSON 响应)都不具备路由能力。
十、对 DESIGN.md 的相应修改
文档明确了本次实验要回写 DESIGN.md 的变更:
- “Dispatcher, sessions, events”第一条:把“整体转发匹配请求”替换为上述前缀规则;删除“没有目录的请求逐个处理”,它们同样走前缀。
- 新增:UI 用一个函数把目录解析为 space;终端传输按目标划分;WebSocket 与 URL-token 白名单加入前缀形状。
- 第二条:把
oc_url_token加入被剥掉的凭据清单。 - session 条:新增合并规则——space 只能上报自己根目录之下的目录,以及 id 冲突规则。
- LESSONS.md 的 “The existing server” 一节:补充
lastDirectory回退与单一多路复用终端 socket 两个事实。
十一、未验证项:诚实标注的边界
文档最后明确列出未验证内容,这本身就是工程严谨性的体现:
- 本次实验什么都没运行。relay、Capacitor、Electron 行为是从代码推断的;relay 技能需要真实 relay 测试来验证新 WebSocket 路径。
- 空间内 OpenCode 是否把
session.directory原样报告为/spaces/<id>/<repo>(symlink、规范路径)未验证——整个 UI 映射依赖这一点(packages/ui/src/stores/globalSessionStructure.ts)。 - 150 个调用点数量是按文件统计的估算,不是全量审计;守卫 2 的存在就是为了兜住漏网之鱼。
- SDK 的
/api/session/...风格端点 GET 时用location[directory](SDKclient.js),未逐一核对 UI 用非 GET 方法且不带 header 调用了哪些。 - 重写带流式 body 的
Request需要duplex: 'half'(packages/ui/src/lib/runtime-fetch.ts#L211-L215 有同款注释)。SDK body 目前都是字符串,上传走字符串路径分支,应当安全,但未实测。 - VS Code webview 的 fetch 路由(
packages/vscode/webview/main.tsx)未读。 - Host 与 space 之间 session id 的格式与碰撞概率未检查。
延伸阅读:本实验属于 docs/isolated-spaces/stage-0/ 的四个 stage-0 实验之一,与之并列的还有 e1-server-in-stock-image.md(服务器在基础镜像中运行)、e3-model-window-and-short-token.md(模型窗口与短令牌)、e4-git-over-exec.md(git 走 exec 控制通道)。整个特性的阶段计划见 STAGES.md,测试规范见 TESTING.md。
- AI Agent
- 人工智能
- 代码智能体
- 交互助手
【免费下载链接】openchamber
Agentic Development Environment based on OpenCode AI agent
相关推荐
LMMS完全指南:免费开源音乐制作软件终极教程
LMMS完全指南:免费开源音乐制作软件终极教程 还在为昂贵的音乐制作软件而犹豫吗?LMMS这款跨平台免费开源数字音频工作站,让你零成本开启专业音乐创作之旅。作为
桌面应用音频处理MOSS-VL-Base-0708环境配置指南:在Linux系统上部署11B参数模型的完整步骤
MOSS VL Base 0708环境配置指南:在Linux系统上部署11B参数模型的完整步骤 MOSS VL Base 0708是OpenMOSS生态系统中用
Paper2GUI 前端框架:为什么选择Naive UI而非Element Plus
Paper2GUI 前端框架:为什么选择Naive UI而非Element Plus Paper2GUI作为一个致力于将AI论文转化为图形界面(GUI)的开源项
人工智能AI 应用桌面应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考