@moonquake2004/dsh-security 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/checks/index.mjs +3 -0
- package/src/checks/sl1-supply-chain.mjs +11 -6
- package/src/checks/sl4-release-compat.mjs +11 -3
- package/src/checks/sp1-dependency-audit.mjs +155 -65
- package/src/checks/sp11-patch-security-override.mjs +132 -0
- package/src/checks/sp12-config-as-code-tag.mjs +125 -0
- package/src/checks/sp13-tools-mode-sandbox.mjs +631 -0
- package/src/checks/sp3-sandbox-consistency.mjs +293 -147
- package/src/checks/sp5-permission-model.mjs +267 -53
- package/src/checks/sp8-dist-tag-health.mjs +16 -0
- package/src/checks/sp9-dual-instance-guard.mjs +241 -57
- package/src/checks/sr1-sandbox-violation.mjs +22 -23
- package/src/checks/sr2-privilege-escalation.mjs +26 -11
- package/src/checks/sr3-data-exfiltration.mjs +6 -14
- package/src/checks/sr4-isolation-verify.mjs +4 -5
- package/src/checks/ss2-pii-exposure.mjs +8 -3
- package/src/checks/ss3-sensitive-output.mjs +9 -8
- package/src/checks/ss4-session-integrity.mjs +20 -18
- package/src/dsh-config.mjs +204 -0
- package/src/index.mjs +3 -0
- package/src/install-tree.mjs +293 -0
- package/src/integrations/ecosystem.mjs +232 -48
- package/src/integrations/index.mjs +72 -24
- package/src/integrations/plugin-reducer.mjs +190 -40
- package/src/integrations/poison-guard.mjs +162 -39
- package/src/integrations/sandbox-audit.mjs +51 -47
- package/src/integrations/tool-exec.mjs +130 -0
- package/src/registry.mjs +3 -0
- package/src/session-reader.mjs +134 -0
|
@@ -18,7 +18,7 @@ import { existsSync, statSync } from 'node:fs';
|
|
|
18
18
|
import { Severity } from '../protocol/severity.mjs';
|
|
19
19
|
import { CheckPhase } from '../protocol/phase.mjs';
|
|
20
20
|
import { pass, fail, skip } from '../protocol/check.mjs';
|
|
21
|
-
import { scanSessionLines } from '../session-reader.mjs';
|
|
21
|
+
import { scanSessionLines, extractEvent } from '../session-reader.mjs';
|
|
22
22
|
|
|
23
23
|
const MIN_LINES = 10;
|
|
24
24
|
|
|
@@ -39,7 +39,8 @@ export async function run(sessionFile) {
|
|
|
39
39
|
let totalLines = 0;
|
|
40
40
|
let invalidJson = 0;
|
|
41
41
|
let firstInvalidLine = null;
|
|
42
|
-
const orphanCalls = new Map(); // callId → line
|
|
42
|
+
const orphanCalls = new Map(); // callId → { line, turn }
|
|
43
|
+
let maxTurn = -1;
|
|
43
44
|
let resultCount = 0;
|
|
44
45
|
|
|
45
46
|
await scanSessionLines(sessionFile, (line, lineNum) => {
|
|
@@ -55,17 +56,15 @@ export async function run(sessionFile) {
|
|
|
55
56
|
return; // 无法 parse 的行跳过后续检查
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
// 2. tool/call 与 tool/result
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
resultCount++;
|
|
68
|
-
}
|
|
59
|
+
// 2. tool/call 与 tool/result 配对检查(字段路径见 docs/session-shape-v3.md)
|
|
60
|
+
// v3 中 tool/result 没有 data.callId,id 在 data.message.source.callId —— 旧写法导致 0 配对、
|
|
61
|
+
// 每个调用都被当成孤儿(2026-09 实测:健康会话被报 45 个孤儿)。
|
|
62
|
+
const ev = extractEvent(parsed);
|
|
63
|
+
// maxTurn 必须对**所有**事件更新(无 callId 的事件同样携带 turn),否则 in-flight 豁免判据失效
|
|
64
|
+
if (typeof ev.turn === 'number') maxTurn = Math.max(maxTurn, ev.turn);
|
|
65
|
+
if (ev.callId) {
|
|
66
|
+
if (ev.kind === 'call') orphanCalls.set(ev.callId, { line: lineNum, turn: ev.turn });
|
|
67
|
+
else if (ev.kind === 'result') { orphanCalls.delete(ev.callId); resultCount++; }
|
|
69
68
|
}
|
|
70
69
|
});
|
|
71
70
|
|
|
@@ -81,10 +80,13 @@ export async function run(sessionFile) {
|
|
|
81
80
|
const issues = [];
|
|
82
81
|
const refs = [];
|
|
83
82
|
|
|
84
|
-
// 汇总孤儿 tool/call
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
// 汇总孤儿 tool/call —— 仅当该调用处在**已闭合的 turn** 中才算真孤儿。
|
|
84
|
+
// 活跃会话的最后一个 turn 天然可能有不配对的调用(正在执行 / 已中断),豁免之(对齐 dsh-doctor S1 的 in-flight 处理)。
|
|
85
|
+
const realOrphans = [...orphanCalls.entries()].filter(([, v]) => typeof v.turn === 'number' && v.turn < maxTurn);
|
|
86
|
+
const inflight = orphanCalls.size - realOrphans.length;
|
|
87
|
+
if (realOrphans.length > 0) {
|
|
88
|
+
const samples = realOrphans.slice(0, 5).map(([id, v]) => ` 行${v.line}: callId=${id.slice(0, 16)}…`);
|
|
89
|
+
issues.push(`${realOrphans.length} 个孤儿 tool/call(有调用无结果):\n${samples.join('\n')}`);
|
|
88
90
|
refs.push('#3234');
|
|
89
91
|
}
|
|
90
92
|
|
|
@@ -96,7 +98,7 @@ export async function run(sessionFile) {
|
|
|
96
98
|
|
|
97
99
|
if (issues.length === 0) {
|
|
98
100
|
return pass(id, Severity.HIGH,
|
|
99
|
-
`会话日志完整性通过:${totalLines} 行,JSON 全有效,${resultCount} 个 tool/result
|
|
101
|
+
`会话日志完整性通过:${totalLines} 行,JSON 全有效,${resultCount} 个 tool/result 已配对${inflight > 0 ? `(尾部 in-flight 未配对 ${inflight} 个,属正常)` : ''}`);
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
return fail(id, Severity.HIGH,
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH Security Framework — dsh 宿主配置读取(SP5 用)
|
|
3
|
+
*
|
|
4
|
+
* SP5 需要报告**真实**的能力面,其中宿主侧两项是权威来源:
|
|
5
|
+
* 1. `$DSH_HOME/settings.yaml` 的 `permission` 命名空间
|
|
6
|
+
* (schema 仅 `{ defaultPreset }`,见 dsh-permission-presets/lib/index.js:24,121)
|
|
7
|
+
* 2. `@deepseek-ai/dsh-base/cordis.patch.yml` 的
|
|
8
|
+
* `sandbox-policy.config.mode` / `approval.config.policy` / `permission.config.presets`
|
|
9
|
+
* (dsh-base/cordis.patch.yml:204-242)
|
|
10
|
+
* 二者都在 profile 之外,必须按模块回退锚点解析(`$DSH_HOME/profiles/node_modules`)。
|
|
11
|
+
*
|
|
12
|
+
* 这里只实现读取这两个文件所需的最小 YAML 子集解析(块式 `key:` / `key: value` / 缩进层级),
|
|
13
|
+
* 不引入依赖;块级解析用于定位 `- id: <row>` 行及其 `config:` 子树。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
/** 最小块扫描:<缩进, 文本> 列表,跳过空行与整行注释 */
|
|
20
|
+
function blockLines(text) {
|
|
21
|
+
const info = [];
|
|
22
|
+
const lines = String(text).split('\n');
|
|
23
|
+
for (let i = 0; i < lines.length; i++) {
|
|
24
|
+
const raw = lines[i];
|
|
25
|
+
if (!raw.trim() || /^\s*#/.test(raw)) continue;
|
|
26
|
+
const indent = raw.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length;
|
|
27
|
+
info.push({ indent, text: raw.trim(), line: i + 1 });
|
|
28
|
+
}
|
|
29
|
+
return info;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 取 `- id: <rowId>` 行开始的块(到下一条同级 `- id:` 为止),
|
|
34
|
+
* 返回其配置子树文本(不含 id 行本身)。找不到返回 null。
|
|
35
|
+
*/
|
|
36
|
+
export function rowBlock(text, rowId) {
|
|
37
|
+
const info = blockLines(text);
|
|
38
|
+
const want = `id: ${rowId}`;
|
|
39
|
+
let start = -1, end = info.length, rowIndent = 0;
|
|
40
|
+
for (let i = 0; i < info.length; i++) {
|
|
41
|
+
const s = info[i].text.replace(/^-\s*/, '');
|
|
42
|
+
if (s === want || s === `${want} ` || s.startsWith(`${want} `)) {
|
|
43
|
+
start = i; rowIndent = info[i].indent;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (start < 0) return null;
|
|
48
|
+
const reId = /^-?\s*id:\s/;
|
|
49
|
+
for (let j = start + 1; j < info.length; j++) {
|
|
50
|
+
if (info[j].indent <= rowIndent && reId.test(info[j].text)) { end = j; break; }
|
|
51
|
+
}
|
|
52
|
+
return info.slice(start, end);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 在一个行的块里取某个顶层键的子树。
|
|
57
|
+
* @returns {Array<{indent:number,text:string,line:number}>|null}
|
|
58
|
+
*/
|
|
59
|
+
export function keyBlock(block, key) {
|
|
60
|
+
if (!block) return null;
|
|
61
|
+
const idx = block.findIndex(l => l.text.startsWith(`${key}:`));
|
|
62
|
+
if (idx < 0) return null;
|
|
63
|
+
const base = block[idx].indent;
|
|
64
|
+
const out = [block[idx]];
|
|
65
|
+
for (let j = idx + 1; j < block.length; j++) {
|
|
66
|
+
if (block[j].indent <= base) break;
|
|
67
|
+
out.push(block[j]);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 在块里取 `key: value` 的裸标量(含引号/`!!js` 表达式原样返回) */
|
|
73
|
+
export function scalarIn(block, key) {
|
|
74
|
+
if (!block) return null;
|
|
75
|
+
for (const l of block) {
|
|
76
|
+
const m = new RegExp(`^${key}:\\s*(.*)$`).exec(l.text);
|
|
77
|
+
if (m) return m[1].trim();
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function unquote(v) {
|
|
83
|
+
if (v === null || v === undefined) return null;
|
|
84
|
+
const t = String(v).trim();
|
|
85
|
+
if ((t.startsWith("'") && t.endsWith("'")) || (t.startsWith('"') && t.endsWith('"'))) return t.slice(1, -1);
|
|
86
|
+
return t;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 从 `!!js` 表达式里抽出字面量(`a ?? 'b'` / 三元 / 纯字面量)。
|
|
91
|
+
* 只做字面量识别,**不求值**——求值会让检查本身变成代码执行面。
|
|
92
|
+
* `decisive=true` 表示表达式无条件返回该字面量(无 env 读取 / 无三元)。
|
|
93
|
+
* @returns {{literal: string|null, expression: string|null, decisive: boolean}|null}
|
|
94
|
+
*/
|
|
95
|
+
export function evalScalarLiteral(raw) {
|
|
96
|
+
if (raw === null || raw === undefined) return null;
|
|
97
|
+
const t = String(raw).trim();
|
|
98
|
+
if (!t) return null;
|
|
99
|
+
if (t.startsWith('!!js')) {
|
|
100
|
+
const expression = t.replace(/^!!js\s*/, '').trim();
|
|
101
|
+
const strings = [...expression.matchAll(/'([^']*)'|"([^"]*)"/g)].map(m => m[1] ?? m[2]).filter(s => s !== '');
|
|
102
|
+
const hasEnv = /process\.env/.test(expression);
|
|
103
|
+
const hasTernary = /\?/.test(expression) && /:/.test(expression);
|
|
104
|
+
return {
|
|
105
|
+
literal: strings.length > 0 ? strings[0] : null,
|
|
106
|
+
expression,
|
|
107
|
+
decisive: strings.length === 1 && !hasEnv && !hasTernary,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { literal: unquote(t), expression: null, decisive: !/process\.env/.test(t) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 取文本顶层某个 key 的块 */
|
|
114
|
+
export function topLevelKeyBlock(text, key) {
|
|
115
|
+
return keyBlock(blockLines(text), key);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 读取 settings.yaml 的 `permission` 命名空间。
|
|
120
|
+
* @returns {{present:boolean, defaultPreset:string|null, raw:string|null, file:string|null}}
|
|
121
|
+
*/
|
|
122
|
+
export function readPermissionSettings(dshHome) {
|
|
123
|
+
const file = dshHome ? join(dshHome, 'settings.yaml') : null;
|
|
124
|
+
if (!file || !existsSync(file)) return { present: false, defaultPreset: null, raw: null, file };
|
|
125
|
+
let text;
|
|
126
|
+
try { text = readFileSync(file, 'utf8'); } catch { return { present: false, defaultPreset: null, raw: null, file }; }
|
|
127
|
+
const block = topLevelKeyBlock(text, 'permission');
|
|
128
|
+
if (!block) return { present: false, defaultPreset: null, raw: null, file };
|
|
129
|
+
const raw = scalarIn(block, 'defaultPreset');
|
|
130
|
+
return { present: true, defaultPreset: unquote(raw), raw, file };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 解析 dsh-base 的 cordis.patch.yml,取出真实的沙箱/审批/预设配置。
|
|
135
|
+
* @returns {object|null} 文件不存在时 null
|
|
136
|
+
*/
|
|
137
|
+
export function readHostSandboxConfig(basePatchPath) {
|
|
138
|
+
if (!basePatchPath || !existsSync(basePatchPath)) return null;
|
|
139
|
+
let text;
|
|
140
|
+
try { text = readFileSync(basePatchPath, 'utf8'); } catch { return null; }
|
|
141
|
+
|
|
142
|
+
const policyBlock = rowBlock(text, 'sandbox-policy');
|
|
143
|
+
const approvalBlock = rowBlock(text, 'approval');
|
|
144
|
+
const permissionBlock = rowBlock(text, 'permission');
|
|
145
|
+
const sandboxBlock = rowBlock(text, 'sandbox');
|
|
146
|
+
|
|
147
|
+
const policyCfg = keyBlock(policyBlock, 'config');
|
|
148
|
+
const approvalCfg = keyBlock(approvalBlock, 'config');
|
|
149
|
+
const permissionCfg = keyBlock(permissionBlock, 'config');
|
|
150
|
+
|
|
151
|
+
const presets = {};
|
|
152
|
+
const presetsBlock = keyBlock(permissionCfg, 'presets');
|
|
153
|
+
if (presetsBlock) {
|
|
154
|
+
// 顶层 preset 名 = 缩进为 presets 直属子级的 key
|
|
155
|
+
const presetsIndent = presetsBlock[0].indent;
|
|
156
|
+
let current = null;
|
|
157
|
+
for (const l of presetsBlock.slice(1)) {
|
|
158
|
+
if (l.indent === presetsIndent + 2 && /^[A-Za-z0-9_-]+:\s*$/.test(l.text)) {
|
|
159
|
+
current = l.text.slice(0, -1);
|
|
160
|
+
presets[current] = {};
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (current && l.indent > presetsIndent) {
|
|
164
|
+
const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(l.text);
|
|
165
|
+
if (m) presets[current][m[1]] = unquote(m[2]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const modeRaw = scalarIn(policyCfg, 'mode');
|
|
171
|
+
const approvalRaw = scalarIn(approvalCfg, 'policy');
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
path: basePatchPath,
|
|
175
|
+
sandboxMode: evalScalarLiteral(modeRaw),
|
|
176
|
+
approvalPolicy: evalScalarLiteral(approvalRaw),
|
|
177
|
+
sandboxService: scalarIn(sandboxBlock, 'name') ? unquote(scalarIn(sandboxBlock, 'name')) : null,
|
|
178
|
+
presets,
|
|
179
|
+
presetsRaw: presetsBlock ? 'present' : 'absent',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 在候选 node_modules / 包目录中定位 dsh-base 的 cordis.patch.yml。
|
|
185
|
+
* 复用模块回退锚点语义:profile → 逐级父目录 → CLI 安装根。
|
|
186
|
+
*/
|
|
187
|
+
export function resolveBasePatch(profileDir, cliRoots = []) {
|
|
188
|
+
const cands = [];
|
|
189
|
+
let dir = profileDir;
|
|
190
|
+
for (let i = 0; i < 6 && dir; i++) {
|
|
191
|
+
cands.push(join(dir, 'node_modules', '@deepseek-ai', 'dsh-base', 'cordis.patch.yml'));
|
|
192
|
+
const parent = dir.replace(/[/\\][^/\\]*$/, '');
|
|
193
|
+
if (!parent || parent === dir) break;
|
|
194
|
+
dir = parent;
|
|
195
|
+
}
|
|
196
|
+
for (const root of cliRoots) {
|
|
197
|
+
cands.push(join(root, 'node_modules', '@deepseek-ai', 'dsh-base', 'cordis.patch.yml'));
|
|
198
|
+
cands.push(join(root, 'cordis.patch.yml'));
|
|
199
|
+
}
|
|
200
|
+
for (const c of cands) {
|
|
201
|
+
try { if (existsSync(c)) return c; } catch { /* 继续 */ }
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -26,6 +26,9 @@ export { sp7Check } from './checks/sp7-client-syntax.mjs';
|
|
|
26
26
|
export { sp8Check } from './checks/sp8-dist-tag-health.mjs';
|
|
27
27
|
export { sp9Check } from './checks/sp9-dual-instance-guard.mjs';
|
|
28
28
|
export { sp10Check } from './checks/sp10-poison-pattern.mjs';
|
|
29
|
+
export { sp11Check } from './checks/sp11-patch-security-override.mjs';
|
|
30
|
+
export { sp12Check } from './checks/sp12-config-as-code-tag.mjs';
|
|
31
|
+
export { sp13Check } from './checks/sp13-tools-mode-sandbox.mjs';
|
|
29
32
|
|
|
30
33
|
// Layer 2: Runtime Checks
|
|
31
34
|
export { sr1Check } from './checks/sr1-sandbox-violation.mjs';
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH Security Framework — 安装树 / profile 布局解析(SP5、SP9 共用)
|
|
3
|
+
*
|
|
4
|
+
* 背景(2026-09 上游兼容审计 R5/R7):
|
|
5
|
+
* 0.1.5 起 `$DSH_HOME/profiles/node_modules` 是 **dsh 自有的符号链接镜像**,
|
|
6
|
+
* 由 `healProfilesModuleFallback`(dsh-app-boot)写成 CLI 安装闭包的镜像;
|
|
7
|
+
* 而 `$DSH_HOME/profiles/<name>/node_modules` 是 pnpm 真实安装目录。
|
|
8
|
+
* 两者语义完全相反,检查必须区分「符号链接镜像」与「真实目录副本」,
|
|
9
|
+
* 因此这里集中解析:
|
|
10
|
+
* - 安装前缀(CLI 自身所在的 @deepseek-ai 目录)
|
|
11
|
+
* - profile 布局(DSH_HOME / 当前 profile 名)
|
|
12
|
+
* - 符号链接目标是否落在安装前缀内
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
18
|
+
|
|
19
|
+
/** 用 profile 目录内的解析器定位真实安装位置(可命中 profile 自身的 node_modules) */
|
|
20
|
+
function requireFrom(baseDir) {
|
|
21
|
+
try { return createRequire(join(baseDir, '__dsh_security_probe__.js')); } catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 解析 CLI 安装的 @deepseek-ai 目录(安装前缀)。
|
|
26
|
+
* 顺序:显式参数 → 从 profileDir 解析 @deepseek-ai/dsh/package.json →
|
|
27
|
+
* npm_node_execpath → DSH_HOME 镜像 → 常见全局前缀。
|
|
28
|
+
* @returns {string|null}
|
|
29
|
+
*/
|
|
30
|
+
export function resolveInstallPrefix(profileDir, explicit = undefined) {
|
|
31
|
+
if (explicit) return explicit;
|
|
32
|
+
|
|
33
|
+
const candidates = [];
|
|
34
|
+
if (profileDir) {
|
|
35
|
+
const req = requireFrom(profileDir);
|
|
36
|
+
if (req) {
|
|
37
|
+
try {
|
|
38
|
+
// @deepseek-ai/dsh/package.json → <prefix>/@deepseek-ai/dsh/package.json
|
|
39
|
+
candidates.push(dirname(dirname(req.resolve('@deepseek-ai/dsh/package.json'))));
|
|
40
|
+
} catch { /* 不在该解析路径 */ }
|
|
41
|
+
try {
|
|
42
|
+
candidates.push(dirname(dirname(req.resolve('@deepseek-ai/dsh-base/package.json'))));
|
|
43
|
+
} catch { /* 不在该解析路径 */ }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (process.env.npm_node_execpath) {
|
|
47
|
+
// …/lib/node_modules/npm/bin/node-gyp-bin/… 或 …/lib/node_modules/npm/…
|
|
48
|
+
const parts = process.env.npm_node_execpath.split(sep);
|
|
49
|
+
const idx = parts.lastIndexOf('node_modules');
|
|
50
|
+
if (idx > 0) candidates.push(join(parts.slice(0, idx).join(sep), 'node_modules', '@deepseek-ai'));
|
|
51
|
+
}
|
|
52
|
+
if (process.env.DSH_HOME) {
|
|
53
|
+
for (const n of ['dsh', 'dsh-base']) {
|
|
54
|
+
const p = join(process.env.DSH_HOME, 'profiles', 'node_modules', '@deepseek-ai', n);
|
|
55
|
+
if (existsSync(join(p, 'package.json'))) candidates.push(dirname(p));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const n of ['dsh', 'dsh-base']) {
|
|
59
|
+
const p = join('/opt/homebrew/lib/node_modules/@deepseek-ai', n);
|
|
60
|
+
if (existsSync(join(p, 'package.json'))) { candidates.push(dirname(p)); break; }
|
|
61
|
+
}
|
|
62
|
+
for (const n of ['dsh', 'dsh-base']) {
|
|
63
|
+
const p = join('/usr/local/lib/node_modules/@deepseek-ai', n);
|
|
64
|
+
if (existsSync(join(p, 'package.json'))) { candidates.push(dirname(p)); break; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const c of candidates) if (c && existsSync(c)) return c;
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* profile 目录布局:DSH_HOME 与当前 profile 名。
|
|
73
|
+
* 支持两种入参:`<dshHome>/profiles/<name>`(doctor 传入)与 `<dshHome>/profiles`(镜像根)。
|
|
74
|
+
*
|
|
75
|
+
* 注意:<dshHome> 会做 realpath 归一(macOS 下 `/var` → `/private/var`),
|
|
76
|
+
* 因此由本函数拼出的路径可能与调用方传入的 profileDir **写法不同**(指向同一实体)。
|
|
77
|
+
* 需要"把调用方路径也列进扫描范围"的检查,应自行把入参一并加入候选。
|
|
78
|
+
* @returns {{dshHome: string|null, profilesDir: string|null, profileName: string|null}}
|
|
79
|
+
*/
|
|
80
|
+
export function resolveProfileLayout(profileDir) {
|
|
81
|
+
let dir;
|
|
82
|
+
try { dir = realpathSync(profileDir); } catch { dir = profileDir; }
|
|
83
|
+
const parent = dirname(dir);
|
|
84
|
+
if (parent.endsWith(`${sep}profiles`) || parent.endsWith('/profiles')) {
|
|
85
|
+
// <dshHome>/profiles/<name> — 排除直接等于 profiles 的情况
|
|
86
|
+
const name = dir.slice(parent.length + 1);
|
|
87
|
+
if (name && name !== 'profiles') {
|
|
88
|
+
return { dshHome: dirname(parent), profilesDir: parent, profileName: name };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (dir.endsWith(`${sep}profiles`) || dir.endsWith('/profiles')) {
|
|
92
|
+
return { dshHome: dirname(dir), profilesDir: dir, profileName: null };
|
|
93
|
+
}
|
|
94
|
+
return { dshHome: null, profilesDir: null, profileName: null };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 共享镜像目录 `<dshHome>/profiles/node_modules`。
|
|
99
|
+
* 对 `<dshHome>/profiles/<name>` 入参返回其父级镜像;对 `<dshHome>/profiles`
|
|
100
|
+
* 入参返回自身(该目录本身就是镜像根)。无法确定 DSH_HOME 时返回 null。
|
|
101
|
+
*/
|
|
102
|
+
export function resolveSharedMirrorDir(profileDir) {
|
|
103
|
+
const { dshHome } = resolveProfileLayout(profileDir);
|
|
104
|
+
if (!dshHome) return null;
|
|
105
|
+
return join(dshHome, 'profiles', 'node_modules');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 符号链接的解析后目标(相对链接按链接所在目录解析);断链也返回字面目标 */
|
|
109
|
+
export function resolveLinkTarget(linkPath, rawTarget) {
|
|
110
|
+
const abs = isAbsolute(rawTarget) ? rawTarget : resolve(dirname(linkPath), rawTarget);
|
|
111
|
+
try { return realpathSync(abs); } catch { return abs; }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 目标是否落在安装前缀内(前缀为 null 时按“无法判定”返回 null) */
|
|
115
|
+
export function isInsidePrefix(target, prefix) {
|
|
116
|
+
if (!prefix) return null;
|
|
117
|
+
// 两侧都先做 realpath 归一(macOS 的 /var → /private/var 等别名),
|
|
118
|
+
// 否则显式注入的前缀与链接目标可能因路径别名而比较失败——那是误报,不是发现。
|
|
119
|
+
const canon = (p) => {
|
|
120
|
+
try { return realpathSync(p); } catch { return resolve(p); }
|
|
121
|
+
};
|
|
122
|
+
const norm = (p) => (p.endsWith(sep) ? p.slice(0, -1) : p);
|
|
123
|
+
const t = norm(canon(target));
|
|
124
|
+
const p = norm(canon(prefix));
|
|
125
|
+
return t === p || t.startsWith(p + sep);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 从候选 node_modules 目录中解析某个包的 package.json(内部使用) */
|
|
129
|
+
function resolvePkgJson(nmDirs, pkgName) {
|
|
130
|
+
for (const nm of nmDirs) {
|
|
131
|
+
const pj = join(nm, pkgName, 'package.json');
|
|
132
|
+
if (existsSync(pj)) return pj;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const req = requireFrom(nmDirs[0] ? dirname(nmDirs[0]) : process.cwd());
|
|
136
|
+
if (req) return req.resolve(`${pkgName}/package.json`);
|
|
137
|
+
} catch { /* fallthrough */ }
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 读取版本号(内部使用) */
|
|
142
|
+
function readVersion(pkgJsonPath) {
|
|
143
|
+
if (!pkgJsonPath) return null;
|
|
144
|
+
try { return JSON.parse(readFileSync(pkgJsonPath, 'utf8')).version || null; } catch { return null; }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 解析 CLI 实际提供的核心运行时版本(installation closure / 镜像链路)。
|
|
149
|
+
* 返回值同时给出解析路径,便于检查在 detail 中如实标注来源。
|
|
150
|
+
*/
|
|
151
|
+
export function resolveCoreVersions(profileDir) {
|
|
152
|
+
const nmDirs = [
|
|
153
|
+
join(profileDir, 'node_modules'),
|
|
154
|
+
resolveSharedMirrorDir(profileDir) ? join(resolveSharedMirrorDir(profileDir)) : null,
|
|
155
|
+
process.env.DSH_HOME ? join(process.env.DSH_HOME, 'profiles', 'node_modules') : null,
|
|
156
|
+
].filter(Boolean);
|
|
157
|
+
|
|
158
|
+
const out = {};
|
|
159
|
+
for (const name of ['@deepseek-ai/dsh', '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-tools', '@deepseek-ai/dsh-session', '@deepseek-ai/dsh-agent-loop']) {
|
|
160
|
+
const pj = resolvePkgJson(nmDirs, name);
|
|
161
|
+
out[name] = { version: readVersion(pj), path: pj };
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** DSH_HOME:仅凭明确信号推断,绝不猜测用户家目录内容 */
|
|
167
|
+
export function resolveDshHome(profileDir) {
|
|
168
|
+
const layout = resolveProfileLayout(profileDir);
|
|
169
|
+
if (layout.dshHome) return layout.dshHome;
|
|
170
|
+
if (process.env.DSH_HOME) return process.env.DSH_HOME;
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ================= semver ================= */
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 尽力从安装树里取到真正的 node-semver(CLI 依赖闭包里有它,7.x)。
|
|
178
|
+
* 取不到时返回 null——调用方必须据此降级为「无法判定」,不得自行近似断言不兼容。
|
|
179
|
+
*/
|
|
180
|
+
export function resolveSemver(profileDir, cliRoots = []) {
|
|
181
|
+
const bases = [];
|
|
182
|
+
const mirror = profileDir ? resolveSharedMirrorDir(profileDir) : null;
|
|
183
|
+
if (mirror) bases.push(join(mirror, '@deepseek-ai', 'dsh-base'));
|
|
184
|
+
if (profileDir) bases.push(join(profileDir, 'node_modules', '@deepseek-ai', 'dsh-base'));
|
|
185
|
+
for (const r of cliRoots) bases.push(join(r, 'node_modules', '@deepseek-ai', 'dsh-base'));
|
|
186
|
+
for (const dir of [...bases, ...(profileDir ? [profileDir] : [])]) {
|
|
187
|
+
const req = requireFrom(dir);
|
|
188
|
+
if (!req) continue;
|
|
189
|
+
try {
|
|
190
|
+
const mod = req('semver');
|
|
191
|
+
if (mod && typeof mod.satisfies === 'function') return { mod, from: dir };
|
|
192
|
+
} catch { /* 继续找 */ }
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const splitVer = (s) => String(s).trim().replace(/^v/, '').split('-')[0].split('.').map(n => Number(n) || 0);
|
|
198
|
+
const isPre = (s) => String(s).includes('-');
|
|
199
|
+
|
|
200
|
+
function cmpVer(a, b) {
|
|
201
|
+
const A = splitVer(a), B = splitVer(b);
|
|
202
|
+
for (let i = 0; i < 3; i++) if (A[i] !== B[i]) return A[i] < B[i] ? -1 : 1;
|
|
203
|
+
const pa = isPre(a), pb = isPre(b);
|
|
204
|
+
if (pa !== pb) return pa ? -1 : 1;
|
|
205
|
+
if (pa && pb && a !== b) return a < b ? -1 : 1;
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 无 node-semver 时的保守近似:只覆盖 `=` / 比较符 / `~` / `^` + `||` 的字面子集。
|
|
211
|
+
* @returns {boolean|null} null = 无法判定(绝不当成 false——那会凭空造出不兼容结论)
|
|
212
|
+
*/
|
|
213
|
+
export function approxSatisfies(v, range) {
|
|
214
|
+
const V = String(v).trim();
|
|
215
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(V)) return null;
|
|
216
|
+
let anyParseable = false;
|
|
217
|
+
for (const alt of String(range).split('||').map(s => s.trim()).filter(Boolean)) {
|
|
218
|
+
const m = /^(\^|~|>=|<=|>|<|=)?\s*(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\s*$/.exec(alt);
|
|
219
|
+
if (!m) continue;
|
|
220
|
+
anyParseable = true;
|
|
221
|
+
const op = m[1] || '=', B = m[2];
|
|
222
|
+
let inBase = false;
|
|
223
|
+
const [vA, vB, vC] = splitVer(V);
|
|
224
|
+
if (op === '=') inBase = cmpVer(V, B) === 0;
|
|
225
|
+
else if (op === '>=') inBase = cmpVer(V, B) >= 0;
|
|
226
|
+
else if (op === '>') inBase = cmpVer(V, B) > 0;
|
|
227
|
+
else if (op === '<=') inBase = cmpVer(V, B) <= 0;
|
|
228
|
+
else if (op === '<') inBase = cmpVer(V, B) < 0;
|
|
229
|
+
else if (op === '~') {
|
|
230
|
+
const [bA, bB] = splitVer(B);
|
|
231
|
+
inBase = vA === bA && vB === bB && cmpVer(V, B) >= 0;
|
|
232
|
+
} else if (op === '^') {
|
|
233
|
+
const [bA, bB, bC] = splitVer(B);
|
|
234
|
+
if (bA > 0) inBase = vA === bA && cmpVer(V, B) >= 0;
|
|
235
|
+
else if (bB > 0) inBase = vA === 0 && vB === bB && cmpVer(V, B) >= 0;
|
|
236
|
+
else inBase = vA === 0 && vB === 0 && cmpVer(V, B) >= 0 && (vC === bC || V === B);
|
|
237
|
+
}
|
|
238
|
+
if (!inBase) continue;
|
|
239
|
+
if (!isPre(V)) return true;
|
|
240
|
+
// 预发布版本:只有与比较符里的预发布 base 同 [major,minor,patch] 才可判定
|
|
241
|
+
if (isPre(B)) {
|
|
242
|
+
const [bA, bB, bC] = splitVer(B);
|
|
243
|
+
if (vA === bA && vB === bB && vC === bC) return true;
|
|
244
|
+
}
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
return anyParseable ? false : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 判定 `version` 是否满足 `range`。
|
|
252
|
+
* @returns {{satisfies: boolean|null, exact: boolean}} satisfies=null 表示无法判定
|
|
253
|
+
*/
|
|
254
|
+
export function checkRange(version, range, semverMod = null) {
|
|
255
|
+
if (!version || !range) return { satisfies: null, exact: false };
|
|
256
|
+
if (semverMod) {
|
|
257
|
+
try {
|
|
258
|
+
return { satisfies: Boolean(semverMod.satisfies(version, range, { includePrerelease: false })), exact: true };
|
|
259
|
+
} catch { return { satisfies: null, exact: false }; }
|
|
260
|
+
}
|
|
261
|
+
return { satisfies: approxSatisfies(version, range), exact: false };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 遍历 profile node_modules 中的候选包目录(含 scoped 包)。
|
|
266
|
+
* 与 SP5/SP8/SP10 的既有语义一致:跳过隐藏目录与 .bin。
|
|
267
|
+
* @returns {Array<{dir: string, name: string}>}
|
|
268
|
+
*/
|
|
269
|
+
export function listPackageDirs(nmDir) {
|
|
270
|
+
const out = [];
|
|
271
|
+
if (!existsSync(nmDir)) return out;
|
|
272
|
+
let entries;
|
|
273
|
+
try { entries = readdirSync(nmDir, { withFileTypes: true }); } catch { return out; }
|
|
274
|
+
for (const entry of entries) {
|
|
275
|
+
if (!entry.name || entry.name.startsWith('.') || entry.name === '.bin') continue;
|
|
276
|
+
// 注意:pnpm 会为包建符号链接,这里必须按“可进入的目录”判断,不能只看 isDirectory()
|
|
277
|
+
const isDirLike = entry.isDirectory() || entry.isSymbolicLink();
|
|
278
|
+
if (!isDirLike) continue;
|
|
279
|
+
if (entry.name.startsWith('@')) {
|
|
280
|
+
const scopeDir = join(nmDir, entry.name);
|
|
281
|
+
let pkgs = [];
|
|
282
|
+
try { pkgs = readdirSync(scopeDir, { withFileTypes: true }); } catch { continue; }
|
|
283
|
+
for (const pkg of pkgs) {
|
|
284
|
+
if (!pkg.name || pkg.name.startsWith('.')) continue;
|
|
285
|
+
if (!pkg.isDirectory() && !pkg.isSymbolicLink()) continue;
|
|
286
|
+
out.push({ dir: join(scopeDir, pkg.name), name: `${entry.name}/${pkg.name}` });
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
out.push({ dir: join(nmDir, entry.name), name: entry.name });
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|