2026-07-28 15:44:53 +08:00
|
|
|
/**
|
|
|
|
|
* 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'
|
2026-07-28 17:39:15 +08:00
|
|
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
2026-07-28 15:44:53 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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()
|
2026-07-28 17:39:15 +08:00
|
|
|
// 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`)
|
|
|
|
|
}
|
2026-07-28 15:44:53 +08:00
|
|
|
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> {
|
2026-07-28 17:39:15 +08:00
|
|
|
// 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`)
|
|
|
|
|
}
|
2026-07-28 15:44:53 +08:00
|
|
|
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)}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|