Copy-only preset authoring, and the way into a preset's files
仅复制的 preset 创作,与通往 preset 文件的入口
The agent-preset settings page carried a web YAML editor: `agentPreset.write` accepted arbitrary composition text, the page held a textarea with no completion, highlighting, or diff, and the shape check leaned on the Loader's own `entryListSchema` — whose dialect includes `!!js`, so "shape-checked text" was still arbitrary code on the next mount. Weak as an editor, wide as a capability, and the source of the editor-v
English
Problem
The agent-preset settings page carried a web YAML editor: agentPreset.write accepted arbitrary composition text, the page held a textarea with no completion, highlighting, or diff, and the shape check leaned on the Loader's own entryListSchema — whose dialect includes !!js, so "shape-checked text" was still arbitrary code on the next mount. Weak as an editor, wide as a capability, and the source of the editor-vs-roster races the section had to defend against.
Decision
Authoring is a host-side copy, and files are the editor. agentPreset.write became agentPreset.copy { from, agentPreset, name? }: two ids the host resolves against its own roots plus an optional display name, whole-directory cp (symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept), metadata rewritten to keep the source's description but never its name or order. The page becomes: read-only viewer over shipped compositions, copy dialog as the only create entry (no blank "new preset" — writing YAML from nothing is not a thing people do), delete for custom rows, and a location action that leads to the files — agentPreset.openDocument { agentPreset } resolves the directory host-side and opens it natively, or answers { opened: false, path } for the row to show as text where the deployment has no desktop (hasDocument on list, pinned by the gateway's nativeOpen config where canOpenNativePath platform detection would mislead, e.g. e2e and containers).
Consequences
- No composition text and no path crosses the browser wire in either authoring direction; the
entryListSchema/!!jsconcern dissolves withassertCompositionitself (deleted). The privileged set is nowread/copy/openDocument/remove— none accepts a filesystem target. - With the editor gone, hand-editing
agent.cordis.ymlis the ONLY composition edit, so the standing-mount layer grew stamp-keyed generations:ensureStandingcompares the file's mtime+size and starts the next generation for later sessions (standing-mounts note, updated in place). Without this, an edited file would serve stale compositions until process restart. - A copy is a full snapshot that drifts from an upgraded shipped source — accepted; the preset layer has no patch semantics (that is the bundle layer's
cordis.patch.yml), and the shipped set itself pays the same cost (cordis/codeare full copies ofstandard) for one-file readability. readdroppedwritable(no editor to gate) and builtin directories are never opened (openDocumentrefuses non-usertrust likeremove): the install is overwritten by upgrades, and pointing an editor into it invites edits an upgrade silently discards.
Load-bearing details
- Copy target refusal is two checks on purpose. The roster check refuses any id a root supplies — a user directory named like a shipped preset would be shadowed, so "create" would land a file nothing ever lists; the disk check (
PresetExistsErrorbeforecpwitherrorOnExistas the race backstop) refuses a directory occupying the name without being a preset, which discovery cannot see. - The revealed path is response-direction disclosure, loopback-pinned. The invariant "no browser payload can select an arbitrary filesystem target" is about the request direction; showing the resolved directory to the loopback user is the fallback the plan requires. It never rides the unprivileged
list. - The e2e lane pins
nativeOpen: false(agent-preset-authoring.overlay.yml) — both so goldens render the same branch on macOS dev and headless Linux CI, and so test runs never pop a real file manager. The revealed directory is tokenized as{{presetRoot}}by the lane itself, sincenormalizeAriaonly knows the workspace cwd.
Alternatives considered
Keeping write with a better editor (CodeMirror etc.): still arbitrary capability over the wire, still the race source, and still a worse editor than the user's own. Patch-semantics copies ("standard plus this diff"): no such layer exists below the bundle plane, and the repo's own shipped presets chose full copies deliberately. Browser-side host.openPath with a returned path: breaks the README's no-arbitrary-target invariant the moment the path is a request parameter.
中文
问题
agent-preset 设置页带着一个网页 YAML 编辑器:agentPreset.write 接收任意组装文本,页面是一个没有补全、高亮或 diff 的文本域,形状检查依赖 Loader 自己的 entryListSchema——其方言含 !!js,所以「过了形状检查的文本」在下一次挂载时仍是任意代码。作为编辑器很弱,作为能力很宽,还是该分区不得不防御的「编辑器 vs 名单」竞态的来源。
决策
创作改为宿主端复制,文件就是编辑器。agentPreset.write 变为 agentPreset.copy { from, agentPreset, name? }:两个由宿主对照自身根目录解析的 id 加一个可选显示名,整目录 cp(符号链接解引用,权限收紧为仅属主并保留属主执行位),元数据重写为保留来源描述、但绝不保留其名称与 order。页面变为:随附组装的只读查看器、作为唯一创建入口的复制对话框(不再有空白「新建预设」——从零手写 YAML 不是人会做的事)、自定义行的删除,以及通向文件的位置操作——agentPreset.openDocument { agentPreset } 在宿主端解析目录并原生打开,部署没有桌面时回答 { opened: false, path } 供该行以文本形式展示(list 上的 hasDocument;在 canOpenNativePath 平台探测会失真处由网关的 nativeOpen 配置钉死,例如 e2e 与容器)。
后果
- 创作两个方向都不再有组装文本或路径跨越浏览器传输层;
entryListSchema/!!js的顾虑随assertComposition本身(已删除)一并消解。特权集现为read/copy/openDocument/remove——没有一个接收文件系统目标。 - 编辑器移除后,手改
agent.cordis.yml成为唯一的组装编辑方式,因此常驻挂载层增加了以 stamp 为键的代际:ensureStanding比对文件的 mtime+大小,为后续会话开启下一代际(常驻挂载 note,已就地更新)。没有它,改过的文件要等进程重启才生效。 - 副本是完整快照,会随随附来源升级而漂移——接受;preset 层没有 patch 语义(那是 bundle 层
cordis.patch.yml的能力),随附集合自己也为「一个文件读完整份组装」付了同样的代价(cordis/code就是standard的完整副本)。 read去掉了writable(没有编辑器可门控),内置目录绝不被打开(openDocument与remove一样拒绝非user信任):安装目录会被升级覆盖,把编辑器指向它等于招揽会被升级悄悄丢弃的编辑。
关键实现细节
- 复制目标的拒绝刻意分两道检查。 roster 检查拒绝任一根目录提供的 id——与随附 preset 同名的用户目录会被遮蔽,「创建」只会落下一个永远不被列出的文件;磁盘检查(
cp之前的PresetExistsError,errorOnExist作竞态兜底)拒绝占着名字却不是 preset 的目录,那是 discovery 看不见的。 - 展示的路径是响应方向的披露,且钉在环回。「没有任何浏览器载荷能选中任意文件系统目标」这条不变量说的是请求方向;把解析出的目录展示给环回用户正是方案要求的降级。它绝不搭乘非特权的
list。 - e2e lane 钉死
nativeOpen: false(agent-preset-authoring.overlay.yml)——既让 golden 在 macOS 开发机与无头 Linux CI 上渲染同一分支,也让测试运行永不弹出真实文件管理器。揭示的目录由 lane 自己 token 化为{{presetRoot}},因为normalizeAria只认识 workspace cwd。
考虑过的替代方案
保留 write 换个更好的编辑器(CodeMirror 等):传输层上仍是任意能力,仍是竞态来源,而且仍不如用户自己的编辑器。带 patch 语义的副本(「standard 加这点 diff」):bundle 面之下没有这样的层,仓库自己的随附 preset 也刻意选了完整副本。浏览器端拿返回路径调 host.openPath:路径一旦成为请求参数,就打破了 README 的「不可选中任意目标」不变量。