文件系统与搜索
文件能力分三层:一条存储原语 seam、一个本地 Provider,以及两组模型可见工具——读写编辑,和基于
打包 ripgrep 的 glob / grep。对应实现:src/Tether.Fs/、src/Tether.Fs.Local/、
src/Tether.Fs.Tools/、src/Tether.Fs.Search/。
存储原语
public interface IFileSystem
{
ValueTask<FsTarget> ResolveAsync(string path, string? cwd = null, CancellationToken ct = default);
ValueTask<FsInfo?> StatAsync(FsTarget target, CancellationToken ct = default);
ValueTask<IReadOnlyList<FsDirEntry>> ListAsync(FsTarget target, CancellationToken ct = default);
ValueTask<string> ReadTextAsync(FsTarget target, int? maxBytes = null, CancellationToken ct = default);
ValueTask<FsWriteOutcome> WriteTextAsync(FsTarget target, string content, FsWriteIntent? intent = null, CancellationToken ct = default);
ValueTask<FsEditOutcome> EditTextAsync(FsTarget target, FsEditRequest edit, string? expectedVersion = null, CancellationToken ct = default);
}
默认 Provider 不声称沙箱隔离
seam 的文档里明确写了这一点:本地 Provider 只是存储原语,不提供沙箱包含性。 把它当成"能读写整台机器"来对待。真正的隔离属于沙箱议题,见 架构总览里的已知风险。
目标与版本都是不透明的
路径先被解析成一个稳定身份,之后所有操作都针对这个身份:
var target = await fs.ResolveAsync("src/main.cs", cwd);
// target.TargetKey —— 后端键,消费者不得解析
// target.DisplayPath —— 给模型和界面看的路径
TargetKey 在本地 Provider 上恰好是文件的 realpath,但契约上是不透明的——别去拼接或解析它,换个后端就不成立了。给人看的一律用 DisplayPath。
realpath 意味着同一个文件经不同路径到达得到同一个 key(对齐 dsh resolveLocalTarget):
- 祖先链接、链接链与大小写别名都解析到底——macOS 上
/var/folders/…与/private/var/folders/…是同一个 key。沙箱世界(web.yaml)原样沿用同一套解析,两种 composition 的 key 不会漂移。 - 目标还不存在时,取最近已存在祖先的 realpath,再接回缺席的后缀,所以文件创建前后 key 不变。
- 某个父段是普通文件、或 POSIX 上
..越过了缺席目录时,解析以FS_NOT_FOUND失败——这样的目标既不存在也创建不出来。 - POSIX 上
..按物理语义跨过链接(link/..是链接目标的父目录,和内核、shell 一致);Windows 沿用原生的词法归一化。本地 Provider 经DisplayPath做原子发布,所以含..的路径会得到一个不含..、物理等价的DisplayPath,与 key 指向同一个位置。
StatAsync 返回的元数据里,Version 同样是不透明的新鲜度令牌:
public sealed class FsInfo
{
public string Version { get; } // 不透明,只用于比较
public FsEntryType Type { get; } // File / Directory / Other
public long? Size { get; }
}
目标不存在时 StatAsync 返回 null 而不是抛异常。FsEntryType 是跟随最后一层符号链接之后的判定,Other 包含断链在内的一切其它情况。ListAsync 按稳定的名字顺序给出直接子项。
读:宁可失败也不静默截断
var text = await fs.ReadTextAsync(target, maxBytes: 1 << 20);
超过 maxBytes 时抛 FS_TOO_LARGE,不做截断。这是刻意的:静默截断会让模型基于不完整内容做判断,而一个明确的失败会让它改用搜索或分段读。
写:意图即守卫
// 无条件创建或覆盖
await fs.WriteTextAsync(target, content);
// 仅当目标不存在时创建
await fs.WriteTextAsync(target, content, FsWriteIntent.CreateIfAbsent);
// 仅当当前版本匹配时替换
await fs.WriteTextAsync(target, content, FsWriteIntent.ReplaceIfVersion(version));
写入是原子的创建或替换。FsWriteOutcome 给出 Created、写入后的 Version、以及 Before / After 全文,方便上层生成 diff。Before 在创建新文件时、或原内容不是文本时为 null。
本地原子发布的权限边界
本地 Provider 不自己拼一套临时文件流程,而是调用 Tether.LocalStorage.AtomicFile。POSIX 的 directory/file 创建 syscall 请求 0700/0600,且不会修改进程级 umask;restrictive umask 可能继续移除目录权限,此时 helper 会在创建任何 descendant 或写入敏感数据前,通过稳定 parent 与 no-follow exact-entry handle 把刚创建的同一个目录归一为 0700,再复核 owner、type、identity、parent chain 与最终 mode。permissive umask 也不会产生 group/world-readable 窗口。Windows 从第一次拿到 handle 起就是 exact current-user owner 与 protected private DACL。
通用 Fs 写的目标可能是用户原本就有的普通文件,所以替换时会保留目标的 POSIX mode 或 Windows DACL;只有新目标与 staging 固定为 owner-only。无 guard 的普通覆盖维持原语义;CreateIfAbsent 携带 ExpectedMissing 到最终 native no-replace syscall,ReplaceIfVersion 与带 expectedVersion 的 edit 携带 exact identity/freshness 到最终 replace syscall,并在 exchange/ReplaceFile 后对 displaced exact object 再复核。Windows 上 File.Replace 可能把同一 DACL 重新报告为 auto-inherited(D:(A;;…) → D:AI(A;ID;…)),因此发布前后比较 DACL 时把 AI/ID 溯源标志视为非语义,owner、protected 标志、ACE 集合(SID、rights 与 CI/OI 传播位)与 SACL 仍逐字节参与比较。pre-publication 或 syscall-gap 中的原地修改、entry replacement、删除重建都会返回对应 Fs domain error 并保留竞态 winner;若需要 rollback,cleanup/rollback 失败只附加到 primary error。
发布前后仍会用 no-follow metadata 与 filesystem identity 复核 parent、target 和 staging:wrong owner、symlink/reparse point、非普通文件或并发替换一律 fail closed,且不会退回普通 copy overwrite、递归删除或“告警后继续”。
POSIX owner-only 把 effective UID 视为本地安全主体,同一 effective UID 属于同一 authority。删除 target file 或空 staging directory 时,helper 在 stable parent fd 下把 entry 原子、no-replace 地 rename 到随机 quarantine 名称;这次 rename 是 target path 删除的线性化点。线性化前换入的同 owner replacement 会在 quarantine identity 复核时 fail closed;线性化后出现在 target 的 winner 不会被 cleanup 删除,restore 也只使用 no-replace。cleanup 前已经观察到的 quarantine identity 变化继续 fail closed。
Linux/macOS 没有 expected-inode conditional unlink:unlinkat 只绑定 stable parent 与名称,Linux O_PATH 也不能把最终 unlink 绑定到已打开 inode。因此 Tether 不宣称关闭线性化后的 final unlink gap;恶意 same-euid actor 在该点后篡改随机 quarantine namespace 明确不在 #68 威胁模型内。Windows 不依赖这项收窄,最终 cleanup 使用不共享 delete 的 exact handle。
这仍然不是沙箱。owner-only 机制保护的是本地创建、重开与原子替换边界;它不阻止当前用户自己访问其它路径,也不把 Tether.Fs.Local 变成 confinement。
编辑:字面替换,每目标临界区
var outcome = await fs.EditTextAsync(
target,
new FsEditRequest(oldString, newString, replaceAll: false),
expectedVersion: version);
编辑是字面替换,不是正则。它在每个目标各自的临界区内执行,所以对同一文件的并发编辑不会互相踩踏。两种匹配问题有专门的码:不带 replaceAll 却命中多处是 FS_AMBIGUOUS_EDIT,一处都没命中是 FS_EDIT_NOT_FOUND。
稳定失败码
所有失败都是带码的 FsException:
| 码 | 含义 |
|---|---|
FS_NOT_FOUND | 目标不存在 |
FS_NOT_DIRECTORY | 需要目录却不是 |
FS_NOT_REGULAR_FILE | 需要普通文件却不是 |
FS_NOT_TEXT | 文件不是 UTF-8 文本 |
FS_TOO_LARGE | 超过调用方给的上限 |
FS_PERMISSION_DENIED | 操作系统拒绝 |
FS_IO_ERROR | 后端 I/O 失败 |
FS_STALE_VERSION | 版本守卫不匹配 |
FS_NOT_OBSERVED | createIfAbsent 却发现目标已存在 |
FS_AMBIGUOUS_EDIT | 未指定 replaceAll 却命中多处 |
FS_EDIT_NOT_FOUND | 编辑没有命中 |
FS_DENIED | 策略拒绝了这次观察或修改 |
模型可见的读写工具
catalog.Register("fs-tools", () => new FsToolsPlugin(workspace));
插件声明 Inject = [typeof(IFileSystem), typeof(IToolRegistry)],注册三个工具:
| 工具 | 参数 | 说明 |
|---|---|---|
fs_read | path、maxBytes? | 读 UTF-8 文本;超限失败而非截断 |
fs_write | path、content、createOnly? | 创建或替换 |
fs_edit | path、oldString、newString、replaceAll? | 字面替换 |
这组工具维护一份进程内的”已观察版本”表,按目标键记录最后一次看到的版本。它把写入变成了乐观并发:
fs_read成功后记录该目标的当前版本;fs_write在createOnly时用CreateIfAbsent;否则若表里有该目标的版本就用ReplaceIfVersion,没有则无条件覆盖;fs_edit把表里的版本作为expectedVersion传下去。
先读后写不是礼貌,是机制
因为版本表只在读之后才有条目,模型"先读再改"就自动获得了版本守卫:
文件在读之后被别人改过,写入会以 FS_STALE_VERSION 失败而不是悄悄覆盖。
fs_edit 的工具描述直接写明了它要求本进程内先读过同一路径。
搜索建在 ripgrep 上
catalog.Register("search-tools", () => new SearchToolsPlugin(workspace, options));
这个插件声明 Inject = [typeof(ISubprocessRuntime), typeof(IToolRegistry), typeof(IFileSystem)]——注意它依赖子进程 seam:搜索不是自己遍历目录,而是把打包的 ripgrep 作为子进程跑起来。
失败码与 FS_* 分开,因为这是 spawn 支撑的操作而非存储操作:
| 码 | 含义 |
|---|---|
SEARCH_MISSING | 打包的 ripgrep 缺失或版本不对 |
SEARCH_INVALID_PATTERN | ripgrep 拒绝了正则或 glob |
SEARCH_FAILED | 跑不起来,或输出无法解析 |
SEARCH_RAW_OUTPUT_OVERFLOW | 原始 stdout 超过解析预算 |
SEARCH_TIMEOUT | 执行超时截断 |
SEARCH_ABORTED | 调用方取消截断 |
SearchException 的消息以码开头,便于日志直接定位。
glob 与 grep 的实际行为
两个工具的行为由传给 ripgrep 的参数决定,写在这里以免误解。
glob 构造的是 rg --files:
--files --glob=<pattern> --sort=modified --no-ignore --hidden
--glob=!**/.git --glob=!**/.git/** …(.svn .hg .bzr .jj .sl 同样)
-- <path 或 .>
由此得出模型可以依赖的性质:只返回文件、绝不返回目录;包含隐藏文件与被忽略的文件(--no-ignore --hidden);但排除版本控制元数据目录;结果按修改时间排序;不含 / 的模式在任意深度匹配基名。
grep 构造的是 rg --json:
--json --regexp=<pattern> [--glob=<include>] -- <path 或 .>
用 --json 而不是解析文本输出,是为了拿到结构化的文件名与行号。参数里显式给出搜索路径有个具体原因:避免被重定向的 stdin 当成搜索语料。
不经过 shell,所以不需要引号转义
argv 是直接构造的数组,没有 shell 参与,因此模式里的空格、引号、
$ 之类字符不会被二次解释。这也是搜索走子进程 seam而不走
shell seam的原因之一。
搜索预算
SearchToolsOptions 的默认值决定了模型一次能看到多少:
| 选项 | 默认 | 含义 |
|---|---|---|
GlobMaxResults | 100 | 单次 glob 内联显示的路径数上限 |
GrepMaxMatches | 250 | 单次 grep 内联显示的匹配数上限 |
GrepMaxLineBytes | 2000 | 每条匹配行预览的字节上限 |
RawOutputMaxBytes | 20000000 | 愿意解析的原始 rg stdout 上限 |
TimeoutMs | 30000 | 前台超时 |
GraceMs | 3000 | 终止升级宽限 |
StderrMaxBytes | 64 KiB | 保留的 stderr 诊断尾部 |
RipgrepPath | 无 | 覆盖打包的 ripgrep 路径 |
超出显示上限时结果会被截到上限,但总数仍然如实报告——模型因此知道”还有更多”,可以缩小范围再搜,而不是误以为已经看全。匹配路径会转成相对工作目录的形式再给模型。