Files
dsh_shenxian/poc/workspace-scoped-picker/lib/index.js
T

254 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 <userRoot>/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/<uuid>/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