news 2026/11/4 6:16:07

为什么 /api/fs 从不越界:OpenChamber 文件系统模块完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
为什么 /api/fs 从不越界:OpenChamber 文件系统模块完整指南

为什么 /api/fs 从不越界:OpenChamber 文件系统模块完整指南

【免费下载链接】openchamberAgentic Development Environment based on OpenCode AI agent项目地址: https://gitcode.com/gh_mirrors/op/openchamber

OpenChamber 是基于 OpenCode AI Agent 的 Agentic 开发环境,它的桌面端、Web 端与移动端共用同一个 Web 服务器运行时来完成所有文件操作。这些操作的全部规则——/api/fs路由、工作区边界校验、Range 流式预览、后台命令执行任务——被刻意收敛进packages/web/server/lib/fs/一个模块,而不是散落在组合根里。本文不照端点清单平铺,而是换一条叙事主线:先建立"接入层 / 策略层 / 执行层"的心智模型,再跟着一次上传和一次视频 seek 走完整条请求旅程,最后落到错误契约与调参扩展。读完你可以直接参与维护或新增端点。

一个模块扛住三端文件操作:策略为什么必须留在 lib/fs 内

先回答"为什么必须自成一模块"。三端客户端对文件系统的诉求完全同构:读写工作区文件、列目录、在系统文件管理器里"揭示"路径、跑后台命令。如果把这些策略(工作区根校验、错误映射、exec 超时行为)写进组合根packages/web/server/index.js,会发生两件事:策略与路由纠缠在一起难以测试;任何一个新端点都可能绕过统一的边界检查。所以模块把整套行为收敛为两个入口:

  • routes.js:路由注册,以及运行时自有的状态——exec 任务队列execJobs、git 读缓存、文件授权令牌表。
  • search.js:供非 FS 路由(比如项目图标发现)复用的模糊文件系统搜索运行时。

组合契约写得非常克制:index.js只提供组合期依赖——平台原语与回调(resolveProjectDirectory、normalizeDirectoryPath、buildAugmentedPath),不再持有任何 FS 路由处理器或 exec 任务状态。最终装配发生在 feature-routes-runtime.js,它把os、path、fsPromises、spawn、crypto以及openchamberUserConfigRoot、managedChatsRoot等注入registerFsRoutes。换句话说:模块自己拥有状态与策略,组合根只负责喂原料。

一张分层图看懂能力结构:接入层、策略层、执行层

