Files
dsh_shenxian/poc/workspace-scoped-picker/lib/index.js
T
admin cbaf3ad653 fix(web): 内置 dsh 安装路径改为按序探测 —— 修厂家目录/平台包目录在 /usr/lib 布局下静默失效
根因:npm root -g 的落点随发行版变(Debian 系 /usr/local/lib;发行版包管理器装的 Node 常见 /usr/lib),而三处把它写死 ⇒ 定位失败**不报错**、只静默降级:
- src/web/model-catalog.ts:30  → 厂家目录读成空 ⇒ GET /api/me/model-providers 返回 {"providers":[]}
- src/web/plugin-compat.ts:36  → platformPkgCount() = 0 ⇒ 预检退化成「平台包目录不可读」
- poc/workspace-scoped-picker/lib/index.js:34-35(**交接单漏掉的第 3 处**)→ 本模块 import 即抛错 ⇒ 实例内目录选择器不可用

改动:
- 新增 src/web/dsh-install.ts(全平台唯一入口;只依赖 node 内建,可单独在目标机验证):env → 平台配置的 dsh 可执行文件解软链反推包根(最可靠)→ 常见全局根 → npm root -g → 历史默认值
  ⚠️ env 名两侧都认:源仓 DSHS_DSH_BIN / 导出侧 DSH_USERS_PLATFORM_DSH_BIN(交接件只用了后者,照抄会让该分支在源仓侧永不生效)
- 三处调用点改走该模块;插件侧跑在实例进程内、拿不到平台代码,自带同思路一份(两处需同步改)
- 新增 scripts/verify-dsh-install.mjs(15 项断言)并接入 npm run verify

验证:本机 tsc 零错误|verify 全套绿|单测 16/0;服务器回归 platformPkgCount=223、厂家目录 39;**测试服 test106(/usr/lib 布局 = 原故障机)在「无 env / DSHS_DSH_BIN / DSH_USERS_PLATFORM_DSH_BIN」三种姿势下均解析到 /usr/lib 且 39 个厂家文件**
2026-09-14 04:38:58 +08:00

328 lines
13 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 { existsSync, readFileSync, realpathSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
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
/** 平台内置 dsh 的包名(用来确认"这个目录确实是 dsh 包根")。 */
const PKG_NAME = '@deepseek-ai/dsh'
/** 要解析的官方包:目录选择器的 seam 基类。 */
const SEAM_PKG = 'dsh-host-directory-picker'
/**
* 平台内置 dsh 的**包根候选**(按序探测,命中即用)。
*
* ⚠️ 2026-09-14 修:原先把两个 seam 候选写成 `/usr/local/lib/node_modules/...` 的**绝对字面量**
* —— 而 `npm i -g` 的落点随发行版 / npm prefix 而变(以发行版包管理器装的 Node 常见
* `/usr/lib/node_modules`;本平台测试服 `test106` 就是这种)。写死 ⇒ 两个候选**都不存在**
* ⇒ 本模块 `import` 即抛错 ⇒ **实例内目录选择器不可用**(已实测复现)。
*
* 平台侧 `src/web/dsh-install.ts` 是**同思路的另一份实现** —— 本插件跑在**实例进程**内、
* 拿不到平台代码,所以必须自带一份。改一侧时记得同步另一侧。
*/
function dshPackageRootCandidates() {
const out = []
for (const k of ['DSH_PACKAGE_DIR', 'DSHS_PACKAGE_DIR', 'DSH_COMPAT_ROOT', 'DSHS_COMPAT_ROOT']) {
const v = process.env[k]
if (typeof v === 'string' && v !== '') out.push(v)
}
// 由平台配置的 dsh 可执行文件解软链反推包根(最可靠的一路;实例内一般拿不到该变量,故非必中)
for (const k of ['DSHS_DSH_BIN', 'DSH_USERS_PLATFORM_DSH_BIN']) {
const bin = process.env[k]
if (typeof bin !== 'string' || bin === '') continue
try {
let cur = resolve(realpathSync(bin))
for (let i = 0; i < 6; i += 1) {
const pj = join(cur, 'package.json')
if (existsSync(pj)) {
const pkg = JSON.parse(readFileSync(pj, 'utf8'))
if (pkg !== null && typeof pkg === 'object' && pkg.name === PKG_NAME) {
out.push(cur)
break
}
}
const up = dirname(cur)
if (up === cur) break
cur = up
}
} catch {
/* 忽略:继续用下面的常见全局根 */
}
}
for (const r of ['/usr/local/lib/node_modules', '/usr/lib/node_modules']) out.push(join(r, PKG_NAME))
return [...new Set(out)]
}
/**
* 官方 seam 基类的候选绝对路径(按序尝试首个可导入者)。
* 每个包根各出两种布局:dsh 自己的 `node_modules` 下(嵌套)与全局 scope 下(扁平)。
*/
const DEFAULT_SEAM_CANDIDATES = dshPackageRootCandidates().flatMap((root) => {
const globalRoot = dirname(dirname(root)) // <globalRoot>/@deepseek-ai/dsh → <globalRoot>
return [
join(root, 'node_modules', '@deepseek-ai', SEAM_PKG, 'lib', 'index.js'),
join(globalRoot, '@deepseek-ai', SEAM_PKG, 'lib', 'index.js'),
]
})
// 常见根全落空时,再问一次 npm(慢,故只在真落空时跑;失败/超时一律当作没有)。
if (!DEFAULT_SEAM_CANDIDATES.some((c) => existsSync(c))) {
try {
const npmRoot = execFileSync('npm', ['root', '-g'], {
encoding: 'utf8',
timeout: 5000,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim()
if (npmRoot !== '') {
DEFAULT_SEAM_CANDIDATES.push(join(npmRoot, '@deepseek-ai', PKG_NAME, 'node_modules', '@deepseek-ai', SEAM_PKG, 'lib', 'index.js'))
DEFAULT_SEAM_CANDIDATES.push(join(npmRoot, '@deepseek-ai', SEAM_PKG, 'lib', 'index.js'))
}
} catch {
/* 忽略 */
}
}
/** 解析并导入官方 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