工作区文件服务
审计基线 0.1.5-alpha.1 @ 5dda764ed3;见 源码 / npm 渠道。
一句话版:
packages/api/workspace-files把"一个会话工作区里的文件"做成一个 typed Remote 命名空间workspaceFiles:分页读 UTF-8 文本、按字节窗口读原始字节、stat、列目录,并把 Agent 自己的写入变成一条流;同一包的浏览器半部再把它包装成file资源协议,右侧栏的文件树与文本预览都消费它。
这是 0.1.5-alpha.1 新增的包。读完你能回答:文件内容是怎么过网的、为什么它不能靠沙箱兜底、Host 侧有哪四道闸、浏览器怎么把一个地址变成活的元数据。
一、边界:它把内容送过网,而不是把路径交给本机
源码 src/index.ts 顶部注释把边界写得很清楚:本服务不是 session.openWorkspacePath 的模型——后者把路径交给本机打开器,效果留在机器上;本服务把文件内容送过 wire,暴露面不是一个量级。
更要紧的一条:经 ctx.fs 的读是不受沙箱约束的。沙箱后端只围栏写与编辑(workspace-write 策略说的是"可能修改工作区内的文件"),所以本服务必须自带全部约束——这就是第四节四道闸的由来。
二、host / client 双半部
一个包,两个编译面:
| 面 | 入口 | tsconfig | 导出子路径 | 角色 |
|---|---|---|---|---|
| Host | src/index.ts | tsconfig.host.json(files: index.ts / types.ts / changes.ts) | .、./types、./typert | WorkspaceFiles 服务 + workspaceFiles Remote 命名空间 |
| Client | src/client/index.ts | tsconfig.client.json(files: src/client/* + src/types.ts) | ./client、./remote | file 资源 provider |
package.json#dsh.client 声明 platform: "web"、inject: ["@deepseek-ai/dsh-api-gateway", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-resources"];Typert 生成 ./typert(Host)与 ./remote(Client)两端产物。web 组合里它作为 workspace-files 行紧跟 session-controller 装载(packages/bundle/web-app/cordis.patch.yml)。
三、Host 侧的五个方法
全部方法在 wire 上先接 Session 身份,由它解析出目标 Agent,所以调用方永远不自己命名根目录。
| 方法 | 入参 | 返回 | 语义 |
|---|---|---|---|
read(agent, path, range, signal) | range { offset?, limit? },行号 1 基 | WorkspaceFileText = stat + { offset, text, lines, eof } | 分页读文本;超过字节上限拒绝而不是截断 |
readBytes(agent, path, range, signal) | range { offset?, length? },字节 0 基 | WorkspaceFileBytes = stat + { offset, data(base64), eof } | 原始字节,不解码、不拒绝二进制 |
stat(agent, path, signal) | — | WorkspaceFileStat { absolutePath, version, bytes? } | 只报身份、版本、大小,不带内容 |
list(agent, path, signal) | — | WorkspaceDirectoryListing { path, entries, truncated } | 直接子项,超上限丢弃并置 truncated |
changes(agent, signal) | — | AsyncIterable<WorkspaceFileWatchFrame>(@Remote({ mode: 'stream' })) | 只报 Agent 的文件观察 |
两种路径口径,别混(src/types.ts 的模块注释就是为这条写的):
read/readBytes/stat/changes报absolutePath:执行世界里的绝对路径、已解 symlink——消费方是 Client 资源系统,地址里带的也是这个路径。list报 workspace path:相对工作区根、根为''——消费方是以根为起点的树,子项路径 = 该值 +/+ 条目名。
分页语义:text 用 \n 连接、末尾不带终止符;lines 是页内行数(0 表示 offset 越过了末行);eof 表示这页是否包含最后一行。空页和"一页恰好一个空行"靠 lines 区分——这就是为什么要单独带 lines。
四、四道闸与错误码
每个读/stat/列举都按顺序过四道闸:
| # | 闸 | 实现 | 失败码 |
|---|---|---|---|
| 1 | 路径自身类型(先于任何 follow) | ctx.fs.lstat(path, { cwd: workspaceRoot }) | not-found / not-regular-file / not-directory |
| 2 | 包含性 | ctx.fs.contains(root, target)(不是字符串前缀比较) | outside-workspace |
| 3 | 上限 | Config 的 maxBytes / maxLines / maxEntries | too-large / gateway/bad-request |
| 4 | 文本 | streamText 逐块解码 + 页内 NUL 扫描 | not-text |
第 1 道在包含性之前是有意的:lstat 是路径形状的、看得见链接本身,而 resolve 会跟随它。代价是"工作区外且类型已不合格"的条目报 not-regular-file / not-directory,而不是 outside-workspace。第 2 道必须用 contains 而不是前缀比较——resolve 会 realpath,前缀比较看不见"绕过链接离开根目录"的情况。
错误码全表(src/types.ts 的 RemoteErrorDetailsMap 声明合并):
| 码 | 触发 | 细节字段 |
|---|---|---|
workspace-file/not-found | 工作区内该路径无条目 | path |
workspace-file/outside-workspace | 路径解析到会话工作区根之外 | path |
workspace-file/too-large | 页/窗口超过字节上限,什么也不返回 | path、limit |
workspace-file/not-text | 不是可解码 UTF-8,或页内含 NUL | path |
workspace-file/not-regular-file | 不是普通文件 | path、kind(directory / symlink / other) |
workspace-file/not-directory | 不是目录 | path、kind(file / symlink / other) |
工作区根来自策略,不是文件系统后端自己的 cwd 默认值:
this.ctx.sandboxPolicy.resolve({ session: agent.session }).workspaceRoot
源码注释给了理由:minimal preset 会用裸 fs-local 覆盖宿主 provider,其 cwd 与工作区根不同;显式解析才能让"谁回答都一样"。
五、配置
| 键 | 默认 | 含义 |
|---|---|---|
maxBytes | 2097152(2 MiB) | 单页文本与单字节窗口的含端上限;超了拒绝,不静默截断(静默截断读起来像整页) |
maxLines | 5000 | 单页行数的默认值与上限;请求更大的 limit 被拒 |
maxEntries | 2000 | 返回目录项上限;其余丢弃并置 truncated |
文件本身没有大小上限——调用方自己分页翻完。实现上分页从 streamText 里切:页前的行只计数不留存,页内每段先过字节闸再缓冲,读到页后第一个字符就返回,所以大文件与"单行巨长"都最多只驻留一页。
六、变更流:fs/observed → changes
| 帧 | 何时 |
|---|---|
{ kind: 'ready' } | Host 的观察队列已就绪、且工作区根已解析;解析期间排队的观察随后作为 change 发出 |
{ kind: 'change', change } | { absolutePath, version }(观察到写)或 { absolutePath, absent: true }(观察到消失) |
帧报的是观察而不是增量:已经持有同版本 version 的消费方可以忽略它。
两条必须知道的限制(源码注释与 README 同口径):
- 只覆盖 Agent 写:源是工具在自己文件操作之后发出的
fs/observed;子进程、shell 命令、用户编辑器改动不会产生帧。操作系统没有被 watch。 - 队列无界:一代会缓冲所有落在根内的观察直到消费方拉取;消费方停滞会让 Host 内存随流存活期增长。
七、Client 半部:file 资源协议
浏览器半部只做一件事——把 workspaceFiles 包装成资源模型的 file 协议:
| 项 | 值 |
|---|---|
| 注册 | ctx.resources.register(provider),inject = ['resources', 'remote', 'remote.workspaceFiles', 'sessions'] |
| 地址 | dsh-resource://file/session/<sessionId>/<相对工作区根的路径> 或 dsh-resource://file/absolute/<绝对路径> |
| 值(只有元数据) | { absolutePath, version, bytes?, changed } |
| 内容 | 另行分页取:remote.workspaceFiles.read(sessionId, path, { offset }, signal) |
| Client 独有错误 | workspace-file/unsupported-address、workspace-file/unknown-workspace(Host 从不发这两个) |
地址由 @deepseek-ai/dsh-util-workspace-path 的 fileAddressFor / parseFileAddress 构造与解析,本包不自己切字符串。
provider 的开流顺序:等 Host 的 ready 帧 → 首次 stat → 读期间排队变更 → 按 stat.absolutePath 绑定 follower。新写入版本抬 changed 但保留上次字节数;重复版本忽略;absent 通知或 reload 触发重新 stat。一个会话只有一条受监管 changes 流,按绝对路径扇出给该会话所有被跟随的文件(\\ 归一为 /);最后一个 follower 离开时流被释放。
八、源码佐证
| 位置 | 符号 / 事实 |
|---|---|
packages/api/workspace-files/src/index.ts | WorkspaceFiles、Config、cutPage、inspect、confine、locateFile、workspaceRootOf、isNotTextRefusal |
packages/api/workspace-files/src/changes.ts | WorkspaceChangeFeed、ChangeFollower、Observed |
packages/api/workspace-files/src/types.ts | WorkspaceFileStat、WorkspaceFileRange、WorkspaceFileText、WorkspaceByteRange、WorkspaceFileBytes、WorkspaceDirectoryEntry、WorkspaceDirectoryListing、WorkspaceFileChange、WorkspaceFileWatchFrame、RemoteErrorDetailsMap 六个码 |
packages/api/workspace-files/src/client/index.ts | inject、apply、ChangeFeed 装配 |
packages/api/workspace-files/src/client/provider.ts | createFileResourceProvider、SessionLookup、metadataOf |
packages/api/workspace-files/src/client/change-feed.ts | ChangeFeed、Follower、SessionFeed、editOf |
packages/api/workspace-files/src/client/types.ts | ResourceProtocolMap.file、WorkspaceFileResource、WorkspaceFileParams、两个 Client 错误码 |
packages/fs/fs/src/index.ts | lstat、resolve、contains、streamText、readByteRange、listDir、processPath、fileUrl |
packages/sandbox/sandbox-policy/src/index.ts | resolve(...).workspaceRoot(会话 cwd → 工作区根) |
packages/bundle/web-app/cordis.patch.yml | workspace-files 行(紧跟 session-controller) |
九、验证
# 1. 装载:web 组合里应有 workspace-files 行,右侧栏与资源模型同组
dsh web --dump-config | grep -iE "workspace-files|client-resources|ui-sidebar"
# 2. 上限与四道闸就在这一个文件里
grep -n "maxBytes\|maxLines\|maxEntries\|workspace-file/" \
packages/api/workspace-files/src/index.ts packages/api/workspace-files/src/types.ts
# 3. 浏览器里:打开一个会话的右侧栏 → 文件标签 → 点开一个文本文件,
# DevTools Network 应出现 POST /api 的 workspaceFiles.stat / read,
# 以及 /api/remote.mux 上的 workspaceFiles.changes 流。
一个可复现的边界观察:让 Agent 用工具改写当前预览的文件,changed 会抬起但页面文本不会被替换——内容是你自己分页读的,元数据流只告诉你"它变了"。
下一步
- 沙箱与安全:为什么读不受沙箱围栏,闸只能自己加
- Remote API 网关:
workspaceFiles命名空间怎么过网关 - 客户端资源与模块:
file协议背后的资源模型 - Web UI 架构:右侧栏文件树与文本预览装在哪