dsh-plugin-tool-management 0.16.6 → 0.18.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/CHANGELOG.md +115 -0
- package/README.md +20 -4
- package/README_EN.md +20 -4
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +59 -0
- package/lib/audit-log.js +6 -2
- package/lib/client.js +1615 -148
- package/lib/compat/capability-specs.js +321 -0
- package/lib/compat/host-probe.js +261 -0
- package/lib/compat/host-roots.js +168 -0
- package/lib/compat/probe-types.js +6 -0
- package/lib/compat/probe.js +54 -714
- package/lib/context-inject-contract.js +114 -0
- package/lib/context-inject-frame.js +147 -0
- package/lib/context-inject-select.js +127 -0
- package/lib/context-inject-surface.js +97 -0
- package/lib/context-inject.js +57 -459
- package/lib/host-names.js +150 -0
- package/lib/http-api.js +229 -0
- package/lib/hub.js +84 -11
- package/lib/imports/parsers.js +40 -15
- package/lib/index.js +235 -843
- package/lib/mcp/inspect-probe.js +199 -0
- package/lib/mcp/loader-token.js +26 -0
- package/lib/mcp/manager.js +267 -558
- package/lib/mcp/patch-yaml.js +207 -40
- package/lib/mcp/secret-guard.js +122 -16
- package/lib/mcp/tool-visibility.js +388 -0
- package/lib/memories/archive-engine.js +163 -16
- package/lib/memories/archive.js +10 -1
- package/lib/memories/discovery.js +261 -0
- package/lib/memories/export-plan.js +42 -0
- package/lib/memories/index-io.js +125 -12
- package/lib/memories/memories-types.js +1 -0
- package/lib/memories/service.js +167 -78
- package/lib/memories/snapshot.js +41 -260
- package/lib/op-registry.js +72 -8
- package/lib/ops/compat.js +17 -2
- package/lib/ops/prompts.js +18 -5
- package/lib/ops/quick-prompts.js +26 -0
- package/lib/ops/scene-records.js +157 -10
- package/lib/ops/scene-sync.js +71 -1
- package/lib/ops/sessions.js +63 -13
- package/lib/ops/snapshot.js +123 -24
- package/lib/ops/trash.js +45 -9
- package/lib/prompts/quick-service.js +211 -0
- package/lib/prompts/service.js +7 -2
- package/lib/request-gate.js +115 -56
- package/lib/scene-linkage.js +184 -0
- package/lib/scene-prompt-sync.js +46 -7
- package/lib/scenes/candidates.js +21 -3
- package/lib/sessions/archive-schemas.js +121 -0
- package/lib/sessions/path-key.js +46 -0
- package/lib/sessions/workspace.js +45 -168
- package/lib/settings-sidecars.js +143 -0
- package/lib/skills/core-types.js +8 -0
- package/lib/skills/core-util.js +81 -0
- package/lib/skills/core.js +97 -2297
- package/lib/skills/dir-watch.js +71 -0
- package/lib/skills/frontmatter.js +96 -0
- package/lib/skills/homes.js +14 -0
- package/lib/skills/import-pipeline.js +231 -0
- package/lib/skills/manager-state.js +800 -0
- package/lib/skills/official.js +269 -0
- package/lib/skills/provider-registration.js +89 -0
- package/lib/skills/service.js +4 -234
- package/lib/skills/skill-roots.js +287 -0
- package/lib/skills/skill-trash.js +265 -0
- package/lib/skills/system-trash.js +97 -0
- package/lib/skills/upload-pipeline.js +374 -0
- package/lib/slash-settings.js +25 -0
- package/lib/subagents/persona-parse.js +312 -0
- package/lib/subagents/persona-types.js +5 -0
- package/lib/subagents/persona-write.js +93 -0
- package/lib/subagents/run-provider.js +159 -0
- package/lib/subagents/service.js +20 -639
- package/lib/subagents/tool-filter.js +98 -0
- package/lib/token-config.js +240 -0
- package/lib/tool-table-switch.js +163 -0
- package/lib/tools/scene.js +40 -9
- package/package.json +3 -2
package/lib/host-names.js
CHANGED
|
@@ -6,6 +6,156 @@
|
|
|
6
6
|
* 少改一处就是「开关点了没反应」或「列表凭空少一行」。档案目录名同理。
|
|
7
7
|
*/
|
|
8
8
|
export const MCP_CLIENT_MODULE = '@deepseek-ai/dsh-mcp-client';
|
|
9
|
+
/**
|
|
10
|
+
* MCP 工具在模型工具表里的**命名前缀**:`mcp__<serverName>__<toolName>`。
|
|
11
|
+
*
|
|
12
|
+
* 这是宿主侧(MCP client 注册工具时)定的字符串契约,插件里十余处按它拼、按它解析
|
|
13
|
+
* (生成侧 `disabledToolNames`、执行侧 `isToolDisabledIn`、列表侧 `liveNames`、工具计数)。
|
|
14
|
+
* 抽到这里是为了"官方改了前缀"只需改一处 —— 但**改一处不够**:前缀一漂,停用表 / 门禁 /
|
|
15
|
+
* 工具计数会一起**静默**失明(守卫以为"这工具不在我的表里"于是放行)。
|
|
16
|
+
*
|
|
17
|
+
* 前向兼容的诚实边界(2026-09-30 审查 §5 F6):这个契约**没有能力探测可做** ——
|
|
18
|
+
* `tools.schemas()` 只回 `name`/`description`(官方源码明确它「NEVER sent to the model」
|
|
19
|
+
* 的其余字段不暴露),拿不到"这条工具属于哪个模块",所以无法独立判定一条 schema 是不是
|
|
20
|
+
* MCP 工具。唯一的可用线索是"工具名以 `mcp` 开头但不是本前缀"(官方改前缀多半会保留
|
|
21
|
+
* `mcp` 词根),据此在 `mcpmListView` 里做一次 inform-only 上报;换成完全不同的词根就
|
|
22
|
+
* 只能靠宿主升级清单人工核对。真要靠探测兜住,得先有官方暴露工具归属的接口。
|
|
23
|
+
*/
|
|
24
|
+
export const MCP_TOOL_PREFIX = 'mcp__';
|
|
25
|
+
/**
|
|
26
|
+
* 一个工具名算不算「MCP 前缀漂移」的**证据**:以 `mcp` 开头,但词根后面**不是** `_`。
|
|
27
|
+
*
|
|
28
|
+
* 为什么要这么窄(2026-09-30 审查 §5 F6 实现时的自查):本插件自己的工具就叫
|
|
29
|
+
* `mcp_manager_list` / `mcp_manager_…` —— 它们同样「以 mcp 开头但不是 `mcp__`」,把它们
|
|
30
|
+
* 算成证据会让这条上报在**每台机器上**误报(只要当前没有 MCP 服务器在跑)。而真正的前缀
|
|
31
|
+
* 漂移(`mcp:` / `mcp-` / `mcpX`)恰恰表现为「词根后面的分隔符变了」这一种形态。
|
|
32
|
+
* `mcp___` 这类改法会落进「没有证据」—— 宁可不报,也不给用户一个错的诊断。
|
|
33
|
+
*/
|
|
34
|
+
export function looksLikeMcpPrefixDrift(name) {
|
|
35
|
+
return /^mcp[^_]/.test(String(name == null ? '' : name));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* MCP 工具全名 `mcp__<server>__<tool>` 的**唯一切分口径**。
|
|
39
|
+
*
|
|
40
|
+
* 为什么必须只有一个:同一个全名在插件里有两处要还原成 `(server, tool)` —— 执行侧守卫
|
|
41
|
+
* (`mcp/manager.ts` 的 `isToolDisabledIn`,决定「这个工具还能不能调」)与显示侧
|
|
42
|
+
* (`index.ts` 的 `toolKeyParts`,决定界面上的勾选键 `server/tool`)。它们此前**各写一套规则**:
|
|
43
|
+
* 守卫取「最长已知 serverName 前缀」,显示侧取「最后一个 `__`」。规则不同 = 同一份数据两边
|
|
44
|
+
* 结论不同;而 `serverName` 的校验(`/^[A-Za-z0-9_-]{1,32}$/`)**允许含 `__`**,所以这个歧义
|
|
45
|
+
* 真的能踩到(2026-09-30 审查 N1)。现在两处都走本函数。
|
|
46
|
+
*
|
|
47
|
+
* **残余歧义(如实标注)**:`mcp__a__b__t` 既可能是「server=a, tool=b__t」也可能是
|
|
48
|
+
* 「server=a__b, tool=t」—— 光看全名无法判定,必须有候选 serverName 集合才谈得上「切对」。
|
|
49
|
+
* 所以集合是**必填参数**,且两个调用点的集合**天然不同**,这不是漏改:
|
|
50
|
+
*
|
|
51
|
+
* * 守卫问的是「这个工具被停用了吗」,权威依据就是**停用表**,集合 = 停用表的键。
|
|
52
|
+
* 把集合放宽到「全部已配置服务器」会**漏拦**:设 `a` 的 `b__c` 被停用、而 `a__b` 只是
|
|
53
|
+
* 配置了、没停用任何工具,全名 `mcp__a__b__c` 放宽后最长匹配取 `a__b`(tool 只剩 `c`)
|
|
54
|
+
* → 查 `a__b` 未命中 → 放行一个真的被停用的工具(fail-open)。歧义时「偏向停用表里有的
|
|
55
|
+
* 那个服务器」才是守卫该有的 fail-closed 方向。
|
|
56
|
+
* * 显示侧问的是「这个键该显示成哪个服务器下的哪个工具」,依据是**全部已知的真实服务器名**
|
|
57
|
+
* ——补丁文件里的 `serverName`(权威、与切分结果无关)+ 停用表键 + 已知工具缓存键,
|
|
58
|
+
* 集合 = 三者并集(见 `serverNameCandidates`)。补丁那份必须在内:另外两份都是插件自己
|
|
59
|
+
* 写出来的(勾选键 / 上次切分的产物),单靠它们会在「首次遇到含 `__` 的名字」时切错,
|
|
60
|
+
* 而错键一旦进集合就会**锁死**错误结论(不自愈)。
|
|
61
|
+
*
|
|
62
|
+
* 两者在「服务器名含 `__`」时仍可能给出不同答案 —— 这是 `__` 当分隔符的固有歧义,靠集合
|
|
63
|
+
* 无法根除;根除要从入口拒绝含 `__` 的 serverName(契约层收紧,本轮明确不做)。`certain`
|
|
64
|
+
* 就是给这件事留的出口。
|
|
65
|
+
*
|
|
66
|
+
* 候选里取**最长**的那个(短候选会把 tool 截断成 `b__c`)—— 真实服务器名比「按分隔符猜」可信,
|
|
67
|
+
* 这也是 0.16.7 那版守卫选「最长已知前缀」的同一个理由。
|
|
68
|
+
* @param fullName - 形如 `mcp__<server>__<tool>` 的工具全名。
|
|
69
|
+
* @param serverNames - 候选 serverName 集合(见上;传空集合等于只走兜底)。
|
|
70
|
+
* @returns 切分结果;不是 MCP 工具名、或切不出非空 server/tool 时返回 null。
|
|
71
|
+
*/
|
|
72
|
+
export function splitMcpToolName(fullName, serverNames) {
|
|
73
|
+
const name = String(fullName == null ? '' : fullName);
|
|
74
|
+
if (!name.startsWith(MCP_TOOL_PREFIX))
|
|
75
|
+
return null;
|
|
76
|
+
const rest = name.slice(MCP_TOOL_PREFIX.length);
|
|
77
|
+
let matched = null;
|
|
78
|
+
if (serverNames) {
|
|
79
|
+
for (const candidate of serverNames) {
|
|
80
|
+
if (typeof candidate !== 'string' || candidate === '')
|
|
81
|
+
continue;
|
|
82
|
+
// tool 必须非空,否则 `mcp__a__` 会被切出空 tool
|
|
83
|
+
if (rest.length <= candidate.length + 2)
|
|
84
|
+
continue;
|
|
85
|
+
if (!rest.startsWith(candidate + '__'))
|
|
86
|
+
continue;
|
|
87
|
+
if (matched === null || candidate.length > matched.length)
|
|
88
|
+
matched = candidate;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (matched !== null)
|
|
92
|
+
return { key: matched + '/' + rest.slice(matched.length + 2), server: matched, tool: rest.slice(matched.length + 2), certain: true };
|
|
93
|
+
// 一个候选都没命中 → 按最后一个 `__` 兜底(显示侧原先的口径),并如实标注「不确定」。
|
|
94
|
+
// 守卫侧不需要特别处理:猜出来的 server 不在停用表里,查表结果与「没有候选命中」一致。
|
|
95
|
+
const i = rest.lastIndexOf('__');
|
|
96
|
+
if (i <= 0)
|
|
97
|
+
return null;
|
|
98
|
+
const server = rest.slice(0, i);
|
|
99
|
+
const tool = rest.slice(i + 2);
|
|
100
|
+
return server && tool ? { key: server + '/' + tool, server, tool, certain: false } : null;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 显示侧切分的候选 serverName 集合:若干张「serverName → 任意」的表取键并集,
|
|
104
|
+
* 或直接给一份名字清单(`Iterable<string>`)。
|
|
105
|
+
*
|
|
106
|
+
* 为什么要这个函数:显示侧有多个调用点(`mcpmList` / `serverKnownTools` / 场景档案的
|
|
107
|
+
* `toolStates`),各自手写一遍 `Object.keys(...)` 迟早又会分叉 —— 而「集合不同」正是切分
|
|
108
|
+
* 结论不同的原因之一。集合的来源必须是**同一批**:补丁文件里的真实 serverName(唯一与切分
|
|
109
|
+
* 结果无关的来源)+ 停用表 + 已知工具缓存。前两者是插件自己写出来的,单靠它们会**循环论证**
|
|
110
|
+
* (见 `configuredServerNames` 的文档)。
|
|
111
|
+
*/
|
|
112
|
+
export function serverNameCandidates(...sources) {
|
|
113
|
+
const out = new Set();
|
|
114
|
+
for (const source of sources) {
|
|
115
|
+
if (!source || typeof source !== 'object')
|
|
116
|
+
continue;
|
|
117
|
+
// 名字清单(数组 / Set)优先按可迭代处理;`Record` 不可迭代,走下面的键枚举。
|
|
118
|
+
if (typeof source[Symbol.iterator] === 'function') {
|
|
119
|
+
for (const name of source)
|
|
120
|
+
if (typeof name === 'string' && name)
|
|
121
|
+
out.add(name);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
for (const name of Object.keys(source))
|
|
125
|
+
if (name)
|
|
126
|
+
out.add(name);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* 候选 serverName 里有没有「`__` 说不清」的形状 —— N2 的判据(2026-09-30 审查)。
|
|
132
|
+
*
|
|
133
|
+
* 为什么要有它:`splitMcpToolName` 能在一批名字里选出**最长**的那个,但"选对"这件事本身
|
|
134
|
+
* 无法自证 —— 名字一旦含 `__`,「server 是 `a`、tool 是 `b__c`」与「server 是 `a__b`、tool 是
|
|
135
|
+
* `c`」在全名上完全同形。宿主自己的解析口径(取第一个 `__`)与展示口径(取最后一个)也不一致,
|
|
136
|
+
* 所以这不是"谁写错了",是分隔符选得不合适。既不能改(改 serverName 校验会把存量服务器从
|
|
137
|
+
* "能管"变成"管不了",见计划 §4 未决 1),那就**出声**:这两种形状一出现就上报,让用户知道
|
|
138
|
+
* 界面上的勾选可能落不到停用表里那条键上。
|
|
139
|
+
*
|
|
140
|
+
* **判据必须一条都不误报** —— 与 `looksLikeMcpPrefixDrift` 同一条纪律:没有这两种形状时
|
|
141
|
+
* 必须返回空,否则这条上报会变成每台机器都亮的噪声,等于没有。
|
|
142
|
+
* 名字不含 `__` 时,切分结果与"取第一个 `__`"、"取最后一个 `__`"三者一致,歧义不存在。
|
|
143
|
+
*/
|
|
144
|
+
export function ambiguousServerNames(names) {
|
|
145
|
+
const all = [...new Set(names)].filter((n) => typeof n === 'string' && n !== '');
|
|
146
|
+
const withSeparator = all.filter((n) => n.includes('__')).sort();
|
|
147
|
+
const prefixPairs = [];
|
|
148
|
+
for (const short of all) {
|
|
149
|
+
for (const long of all) {
|
|
150
|
+
// `short` 是 `long` 的 `__` 前缀:`mcp__<short>__<long.slice(...)>` 与
|
|
151
|
+
// `mcp__<long>__<剩下的>` 同形。`all` 已去重,所以每对只会出现一次。
|
|
152
|
+
if (short !== long && long.startsWith(short + '__'))
|
|
153
|
+
prefixPairs.push([short, long]);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
prefixPairs.sort((a, b) => (a[0] + ' ' + a[1] < b[0] + ' ' + b[1] ? -1 : a[0] + ' ' + a[1] > b[0] + ' ' + b[1] ? 1 : 0));
|
|
157
|
+
return { withSeparator, prefixPairs };
|
|
158
|
+
}
|
|
9
159
|
/** `ensurePaths()` 的档案探测顺序(先 web 后 headless);都不在才退到「任意带 patch 的档案」。 */
|
|
10
160
|
export const PROFILE_CANDIDATES = ['web', 'headless'];
|
|
11
161
|
/** 探测不到任何档案时的兜底目录名(历史上的默认档案)。 */
|
package/lib/http-api.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { noteRuntime } from './compat/runtime-notes.js';
|
|
2
|
+
import { recordOpAudit } from './audit-log.js';
|
|
3
|
+
import { isReadCall } from './op-registry.js';
|
|
4
|
+
import { TOKEN_CODE_BAD, TOKEN_MSG, fenceRejection, secretOpRejection } from './http-fence.js';
|
|
5
|
+
export function installHttpApi(deps) {
|
|
6
|
+
// ---------- HTTP API route (UI half), registered defensively ----------
|
|
7
|
+
if (deps.webServer) {
|
|
8
|
+
// Cap request bodies (88 MiB): skill-upload carries Base64 folder/ZIP
|
|
9
|
+
// payloads (64 MiB of raw content). Reachability is decided by
|
|
10
|
+
// fenceRejection() below — the host's Host/Origin + browser-session fence
|
|
11
|
+
// (or a correct explicit token). `config.maxBodyBytes` overrides the cap
|
|
12
|
+
// (tests use a small value).
|
|
13
|
+
const configuredMaxBody = Number(deps.config?.maxBodyBytes);
|
|
14
|
+
// 默认 88 MiB:够塞下一份带多张大附件(图片 / PDF)的技能或记忆打包体,又不至于让
|
|
15
|
+
// 一个失控的客户端把进程内存吃光。`config.maxBodyBytes` 可覆盖(测试用小值)。
|
|
16
|
+
const DEFAULT_MAX_BODY_BYTES = 88 * 1024 * 1024;
|
|
17
|
+
const MAX_BODY = Number.isFinite(configuredMaxBody) && configuredMaxBody > 0 ? configuredMaxBody : DEFAULT_MAX_BODY_BYTES;
|
|
18
|
+
const readBody = (req) => new Promise((resolve, reject) => {
|
|
19
|
+
// 先看 content-length:注定超限的请求在**读第一个字节之前**就拒掉。以前只能边收边数,
|
|
20
|
+
// 一个 5 GB 的上传会先让我们缓冲满 88 MiB 才回错。读取流照旧 drain(一个字节都不留),
|
|
21
|
+
// 否则响应写完后服务端会直接拆连接,客户端可能在上传中途拿到网络错误而不是下面这句 JSON。
|
|
22
|
+
// 错误消息必须含 `body too large` —— 后面的 catch 靠这个子串分流。
|
|
23
|
+
const declared = Number(req.headers && req.headers['content-length']);
|
|
24
|
+
if (Number.isFinite(declared) && declared > MAX_BODY) {
|
|
25
|
+
const drain = req.resume;
|
|
26
|
+
if (typeof drain === 'function')
|
|
27
|
+
drain.call(req);
|
|
28
|
+
reject(new Error('request body too large'));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// Accumulate Buffers and decode ONCE at the end: decoding each chunk
|
|
32
|
+
// separately corrupts multi-byte UTF-8 characters that straddle a
|
|
33
|
+
// chunk boundary (e.g. long Chinese skill bodies).
|
|
34
|
+
const chunks = [];
|
|
35
|
+
let size = 0;
|
|
36
|
+
req.on('data', (c) => {
|
|
37
|
+
const buf = Buffer.isBuffer(c) ? c : Buffer.from(String(c));
|
|
38
|
+
size += buf.length;
|
|
39
|
+
if (size > MAX_BODY) {
|
|
40
|
+
reject(new Error('request body too large'));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
chunks.push(buf);
|
|
44
|
+
});
|
|
45
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
46
|
+
req.on('error', reject);
|
|
47
|
+
});
|
|
48
|
+
// 宿主 BrowserAuth 栅栏。宿主的鉴权**只包在它自己注册的路由上**——dsh-host-webserver
|
|
49
|
+
// 没有全局中间件,既定约定是每个注册方自己调 connection.requestRejection(req):
|
|
50
|
+
// 它先做 Host/Origin 栅栏(Host 必须是回环或 deployment 派生的 LAN IP 字面量,
|
|
51
|
+
// 这是 DNS rebinding 唯一伪造不了的头),再叠加 browser-session cookie 鉴权。
|
|
52
|
+
// 本插件此前只查公开常量 x-dsh-plugin 与自比对(Origin.host === Host)的 "同源",
|
|
53
|
+
// 两者都挡不住 rebind 后的网页或本机任意进程 —— 等于没鉴权。现在优先走宿主栅栏。
|
|
54
|
+
const connection = (() => {
|
|
55
|
+
try {
|
|
56
|
+
return deps.ctx.get('connection');
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
})();
|
|
62
|
+
try {
|
|
63
|
+
// ctx.effect wires the route's disposer into this plugin's scope, so an
|
|
64
|
+
// unload (HMR removal, disable, update) unregisters the route — the
|
|
65
|
+
// documented cleanup contract (webServer.register does not auto-scope).
|
|
66
|
+
deps.ctx.effect(() => deps.webServer.register({
|
|
67
|
+
kind: 'exact',
|
|
68
|
+
path: '/dsh-plugin-tool-management/api',
|
|
69
|
+
handler: async (req, res) => {
|
|
70
|
+
// 鉴权顺序:POST-only → 插件门禁头(防误触,不是凭证)→ 宿主栅栏
|
|
71
|
+
// (Host/Origin + browser-session cookie)→ 可选访问令牌。令牌是本地工具与
|
|
72
|
+
// LAN 部署的逃生门:**令牌正确即视为已授权**,可越过浏览器鉴权。
|
|
73
|
+
//
|
|
74
|
+
// 「未配置令牌时」别读成"仍有门禁":下面的写 op 判据是
|
|
75
|
+
// `if (TOKEN && WRITE_OPS.has(op))` —— `TOKEN === ''` 时整条短路,79 个写 op
|
|
76
|
+
// 只剩 `fenceRejection` 一道。而它自身有两种形态:宿主栅栏在场时含 cookie 鉴权;
|
|
77
|
+
// 栅栏缺席或抛错时退回本地判定,**只查回环 Host 与(若给了)Origin 自比对**,
|
|
78
|
+
// 没有 cookie 那一层 —— 本机任意进程都能调写 op。所以准确说法是:
|
|
79
|
+
// 未配置令牌时不再有「**无栅栏**放行」的缺口,但进程级边界只有配了令牌才有。
|
|
80
|
+
const hdr = (name) => {
|
|
81
|
+
const v = req.headers?.[name];
|
|
82
|
+
return Array.isArray(v) ? v[0] ?? '' : v ?? '';
|
|
83
|
+
};
|
|
84
|
+
if (String(req.method || 'POST').toUpperCase() !== 'POST') {
|
|
85
|
+
res.writeHead(405, { 'content-type': 'application/json' });
|
|
86
|
+
res.end(JSON.stringify({ ok: false, error: 'method not allowed' }));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (hdr('x-dsh-plugin') !== 'dsh-plugin-tool-management') {
|
|
90
|
+
res.writeHead(403, { 'content-type': 'application/json' });
|
|
91
|
+
res.end(JSON.stringify({ ok: false, error: 'missing plugin gate header' }));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// 「本次带的这串对不对」比的是**存量**令牌:关掉令牌功能之后仍然要凭它才能开回来、
|
|
95
|
+
// 换掉、或看明文(要求 9)。
|
|
96
|
+
const tokenAccepted = deps.CONFIG_TOKEN !== '' && deps.tokenMatches(hdr('x-dsh-token'));
|
|
97
|
+
// 验过就置闩(本次进程一次即可)。`agent/pre-step` 的令牌门禁读的就是它 ——
|
|
98
|
+
// 那一边看不到请求头,只能由这里告诉它"这台机器已经有人验过令牌了"。
|
|
99
|
+
if (tokenAccepted)
|
|
100
|
+
deps.markAccepted();
|
|
101
|
+
if (!tokenAccepted) {
|
|
102
|
+
const fence = fenceRejection(req, connection);
|
|
103
|
+
if (fence) {
|
|
104
|
+
res.writeHead(fence.status, { 'content-type': 'application/json' });
|
|
105
|
+
res.end(JSON.stringify({ ok: false, error: fence.error }));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
110
|
+
try {
|
|
111
|
+
let payload = {};
|
|
112
|
+
try {
|
|
113
|
+
payload = JSON.parse((await readBody(req)) || '{}');
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
if (String(e?.message).includes('body too large')) {
|
|
117
|
+
res.end(JSON.stringify({ ok: false, error: '请求体过大' }));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
/* otherwise fall through with {} */
|
|
121
|
+
}
|
|
122
|
+
const op = String(payload.op || '');
|
|
123
|
+
// 敏感 op(会吐明文密钥与完整配置):**配了令牌就必须带对的**,没配令牌则不设防
|
|
124
|
+
// (判定与理由见 http-fence.ts 的 secretOpRejection —— 用户裁定 2026-09-19:
|
|
125
|
+
// 没配令牌时那道门没有出路,用户无从填一个不存在的令牌)。
|
|
126
|
+
// 之前这里只查"有没有 Origin 头",而 token 为空时它形同虚设 —— 任何能
|
|
127
|
+
// 打开 GUI 的浏览器都拿得到全部密钥原文。
|
|
128
|
+
if (deps.SENSITIVE_OPS.has(op)) {
|
|
129
|
+
// 明文门禁看的是**存量**令牌(CONFIG_TOKEN 而不是生效值 TOKEN):关掉令牌
|
|
130
|
+
// 功能之后仍然"要凭存量令牌才给明文"—— 那条路是通的(填对就能看),
|
|
131
|
+
// 与"从来没配过"不同。
|
|
132
|
+
const gate = secretOpRejection({ tokenConfigured: deps.CONFIG_TOKEN !== '', tokenAccepted });
|
|
133
|
+
if (gate) {
|
|
134
|
+
res.end(JSON.stringify({ ok: false, error: gate.error, code: gate.code }));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// 「读改写」两态 op(`inject-settings` / `tool-table` / `scene-settings` /
|
|
139
|
+
// `mcpm-settings`):同一个名字既读又写,于是整条 op 被列进 WRITE_OPS —— 读侧也
|
|
140
|
+
// 一起被拦,界面表现是"没填令牌时整块设置消失、填了还要刷新才出现"(2026-09-19
|
|
141
|
+
// 用户报的)。判据是**这次调用要不要写**,不是 op 名在不在名单里。
|
|
142
|
+
// 判据本身在登记表(`writeWhen`),这里不再手写一份 —— 手写那份只认 `set:true`,
|
|
143
|
+
// 于是 `tool-table` 的 `presetSave` / `presetDelete` 被当成读放行,配了令牌也能
|
|
144
|
+
// 只凭宿主栅栏改侧车(审查 F2,0.17.0 修)。
|
|
145
|
+
const readOnlyCall = isReadCall(op, payload.args);
|
|
146
|
+
if (deps.TOKEN && deps.WRITE_OPS.has(op) && !readOnlyCall && !deps.tokenMatches(hdr('x-dsh-token'))) {
|
|
147
|
+
// 文案与明文门禁同源(http-fence.ts),code 也统一 —— 界面据此在最右侧挂
|
|
148
|
+
// 「去填令牌」跳转按钮。这条错误会出现在**任意**页面,而入口只有一个。
|
|
149
|
+
res.end(JSON.stringify({ ok: false, code: TOKEN_CODE_BAD, error: TOKEN_MSG }));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// 令牌状态(只读,**不需要**令牌本身):回答"宿主配没配"、"本次带的这串对不对"、
|
|
153
|
+
// "配置文件里的改动是不是还没重启生效"、"本次进程的标识"。就地回答而不是进 handlers 表:
|
|
154
|
+
// 前者的答案来自本次请求头与进程状态,而后者的 handlers 只拿得到 args。
|
|
155
|
+
// 不返回令牌、也不返回它的任何片段 —— 泄给同源页面等于把最后一道门交出去。
|
|
156
|
+
if (op === 'token-status') {
|
|
157
|
+
const state = await deps.tokenConfigState();
|
|
158
|
+
res.end(JSON.stringify({
|
|
159
|
+
ok: true,
|
|
160
|
+
// 「宿主配置了令牌」= 配置里**有**值(关掉也算有:随时能开回来)。
|
|
161
|
+
hostConfigured: deps.CONFIG_TOKEN !== '',
|
|
162
|
+
// 当前进程里令牌功能是开还是关(关掉 = 写操作不要求令牌)。
|
|
163
|
+
disabled: deps.TOKEN_DISABLED,
|
|
164
|
+
// 「当前进程里令牌**在生效**」= 写操作要不要令牌。关掉或没配都是 false。
|
|
165
|
+
// 界面据此判断"填令牌"这件事有没有意义(没生效时填了也没人验,还会把
|
|
166
|
+
// 一句无处可填的提示挂在屏幕上 —— 用户 2026-09-19 报的就是这个)。
|
|
167
|
+
active: deps.TOKEN !== '',
|
|
168
|
+
accepted: tokenAccepted,
|
|
169
|
+
bootId: deps.BOOT_ID,
|
|
170
|
+
configHasToken: state.configHasToken,
|
|
171
|
+
configDisabled: state.configDisabled,
|
|
172
|
+
configFound: state.found,
|
|
173
|
+
pendingRestart: state.pendingRestart,
|
|
174
|
+
}));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// 设置 / 关闭令牌:同样就地处理(理由见 tokenConfigure 的注释 —— 也正因为不进
|
|
178
|
+
// handlers,模型侧没有任何工具能间接关掉它)。
|
|
179
|
+
if (op === 'token-configure') {
|
|
180
|
+
res.end(JSON.stringify(deps.withPatchWarnings(await deps.tokenConfigure(payload.args || {}, tokenAccepted))));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// 「清除令牌」的配套:把「本次启动已验过」的闩重新挂上。没有它,解锁一次之后
|
|
184
|
+
// 清除令牌 + 强刷新,浏览器里已无令牌,闩却挂着 —— pre-step 门禁读闩
|
|
185
|
+
// (tokenGateActive),整个启动期都放行对话(2026-09-19 用户实测的漏洞)。
|
|
186
|
+
// 不要求凭证:它只会收紧(要求重新验令牌),给不了任何人任何权限;同样不进
|
|
187
|
+
// handlers —— 模型不该有能力拨这颗闩。
|
|
188
|
+
if (op === 'token-unaccept') {
|
|
189
|
+
deps.unmarkAccepted();
|
|
190
|
+
res.end(JSON.stringify({ ok: true }));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const fn = deps.handlers[op];
|
|
194
|
+
if (!fn) {
|
|
195
|
+
res.end(JSON.stringify({ ok: false, error: '未知操作: ' + op }));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const result = await fn(payload.args || {});
|
|
199
|
+
// 「最近改动」流水:来源 = 面板(这条 HTTP 路由是界面唯一的入口)。
|
|
200
|
+
// 「这次算不算写」的判据在 recordOpAudit 里(与写门禁同源:登记表的 `writeWhen`)
|
|
201
|
+
// —— 不在这里再传一次 `readOnlyCall`,两处各判一次迟早会分家。
|
|
202
|
+
recordOpAudit(op, payload.args || {}, 'panel', result);
|
|
203
|
+
res.end(JSON.stringify(deps.withPatchWarnings(result === undefined ? { ok: true } : result)));
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
res.end(JSON.stringify({ ok: false, error: deps.message(e) }));
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
}), 'dsh-plugin-tool-management: api route');
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
// A registration failure must never take down the whole entry: log and continue.
|
|
213
|
+
console.error('[dsh-plugin-tool-management] webServer route registration failed:', deps.message(e));
|
|
214
|
+
// 结构性弱点(2026-09-30 审查 §5 F4):compat-status **本身就走这条路由**,所以路由没注册上
|
|
215
|
+
// 时兼容页也拿不到这条降级行 —— 面板是**整页失联**,不是"少一行"。这里至少把它写进运行期
|
|
216
|
+
// 上报通道(日志里必然有一条,进程内可查),并在 README 里写明"面板空白先看宿主日志"。
|
|
217
|
+
// 之所以不是"必然可见"的告警:可见的通道就是这条路由,它自己挂了就没得显示。
|
|
218
|
+
noteRuntime({
|
|
219
|
+
id: 'web-server-route',
|
|
220
|
+
label: 'HTTP 路由注册',
|
|
221
|
+
kind: 'write',
|
|
222
|
+
fallback: 'inform-only',
|
|
223
|
+
detail: '向宿主 webServer 注册 API 路由失败(' + deps.message(e) + '):插件面板与全部 HTTP op 不可用(兼容页也走这条路由,所以它也打不开)。宿主日志里有同一条记录;模型工具不受影响。',
|
|
224
|
+
detailKey: 'web-server-route.register-failed',
|
|
225
|
+
params: { reason: deps.message(e) },
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
package/lib/hub.js
CHANGED
|
@@ -45,6 +45,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from '
|
|
|
45
45
|
import { randomUUID } from 'node:crypto';
|
|
46
46
|
import { basename, dirname, join } from 'node:path';
|
|
47
47
|
import { resolveDshHome } from './skills/core.js';
|
|
48
|
+
import { isSameOrDescendant } from './paths.js';
|
|
48
49
|
/** hub 根目录名(`$DSH_HOME` 下)。 */
|
|
49
50
|
export const HUB_DIR = 'tool-management';
|
|
50
51
|
/** hub 根绝对路径。 */
|
|
@@ -91,7 +92,10 @@ const ROOT_FILE_MOVES = [
|
|
|
91
92
|
];
|
|
92
93
|
/** 宿主 patch 的备份名(`cordis.patch.yml.bak-<时间戳>`)。 */
|
|
93
94
|
export function isPatchBackupName(name) {
|
|
94
|
-
|
|
95
|
+
// 两代时间戳都认(0.17.0 起加了毫秒与进程内序号,见 index.ts 的 backupStamp)。
|
|
96
|
+
// 这里只用于「把散落在 $DSH_HOME / profiles 下的旧备份搬进 hub/backups」—— 新格式备份
|
|
97
|
+
// 本来就直写 hub/backups,但两处谓词保持同一口径,免得将来手工搬进来一份就漏掉。
|
|
98
|
+
return /^cordis\.patch\.yml\.bak-\d{8}-\d{6}(?:\d{6})?$/.test(basename(name));
|
|
95
99
|
}
|
|
96
100
|
/**
|
|
97
101
|
* 布局迁移(同步、幂等、只搬不删、绝不覆盖)。**必须在任何读盘之前调用一次**:
|
|
@@ -241,6 +245,8 @@ const LEGACY_TRASH_KINDS = {
|
|
|
241
245
|
prompts: ['agents-md'],
|
|
242
246
|
subagents: ['agents'],
|
|
243
247
|
scenes: [],
|
|
248
|
+
// 0.18.0 才有的域,没有旧名字要认。
|
|
249
|
+
'quick-prompts': [],
|
|
244
250
|
};
|
|
245
251
|
/** 某一类回收站的根目录:`hub/trash/<kind>-trash`。 */
|
|
246
252
|
export function trashKindDir(kind) {
|
|
@@ -297,10 +303,21 @@ export async function moveToTrash(kind, name, moves, data) {
|
|
|
297
303
|
const id = newTrashId();
|
|
298
304
|
const dir = join(trashKindDir(kind), id);
|
|
299
305
|
const done = [];
|
|
306
|
+
// 入口先过一遍谓词(原先只有恢复侧 `moveOutOfTrash` 校验,删除侧不看):`dest` 最终会写进
|
|
307
|
+
// manifest 的 `files[]`,恢复时按它搬回来 —— 入口放一个非法名进去,等于把失败推给
|
|
308
|
+
// "将来某一天点恢复"(场景回收站那条链就是这么坏掉的)。现在当场拒绝、什么都不搬,
|
|
309
|
+
// 由调用方如实报错(审查 P0-2 / P2-14)。
|
|
310
|
+
for (const move of moves) {
|
|
311
|
+
if (!isValidTrashPayloadPath(move.dest))
|
|
312
|
+
return { ok: false, error: `负载名非法,未搬动任何文件:${move.dest}` };
|
|
313
|
+
}
|
|
300
314
|
try {
|
|
301
315
|
await mkdir(dir, { recursive: true });
|
|
302
316
|
for (const move of moves) {
|
|
303
317
|
const to = join(dir, move.dest);
|
|
318
|
+
// 嵌套负载(`bundle1/bundle1.md`)的父目录不存在时 `rename` 必然失败 —— 那本来不是错,
|
|
319
|
+
// 但接着靠 `cp` 的隐式建父目录兜底是不可依赖的跨平台行为。显式建出来。
|
|
320
|
+
await mkdir(dirname(to), { recursive: true });
|
|
304
321
|
try {
|
|
305
322
|
await rename(move.from, to);
|
|
306
323
|
}
|
|
@@ -309,6 +326,9 @@ export async function moveToTrash(kind, name, moves, data) {
|
|
|
309
326
|
await cp(move.from, to, { recursive: true });
|
|
310
327
|
await rm(move.from, { recursive: true, force: true });
|
|
311
328
|
}
|
|
329
|
+
// 后置断言(与恢复侧同一道):谓词已挡掉 `..`,这里防的是"条目目录里恰好有个链接"。
|
|
330
|
+
if (!isSameOrDescendant(dir, to))
|
|
331
|
+
throw new Error(`负载越出条目目录:${move.dest}`);
|
|
312
332
|
done.push({ from: move.from, to });
|
|
313
333
|
}
|
|
314
334
|
const manifest = {
|
|
@@ -372,20 +392,42 @@ export async function readTrashEntry(kind, id) {
|
|
|
372
392
|
return await readManifest(kind, id);
|
|
373
393
|
}
|
|
374
394
|
/**
|
|
375
|
-
* 条目里的一个负载名是不是"
|
|
395
|
+
* 条目里的一个负载名是不是"一条**安全的相对路径**"(可以含 `/`)。
|
|
396
|
+
*
|
|
397
|
+
* 为什么允许 `/`(2026-09-30,审查 P0-2):场景回收站的负载**天然是目录树** —— 一个场景目录
|
|
398
|
+
* 里的记忆可能是 `<场景>/<名>.md`,也可能是 bundle 形态 `<场景>/<名>/<名>.md` 或任意子目录。
|
|
399
|
+
* 原来这里对任何含 `/` 的名字一律返回 false,于是「含嵌套记忆的场景」删除能成功(`moveToTrash`
|
|
400
|
+
* 侧靠 `cp` 兜底)、恢复**必然失败**,且失败后场景目录已非空 → 再也恢复不了。
|
|
401
|
+
*
|
|
402
|
+
* 判据(与 `paths.ts` 的单段校验同源,只是放宽到多段):
|
|
403
|
+
* - 只认 `/` 作分隔符(反斜杠在 Windows 上等价,留着只会让判据分叉);
|
|
404
|
+
* - 拒绝对路径(前导 `/` `\` 或盘符)与 `..` / `.` / 空段 —— 这是**路径穿越**那一类;
|
|
405
|
+
* - 每段拒绝控制字符、Windows 非法字符 `<>:"|?*`、首尾空白与尾点(Windows 会静默规整它们,
|
|
406
|
+
* 于是"写进去的名字"与"读出来的名字"不再是同一个);
|
|
407
|
+
* - 条目根下的 `manifest.json` 是清单,不许当负载。
|
|
376
408
|
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
* "任意相对路径读源 + 任意绝对目录建目标"的能力(`mkdir(dirname(to))` 会顺手把目录建出来)。
|
|
409
|
+
* 仍然拒绝 `.` 开头的段吗?**不**(旧谓词拒绝)。理由:`..` 与 `.` 这两个才是穿越,靠精确判据
|
|
410
|
+
* 挡住即可;而 `.DS_Store` 这类隐藏文件可能真的躺在用户的场景目录里,一刀切会让「删除这个场景」
|
|
411
|
+
* 从"能用但恢复不了"变成"直接删不掉" —— 那是把问题换个地方,不是修好它。
|
|
381
412
|
*/
|
|
382
|
-
export function
|
|
413
|
+
export function isValidTrashPayloadPath(dest) {
|
|
383
414
|
const text = String(dest ?? '');
|
|
384
|
-
if (!text || text.
|
|
415
|
+
if (!text || text.includes('\0'))
|
|
385
416
|
return false;
|
|
386
|
-
if (/[
|
|
417
|
+
if (text.startsWith('/') || text.startsWith('\\') || /^[A-Za-z]:/.test(text))
|
|
387
418
|
return false;
|
|
388
|
-
if (text
|
|
419
|
+
if (text.includes('\\'))
|
|
420
|
+
return false;
|
|
421
|
+
const parts = text.split('/');
|
|
422
|
+
for (const part of parts) {
|
|
423
|
+
if (part === '' || part === '.' || part === '..')
|
|
424
|
+
return false;
|
|
425
|
+
if (/[<>:"|?*\u0000-\u001f]/.test(part))
|
|
426
|
+
return false;
|
|
427
|
+
if (part.endsWith(' ') || part.endsWith('.'))
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
if (parts.length === 1 && parts[0] === 'manifest.json')
|
|
389
431
|
return false;
|
|
390
432
|
return true;
|
|
391
433
|
}
|
|
@@ -397,9 +439,40 @@ export async function moveOutOfTrash(kind, id, dest, to) {
|
|
|
397
439
|
const dir = trashEntryPath(kind, id);
|
|
398
440
|
if (dir === null)
|
|
399
441
|
throw new Error(`回收站条目 id 非法:${id}`);
|
|
400
|
-
if (!
|
|
442
|
+
if (!isValidTrashPayloadPath(dest))
|
|
401
443
|
throw new Error(`回收站负载名非法:${dest}`);
|
|
402
444
|
const from = join(dir, dest);
|
|
445
|
+
// 后置断言:谓词已经挡掉了 `..`,这一条挡的是「条目目录里恰好有个指向别处的链接」——
|
|
446
|
+
// 那种情况下 `join` 看着在条目内,实际会从条目外取源。
|
|
447
|
+
if (!isSameOrDescendant(dir, from))
|
|
448
|
+
throw new Error(`回收站负载越出条目目录:${dest}`);
|
|
449
|
+
await mkdir(dirname(to), { recursive: true });
|
|
450
|
+
try {
|
|
451
|
+
await rename(from, to);
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
await cp(from, to, { recursive: true });
|
|
455
|
+
await rm(from, { recursive: true, force: true });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* 把一个文件/目录搬**进已存在的条目**(`moveOutOfTrash` 的反向补充,专给回滚用)。
|
|
460
|
+
*
|
|
461
|
+
* 为什么需要它:恢复是逐个负载搬的,中途失败时必须把**已经搬出去的那几个**放回条目里,
|
|
462
|
+
* 否则它们留在目标目录里 —— 而恢复的下一次尝试会撞「目标目录已有内容」的检查,
|
|
463
|
+
* 这个条目就再也恢复不了了(场景回收站那条链正是这么坏掉的,审查 P0-2)。
|
|
464
|
+
* 谓词与后置断言与 `moveOutOfTrash` 完全一致(同一道闸,两个方向)。
|
|
465
|
+
* @throws 条目 id 或负载名非法时抛错(调用方按"回滚失败"处理)。
|
|
466
|
+
*/
|
|
467
|
+
export async function moveIntoTrash(kind, id, from, dest) {
|
|
468
|
+
const dir = trashEntryPath(kind, id);
|
|
469
|
+
if (dir === null)
|
|
470
|
+
throw new Error(`回收站条目 id 非法:${id}`);
|
|
471
|
+
if (!isValidTrashPayloadPath(dest))
|
|
472
|
+
throw new Error(`回收站负载名非法:${dest}`);
|
|
473
|
+
const to = join(dir, dest);
|
|
474
|
+
if (!isSameOrDescendant(dir, to))
|
|
475
|
+
throw new Error(`回收站负载越出条目目录:${dest}`);
|
|
403
476
|
await mkdir(dirname(to), { recursive: true });
|
|
404
477
|
try {
|
|
405
478
|
await rename(from, to);
|
package/lib/imports/parsers.js
CHANGED
|
@@ -45,33 +45,58 @@ function flushTurn(current, turns) {
|
|
|
45
45
|
if (text)
|
|
46
46
|
turns.push({ role: current.role, text });
|
|
47
47
|
}
|
|
48
|
+
/** 嗅探时最多看几行(见 `sniffFormat`)。 */
|
|
49
|
+
const SNIFF_LINES = 5;
|
|
48
50
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
+
* 按**内容**嗅探转录格式。只看前若干条非空行,不读全篇。
|
|
52
|
+
*
|
|
53
|
+
* 为什么不是「只看第一行」:Claude Code 的转录稿常在正文前带 `summary` / `system` 行,
|
|
54
|
+
* 那些行也是 JSON 但 `type` 不是 user/assistant;只看第一行会把它们判成 generic。
|
|
55
|
+
* 只扫前 `SNIFF_LINES` 行是为了压低误报 —— 一份 Markdown 正文里偶然粘了一段
|
|
56
|
+
* `{"role":"user"}` 的 API 载荷,不该让整份稿子按 JSONL 解析。
|
|
51
57
|
* @returns {'jsonl' | 'markdown' | 'generic'}
|
|
52
58
|
*/
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const first = String(content || '').split(/\r?\n/).map((s) => s.trim()).find((s) => s) || '';
|
|
62
|
-
if (first.startsWith('{')) {
|
|
59
|
+
function sniffFormat(content) {
|
|
60
|
+
const lines = String(content || '')
|
|
61
|
+
.split(/\r?\n/)
|
|
62
|
+
.map((s) => s.trim())
|
|
63
|
+
.filter((s) => s);
|
|
64
|
+
for (const line of lines.slice(0, SNIFF_LINES)) {
|
|
65
|
+
if (!line.startsWith('{'))
|
|
66
|
+
continue;
|
|
63
67
|
try {
|
|
64
68
|
// JSON 值实际形状未知;若为对象,type/role 字段由下方运行时比较判定
|
|
65
|
-
const obj = JSON.parse(
|
|
66
|
-
if (obj && typeof obj === 'object' && (obj
|
|
69
|
+
const obj = JSON.parse(line);
|
|
70
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj) &&
|
|
71
|
+
(obj.type === 'user' || obj.type === 'assistant' || obj.role === 'user' || obj.role === 'assistant'))
|
|
67
72
|
return 'jsonl';
|
|
68
73
|
}
|
|
69
|
-
catch (e) { /*
|
|
74
|
+
catch (e) { /* 这一行不是 JSON,继续往下看 */ }
|
|
70
75
|
}
|
|
76
|
+
const first = lines[0] || '';
|
|
71
77
|
if (HEADING_RE.test(first) || BOLD_PREFIX_RE.test(first))
|
|
72
78
|
return 'markdown';
|
|
73
79
|
return 'generic';
|
|
74
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Detect the transcript format. Extension wins; content sniffing applies
|
|
83
|
+
* when the name carries no recognized extension **or is `.txt`**.
|
|
84
|
+
*
|
|
85
|
+
* `.txt` 走嗅探,不是无条件 generic(2026-09-30 审查 F12):`.txt` 恰恰是**最常被改名**的
|
|
86
|
+
* 扩展名 —— Claude Code / Cursor 的 JSONL、Markdown 转录稿都可能被存成 `transcript.txt`
|
|
87
|
+
*(客户端拿不到文件名时自己就默认这个名字)。原来无条件判 generic,于是整份对话被折成
|
|
88
|
+
* **一条 user 消息**、分轮结构全失、零警告,op 还回 `{ok:true,count:1}`。
|
|
89
|
+
* `.jsonl` / `.md` 仍按扩展名直判(那两种名字下内容必然是同一种,嗅探没有收益)。
|
|
90
|
+
* @returns {'jsonl' | 'markdown' | 'generic'}
|
|
91
|
+
*/
|
|
92
|
+
export function detectFormat(fileName, content) {
|
|
93
|
+
const name = String(fileName || '').toLowerCase();
|
|
94
|
+
if (/\.jsonl$/.test(name))
|
|
95
|
+
return 'jsonl';
|
|
96
|
+
if (/\.(md|markdown)$/.test(name))
|
|
97
|
+
return 'markdown';
|
|
98
|
+
return sniffFormat(content);
|
|
99
|
+
}
|
|
75
100
|
/**
|
|
76
101
|
* Parse a Claude Code / Cursor style JSONL transcript.
|
|
77
102
|
* Recognizes `{type:'user'|'assistant', message:{role,content}}` (Claude Code)
|