dsh-plugin-tool-management 0.12.0 → 0.13.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.
@@ -0,0 +1,273 @@
1
+ // HTTP op 登记表 —— 每个 op 一条判据声明,机器可数的那一份。
2
+ //
3
+ // 为什么要有这个文件:门禁今天靠**五张手写清单**(`WRITE_OPS`、`SENSITIVE_OPS`、
4
+ // `guardLockedOps`、`syncSceneArchiveOnSwitch`、`annotateLocked` 的 op 列表)。它们各自
5
+ // 回答一个不同的问题,作用在同一批 op 名字上,中间没有任何约束 —— 新加一个 op 只写
6
+ // handler,编译器一声不吭。这条纪律反复失守过四次(0.6.0 / 0.7.0 / 0.10.0 两次),
7
+ // 症状都不是报错,而是"没配令牌也能写"或"锁定的场景能被改"。
8
+ //
9
+ // 判据(一个 op 可以同时占多条,所以这是**标志集合**不是单一分类):
10
+ // serviceWrite 写门禁的一部分,但**由所属 service 自报**(`writeOps`)。这里登记一份
11
+ // 是为了让对账能双向查:service 加了写 op 而登记表没条目 → 红;登记表有条目
12
+ // 而 service 不再自报 → 也红。两边谁改了都必须动另一边。
13
+ // write 需要访问令牌。判据是"会不会改**宿主或外部系统**的状态" —— 注意这**不是**
14
+ // "会不会写盘":见下方 readonly 那一段。
15
+ // sensitive 会泄露明文凭据 / 完整配置。**必须**带对的令牌,没配令牌就一律拒绝。
16
+ // frozen 任一场景 locked=true 时整体拒绝(五个管理域冻结)。
17
+ // frozenScope frozen 的三种口径,默认 'all':
18
+ // all 只要有场景锁着就拒。
19
+ // self-scene 只挡"被锁的那个场景"自己(别的场景照常)。
20
+ // active-scene 只在"锁着的场景正在生效"时拒(它能把当前场景清空)。
21
+ // syncsArchive 成功后把改动同步进当前场景档案。与 frozen **正交**:锁定冻结全部写操作,
22
+ // 这条只是把页面开关的意图也写进档案。
23
+ // annotatesLock 响应里附加 anyLocked / activeScene(界面横幅的数据源)。
24
+ // readonly 明确声明"这条就是只读,上面几项都不需要"。**归不进上面任何一条的必须显式
25
+ // 写这一条** —— 漏登记和"确实只读"在编译器眼里长得一样,这正是四次失守的成因。
26
+ //
27
+ // readonly 的判据边界(原样保留在 request-gate.ts 的注释里,这里重申一遍):
28
+ // 有若干只读 op **会写盘** —— `history-list` / `history-sessions` 每次写工作区快照,
29
+ // `rules-list` / `rules-read` / `rules-diagnose` 整份覆盖写 `memories-index.json`,
30
+ // `mcpm-list` / `mcpm-tools` 回写 `mcp-known-tools.json`。它们都**不**算写操作:判据是
31
+ // "会不会改宿主或外部系统的状态",而上面那些写的是插件自己的侧车(丢了可重建)。
32
+ // 想要它们也带令牌就显式改成 write,别靠"读操作带副作用"这句话推。
33
+ export const OP_REGISTRY = Object.freeze({
34
+ // ── MCP 域(16)────────────────────────────────────────────────────────────
35
+ 'mcpm-list': { annotatesLock: true },
36
+ // reveal 返回**未打码**的凭据。它是读操作,但按写门禁 —— 局域网暴露的端口上,令牌对
37
+ // 明文凭据必须是最后一道防线,而不只对着写操作。
38
+ 'mcpm-reveal': { write: true, sensitive: true, annotatesLock: true },
39
+ 'mcpm-export': { write: true, sensitive: true },
40
+ 'mcpm-add': { write: true, frozen: true },
41
+ 'mcpm-edit': { write: true, frozen: true },
42
+ 'mcpm-remove': { write: true, frozen: true },
43
+ 'mcpm-import': { write: true, frozen: true },
44
+ 'mcpm-compact': { write: true, frozen: true },
45
+ 'mcpm-note': { write: true, frozen: true },
46
+ 'mcpm-settings': { write: true, frozen: true },
47
+ 'mcpm-set-enabled': { write: true, frozen: true, syncsArchive: true },
48
+ 'mcpm-set-all': { write: true, frozen: true, syncsArchive: true },
49
+ 'mcpm-tool-enabled': { write: true, frozen: true, syncsArchive: true },
50
+ // restart 不是"只重连":实现里两次 `writePatch`(先强制停用、轮询、再按重启前状态恢复),
51
+ // 恢复失败会停在停用态。锁定期间它是唯一能落盘改补丁的入口,所以按写门禁、但**不冻结**
52
+ // —— 它同时是"卡住了重连一下"这条恢复路径。
53
+ 'mcpm-restart': { write: true },
54
+ // 为了拿实时工具表会临时启用目标服务器、结束后恢复原状(两次 writePatch),恢复失败还
55
+ // 会停在启用态 —— 是写不是读。(0.6.0 / 0.7.0 各有漏列前科,见文件头。)
56
+ 'mcpm-tools-refresh': { write: true },
57
+ 'mcpm-tools': { readonly: true },
58
+ // ── 技能域(21,含内联的 skill-open)───────────────────────────────────────
59
+ 'skill-state': { annotatesLock: true },
60
+ 'skill-detail': { readonly: true },
61
+ 'skill-browse': { readonly: true },
62
+ 'skill-enable': { serviceWrite: true, frozen: true, syncsArchive: true },
63
+ 'skill-disable': { serviceWrite: true, frozen: true, syncsArchive: true },
64
+ 'skill-set-all': { serviceWrite: true, frozen: true, syncsArchive: true },
65
+ 'skill-source-enable': { serviceWrite: true, frozen: true, syncsArchive: true },
66
+ 'skill-source-disable': { serviceWrite: true, frozen: true, syncsArchive: true },
67
+ // 移除 / 恢复来源会改写本地来源状态(候选清单随之变化,模型侧的技能目录也变),是写。
68
+ 'skill-source-remove': { serviceWrite: true, frozen: true },
69
+ 'skill-source-restore': { serviceWrite: true, frozen: true },
70
+ 'skill-prefer': { serviceWrite: true, frozen: true },
71
+ 'skill-unprefer': { serviceWrite: true, frozen: true },
72
+ 'skill-create': { serviceWrite: true, frozen: true },
73
+ 'skill-import': { serviceWrite: true, frozen: true },
74
+ 'skill-upload': { serviceWrite: true, frozen: true },
75
+ 'skill-delete': { serviceWrite: true, frozen: true },
76
+ 'skill-trash-restore': { serviceWrite: true, frozen: true },
77
+ 'skill-trash-delete': { serviceWrite: true, frozen: true },
78
+ 'skill-custom-add': { serviceWrite: true, frozen: true },
79
+ 'skill-custom-remove': { serviceWrite: true, frozen: true },
80
+ // 打开系统编辑器改文件:不改宿主状态,但它是"界面按钮"性质的写入口,按写门禁。
81
+ 'skill-open': { write: true },
82
+ // ── 记忆与场景域(27:memories 24 + archive-engine 3)──────────────────────
83
+ 'rules-list': { readonly: true },
84
+ 'rules-read': { readonly: true },
85
+ 'rules-budget': { readonly: true },
86
+ 'rules-diagnose': { readonly: true },
87
+ 'rules-trash-list': { readonly: true },
88
+ 'rules-create': { serviceWrite: true, frozen: true },
89
+ 'rules-update': { serviceWrite: true, frozen: true },
90
+ 'rules-remove': { serviceWrite: true, frozen: true },
91
+ 'rules-restore': { serviceWrite: true, frozen: true },
92
+ 'rules-toggle': { serviceWrite: true, frozen: true },
93
+ 'rules-import': { serviceWrite: true, frozen: true },
94
+ 'rules-attach': { serviceWrite: true, frozen: true },
95
+ 'rules-detach': { serviceWrite: true, frozen: true },
96
+ 'rules-trash-remove': { serviceWrite: true, frozen: true },
97
+ 'rules-set-index': { serviceWrite: true, frozen: true },
98
+ // 场景启停本身要可用(否则进不去也出不来),但它能把**当前场景清空** —— 而"当前场景已
99
+ // 锁定"是模型侧 `lockedSceneGuard` 唯一的判据,场景一空它就返回 null,四个写工具全放开,
100
+ // 运行时却仍是那个场景的档案态(2026-09-19 审计 T-32)。所以单独一刀,见 frozenScope。
101
+ 'rules-set-active': { serviceWrite: true, frozen: true, frozenScope: 'active-scene' },
102
+ 'rules-remove-scene': { serviceWrite: true, frozen: true, frozenScope: 'self-scene' },
103
+ 'rules-rebind-prompt': { serviceWrite: true },
104
+ // 锁定开关自己**不能**被冻结挡住,否则锁上了解不开。
105
+ 'rules-scene-lock': { serviceWrite: true },
106
+ // ⚠️ 口径不一致,登记时才看得出来:`rules-remove-scene` 冻结,而 `rules-create-scene` /
107
+ // `rules-update-scene` 不冻结 —— 都是改场景集合,三缺一。今天按现状登记(A1 只登记不改
108
+ // 行为),要统一得单独判一次,记在这里别丢。
109
+ 'rules-create-scene': { serviceWrite: true },
110
+ 'rules-update-scene': { serviceWrite: true },
111
+ 'scene-trash-list': { readonly: true },
112
+ 'scene-trash-restore': { serviceWrite: true },
113
+ 'scene-trash-delete': { serviceWrite: true },
114
+ 'scene-mode-get': { readonly: true },
115
+ // 进入场景前的「会改什么」预览:与 `scene-mode-set` 走同一批纯计划函数,但只读现状、
116
+ // 不落盘也不应用 —— 登记成 readonly 就是这条承诺的机器可查版本(改这个 op 的人
117
+ // 若往里加了 apply* / saveSlice,登记表与实现就对不上了)。
118
+ 'scene-mode-preview': { readonly: true },
119
+ 'scene-archive-save': { serviceWrite: true, frozen: true, frozenScope: 'self-scene' },
120
+ 'scene-mode-set': { serviceWrite: true },
121
+ // ── 子智能体域(10)────────────────────────────────────────────────────────
122
+ 'subagent-list': { annotatesLock: true },
123
+ 'subagent-get': { readonly: true },
124
+ 'subagent-trash-list': { readonly: true },
125
+ 'subagent-create': { serviceWrite: true, frozen: true },
126
+ 'subagent-update': { serviceWrite: true, frozen: true },
127
+ 'subagent-delete': { serviceWrite: true, frozen: true },
128
+ 'subagent-import': { serviceWrite: true, frozen: true },
129
+ 'subagent-toggle': { serviceWrite: true, frozen: true, syncsArchive: true },
130
+ 'subagent-trash-restore': { serviceWrite: true, frozen: true },
131
+ 'subagent-trash-delete': { serviceWrite: true, frozen: true },
132
+ // ── 提示词域(11)──────────────────────────────────────────────────────────
133
+ 'agentsmd-list': { annotatesLock: true },
134
+ 'agentsmd-read': { readonly: true },
135
+ 'agentsmd-get-current': { readonly: true },
136
+ 'agentsmd-trash-list': { readonly: true },
137
+ 'agentsmd-create': { write: true, frozen: true },
138
+ 'agentsmd-update': { write: true, frozen: true },
139
+ // apply 写全局 AGENTS.md = 换掉生效基线。
140
+ 'agentsmd-apply': { write: true, frozen: true },
141
+ 'agentsmd-remove': { write: true, frozen: true },
142
+ 'agentsmd-import': { write: true, frozen: true },
143
+ 'agentsmd-trash-restore': { write: true, frozen: true },
144
+ 'agentsmd-trash-delete': { write: true, frozen: true },
145
+ // ── 历史会话域(16)────────────────────────────────────────────────────────
146
+ 'history-list': { readonly: true },
147
+ 'history-sessions': { readonly: true },
148
+ 'history-export-defaults': { readonly: true },
149
+ 'history-retention-get': { readonly: true },
150
+ 'dir-list': { readonly: true },
151
+ 'history-archive': { write: true },
152
+ 'history-unarchive': { write: true },
153
+ 'history-delete': { write: true },
154
+ 'history-archive-batch': { write: true },
155
+ 'history-unarchive-batch': { write: true },
156
+ 'history-delete-batch': { write: true },
157
+ 'history-retention-set': { write: true },
158
+ 'history-import': { write: true },
159
+ 'history-export': { write: true },
160
+ // 通用导出:往**用户指定的目录**写文件,按写操作门禁。
161
+ 'bundle-export': { write: true },
162
+ // 新增一条宿主工作区登记 —— 改的是宿主侧的登记,不是插件自己的侧车。
163
+ 'history-workspace-register': { write: true },
164
+ // ── 兼容 / 注入 / 备份域(7)───────────────────────────────────────────────
165
+ 'compat-status': { readonly: true },
166
+ 'feature-overview': { readonly: true },
167
+ 'preset-reach': { readonly: true },
168
+ // 五个域当前正文的**全文**都从这里出去。今天不带令牌就能读,是这批只读里披露面最大的一条;
169
+ // 要不要上门禁是产品决定,登记在这里是为了让它可数。
170
+ 'injection-live': { readonly: true },
171
+ 'backups-list': { readonly: true },
172
+ // 注入设置(五域开关 / 压制型预设口径)写侧车。它是"配置"不是"宿主状态",但改的是
173
+ // 投递语义,按写操作门禁。
174
+ 'inject-settings': { write: true },
175
+ // 模型工具表(哪些工具根本不发给模型)也写侧车,改的是每轮请求的内容 —— 同上按写门禁。
176
+ 'tool-table': { write: true },
177
+ // 场景页的界面设置(进入场景前要不要弹「会改什么」的预览卡):写侧车,按写门禁。
178
+ // **刻意不冻结**:它只是界面提示,锁着场景的人在场景页照样该能关掉提醒 —— 冻结的是五个
179
+ // 管理域的改动,不是这个页面的显示偏好(判据见文件头 write/frozen 两段的边界)。
180
+ 'scene-settings': { write: true },
181
+ // 清理 patch 备份:删磁盘文件(备份里含明文凭据副本),按写操作门禁。
182
+ 'backups-clean': { write: true },
183
+ // ── 场景候选源(3)与内联(1)──────────────────────────────────────────────
184
+ 'model-candidates': { readonly: true },
185
+ 'scene-inventory': { readonly: true },
186
+ // 跨域悬空引用体检:只读对账(索引走 readIndexSync,权威集合各读一次)。
187
+ 'state-doctor': { readonly: true },
188
+ // preset-tools 是只读枚举,但枚举会为预设建立 standing mount(官方语义:每进程只挂一次)。
189
+ // 未授权调用者不该触发挂载 —— 按写门禁。
190
+ 'preset-tools': { write: true },
191
+ 'plugin-version': { readonly: true },
192
+ });
193
+ /** 登记表里所有带某个标志的 op(按登记顺序,不去排序 —— 顺序稳定便于比对 diff)。 */
194
+ export function opsWith(flag) {
195
+ const out = [];
196
+ for (const [op, cls] of Object.entries(OP_REGISTRY))
197
+ if (cls[flag] === true)
198
+ out.push(op);
199
+ return out;
200
+ }
201
+ /** 冻结类 op。`scope` 省略 = 全部三种口径都要挡。 */
202
+ export function frozenOps(scope) {
203
+ const out = [];
204
+ for (const [op, cls] of Object.entries(OP_REGISTRY)) {
205
+ if (cls.frozen !== true)
206
+ continue;
207
+ if (scope !== undefined && (cls.frozenScope ?? 'all') !== scope)
208
+ continue;
209
+ out.push(op);
210
+ }
211
+ return out;
212
+ }
213
+ /**
214
+ * 对账:真实 op 表里**没在登记表出现**的键。
215
+ *
216
+ * 这就是 A1 要的那台机器 —— 新加一个 op 而忘了归类,这里立刻数得出来。判据是"要么登记过,
217
+ * 要么所属 service 自报过写 op 且登记过",两者都指向同一个条件:登记表里有这一条。
218
+ */
219
+ export function unclassifiedOps(opNames) {
220
+ return [...opNames].filter((op) => OP_REGISTRY[op] === undefined).sort();
221
+ }
222
+ /** 反方向:登记表里有条目、真实 op 表里却没有 → 改名或删 op 时忘了同步(幽灵条目)。 */
223
+ export function staleRegistryEntries(opNames) {
224
+ const live = new Set(opNames);
225
+ return Object.keys(OP_REGISTRY).filter((op) => !live.has(op)).sort();
226
+ }
227
+ /**
228
+ * `serviceWrite` 的双向核对:登记表说"这条由 service 自报",service 真自报了吗?
229
+ * 反过来,service 自报的写 op 都在登记表里吗?两个方向都查,谁改了必须动另一边。
230
+ */
231
+ export function serviceWriteMismatch(reported) {
232
+ const declared = new Set(Object.entries(OP_REGISTRY).filter(([, cls]) => cls.serviceWrite === true).map(([op]) => op));
233
+ const actual = new Set(reported);
234
+ return {
235
+ missing: [...actual].filter((op) => !declared.has(op)).sort(),
236
+ extra: [...declared].filter((op) => !actual.has(op)).sort(),
237
+ };
238
+ }
239
+ /** 自相矛盾的条目:既声明只读又声明要令牌 / 泄露明文。(登记时手滑的兜底。) */
240
+ export function contradictoryEntries() {
241
+ return Object.entries(OP_REGISTRY)
242
+ .filter(([, cls]) => cls.readonly === true && (cls.write === true || cls.sensitive === true))
243
+ .map(([op]) => op)
244
+ .sort();
245
+ }
246
+ /**
247
+ * 一次性对账(纯函数,无副作用):把登记表与**真实** op 表四个方向都比一遍。
248
+ *
249
+ * 为什么用真实 op 表而不是再抄一份清单:登记表本身也可能烂。只有拿 `handlers` 的键集合来
250
+ * 对,"加了 op 忘了登记"才会当场暴露 —— 这正是 F-4 那四次失守的共同形状。
251
+ */
252
+ export function auditOpRegistry(opNames, serviceWriteOps) {
253
+ const names = [...opNames];
254
+ const mismatch = serviceWriteMismatch(serviceWriteOps);
255
+ return {
256
+ unclassified: unclassifiedOps(names),
257
+ stale: staleRegistryEntries(names),
258
+ serviceWriteMissing: mismatch.missing,
259
+ serviceWriteExtra: mismatch.extra,
260
+ contradictory: contradictoryEntries(),
261
+ total: names.length,
262
+ };
263
+ }
264
+ /** 对账结果里所有问题项(空数组 = 全绿)。 */
265
+ export function auditProblems(audit) {
266
+ return [
267
+ ...audit.unclassified.map((op) => op + '(未归类)'),
268
+ ...audit.stale.map((op) => op + '(幽灵条目)'),
269
+ ...audit.serviceWriteMissing.map((op) => op + '(service 自报写、登记表未标)'),
270
+ ...audit.serviceWriteExtra.map((op) => op + '(登记表标了写、service 未自报)'),
271
+ ...audit.contradictory.map((op) => op + '(只读与写自相矛盾)'),
272
+ ];
273
+ }
package/lib/ops/compat.js CHANGED
@@ -110,6 +110,10 @@ export function buildCompatOps(deps) {
110
110
  },
