Browse Source

fix(host,client): require fully qualified browse paths; clear the picker kind on close

ds-review-bot round 4. On Windows, isAbsolute admits rooted drive-less
forms (\foo, /foo) that resolve() then rebases onto the process's current
drive; both browse primitives now gate on a fullyQualified check (drive
letter or UNC on win32, POSIX-absolute elsewhere) with a platform test
seam, per-platform unit cases, and the contract wording updated on the
seam, the backend README pair, and the error messages.

The picker-kind effect also kept a resolved 'dialog' across close, so a
backend swapped while the menu was closed could paint the stale entry for
one frame on reopen; the close arm now clears the state, pinned by a
reopen-under-pending-read race test.
creatixchu 2 months ago
parent
commit
b211a80b1f

+ 1 - 1
docs/cordis-catalog/services.md

@@ -481,7 +481,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load
 abstract capability(): DirectoryPickerCapability
 ```
 
-Source: [`packages/host/directory-picker/src/index.ts:120`](../../packages/host/directory-picker/src/index.ts)
+Source: [`packages/host/directory-picker/src/index.ts:121`](../../packages/host/directory-picker/src/index.ts)
 
 ## `ctx.fs` — `FileSystem` (abstract seam)
 

+ 11 - 5
packages/client/ui-workspace/src/client/WorkspacePicker.tsx

@@ -79,11 +79,17 @@ export function WorkspaceCreateFlow({
   // flow open — no cache to go stale across reconnects.
   const [dialogPicker, setDialogPicker] = useState(false)
   useEffect(() => {
-    if (!open) return
-    // Reset before each read: a reconnect can change the composed backend, so
-    // a previous open's answer must not leak into this one; and a settlement
-    // from a superseded open (flow closed, or a newer read started) is
-    // discarded via the cleanup-toggled flag.
+    if (!open) {
+      // Close discards the answer: a reconnect or HMR can swap the composed
+      // backend while the menu is closed, and the reopened menu must never
+      // paint the previous host's entry before the fresh read lands.
+      setDialogPicker(false)
+      return
+    }
+    // Reset before each read: the injected reader can also change identity
+    // while the flow stays open, and that prior answer must not leak either;
+    // a settlement from a superseded read is discarded via the
+    // cleanup-toggled flag.
     setDialogPicker(false)
     let stale = false
     void directoryPickerKind()

+ 14 - 0
packages/client/ui-workspace/tests/workspace-picker.spec.tsx

@@ -300,6 +300,20 @@ describe('WorkspacePicker', () => {
     expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
   })
 
+  it('clears the advertised kind on close so a reopen cannot paint the previous host entry', async () => {
+    const directoryPickerKind = vi.fn<() => Promise<string>>()
+      .mockImplementationOnce(async () => 'dialog')
+      // The reopened read never settles: the assertion below sees the paint
+      // that precedes any fresh answer.
+      .mockImplementation(() => new Promise<string>(() => {}))
+    const t = togglable(directoryPickerKind)
+    await screen.findByRole('menuitem', { name: 'Open local folder…' })
+    t.setOpen(false)
+    t.setOpen(true)
+    await screen.findByRole('menuitem', { name: 'Create a new workspace' })
+    expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
+  })
+
   it('discards a stale describe failure after a newer open already answered', async () => {
     let rejectFirst!: (reason: Error) => void
     const first = new Promise<string>((_settle, reject) => { rejectFirst = reject })

+ 2 - 2
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: 81357269e1d4b075f7e31b5f3ac5d4721811024a
-README.zh.md: 06a7f7651b2abc0aa73eba41042f3d1a86661b76
+README.md: 160a12a8594400c9e6c565881d8e9ca0517b4e24
+README.zh.md: 4cfc4a611fb17cb30ac841c8af8498b51a6388b0

+ 1 - 1
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 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. 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).
+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`) 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).
 
 ## Model Experience
 

+ 1 - 1
packages/host/directory-picker-browse/README.zh.md

