根因: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 个厂家文件**
328 lines
13 KiB
JavaScript
328 lines
13 KiB
JavaScript
/**
|
||
* @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
|