111
111
  // 注入设置(读 / 写):压制型预设下是否仍然注入 + 各域开关。界面在「兼容」页。
112
112
  'inject-settings': (args) => deps.injectSettingsOp(args),
113
+ // 模型工具表(读 / 写):哪些工具**根本不发**给模型。工具表按每个请求付钱,关掉的
114
+ // 整份不进请求(与 MCP 停用那半边的区别:那些是宿主工具、关掉仍留在表里;这些是
115
+ // 我们自己的工具,关掉两边一起生效)。返回里带分组体积,界面据此显示 ≈token。
116
+ 'tool-table': (args) => deps.toolTableOp(args),
113
117
  // 功能总览(B6):**按功能点**回答"现在每一项到底能不能用",三层合成 ——
114
118
  // ① 装配层(listener / provider / 适配是否真的挂上,来自运行时上报通道)
115
119
  // ② 宿主能力层(compat 探测的路由判定)
@@ -141,7 +145,7 @@ export function buildCompatOps(deps) {
141
145
  ['official-suppression', '官方注入的关域拦截', 'compat'],
142
146
  ['skills-provider', '技能 provider 装配', 'skills'],
143
147
  ['projection-cache-adapter', '投影缓存删除屏障', 'sessions'],
144
- ['mcp-tool-visibility', '停用工具的可见性', 'mcp'],
148
+ ['mcp-tool-visibility', '工具表可见性(停用 / 关掉的工具不下发)', 'mcp'],
145
149
  ];
