OpenChamber /api/fs 完全指南:Web 服务器如何安全管控工作区文件、媒体预览与后台命令
【免费下载链接】openchamberAgentic Development Environment based on OpenCode AI agent项目地址: https://gitcode.com/gh_mirrors/op/openchamber
OpenChamber(基于 OpenCode AI agent 的 Agentic 开发环境)的桌面端、Web 端与移动端,所有文件读写、目录列举、媒体预览和命令执行,最终都收敛到同一个 Web 服务器运行时暴露的一组/api/fs/*HTTP 端点上。这套能力由 OpenChamber Web 服务器 FS 模块(位于packages/web/server/lib/fs/)独立承载:它拥有自己的工作区边界策略、错误映射、超时行为与任务队列,而不把这些逻辑散落在组合根里。这篇指南带你从接口面出发,逐层拆解它的边界校验、Range 流式预览、后台执行任务队列、git 子进程纪律与可配置项,读完可以直接上手调试或扩展新端点。
模块定位:为什么文件系统策略必须收进服务端一个模块
想象一下另一种架构:组合根packages/web/index.js里混杂着目录列举处理器、上传限流、exec 超时逻辑。每加一个端点,边界校验就会被复制一遍,策略开始漂移。OpenChamber 的选择是反过来的——把整套文件系统行为封装成内聚模块,组合根只负责"供给",模块负责"裁决"。
模块由几个协作文件构成:
| 文件 | 职责 |
|---|---|
| routes.js | 注册全部/api/fs/*端点,并持有运行时私有状态:exec 任务队列(execJobs)、git 读缓存(gitReadCache)、工作区外文件授权(grants) |
| search.js | 模糊文件系统搜索运行时createFsSearchRuntime,被非 FS 路由(如项目图标发现)复用 |
| byte-range.js | 把Range: bytes=…请求头约简为唯一字节跨度,驱动 206 响应 |
| DOCUMENTATION.md | 模块自身的行为契约与贡献者约定 |
装配关系值得注意:index.js只提供组合期依赖——平台原语与回调(resolveProjectDirectory、normalizeDirectoryPath、buildAugmentedPath等),不再持有任何 FS 路由处理器或 exec 任务状态;真正把os、path、fsPromises、spawn、crypto以及openchamberUserConfigRoot、managedChatsRoot注入registerFsRoutes的,是 feature-routes-runtime.js。文档把这条边界写成硬性约定:工作区根校验、错误映射、exec 超时行为属于模块内部资产,禁止外泄到组合根。
接口面总览:16 个端点覆盖目录、文件、媒体与执行
registerFsRoutes(app, dependencies)注册的全部端点如下,按能力归组:
| 分组 | 端点 | 用途 |
|---|---|---|
| 元信息 | GET /api/fs/home | 返回{ home, chatsRoot, canonicalChatsRoot, canonicalLegacyChatsRoot } |
| 目录 | POST /api/fs/mkdir | 在工作区内递归创建目录 |
| 目录 | GET /api/fs/list | 列目录,可带respectGitignore=true |
| 目录 | GET /api/fs/directory-stat | 单目录 stat,不列内容、不解析项目拓扑 |
| 目录 | GET /api/fs/git-dirs | 浅层发现嵌套 git 仓库,供 Git 标签页选仓库 |
| 文件 | GET /api/fs/read | 按 UTF-8 读文本,返回text/plain |
| 文件 | GET /api/fs/raw | 读原始字节,支持Range与下载 |
| 文件 | GET /api/fs/stat | 单文件 stat:{ path, isFile, size, mtimeMs } |
| 文件 | POST /api/fs/write | 原子写文本文件 |
| 文件 | POST /api/fs/upload | 流式上传单个二进制文件 |
| 文件 | POST /api/fs/delete | 递归删除文件或目录 |
| 文件 | POST /api/fs/rename | 同一工作区根内移动/重命名 |
| 文件 | POST /api/fs/reveal | 在系统文件管理器中定位路径 |
| 媒体 | GET /api/fs/serve/:path(*) | 无令牌的静态预览(HTML、图片、PDF 等) |
| 执行 | POST /api/fs/exec | 在指定 cwd 顺序执行一组 shell 命令 |
| 执行 | GET /api/fs/exec/:jobId | 轮询命令执行任务状态 |
除端点外,模块还导出了mintOutsideFileGrant(targetPath, options):为工作区外的单个文件铸造临时授权令牌,作用域默认stat/read/raw,TTL 10 分钟;目标必须是文件,且先经fs.realpath规范化(见 routes.js)。
安全边界:三层工作区校验与符号链接的陷阱
所有写类路由(mkdir、write、upload、delete、rename、exec)与绝大多数读路由,动手前都要先过resolveWorkspacePathFromContext这道闸门。它按三个来源依次放行:
- 活动工作区——把目标
path.resolve后,要求落在resolveProjectDirectory(req)返回的活动项目目录内部; - Managed roots——OpenChamber 配置根与托管聊天根(
managedChatsRoot,上游即OPENCHAMBER_CHATS_DIR,默认<config root>/chats)。聊天 worktree 完全可能合法地躺在任何项目工作区之外,所以这两个受管根始终视为合法目标; - Worktree 回退——通过
../git/index.js的getWorktrees枚举活动仓库的全部 worktree,把每个 worktree 根也纳入白名单。
真正有意思的是符号链接。活动项目目录是用fs.realpath校验过的规范化路径;一旦项目根本身是个符号链接,客户端发来的路径仍停留在用户可见的原始目录上,两者就对不上了。模块的解法是:先用客户端实际请求的原始目录(resolveProjectDirectory返回的requestedDirectory)做一次词法比对,再解析符号链接;而写路由与 exec 路由还会对规范化基座追加一次规范包含检查(canonical containment check),堵死借符号链接逃逸的路线。
配套细节还有几条:
- 每个 managed root 都会经
canonicalDirectoryPath处理——把不存在的路径解析到最近存在的祖先再拼接缺失片段。只解析,绝不创建目录。 - 路径比较在 POSIX 上保持大小写敏感:Linux 下名为
chats和Chats的两个目录永远不会因字符串折叠而互为别名。 - 读类路由(
read、raw、stat)在allowOutsideWorkspace=true时直接放行到工作区外(仍受服务端认证与操作系统权限约束),不需要单独的文件授权,遗留的 grant 参数被忽略;工作区内的读取则在检查请求路径之后再解析符号链接。
数据流之一:读文件、原始字节与 206 Range 流式预览
三个读端点各有分工,其中/api/fs/read藏着一个精巧的竞态处理。
空读重试:文件可能在你stat和readFile之间被另一个进程改写。如果stat显示有内容而readFile却返回空串,说明并发写入者正处在O_TRUNC窗口——文件已截断、新内容还没写回。此时按 50/100/150ms 间隔最多重试 3 次,避免客户端拿到半成品。optional=true时文件不存在返回空串而非 404。
Range 与 206:/api/fs/raw由 byte-range.js 提供区间语义——单个Range: bytes=…头被约简为唯一字节跨度,resolveByteRange给出range/whole/unsatisfiable三种结果。为什么这么在意?因为媒体元素首次请求bytes=0-、每次 seek 都发bytes=<offset>-,而Chromium 与 WebKit 在没有 206 响应时拒绝 seek,Safari 甚至拒绝播放,视图器里的音视频播放器完全吃这一套。设计上还做了两处克制:多段 Range 直接按整文件处理(没有媒体消费者会发多段请求,不值得为预览器背负 multipart 响应体);unsatisfiable区间(如bytes=-0、start ≥ size、end < start)返回416加Content-Range: bytes */<size>。命中的区间以206+Content-Range+createReadStream({ start, end })从磁盘流式输出,句柄随流关闭,成功失败路径一致;整文件请求则整体读入内存发送。响应固定带Accept-Ranges: bytes与no-store;download=true时按 RFC 5987 同时输出 ASCIIfilename=与 UTF-8filename*=UTF-8''...两种 Content-Disposition。
无令牌预览/api/fs/serve:用于在页面内预览 HTML/图片/PDF。它显式拒绝allowOutsideWorkspace=true,且单文件上限MAX_SERVE_BYTES= 100 MiB(超出 413)。它的 MIME 表FILE_MIME_MAP与 raw 路由共享,覆盖 HTML/CSS/JS、图片、PDF、音频(mp3/m4a/aac/wav/flac/ogg/opus/weba)、视频(mp4/m4v/webm/mov/ogv/mkv)、字体(woff2/woff/ttf/otf/eot)、CSV/TSV,.mmd按text/plain处理。
这里有个容易被忽略的子资源鉴权约定:serve 出来的页面,其相对引用的图片、样式、脚本在加载时不带令牌。ui-auth.js 会从Referer头接受页面自身的oc_url_token,但仅当两个 URL 都位于/api/fs/serve/之下。桌面端在 loopback API 源上没有会话 Cookie,缺了这条机制,HTML 预览里的图片就会成片加载失败。
数据流之二:写操作——原子写入、流式上传与不可逃逸的父目录
/api/fs/write:临时文件 + rename。写入前有两道防护:先realpath解析目标(ENOENT时保留原路径),再对规范化基座做isPathWithinRoot包含检查,失败 403。幂等性也有保障——目标已存在且内容与请求完全相同时直接返回成功。真正的落盘走"临时文件 + rename":写成<path>.tmp-<pid>-<时间戳>-<随机串>后原子 rename 到目标。为什么不直接writeFile?因为直接写会暴露在O_TRUNC窗口里,并发读者可能看到一个空文件;临时文件写入失败则立即清理。
/api/fs/upload:流式、限额、无覆盖提交。这是契约最严的端点:
- 请求体必须是
application/octet-stream(否则 415),目标由path查询参数指定,overwrite=true显式声明覆盖; Content-Length超限直接 413;否则经streamUploadBody逐块写入同目录临时文件,累计字节超限同样 413 并清理;- 目标已存在:是目录则 400;未声明
overwrite则 409(reason: "already-exists");声明覆盖则 rename 临时文件到位; - 新文件用同目录硬链接(
fs.link)原子提交,随后删除临时文件。链接天然不会替换检查之后新出现的同名目标,而临时文件已完全落盘——读者永远看不到半截上传; - 父目录在写入前就解析:
requestedParent被realpath规范化并做包含检查,上传无法借工作区符号链接逃逸到外部。
默认上限 100 MiB,可用OPENCHAMBER_FS_UPLOAD_MAX_BYTES覆盖;routes.test.js 里就有把上限临时改为 5 字节并断言 413 的用例。
delete 与 rename:删除是fsPromises.rm(path, { recursive: true, force: true });重命名要求oldPath与newPath解析出的base严格相等("Source and destination must share the same workspace root"),从机制上禁止跨工作区根搬文件。
数据流之三:后台执行——/api/fs/exec任务队列与 git 读缓存
POST /api/fs/exec接收{ commands: string[], cwd, background }。注意:background字段当前被显式拒绝(Background command execution is not allowed),任务总是前台顺序执行。执行流程:
- cwd 必须通过工作区解析(越界 403),且确实是个目录;
- shell 取
process.env.SHELL,Windows 回退cmd.exe(flag/c),其余平台回退/bin/sh(flag-c); - 任务以
crypto.randomUUID()生成jobId,入队到模块私有的execJobsMap,状态机queued → running → done; - 每条命令经
runCommandInDirectory执行,stdout/stderr 收集后 trim,exitCode === 0且未超时才算成功; GET /api/fs/exec/:jobId轮询状态与逐条results,未命中 404。
生命周期由两把"剪刀"控制:
- 任务 TTL:
EXEC_JOB_TTL_MS= 30 分钟。pruneExecJobs在创建任务与轮询时都顺手清理超龄任务,防止长驻服务端内存无限膨胀; - 单命令超时:
OPENCHAMBER_FS_EXEC_TIMEOUT_MS默认 5 分钟,超时SIGKILL子进程,结果里带上Command timed out after <ms>ms。
exec 层内还嵌了一个gitReadCache,规则非常克制:只有白名单内的确定性、无副作用git plumbing 路径查询(git rev-parse --absolute-git-dir、--git-common-dir、--show-toplevel的组合)才可进缓存,其他任何命令(包括非 git 命令)一律正常执行、绝不写缓存。缓存本身是"条数 + 字节"双约束:上限 500 条且总量不超过 1 MiB,LRU 逐出,TTL 默认 30 秒(OPENCHAMBER_GIT_READ_CACHE_TTL_MS,设 0 关闭);同 key 的在途请求经inFlightGitReadCache合并去重;且只缓存成功结果——失败可能只是暂时的。它的实战意义:页面刷新后客户端对每个项目触发的那批相同rev-parse查询,可以被安全地吸进缓存。
运维陷阱:git check-ignore的 stderr 纪律
这是整个模块里最"运维向"的一条军规,文档与代码反复强调:search 与 directory listing 都会在 spawn 时丢弃git check-ignore的 stderr(stdio: ['ignore', 'pipe', 'ignore']),只消费 stdout 的忽略匹配结果。永远不要创建一条没人读的 stderr 管道。
原理很朴素:子进程的 stderr 是操作系统管理的管道,缓冲区有限。Git 的诊断输出一旦填满这条无人消费的管道,写操作就会阻塞,子进程永久挂起;而反复搜索会不断累积这样的"活"进程。git-process.test.js 用真实 OS 管道把这个坑钉死了:它用一个每子进程向 stderr 灌 2 MiB 诊断(32 × 64 KiB)的noisyGit桩替换 git,然后对search与list两条路径各发起12 个并发检查,断言全部完成、忽略过滤仍生效(visible.txt可见、ignored.txt被剔除)、12 个子进程全部以退出码 0 被回收。任何改动若引入未读的 stderr 管道,都会在该测试的 5 秒看门狗下超时失败。
超时兜底同样存在:git check-ignore子进程受OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS(默认 2500ms)保护,超时直接SIGKILL并按"无忽略结果"继续。
浅层仓库发现与模糊搜索:git-dirs与search.js两个受限遍历器
GET /api/fs/git-dirs?path=...服务于 Git 标签页的"选择仓库"下拉框。findGitDirectories是受限遍历:GIT_DIRS_MAX_DEPTH = 3、GIT_DIRS_MAX_DIRS = 100,跳过node_modules、dist、build、.venv、target、.next等垃圾目录,且符号链接目录绝不深入(防遍历逃逸与环)。一个目录出现.git(目录、worktree 指针文件、符号链接都算)即构成仓库边界并停止下探,嵌套仓库不会重复上报;根本身是仓库时不上报根。不可读的非根子树静默跳过,但根自身不可读会按共享错误处理映射为 403/404/500。
search.js 的createFsSearchRuntime({ fsPromises, path, spawn, resolveGitBinaryForSpawn })返回{ searchFilesystemFiles(rootPath, options) },被非 FS 路由(如项目图标发现)复用。其设计要点:
- 并发受限的 BFS:
FILE_SEARCH_MAX_CONCURRENCY = 5,队列按批并行读目录; - 排除目录:
node_modules、.git、dist、build、.next、.turbo、.cache、coverage、tmp、logs(大小写不敏感);includeHidden=false(默认)时跳过点号开头的条目; - gitignore 过滤:
respectGitignore默认开启,每目录批量git check-ignore,同样以stdio: ['ignore', 'pipe', 'ignore']丢弃 stderr; - 模糊匹配评分:子串命中给"前缀 20 分 / 分隔符前 15 分"加成,再扣偏移与长度惩罚;非子串命中按字符顺序、相邻度、分隔符前缀与路径长度综合计分,低于阈值返回
null剔除;同分先按相对路径长度、再按字典序稳定排序; - 上限逻辑:空查询(
matchAll)收集到limit即止;有查询时先收集max(limit*3, 200)的候选池再排序取前limit,保证取到的是全局最优而非批次截断的结果。
另外,/api/fs/list还有一个易漏的约定:realpath 只用于读取目录内容,响应里的path与每个 entry 的path必须用path.join(requestedPath, name)构造,停留在调用方的请求路径空间里。否则通过工作区符号链接访问的目录,会在文件树展开时因路径落在工作区根之外而被 UI 拒绝。顺带一提,list对.opencode/plans结尾且尚不存在的路径(如~/.opencode/plans)返回{ path, entries: [] }而非 404,保证计划目录在首次创建前也能被 UI 正常展开。
错误契约:四个稳定的 reason 标记
模块把错误映射收敛成稳定契约,客户端据此区分"可补救"与"不可补救":
reason标记 | HTTP | 触发场景 |
|---|---|---|
os-permission | 403 | EACCES/EPERM,文件系统层权限拒绝。原生文件夹选择器可以补救,所以这是稳定的响应标记 |
already-exists | 409 | 上传目标已存在且未声明overwrite |
not-found | 404 | 目录/文件不存在 |
not-directory | 400 | 目标是文件或ENOTDIR |
一条关键原则:工作区边界策略拒绝(如 "Path is outside of active workspace")不得使用os-permission标记——它不是 OS 权限问题,原生文件夹选择器根本无从补救。/api/fs/directory-stat与/api/fs/list遵循同一鉴权目录发现策略:允许查询活动工作区之外的目标,但"权限失败"与"路径不存在"始终保持区分。VS Code 对directory-stat显式返回 501,共享客户端因此把它的探针结果视为"未知"。
配置与扩展:环境变量速查表与贡献者清单
全部可调参数都来自服务端进程环境:
| 环境变量 | 默认值 | 作用 |
|---|---|---|
OPENCHAMBER_FS_UPLOAD_MAX_BYTES | 100 MiB | 上传体积上限,非正数回退默认 |
OPENCHAMBER_FS_EXEC_TIMEOUT_MS | 5 分钟 | 单条 exec 命令超时,超时SIGKILL |
OPENCHAMBER_GIT_READ_CACHE_TTL_MS | 30 秒 | git 读缓存 TTL,0关闭缓存 |
OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS | 2500 ms | git check-ignore子进程超时,超时SIGKILL按无忽略处理 |
OPENCHAMBER_CHATS_DIR | <config root>/chats | 托管聊天根(managedChatsRoot),可整体搬迁 |
如果你要为/api/fs/*新增端点,模块内既有约定就是一份验收清单:
- 路由加在 routes.js 并同步扩展 DOCUMENTATION.md;
- 工作区校验一律走
resolveWorkspacePathFromContext(读类路由走resolveReadPathFromContext),不要自己发明包含判断; - OS 权限错误映射到
reason: "os-permission",策略拒绝绝不复用该标记; - 任何
git check-ignore子进程必须丢弃 stderr、只消费 stdout,并接受超时保护——git-process.test.js的并发大诊断管道测试会替你验证; /api/fs/list的响应路径必须停留在请求方路径空间;- 上传类端点必须先解析目标父目录并做规范包含检查。
遵循这些约定,新端点就能无缝继承整个模块的鉴权、错误映射与资源回收纪律——这正是把它从组合根里剥离出来的全部意义。
【免费下载链接】openchamberAgentic Development Environment based on OpenCode AI agent项目地址: https://gitcode.com/gh_mirrors/op/openchamber
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考