跳到主要内容
路径文档

工作区文件服务

审计基线 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导出子路径角色
Hostsrc/index.tstsconfig.host.jsonfiles: index.ts / types.ts / changes.ts../types./typertWorkspaceFiles 服务 + workspaceFiles Remote 命名空间
Clientsrc/client/index.tstsconfig.client.jsonfiles: src/client/* + src/types.ts./client./remotefile 资源 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 / changesabsolutePath:执行世界里的绝对路径、已解 symlink——消费方是 Client 资源系统,地址里带的也是这个路径。
  • listworkspace 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上限ConfigmaxBytes / maxLines / maxEntriestoo-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.tsRemoteErrorDetailsMap 声明合并):

触发细节字段
workspace-file/not-found工作区内该路径无条目path
workspace-file/outside-workspace路径解析到会话工作区根之外path
workspace-file/too-large页/窗口超过字节上限,什么也不返回pathlimit
workspace-file/not-text不是可解码 UTF-8,或页内含 NULpath
workspace-file/not-regular-file不是普通文件pathkinddirectory / symlink / other
workspace-file/not-directory不是目录pathkindfile / symlink / other

工作区根来自策略,不是文件系统后端自己的 cwd 默认值:

this.ctx.sandboxPolicy.resolve({ session: agent.session }).workspaceRoot

源码注释给了理由:minimal preset 会用裸 fs-local 覆盖宿主 provider,其 cwd 与工作区根不同;显式解析才能让"谁回答都一样"。

五、配置

默认含义
maxBytes2097152(2 MiB)单页文本与单字节窗口的含端上限;超了拒绝,不静默截断(静默截断读起来像整页)
maxLines5000单页行数的默认值与上限;请求更大的 limit 被拒
maxEntries2000返回目录项上限;其余丢弃并置 truncated

文件本身没有大小上限——调用方自己分页翻完。实现上分页从 streamText 里切:页前的行只计数不留存,页内每段先过字节闸再缓冲,读到页后第一个字符就返回,所以大文件与"单行巨长"都最多只驻留一页。

六、变更流:fs/observedchanges

何时
{ 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-addressworkspace-file/unknown-workspace(Host 从不发这两个)

地址由 @deepseek-ai/dsh-util-workspace-pathfileAddressFor / parseFileAddress 构造与解析,本包不自己切字符串。

provider 的开流顺序:等 Host 的 ready 帧 → 首次 stat → 读期间排队变更 → 按 stat.absolutePath 绑定 follower。新写入版本抬 changed 但保留上次字节数;重复版本忽略;absent 通知或 reload 触发重新 stat。一个会话只有一条受监管 changes 流,按绝对路径扇出给该会话所有被跟随的文件(\\ 归一为 /);最后一个 follower 离开时流被释放。

八、源码佐证

位置符号 / 事实
packages/api/workspace-files/src/index.tsWorkspaceFilesConfigcutPageinspectconfinelocateFileworkspaceRootOfisNotTextRefusal
packages/api/workspace-files/src/changes.tsWorkspaceChangeFeedChangeFollowerObserved
packages/api/workspace-files/src/types.tsWorkspaceFileStatWorkspaceFileRangeWorkspaceFileTextWorkspaceByteRangeWorkspaceFileBytesWorkspaceDirectoryEntryWorkspaceDirectoryListingWorkspaceFileChangeWorkspaceFileWatchFrameRemoteErrorDetailsMap 六个码
packages/api/workspace-files/src/client/index.tsinjectapplyChangeFeed 装配
packages/api/workspace-files/src/client/provider.tscreateFileResourceProviderSessionLookupmetadataOf
packages/api/workspace-files/src/client/change-feed.tsChangeFeedFollowerSessionFeededitOf
packages/api/workspace-files/src/client/types.tsResourceProtocolMap.fileWorkspaceFileResourceWorkspaceFileParams、两个 Client 错误码
packages/fs/fs/src/index.tslstatresolvecontainsstreamTextreadByteRangelistDirprocessPathfileUrl
packages/sandbox/sandbox-policy/src/index.tsresolve(...).workspaceRoot(会话 cwd → 工作区根)
packages/bundle/web-app/cordis.patch.ymlworkspace-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 会抬起但页面文本不会被替换——内容是你自己分页读的,元数据流只告诉你"它变了"。

下一步