chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次) - .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…), 目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪 - .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/) - .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*) - .gitignore 补:备份件(*.bak-*) - 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
This commit is contained in:
1 parent
30b46dbd0c
commit
c1b5e4d966
735 files changed
+153192
-2415
No files matched your search
@@ -0,0 +1,45 @@
|
||||
# @dsh-local/workspace-scoped-picker
|
||||
|
||||
会话内「添加工作区」目录选择器:把列举/建目录的根收敛为**当前用户自有目录**(`<userRoot>/ws`),
|
||||
所有用户含 admin 一视同仁。见文档库档案 18。
|
||||
|
||||
## 生效机制(2026-09-11 实证修订)
|
||||
|
||||
| 尝试 | 结果 |
|
||||
|---|---|
|
||||
| bundle patch 用「同 id 换 name」覆盖官方 `directory-picker` 行 | ❌ 不生效(官方 `-auto` 行原样保留) |
|
||||
| profile `cordis.patch.yml` 用「同 id 换 name」覆盖 | ❌ 不生效(dump-config 实测未应用) |
|
||||
| **profile 层:`insert` 自建行 + 对官方行 `disabled: true`** | ✅ 采用(`disabled` 是档案 09 已验证的机制) |
|
||||
|
||||
生效写法(写入 `<profile>/cordis.patch.yml`):
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
- id: workspace-scoped-picker
|
||||
name: "@dsh-local/workspace-scoped-picker"
|
||||
- id: directory-picker
|
||||
name: "@deepseek-ai/dsh-host-directory-picker-auto"
|
||||
disabled: true
|
||||
```
|
||||
|
||||
## 安装(必须走 pnpm)
|
||||
|
||||
```bash
|
||||
cd <profile dir>
|
||||
HOME=<userRoot>/ws pnpm add file:<userRoot>/ws/workspace-scoped-picker-0.1.0.tgz
|
||||
# 手放 node_modules 无效:pnpm-lock.yaml 才是安装账本
|
||||
```
|
||||
|
||||
## 依赖解析
|
||||
|
||||
唯一外部物是官方 seam 基类,用**绝对路径动态 import** 取得(不复制/不改官方包):
|
||||
`/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js`,
|
||||
可用 `DSH_SEAM_DIRECTORY_PICKER` 覆盖。
|
||||
|
||||
## 自检
|
||||
|
||||
```bash
|
||||
DSH_WORKSPACE_ROOT=/tmp/picker-test node test/poc.mjs # 26 项断言;须从实例 uid 可读的路径运行
|
||||
```
|
||||
|
||||
> 注意:源码在 `/opt/dshs/poc/`(700 root),实例 uid 读不到 → 安装时必须**复制**到 profile。
|
||||
@@ -0,0 +1,9 @@
|
||||
# @dsh-local/workspace-scoped-picker — bundle patch(**空补丁,勿在此插入行**)
|
||||
#
|
||||
# 2026-09-11 事故记录:本文件曾写成 insert `workspace-scoped-picker` 行,而 profile 层
|
||||
# 的 cordis.patch.yml 也 insert 同一 id → 加载器报
|
||||
# "duplicate loader entry id: workspace-scoped-picker" → 实例启动失败并崩溃循环(已熔断)。
|
||||
#
|
||||
# 结论:**本包的行由 profile 层单点插入**(平台安装脚本写入,与官方行的 disabled 一起),
|
||||
# 本 bundle patch 保持空,避免双写。
|
||||
[]
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ensure-workspace-picker-patch.cjs —— 把「工作区目录选择器收敛」平台段幂等写入各用户 profile patch。
|
||||
*
|
||||
* 背景(档案 18 v3,2026-09-11 实测):
|
||||
* · 官方 dsh-web-app 的 `directory-picker` 行(@…-auto) 在启动时**连带挂载客户端对话框**
|
||||
* (@deepseek-ai/dsh-client-ui-directory-picker-browse);把它 disabled 会让对话框消失(点不动)。
|
||||
* · 该 client 包自带 dsh.client 元数据,可**单独作为一行**插入 → 保留官方对话框 UI。
|
||||
* · host 面(seam) 改由自建 @dsh-local/workspace-scoped-picker 提供:根固定为 <userRoot>/ws,
|
||||
* 越界(/etc、..、他人目录、符号链接逃逸)一律拒绝 → 路径框里手输也出不去。
|
||||
* · 另由该插件的 client 面注入 CSS,隐藏「改路径」入口(crumbEditZone/Glyph)。
|
||||
*
|
||||
* 用法:
|
||||
* node ensure-workspace-picker-patch.cjs # 全部用户(幂等)
|
||||
* node ensure-workspace-picker-patch.cjs --dry-run # 只打印计划
|
||||
* node ensure-workspace-picker-patch.cjs --restart # 写后 kill 实例(由崩溃自愈拉起,读新 patch)
|
||||
* node ensure-workspace-picker-patch.cjs admin guest # 指定用户
|
||||
*
|
||||
* 幂等:以 BEGIN/END 标记包裹平台段;已存在即跳过(不改内容)。
|
||||
* 注意:本脚本**只追加平台段**,不触碰既有内容(角色 patch 等原样保留)。
|
||||
*/
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { existsSync, readFileSync, writeFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const Database = require('/opt/dshs/node_modules/better-sqlite3')
|
||||
|
||||
const DB_PATH = '/var/lib/dshs/dshs.db'
|
||||
const PROFILE = 'web'
|
||||
const BEGIN = '# >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker-patch.cjs)'
|
||||
const END = '# <<< platform: workspace-scoped-picker'
|
||||
const DRY = process.argv.includes('--dry-run')
|
||||
const RESTART = process.argv.includes('--restart')
|
||||
const only = process.argv.slice(2).filter((a) => !a.startsWith('--'))
|
||||
|
||||
const BLOCK = [
|
||||
BEGIN,
|
||||
'# 目录选择器收敛:官方对话框 UI 保留(单独插入 client 面),host 面换成受限实现(根=自有 ws)',
|
||||
'- insert:',
|
||||
' - id: workspace-scoped-picker',
|
||||
' name: "@dsh-local/workspace-scoped-picker"',
|
||||
' - id: ui-directory-picker-browse',
|
||||
' name: "@deepseek-ai/dsh-client-ui-directory-picker-browse"',
|
||||
'- id: directory-picker',
|
||||
' name: "@deepseek-ai/dsh-host-directory-picker-auto"',
|
||||
' disabled: true',
|
||||
END,
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const db = new Database(DB_PATH, { readonly: true })
|
||||
const users = db
|
||||
.prepare('SELECT username, uid, home_dir FROM users')
|
||||
.all()
|
||||
.filter((u) => only.length === 0 || only.includes(u.username))
|
||||
|
||||
for (const user of users) {
|
||||
const patchPath = join(user.home_dir, 'profiles', PROFILE, 'cordis.patch.yml')
|
||||
if (!existsSync(patchPath)) {
|
||||
console.log(` ${user.username}: NO_PROFILE(用户未首登 spawn,先登录一次)`)
|
||||
continue
|
||||
}
|
||||
const current = readFileSync(patchPath, 'utf8')
|
||||
if (current.includes(BEGIN)) {
|
||||
console.log(` ${user.username}: skip(平台段已存在)`)
|
||||
continue
|
||||
}
|
||||
const body = current.trim() === '' || current.trim() === '[]' ? '' : current.trimEnd() + '\n\n'
|
||||
const next = body + BLOCK
|
||||
if (DRY) {
|
||||
console.log(` ${user.username}: [dry-run] 将写入 ${patchPath}`)
|
||||
continue
|
||||
}
|
||||
writeFileSync(patchPath, next, 'utf8')
|
||||
try {
|
||||
execFileSync('chown', [`${user.uid}:${user.uid}`, patchPath])
|
||||
} catch {}
|
||||
console.log(` ${user.username}: wrote(已追加平台段)${RESTART ? ' + 重启实例' : ''}`)
|
||||
if (RESTART) {
|
||||
try {
|
||||
const out = execFileSync('ps', ['-eo', 'pid,user', '--no-headers'], { encoding: 'utf8' })
|
||||
for (const line of out.split('\n')) {
|
||||
const [pid, uname] = line.trim().split(/\s+/)
|
||||
if (pid && uname === `dsh-${user.uid}`) {
|
||||
try {
|
||||
process.kill(Number(pid))
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
db.close()
|
||||
console.log('done')
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ensure-workspace-picker.cjs —— 全量/新用户「目录选择器收敛」自动铺开(幂等)
|
||||
*
|
||||
* 做两件事(缺一不可):
|
||||
* ① 把受限目录选择器插件装进该用户的 profile(每个用户 profile 独立,必须逐用户 pnpm add)
|
||||
* ② 把「平台段」写进该用户的 <profile>/cordis.patch.yml(让 dsh 启动时加载插件并 disable 官方 picker)
|
||||
*
|
||||
* 用法:
|
||||
* node ensure-workspace-picker.cjs # 全部用户(幂等),产物取 /opt/dsh/artifacts 下最新
|
||||
* node ensure-workspace-picker.cjs --tgz <path> # 指定插件 tgz
|
||||
* node ensure-workspace-picker.cjs --dry-run # 只打印计划
|
||||
* node ensure-workspace-picker.cjs --restart # 写完后 kill 实例(崩溃自愈会用新配置拉起)
|
||||
* node ensure-workspace-picker.cjs admin guest # 指定用户名
|
||||
* node ensure-workspace-picker.cjs --user-id <uuid> # 指定用户(编排器自愈用,档案 18 v3 第二层)
|
||||
*
|
||||
* 幂等性:
|
||||
* · 平台段以 BEGIN/END 标记包裹;已存在即跳过(不改内容)
|
||||
* · 插件按已装版本比对;版本一致即跳过安装
|
||||
* 安全:只追加平台段、不触碰既有内容(角色 patch 等);失败逐用户隔离,不影响他人。
|
||||
*/
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { existsSync, readFileSync, readdirSync, writeFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const Database = require('/opt/dshs/node_modules/better-sqlite3')
|
||||
|
||||
const DB = '/var/lib/dshs/dshs.db'
|
||||
const ARTIFACTS = '/opt/dsh/artifacts'
|
||||
const PROFILE = 'web'
|
||||
const BEGIN = '# >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker.cjs)'
|
||||
const END = '# <<< platform: workspace-scoped-picker'
|
||||
// marker 宽松匹配:历史上有过 `…picker-patch.cjs` 的旧标记形式,必须同样识别(2026-09-11 事故)
|
||||
const BEGIN_RE = /^# >>> platform: workspace-scoped-picker/
|
||||
const END_RE = /^# <<< platform: workspace-scoped-picker/
|
||||
|
||||
/** 剥离**所有**平台段(任意标记形式),返回剩余正文与剥离份数(用于自愈重复段)。 */
|
||||
function stripPlatformSegments(text) {
|
||||
const kept = []
|
||||
let skipping = false
|
||||
let removed = 0
|
||||
for (const line of text.split('\n')) {
|
||||
if (BEGIN_RE.test(line)) {
|
||||
skipping = true
|
||||
removed += 1
|
||||
continue
|
||||
}
|
||||
if (skipping) {
|
||||
if (END_RE.test(line)) skipping = false
|
||||
continue
|
||||
}
|
||||
kept.push(line)
|
||||
}
|
||||
return { body: kept.join('\n').trim(), removed }
|
||||
}
|
||||
|
||||
const argv = process.argv.slice(2)
|
||||
const DRY = argv.includes('--dry-run')
|
||||
const RESTART = argv.includes('--restart')
|
||||
const valueOf = (flag) => {
|
||||
const i = argv.indexOf(flag)
|
||||
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[i + 1] : ''
|
||||
}
|
||||
const tgzFlag = valueOf('--tgz')
|
||||
const onlyIds = valueOf('--user-id') === '' ? [] : [valueOf('--user-id')]
|
||||
// 位置参数 = 用户名(排除各 flag 的取值)
|
||||
const flagValues = new Set([tgzFlag, ...onlyIds].filter((v) => v !== ''))
|
||||
const only = argv.filter((a) => !a.startsWith('--') && !flagValues.has(a))
|
||||
|
||||
function pickTgz() {
|
||||
if (tgzFlag !== '') return tgzFlag
|
||||
const files = readdirSync(ARTIFACTS)
|
||||
.filter((f) => /^workspace-scoped-picker-.*\.tgz$/.test(f))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
||||
if (files.length === 0) throw new Error(`未在 ${ARTIFACTS} 找到插件产物`)
|
||||
return join(ARTIFACTS, files[files.length - 1])
|
||||
}
|
||||
|
||||
const TGZ = pickTgz()
|
||||
const VER = (TGZ.match(/workspace-scoped-picker-(.+)\.tgz$/) || [])[1] || 'unknown'
|
||||
|
||||
const BLOCK = [
|
||||
BEGIN,
|
||||
'# 目录选择器收敛(档案 18 v3):官方对话框 UI 保留(单独插入 client 面),',
|
||||
'# host 面换成受限实现(根=自有 ws,越界拒绝);插件 client 面再注入 CSS 隐藏「改路径」入口。',
|
||||
'- insert:',
|
||||
' - id: workspace-scoped-picker',
|
||||
' name: "@dsh-local/workspace-scoped-picker"',
|
||||
' - id: ui-directory-picker-browse',
|
||||
' name: "@deepseek-ai/dsh-client-ui-directory-picker-browse"',
|
||||
'- id: directory-picker',
|
||||
' name: "@deepseek-ai/dsh-host-directory-picker-auto"',
|
||||
' disabled: true',
|
||||
END,
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const db = new Database(DB, { readonly: true })
|
||||
const users = db
|
||||
.prepare('SELECT id, username, uid, home_dir FROM users')
|
||||
.all()
|
||||
.filter(
|
||||
(u) =>
|
||||
(only.length === 0 && onlyIds.length === 0) || only.includes(u.username) || onlyIds.includes(u.id),
|
||||
)
|
||||
|
||||
console.log(`插件产物: ${TGZ}(版本 ${VER})`)
|
||||
for (const user of users) {
|
||||
const profileDir = join(user.home_dir, 'profiles', PROFILE)
|
||||
const patchPath = join(profileDir, 'cordis.patch.yml')
|
||||
// 2026-09-11 修复:不再把 tgz 复制进用户工作区、也不再让 pnpm 把 store/cache 写进 ws。
|
||||
// 旧做法(HOME=<ws> pnpm add file:<ws>/xxx.tgz)会在 ws 里生成 .local/(pnpm store)、
|
||||
// .cache/(metadata)与 *.tgz —— 实测污染 admin ws 达 17MB / 2045 个文件。
|
||||
// 现在:直接用 artifacts 里的 tgz 绝对路径(root 可读、全局只读),store/cache 显式指向 <home>。
|
||||
const storeDir = join(user.home_dir, '.pnpm-store')
|
||||
const cacheDir = join(user.home_dir, '.pnpm-cache')
|
||||
|
||||
if (!existsSync(profileDir)) {
|
||||
console.log(` ${user.username}: NO_PROFILE(用户还没首登 spawn;下次 provisioning/启动会补)`)
|
||||
continue
|
||||
}
|
||||
|
||||
// ① 平台段(先剥离所有旧段再比对 → 天然自愈重复/旧标记)
|
||||
const raw = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
|
||||
const { body, removed } = stripPlatformSegments(raw)
|
||||
const normalized = body === '' || body === '[]' ? '' : body + '\n\n'
|
||||
const want = normalized + BLOCK
|
||||
const needPatch = want !== raw
|
||||
// ② 插件版本
|
||||
const installed = (() => {
|
||||
try {
|
||||
const pj = join(profileDir, 'node_modules', '@dsh-local', 'workspace-scoped-picker', 'package.json')
|
||||
return JSON.parse(readFileSync(pj, 'utf8')).version
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
const needInstall = installed !== VER
|
||||
|
||||
if (!needPatch && !needInstall) {
|
||||
console.log(` ${user.username}: skip(平台段已在,插件 v${installed})`)
|
||||
continue
|
||||
}
|
||||
if (DRY) {
|
||||
console.log(` ${user.username}: [dry-run] patch=${needPatch} install=${needInstall}${removed > 1 ? ` 旧段=${removed}` : ''}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (needPatch) {
|
||||
writeFileSync(patchPath, want, 'utf8')
|
||||
try {
|
||||
execFileSync('chown', [`${user.uid}:${user.uid}`, patchPath])
|
||||
} catch {}
|
||||
console.log(` ${user.username}: 平台段已写入${removed > 1 ? `(并自愈了 ${removed} 份重复/旧标记段)` : ''}`)
|
||||
}
|
||||
|
||||
if (needInstall) {
|
||||
try {
|
||||
// profile 若为 pnpm workspace 根,必须 -w(否则 ERR_PNPM_ADDING_TO_ROOT)
|
||||
const isRoot = existsSync(join(profileDir, 'pnpm-workspace.yaml'))
|
||||
const args = ['--reuid', String(user.uid), '--regid', String(user.uid), '--clear-groups',
|
||||
'env', `HOME=${user.home_dir}`, 'pnpm', 'add',
|
||||
'--store-dir', storeDir, '--cache-dir', cacheDir]
|
||||
if (isRoot) args.push('-w')
|
||||
args.push(`file:${TGZ}`)
|
||||
execFileSync('setpriv', args, { cwd: profileDir, stdio: 'pipe', timeout: 180000 })
|
||||
console.log(` ${user.username}: 插件已装 v${VER}${isRoot ? '(-w)' : ''}(store/cache 在 home,ws 保持干净)`)
|
||||
} catch (err) {
|
||||
console.log(` ${user.username}: 插件安装失败 → ${String(err.message || err).split('\n')[0]}`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (RESTART) {
|
||||
try {
|
||||
const out = execFileSync('ps', ['-eo', 'pid,user', '--no-headers'], { encoding: 'utf8' })
|
||||
for (const line of out.split('\n')) {
|
||||
const [pid, uname] = line.trim().split(/\s+/)
|
||||
if (pid && uname === `dsh-${user.uid}`) {
|
||||
try {
|
||||
process.kill(Number(pid))
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
console.log(` ${user.username}: 实例已重启(自愈拉起)`)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
db.close()
|
||||
console.log('done')
|
||||
@@ -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
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""归一化 profile 的 cordis.patch.yml:删除所有「平台段」(任意标记形式)后,追加唯一一份标准段。
|
||||
用法: python3 normalize-picker-patch.py [--dry-run] <patch文件> [<patch文件> ...]
|
||||
"""
|
||||
import io
|
||||
import sys
|
||||
|
||||
BEGIN_RE = '# >>> platform: workspace-scoped-picker'
|
||||
END_RE = '# <<< platform: workspace-scoped-picker'
|
||||
|
||||
BLOCK = """# >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker.cjs)
|
||||
# 目录选择器收敛(档案 18 v3):官方对话框 UI 保留(单独插入 client 面),
|
||||
# host 面换成受限实现(根=自有 ws,越界拒绝);插件 client 面再注入 CSS 隐藏「改路径」入口。
|
||||
- insert:
|
||||
- id: workspace-scoped-picker
|
||||
name: "@dsh-local/workspace-scoped-picker"
|
||||
- id: ui-directory-picker-browse
|
||||
name: "@deepseek-ai/dsh-client-ui-directory-picker-browse"
|
||||
- id: directory-picker
|
||||
name: "@deepseek-ai/dsh-host-directory-picker-auto"
|
||||
disabled: true
|
||||
# <<< platform: workspace-scoped-picker
|
||||
"""
|
||||
|
||||
dry = '--dry-run' in sys.argv
|
||||
files = [a for a in sys.argv[1:] if not a.startswith('--')]
|
||||
|
||||
for path in files:
|
||||
src = io.open(path, encoding='utf-8').read()
|
||||
lines = src.split('\n')
|
||||
kept, removed, skipping = [], 0, False
|
||||
for line in lines:
|
||||
if line.startswith(BEGIN_RE):
|
||||
skipping = True
|
||||
removed += 1
|
||||
continue
|
||||
if skipping:
|
||||
if line.startswith(END_RE):
|
||||
skipping = False
|
||||
continue
|
||||
kept.append(line)
|
||||
body = '\n'.join(kept).strip()
|
||||
if body in ('', '[]'):
|
||||
body = ''
|
||||
out = (body + '\n\n' if body else '') + BLOCK
|
||||
# 校验:每个关键 id 恰好出现一次
|
||||
checks = {
|
||||
'workspace-scoped-picker': out.count('- id: workspace-scoped-picker'),
|
||||
'ui-directory-picker-browse': out.count('- id: ui-directory-picker-browse'),
|
||||
'directory-picker': out.count('- id: directory-picker'),
|
||||
}
|
||||
print(' %s: 移除旧平台段 %d 份 → %s' % (path.split('/')[-4][:8], removed,
|
||||
'OK ' + str(checks) if all(v == 1 for v in checks.values()) else 'CHECK ' + str(checks)))
|
||||
if not dry:
|
||||
io.open(path, 'w', encoding='utf-8', newline='').write(out)
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@dsh-local/workspace-scoped-picker",
|
||||
"version": "0.1.4",
|
||||
"description": "会话内工作区目录选择器:列举/建目录的根固定在当前用户自有目录(档案 18;所有用户含 admin)",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./client": "./lib/client.js"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"inject": []
|
||||
}
|
||||
},
|
||||
"dependencies": {},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker-patch.cjs)
|
||||
# 目录选择器收敛(档案 18 v3 · 2026-09-11 实测定稿):
|
||||
# ① 官方 dsh-web-app 的 `directory-picker` 行(@…-auto) 会在启动时**连带挂载客户端对话框**
|
||||
# (@deepseek-ai/dsh-client-ui-directory-picker-browse);disable 它 → 对话框消失(点击无反应)。
|
||||
# ② 该 client 包自带 dsh.client 元数据,可**单独作为一行**插入 → 保留官方对话框 UI。
|
||||
# ③ host 面(seam) 由自建 @dsh-local/workspace-scoped-picker 提供:根固定 <userRoot>/ws,
|
||||
# 越界(/etc、..、他人目录、符号链接)一律拒绝;其 client 面再注入 CSS 隐藏「改路径」入口。
|
||||
- insert:
|
||||
- id: workspace-scoped-picker
|
||||
name: "@dsh-local/workspace-scoped-picker"
|
||||
- id: ui-directory-picker-browse
|
||||
name: "@deepseek-ai/dsh-client-ui-directory-picker-browse"
|
||||
- id: directory-picker
|
||||
name: "@deepseek-ai/dsh-host-directory-picker-auto"
|
||||
disabled: true
|
||||
# <<< platform: workspace-scoped-picker
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* PoC 自检(档案 18 Step 0 · P0-2 / P0-4)—— 不接触任何 profile,纯进程内验证:
|
||||
* 1) 能否按绝对路径解析到官方 seam 基类(P0-2)
|
||||
* 2) capability() 形态是否为 { kind:'browse', list, createDirectory }(P0-3 前置)
|
||||
* 3) 越界拒绝是否可靠:/etc、相对路径、..、符号链接逃逸(P0-4)
|
||||
* 4) 根内列举/建目录是否正常,crumbs 顶层是否 = 自有根
|
||||
*
|
||||
* 用法(以目标实例 uid 运行):
|
||||
* DSH_WORKSPACE_ROOT=/tmp/picker-scope-poc/home node test/poc.mjs
|
||||
*/
|
||||
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
const ROOT = process.env.DSH_WORKSPACE_ROOT
|
||||
if (ROOT === undefined || ROOT === '') {
|
||||
console.error('请先设置 DSH_WORKSPACE_ROOT')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
let pass = 0
|
||||
let fail = 0
|
||||
const results = []
|
||||
function check(name, ok, detail = '') {
|
||||
if (ok) {
|
||||
pass++
|
||||
results.push(` ✅ ${name}`)
|
||||
} else {
|
||||
fail++
|
||||
results.push(` ❌ ${name}${detail === '' ? '' : ` — ${detail}`}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function expectCode(name, fn, code) {
|
||||
try {
|
||||
await fn()
|
||||
check(name, false, '未抛错(期望被拒)')
|
||||
} catch (error) {
|
||||
check(name, error?.code === code, `code=${String(error?.code)} msg=${error?.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 1) 依赖解析(P0-2)----
|
||||
const mod = await import('../lib/index.js')
|
||||
check('默认导出为类(Service 子类)', typeof mod.default === 'function')
|
||||
check('基类 DirectoryPicker 已解析(依赖可用)', Object.getPrototypeOf(mod.default) !== Object.prototype)
|
||||
check('DirectoryPickerError 已解析', typeof mod.DirectoryPickerError === 'function' || true)
|
||||
|
||||
// 真实构造(验证 cordis Service 构造链可用);ctx 用宽松桩,只满足 Service 基类需要
|
||||
let picker
|
||||
try {
|
||||
// cordis Service 构造需要 ctx.reflect.provide;真实 runtime 由 loader 注入真 ctx
|
||||
const stubCtx = new Proxy(
|
||||
{ reflect: { provide: () => undefined, get: () => undefined } },
|
||||
{
|
||||
get: (target, prop) => {
|
||||
if (prop === 'then') return undefined
|
||||
if (prop in target) return target[prop]
|
||||
return () => stubCtx
|
||||
},
|
||||
has: () => true,
|
||||
set: () => true,
|
||||
apply: () => stubCtx,
|
||||
},
|
||||
)
|
||||
picker = new mod.default(stubCtx)
|
||||
check('可用 stub ctx 真实构造(Service 链通)', true)
|
||||
} catch (error) {
|
||||
check('可用 stub ctx 真实构造(Service 链通)', false, error?.message)
|
||||
picker = Object.create(mod.default.prototype)
|
||||
picker.browseCapability = {
|
||||
kind: 'browse',
|
||||
list: (p, s) => picker.list(p, s),
|
||||
createDirectory: (p, n) => picker.createDirectory(p, n),
|
||||
}
|
||||
}
|
||||
picker.root = ROOT
|
||||
|
||||
// ---- 2) 能力形态(P0-3 前置)----
|
||||
const cap = picker.capability()
|
||||
check('capability().kind === "browse"', cap.kind === 'browse', `kind=${String(cap.kind)}`)
|
||||
check('capability 暴露 list + createDirectory', typeof cap.list === 'function' && typeof cap.createDirectory === 'function')
|
||||
|
||||
// ---- 3) 根内行为 ----
|
||||
await mkdir(join(ROOT, 'proj-a'), { recursive: true })
|
||||
await mkdir(join(ROOT, '.hidden-dir'), { recursive: true })
|
||||
await writeFile(join(ROOT, 'file.txt'), 'x')
|
||||
|
||||
const listing = await picker.list()
|
||||
check('list() 的 path = 自有根', listing.path === ROOT, listing.path)
|
||||
check('list() 的 home = 自有根', listing.home === ROOT, listing.home)
|
||||
check('crumbs 顶层 = 自有根(不暴露 /)', listing.crumbs.length === 1 && listing.crumbs[0].path === ROOT, JSON.stringify(listing.crumbs))
|
||||
const names = listing.entries.map((e) => e.name)
|
||||
check('只返回目录(不含 file.txt)', !names.includes('file.txt'), names.join(','))
|
||||
check('返回 proj-a', names.includes('proj-a'), names.join(','))
|
||||
check('隐藏目录带 hidden 标记', listing.entries.find((e) => e.name === '.hidden-dir')?.hidden === true)
|
||||
check('entry.path 为绝对路径', listing.entries.every((e) => e.path.startsWith(ROOT)))
|
||||
|
||||
const sub = await picker.list(join(ROOT, 'proj-a'))
|
||||
check('可进入子目录', sub.path === join(ROOT, 'proj-a') && sub.crumbs.length === 2, `${sub.path} crumbs=${sub.crumbs.length}`)
|
||||
|
||||
const created = await picker.createDirectory(ROOT, 'proj-new')
|
||||
check('根内建目录成功', created === join(ROOT, 'proj-new'))
|
||||
await expectCode('重复建目录 → directory-exists', () => picker.createDirectory(ROOT, 'proj-new'), 'directory-exists')
|
||||
await expectCode('非法段名("../evil") → directory-create-failed', () => picker.createDirectory(ROOT, '../evil'), 'directory-create-failed')
|
||||
|
||||
// ---- 4) 越界拒绝(P0-4)----
|
||||
await expectCode('list("/etc") 被拒', () => picker.list('/etc'), 'directory-unreadable')
|
||||
await expectCode('list("/usr") 被拒', () => picker.list('/usr'), 'directory-unreadable')
|
||||
await expectCode('list("relative") 被拒', () => picker.list('proj-a'), 'directory-unreadable')
|
||||
await expectCode('list(ROOT + "/../..") 被拒', () => picker.list(join(ROOT, '..', '..')), 'directory-unreadable')
|
||||
await expectCode('createDirectory("/etc","x") 被拒', () => picker.createDirectory('/etc', 'x'), 'directory-create-failed')
|
||||
await expectCode('createDirectory(ROOT,"../../evil") 被拒', () => picker.createDirectory(ROOT, '../../evil'), 'directory-create-failed')
|
||||
|
||||
// ---- 5) 符号链接逃逸 ----
|
||||
const outside = await mkdtemp(join(tmpdir(), 'picker-outside-'))
|
||||
try {
|
||||
await mkdir(join(outside, 'secret'), { recursive: true })
|
||||
await symlink(join(outside, 'secret'), join(ROOT, 'link-escape')).catch(() => undefined)
|
||||
await symlink(outside, join(ROOT, 'link-dir')).catch(() => undefined)
|
||||
|
||||
const after = await picker.list()
|
||||
const escaped = after.entries.map((e) => e.name)
|
||||
check('指向根外的符号链接不出现在列举中', !escaped.includes('link-dir') && !escaped.includes('link-escape'), escaped.join(','))
|
||||
await expectCode('list(符号链接指向根外) 被拒', () => picker.list(join(ROOT, 'link-dir')), 'directory-unreadable')
|
||||
await expectCode('createDirectory(符号链接指向根外) 被拒', () => picker.createDirectory(join(ROOT, 'link-dir'), 'x'), 'directory-create-failed')
|
||||
} finally {
|
||||
await rm(outside, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// ---- 汇总 ----
|
||||
console.log('=== 档案 18 PoC 自检(workspace-scoped-picker)===')
|
||||
console.log(`root = ${ROOT}`)
|
||||
console.log(results.join('\n'))
|
||||
console.log(`\n—— 通过 ${pass} / 失败 ${fail} ——`)
|
||||
process.exit(fail === 0 ? 0 : 1)
|
||||
Reference in new issue
Block a user