From 5245182db290e057a23fba5e4bba936d6de64557 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 03:45:26 +0800 Subject: [PATCH] fix(host): bound listDirectory levels at a configurable maxEntries One list call now materializes at most maxEntries child rows (config, default 1000 - GitHub's web-UI directory-listing bound). Candidates sort before probing so a cut level keeps the name-sorted head and symlink probing stops with the bound, and DirectoryListing carries a required truncated flag on the seam and the wire so clients can state incompleteness instead of silently missing tail entries. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 1 + ...-28-directory-picker-capability-seam.zh.md | 1 + docs/config-catalog.md | 13 +++++- docs/cordis-catalog/services.md | 2 +- .../client/connection/src/client/fixture.ts | 2 + packages/client/connection/tests/fake-api.ts | 3 +- packages/client/runtime/tests/fake-api.ts | 3 +- .../runtime/tests/workspaces-service.spec.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/apiproxy/src/api/host.schema.ts | 1 + packages/host/apiproxy/src/api/host.ts | 2 + .../tests/api-proxy-workspace.spec.ts | 1 + .../apiproxy/tests/client-handler.spec.ts | 2 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 4 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 3 ++ .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../host/directory-picker-browse/src/index.ts | 43 +++++++++++++++++-- .../tests/service.spec.ts | 21 +++++++++ packages/host/directory-picker/src/index.ts | 9 +++- 22 files changed, 107 insertions(+), 20 deletions(-) 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 f1bd3fd344..500e032939 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: 268caad197985d052affb17fe2a7644cd6722075 -2026-07-28-directory-picker-capability-seam.zh.md: 573bfc19bb0f5eeae3f1e851e733fd2498a986bf +2026-07-28-directory-picker-capability-seam.md: 29c00c370b4fe00f528db62751f1a5df014ffd32 +2026-07-28-directory-picker-capability-seam.zh.md: 740d098ce881dd38f01281313bf44421d54b64c8 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 268caad197..29c00c370b 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,6 +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.** One `list` call materializes at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound): candidates sort before probing so a cut level keeps the name-sorted head and probing stops with the bound, and the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. 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 573bfc19bb..740d098ce8 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,6 +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 网页端目录列举的同一上限):候选先按名排序再探测,因此被截断的层级保留排序后的头部、探测随上限一同停止;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 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 9bdae9aabf..865e97a555 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -523,6 +523,18 @@ export interface Config { Source: [`packages/host/apiproxy/src/index.ts:33`](../packages/host/apiproxy/src/index.ts) +## `@deepseek-ai/dsh-host-directory-picker-browse` + +```ts config-catalog +/** Validated plugin configuration. */ +export interface Config { + /** Complete-result bound of one listing level; see {@link BrowseDirectoryPicker.Config}. */ + maxEntries: number +} +``` + +Source: [`packages/host/directory-picker-browse/src/index.ts:85`](../packages/host/directory-picker-browse/src/index.ts) + ## `@deepseek-ai/dsh-host-webserver` ```ts config-catalog @@ -2209,7 +2221,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) -- `@deepseek-ai/dsh-host-directory-picker-browse` ([`packages/host/directory-picker-browse/src/index.ts`](../packages/host/directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e8c7aac38a..5c422d6586 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:121`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:128`](../../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 82ecbb1cf3..0a029072d0 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -880,6 +880,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { crumbs: crumbsOf(target), entries: [...children].sort((a, b) => a.localeCompare(b)) .map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })), + // The fixture tree is tiny; no level ever reaches a backend bound. + truncated: false, }) }, createDirectory: (request) => { diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 13ca7f4922..59bb426def 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -75,8 +75,9 @@ export class FakeApiClient implements IApiClient { home: string crumbs: { name: string; path: string; hidden: boolean }[] entries: { name: string; path: string; hidden: boolean }[] + truncated: boolean }>> = - () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] })) + () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false })) onCreateDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: '/home/fake/new' })) diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index b0f5f13db5..15b2df1a81 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -93,8 +93,9 @@ export class FakeApiClient implements IApiClient { home: string crumbs: { name: string; path: string; hidden: boolean }[] entries: { name: string; path: string; hidden: boolean }[] + truncated: boolean }>> = - () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] })) + () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false })) onCreateDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: '/home/fake/new' })) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 6f0d35f2fa..54ec218765 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -242,7 +242,7 @@ describe('WorkspacesService', () => { const ctx = new Context() const api = new FakeApiClient() const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api)) - const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }] } + const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false } api.onListDirectory = () => Promise.resolve(ok(listing)) await expect(workspaces.listDirectory()).resolves.toEqual(listing) await expect(workspaces.listDirectory('/home/u')).resolves.toEqual(listing) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7c23da0949..9ca1d81ca9 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1665,7 +1665,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'DirectoryListing', - declaration: 'export interface DirectoryListing {\n path: string;\n home: string;\n crumbs: DirectoryEntry[];\n entries: DirectoryEntry[];\n}', + declaration: 'export interface DirectoryListing {\n path: string;\n home: string;\n crumbs: DirectoryEntry[];\n entries: DirectoryEntry[];\n truncated: boolean;\n}', }, { name: 'DirectoryPickerBrowseCapability', diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index f5d17421fb..43031288fc 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -47,6 +47,7 @@ export const hostListDirectoryValueSchema = z.object({ home: z.string(), crumbs: z.array(directoryEntrySchema), entries: z.array(directoryEntrySchema), + truncated: z.boolean(), }) satisfies z.ZodType>> /** host.createDirectory request payload: name must be one plain path segment. */ diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 937fa905af..0338494bed 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -28,6 +28,8 @@ export interface DirectoryListing { crumbs: DirectoryEntry[] /** Direct child directories, name-sorted; symlinks to directories included. */ entries: DirectoryEntry[] + /** True when the backend cut `entries` at its complete-result bound (the name-sorted tail is absent). */ + truncated: boolean } /** Host-level unary methods. */ diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index e978db9f9b..9c52b62a61 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -159,6 +159,7 @@ const BROWSE_STUB: DirectoryPickerCapability = { home: '/home/user', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }], + truncated: false, } }, createDirectory: async (path, name) => { diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index fc1dfd777a..8232b2f524 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -50,7 +50,7 @@ function scriptedApi(overrides: { host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), pickDirectory: r => ok(r, { path: null }), - listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [] }), + listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }), createDirectory: r => ok(r, { path: '/t/new' }), openPath: r => ok(r, { opened: true as const }), ...overrides.host, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index bf0be224f2..b760f3e3e4 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -82,7 +82,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } } }, async listDirectory(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] } } } + return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false } } } }, async createDirectory(request) { return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w/new' } } } @@ -224,7 +224,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const listed = await c.host.listDirectory({ path: '/w' }) expect(listed.result).toEqual({ ok: true, - value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] }, + value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }, }) const home = await c.host.listDirectory({}) expect(home.result).toMatchObject({ ok: true, value: { home: '/w' } }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 6c400871ae..4d648e491d 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -231,8 +231,11 @@ describe('host domain schemas', () => { home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'p', path: '/home/u/p', hidden: false }], entries: [{ name: '.dot', path: '/home/u/p/.dot', hidden: true }], + truncated: false, }) expect(listing.entries[0]?.hidden).toBe(true) + // The flag is part of the wire value, not an optional decoration. + expect(() => hostListDirectoryValueSchema.parse({ path: '/x', home: '/x', crumbs: [], entries: [] })).toThrow() expect(hostCreateDirectoryRequestSchema.parse({ path: '/x', name: 'new' })).toEqual({ path: '/x', name: 'new' }) for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) { expect(() => hostCreateDirectoryRequestSchema.parse({ path: '/x', name })).toThrow() diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 150a9c2323..9588943980 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: 632dfec3dac57cac9ea7a02225959fe6e3acf6a0 -README.zh.md: 81a1eb53eac0d3b5a1ef8f2f98c4359ef6a4c5fd +README.md: 2f1994100f0fb8fcadae46094267057b3966197e +README.zh.md: a149133a13ca99f176680c68b4931b9673f4aaa8 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 632dfec3da..2f1994100f 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. 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 materializes at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings): a cut level keeps the name-sorted head, counts hidden rows against the bound, stops probing once the bound is hit, and reports `truncated: true` so the client can say the level is incomplete. 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). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 81a1eb53ea..a149133a13 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 或当前盘符之下。失败抛出 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 网页端对目录列举采用的同一上限):被截断的层级保留按名排序的头部、隐藏行计入上限、达到上限即停止探测,并报告 `truncated: true`,供客户端提示层级不完整。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index 561e168421..b9699cd612 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -12,6 +12,8 @@ import { mkdir, readdir, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, join, posix, resolve, win32 } from 'node:path' +import type { Context } from 'cordis' +import z from 'schemastery' import { DirectoryPicker, DirectoryPickerError, } from '@deepseek-ai/dsh-host-directory-picker' @@ -79,14 +81,35 @@ async function directoryRow(parent: string, name: string, isDirectory: boolean, return { name, path, hidden: name.startsWith('.') } } +/** Validated plugin configuration. */ +export interface Config { + /** Complete-result bound of one listing level; see {@link BrowseDirectoryPicker.Config}. */ + maxEntries: number +} + /** The `ctx.directoryPicker` browse implementation (stable capability object per service life). */ export default class BrowseDirectoryPicker extends DirectoryPicker { + /** + * `maxEntries` bounds the complete listing level a single `list` call may + * materialize and put on the wire: at most this many child-directory rows + * (hidden rows included), with `truncated` flagging a cut level. The + * default follows GitHub's web UI, which truncates directory listings at + * 1,000 entries. + */ + static Config: z = z.object({ + maxEntries: z.natural().min(1).default(1000), + }) + private readonly browseCapability: DirectoryPickerCapability = { kind: 'browse', list: path => this.list(path), createDirectory: (path, name) => this.createDirectory(path, name), } + constructor(ctx: Context, private readonly config: Config) { + super(ctx) + } + /** * The browse interaction capability. * @returns the stable `browse` capability object. @@ -115,10 +138,22 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { } catch (error: unknown) { throw new DirectoryPickerError('directory-unreadable', target, `cannot list ${target}: ${messageOf(error)}`) } - const rows = await Promise.all(names.map(entry => directoryRow(target, entry.name, entry.isDirectory, entry.isSymbolicLink))) - const entries = rows.filter((row): row is DirectoryEntry => row !== null) - .sort((a, b) => a.name.localeCompare(b.name)) - return { path: target, home, crumbs: ancestryCrumbs(target), entries } + // Sort candidates before probing so the bound keeps the name-sorted head + // of the level and probing (symlink stat) stops with the bound instead of + // touching every child of an oversized directory. + names.sort((a, b) => a.name.localeCompare(b.name)) + const entries: DirectoryEntry[] = [] + let truncated = false + for (const entry of names) { + const row = await directoryRow(target, entry.name, entry.isDirectory, entry.isSymbolicLink) + if (row === null) continue + if (entries.length === this.config.maxEntries) { + truncated = true + break + } + entries.push(row) + } + return { path: target, home, crumbs: ancestryCrumbs(target), entries, truncated } } private async createDirectory(path: string, name: string): Promise { diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 3f214760b7..8e7a2ba049 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -45,6 +45,27 @@ describe('BrowseDirectoryPicker', () => { expect(listing.entries.map(entry => entry.hidden)).toEqual([true, false, false]) // Every entry path is absolute and host-joined — clients never join segments. expect(listing.entries.every(entry => entry.path === join(root, entry.name))).toBe(true) + // Well under the default bound: the complete level, not a cut one. + expect(listing.truncated).toBe(false) + }) + + it('cuts a level at maxEntries keeping the name-sorted head, and flags the cut', async () => { + const ctx = new Context() + const fiber = ctx.plugin(BrowseDirectoryPicker, { maxEntries: 1 }) + await fiber.await() + const bounded = ctx.get('directoryPicker')!.capability() + if (bounded.kind !== 'browse') throw new Error('browse backend must advertise the browse capability') + try { + const cut = await bounded.list(root) + expect(cut.entries.map(entry => entry.name)).toEqual(['.hidden-dir']) + expect(cut.truncated).toBe(true) + // Exactly at the bound is complete, not truncated. + const exact = await bounded.list(join(root, 'projects')) + expect(exact.entries.map(entry => entry.name)).toEqual(['harness']) + expect(exact.truncated).toBe(false) + } finally { + await fiber.dispose() + } }) 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 c96f21d9a6..3b4b60562f 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -47,6 +47,12 @@ export interface DirectoryListing { crumbs: DirectoryEntry[] /** Direct child directories, name-sorted; symlinks to directories included. */ entries: DirectoryEntry[] + /** + * True when the backend cut `entries` at its complete-result bound: the + * level has more child directories than reported, and the missing rows are + * the name-sorted tail (hidden rows count toward the bound). + */ + truncated: boolean } /** @@ -59,7 +65,8 @@ export interface DirectoryPickerBrowseCapability { /** * List one directory level. * @param path - absolute directory to list; absent lists the home directory. - * @returns the level's listing with ancestry. + * @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.