初始提交:DSH 多租户平台(dshs)
This commit is contained in:
commit
43976fea6a
167 files changed
+24456
No files matched your search
@@ -0,0 +1,52 @@
|
||||
// @dsh-local/workspace-scoped-picker — client half(档案 18 v3 收尾 · 2026-09-11)
|
||||
//
|
||||
// 目的:让用户在「选择工作区目录」对话框里**看不到"改路径"输入口**(官方对话框的
|
||||
// crumbEditZone / crumbEditGlyph),从而无法通过手输 `/` 或 `..` 跳出自己的目录。
|
||||
// 手段:纯 CSS 覆盖(不改官方包、不替换官方 UI)——dsh client bundle 以普通脚本执行并向
|
||||
// window.__ModuleLoader__ 注册 factory,这里只导出 apply + inject(**绝不 exports.default**,红线 R3)。
|
||||
//
|
||||
// 说明:越界本身已由 host 面(@dsh-local/workspace-scoped-picker 的 list/createDirectory)
|
||||
// 拒绝;本 CSS 只是**从界面上消除这个入口**,属 defense-in-depth + 体验收敛。
|
||||
|
||||
window.__ModuleLoader__.load({
|
||||
id: "@dsh-local/workspace-scoped-picker",
|
||||
factory: (require) => {
|
||||
var module = { exports: {} };
|
||||
var exports = module.exports;
|
||||
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
||||
|
||||
var STYLE_ID = "wsp-hide-path-edit";
|
||||
|
||||
/** 注入一次样式:隐藏路径编辑区/编辑图标(含 CSS-module 哈希类名的模糊匹配)。 */
|
||||
function hidePathEditZone() {
|
||||
if (typeof document === "undefined") return;
|
||||
if (document.getElementById(STYLE_ID) !== null) return;
|
||||
var style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = [
|
||||
/* 「选择工作区目录」对话框顶部的可编辑路径框与其铅笔图标 */
|
||||
'[class*="crumbEditZone"]{display:none !important}',
|
||||
'[class*="crumbEditGlyph"]{display:none !important}',
|
||||
'[class*="crumbEditSource"]{display:none !important}',
|
||||
/* 兜底:任何以 crumbEdit 开头的类(防官方改名/加类) */
|
||||
'[class^="crumbEdit"],[class*=" crumbEdit"]{display:none !important}',
|
||||
].join("\n");
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
}
|
||||
|
||||
function apply() {
|
||||
hidePathEditZone();
|
||||
// 对话框可能是懒挂载的:监听一次 DOM 变化,出现即注入(样式是幂等的)。
|
||||
if (typeof MutationObserver !== "undefined" && typeof document !== "undefined") {
|
||||
var observer = new MutationObserver(function () {
|
||||
hidePathEditZone();
|
||||
});
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
|
||||
exports.apply = apply;
|
||||
exports.inject = []; // 不需要任何 slot,纯副作用
|
||||
return module.exports;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* @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
|
||||
Reference in new issue
Block a user