fix(host): review round 20 — canonical shape declared at the interface; hermetic home-shape spec; single resolve

This commit is contained in:
creatixchu
2026-07-30 02:10:07 +08:00
parent cc92b6b578
commit 983e6d07a4
9 changed files with 55 additions and 17 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
2026-07-28-directory-picker-capability-seam.md: 6cba49f9a5817d05e4933e397918a0b9a17ce845
2026-07-28-directory-picker-capability-seam.zh.md: bfa9f80db32b1e73a198442826c7aea9972c7412
2026-07-28-directory-picker-capability-seam.md: ebd40b9dbc89630c5cbbfacdcfb8be2b6a65fd49
2026-07-28-directory-picker-capability-seam.zh.md: 6ba8eb347bf906f05ac6192cf941e76d3bc4f44a

View File

@@ -22,6 +22,7 @@ Placement and policy rulings folded into this decision:
- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body, and a show-hidden toggle click that finds focus among the rows parks synchronously on the toggle itself. The guarantee is scoped to the dialog's own node replacements — the Modal has no focus trap, so tabbing past the card's edge legitimately leaves, and the owner's adopt window (where `busy` inerts every control and the dialog is closing either way) is likewise outside it.
- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans.
- **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption.
- **One canonical path shape on the wire.** A listing's `path`, `crumbs[].path`, `entries[].path`, and `home` all ship host-resolved — homedir() output included, since the environment may decorate HOME and the backend resolves it before stamping. Clients compare listing paths verbatim on that promise; the only lexical mirror left in the browse client serves the draft side, the one path a user types and hence naturally non-canonical. Normalizing at the source replaces a client-side mirror of resolve() that had to anticipate every decoration (trailing and repeated separators, dot segments, UNC roots, forward slashes), and the promise binds every browse backend.
- **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories.
- **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it.
- **The native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs.

View File

@@ -22,6 +22,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick
- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/``C:\``\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded``aria-controls`active-descendant、结果播报同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量而非编辑器独有每次选取——无论是否处于编辑态包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上而其余所有会顶离焦点的退出Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举以及嵌套创建对话框的关闭只要焦点确实落到了 body 上,就回落到 crumb 编辑区,而点击"显示隐藏"开关时若发现焦点落在行间则把焦点同步停靠到开关自身。该保证的范围仅限对话框自身的节点替换——Modal 没有焦点陷阱,所以 Tab 越过卡片边缘属于正当离开,而 owner 的接纳窗口(其间 `busy` 把每个控件置为惰性,且对话框反正正在关闭)同样在此范围之外。
- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地重新选中目标在父层级中的实际条目Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内可能因所按行节点被替换而丢失点击键盘焦点会重新停靠到重新选中的行上指针的这段窗口则被接受斜杠平台macOS上仅末段的大小写偏差会错过父层级条目匹配保留单栏落地而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏代价是 Home 塌缩且导航总是重新列举两程哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。
- **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。
- **线上只有一种规范路径形态。** 列举的 `path``crumbs[].path``entries[].path``home` 一律以宿主解析后的形态发出——homedir() 的输出也不例外,因为环境可能修饰 HOME后端在标注前先行解析。客户端凭这一承诺逐字比较列举路径browse 客户端仅剩的词法镜像服务于草稿一侧——用户键入的那一条路径因而天然非规范。在源头做规范化取代了客户端侧那份必须预判每种修饰末尾与重复的分隔符、点段、UNC 根、正斜杠)的 resolve() 镜像,且这一承诺约束每一个 browse 后端。
- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await打开、每次读取、每次符号链接探测都与信号赛跑中止路径放弃而非等待 closeNode 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。
- **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。
- **native 后端保留。** 插件化正是目的:多方都能提供该 seamElectron 壳可以经自己的对话框 API 提供 `native` 交互。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。

View File

@@ -1016,6 +1016,10 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
// same tree the browse primitives serve).
pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }),
listDirectory: (request) => {
// The fixture accepts CANONICAL paths only: a decorated input
// (./, //, ..) misses the tree map and reads as unreadable, where
// the real backend resolve()s it first. The keyless lanes drive
// canonical paths, so the divergence stays out of transcripts.
const target = request.payload.path ?? FIXTURE_HOME
const children = childrenOf(target)
if (children === undefined) {

View File

@@ -15,11 +15,18 @@ export interface DirectoryEntry {
hidden: boolean
}
/** host.listDirectory response value: one directory level plus its ancestry. */
/**
* host.listDirectory response value: one directory level plus its ancestry.
* Every path in one listing — `path`, `crumbs[].path`, `entries[].path`,
* and `home` — is host-resolved canonical form: no `.`/`..` segments, no
* repeated or trailing separators (bare roots `/`, `C:\`, `\\server\share\`
* excepted), one platform separator. Clients compare paths on this promise
* without re-normalizing.
*/
export interface DirectoryListing {
/** Absolute path of the listed directory. */
path: string
/** The host account's home directory (breadcrumb "Home" rooting), in the same resolved shape as `path` and `crumbs[].path`. */
/** The host account's home directory (breadcrumb "Home" rooting), in the interface's canonical shape like every other path here. */
home: string
/**
* Ancestor chain from the filesystem root to the listed directory

View File

@@ -137,8 +137,9 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE
/**
* The listing's platform separator, read from the host-resolved root crumb
* (`/`, `C:\`, `\\server\share\`) — exact for every root form the backend
* emits, immune both to a home delivered in the other slash flavor
* (`USERPROFILE=C:/Users/Alice`) and to backslashes inside POSIX names.
* emits, and immune to backslashes inside POSIX names (which the home text
* may legally carry; the wire contract already excludes non-canonical
* shapes elsewhere).
* TODO: replace with a host-stamped `separator` field on the wire
* DirectoryListing so the platform fact travels verbatim (the trade-off is
* recorded in the directory-picker capability seam Agent Note).

View File

@@ -217,9 +217,12 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
private async list(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
// Resolved like every other path in the listing: the environment may
// decorate HOME (trailing or repeated separators, dot segments, win32
// forward slashes) and homedir() ships it verbatim, while clients
// compare home against the resolved `path`/`crumbs` — the wire contract
// promises one canonical shape for all three.
// forward slashes) and homedir() ships it verbatim, while the wire
// contract promises one canonical shape for every listing path. A
// relative or drive-less HOME rebases under the process cwd / current
// drive here — the behavior the fullyQualified fence refuses for wire
// values — accepted for the host's own environment, since the listed
// target derives from home and stays consistent with it.
const home = resolve(homedir())
// The seam contract takes fully qualified paths only; resolve() would
// silently rebase a relative or empty wire value under the host process
@@ -227,7 +230,7 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
if (path !== undefined && !fullyQualified(path)) {
throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`)
}
const target = resolve(path ?? home)
const target = path === undefined ? home : resolve(path)
// Stream the level (opendir, one dirent at a time) into a name-sorted
// window of maxEntries + 1 candidates: memory stays bounded no matter how
// many children the directory holds, the window keeps the name-sorted

