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.
This commit is contained in:
creatixchu
2026-07-28 18:13:20 +08:00
parent c4bf919895
commit b211a80b1f
9 changed files with 74 additions and 22 deletions

View File

@@ -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,