dsh-subagent-profile 0.3.2 → 0.3.3

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.
@@ -1,21 +1,19 @@
1
- // lib/core/http-routes.mjs — settings HTTP loopback routes for the Client UI.
2
- // Read routes (list / options summary / per-model efforts / tools) + write
3
- // routes (set-enabled / add / remove / reset / reset-all / set-profile-enabled).
4
- // The /options catalog (models / presets / tools / efforts) is served from the
5
- // shared catalog cache (lib/core/catalog-cache.mjs), so these routes no longer
6
- // walk the llm directory themselves.
1
+ // lib/core/http-routes.mjs — Client UI 的设置 HTTP loopback 路由。
2
+ // 读路由(list / options 摘要 / 按模型 efforts / tools)+ 写路由
3
+ // set-enabled / add / remove / reset / reset-all / set-profile-enabled)。
4
+ // /options 目录(models / presets / tools / efforts)由共享 catalog 缓存
5
+ // lib/core/catalog-cache.mjs)提供,这些路由不再自行遍历 llm 目录。
7
6
  //
8
- // Injection: every apply-closure / ctx dependency is an explicit parameter —
9
- // store the profile store (profiles Map / persistProfiles /
10
- // persistEnabled / deletedBuiltins),
11
- // getEnabled reads the apply-closure `enabled` flag (mutated by
12
- // /set-enabled),
13
- // setEnabled writes it,
14
- // syncTool unregisters/registers the dispatch tool on /set-enabled,
15
- // catalog the shared catalog cache (getSnapshot / invalidate),
16
- // logger ctx.logger (route error warnings).
17
- // The factory returns the scope.effect setup function so the caller keeps the
18
- // exact original registration shape: effect(() => register + disposer).
7
+ // 注入:所有 apply 闭包 / ctx 依赖都是显式参数——
8
+ // store profile storeprofiles Map / persistProfiles /
9
+ // persistEnabled / deletedBuiltins),
10
+ // getEnabled 读取 apply 闭包 `enabled` 标志(被 /set-enabled 改写),
11
+ // setEnabled 写它,
12
+ // syncTool 在 /set-enabled 时注销/注册 dispatch 工具,
13
+ // catalog 共享 catalog 缓存(getSnapshot / invalidate),
14
+ // logger ctx.logger(路由错误告警)。
15
+ // 工厂返回 scope.effect 设置函数,调用方保持原有注册形状:
16
+ // effect(() => register + disposer)。
19
17
  //
20
18
  // 写路由各抽为模块级处理函数(if 链分发保持);/options 拆三读路由 + 手动刷新,
21
19
  // 目录数据统一来自 catalog 快照。
@@ -24,9 +22,18 @@ import { sanitizeProfile } from './pure.mjs';
24
22
  import { BUILTIN_SEEDS } from './profiles-store.mjs';
25
23
  import { detectVersions } from './shims.mjs';
26
24
 
27
- // Only the loopback interfaces may drive the settings HTTP routes.
25
+ // 只有 loopback 接口可以驱动设置 HTTP 路由。
28
26
  const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
29
27
 
28
+ // 写路由 CSRF 传输授权三件套。loopback 只挡「来源不是本机」;这三件挡「浏览器里的
29
+ // 跨域表单/脚本冒充本机提交」——schema 校验的是数据形状,管不到谁有权写。
30
+ // URL.hostname 对 IPv6 字面量返回带方括号形态(http://[::1]:port → '[::1]'),
31
+ // 两种形态都列入,避免 IPv6 loopback 源被误拒。
32
+ const LOOPBACK_ORIGIN_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
33
+ const CSRF_HEADER = 'X-DSH-Plugin';
34
+ const CSRF_HEADER_KEY = CSRF_HEADER.toLowerCase();
35
+ const CSRF_HEADER_VALUE = 'dsh-subagent-profile';
36
+
30
37
  function json(res, code, data) {
31
38
  res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
32
39
  res.end(JSON.stringify(data));
@@ -48,10 +55,9 @@ function readBody(req) {
48
55
  });
49
56
  }
50
57
 