146
150
  for (const [noteId, label, tab] of assemblyRows) {
147
151
  const note = notes.get(noteId);
@@ -164,6 +168,15 @@ export function buildCompatOps(deps) {
164
168
  : token.accepted ? '已生效且本次启动已通过验证' : '已生效,本次启动尚未验证:写操作与对话会被拦住,到本页下方填写令牌');
165
169
  push('scene-lock', '场景锁定', 'scenes', lockedScenes.length === 0 ? 'ok' : 'locked', lockedScenes.length === 0 ? '无锁定场景' : `锁定中:${lockedScenes.join('、')}(写门禁按锁定场景生效)`);
166
170
  push('mcp-tools', 'MCP 工具停用', 'mcp', disabledTools === 0 ? 'ok' : 'partial', disabledTools === 0 ? '没有停用的工具' : `${disabledTools} 个工具处于停用态(执行拦截 + 可见性摘除)`);
171
+ // 工具表按每个请求付钱:这一行回答"这一轮实际发出去多少"。关掉的工具整份不进请求,
172
+ // 但代价是模型调不到它们(本插件的面板不受影响)——所以是 partial,不是 ok。
173
+ // 末尾那句是**逐会话**的差额:官方 `skill` 工具在场的会话里我们那份加载器会再让位
174
+ // 一个(见 index.ts 的 CARRIER_DUPLICATES),本表的数字是全局口径、不含它。
175
+ const table = deps.toolTableReport();
176
+ const carrierNote = ';官方 `skill` 工具在场的会话,`skill_manager_read` 还会自动让位一份';
177
+ push('tool-table', '模型工具表', 'compat', table.hiddenCount === 0 ? 'ok' : 'partial', (table.hiddenCount === 0
178
+ ? `${table.totalCount} 个工具全部下发(≈${table.totalTok} tok/轮)`
179
+ : `关掉 ${table.hiddenCount}/${table.totalCount} 个:一轮少发 ≈${table.hiddenTok} tok(现在 ≈${table.visibleTok} tok/轮,面板不受影响)`) + carrierNote);
167
180
  push('native-delete', '宿主原生删除入口', 'compat', notes.has('workspace.delete-native') ? 'partial' : 'ok', notes.get('workspace.delete-native')?.detail ?? '宿主未提供原生删除入口(本插件自有完整序列)');
168
181
  // 身份 / 版本:与 compat-status 同一份数据。
169
182
  const identity = assessment?.identity;
@@ -209,7 +222,7 @@ export function buildCompatOps(deps) {
209
222
  ...(r.failed.length ? { failedCount: r.failed.length, failedNames: r.failed } : {}),
210
223
  };
211
224
  },
