dsh-plugin-tool-management 0.10.0 → 0.12.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 +100 -1
- package/README.md +67 -50
- package/README_EN.md +61 -38
- package/cordis.patch.yml +10 -1
- 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 +132 -12
- package/lib/client.js +2826 -547
- package/lib/compat/patch-dialect.js +173 -0
- package/lib/compat/preset-reach.js +1 -10
- package/lib/compat/probe.js +205 -24
- package/lib/compat/runtime-notes.js +25 -0
- package/lib/context-inject.js +83 -9
- package/lib/host-names.js +12 -0
- package/lib/http-fence.js +35 -15
- package/lib/hub.js +28 -2
- package/lib/imports/parsers.js +15 -9
- package/lib/imports/upload.js +43 -4
- package/lib/index.js +927 -3897
- package/lib/mcp/loader-token.js +238 -0
- package/lib/mcp/manager.js +1769 -0
- package/lib/mcp/override-blocks.js +10 -3
- package/lib/mcp/patch-yaml.js +351 -0
- package/lib/mcp/secret-guard.js +145 -0
- package/lib/{rules → memories}/archive-engine.js +1 -1
- package/lib/{rules → memories}/archive.js +1 -1
- package/lib/memories/constants.js +128 -0
- package/lib/memories/index-io.js +330 -0
- package/lib/memories/projection.js +280 -0
- package/lib/memories/service.js +686 -0
- package/lib/memories/snapshot.js +672 -0
- package/lib/ops/candidates.js +64 -0
- package/lib/ops/compat.js +226 -0
- package/lib/ops/ctx.js +9 -0
- package/lib/ops/memory.js +678 -0
- package/lib/ops/prompts.js +107 -0
- package/lib/ops/scene-records.js +460 -0
- package/lib/ops/scene-sync.js +17 -0
- package/lib/ops/sessions.js +603 -0
- package/lib/ops/trash.js +140 -0
- package/lib/paths.js +103 -0
- package/lib/prompts/preset-id.js +49 -0
- package/lib/{agents-md → prompts}/service.js +1 -1
- package/lib/request-gate.js +320 -0
- package/lib/scene-prompt-sync.js +4 -4
- package/lib/scenes/candidates.js +344 -0
- package/lib/{history → sessions}/bridge.js +124 -36
- package/lib/sessions/history.js +323 -0
- package/lib/{history → sessions}/tombstone.js +1 -1
- package/lib/{history → sessions}/workspace.js +151 -50
- package/lib/skills/core.js +74 -39
- package/lib/skills/readonly-discovery.js +4 -1
- package/lib/skills/service.js +98 -13
- package/lib/subagents/service.js +199 -55
- package/lib/tools/deps.js +8 -0
- package/lib/tools/mcp.js +110 -0
- package/lib/tools/memory.js +87 -0
- package/lib/tools/prompt.js +70 -0
- package/lib/tools/skills.js +139 -0
- package/lib/tools/subagent.js +40 -0
- package/package.json +13 -10
- package/lib/agents-md/preset-id.js +0 -49
- package/lib/rules/service.js +0 -3078
package/lib/ops/trash.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// 规则/记忆域的**回收站 ops**(2026-09-19 从 memories/service.ts 的 createMemoriesService 闭包抽出)。
|
|
2
|
+
//
|
|
3
|
+
// 删除进回收站、从回收站恢复、回收站列出与彻底删除。与记忆正文 ops 分域,
|
|
4
|
+
// 但共用 copyIntoMemoriesTrash 等入口,经 MemoriesOpsCtx 显式传入。
|
|
5
|
+
import { copyFile, lstat, mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { isValidTrashId } from '../hub.js';
|
|
8
|
+
import { LEGACY_BUNDLE_DOC, bundleDocName, isValidGroupSegment, isValidGroupPath, fail, MEMORIES_TRASH_DIR } from '../memories/constants.js';
|
|
9
|
+
import { readIndex, writeIndex } from '../memories/index-io.js';
|
|
10
|
+
/** 组装本域 op。rc 由 createMemoriesService 组装,契约见 ./ctx.ts。 */
|
|
11
|
+
export function buildTrashOps(rc) {
|
|
12
|
+
const { buildProjected, copyIntoMemoriesTrash, invalidateSnapshot, locateRule, parseId, refuseOutsideRoot, memoriesRoot, stateDir } = rc;
|
|
13
|
+
async function rulesRemove(args) {
|
|
14
|
+
const id = String((args && args.id) || '');
|
|
15
|
+
const parts = parseId(id);
|
|
16
|
+
if (!parts)
|
|
17
|
+
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
18
|
+
const located = await locateRule(parts.group, parts.name);
|
|
19
|
+
if (!located)
|
|
20
|
+
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
21
|
+
// 删除前确认落点仍在根内(realpath 口径):`located` 是顺着磁盘发现的,中间目录可能是链接。
|
|
22
|
+
const denied = await refuseOutsideRoot(located.entryPath);
|
|
23
|
+
if (denied)
|
|
24
|
+
return denied;
|
|
25
|
+
const trashId = await copyIntoMemoriesTrash(located, parts.group, parts.name);
|
|
26
|
+
if (located.kind === 'bundle')
|
|
27
|
+
await rm(located.entryPath, { recursive: true, force: true });
|
|
28
|
+
else
|
|
29
|
+
await rm(located.docPath, { force: true });
|
|
30
|
+
const index = await readIndex(stateDir);
|
|
31
|
+
delete index.rules[id];
|
|
32
|
+
await writeIndex(stateDir, index);
|
|
33
|
+
invalidateSnapshot();
|
|
34
|
+
return { ok: true, trashId };
|
|
35
|
+
}
|
|
36
|
+
async function rulesRestore(args) {
|
|
37
|
+
const trashId = String((args && args.trashId) || '');
|
|
38
|
+
if (!isValidTrashId(trashId))
|
|
39
|
+
return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
|
|
40
|
+
const trashDir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
|
|
41
|
+
let manifest;
|
|
42
|
+
try {
|
|
43
|
+
manifest = JSON.parse(await readFile(join(trashDir, 'manifest.json'), 'utf8'));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
|
|
47
|
+
}
|
|
48
|
+
if (!manifest || typeof manifest.name !== 'string' || typeof manifest.group !== 'string' || !isValidGroupSegment(manifest.name)) {
|
|
49
|
+
return fail('error.rules.notFound', `回收站条目损坏:${trashId}`);
|
|
50
|
+
}
|
|
51
|
+
// 恢复目标已存在(期间用户重建了同名规则)→ 拒绝,避免覆盖。
|
|
52
|
+
const conflict = await locateRule(manifest.group, manifest.name);
|
|
53
|
+
if (conflict) {
|
|
54
|
+
return fail('error.rules.exists', `同名记忆已存在:${conflict.id},请先移除后再恢复`);
|
|
55
|
+
}
|
|
56
|
+
const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
|
|
57
|
+
if (form === 'bundle') {
|
|
58
|
+
await mkdir(join(memoriesRoot, manifest.group), { recursive: true });
|
|
59
|
+
const { cp } = await import('node:fs/promises');
|
|
60
|
+
await cp(join(trashDir, 'bundle'), join(memoriesRoot, manifest.group, manifest.name), { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
await mkdir(join(memoriesRoot, manifest.group), { recursive: true });
|
|
64
|
+
await copyFile(join(trashDir, 'rule.md'), join(memoriesRoot, manifest.group, manifest.name + '.md'));
|
|
65
|
+
}
|
|
66
|
+
await rm(trashDir, { recursive: true, force: true });
|
|
67
|
+
invalidateSnapshot();
|
|
68
|
+
return { ok: true, rule: await buildProjected(manifest.group ? `${manifest.group}/${manifest.name}` : manifest.name) };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 记忆回收站列表(只读):`<stateDir>/memories-trash/<trashId>/`(`rulesRemove` 移入,
|
|
72
|
+
* `rulesRestore` 恢复)。损坏或内容缺失的条目跳过,不让一个坏条目挡住整份列表。
|
|
73
|
+
*/
|
|
74
|
+
async function rulesTrashList() {
|
|
75
|
+
const root = join(stateDir, MEMORIES_TRASH_DIR);
|
|
76
|
+
let ids = [];
|
|
77
|
+
try {
|
|
78
|
+
ids = await readdir(root);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return { ok: true, entries: [] }; // 目录不存在 = 回收站为空
|
|
82
|
+
}
|
|
83
|
+
const entries = [];
|
|
84
|
+
for (const trashId of ids) {
|
|
85
|
+
if (!isValidTrashId(trashId))
|
|
86
|
+
continue;
|
|
87
|
+
const dir = join(root, trashId);
|
|
88
|
+
try {
|
|
89
|
+
const st = await lstat(dir);
|
|
90
|
+
if (!st.isDirectory() || st.isSymbolicLink())
|
|
91
|
+
continue;
|
|
92
|
+
const manifest = JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8'));
|
|
93
|
+
if (!manifest || typeof manifest.name !== 'string' || !isValidGroupSegment(manifest.name))
|
|
94
|
+
continue;
|
|
95
|
+
const group = typeof manifest.group === 'string' ? manifest.group : '';
|
|
96
|
+
if (group !== '' && !isValidGroupPath(group))
|
|
97
|
+
continue;
|
|
98
|
+
const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
|
|
99
|
+
let bytes = 0;
|
|
100
|
+
try {
|
|
101
|
+
const bundleDoc = form === 'bundle'
|
|
102
|
+
? join(dir, 'bundle', bundleDocName(manifest.name))
|
|
103
|
+
: join(dir, 'rule.md');
|
|
104
|
+
bytes = (await stat(bundleDoc)).size;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// 兼容旧回收站条目(正文仍叫 SKILL.md);仍读不到就按 0 计,恢复时由 rulesRestore 兜底报错。
|
|
108
|
+
if (form === 'bundle') {
|
|
109
|
+
try {
|
|
110
|
+
bytes = (await stat(join(dir, 'bundle', LEGACY_BUNDLE_DOC))).size;
|
|
111
|
+
}
|
|
112
|
+
catch { /* 内容缺失 */ }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
entries.push({ trashId, group, name: manifest.name, form, deletedAt: String(manifest.deletedAt || ''), bytes });
|
|
116
|
+
}
|
|
117
|
+
catch { /* 损坏条目跳过 */ }
|
|
118
|
+
}
|
|
119
|
+
entries.sort((a, b) => (a.deletedAt === b.deletedAt ? b.trashId.localeCompare(a.trashId) : b.deletedAt.localeCompare(a.deletedAt)));
|
|
120
|
+
return { ok: true, entries };
|
|
121
|
+
}
|
|
122
|
+
/** 永久删除回收站条目(不可恢复)。 */
|
|
123
|
+
async function rulesTrashRemove(args) {
|
|
124
|
+
const trashId = String((args && args.trashId) || '');
|
|
125
|
+
if (!isValidTrashId(trashId))
|
|
126
|
+
return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
|
|
127
|
+
const dir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
|
|
128
|
+
try {
|
|
129
|
+
const st = await lstat(dir);
|
|
130
|
+
if (!st.isDirectory())
|
|
131
|
+
return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
|
|
135
|
+
}
|
|
136
|
+
await rm(dir, { recursive: true, force: true });
|
|
137
|
+
return { ok: true };
|
|
138
|
+
}
|
|
139
|
+
return { rulesRemove, rulesRestore, rulesTrashList, rulesTrashRemove };
|
|
140
|
+
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// dsh-plugin-tool-management —— 路径段与根内约束的**唯一口径**。
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独抽一个模块:名字校验此前散在五处(rules / subagents / imports / skills /
|
|
4
|
+
// preset-id),每处各写一套,口径不一 —— 有的漏 Windows 保留设备名(`CON` / `NUL`),
|
|
5
|
+
// 有的漏 `\0` 与控制字符。于是「同一个名字在 A 域能建、在 B 域不能建」,而漏掉的那两样
|
|
6
|
+
// 恰好都是**文件系统层面**的问题:Windows 上以设备名创建目录会失败,NUL 会把路径截断。
|
|
7
|
+
//
|
|
8
|
+
// 职责边界(写清楚是为了不再长出第六份实现):
|
|
9
|
+
// - 只判「这个字符串能不能安全地当一个路径段」,不管业务语义(是否保留字、是否重名、
|
|
10
|
+
// 是否是 kebab-case)——那些留在各域;
|
|
11
|
+
// - 长度上限由调用方给(人设名 64、技能名 128…),因为那是各域自己的策略,不是安全边界。
|
|
12
|
+
import { realpath } from 'node:fs/promises';
|
|
13
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
14
|
+
/** 段名默认长度上限(字符)。 */
|
|
15
|
+
export const SEGMENT_MAX_LENGTH = 128;
|
|
16
|
+
/** Windows 保留设备名:不区分大小写,带扩展名同样非法(`con.md` 也不行)。 */
|
|
17
|
+
const WINDOWS_DEVICE_NAME_RE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
18
|
+
/** 路径分隔符:出现即意味着「不止一段」。 */
|
|
19
|
+
const SEPARATOR_RE = /[\\/]/;
|
|
20
|
+
/** Windows 保留字符。 */
|
|
21
|
+
const RESERVED_CHAR_RE = /[<>:"|?*]/;
|
|
22
|
+
/** 控制字符(含 NUL、换行、制表):目录名与日志会不可读,NUL 还会截断路径。 */
|
|
23
|
+
const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/;
|
|
24
|
+
/**
|
|
25
|
+
* 判断一个名字能否作为**单个**路径段。
|
|
26
|
+
* @param name - 待判定的名字;非字符串按不合法处理。
|
|
27
|
+
* @param maxLength - 该域自己的长度上限。
|
|
28
|
+
* @returns 不合法的原因;`null` 表示合法。
|
|
29
|
+
*/
|
|
30
|
+
export function segmentProblem(name, maxLength = SEGMENT_MAX_LENGTH) {
|
|
31
|
+
if (typeof name !== 'string' || name === '')
|
|
32
|
+
return 'empty';
|
|
33
|
+
if (name.length > maxLength)
|
|
34
|
+
return 'too-long';
|
|
35
|
+
if (name === '.' || name === '..')
|
|
36
|
+
return 'dot';
|
|
37
|
+
if (name.startsWith('.'))
|
|
38
|
+
return 'hidden';
|
|
39
|
+
if (SEPARATOR_RE.test(name))
|
|
40
|
+
return 'separator';
|
|
41
|
+
if (RESERVED_CHAR_RE.test(name))
|
|
42
|
+
return 'reserved-char';
|
|
43
|
+
if (CONTROL_CHAR_RE.test(name))
|
|
44
|
+
return 'control-char';
|
|
45
|
+
if (WINDOWS_DEVICE_NAME_RE.test(name))
|
|
46
|
+
return 'device-name';
|
|
47
|
+
// 首尾空白:Windows 会静默裁剪,导致磁盘上的名字与界面显示的名字不一致。
|
|
48
|
+
if (name !== name.trim())
|
|
49
|
+
return 'padding';
|
|
50
|
+
// 末尾的点同理(`foo.` 在 Windows 上落地成 `foo`)。
|
|
51
|
+
if (/[.\s]$/.test(name))
|
|
52
|
+
return 'trailing-dot-or-space';
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
/** 便捷判定:能安全当路径段即为真。 */
|
|
56
|
+
export function isValidSegment(name, maxLength = SEGMENT_MAX_LENGTH) {
|
|
57
|
+
return segmentProblem(name, maxLength) === null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* `child` 是否就是 `parent` 本身或位于其下(纯字符串口径,不做 realpath)。
|
|
61
|
+
* 两侧先 `resolve`,因此 `a/../b` 这类写法不会误判成越界或反之。
|
|
62
|
+
* 跨盘符时 `relative` 会返回绝对路径,所以那种情况判为「不在其下」。
|
|
63
|
+
*/
|
|
64
|
+
export function isSameOrDescendant(parent, child) {
|
|
65
|
+
const rel = relative(resolve(parent), resolve(child));
|
|
66
|
+
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
|
|
67
|
+
}
|
|
68
|
+
/** `candidate` 是否落在 `root` 内(含根本身)。 */
|
|
69
|
+
export function isInsideRoot(root, candidate) {
|
|
70
|
+
return isSameOrDescendant(root, candidate);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 解析路径的真实落点,**末级允许不存在**:逐级向上找到最近既存祖先做 realpath,
|
|
74
|
+
* 再把尚未存在的部分接回去。
|
|
75
|
+
*
|
|
76
|
+
* 为什么需要它而不是 `lstat`:`lstat` 只看末级。中间目录若是符号链接,
|
|
77
|
+
* `<root>/办公/x.md` 拼出来看着在根内,实际会写到链接指向的地方 ——
|
|
78
|
+
* 只有把中间段也解析成真实路径才拦得住。
|
|
79
|
+
*/
|
|
80
|
+
export async function realPathOf(path) {
|
|
81
|
+
let current = resolve(path);
|
|
82
|
+
const missing = [];
|
|
83
|
+
for (;;) {
|
|
84
|
+
try {
|
|
85
|
+
return resolve(await realpath(current), ...missing);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
const parent = dirname(current);
|
|
89
|
+
if (parent === current)
|
|
90
|
+
return resolve(path);
|
|
91
|
+
missing.unshift(basename(current));
|
|
92
|
+
current = parent;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 路径经 realpath 解析后是否仍在根内(含根本身)。
|
|
98
|
+
* 供「写入 / 删除之前」的根内断言使用,是 `isInsideRoot` 的防符号链接版本。
|
|
99
|
+
*/
|
|
100
|
+
export async function isInsideRootResolved(root, candidate) {
|
|
101
|
+
const [rootReal, candidateReal] = await Promise.all([realPathOf(root), realPathOf(candidate)]);
|
|
102
|
+
return isSameOrDescendant(rootReal, candidateReal);
|
|
103
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// dsh-plugin-tool-management —— 提示词预设 id 的唯一口径。
|
|
2
|
+
//
|
|
3
|
+
// 用户裁定(2026-09-15):「id 应该什么都能写」——不再限定小写字母/数字/连字符,
|
|
4
|
+
// 中文、空格、下划线、点都可以。id 同时也是**磁盘目录名**,所以只保留两类硬约束:
|
|
5
|
+
// ① 文件系统安全:交由 `../paths.js` 的共享谓词判定(不含路径分隔符与 Windows 非法字符、
|
|
6
|
+
// 不以点开头/结尾、不是 `.` / `..`、不是 Windows 保留设备名、不含控制字符);
|
|
7
|
+
// ② 一个保留字:`__last-applied__`(apply 的备份槽,不是用户预设)。
|
|
8
|
+
// 这套口径由提示词服务(prompts/ 目录)与场景绑定(`scenes[].prompt`)共用,避免两处漂移。
|
|
9
|
+
// 约定镜像 @deepseek-ai/dsh-agent-presets 的「id 即目录名」,但放宽了字符集。
|
|
10
|
+
import { segmentProblem } from '../paths.js';
|
|
11
|
+
/** id 长度上限(字符数;目录名过长在 Windows 上还受 MAX_PATH 约束)。 */
|
|
12
|
+
export const PRESET_ID_MAX = 64;
|
|
13
|
+
/** apply 的备份槽:不是用户预设,任何入口都不得当作预设 id。 */
|
|
14
|
+
export const LAST_APPLIED_PRESET_ID = '__last-applied__';
|
|
15
|
+
/** 把共享谓词给的原因翻成这个域自己的文案:规则只有一份,消息留在这里。 */
|
|
16
|
+
function presetIdProblemText(problem, id) {
|
|
17
|
+
switch (problem) {
|
|
18
|
+
case 'empty': return 'id 不能为空';
|
|
19
|
+
case 'too-long': return `id 过长(≤${PRESET_ID_MAX} 字符,当前 ${id.length})`;
|
|
20
|
+
case 'dot': return 'id 不能是「.」或「..」';
|
|
21
|
+
case 'hidden': return 'id 不能以「.」开头';
|
|
22
|
+
case 'separator': return 'id 不能含路径分隔符(/ 或 \\)';
|
|
23
|
+
case 'reserved-char': return 'id 不能含 < > : " | ? * 这些字符';
|
|
24
|
+
case 'control-char': return 'id 不能含控制字符';
|
|
25
|
+
case 'device-name': return `「${id}」是 Windows 保留设备名`;
|
|
26
|
+
case 'padding': return 'id 不能含首尾空白';
|
|
27
|
+
case 'trailing-dot-or-space': return 'id 不能以点或空格结尾(Windows 会静默去掉)';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 校验并归一化一个预设 id。
|
|
32
|
+
* @param raw - 用户输入或来自索引的原始值。
|
|
33
|
+
* @returns `{ ok: true, id }`(已 trim)或 `{ ok: false, error }`(给人看的中文原因)。
|
|
34
|
+
*/
|
|
35
|
+
export function normalizePresetId(raw) {
|
|
36
|
+
const id = String(raw ?? '').trim();
|
|
37
|
+
if (id === LAST_APPLIED_PRESET_ID)
|
|
38
|
+
return { ok: false, error: `「${LAST_APPLIED_PRESET_ID}」是「应用」的备份槽,不能用作预设 id` };
|
|
39
|
+
// 文件系统安全那一组交给共享谓词(`../paths.js`)—— 此前这里与 rules / subagents /
|
|
40
|
+
// imports / skills 各写一套,五份实现迟早会漂。长度上限仍是本域自己的策略。
|
|
41
|
+
const problem = segmentProblem(id, PRESET_ID_MAX);
|
|
42
|
+
if (problem !== null)
|
|
43
|
+
return { ok: false, error: presetIdProblemText(problem, id) };
|
|
44
|
+
return { ok: true, id };
|
|
45
|
+
}
|
|
46
|
+
/** 便捷判定(索引解析等只关心"能不能用"的地方)。 */
|
|
47
|
+
export function isValidPresetId(raw) {
|
|
48
|
+
return normalizePresetId(raw).ok;
|
|
49
|
+
}
|
|
@@ -30,7 +30,7 @@ const APPLIED_FILE = '__applied__.json';
|
|
|
30
30
|
const BLANK_TEMPLATE = '# AGENTS.md\n\n(DSH 全局指令基线预设,待编辑)\n';
|
|
31
31
|
/** 全局 AGENTS.md 的备份代际上限:与 mcpm patch 的 KEEP_PATCH_BACKUPS 同纪律。 */
|
|
32
32
|
const KEEP_GLOBAL_BACKUPS = 5;
|
|
33
|
-
export function
|
|
33
|
+
export function createPromptsService(_ctx, deps) {
|
|
34
34
|
const message = (e) => String((e && e.message) || e);
|
|
35
35
|
/** 校验 id 并返回错误文本(合法时返回 `null`)。 */
|
|
36
36
|
const idError = (raw) => {
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// HTTP 请求准入与 handlers 后处理(原 index.ts apply() 闭包内四段,2026-09-19 抽出)。
|
|
2
|
+
//
|
|
3
|
+
// 这一域管「谁能改、改哪些、什么时候不许改」:
|
|
4
|
+
// * 访问令牌(可选)—— 两条用途:宿主浏览器鉴权的逃生口,以及写操作的纵深防御;
|
|
5
|
+
// * 写 / 敏感 op 白名单 —— 哪些 op 要凭令牌(含"只读但会泄露明文"与"只读但会改宿主状态");
|
|
6
|
+
// * 场景锁定 —— 任一场景 locked=true 时五个管理域整体冻结;
|
|
7
|
+
// * handlers 后处理 —— 把上面三条装到 op 表上(包装顺序即语义,见 installHandlerGuards)。
|
|
8
|
+
//
|
|
9
|
+
// 为什么抽出来:四段分散在 apply() 的 220 / 902 / 1650 / 1928,靠闭包共享 handlers 与
|
|
10
|
+
// memoriesService。抽成工厂后依赖改为显式入参,index.ts 只剩四行装配 —— 而引用点用解构
|
|
11
|
+
// 保持原名字,一行没动(改名才是这类搬迁最容易出静默错误的地方)。
|
|
12
|
+
//
|
|
13
|
+
// **一处必须守住的不变量**:handlers 的后处理顺序。`guardLockedOps` / `syncSceneArchiveOnSwitch`
|
|
14
|
+
// 都是「读原函数 → 换包装」的原地改写,先装的在内层、后装的在外层;`annotateLocked` 只挂在
|
|
15
|
+
// 五个读 op 上。整段照搬、顺序未变。
|
|
16
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
17
|
+
/**
|
|
18
|
+
* 建访问令牌三件套。**必须**从 apply 的第二个参数取配置 ——
|
|
19
|
+
* 读 `ctx.config` 不是注入服务,启动时会抛 "cannot get property without inject"。
|
|
20
|
+
*/
|
|
21
|
+
export function createAccessToken(deps) {
|
|
22
|
+
const { config } = deps;
|
|
23
|
+
// Optional access token. Enabled by setting `config.token` on this plugin's
|
|
24
|
+
// loader row (profile cordis.patch.yml override) or the
|
|
25
|
+
// DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN env var. Two roles now:
|
|
26
|
+
// 1. escape hatch — a correct token is accepted *in place of* the host's
|
|
27
|
+
// browser authentication, so curl/scripts and LAN tooling keep working;
|
|
28
|
+
// 2. defense in depth — when set, every state-changing op additionally
|
|
29
|
+
// requires `x-dsh-token: <token>`.
|
|
30
|
+
// It is NOT the primary gate anymore: the route calls the host's
|
|
31
|
+
// connection.requestRejection fence first (Host must be loopback/LAN
|
|
32
|
+
// IP-literal → defeats DNS rebinding, plus browser-session cookie auth),
|
|
33
|
+
// so an unset token no longer means "anyone may call writes".
|
|
34
|
+
// NOTE: the entry config arrives as the SECOND apply argument (Cordis
|
|
35
|
+
// calls `callback(ctx, config)`) — never read it off `ctx.config`, which
|
|
36
|
+
// is not an injected service and throws "cannot get property without
|
|
37
|
+
// inject" at boot.
|
|
38
|
+
const CONFIG_TOKEN = String(config?.token || process.env.DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN || '').trim();
|
|
39
|
+
/**
|
|
40
|
+
* `config.tokenDisabled: true` = 令牌**留在配置里**但当前不生效(用户主动关掉)。
|
|
41
|
+
*
|
|
42
|
+
* 与"没配令牌"的区别(用户裁定 2026-09-19):关掉之后随时能开回来,不需要重新输一遍;
|
|
43
|
+
* 两者对写操作的要求一样 —— 都不带令牌。
|
|
44
|
+
*/
|
|
45
|
+
const TOKEN_DISABLED = config?.tokenDisabled === true;
|
|
46
|
+
/** 写门禁用的**生效**令牌:关掉即视为没配(配置里的值原样保留)。 */
|
|
47
|
+
const TOKEN = TOKEN_DISABLED ? '' : CONFIG_TOKEN;
|
|
48
|
+
// 令牌比较走「双侧 sha256 → timingSafeEqual」:摘要定长 32 字节,无需长度分支
|
|
49
|
+
// (直接比较不等长 Buffer 会抛),逐字节的耗时不随匹配前缀长度变化 —— JS 的 ===
|
|
50
|
+
// 逐字符短路,理论上可被计时侧信道逐位猜测。
|
|
51
|
+
// 比较的是**存量**令牌(CONFIG_TOKEN)而不是生效值:关掉之后仍然要凭它才能开回来 /
|
|
52
|
+
// 换掉 / 看明文(要求 9:关闭令牌需要验证当前的令牌)。
|
|
53
|
+
const tokenMatches = (presented) => timingSafeEqual(createHash('sha256').update(presented).digest(), createHash('sha256').update(CONFIG_TOKEN).digest());
|
|
54
|
+
// 本次**进程**的标识(用户裁定 2026-09-18 第 3 条:「每次启动只用填一次,退出进程后再启动就要重填」)。
|
|
55
|
+
// 界面把"填过的令牌"和这个值绑在一起存,读到不同值就作废 —— 于是重启后必须重填,
|
|
56
|
+
// 而同一次运行内刷新页面不必重填。
|
|
57
|
+
//
|
|
58
|
+
// 用 pid + 进程启动时刻,而不是每次 apply 现生成的随机串:apply 在 HMR / 重复挂载时会再跑一次,
|
|
59
|
+
// 那时候换掉 BOOT_ID 会把用户刚填好的令牌判成过期(表现为"刚填完又要求填")。
|
|
60
|
+
// 进程启动时刻由 `Date.now() - uptime()` 反推,同一进程内恒定、跨进程几乎不会撞。
|
|
61
|
+
const BOOT_ID = (() => {
|
|
62
|
+
try {
|
|
63
|
+
return process.pid + '-' + Math.round(Date.now() - process.uptime() * 1000);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return 'unknown';
|
|
67
|
+
}
|
|
68
|
+
})();
|
|
69
|
+
/**
|
|
70
|
+
* 「本次启动已经验过令牌」的闩。验过即置位;唯一的手动清零入口是 `unmarkAccepted`
|
|
71
|
+
* (「清除令牌」走的 `token-unaccept`,见上)。重启自然清零。
|
|
72
|
+
*
|
|
73
|
+
* 用途:`agent/pre-step` 的令牌门禁要回答"这台机器现在是不是还没人验过令牌",而它看不到
|
|
74
|
+
* 浏览器请求头。HTTP 入口是唯一知道答案的地方(那里才知道 `x-dsh-token` 对不对),
|
|
75
|
+
* 所以由入口在验过时置位,门禁读这一个布尔 —— 两边不共享别的东西。
|
|
76
|
+
*
|
|
77
|
+
* 注意它**不是**"当前请求带没带令牌":带上之后页面刷新、切页、轮询都不该再要求重填,
|
|
78
|
+
* 而这与界面的 bootId 口径一致(同一进程内只需填一次)。
|
|
79
|
+
*
|
|
80
|
+
* 已知边界:闩按**进程**记,不区分浏览器 —— 同一启动里任何一个客户端验对过,其它
|
|
81
|
+
* 客户端的对话也放行。这是"填一次只管本次启动"(裁定 2026-09-18 第 3 条)在 pre-step
|
|
82
|
+
* 只能看宿主全局状态下的固有代价;外层还有宿主的连接围栏(回环 / 局域网字面量 +
|
|
83
|
+
* 浏览器会话 cookie)兜着。
|
|
84
|
+
*/
|
|
85
|
+
let accepted = false;
|
|
86
|
+
return {
|
|
87
|
+
CONFIG_TOKEN,
|
|
88
|
+
TOKEN_DISABLED,
|
|
89
|
+
TOKEN,
|
|
90
|
+
tokenMatches,
|
|
91
|
+
BOOT_ID,
|
|
92
|
+
acceptedThisBoot: () => accepted,
|
|
93
|
+
markAccepted: () => { accepted = true; },
|
|
94
|
+
unmarkAccepted: () => { accepted = false; },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** 建白名单。必须在四个 service 创建之后构造(依赖其 writeOps)。 */
|
|
98
|
+
export function createOpWhitelist(deps) {
|
|
99
|
+
// HTTP 写操作门禁清单。skills/rules/档案引擎域由各自 service 导出的 writeOps 派生
|
|
100
|
+
// (与其 ops 表同文件维护,新增写 op 改对应 service 即可);本文件内联域
|
|
101
|
+
// (mcpm-* / skill-open / agentsmd-* / history-*)在此列举。
|
|
102
|
+
// 注意:必须在上述 service 创建之后构造(依赖其 writeOps)。
|
|
103
|
+
const WRITE_OPS = new Set([
|
|
104
|
+
...deps.writeOps.skills,
|
|
105
|
+
...deps.writeOps.memories,
|
|
106
|
+
...deps.writeOps.archives,
|
|
107
|
+
...deps.writeOps.subagents,
|
|
108
|
+
'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-restart',
|
|
109
|
+
'mcpm-compact',
|
|
110
|
+
'mcpm-export', 'mcpm-import', 'mcpm-note', 'mcpm-settings', 'mcpm-tool-enabled',
|
|
111
|
+
// mcpm-tools-refresh 为了拿实时工具表会临时启用目标服务器、结束后恢复原状(两次
|
|
112
|
+
// writePatch),恢复失败还会停留在启用态 —— 是写不是读,按写门禁(与 mcpm-restart
|
|
113
|
+
// 已在清单同理;0.6.0/0.7.0 已有两次写 op 漏列前科)。
|
|
114
|
+
'mcpm-tools-refresh',
|
|
115
|
+
// mcpm-reveal returns UNMASKED secrets; even though it is a read, it is
|
|
116
|
+
// token-gated like a write — on a LAN-exposed port the token must be the
|
|
117
|
+
// last line of defense for plaintext credentials too, not just writes.
|
|
118
|
+
'mcpm-reveal',
|
|
119
|
+
'skill-open',
|
|
120
|
+
// preset-tools 是只读枚举,但枚举会为预设建立 standing mount(官方语义:每进程只挂一次)。
|
|
121
|
+
// 未授权调用者不该触发挂载 —— 按写门禁。
|
|
122
|
+
//
|
|
123
|
+
// 这条规则的范围写清楚,别当成"凡会写盘就入门禁":`history-list` / `history-sessions`
|
|
124
|
+
// 每次都会写工作区快照、`rules-list` / `rules-read` / `rules-diagnose` 会整份覆盖写
|
|
125
|
+
// `memories-index.json`、`mcpm-list` / `mcpm-tools` 回写 `mcp-known-tools.json` ——
|
|
126
|
+
// 五个都是读 op 且**不在本清单**。判据是"会不会改**宿主或外部系统**的状态":
|
|
127
|
+
// standing mount 改的是宿主进程,而上面那些写的是插件自己的侧车(丢了可重建)。
|
|
128
|
+
// 想要它们也带令牌就显式加进来,别靠"读操作带副作用"这句话推。
|
|
129
|
+
'preset-tools',
|
|
130
|
+
// 提示词写操作(create/update/remove 改预设库;apply 写全局 AGENTS.md;import 从外部内容建预设)
|
|
131
|
+
'agentsmd-create', 'agentsmd-update', 'agentsmd-apply', 'agentsmd-remove', 'agentsmd-import',
|
|
132
|
+
// 提示词预设的回收站(恢复 / 永久删除都是写)
|
|
133
|
+
'agentsmd-trash-restore', 'agentsmd-trash-delete',
|
|
134
|
+
// history 写操作(archive/unarchive 改归档集合;delete 永久删除;retention-set 写保留期;
|
|
135
|
+
// workspace-register 会新增一条宿主工作区登记,同样是写)
|
|
136
|
+
'history-archive', 'history-unarchive', 'history-delete', 'history-retention-set',
|
|
137
|
+
'history-unarchive-batch', 'history-delete-batch', 'history-import', 'history-export',
|
|
138
|
+
// 通用导出:往用户指定的目录写文件,按写操作门禁(token)。
|
|
139
|
+
'bundle-export',
|
|
140
|
+
'history-archive-batch', 'history-workspace-register',
|
|
141
|
+
// 注入设置(五个域开关 / 压制型预设口径)写侧车,按写操作门禁。
|
|
142
|
+
'inject-settings',
|
|
143
|
+
// 清理 patch 备份:删磁盘文件(含明文凭据副本),按写操作门禁。
|
|
144
|
+
'backups-clean',
|
|
145
|
+
]);
|
|
146
|
+
// 会泄露明文凭据 / 完整配置的 op:**必须**带对的访问令牌,没配令牌就一律拒绝
|
|
147
|
+
// (判定在 http-fence.ts 的 secretOpRejection,含两种情况的区分与理由)。
|
|
148
|
+
const SENSITIVE_OPS = new Set(['mcpm-reveal', 'mcpm-export']);
|
|
149
|
+
return { WRITE_OPS, SENSITIVE_OPS };
|
|
150
|
+
}
|
|
151
|
+
// ---------- 场景锁定守卫(v0.8)----------
|
|
152
|
+
// 任一场景 locked=true = 五个管理域(MCP/技能/子智能体/记忆/提示词)整体冻结:
|
|
153
|
+
// 下面列出的写 op 一律拒绝;界面按钮同步禁用,这里是兜底(防止绕过界面直接打 op)。
|
|
154
|
+
// 只包 **handlers** 这一层是有意的:进/退模式的运行时应用走的是内部函数与
|
|
155
|
+
// service.ops(applyMcpServerSwitches / applySkills / applySubagentSwitches / patchIndex),
|
|
156
|
+
// 不经过 handlers —— 锁定就是为了让场景能按原样启动,运行时应用不能被自己挡住。
|
|
157
|
+
// 例外里的例外:scene-archive-save / rules-remove-scene 只对**被锁的那个场景**拒绝。
|
|
158
|
+
export function createSceneLock(deps) {
|
|
159
|
+
async function lockedSceneNames() {
|
|
160
|
+
const r = await deps.readSceneList();
|
|
161
|
+
return ((r && r.scenes) || []).filter((s) => s.locked === true).map((s) => String(s.name));
|
|
162
|
+
}
|
|
163
|
+
/** 当前启用(= 已进入)的那个场景名;没有则 null。 */
|
|
164
|
+
async function activeSceneName() {
|
|
165
|
+
try {
|
|
166
|
+
const r = await deps.readSceneList();
|
|
167
|
+
return r && r.ok && r.activeScene ? String(r.activeScene) : null;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** 「当前场景已锁定」的拒绝文案(模型工具用;无活动场景 / 未锁定 → null)。 */
|
|
174
|
+
async function lockedSceneGuard() {
|
|
175
|
+
const scene = await activeSceneName();
|
|
176
|
+
if (!scene)
|
|
177
|
+
return null;
|
|
178
|
+
const locked = await lockedSceneNames();
|
|
179
|
+
return locked.includes(scene) ? `场景「${scene}」已锁定:先到场景页解锁再改。` : null;
|
|
180
|
+
}
|
|
181
|
+
return { lockedSceneNames, activeSceneName, lockedSceneGuard };
|
|
182
|
+
}
|
|
183
|
+
export function installHandlerGuards(deps) {
|
|
184
|
+
const { handlers, lockedSceneNames, activeSceneName, syncSwitchToScene, subagentToolFailures } = deps;
|
|
185
|
+
/**
|
|
186
|
+
* 包一层:开关成功后把改动同步进当前场景档案(见 syncSwitchToScene)。
|
|
187
|
+
* 失败只挂 `sceneSyncError`,不改写原操作的成功结论 —— 运行时确实改了,档案没跟上要说得清。
|
|
188
|
+
* 锁定仍由 `guardLockedOps` 挡住(更靠内的那层包装先执行)。
|
|
189
|
+
*/
|
|
190
|
+
function syncSceneArchiveOnSwitch(opNames) {
|
|
191
|
+
for (const opName of opNames) {
|
|
192
|
+
const original = handlers[opName];
|
|
193
|
+
if (typeof original !== 'function')
|
|
194
|
+
continue;
|
|
195
|
+
handlers[opName] = async (args) => {
|
|
196
|
+
const res = await original(args);
|
|
197
|
+
if (!res || res.ok === false)
|
|
198
|
+
return res;
|
|
199
|
+
const err = await syncSwitchToScene(opName, args);
|
|
200
|
+
return err ? { ...res, sceneSyncError: err } : res;
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function guardLockedOps(opNames, what) {
|
|
205
|
+
for (const opName of opNames) {
|
|
206
|
+
const original = handlers[opName];
|
|
207
|
+
if (typeof original !== 'function')
|
|
208
|
+
continue;
|
|
209
|
+
handlers[opName] = async (args) => {
|
|
210
|
+
const locked = await lockedSceneNames();
|
|
211
|
+
if (locked.length)
|
|
212
|
+
return { ok: false, error: `场景已锁定(${locked.join('、')}):先到场景页解锁再${what}` };
|
|
213
|
+
return original(args);
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
guardLockedOps([
|
|
218
|
+
// MCP:改配置 / 服务器启停 / 工具启停 / 导入导出配置 / 备注 / 设置。
|
|
219
|
+
// restart 仍放行(它是"重连"这条恢复路径),但它**不是只读**:实现会两次 `writePatch`
|
|
220
|
+
//(先强制停用、轮询、再按重启前状态恢复)。所以它不改的是**用户选的启停值**,
|
|
221
|
+
// 不是"不碰补丁文件" —— 锁定期间它是唯一能落盘改补丁的入口,进程中断会把服务器
|
|
222
|
+
// 留在停用态。别按"只重连"去理解它。
|
|
223
|
+
'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-tool-enabled', 'mcpm-import', 'mcpm-compact', 'mcpm-note', 'mcpm-settings',
|
|
224
|
+
// 技能:启停 / 来源启停与移除恢复 / 首选 / 删除 / 创建导入 / 自定义目录 / 回收站 / 批量启停。
|
|
225
|
+
'skill-enable', 'skill-disable', 'skill-source-enable', 'skill-source-disable', 'skill-source-remove', 'skill-source-restore',
|
|
226
|
+
'skill-prefer', 'skill-unprefer', 'skill-delete', 'skill-create', 'skill-import', 'skill-upload', 'skill-set-all',
|
|
227
|
+
'skill-custom-add', 'skill-custom-remove', 'skill-trash-restore', 'skill-trash-delete',
|
|
228
|
+
// 子智能体:开关 / 改名保存 / 删除 / 导入 / 回收站。
|
|
229
|
+
'subagent-create', 'subagent-update', 'subagent-delete', 'subagent-toggle', 'subagent-import', 'subagent-trash-restore', 'subagent-trash-delete',
|
|
230
|
+
// 记忆:增删改 / 开关 / 导入 / 回收站 / 绑定。set-active 是场景启停,不在冻结范围。
|
|
231
|
+
'rules-create', 'rules-update', 'rules-remove', 'rules-toggle', 'rules-import', 'rules-restore', 'rules-trash-remove', 'rules-attach', 'rules-detach', 'rules-set-index',
|
|
232
|
+
// 提示词:建改删 / 应用(切换生效基线)/ 导入 / 回收站。
|
|
233
|
+
'agentsmd-create', 'agentsmd-update', 'agentsmd-remove', 'agentsmd-apply', 'agentsmd-import', 'agentsmd-trash-restore', 'agentsmd-trash-delete',
|
|
234
|
+
], '修改');
|
|
235
|
+
// 场景内「开关」类操作(用户裁定 2026-09-17):未锁定时**可用**,改动同步进当前场景档案。
|
|
236
|
+
// 与「锁定」正交:锁定冻结全部写操作,这里只是把页面开关的意图也写进档案。
|
|
237
|
+
syncSceneArchiveOnSwitch([
|
|
238
|
+
'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-tool-enabled',
|
|
239
|
+
'skill-enable', 'skill-disable', 'skill-set-all', 'skill-source-enable', 'skill-source-disable',
|
|
240
|
+
'subagent-toggle',
|
|
241
|
+
]);
|
|
242
|
+
// `rules-set-active` 不在 `guardLockedOps` 里(场景启停本身要可用),但它能把**当前
|
|
243
|
+
// 场景清空** —— 而「当前场景已锁定」是模型侧 `lockedSceneGuard` 唯一的判据,场景一空
|
|
244
|
+
// 它就返回 null,四个写工具全部放开,运行时却仍是那个场景的档案态(2026-09-19 审计
|
|
245
|
+
// T-32)。所以这里单独挡一刀:锁着的场景正在生效时,不许改启用集合(先解锁再说)。
|
|
246
|
+
// 没有锁定场景在生效时(全局态 / 场景未锁)照旧可用 —— 那本来就是允许的。
|
|
247
|
+
for (const [opName, what] of [['rules-set-active', '切换场景']]) {
|
|
248
|
+
const original = handlers[opName];
|
|
249
|
+
if (typeof original !== 'function')
|
|
250
|
+
continue;
|
|
251
|
+
handlers[opName] = async (args) => {
|
|
252
|
+
const scene = await activeSceneName();
|
|
253
|
+
if (scene && (await lockedSceneNames()).includes(scene)) {
|
|
254
|
+
// 同集合的重复提交放行:它没有改变任何东西,不该被"冻结"挡下来。
|
|
255
|
+
const current = new Set([scene]);
|
|
256
|
+
const next = new Set((Array.isArray(args && args.scenes) ? args.scenes : [])
|
|
257
|
+
.map((n) => String(n == null ? '' : n).trim())
|
|
258
|
+
.filter((n) => n !== ''));
|
|
259
|
+
const same = current.size === next.size && [...current].every((n) => next.has(n));
|
|
260
|
+
if (!same) {
|
|
261
|
+
return { ok: false, error: `场景「${scene}」已锁定:先到场景页解锁再${what}(锁定期间改启用集合会让模型侧的写门禁失效)` };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return original(args);
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
// 被锁场景自身的档案与删除:只挡它自己,别的场景照常。
|
|
268
|
+
for (const [opName, pickScene, what] of [
|
|
269
|
+
['scene-archive-save', (args) => (args && args.scene) || '', '改档案'],
|
|
270
|
+
['rules-remove-scene', (args) => (args && args.name) || '', '删除'],
|
|
271
|
+
]) {
|
|
272
|
+
const original = handlers[opName];
|
|
273
|
+
if (typeof original !== 'function')
|
|
274
|
+
continue;
|
|
275
|
+
handlers[opName] = async (args) => {
|
|
276
|
+
const scene = String(pickScene(args) || '').trim();
|
|
277
|
+
const locked = await lockedSceneNames();
|
|
278
|
+
if (scene && locked.includes(scene))
|
|
279
|
+
return { ok: false, error: `场景「${scene}」已锁定:先解锁再${what}` };
|
|
280
|
+
return original(args);
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
// 各页列表响应带上 anyLocked + activeScene:界面据此禁用写控件、并说明
|
|
284
|
+
// 「当前处于场景 X,开关请到档案里改」(读 op,附加字段不影响既有消费方)。
|
|
285
|
+
async function annotateLocked(res) {
|
|
286
|
+
if (res && res.ok !== false) {
|
|
287
|
+
const locked = (await lockedSceneNames()).length > 0;
|
|
288
|
+
const scene = await activeSceneName();
|
|
289
|
+
res.anyLocked = locked;
|
|
290
|
+
res.activeScene = scene;
|
|
291
|
+
// 信封里再套一层 `data` 的 op(`skill-state` 就是)必须**同时**写进 `data`:
|
|
292
|
+
// 客户端 callApi 只把 `data` 交给页面,外层字段在那一层就被丢掉了 ——
|
|
293
|
+
// 于是"服务端明明标了、技能页两个横幅却从来不显示"(2026-09-19 用户报的)。
|
|
294
|
+
if (res.data && typeof res.data === 'object') {
|
|
295
|
+
res.data.anyLocked = locked;
|
|
296
|
+
res.data.activeScene = scene;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return res;
|
|
300
|
+
}
|
|
301
|
+
for (const opName of ['mcpm-list', 'mcpm-reveal', 'skill-state', 'subagent-list', 'agentsmd-list']) {
|
|
302
|
+
const original = handlers[opName];
|
|
303
|
+
if (typeof original !== 'function')
|
|
304
|
+
continue;
|
|
305
|
+
handlers[opName] = async (args) => annotateLocked(await original(args));
|
|
306
|
+
}
|
|
307
|
+
// 子智能体页面横幅的数据源:注册失败的工具名与原因随列表一起带出去(同一处 attach 的
|
|
308
|
+
// 语义,见上方 annotateLocked)。失败清单在 apply 期间就定下来了,这里只是挂上去。
|
|
309
|
+
{
|
|
310
|
+
const original = handlers['subagent-list'];
|
|
311
|
+
if (typeof original === 'function') {
|
|
312
|
+
handlers['subagent-list'] = async (args) => {
|
|
313
|
+
const res = await original(args);
|
|
314
|
+
if (res && res.ok !== false)
|
|
315
|
+
res.toolFailures = subagentToolFailures.map((f) => ({ ...f }));
|
|
316
|
+
return res;
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|