/** * @dsh-local/workspace-scoped-picker — 会话内工作区目录选择器(根 = 用户自有目录)。 * * 档案 18:官方 `dsh-host-directory-picker-browse` 的策略是 whole-filesystem scope * (向导 /etc、/usr、/proc),本包把 enumerate/create 的根收敛为「当前用户自有目录」, * **所有用户含 admin 一视同仁**;越界一律抛 seam 的封闭错误码。 * * 根解析优先级:`process.env.DSH_WORKSPACE_ROOT` → `process.cwd()` * (编排器 spawn 时以 `--chdir /ws` 启动,故 cwd 即用户工作区,双保险)。 * * 依赖策略(红线 R2:不复制、不修改官方包): * 唯一需要官方物 = seam 基类;用**绝对路径动态 import** 取得,解析到与核心同一个模块实例 * (ESM 模块缓存按 realpath 去重)。可选 env `DSH_SEAM_DIRECTORY_PICKER` 指定路径。 * * 官方契约(对齐 `dsh-host-directory-picker` 类型定义 与 `-browse` 实现): * - 默认导出 = 继承 `DirectoryPicker` 的 Service 子类;加载即注册 `ctx.directoryPicker` * - `capability()` 返回稳定对象:`{ kind: 'browse', list(path?, signal?), createDirectory(path, name) }` * - 列举只返回**目录**行,按名排序,跟随指向目录的符号链接,`hidden` = 点号前缀 * - `crumbs` = 祖先链(本实现以自有根为顶层,而不是文件系统 /) * - 错误码封闭:`directory-unreadable` / `directory-exists` / `directory-create-failed` * * @module @dsh-local/workspace-scoped-picker */ import { mkdir, opendir, realpath, stat } from 'node:fs/promises' import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' /** 单个列举层级的行数上限(与官方后端同为 GitHub 风格 1000)。 */ const MAX_ENTRIES = 1000 /** 官方 seam 基类的候选绝对路径(按序尝试首个可导入者)。 */ const DEFAULT_SEAM_CANDIDATES = [ '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js', '/usr/local/lib/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js', ] /** 解析并导入官方 seam 基类。 */ async function loadSeam() { const candidates = [] const override = process.env.DSH_SEAM_DIRECTORY_PICKER if (override !== undefined && override !== '') candidates.push(override) candidates.push(...DEFAULT_SEAM_CANDIDATES) const failures = [] for (const candidate of candidates) { try { const mod = await import(pathToFileURL(candidate).href) if (typeof mod.DirectoryPicker !== 'function') throw new Error('seam module has no DirectoryPicker export') return mod } catch (error) { failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`) } } throw new Error( `workspace-scoped-picker: 无法解析官方 seam 基类(@deepseek-ai/dsh-host-directory-picker)。已尝试:\n ${failures.join('\n ')}\n` + '可通过环境变量 DSH_SEAM_DIRECTORY_PICKER 指定该包 lib/index.js 的绝对路径。', ) } const { DirectoryPicker, DirectoryPickerError } = await loadSeam() /** 单段目录名校验(不得含分隔符,不得为 . / ..)。 */ function isSingleSegment(name) { return name.trim() !== '' && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\') } /** * 自有目录作用域内的目录选择服务。 * 范围语义:`list()` 的根 = 自有目录;向上不可越界;`crumbs` 顶层即自有目录。 */ class WorkspaceScopedDirectoryPicker extends DirectoryPicker { /** 自有根(绝对路径)。 */ root = resolve(process.env.DSH_WORKSPACE_ROOT ?? process.cwd()) /** 稳定的 browse 能力对象(consumers 可能跨调用缓存它)。 */ browseCapability = { kind: 'browse', list: (path, signal) => this.list(path, signal), createDirectory: (path, name) => this.createDirectory(path, name), } /** @param ctx - cordis 上下文(由 loader 注入)。 */ constructor(ctx) { super(ctx) // 加载标记:实例启动日志里可直接确认本插件是否生效(排障用,2026-09-11)。 process.stderr.write( `[workspace-scoped-picker] loaded root=${this.root} (${process.env.DSH_WORKSPACE_ROOT !== undefined ? 'env' : 'cwd'})\n`, ) } /** @returns 稳定的 `browse` 能力对象。 */ capability() { return this.browseCapability } /** 自有根(含符号链接解析后的真实路径)。 */ async realRoot() { try { return await realpath(this.root) } catch { return this.root } } /** * 校验候选路径落在自有根之内(先词法归一,再 realpath 抗符号链接逃逸)。 * 注:用普通方法而非 `#私有字段`——服务实例可能被框架 Proxy 包装,私有 brand 检查会失败。 * @param candidate - 待校验的绝对路径。 * @param code - 校验失败时抛出的封闭错误码。 * @returns 归一后的绝对路径。 */ async assertInside(candidate, code) { if (typeof candidate !== 'string' || !isAbsolute(candidate)) { throw new DirectoryPickerError(code, String(candidate), `not a fully qualified path: ${String(candidate)}`) } const target = resolve(candidate) const realRoot = await this.realRoot() const lexicalOk = target === realRoot || target.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep) if (!lexicalOk) { throw new DirectoryPickerError(code, target, `outside the workspace root: ${target}`) } // 抗符号链接:若目标已存在,比较真实路径;不存在则沿用词法判定(调用方随后会自然失败)。 try { const realTarget = await realpath(target) const inside = realTarget === realRoot || realTarget.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep) if (!inside) throw new DirectoryPickerError(code, target, `symlink escapes the workspace root: ${target}`) } catch (error) { if (error instanceof DirectoryPickerError) throw error // ENOENT:目标不存在,交由上层语义处理(列举会报 directory-unreadable)。 } return target } /** * 自有根到目标(含)的祖先链,顶层即自有根 —— crumbs 不暴露文件系统 /。 * 顶层用**友好名**(默认「我的工作区」,可用 DSH_WORKSPACE_LABEL 覆盖), * 不在 UI 上展示 `/var/lib/.../users//ws` 这类完整路径(档案 24 后续项)。 */ crumbs(target) { const rootLabel = process.env.DSH_WORKSPACE_LABEL ?? '我的工作区' const chain = [] let current = target for (;;) { chain.unshift({ name: current === this.root ? rootLabel : basename(current), path: current, hidden: false, }) if (current === this.root) return chain const parent = dirname(current) if (parent === current) return chain // 兜底:理论不可达(越界已在入口拦截) current = parent } } /** * 列举自有根内的一层目录。 * @param path - 省略时列举自有根。 * @param signal - 可选中止信号。 * @returns 列举结果(`home` 锚点为自有根)。 */ async list(path, signal) { const target = path === undefined ? this.root : await this.assertInside(path, 'directory-unreadable') let dir try { dir = await opendir(target) } catch (error) { throw new DirectoryPickerError( 'directory-unreadable', target, `cannot list ${target}: ${error instanceof Error ? error.message : String(error)}`, ) } const names = [] let truncated = false try { for await (const entry of dir) { if (signal?.aborted === true) throw new DirectoryPickerError('directory-unreadable', target, 'aborted') if (names.length >= MAX_ENTRIES) { truncated = true break } if (!entry.isDirectory() && !entry.isSymbolicLink()) continue const child = join(target, entry.name) if (entry.isSymbolicLink()) { // 与官方后端一致:跟随指向目录的符号链接;断链/指向文件则跳过。 try { const st = await stat(child) if (!st.isDirectory()) continue } catch { continue } } // 符号链接目标也必须留在自有根内,否则不展示(避免借链接越界浏览)。 try { await this.assertInside(child, 'directory-unreadable') } catch { continue } names.push(entry.name) } } finally { await dir.close().catch(() => undefined) } names.sort((a, b) => a.localeCompare(b)) return { path: target, home: this.root, crumbs: this.crumbs(target), entries: names.map((name) => ({ name, path: join(target, name), hidden: name.startsWith('.'), })), truncated, } } /** * 在自有根内的既有父目录下创建单层子目录。 * @param path - 父目录(省略时用自有根)。 * @param name - 单个路径段。 * @returns 新目录的绝对路径。 */ async createDirectory(path, name) { const parent = path === undefined || path === '' ? this.root : await this.assertInside(path, 'directory-create-failed') if (typeof name !== 'string' || !isSingleSegment(name)) { throw new DirectoryPickerError( 'directory-create-failed', join(parent, String(name)), `"${String(name)}" is not a single path segment`, ) } const target = join(parent, name) await this.assertInside(target, 'directory-create-failed') try { await mkdir(target) return target } catch (error) { const code = error instanceof Error && 'code' in error ? error.code : undefined if (code === 'EEXIST') throw new DirectoryPickerError('directory-exists', target, `${target} already exists`) throw new DirectoryPickerError( 'directory-create-failed', target, `cannot create ${target}: ${error instanceof Error ? error.message : String(error)}`, ) } } } export { WorkspaceScopedDirectoryPicker, isSingleSegment } export default WorkspaceScopedDirectoryPicker