212
- // 注入实况(只读):最近活跃会话里模型**真正看到**的五域文本 + 本次运行的投递统计。
225
+ // 注入实况(只读):最近活跃会话里模型**真正看到**的五域文本 + 那一段对话的投递统计。
213
226
  // 回答"勾了开关到底送没送到"——界面配置与实际注入不一致时,这里一眼可见。
214
227
  'injection-live': async () => {
215
228
  const live = deps.getContextInjectorLive();
@@ -0,0 +1,115 @@
1
+ // src/ops/state-doctor.ts —— 跨域**悬空引用**体检(只读)。
2
+ //
3
+ // 为什么需要它:这个插件到处在存"别人的名字" —— 场景档案存 MCP 服务器名、技能 key、
4
+ // 人设名,场景记录存提示词预设 id,模式快照存进场景前的那一整套。名字会过期:改名与删除
5
+ // 之后,存着旧名的那一条**不会报错,只是再也不匹配任何东西**。界面因此骗人:
6
+ // 「档案里还写着 代码审查」→ 看着像绑着,委派时按新名找不到,设置像在、其实不生效。
7
+ // 这一类已经真出过两次(0.9.1 人设改名、0.10.0 预设改名),所以缺的不是又一份同步代码,
8
+ // 是一个把全部悬空引用一次数出来的出口。
9
+ //
10
+ // 为什么是一个 op 而不是一份"引用表":那要求所有写路径都改成先登记再引用,是一次大动作,
11
+ // 而且要改官方 provider 的写入语义。这里只做**读侧对账**:拿现有的权威集合
12
+ // (配置里的服务器 / 实时发现的技能 / 人设 / 预设清单)去比现有的存储,比不上的说出来。
13
+ //
14
+ // 只读纪律:本模块不写任何文件。索引走 `readIndexSync`(契约见 memories/index-io.ts 的注释:
15
+ // 返回的可能是缓存实例,只许读);**不走** `rules-list` / `snapshot()` —— 那两个会做悬空
16
+ // 记忆清理并整份覆盖索引,那样一来"体检"自己就成了改动。
17
+ //
18
+ // 判据与界面共用:这里的"存在"与场景页 / 档案编辑器同一套口径(`configuredServers` /
19
+ // `knownSkillKeys` / `knownPersonas` / `promptsService.list`),不另起一套。
20
+ const toSet = (list) => (list === null ? null : new Set(list));
21
+ /** 跑一次体检。纯读,任何一域的权威集合读不到就跳过那一类并如实记下来。 */
22
+ export async function runStateDoctor(deps) {
23
+ const findings = [];
24
+ const skipped = [];
25
+ const index = deps.index() || {};
26
+ const scenes = index.scenes || {};
27
+ const archives = index.archives || {};
28
+ const presets = toSet(await deps.presetIds());
29
+ if (presets === null)
30
+ skipped.push('prompts');
31
+ const servers = toSet(await deps.serverNames());
32
+ if (servers === null)
33
+ skipped.push('mcp');
34
+ const skills = await deps.skillKeys();
35
+ if (skills === null)
36
+ skipped.push('skills');
37
+ const personas = await deps.personaNames();
38
+ if (personas === null)
39
+ skipped.push('subagents');
40
+ // ① 场景记录 → 提示词预设。界面上那条 `scenes.prompt.missing` 标签今天**永远不亮**
41
+ // (宿主把"绑的预设读不到正文"当成"没绑",见 memories/service.ts 的 resolveScenePreset),
42
+ // 所以改名留下的悬空绑定在别处没有任何地方说得出来 —— 这一类是本 op 的主要新增价值。
43
+ if (presets !== null) {
44
+ for (const scene of Object.keys(scenes)) {
45
+ const bound = String(scenes[scene]?.prompt || '');
46
+ if (bound !== '' && !presets.has(bound)) {
47
+ findings.push({ where: `场景「${scene}」绑定的提示词预设`, kind: 'prompt', name: bound });
48
+ }
49
+ }
50
+ }
51
+ // ② 档案三段 + 备注段:勾选集里的名字逐个对权威集合。
52
+ //
53
+ // (这里原本还想查"档案表里孤儿场景键"。**没做**,因为它要的权威集合是"哪些场景存在",
54
+ // 而那份只能从 `snapshot()` 拿 —— 那个函数会顺手清理悬空记忆并整份覆盖索引,
55
+ // 一个"体检"op 自己先把状态改了是不可接受的。少一个检查远比误报一条好:
56
+ // 索引没镜像到目录场景时,报"这份档案没人读"是假话。)
57
+ for (const scene of Object.keys(archives)) {
58
+ const archive = archives[scene] || {};
59
+ if (servers !== null) {
60
+ for (const server of Object.keys(archive.mcp || {})) {
61
+ if (!servers.has(server))
62
+ findings.push({ where: `场景「${scene}」的 MCP 勾选`, kind: 'mcp', name: server });
63
+ }
64
+ for (const server of Object.keys(archive.mcpNotes || {})) {
65
+ if (!servers.has(server))
66
+ findings.push({ where: `场景「${scene}」的场景备注`, kind: 'mcpNote', name: server });
67
+ }
68
+ }
69
+ if (skills !== null) {
70
+ for (const key of Array.isArray(archive.skills) ? archive.skills : []) {
71
+ if (!skills.has(String(key)))
72
+ findings.push({ where: `场景「${scene}」的技能勾选`, kind: 'skill', name: String(key) });
73
+ }
74
+ }
75
+ if (personas !== null) {
76
+ for (const name of Array.isArray(archive.subagents) ? archive.subagents : []) {
77
+ if (!personas.has(String(name)))
78
+ findings.push({ where: `场景「${scene}」的人设勾选`, kind: 'persona', name: String(name) });
79
+ }
80
+ }
81
+ }
82
+ // ⑥ 模式快照:退出场景按它整体回写,所以快照里的悬空名字**比档案里的更贵** ——
83
+ // 档案的悬空项在应用时被补集成 stale 名单,快照则会在退出时原样写回去。
84
+ const snapshot = index.mode && index.mode.snapshot;
85
+ if (snapshot) {
86
+ const modeScene = String((index.mode && index.mode.scene) || '(当前场景)');
87
+ if (skills !== null) {
88
+ for (const key of Object.keys(snapshot.skills || {})) {
89
+ if (!skills.has(key))
90
+ findings.push({ where: `模式快照(退出「${modeScene}」时要回写的技能)`, kind: 'snapshot', name: key });
91
+ }
92
+ }
93
+ if (servers !== null) {
94
+ for (const server of Object.keys(snapshot.mcp || {})) {
95
+ if (!servers.has(server))
96
+ findings.push({ where: `模式快照(退出「${modeScene}」时要回写的 MCP 停用表)`, kind: 'snapshot', name: server });
97
+ }
98
+ }
99
+ if (personas !== null) {
100
+ const personaNames = new Set([
101
+ ...Object.keys(snapshot.subagentsAll || {}),
102
+ ...(Array.isArray(snapshot.subagents) ? snapshot.subagents : []),
103
+ ...(Array.isArray(snapshot.subagentsOn) ? snapshot.subagentsOn : []),
104
+ ]);
105
+ for (const name of personaNames) {
106
+ if (!personas.has(String(name)))
107
+ findings.push({ where: `模式快照(退出「${modeScene}」时要回写的人设)`, kind: 'snapshot', name: String(name) });
108
+ }
109
+ }
110
+ }
111
+ const counts = {};
112
+ for (const row of findings)
113
+ counts[row.kind] = (counts[row.kind] || 0) + 1;
114
+ return { findings, counts, skipped };
115
+ }
@@ -3,6 +3,7 @@
3
3
  // 这一域管「谁能改、改哪些、什么时候不许改」:
4
4
  // * 访问令牌(可选)—— 两条用途:宿主浏览器鉴权的逃生口,以及写操作的纵深防御;
5
5
  // * 写 / 敏感 op 白名单 —— 哪些 op 要凭令牌(含"只读但会泄露明文"与"只读但会改宿主状态");
6
+ // 清单本身在 `./op-registry.ts`,本文件只拼装(`opsWith` / `frozenOps`)。
6
7
  // * 场景锁定 —— 任一场景 locked=true 时五个管理域整体冻结;
7
8
  // * handlers 后处理 —— 把上面三条装到 op 表上(包装顺序即语义,见 installHandlerGuards)。
8
9
  //
@@ -14,6 +15,7 @@
14
15
  // 都是「读原函数 → 换包装」的原地改写,先装的在内层、后装的在外层;`annotateLocked` 只挂在
15
16
  // 五个读 op 上。整段照搬、顺序未变。
16
17
  import { createHash, timingSafeEqual } from 'node:crypto';
18
+ import { frozenOps, opsWith } from './op-registry.js';
17
19
  /**
18
20
  * 建访问令牌三件套。**必须**从 apply 的第二个参数取配置 ——
19
21
  * 读 `ctx.config` 不是注入服务,启动时会抛 "cannot get property without inject"。
@@ -96,56 +98,18 @@ export function createAccessToken(deps) {
96
98
  }
97
99
  /** 建白名单。必须在四个 service 创建之后构造(依赖其 writeOps)。 */
98
100
  export function createOpWhitelist(deps) {
99
- // HTTP 写操作门禁清单。skills/rules/档案引擎域由各自 service 导出的 writeOps 派生
100
- // (与其 ops 表同文件维护,新增写 op 改对应 service 即可);本文件内联域
101
- // (mcpm-* / skill-open / agentsmd-* / history-*)在此列举。
102
- // 注意:必须在上述 service 创建之后构造(依赖其 writeOps)。
101
+ // 清单本身在 `./op-registry.ts` 里逐条登记(判据、口径、为什么)。这里只做拼装:
102
+ // 写 = 四个 service 自报的 writeOps ∪ 登记表里标了 write 的内联域;敏感 = 登记表标 sensitive。
103
103
  const WRITE_OPS = new Set([
104
104
  ...deps.writeOps.skills,
105
105
  ...deps.writeOps.memories,
106
106
  ...deps.writeOps.archives,
107
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',
108
+ ...opsWith('write'),
145
109
  ]);
146
110
  // 会泄露明文凭据 / 完整配置的 op:**必须**带对的访问令牌,没配令牌就一律拒绝
147
111
  // (判定在 http-fence.ts 的 secretOpRejection,含两种情况的区分与理由)。
148
- const SENSITIVE_OPS = new Set(['mcpm-reveal', 'mcpm-export']);
112
+ const SENSITIVE_OPS = new Set(opsWith('sensitive'));
149
113
  return { WRITE_OPS, SENSITIVE_OPS };
150
114
  }
151
115
  // ---------- 场景锁定守卫(v0.8)----------
@@ -214,37 +178,16 @@ export function installHandlerGuards(deps) {
214
178
  };
215
179
  }
