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 — settings/openAgentPresetDirectory { 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; settings/canOpenAgentPresetDirectory gates the row, and Settings Controller's nativeOpen pins server behavior where platform detection would mislead).
Consequences
- No composition text and no path crosses the browser wire in either authoring direction; the
entryListSchema/!!jsconcern dissolves withassertCompositionitself (deleted). The authoring operations areread/copy/openDocument/remove— none accepts a filesystem target, and Connection authenticates them with the complete Host API. - 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, browser-authenticated. The invariant "no browser payload can select an arbitrary filesystem target" is about the request direction; showing the resolved directory to the authenticated browser is the fallback the plan requires. It never rides
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 session/openWorkspacePath 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。页面包含随附组装的只读查看器、作为唯一创建入口的复制对话框(不提供空白「新建预设」)、自定义行的删除,以及通向文件的位置操作。settings/openAgentPresetDirectory { agentPreset } 在 Host 侧解析目录并原生打开,部署没有桌面时回答 { opened: false, path } 供该行以文本形式展示;settings/canOpenAgentPresetDirectory 控制该行是否显示,Settings Controller 的 nativeOpen 则在平台探测可能误判时固定服务端行为。
后果
- 创作两个方向都没有组装文本或路径跨越浏览器传输层;
entryListSchema/!!js的顾虑随assertComposition本身(已删除)一并消解。创作操作是read/copy/openDocument/remove——没有一个接收文件系统目标,且 Connection 用完整 Host API 的同一会话认证它们。 - 编辑器移除后,手改
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 也刻意选了完整副本。浏览器端拿返回路径调 session/openWorkspacePath:路径一旦成为请求参数,就打破了 README 的「不可选中任意目标」不变量。