51
- // Write-failure contract: the write routes return HTTP 200 with
52
- // `persisted` always present; when the disk write failed, persistWarning
53
- // explains "已保存但未持久化" (in-memory state drives this process, the
54
- // disk did not update). The client renders that as the amber warning.
58
+ // 写失败契约:写路由返回 HTTP 200 `persisted` 恒在场;磁盘写失败时
59
+ // persistWarning 说明「已保存但未持久化」(内存态驱动本进程,磁盘未更新)。
60
+ // 客户端把它渲染为琥珀色警告。
55
61
  function persistOk(res, payload, persist) {
56
62
  return json(res, 200, {
57
63
  ok: true,
@@ -65,8 +71,8 @@ function listClean(store) {
65
71
  return [...store.profiles.values()].map((profile) => {
66
72
  const clean = {};
67
73
  for (const [key, value] of Object.entries(profile)) if (value !== undefined && key !== 'persisted') clean[key] = value;
68
- // The internal `persisted` flag is stripped above; expose a UI-facing
69
- // "modified" signal so the reset panel can label a changed builtin.
74
+ // 内部 `persisted` 标志已在上方剥离;对外暴露 UI 面向的 "modified"
75
+ // 信号,供重置面板标注被改过的内置方案。
70
76
  if (profile.builtin === true && profile.persisted === true) clean.modified = true;
71
77
  return clean;
72
78
  });
@@ -78,10 +84,20 @@ async function handleList(deps, res) {
78
84
  return json(res, 200, { ok: true, profiles: listClean(deps.store) });
79
85
  }
80
86
 
81
- // 轻量摘要:enabled + 模型目录(含 provider 信息,客户端据此派生提供方列表)+ 预设名册。
87
+ // 轻量摘要:enabled + evolutionAdvice + 模型目录(含 provider 信息,客户端据此派生提供方列表)+ 预设名册
88
+ // + 审计分级(lostTelemetry/lostGovernance/health,供设置页红字与丢失计数展示)。
82
89
  async function handleSummary(deps, res) {
83
90
  const snapshot = await deps.catalog.getSnapshot();
84
- return json(res, 200, { ok: true, enabled: deps.getEnabled(), models: snapshot.models, presets: snapshot.presets });
91
+ return json(res, 200, {
92
+ ok: true,
93
+ enabled: deps.getEnabled(),
94
+ evolutionAdvice: deps.getEvolutionAdvice(),
95
+ models: snapshot.models,
96
+ presets: snapshot.presets,
97
+ audit: deps.getAudit(),
98
+ escapeEnabled: deps.getEscapeEnabled(),
99
+ escapePresets: deps.escape.list(),
100
+ });
85
101
  }
86
102
 
87
103
  // 每模型 reasoning-effort 等级(懒加载;model 命中快照的 efforts 表)。
@@ -104,26 +120,82 @@ function handleVersions(res) {
104
120
  return json(res, 200, { ok: true, versions, warnings });
105
121
  }
106
122
 
107
- // 手动刷新:清缓存兜底(TTL 过期前的目录变更经此立即生效)。
123
+ // 手动刷新:清缓存兜底(TTL 过期前的目录变更经此立即生效)+ 重算 evolution:advice
124
+ // 聚合(T1:/options/refresh 此前不触发聚合,summaries.json 永无生成路径)。
108
125
  async function handleRefresh(deps, res) {
109
126
  deps.catalog.invalidate();
127
+ if (typeof deps.refreshAdvice === 'function') deps.refreshAdvice();
110
128
  return json(res, 200, { ok: true, refreshed: true });
111
129
  }
112
130
 
131
+ // 失败台账读取:GET /ledger/failures?session=<id> → { ok:true, failures:[...] }。
132
+ // 未知 session 返回空数组;台账是会话级内存结构,进程重启即失。
133
+ async function handleLedgerFailures(deps, url, res) {
134
+ const session = url.searchParams.get('session') ?? '';
135
+ return json(res, 200, { ok: true, failures: deps.ledger.get(session) });
136
+ }
137
+
113
138
  async function handleSetEnabled(deps, req, res) {
114
139
  const body = await readBody(req);
115
140
  const next = !!(body && body.enabled === true);
116
141
  deps.setEnabled(next);
117
- deps.store.persistEnabled(next);
142
+ const persist = deps.store.persistEnabled(next);
118
143
  deps.syncTool();
119
- return json(res, 200, { ok: true, enabled: deps.getEnabled() });
144
+ return persistOk(res, { enabled: deps.getEnabled() }, persist);
145
+ }
146
+
147
+ // 只读建议注入开关(默认关):改写 apply 闭包 evolutionAdvice 并持久化到 state.json。
148
+ // 不改派发行为(建议只读提示);写路由 CSRF 三件套自动生效。
149
+ async function handleSetEvolutionAdvice(deps, req, res) {
150
+ const body = await readBody(req);
151
+ const next = !!(body && body.advice === true);
152
+ deps.setEvolutionAdvice(next);
153
+ const persist = deps.store.persistEvolutionAdvice(next);
154
+ return persistOk(res, { evolutionAdvice: deps.getEvolutionAdvice() }, persist);
155
+ }
156
+
157
+ // 逃生舱开关(默认关):改写 apply 闭包 escapeEnabled 并持久化到 state.json。
158
+ // 只放行「放行集内且其余三道闸全过」的非 system 预设;非成员即便开关开也恒拒。
159
+ async function handleSetEscape(deps, req, res) {
160
+ const body = await readBody(req);
161
+ const next = !!(body && body.enabled === true);
162
+ deps.setEscapeEnabled(next);
163
+ const persist = deps.store.persistEscapeEnabled(next);
164
+ return persistOk(res, { escapeEnabled: deps.getEscapeEnabled() }, persist);
165
+ }
166
+
167
+ // 放行集写路径校验:解析请求体的 preset id(非字符串/缺失归一为空串,由调用方
168
+ // 400 拒绝)。纯同步、无副作用。
169
+ function escapePresetId(body) {
170
+ const raw = body && typeof body === 'object' && typeof body.preset === 'string' ? body.preset : '';
171
+ return raw.trim();
172
+ }
173
+
174
+ async function handleEscapeAdd(deps, req, res) {
175
+ const body = await readBody(req);
176
+ const preset = escapePresetId(body);
177
+ if (preset === '') return json(res, 400, { ok: false, error: 'subagent-profiles: escape preset 必须为非空字符串' });
178
+ const snapshot = await deps.catalog.getSnapshot();
179
+ if (snapshot.presets.some((p) => p && p.id === preset)) {
180
+ return json(res, 400, { ok: false, error: `subagent-profiles: preset "${preset}" 已是 system-trust,无需加入逃生舱` });
181
+ }
182
+ const persist = deps.escape.add(preset);
183
+ return persistOk(res, { preset, presets: deps.escape.list() }, persist);
184
+ }
185
+
186
+ async function handleEscapeRemove(deps, req, res) {
187
+ const body = await readBody(req);
188
+ const preset = escapePresetId(body);
189
+ if (preset === '') return json(res, 400, { ok: false, error: 'subagent-profiles: escape preset 必须为非空字符串' });
190
+ const persist = deps.escape.remove(preset);
191
+ return persistOk(res, { preset, presets: deps.escape.list() }, persist);
120
192
  }
121
193
 
122
194
  async function handleAdd(deps, req, res) {
123
195
  const body = await readBody(req);
124
196
  const profile = body && typeof body === 'object' ? body : {};
125
197
  if (typeof profile.id !== 'string' || profile.id.length === 0) {
126
- return json(res, 400, { ok: false, error: 'subagent-profiles: profile id must be a non-empty string' });
198
+ return json(res, 400, { ok: false, error: 'subagent-profiles: profile id 必须为非空字符串' });
127
199
  }
128
200
  // 写路径上限:strict=true —— 超限/非法字段直接 400 拒绝,
129
201
  // 与 loadProfiles(strict=false 迁移宽松读取)的行为区分。列被拒字段与中文原因。
@@ -141,9 +213,8 @@ async function handleAdd(deps, req, res) {
141
213
  const existing = deps.store.profiles.get(clean.id);
142
214
  const seed = BUILTIN_SEEDS.find((s) => s.id === clean.id);
143
215
  const isBuiltin = (existing !== undefined && existing.builtin === true) || seed !== undefined;
144
- // Merge (not replace): start from the existing profile — or its seed when it
145
- // was deleted — so fields not present in the form (e.g. a builtin's
146
- // persona/preset) survive an edit or a re-add.
216
+ // 合并而非替换:从现有 profile(或它被删时的 seed)出发,表单未带的字段
217
+ // (如内置方案的 persona/preset)在编辑或重新添加后仍然保留。
147
218
  const merged = { ...(existing ?? seed ?? {}) };
148
219
  merged.id = clean.id;
149
220
  for (const key of ['name', 'description', 'preset', 'provider', 'model', 'reasoningEffort', 'persona', 'enabled']) {
@@ -173,7 +244,7 @@ async function handleRemove(deps, req, res) {
173
244
  const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
174
245
  const existing = deps.store.profiles.get(id);
175
246
  if (existing === undefined) {
176
- return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" does not exist` });
247
+ return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" 不存在(可在设置页确认可用的 profile id)` });
177
248
  }
178
249
  deps.store.profiles.delete(id);
179
250
  if (existing.builtin === true) deps.store.deletedBuiltins.add(id);
@@ -185,7 +256,7 @@ async function handleReset(deps, req, res) {
185
256
  const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
186
257
  const seed = BUILTIN_SEEDS.find((s) => s.id === id);
187
258
  if (seed === undefined) {
188
- return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" is not a builtin (nothing to reset)` });
259
+ return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" 不是内置方案(无可重置项)` });
189
260
  }
190
261
  deps.store.profiles.set(id, { ...seed });
191
262
  deps.store.deletedBuiltins.delete(id);
@@ -205,17 +276,45 @@ async function handleSetProfileEnabled(deps, req, res) {
205
276
  const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
206
277
  const existing = deps.store.profiles.get(id);
207
278
  if (existing === undefined) {
208
- return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" does not exist` });
279
+ return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" 不存在(可在设置页确认可用的 profile id)` });
209
280
  }
210
281
  existing.enabled = body && body.enabled === false ? false : true;
211
- // Persist unconditionally (not just for builtins): a runtime-registered
212
- // profile's enable/disable must also survive a restart.
282
+ // 无条件持久化(不只是内置方案):运行时注册的 profile 的启用/禁用也必须
283
+ // 在重启后保留。
213
284
  existing.persisted = true;
214
285
  return persistOk(res, { id, enabled: existing.enabled }, deps.store.persistProfiles());
215
286
  }
216
287
 
217
- export function createHttpRoutes({ webServer, store, getEnabled, setEnabled, syncTool, catalog, logger }) {
218
- const deps = { store, getEnabled, setEnabled, syncTool, catalog, logger };
288
+ // 写路由 CSRF 传输授权:Origin 白名单 + Content-Type + 自定义头三道检查。
289
+ // 返回 null 表示放行;否则返回 { reason, origin, contentType }——reason 403
290
+ // 响应与 warn 日志,origin/contentType 只进日志(二者本就是请求头,非秘密)。
291
+ // GET 只读路由不经过此处(只读无状态变更,不设防)。
292
+ function csrfViolation(req) {
293
+ const headers = req.headers ?? {};
294
+ const origin = typeof headers.origin === 'string' ? headers.origin : '';
295
+ const contentType = typeof headers['content-type'] === 'string' ? headers['content-type'] : '';
296
+ // 1. Origin 白名单:带 Origin 时只放行本机源(hostname 宽松判定,端口不限);
297
+ // 无 Origin 的本地脚本/curl 跳过此道,靠第 3 道自定义头兜底。
298
+ if (origin !== '') {
299
+ let host;
300
+ try { host = new URL(origin).hostname; } catch { host = null; }
301
+ if (host === null || !LOOPBACK_ORIGIN_HOSTS.has(host)) {
302
+ return { reason: `Origin 不在本机白名单(收到:${origin})`, origin, contentType };
303
+ }
304
+ }
305
+ // 2. 强制 Content-Type: application/json(text/plain、表单编码等一律拒绝)。
306
+ if (!contentType.toLowerCase().startsWith('application/json')) {
307
+ return { reason: `写请求必须带 Content-Type: application/json(收到:${contentType || '缺失'})`, origin, contentType };
308
+ }
309
+ // 3. 自定义头逼 preflight:跨域表单/脚本无法携带非简单头,缺头即拒。
310
+ if (headers[CSRF_HEADER_KEY] !== CSRF_HEADER_VALUE) {
311
+ return { reason: `写请求必须带 ${CSRF_HEADER}: ${CSRF_HEADER_VALUE} 请求头`, origin, contentType };
312
+ }
313
+ return null;
314
+ }
315
+
316
+ export function createHttpRoutes({ webServer, store, getEnabled, setEnabled, syncTool, catalog, ledger, getAudit, getEvolutionAdvice, setEvolutionAdvice, getEscapeEnabled, setEscapeEnabled, escape, refreshAdvice, logger }) {
317
+ const deps = { store, getEnabled, setEnabled, syncTool, catalog, ledger, getAudit, getEvolutionAdvice, setEvolutionAdvice, getEscapeEnabled, setEscapeEnabled, escape, refreshAdvice, logger };
219
318
  // 路由分发(if 链保持,判断顺序与 404/500 兜底不变)。
220
319
  const handler = async (req, res) => {
221
320
  const remote = req.socket?.remoteAddress;
@@ -223,13 +322,29 @@ export function createHttpRoutes({ webServer, store, getEnabled, setEnabled, syn
223
322
  const url = new URL(req.url ?? '/', 'http://localhost');
224
323
  const sub = (url.pathname.replace(/^\/subagent-profiles/, '') || '/').replace(/\/+$/, '') || '/';
225
324
  try {
325
+ // 写路由统一做 CSRF 传输授权(loopback 之后、路由分发之前);GET 只读不设防。
326
+ if (req.method === 'POST') {
327
+ const violation = csrfViolation(req);
328
+ if (violation !== null) {
329
+ deps.logger.warn(
330
+ `[dsh-subagent-profile] CSRF 防护拒绝:${violation.reason}` +
331
+ `(origin=${violation.origin || '无'},contentType=${violation.contentType || '无'})`
332
+ );
333
+ return json(res, 403, { ok: false, error: `CSRF 防护拒绝:${violation.reason}` });
334
+ }
335
+ }
226
336
  if (req.method === 'GET' && (sub === '/' || sub === '/list')) return handleList(deps, res);
227
337
  if (req.method === 'GET' && sub === '/options/summary') return handleSummary(deps, res);
228
338
  if (req.method === 'GET' && sub === '/options/versions') return handleVersions(res);
229
339
  if (req.method === 'GET' && sub === '/options/efforts') return handleEfforts(deps, url, res);
230
340
  if (req.method === 'GET' && sub === '/options/tools') return handleTools(deps, res);
341
+ if (req.method === 'GET' && sub === '/ledger/failures') return handleLedgerFailures(deps, url, res);
231
342
  if (req.method === 'POST' && sub === '/options/refresh') return handleRefresh(deps, res);
232
343
  if (req.method === 'POST' && sub === '/set-enabled') return handleSetEnabled(deps, req, res);
344
+ if (req.method === 'POST' && sub === '/set-evolution-advice') return handleSetEvolutionAdvice(deps, req, res);
345
+ if (req.method === 'POST' && sub === '/set-escape') return handleSetEscape(deps, req, res);
346
+ if (req.method === 'POST' && sub === '/escape-add') return handleEscapeAdd(deps, req, res);
347
+ if (req.method === 'POST' && sub === '/escape-remove') return handleEscapeRemove(deps, req, res);
233
348
  if (req.method === 'POST' && sub === '/add') return handleAdd(deps, req, res);
234
349
  if (req.method === 'POST' && sub === '/remove') return handleRemove(deps, req, res);
235
350
  if (req.method === 'POST' && sub === '/reset') return handleReset(deps, req, res);
@@ -1,16 +1,13 @@
1
- // lib/core/intersection.mjs — tool intersection(安全门 1)纯函数核心,从 index.mjs
1
+ // lib/core/intersection.mjs — tool intersection(安全门 1)纯函数核心,从 index.mjs
2
2
  // 的 provider start 拆出。无 @deepseek-ai 依赖。此处只做 allow 收窄计算;
3
3
  // 调用方(provider start)保留空集 fail-loud throw(错误文案逐字不变)与
4
4
  // restrict 的 try/catch 包裹。
5
5
  //
6
- // Relationship to lib/core/pure.mjs computeContinuableAllow(parentNames, toolFilter):
7
- // the continuable variant has NO childNames it assumes the child toolset ≈
8
- // parent toolset (continuable inherits the parent preset, so a true parent∩child
9
- // intersection cannot be recomputed there) and computes parent − run_code − deny
10
- // → allow. computeEffectiveAllow additionally intersects with the ACTUAL child
11
- // toolset (parentNames ∩ childNames), so it stays correct even when the child
12
- // composes a different toolset. The two serve different call paths and safety
13
- // guarantees and must NOT be merged.
6
+ // lib/core/pure.mjs computeContinuableAllow(parentNames, toolFilter) 的关系:
7
+ // continuable 变体没有 childNames——它假设子工具集 父工具集(continuable 继承
8
+ // 父预设,那里无法重算真正的父∩子交集),计算 parent run_code deny allow。
9
+ // computeEffectiveAllow 额外与真实子工具集求交(parentNames childNames),
10
+ // 子组合了不同工具集时仍然正确。两者服务于不同的调用路径与安全保证,不得合并。
14
11
 
15
12
  // parent∩child − run_code − deny → allow 收窄;空集返回 [](fail-loud 由调用方
16
13
  // provider start 以逐字不变的 error 文案 throw)。