216
180
  }
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
- ], '修改');
181
+ // 冻结清单与档案同步清单都来自 `./op-registry.ts`:加 op 时在那里登记一条,这里不再抄。
182
+ guardLockedOps(frozenOps('all'), '修改');
235
183
  // 场景内「开关」类操作(用户裁定 2026-09-17):未锁定时**可用**,改动同步进当前场景档案。
236
184
  // 与「锁定」正交:锁定冻结全部写操作,这里只是把页面开关的意图也写进档案。
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', '切换场景']]) {
185
+ syncSceneArchiveOnSwitch(opsWith('syncsArchive'));
186
+ // 口径 `frozenScope: 'active-scene'` 的那几条(判据与理由登记在 op-registry.ts 的
187
+ // `rules-set-active` 条目上)。没有锁定场景在生效时(全局态 / 场景未锁)照旧可用。
188
+ const ACTIVE_SCENE_LABEL = { 'rules-set-active': '切换场景' };
189
+ for (const opName of frozenOps('active-scene')) {
190
+ const what = ACTIVE_SCENE_LABEL[opName] ?? '改它';
248
191
  const original = handlers[opName];
249
192
  if (typeof original !== 'function')
250
193
  continue;
@@ -264,16 +207,16 @@ export function installHandlerGuards(deps) {
264
207
  return original(args);
265
208
  };
266
209
  }
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
- ]) {
210
+ // 被锁场景自身的档案与删除:只挡它自己,别的场景照常(口径 `frozenScope: 'self-scene'`)。
211
+ // 目标场景一律从 `args.scene` / `args.name` 里取 —— 这两个键名就是本插件 op 的惯例。
212
+ const SELF_SCENE_LABEL = { 'scene-archive-save': '改档案', 'rules-remove-scene': '删除' };
213
+ for (const opName of frozenOps('self-scene')) {
214
+ const what = SELF_SCENE_LABEL[opName] ?? '改它';
272
215
  const original = handlers[opName];
273
216
  if (typeof original !== 'function')
274
217
  continue;
275
218
  handlers[opName] = async (args) => {
276
- const scene = String(pickScene(args) || '').trim();
219
+ const scene = String((args && (args.scene || args.name)) || '').trim();
277
220
  const locked = await lockedSceneNames();
278
221
  if (scene && locked.includes(scene))
279
222
  return { ok: false, error: `场景「${scene}」已锁定:先解锁再${what}` };
@@ -298,7 +241,7 @@ export function installHandlerGuards(deps) {
298
241
  }
299
242
  return res;
300
243
  }
301
- for (const opName of ['mcpm-list', 'mcpm-reveal', 'skill-state', 'subagent-list', 'agentsmd-list']) {
244
+ for (const opName of opsWith('annotatesLock')) {
302
245
  const original = handlers[opName];
303
246
  if (typeof original !== 'function')
304
247
  continue;
@@ -0,0 +1,17 @@
1
+ // 场景页的界面设置(侧车 `scene-settings.json`,界面在场景页右上的按钮)。
2
+ //
3
+ // 目前只有一项:进入场景前要不要先弹那张「会改什么」的卡。它是**界面提示**,不是管理动作 ——
4
+ // 所以它既不受场景锁定影响(锁着场景照样能关提醒),也不参与任何门禁判定;改了只影响下一次
5
+ // 点「启用这个场景」时弹不弹卡。
6
+ //
7
+ // 为什么放在服务端而不是浏览器本地:与其余设置同一个家(侧车 + op + 登记表),换浏览器
8
+ // 也在;写在 localStorage 里就只有那个浏览器认,而这个开关的意义正是"我不想每次都被拦一下",
9
+ // 换了浏览器又冒出来会显得是 bug。
10
+ export const DEFAULT_SCENE_SETTINGS = { enterPreview: true };
11
+ /** 把任意输入夹成合法设置(缺项/类型不对一律退回默认:默认不阻止任何确认)。 */
12
+ export function normalizeSceneSettings(raw) {
13
+ const obj = (raw && typeof raw === 'object' ? raw : {});
14
+ return {
15
+ enterPreview: typeof obj.enterPreview === 'boolean' ? obj.enterPreview : DEFAULT_SCENE_SETTINGS.enterPreview,
16
+ };
17
+ }