View File

@@ -2,19 +2,33 @@
* The wire contract's home shape: a decorated HOME (trailing/repeated
* separators, dot segments — homedir() ships it verbatim) still leaves the
* listing carrying the resolved form, matching `path` and `crumbs[].path`.
* The mock points homedir at a scratch tree so the probe never scans the
* running machine's real home (same hermetic reasoning as service.spec's
* temporary tree); the mock spreads the actual module, so tmpdir stays real.
*/
import { resolve } from 'node:path'
import { expect, it, vi } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterAll, beforeAll, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
let scratch: string
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>()
return { ...actual, homedir: () => `${actual.homedir()}/.//.` }
return { ...actual, homedir: () => `${scratch}/.//.` }
})
beforeAll(async () => {
scratch = await mkdtemp(join(tmpdir(), 'dsh-home-shape-'))
})
afterAll(async () => {
await rm(scratch, { recursive: true, force: true })
})
it('resolves a decorated homedir before stamping listing.home', async () => {
const { homedir } = await vi.importActual<typeof import('node:os')>('node:os')
const { default: BrowseDirectoryPicker } = await import('../src/index.ts')
const ctx = new Context()
const fiber = ctx.plugin(BrowseDirectoryPicker)
@@ -22,7 +36,7 @@ it('resolves a decorated homedir before stamping listing.home', async () => {
const picked = ctx.get('directoryPicker')!.capability()
if (picked.kind !== 'browse') throw new Error('browse backend must advertise the browse capability')
const listing = await picked.list()
expect(listing.home).toBe(resolve(homedir()))
expect(listing.home).toBe(resolve(scratch))
expect(listing.path).toBe(listing.home)
await fiber.dispose()
})

View File

@@ -34,11 +34,18 @@ export interface DirectoryEntry {
hidden: boolean
}
/** One directory level plus its ancestry, as a browse backend reports it. */
/**
* One directory level plus its ancestry, as a browse backend reports it.
* Every path in one listing — `path`, `crumbs[].path`, `entries[].path`,
* and `home` — is host-resolved canonical form: no `.`/`..` segments, no
* repeated or trailing separators (bare roots `/`, `C:\`, `\\server\share\`
* excepted), one platform separator. Clients compare paths on this promise
* without re-normalizing; every backend must resolve before stamping.
*/
export interface DirectoryListing {
/** Absolute path of the listed directory. */
path: string
/** The host account's home directory (breadcrumb "Home" rooting), in the same resolved shape as `path` and `crumbs[].path`. */
/** The host account's home directory (breadcrumb "Home" rooting), in the interface's canonical shape like every other path here. */
home: string
/**
* Ancestor chain from the filesystem root to the listed directory