fix(host,client): gate the picker affordance on the advertised kind; reject non-absolute browse paths

ds-review-bot round 2. The workspace UI never consulted the advertised
directoryPicker kind: under a browse (or merge-added) backend it still
rendered 'Open local folder…' and called pickDirectory(), which the host
answers with directory-picker-unavailable. The create flow now reads
directoryPickerKind() per menu open and renders the dialog affordance only
under 'dialog' — browse (until its in-app browser UI lands) and unknown
kinds hide the entry, realizing the seam's documented default; a keyless
workspace-flow snapshot pins the hidden entry over the browse fixture.

The browse backend also resolved wire paths, silently rebasing '' or
relative parents under the host process cwd; both primitives now reject
non-absolute explicit paths with their business codes, and the seam JSDoc
carries the contract.
This commit is contained in:
creatixchu
2026-07-28 17:39:15 +08:00
parent 6e299cd55a
commit cd7aa3c7d8
20 changed files with 146 additions and 37 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 packages/host/directory-picker-browse/README.md
README.md: f86f74acf4922d490b23c033a281436f1a428f13
README.zh.md: 0d240630a8b21003c5285bc75e93aac9adf36d92
README.md: 81357269e1d4b075f7e31b5f3ac5d4721811024a
README.zh.md: 06a7f7651b2abc0aa73eba41042f3d1a86661b76

View File

@@ -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 dialog 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. 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 a non-absolute explicit path (`directory-unreadable`/`directory-create-failed`) instead of letting `resolve` rebase it under the host process cwd. 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

View File

@@ -4,7 +4,7 @@
[目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**`BrowseDirectoryPicker``browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 dialog 后端无法触及的远程客户端。
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/``C:\``list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。失败抛出 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 的栅栏一致。两个原语都拒绝非绝对的显式路径(`directory-unreadable``directory-create-failed`),而不是任由 `resolve` 把它重定位到宿主进程 cwd 之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
## 模型体验

View File

@@ -11,7 +11,7 @@
import { mkdir, readdir, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, dirname, join, resolve } from 'node:path'
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
import {
DirectoryPicker, DirectoryPickerError,
} from '@deepseek-ai/dsh-host-directory-picker'
@@ -81,6 +81,11 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
private async list(path?: string): Promise<DirectoryListing> {
const home = homedir()
// The seam contract takes absolute paths only; resolve() would silently
// rebase a relative or empty wire value under the host process cwd.
if (path !== undefined && !isAbsolute(path)) {
throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not an absolute path`)
}
const target = resolve(path ?? home)
let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[]
try {
@@ -100,6 +105,10 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
}
private async createDirectory(path: string, name: string): Promise<string> {
// Same absolute-path fence as list: never rebase a parent under the cwd.
if (!isAbsolute(path)) {
throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not an absolute parent path`)
}
const parent = resolve(path)
// The backend owns segment validation (the wire schema also refuses these,
// but direct service consumers must hit the same fence).

View File

@@ -70,6 +70,19 @@ describe('BrowseDirectoryPicker', () => {
expect((failure as DirectoryPickerError).path).toBe(missing)
})
it('rejects non-absolute paths instead of rebasing them under the process cwd', async () => {
for (const relative of ['', 'projects', './projects', '..']) {
const listFailure = await capability.list(relative).catch((error: unknown) => error)
expect(listFailure).toBeInstanceOf(DirectoryPickerError)
expect((listFailure as DirectoryPickerError).code).toBe('directory-unreadable')
expect((listFailure as DirectoryPickerError).path).toBe(relative)
const createFailure = await capability.createDirectory(relative, 'child').catch((error: unknown) => error)
expect(createFailure).toBeInstanceOf(DirectoryPickerError)
expect((createFailure as DirectoryPickerError).code).toBe('directory-create-failed')
expect((createFailure as DirectoryPickerError).path).toBe(relative)
}
})
it('creates one child directory and surfaces it in the next listing', async () => {
const created = await capability.createDirectory(root, 'fresh')
expect(created).toBe(join(root, 'fresh'))

View File

@@ -60,7 +60,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.
* @throws {DirectoryPickerError} `directory-unreadable` when the target cannot be listed.
* @throws {DirectoryPickerError} `directory-unreadable` when the target is not absolute
* (a wire value must never rebase under the host cwd) or cannot be listed.
*/
list(path?: string): Promise<DirectoryListing>
/**
@@ -68,7 +69,8 @@ export interface DirectoryPickerBrowseCapability {
* @param path - absolute existing parent directory.
* @param name - single non-blank path segment (no separators, not `.`/`..`).
* @returns the created directory's absolute path.
* @throws {DirectoryPickerError} `directory-exists` for an existing child, `directory-create-failed` otherwise.
* @throws {DirectoryPickerError} `directory-exists` for an existing child,
* `directory-create-failed` for a non-absolute parent or any other failure.
*/
createDirectory(path: string, name: string): Promise<string>
}