与其背一张端点大表,不如按"一次请求经过的三道关卡"来记忆。所有/api/fs/*端点都可以按主归属归入三层:

┌─────────────────────────────────────────────────────────┐ │ 接入层:协议入口与身份发现 │ │ home / reveal / exec/:jobId / list / git-dirs / serve │ ├─────────────────────────────────────────────────────────┤ │ 策略层:边界解析 + 错误映射 + 超时与限额(模块私有状态) │ │ resolveWorkspacePathFromContext · allowOutsideWorkspace │ │ reason 契约 · 上传限额 · exec 超时 · git 读缓存 │ ├─────────────────────────────────────────────────────────┤ │ 执行层:OS 原语 │ │ fsPromises 读写/流式 · spawn 子进程 · 硬链接/rename 提交 │ └─────────────────────────────────────────────────────────┘

各端点归位如下(顺序即请求经过的层次,而非方法名排序):

端点主归属层一句话职责
GET /api/fs/home接入层身份发现:返回{ home, chatsRoot, canonicalChatsRoot, canonicalLegacyChatsRoot }
POST /api/fs/reveal接入层在系统文件管理器中定位路径(跨平台 spawn 系统程序)
POST /api/fs/exec策略层 → 执行层指定 cwd 顺序执行一组 shell 命令
GET /api/fs/exec/:jobId接入层轮询任务状态与逐条结果,未命中 404
GET /api/fs/list策略层 → 执行层列目录,可带respectGitignore=true
GET /api/fs/git-dirs接入层浅层发现嵌套 git 仓库,供 Git 标签页选择仓库
GET /api/fs/read执行层UTF-8 文本读取,带"空读重试"
GET /api/fs/raw执行层原始字节流,支持Range与下载
GET /api/fs/stat执行层单文件 stat:{ path, isFile, size, mtimeMs }
GET /api/fs/directory-stat策略层单目录 stat,不列内容、不解析项目拓扑
GET /api/fs/serve/:path(*)接入层静态预览页面/资源(HTML、图片、PDF 等)
POST /api/fs/mkdir策略层 → 执行层工作区内递归建目录
POST /api/fs/write策略层 → 执行层原子写文本文件
POST /api/fs/upload策略层 → 执行层流式上传单个二进制文件
POST /api/fs/delete策略层 → 执行层递归删除文件或目录
POST /api/fs/rename策略层 → 执行层同一工作区根内移动/重命名

模块还导出mintOutsideFileGrant(targetPath, options):为工作区外的单个文件铸造带作用域(默认stat/read/raw)与 10 分钟 TTL 的临时授权令牌,目标必须是文件且经fs.realpath规范化。它是"读类路由放行工作区外路径"之外的另一条受控通道。

一次上传与一次 seek:请求的完整旅程

旅程一:上传一个二进制文件

POST /api/fs/upload是整个模块契约最密集的端点,按四段走读,每段都有一个"防什么":

  1. 校验(防伪装与超限):请求体必须是application/octet-stream,否则 415;目标路径走path查询参数。Content-Length声明超过上限(默认 100 MiB)时直接 413,连流都不读。随后是两道边界检查:先过工作区解析,再把requestedParent(目标父目录)realpath规范化并做规范包含检查——上传因此无法借工作区符号链接逃逸到外部。
  2. 处理(防半截文件):请求体经streamUploadBody逐块写入同目录临时文件<目标>.upload-<uuid>,接收过程中累计字节数一旦超过上限,同样 413 并清理临时文件。全程不把整个 body 读进内存。
  3. 提交(防覆盖竞态):目标已存在且是目录 → 400;已存在但未声明overwrite=true→ 409(reason: "already-exists");声明覆盖则rename临时文件到位。新文件走同目录硬链接(fs.link)原子提交后删除临时文件。为什么用硬链接而不是 rename?因为硬链接不会替换检查之后新出现的同名目标,而临时文件此时已完全落盘,读者永远不会观察到半截上传。
  4. 响应:成功返回{ success: true, path };任何失败路径都保证临时文件被清理。

上限可用OPENCHAMBER_FS_UPLOAD_MAX_BYTES覆盖,routes.test.js 里就有把上限临时改成'5'字节并断言 413 的用例。

旅程二:流式预览一段视频

<video>的行为决定了/api/fs/raw的设计。媒体元素首次请求Range: bytes=0-,之后每次 seek 都发bytes=<offset>-;Chromium 与 WebKit 在拿不到206响应时拒绝 seek,Safari 甚至直接拒绝播放。所以:

  1. 路由先按读类策略解析路径(工作区内则检查请求路径后再解析符号链接),stat得到大小,按FILE_MIME_MAP(与/api/fs/serve共享,覆盖 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)定 MIME。
  2. 响应头固定三件套:Accept-Ranges: bytes、Cache-Control: no-store;download=true时按 RFC 5987 同时输出 ASCIIfilename=与filename*=UTF-8''...两种 Content-Disposition。
  3. byte-range.js 的resolveByteRange把单个 Range 头约简为唯一字节跨度,返回range/whole/unsatisfiable三态。命中区间则206+Content-Range: bytes start-end/size,并用createReadStream({ start, end })从磁盘流式输出——文件句柄随流关闭,成功与失败路径一致,几百 MB 的录音或 PDF 不会整块驻留服务器内存。
  4. unsatisfiable(如bytes=-0、start ≥ size、end < start)返回416与Content-Range: bytes */<size>;多段 Range 请求按整文件响应——没有媒体消费者会发多段请求,为预览器承载 multipart 响应体不值得。

