文件系统与搜索

文件能力分三层:一条存储原语 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_OBSERVEDcreateIfAbsent 却发现目标已存在
FS_AMBIGUOUS_EDIT未指定 replaceAll 却命中多处
FS_EDIT_NOT_FOUND编辑没有命中
FS_DENIED策略拒绝了这次观察或修改

模型可见的读写工具

catalog.Register("fs-tools", () => new FsToolsPlugin(workspace));

插件声明 Inject = [typeof(IFileSystem), typeof(IToolRegistry)],注册三个工具:

工具参数说明
fs_readpath、maxBytes?读 UTF-8 文本;超限失败而非截断
fs_writepath、content、createOnly?创建或替换
fs_editpath、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_PATTERNripgrep 拒绝了正则或 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 的默认值决定了模型一次能看到多少:

选项默认含义
GlobMaxResults100单次 glob 内联显示的路径数上限
GrepMaxMatches250单次 grep 内联显示的匹配数上限
GrepMaxLineBytes2000每条匹配行预览的字节上限
RawOutputMaxBytes20000000愿意解析的原始 rg stdout 上限
TimeoutMs30000前台超时
GraceMs3000终止升级宽限
StderrMaxBytes64 KiB保留的 stderr 诊断尾部
RipgrepPath无覆盖打包的 ripgrep 路径

超出显示上限时结果会被截到上限,但总数仍然如实报告——模型因此知道”还有更多”,可以缩小范围再搜,而不是误以为已经看全。匹配路径会转成相对工作目录的形式再给模型。

下一步

在 GitHub 上编辑此页