diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index a259edb5ff..a30ff5970e 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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: 7d6a78a735a02ae1d17616c63da1acf9a9117ba9 -2026-07-28-directory-picker-capability-seam.zh.md: 1306eae397e2a4c11603264d434ced3b4113f9b2 +2026-07-28-directory-picker-capability-seam.md: dcb3300637aef18d1e244d0a6d3408c5f1eadf02 +2026-07-28-directory-picker-capability-seam.zh.md: 73cc8febc9f5d70621254bbaf2860060554d518e diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 7d6a78a735..dcb3300637 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the planned show-hidden toggle becomes a client-only change. Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **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. -- **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. An unbounded level is a memory/responsiveness hole for large or adversarial directories. +- **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. 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. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 1306eae397..73cc8febc9 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 -- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 +- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 - **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8db82e5cc7..3e70c22fbc 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -533,7 +533,7 @@ export interface Config { } ``` -Source: [`packages/host/directory-picker-browse/src/index.ts:114`](../packages/host/directory-picker-browse/src/index.ts) +Source: [`packages/host/directory-picker-browse/src/index.ts:127`](../packages/host/directory-picker-browse/src/index.ts) ## `@deepseek-ai/dsh-host-webserver` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5c422d6586..314c707f4c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -500,7 +500,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:128`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:131`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 0a029072d0..a11d8c4157 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1160,7 +1160,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) - case 'host.listDirectory': return this.api.host.listDirectory(request) + case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal) case 'host.createDirectory': return this.api.host.createDirectory(request) case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) case 'workspace.list': return this.api.workspace.list(request) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9ca1d81ca9..e8761181ad 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1669,7 +1669,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'DirectoryPickerBrowseCapability', - declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string): Promise;\n createDirectory(path: string, name: string): Promise;\n}', + declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string, signal?: AbortSignal): Promise;\n createDirectory(path: string, name: string): Promise;\n}', }, { name: 'DirectoryPickerCapabilities', diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 7b7beacec8..d4c4268088 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1076,7 +1076,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, - async listDirectory(request) { + async listDirectory(request, signal) { const capability = ctx.directoryPicker.capability() if (capability.kind !== 'browse') { return err(request, { @@ -1086,7 +1086,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } try { - return ok(request, await capability.list(request.payload.path)) + // The carrier's signal follows the caller: a disconnect or timeout + // stops the backend's directory scan instead of outliving it. + return ok(request, await capability.list(request.payload.path, signal)) } catch (error: unknown) { return err(request, directoryError(error)) } diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 0338494bed..3d0713e523 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -62,10 +62,13 @@ export interface HostApi { /** * List one directory level for the in-app browser; an absent path lists the * host account's home directory. Only served under the `browse` capability; - * unreadable or missing targets fail with `directory-unreadable`. + * unreadable or missing targets fail with `directory-unreadable`. The + * carrier's request signal follows the caller, stopping the backend's scan + * on disconnect or timeout. */ listDirectory( request: RpcRequest<{ path?: string }>, + signal: AbortSignal, ): Promise> /** diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 1f9bf3fde7..5398ce4848 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -64,7 +64,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, - 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r) => api.host.listDirectory(r) }, + 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) }, 'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) }, 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) }, 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 9c52b62a61..f04850c34d 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -172,9 +172,9 @@ const BROWSE_STUB: DirectoryPickerCapability = { describe('host.listDirectory / host.createDirectory', () => { it('serves listings and creation through the browse capability, defaulting to home', async () => { const { api } = await harness(undefined, BROWSE_STUB) - const home = await api.host.listDirectory(request({})) + const home = await api.host.listDirectory(request({}), new AbortController().signal) expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } }) - const listed = await api.host.listDirectory(request({ path: '/home/user/projects' })) + const listed = await api.host.listDirectory(request({ path: '/home/user/projects' }), new AbortController().signal) expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } }) const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' })) expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } }) @@ -182,7 +182,7 @@ describe('host.listDirectory / host.createDirectory', () => { it('maps typed picker failures onto the wire error codes and folds unknown throws to internal', async () => { const { api } = await harness(undefined, BROWSE_STUB) - expect((await api.host.listDirectory(request({ path: '/denied' }))).result).toMatchObject({ + expect((await api.host.listDirectory(request({ path: '/denied' }), new AbortController().signal)).result).toMatchObject({ ok: false, error: { code: 'directory-unreadable', details: { path: '/denied' } }, }) expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result).toMatchObject({ @@ -195,7 +195,7 @@ describe('host.listDirectory / host.createDirectory', () => { it('refuses the browse RPCs under a native composition', async () => { const { api } = await harness() - expect((await api.host.listDirectory(request({}))).result).toMatchObject({ + expect((await api.host.listDirectory(request({}), new AbortController().signal)).result).toMatchObject({ ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, }) expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({ diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index f6cf3fe8d0..4454afde3a 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -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 packages/host/directory-picker-browse/README.md -README.md: 36349a607cdcf99ce2b8c9ee767ffd4d896a64a3 -README.zh.md: 0108e37bd7c4213bf057a42af2026beb6bf53d45 +README.md: 318380405214d5f25ad77e348c4e134a8981ffb3 +README.zh.md: 2f88f64cc2974b8535e34eb9798f512ea109b754 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 36349a607c..3183804052 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the native backend cannot. -Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated). Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). +Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (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, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). **Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view, breadcrumb with a click-to-edit path zone, nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 0108e37bd7..2f88f64cc2 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -4,7 +4,7 @@ [目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 native 后端无法触及的远程客户端。 -行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断)。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 **双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图、带点击即编辑路径区的面包屑、嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index 20ebef1c62..be27c5a2d4 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -74,9 +74,22 @@ export interface ListingCandidate { * @returns true when an eviction happened (the level has candidates beyond the window). */ export function boundedInsert(window: ListingCandidate[], candidate: ListingCandidate, keep: number): boolean { - const at = window.findIndex(existing => candidate.name.localeCompare(existing.name) < 0) - if (at === -1) window.push(candidate) - else window.splice(at, 0, candidate) + // Full window, name at or beyond the tail: one comparison rejects, so an + // oversized level costs O(1) per candidate past the head instead of a + // window scan (100k children against a 1,001 window must not approach + // 10^8 comparisons). + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- a full window (length === keep >= 1) has a tail + if (window.length === keep && candidate.name.localeCompare(window[window.length - 1]!.name) >= 0) return true + // Binary insertion keeps a retained candidate at O(log keep) comparisons. + let lo = 0 + let hi = window.length + while (lo < hi) { + const mid = (lo + hi) >>> 1 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition + if (candidate.name.localeCompare(window[mid]!.name) < 0) hi = mid + else lo = mid + 1 + } + window.splice(lo, 0, candidate) if (window.length <= keep) return false window.pop() return true @@ -131,7 +144,7 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { private readonly browseCapability: DirectoryPickerCapability = { kind: 'browse', - list: path => this.list(path), + list: (path, signal) => this.list(path, signal), createDirectory: (path, name) => this.createDirectory(path, name), } @@ -147,7 +160,7 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { return this.browseCapability } - private async list(path?: string): Promise { + private async list(path?: string, signal?: AbortSignal): Promise { const home = homedir() // The seam contract takes fully qualified paths only; resolve() would // silently rebase a relative or empty wire value under the host process @@ -169,6 +182,9 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { try { const level = await opendir(target) for await (const dirent of level) { + // A disconnected/timed-out caller stops the scan here; throwing out + // of the loop closes the directory handle via the iterator's return. + signal?.throwIfAborted() // Only rows a browser could enter contend for the window; dirent // says "directory" outright, a symlink needs the later stat probe. if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue @@ -176,6 +192,8 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { if (boundedInsert(window, candidate, keep)) evicted = true } } catch (error: unknown) { + // An abort is the caller's own reason, not an unreadable directory. + signal?.throwIfAborted() throw new DirectoryPickerError('directory-unreadable', target, `cannot list ${target}: ${messageOf(error)}`) } const entries: DirectoryEntry[] = [] diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 2b01d0d29e..99366e8754 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -83,6 +83,19 @@ describe('BrowseDirectoryPicker', () => { } }) + it('stops the scan with the caller: an aborted signal rejects with its own reason', async () => { + const gone = new AbortController() + gone.abort(new Error('caller left')) + // The abort surfaces as-is, not dressed as an unreadable directory. + await expect(capability.list(root, gone.signal)).rejects.toThrow('caller left') + // A live signal changes nothing about ordinary failures. + const live = new AbortController() + const missing = join(root, 'no-such-dir') + const failure = await capability.list(missing, live.signal).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(DirectoryPickerError) + expect((failure as DirectoryPickerError).code).toBe('directory-unreadable') + }) + it('boundedInsert keeps the window name-sorted and bounded, reporting evictions', () => { const candidate = (name: string): ListingCandidate => ({ name, isDirectory: true, isSymbolicLink: false }) const window: ListingCandidate[] = [] @@ -91,9 +104,11 @@ describe('BrowseDirectoryPicker', () => { // A smaller name lands in place and pushes the current largest out. expect(boundedInsert(window, candidate('a'), 2)).toBe(true) expect(window.map(entry => entry.name)).toEqual(['a', 'm']) - // A name beyond the window's tail enters last and leaves immediately. + // A name at or beyond the full window's tail rejects on one comparison. expect(boundedInsert(window, candidate('t'), 2)).toBe(true) expect(window.map(entry => entry.name)).toEqual(['a', 'm']) + expect(boundedInsert(window, candidate('m'), 2)).toBe(true) + expect(window.map(entry => entry.name)).toEqual(['a', 'm']) }) it('reports the ancestry as jump-target crumbs ending at the listed directory', async () => { diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 3b4b60562f..4dba9c9c22 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -65,13 +65,16 @@ export interface DirectoryPickerBrowseCapability { /** * List one directory level. * @param path - absolute directory to list; absent lists the home directory. + * @param signal - caller lifetime; abort stops the scan (a stalled network + * directory must not outlive a disconnected caller) and rejects with the + * abort reason. * @returns the level's listing with ancestry; backends bound the complete * result, and a cut level reports `truncated`. * @throws {DirectoryPickerError} `directory-unreadable` when the target is not fully * qualified (a wire value must never resolve against the host cwd or, on * Windows, its current drive) or cannot be listed. */ - list(path?: string): Promise + list(path?: string, signal?: AbortSignal): Promise /** * Create one child directory under an existing parent. * @param path - absolute existing parent directory.