/api/fs/serve是同一 MIME 表的另一消费方,用于页面内预览,但它有两条硬约束:显式拒绝allowOutsideWorkspace=true;单文件上限MAX_SERVE_BYTES= 100 MiB,超出 413。还有一条容易被忽视的子资源鉴权约定:通过 serve 展示的页面,其相对引用的图片、样式与脚本加载时不携带令牌;ui-auth 会从Referer头接受页面自身的oc_url_token,但仅当两个 URL 都位于/api/fs/serve/之下。缺了这层机制,桌面端(loopback API 源上没有会话 Cookie)的 HTML 预览会出现图片加载失败。

读与写两条流水线:每个选择背后的"为什么"

读流水线:text、raw、stat、serve

  • GET /api/fs/read按 UTF-8 读文本,带一个"空读重试"逻辑:若stat时文件有内容而readFile返回空串,说明并发写入者正处于O_TRUNC窗口(截断后尚未写回),于是以 50/100/150ms 间隔重试最多 3 次——为什么:不让客户端拿到半成品文件。optional=true时文件不存在返回空串而非 404。
  • GET /api/fs/stat单文件 stat,optional=true时ENOENT返回{ path, exists: false }。
  • GET /api/fs/raw如上节旅程二所述,是媒体播放的 206 依赖方。
  • 读类放行规则:read、raw、stat在allowOutsideWorkspace=true时直接放行到工作区之外(仍受服务端认证与 OS 权限约束),无需单独文件授权;遗留 grant 参数被忽略。工作区范围内的读取则在检查请求路径之后才解析符号链接——顺序不能反,否则边界检查会作用在规范化后的路径上,语义就变了。

写流水线:write、upload、rename、delete

  • POST /api/fs/write写入前两道防护:先realpath解析目标(ENOENT时保留原始路径),再对规范化基座做包含检查,失败 403。若文件已存在且内容与请求完全相同,直接返回成功(幂等——为什么:重试与多端同步场景下重复写是常态,不必真的落盘)。真正的写入是"临时文件 + rename":<path>.tmp-<pid>-<时间戳>-<随机串>写完后rename到目标,临时文件写失败立即清理。为什么:直接writeFile会在O_TRUNC窗口让并发读者看到空文件,而 rename 在 POSIX 上是原子的。
  • POST /api/upload的"为什么"已在上文旅程一展开:同目录临时文件 + 硬链接提交。
  • POST /api/fs/rename要求oldPath与newPath解析出的base严格相等("Source and destination must share the same workspace root"),为什么:防止借 rename 跨工作区根移动文件。
  • POST /api/fs/delete使用fsPromises.rm(path, { recursive: true, force: true }),简单直接,边界校验走同一入口。

命令执行的异步世界:exec 任务队列与 git 读缓存

POST /api/fs/exec接收{ commands: string[], cwd, background }。注意background字段当前被显式拒绝("Background command execution is not allowed")——任务总是前台顺序执行。完整流程:

  1. cwd必须经工作区解析(越界 403)且确实为目录;
  2. shell 取process.env.SHELL,Windows 回退cmd.exe,其余平台回退/bin/sh;flag 在 Windows 为/c,其余为-c;
  3. 任务以crypto.randomUUID()生成jobId,入队模块自有的execJobsMap,状态机为queued → running → done;
  4. 每条命令经runCommandInDirectory执行:收集并 trim stdout/stderr,exitCode === 0且未超时才算成功;
  5. GET /api/fs/exec/:jobId轮询状态与逐条results,未命中 404。

生命周期与裁剪:EXEC_JOB_TTL_MS为 30 分钟,pruneExecJobs在创建任务与轮询时都会清理超龄任务——为什么:防止长驻服务端内存无限增长。单条命令超时由OPENCHAMBER_FS_EXEC_TIMEOUT_MS控制(默认 5 分钟),超时SIGKILL子进程,结果携带Command timed out after <ms>ms。

