Files
deepseek-harness/packages/host/directory-picker-browse/src/index.ts
creatixchu 987ecc2ec2 fix(host): require complete UNC forms in the fully-qualified path check
ds-review-bot round 5: '\\' and '\\server' satisfy win32.isAbsolute and
the previous two-separator test, yet resolve() collapses them to
drive-relative roots. The UNC arm now requires server and share components;
incomplete prefixes reject with the business codes, covered per-platform.
2026-07-28 18:27:26 +08:00

150 lines
6.7 KiB
TypeScript

/**
* Browse backend of the directory-picker seam: registers `ctx.directoryPicker`
* with the `browse` capability — one-level directory listing and child-directory
* creation over the host filesystem via 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. Policy decisions (hidden
* entries flagged but returned, symlinks followed, whole-filesystem scope) are
* recorded in the directory-picker seam Agent Note.
* @module @deepseek-ai/dsh-host-directory-picker-browse
*/
import { mkdir, readdir, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, dirname, join, posix, resolve, win32 } from 'node:path'
import {
DirectoryPicker, DirectoryPickerError,
} from '@deepseek-ai/dsh-host-directory-picker'
import type {
DirectoryEntry, DirectoryListing, DirectoryPickerCapability,
} from '@deepseek-ai/dsh-host-directory-picker'
/**
* Ancestor chain from the filesystem root to `target` inclusive — the
* breadcrumb rows of a listing, every one a jump target.
*/
function ancestryCrumbs(target: string): DirectoryEntry[] {
const crumbs: DirectoryEntry[] = []
let current = target
for (;;) {
const parent = dirname(current)
// basename of a root is '' — label the root crumb by its full path ('/', 'C:\').
crumbs.unshift({ name: parent === current ? current : basename(current), path: current, hidden: false })
if (parent === current) return crumbs
current = parent
}
}
/**
* True when the path names one fixed filesystem location regardless of
* process state: POSIX-absolute on POSIX; on Windows only drive-qualified
* (`C:\…`) or complete UNC (`\\server\share…`) forms. Rooted drive-less
* forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`)
* 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. */
return error instanceof Error ? error.message : String(error)
}
/**
* One listing row for a dirent, following symlinks to directories; null for
* non-directories and broken/cyclic links (skipped silently — the browser
* shows what can be entered, and a broken link cannot).
*/
async function directoryRow(parent: string, name: string, isDirectory: boolean, isSymbolicLink: boolean): Promise<DirectoryEntry | null> {
const path = join(parent, name)
let enterable = isDirectory
if (!enterable && isSymbolicLink) {
try {
enterable = (await stat(path)).isDirectory()
} catch {
// Broken or cyclic symlink: stat is the probe, failure means "not enterable".
return null
}
}
if (!enterable) return null
// POSIX hidden convention; Windows' hidden attribute is not exposed by
// dirents (Known Limitations). The client owns whether hidden rows show.
return { name, path, hidden: name.startsWith('.') }
}
/** The `ctx.directoryPicker` browse implementation (stable capability object per service life). */
export default class BrowseDirectoryPicker extends DirectoryPicker {
private readonly browseCapability: DirectoryPickerCapability = {
kind: 'browse',
list: path => this.list(path),
createDirectory: (path, name) => this.createDirectory(path, name),
}
/**
* The browse interaction capability.
* @returns the stable `browse` capability object.
*/
capability(): DirectoryPickerCapability {
return this.browseCapability
}
private async list(path?: string): Promise<DirectoryListing> {
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
// 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 }[]
try {
const dirents = await readdir(target, { withFileTypes: true })
names = dirents.map(dirent => ({
name: dirent.name,
isDirectory: dirent.isDirectory(),
isSymbolicLink: dirent.isSymbolicLink(),
}))
} 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 }
}
private async createDirectory(path: string, name: string): Promise<string> {
// 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,
// but direct service consumers must hit the same fence).
if (name.trim() === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
throw new DirectoryPickerError('directory-create-failed', join(parent, name), `"${name}" is not a single path segment`)
}
const target = join(parent, name)
try {
// Non-recursive: the parent is the directory the browser is showing, so
// a missing parent is a real failure, not a level to invent.
await mkdir(target)
return target
} catch (error: unknown) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') {
throw new DirectoryPickerError('directory-exists', target, `${target} already exists`)
}
throw new DirectoryPickerError('directory-create-failed', target, `cannot create ${target}: ${messageOf(error)}`)
}
}
}