@@ -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 的栅栏一致。两个原语都拒绝非绝对的显式路径(`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`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
 
 ## 模型体验
 

+ 26 - 8
packages/host/directory-picker-browse/src/index.ts

@@ -11,7 +11,7 @@
 
 import { mkdir, readdir, stat } from 'node:fs/promises'
 import { homedir } from 'node:os'
-import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
+import { basename, dirname, join, posix, resolve, win32 } from 'node:path'
 import {
   DirectoryPicker, DirectoryPickerError,
 } from '@deepseek-ai/dsh-host-directory-picker'
@@ -35,6 +35,22 @@ function ancestryCrumbs(target: string): DirectoryEntry[] {
   }
 }
 
+/**
+ * True when the path names one fixed filesystem location regardless of
+ * process state: POSIX-absolute on POSIX; on Windows only drive-qualified
+ * (`C:\…`) or UNC (`\\server\…`) forms — rooted drive-less forms (`\foo`,
+ * `/foo`) pass `isAbsolute` yet still resolve against the process's current
+ * drive.
+ * @param path - candidate path.
+ * @param platform - replaces `process.platform` for deterministic tests.
+ * @returns whether the path is fully qualified on the platform.
+ */
+export function fullyQualified(path: string, platform: NodeJS.Platform = process.platform): boolean {
+  return platform === 'win32'
+    ? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2})/.test(path)
+    : posix.isAbsolute(path)
+}
+
 /** Message text of an unknown thrown value. */
 function messageOf(error: unknown): string {
   /* v8 ignore next -- node:fs rejects with Error instances; the String arm only satisfies the unknown narrowing. */
@@ -81,10 +97,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`)
+    // The seam contract takes fully qualified paths only; resolve() would
+    // silently rebase a relative or empty wire value under the host process
+    // cwd (or, for rooted drive-less Windows forms, its current drive).
+    if (path !== undefined && !fullyQualified(path)) {
+      throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`)
     }
     const target = resolve(path ?? home)
     let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[]
@@ -105,9 +122,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`)
+    // Same fully-qualified fence as list: never rebase a parent under the
+    // cwd or the current drive.
+    if (!fullyQualified(path)) {
+      throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not a fully qualified parent path`)
     }
     const parent = resolve(path)
     // The backend owns segment validation (the wire schema also refuses these,

+ 14 - 1
packages/host/directory-picker-browse/tests/service.spec.ts

@@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
 import { Context } from 'cordis'
 import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
 import type { DirectoryPickerBrowseCapability } from '@deepseek-ai/dsh-host-directory-picker'
-import BrowseDirectoryPicker from '../src/index.ts'
+import BrowseDirectoryPicker, { fullyQualified } from '../src/index.ts'
 
 let root: string
 let capability: DirectoryPickerBrowseCapability
@@ -70,6 +70,19 @@ describe('BrowseDirectoryPicker', () => {
     expect((failure as DirectoryPickerError).path).toBe(missing)
   })
 
+  it('classifies fully qualified paths per platform (drive-less rooted Windows forms rejected)', () => {
+    expect(fullyQualified('/home/x', 'linux')).toBe(true)
+    expect(fullyQualified('x/y', 'darwin')).toBe(false)
+    expect(fullyQualified('C:\\projects', 'win32')).toBe(true)
+    expect(fullyQualified('C:/projects', 'win32')).toBe(true)
+    expect(fullyQualified('\\\\server\\share', 'win32')).toBe(true)
+    // Rooted but drive-less: isAbsolute accepts these, yet resolve() would
+    // inject the process's current drive.
+    expect(fullyQualified('\\foo', 'win32')).toBe(false)
+    expect(fullyQualified('/foo', 'win32')).toBe(false)
+    expect(fullyQualified('C:relative', 'win32')).toBe(false)
+  })
+
   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)

+ 4 - 3
packages/host/directory-picker/src/index.ts

@@ -60,8 +60,9 @@ 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 is not absolute
-   * (a wire value must never rebase under the host cwd) or cannot be listed.
+   * @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<DirectoryListing>
   /**
@@ -70,7 +71,7 @@ export interface DirectoryPickerBrowseCapability {
    * @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` for a non-absolute parent or any other failure.
+   * `directory-create-failed` for a parent that is not fully qualified or any other failure.
    */
   createDirectory(path: string, name: string): Promise<string>
 }