git 读缓存:只缓存确定性的东西。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-dirs:为"选择仓库"下拉框做的受限遍历

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,同样丢弃 stderr(下节详述);
  • 模糊匹配评分:子串命中给"前缀 20 分 / 分隔符前 15 分"加成,再扣减偏移与长度惩罚;非子串命中按字符顺序、相邻度、分隔符前缀与路径长度综合计分,低于阈值返回null被剔除;同分按相对路径长度、再按字典序稳定排序;
  • 上限逻辑:空查询(matchAll)收集满limit即停;有查询时先收集max(limit*3, 200)的候选池(受 10,000 个受检文件上限约束)再排序取前limit——为什么:保证全局最优而非被浅层弱匹配耗尽预算。

/api/fs/list:目录列举的两个纪律

GET /api/fs/list接收path(缺省os.homedir())与respectGitignore参数。两个关键约定:

  • realpath 只用于读取目录内容,响应路径必须停留在调用方的请求路径空间:path与每个 entry 的path用path.join(requestedPath, name)构造,绝不返回真实路径。否则通过工作区符号链接访问的目录,在文件树展开时会因路径落在工作区根之外而被 UI 拒绝;
  • respectGitignore=true时对当前目录的每个 dirent 做一次git check-ignore -- <names>批量判定,命中条目从结果剔除;Git 子进程受OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS(默认 2500ms)超时保护,超时直接SIGKILL并按无忽略结果处理。

还有一个贴心特例:路径以.opencode/plans结尾且目录尚不存在时(如~/.opencode/plans),返回{ path, entries: [] }而非 404——保证计划目录在首次创建前也能被 UI 正常展开。

不起眼但致命:稳定性工程的三道防线

stderr 纪律:千万不能堵死的管道

search 与 directory listing 都会丢弃git check-ignore的 stderr(stdio: ['ignore', 'pipe', 'ignore']),只消费 stdout 的忽略匹配结果。这里有个容易堵死的坑:永远不要创建未读的 stderr 管道——Git 的诊断输出可能填满管道并永久阻塞子进程,导致反复搜索不断累积"活"的 Git 进程。

git-process.test.js 用真实 OS 管道验证了这一点:它用一个每子进程写入 2 MiB stderr 诊断(32 × 64 KiB)的noisyGit桩替换 git,然后对search与list两条路径分别发起12 个并发检查,断言全部完成、忽略过滤仍然生效(visible.txt可见、ignored.txt被剔除)、12 个子进程全部以退出码 0 被回收。任何改动若引入未读的 stderr 管道,都会在该测试的 5 秒看门狗下超时失败。

超时家族:所有子进程都有死亡期限

模块里每个会 spawn 的 Git 子进程都有超时兜底:git check-ignore超时SIGKILL后按"无忽略"继续;exec 命令超时SIGKILL并携带明确错误文本。原则一致:宁可降级为无结果,也不让子进程泄漏。

媒体 206 依赖与句柄回收

如旅程二所述,206不是锦上添花而是播放器的硬依赖;同时 raw 路由保证createReadStream的句柄在成功与失败路径都随流关闭,播放器提前断开也不会泄漏 FileHandle。

出错了怎么办:一份稳定的错误契约

模块把错误映射收敛为稳定契约,客户端据此区分"可补救"与"不可补救":

reason标记触发场景说明
os-permissionEACCES/EPERM文件系统层权限拒绝,返回 403。原生文件夹选择器可以补救,因此该标记是稳定的响应标记
already-exists上传目标已存在且未声明overwrite返回 409
not-found目录/文件不存在404
not-directory目标是文件或ENOTDIR400

关键原则:工作区边界策略拒绝(如 "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_BYTES100 MiB上传体积上限,非正数回退默认
OPENCHAMBER_FS_EXEC_TIMEOUT_MS5 分钟单条 exec 命令超时,超时SIGKILL
OPENCHAMBER_GIT_READ_CACHE_TTL_MS30 秒git 读缓存 TTL,0关闭缓存
OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS2500 msgit check-ignore子进程超时,超时SIGKILL按无忽略处理
OPENCHAMBER_CHATS_DIR<config root>/chats托管聊天根(managedChatsRoot),可整体搬迁

如何新增一个 /api/fs 端点

若要为/api/fs/*新增端点,请遵循模块内既定约定(详见 DOCUMENTATION.md 的 Notes for contributors):

  1. 路由加在 routes.js 并同步扩展 DOCUMENTATION.md;
  2. 工作区校验一律走resolveWorkspacePathFromContext(读类路由走resolveReadPathFromContext),不要自造包含检查;
  3. OS 权限错误映射到reason: "os-permission",而策略拒绝不得复用该标记;
  4. 任何git check-ignore子进程必须丢弃 stderr、只消费 stdout,并接受超时保护——并发大诊断管道测试会验证这一点;
  5. /api/fs/list风格的响应路径必须停留在请求方路径空间;
  6. 上传类端点必须解析目标父目录并做规范包含检查,防止符号链接逃逸。

遵循这些约定,新端点即可无缝继承模块的鉴权、错误映射与资源回收纪律——这正是把策略留在lib/fs而非组合根的回报。

【免费下载链接】openchamberAgentic Development Environment based on OpenCode AI agent项目地址: https://gitcode.com/gh_mirrors/op/openchamber

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 6:12:05

相机在哪?Kornia 带你跑通 PnP 相机位姿估计,一步不掉坑

相机在哪&#xff1f;Kornia 带你跑通 PnP 相机位姿估计&#xff0c;一步不掉坑 【免费下载链接】kornia &#x1f40d; 空间人工智能的几何计算机视觉库 项目地址: https://gitcode.com/kornia/kornia 做 AR 导航 Demo 时&#xff0c;手机稍微一晃&#xff0c;虚拟标牌…

作者头像 李华
网站建设 2026/11/4 6:12:02

PhotoCraft:把 500 多条命令交给 AI 的纯 Rust 开源修图软件

PhotoCraft&#xff1a;把 500 多条命令交给 AI 的纯 Rust 开源修图软件 【免费下载链接】photocraft PhotoCraft: An open-source, photo editor written by scratch in pure Rust 项目地址: https://gitcode.com/gh_mirrors/pho/photocraft 上周我在终端里敲下一条 ph…

作者头像 李华
网站建设 2026/11/4 6:11:32

Folia 手机端 PWA 部署教程:把全屏歌词动画装进手机主屏

Folia 手机端 PWA 部署教程&#xff1a;把全屏歌词动画装进手机主屏 【免费下载链接】folia-major 专注于绚丽的歌词动画效果的本地音乐/navidrome/第三方多平台在线音乐播放器 项目地址: https://gitcode.com/GitHub_Trending/fo/folia-major 你有一台 NAS 或闲置服务器…

作者头像 李华
网站建设 2026/11/4 6:11:27

告别虚拟机:5 分钟在 Mac 上跑起 Windows 应用

告别虚拟机&#xff1a;5 分钟在 Mac 上跑起 Windows 应用 【免费下载链接】Whisky A modern Wine wrapper for macOS built with SwiftUI 项目地址: https://gitcode.com/gh_mirrors/wh/Whisky 每次想用那个只有 Windows 版的软件&#xff0c;都得先唤醒虚拟机、等它慢…

作者头像 李华
网站建设 2026/11/4 6:08:51

OpenGlass 拆解:$25 把普通眼镜改成 AI 智能眼镜的完整方案

OpenGlass 拆解&#xff1a;$25 把普通眼镜改成 AI 智能眼镜的完整方案 【免费下载链接】OpenGlass Turn any glasses into AI-powered smart glasses 项目地址: https://gitcode.com/GitHub_Trending/op/OpenGlass OpenGlass 这个项目&#xff0c;用一块 XIAO ESP32 S3…

作者头像 李华