dsh-subagent-profile 0.3.2 → 0.3.4

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.
Files changed (40) hide show
  1. package/README.md +77 -40
  2. package/README.zh.md +111 -74
  3. package/docs/screenshots/dispatch-card.png +0 -0
  4. package/docs/screenshots/settings-page1.png +0 -0
  5. package/docs/screenshots/settings-page2.png +0 -0
  6. package/index.mjs +276 -81
  7. package/lib/client.js +3218 -166
  8. package/lib/core/adoption-reminder.mjs +48 -0
  9. package/lib/core/adoption-tracker.mjs +430 -0
  10. package/lib/core/background-ledger.mjs +71 -0
  11. package/lib/core/catalog-cache.mjs +45 -7
  12. package/lib/core/catalog.mjs +6 -6
  13. package/lib/core/cost-evidence.mjs +145 -0
  14. package/lib/core/cost-guard.mjs +71 -44
  15. package/lib/core/decision-trace.mjs +413 -0
  16. package/lib/core/delegation.mjs +111 -50
  17. package/lib/core/dispatch-gates.mjs +153 -0
  18. package/lib/core/dispatch-guard.mjs +156 -0
  19. package/lib/core/dispatch-schema.mjs +103 -14
  20. package/lib/core/dispatch-tool.mjs +220 -204
  21. package/lib/core/draft-gates.mjs +45 -0
  22. package/lib/core/drafts-store.mjs +45 -0
  23. package/lib/core/escape.mjs +130 -0
  24. package/lib/core/evolution-advice.mjs +224 -0
  25. package/lib/core/evolution-ledger.mjs +300 -0
  26. package/lib/core/evolution-summary.mjs +255 -0
  27. package/lib/core/http-routes.mjs +256 -72
  28. package/lib/core/intersection.mjs +6 -9
  29. package/lib/core/presets-sync.mjs +161 -43
  30. package/lib/core/prices.mjs +46 -0
  31. package/lib/core/profile-directory.mjs +139 -0
  32. package/lib/core/profile-provider.mjs +42 -39
  33. package/lib/core/profiles-store.mjs +103 -76
  34. package/lib/core/pure.mjs +110 -66
  35. package/lib/core/reminder-store.mjs +172 -0
  36. package/lib/core/shims.mjs +67 -76
  37. package/lib/core/whitelist.mjs +23 -17
  38. package/package.json +82 -83
  39. package/presets/orchestrator/agent.cordis.yml +59 -87
  40. package/presets/orchestrator/NOTICE +0 -3
@@ -1,32 +1,24 @@
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.
7
- //
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).
19
- //
20
- // 写路由各抽为模块级处理函数(if 链分发保持);/options 拆三读路由 + 手动刷新,
21
- // 目录数据统一来自 catalog 快照。
1
+ // lib/core/http-routes.mjs — Client UI 设置 HTTP loopback 路由。/options 目录由共享
2
+ // catalog 缓存(catalog-cache.mjs)提供;注入全是显式参数——store、getEnabled/setEnabled、
3
+ // syncTool、catalog、logger;写路由各抽为模块级处理函数,工厂返回 scope.effect 设置函数。
22
4
 
23
5
  import { sanitizeProfile } from './pure.mjs';
24
6
  import { BUILTIN_SEEDS } from './profiles-store.mjs';
25
7
  import { detectVersions } from './shims.mjs';
8
+ import { readSummaries, computeAdvice } from './evolution-advice.mjs';
9
+ import { profileStatsFromSummaries } from './profile-directory.mjs';
10
+ import { costSummaryFromSummaries } from './cost-evidence.mjs';
26
11
 
27
- // Only the loopback interfaces may drive the settings HTTP routes.
12
+ // 只有 loopback 接口可以驱动设置 HTTP 路由。
28
13
  const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
29
14
 
15
+ // CSRF 三件套:loopback 只挡「非本机」;这三件挡「跨域表单/脚本冒充本机提交」。
16
+ // IPv6 字面量带方括号形态,两种形态都列入避免误拒。
17
+ const LOOPBACK_ORIGIN_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
18
+ const CSRF_HEADER = 'X-DSH-Plugin';
19
+ const CSRF_HEADER_KEY = CSRF_HEADER.toLowerCase();
20
+ const CSRF_HEADER_VALUE = 'dsh-subagent-profile';
21
+
30
22
  function json(res, code, data) {
31
23
  res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
32
24
  res.end(JSON.stringify(data));
@@ -48,10 +40,6 @@ function readBody(req) {
48
40
  });
49
41
  }
50
42
 
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.
55
43
  function persistOk(res, payload, persist) {
56
44
  return json(res, 200, {
57
45
  ok: true,
@@ -61,27 +49,56 @@ function persistOk(res, payload, persist) {
61
49
  });
62
50
  }
63
51
 
64
- function listClean(store) {
52
+ function listClean(store, stats) {
53
+ const map = stats instanceof Map ? stats : new Map();
65
54
  return [...store.profiles.values()].map((profile) => {
66
55
  const clean = {};
67
56
  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.
57
+ // 对外暴露 UI 面向的 "modified" 信号(内置方案被改过)。
70
58
  if (profile.builtin === true && profile.persisted === true) clean.modified = true;
59
+ // 方案卡成本预览数据(有数据才带;N 必显作置信度信号)。
60
+ const stat = map.get(profile.id);
61
+ if (stat !== undefined) {
62
+ if (stat.avgCost !== null && stat.avgCost !== undefined) clean.avgCost = stat.avgCost;
63
+ if (stat.successRate !== null && stat.successRate !== undefined) clean.successRate = stat.successRate;
64
+ if (stat.n !== undefined) clean.n = stat.n;
65
+ }
71
66
  return clean;
72
67
  });
73
68
  }
74
69
 
75
- // --- 路由处理函数(从 createHttpRoutes 拆出;if 链分发在内保持)----------------
76
-
77
70
  async function handleList(deps, res) {
78
- return json(res, 200, { ok: true, profiles: listClean(deps.store) });
71
+ let stats = new Map();
72
+ let costSummary = { global: null, byModel: [] };
73
+ let advice = [];
74
+ let adviceGlobal = null;
75
+ try {
76
+ if (typeof deps.refreshAdvice === 'function') deps.refreshAdvice();
77
+ const summaries = typeof deps.summariesFile === 'string' ? readSummaries(deps.summariesFile, deps.logger) : null;
78
+ stats = profileStatsFromSummaries(deps.store, summaries);
79
+ costSummary = costSummaryFromSummaries(summaries);
80
+ if (deps.adviceWhitelist !== undefined && typeof deps.dispatchFile === 'string') {
81
+ const result = computeAdvice({ summariesFile: deps.summariesFile, dispatchFile: deps.dispatchFile, whitelist: deps.adviceWhitelist, logger: deps.logger });
82
+ advice = result.advice ?? [];
83
+ adviceGlobal = result.global ?? null;
84
+ }
85
+ } catch { /* 统计/建议是增量展示;失败不影响 /list 主响应 */ }
86
+ return json(res, 200, { ok: true, profiles: listClean(deps.store, stats), costSummary, advice, adviceGlobal });
79
87
  }
80
88
 
81
- // 轻量摘要:enabled + 模型目录(含 provider 信息,客户端据此派生提供方列表)+ 预设名册。
89
+ // 轻量摘要:enabled/advice 开关/模型目录/预设名册/审计分级。
82
90
  async function handleSummary(deps, res) {
83
91
  const snapshot = await deps.catalog.getSnapshot();
84
- return json(res, 200, { ok: true, enabled: deps.getEnabled(), models: snapshot.models, presets: snapshot.presets });
92
+ return json(res, 200, {
93
+ ok: true,
94
+ enabled: deps.getEnabled(),
95
+ evolutionAdvice: deps.getEvolutionAdvice(),
96
+ models: snapshot.models,
97
+ presets: snapshot.presets,
98
+ audit: deps.getAudit(),
99
+ escapeEnabled: deps.getEscapeEnabled(),
100
+ escapePresets: deps.escape.list(),
101
+ });
85
102
  }
86
103
 
87
104
  // 每模型 reasoning-effort 等级(懒加载;model 命中快照的 efforts 表)。
@@ -100,50 +117,164 @@ async function handleTools(deps, res) {
100
117
 
101
118
  // 版本探测:三包 version + 越界/未知的中文 warnings(纯探测,同步)。
102
119
  function handleVersions(res) {
103
- const { versions, warnings } = detectVersions();
104
- return json(res, 200, { ok: true, versions, warnings });
120
+ const { versions, warnings, pluginVersion } = detectVersions();
121
+ return json(res, 200, { ok: true, versions, warnings, pluginVersion });
105
122
  }
106
123
 
107
- // 手动刷新:清缓存兜底(TTL 过期前的目录变更经此立即生效)。
108
124
  async function handleRefresh(deps, res) {
109
125
  deps.catalog.invalidate();
126
+ if (typeof deps.refreshAdvice === 'function') deps.refreshAdvice();
110
127
  return json(res, 200, { ok: true, refreshed: true });
111
128
  }
112
129
 
130
+ // 提醒系统:提醒与审计同源(host reminder store),全动作留痕。
131
+ async function handleRemindersList(deps, res) {
132
+ const store = deps.reminderStore;
133
+ return json(res, 200, { ok: true, reminders: store !== undefined ? store.list() : [], unread: store !== undefined ? store.unreadCount() : 0 });
134
+ }
135
+
136
+ async function handleRemindersAck(deps, req, res) {
137
+ const body = await readBody(req);
138
+ const ids = Array.isArray(body.ids) ? body.ids.filter((id) => typeof id === 'string') : [];
139
+ const acked = deps.reminderStore !== undefined ? deps.reminderStore.ack(ids) : [];
140
+ return json(res, 200, { ok: true, acked });
141
+ }
142
+
143
+ async function handleReminderAction(deps, req, res) {
144
+ const body = await readBody(req);
145
+ const id = typeof body.id === 'string' ? body.id : '';
146
+ const action = typeof body.action === 'string' ? body.action : '';
147
+ const done = deps.reminderStore !== undefined ? deps.reminderStore.actOn(id, action) : false;
148
+ if (!done) return json(res, 400, { ok: false, error: 'subagent-profiles: 提醒 id 或动作无效' });
149
+ return json(res, 200, { ok: true, id, action });
150
+ }
151
+
152
+ async function handleLedgerFailures(deps, url, res) {
153
+ const session = url.searchParams.get('session') ?? '';
154
+ return json(res, 200, { ok: true, failures: deps.ledger.get(session) });
155
+ }
156
+
157
+ async function handleLedgerJobs(deps, url, res) {
158
+ const session = url.searchParams.get('session') ?? '';
159
+ const jobs = deps.backgroundLedger !== undefined ? deps.backgroundLedger.get(session) : [];
160
+ return json(res, 200, { ok: true, jobs });
161
+ }
162
+
163
+ async function handleDraftsList(deps, res) {
164
+ return json(res, 200, { ok: true, drafts: deps.draftsStore.list() });
165
+ }
166
+
167
+ async function handleDraftAdd(deps, req, res) {
168
+ const body = await readBody(req);
169
+ const config = body && typeof body.config === 'object' && body.config !== null ? body.config : {};
170
+ if (typeof config.id !== 'string' || config.id === '') return json(res, 400, { ok: false, error: 'draft config.id 必须为非空字符串' });
171
+ const draft = {
172
+ id: 'draft-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
173
+ source: typeof body.source === 'string' && body.source !== '' ? body.source : 'human',
174
+ config,
175
+ name: typeof body.name === 'string' && body.name !== '' ? body.name : config.id,
176
+ description: typeof body.description === 'string' ? body.description : '',
177
+ createdAt: Date.now(),
178
+ };
179
+ const persist = deps.draftsStore.add(draft);
180
+ return persistOk(res, { draft }, persist);
181
+ }
182
+
183
+ async function handleDraftApply(deps, req, res) {
184
+ const body = await readBody(req);
185
+ const id = body && typeof body.id === 'string' ? body.id : '';
186
+ if (id === '') return json(res, 400, { ok: false, error: 'draft id 不能为空' });
187
+ const draft = deps.draftsStore.get(id);
188
+ if (draft === undefined) return json(res, 404, { ok: false, error: 'draft 不存在(可能已被应用或删除)' });
189
+ const next = { ...draft };
190
+ if (typeof body.name === 'string' && body.name !== '') next.name = body.name;
191
+ if (typeof body.description === 'string') next.description = body.description;
192
+ const applied = await deps.applyDraft(next);
193
+ deps.draftsStore.remove(id);
194
+ return persistOk(res, applied, { persisted: applied.persisted !== false });
195
+ }
196
+
197
+ async function handleDraftRemove(deps, req, res) {
198
+ const body = await readBody(req);
199
+ const id = body && typeof body.id === 'string' ? body.id : '';
200
+ if (id === '') return json(res, 400, { ok: false, error: 'draft id 不能为空' });
201
+ const result = deps.draftsStore.remove(id);
202
+ return persistOk(res, { id }, result);
203
+ }
204
+
113
205
  async function handleSetEnabled(deps, req, res) {
114
206
  const body = await readBody(req);
115
207
  const next = !!(body && body.enabled === true);
116
208
  deps.setEnabled(next);
117
- deps.store.persistEnabled(next);
209
+ const persist = deps.store.persistEnabled(next);
118
210
  deps.syncTool();
119
- return json(res, 200, { ok: true, enabled: deps.getEnabled() });
211
+ return persistOk(res, { enabled: deps.getEnabled() }, persist);
212
+ }
213
+
214
+ // 派发优化建议注入开关(默认关,只提示不改派发行为)。
215
+ async function handleSetEvolutionAdvice(deps, req, res) {
216
+ const body = await readBody(req);
217
+ const next = !!(body && body.advice === true);
218
+ deps.setEvolutionAdvice(next);
219
+ const persist = deps.store.persistEvolutionAdvice(next);
220
+ return persistOk(res, { evolutionAdvice: deps.getEvolutionAdvice() }, persist);
221
+ }
222
+
223
+ // 逃生舱开关(默认关):只放行「放行集内且三道闸全过」的非 system 预设。
224
+ async function handleSetEscape(deps, req, res) {
225
+ const body = await readBody(req);
226
+ const next = !!(body && body.enabled === true);
227
+ deps.setEscapeEnabled(next);
228
+ const persist = deps.store.persistEscapeEnabled(next);
229
+ return persistOk(res, { escapeEnabled: deps.getEscapeEnabled() }, persist);
230
+ }
231
+
232
+ // 放行集写路径校验:preset id 非字符串/缺失归一空串(调用方 400 拒绝)。
233
+ function escapePresetId(body) {
234
+ const raw = body && typeof body === 'object' && typeof body.preset === 'string' ? body.preset : '';
235
+ return raw.trim();
236
+ }
237
+
238
+ async function handleEscapeAdd(deps, req, res) {
239
+ const body = await readBody(req);
240
+ const preset = escapePresetId(body);
241
+ if (preset === '') return json(res, 400, { ok: false, error: 'subagent-profiles: escape preset 必须为非空字符串' });
242
+ const snapshot = await deps.catalog.getSnapshot();
243
+ if (snapshot.presets.some((p) => p && p.id === preset)) {
244
+ return json(res, 400, { ok: false, error: `subagent-profiles: preset "${preset}" 已是 system-trust,无需加入逃生舱` });
245
+ }
246
+ const persist = deps.escape.add(preset);
247
+ return persistOk(res, { preset, presets: deps.escape.list() }, persist);
248
+ }
249
+
250
+ async function handleEscapeRemove(deps, req, res) {
251
+ const body = await readBody(req);
252
+ const preset = escapePresetId(body);
253
+ if (preset === '') return json(res, 400, { ok: false, error: 'subagent-profiles: escape preset 必须为非空字符串' });
254
+ const persist = deps.escape.remove(preset);
255
+ return persistOk(res, { preset, presets: deps.escape.list() }, persist);
120
256
  }
121
257
 
122
258
  async function handleAdd(deps, req, res) {
123
259
  const body = await readBody(req);
124
260
  const profile = body && typeof body === 'object' ? body : {};
125
261
  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' });
262
+ return json(res, 400, { ok: false, error: 'subagent-profiles: profile id 必须为非空字符串' });
127
263
  }
128
- // 写路径上限:strict=true —— 超限/非法字段直接 400 拒绝,
129
- // 与 loadProfiles(strict=false 迁移宽松读取)的行为区分。列被拒字段与中文原因。
264
+ // 写路径 strict=true:超限/非法字段 400 拒绝(与 loadProfiles 宽松读取区分)。
130
265
  const { clean, warnings } = sanitizeProfile(profile, { strict: true });
131
266
  if (warnings.length > 0) {
132
267
  const detail = warnings.map((w) => `${w.field}:${w.reason}`).join(';');
133
268
  return json(res, 400, { ok: false, error: `写入被拒绝:${detail}` });
134
269
  }
135
270
  const hadToolFilter = profile.toolFilter !== undefined;
136
- // tokenTier 不参与上方通用 merge 循环:sanitizeProfile 对「未提供」恒回填
137
- // balanced,若进循环会破坏「未传→保留 existing」语义(编辑内置 researcher
138
- // 时 cheap 会被重置为 balanced)。故单独用 raw-body 守卫:传了才写(strict
139
- // 模式下非法值已在上面 400 拒绝,clean.tokenTier 必为合法 enum)。
271
+ // tokenTier 单独用 raw-body 守卫(sanitize 恒回填 balanced,进 merge 会破坏
272
+ // 「未传→保留 existing」语义);strict 下非法值已在上面 400 拒绝。
140
273
  const hadTokenTier = profile.tokenTier !== undefined;
141
274
  const existing = deps.store.profiles.get(clean.id);
142
275
  const seed = BUILTIN_SEEDS.find((s) => s.id === clean.id);
143
276
  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.
277
+ // 合并而非替换:表单未带字段(内置 persona/preset 等)编辑后仍保留。
147
278
  const merged = { ...(existing ?? seed ?? {}) };
148
279
  merged.id = clean.id;
149
280
  for (const key of ['name', 'description', 'preset', 'provider', 'model', 'reasoningEffort', 'persona', 'enabled']) {
@@ -151,8 +282,7 @@ async function handleAdd(deps, req, res) {
151
282
  if (clean[key] === '' || clean[key] === null) { delete merged[key]; continue; } // 空:清除字段
152
283
  merged[key] = clean[key];
153
284
  }
154
- // toolFilter 特殊处理:前端改成多选下拉后总是传数组,空数组 = 清除。请求未传
155
- // toolFilter 时保留 existing 原值(merge 语义);传了但被 sanitize 归一为空则清除。
285
+ // toolFilter:空数组 = 清除;未传保留 existing;传了但归一为空则清除。
156
286
  if (hadToolFilter) {
157
287
  const tf = clean.toolFilter;
158
288
  if (tf !== undefined && ((Array.isArray(tf.allow) && tf.allow.length > 0) || (Array.isArray(tf.deny) && tf.deny.length > 0))) {
@@ -173,7 +303,7 @@ async function handleRemove(deps, req, res) {
173
303
  const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
174
304
  const existing = deps.store.profiles.get(id);
175
305
  if (existing === undefined) {
176
- return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" does not exist` });
306
+ return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" 不存在(可在设置页确认可用的 profile id)` });
177
307
  }
178
308
  deps.store.profiles.delete(id);
179
309
  if (existing.builtin === true) deps.store.deletedBuiltins.add(id);
@@ -185,7 +315,7 @@ async function handleReset(deps, req, res) {
185
315
  const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
186
316
  const seed = BUILTIN_SEEDS.find((s) => s.id === id);
187
317
  if (seed === undefined) {
188
- return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" is not a builtin (nothing to reset)` });
318
+ return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" 不是内置方案(无可重置项)` });
189
319
  }
190
320
  deps.store.profiles.set(id, { ...seed });
191
321
  deps.store.deletedBuiltins.delete(id);
@@ -205,37 +335,91 @@ async function handleSetProfileEnabled(deps, req, res) {
205
335
  const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
206
336
  const existing = deps.store.profiles.get(id);
207
337
  if (existing === undefined) {
208
- return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" does not exist` });
338
+ return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" 不存在(可在设置页确认可用的 profile id)` });
209
339
  }
210
340
  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.
341
+ // 无条件持久化:运行时注册的 profile 启用/禁用也须重启后保留。
213
342
  existing.persisted = true;
214
343
  return persistOk(res, { id, enabled: existing.enabled }, deps.store.persistProfiles());
215
344
  }
216
345
 
217
- export function createHttpRoutes({ webServer, store, getEnabled, setEnabled, syncTool, catalog, logger }) {
218
- const deps = { store, getEnabled, setEnabled, syncTool, catalog, logger };
219
- // 路由分发(if 链保持,判断顺序与 404/500 兜底不变)。
346
+ // CSRF 传输授权:Origin 白名单 + Content-Type + 自定义头;reason 403 warn
347
+ // 日志,GET 只读不设防。
348
+ function csrfViolation(req) {
349
+ const headers = req.headers ?? {};
350
+ const origin = typeof headers.origin === 'string' ? headers.origin : '';
351
+ const contentType = typeof headers['content-type'] === 'string' ? headers['content-type'] : '';
352
+ // 1. Origin 白名单:只放行本机源;无 Origin 的本地脚本靠第 3 道兜底。
353
+ if (origin !== '') {
354
+ let host;
355
+ try { host = new URL(origin).hostname; } catch { host = null; }
356
+ if (host === null || !LOOPBACK_ORIGIN_HOSTS.has(host)) {
357
+ return { reason: `Origin 不在本机白名单(收到:${origin})`, origin, contentType };
358
+ }
359
+ }
360
+ // 2. 强制 Content-Type: application/json(text/plain、表单编码等一律拒绝)。
361
+ if (!contentType.toLowerCase().startsWith('application/json')) {
362
+ return { reason: `写请求必须带 Content-Type: application/json(收到:${contentType || '缺失'})`, origin, contentType };
363
+ }
364
+ // 3. 自定义头逼 preflight:跨域表单/脚本无法携带非简单头,缺头即拒。
365
+ if (headers[CSRF_HEADER_KEY] !== CSRF_HEADER_VALUE) {
366
+ return { reason: `写请求必须带 ${CSRF_HEADER}: ${CSRF_HEADER_VALUE} 请求头`, origin, contentType };
367
+ }
368
+ return null;
369
+ }
370
+
371
+ // 路由分发链(从 createHttpRoutes 抽出守行门):纯 if 链分发,每条路由一行;
372
+ // 未知路由 404 兜底。CSRF 与 loopback 守卫在 createHttpRoutes 入口完成。
373
+ async function routeRequest(deps, req, res, url, sub) {
374
+ if (req.method === 'GET' && (sub === '/' || sub === '/list')) return handleList(deps, res);
375
+ if (req.method === 'GET' && sub === '/options/summary') return handleSummary(deps, res);
376
+ if (req.method === 'GET' && sub === '/options/versions') return handleVersions(res);
377
+ if (req.method === 'GET' && sub === '/options/efforts') return handleEfforts(deps, url, res);
378
+ if (req.method === 'GET' && sub === '/options/tools') return handleTools(deps, res);
379
+ if (req.method === 'GET' && sub === '/ledger/failures') return handleLedgerFailures(deps, url, res);
380
+ if (req.method === 'GET' && sub === '/ledger/jobs') return handleLedgerJobs(deps, url, res);
381
+ if (req.method === 'GET' && sub === '/reminders') return handleRemindersList(deps, res);
382
+ if (req.method === 'POST' && sub === '/reminders/ack') return handleRemindersAck(deps, req, res);
383
+ if (req.method === 'POST' && sub === '/reminders/action') return handleReminderAction(deps, req, res);
384
+ if (req.method === 'GET' && sub === '/drafts') return handleDraftsList(deps, res);
385
+ if (req.method === 'POST' && sub === '/draft') return handleDraftAdd(deps, req, res);
386
+ if (req.method === 'POST' && sub === '/draft/apply') return handleDraftApply(deps, req, res);
387
+ if (req.method === 'POST' && sub === '/draft/remove') return handleDraftRemove(deps, req, res);
388
+ if (req.method === 'POST' && sub === '/draft/preview') { const body = await readBody(req); return json(res, 200, { ok: true, ...(await deps.previewDraft(body)) }); }
389
+ if (req.method === 'POST' && sub === '/options/refresh') return handleRefresh(deps, res);
390
+ if (req.method === 'POST' && sub === '/set-enabled') return handleSetEnabled(deps, req, res);
391
+ if (req.method === 'POST' && sub === '/set-evolution-advice') return handleSetEvolutionAdvice(deps, req, res);
392
+ if (req.method === 'POST' && sub === '/set-escape') return handleSetEscape(deps, req, res);
393
+ if (req.method === 'POST' && sub === '/escape-add') return handleEscapeAdd(deps, req, res);
394
+ if (req.method === 'POST' && sub === '/escape-remove') return handleEscapeRemove(deps, req, res);
395
+ if (req.method === 'POST' && sub === '/add') return handleAdd(deps, req, res);
396
+ if (req.method === 'POST' && sub === '/remove') return handleRemove(deps, req, res);
397
+ if (req.method === 'POST' && sub === '/reset') return handleReset(deps, req, res);
398
+ if (req.method === 'POST' && sub === '/reset-all') return handleResetAll(deps, res);
399
+ if (req.method === 'POST' && sub === '/set-profile-enabled') return handleSetProfileEnabled(deps, req, res);
400
+ json(res, 404, { ok: false, error: `未知路由 ${sub}` });
401
+ }
402
+
403
+ export function createHttpRoutes({ webServer, store, getEnabled, setEnabled, syncTool, catalog, ledger, backgroundLedger, draftsStore, applyDraft, getAudit, getEvolutionAdvice, setEvolutionAdvice, getEscapeEnabled, setEscapeEnabled, escape, refreshAdvice, summariesFile, dispatchFile, adviceWhitelist, previewDraft, reminderStore, logger }) {
404
+ const deps = { store, getEnabled, setEnabled, syncTool, catalog, ledger, backgroundLedger, draftsStore, applyDraft, getAudit, getEvolutionAdvice, setEvolutionAdvice, getEscapeEnabled, setEscapeEnabled, escape, refreshAdvice, summariesFile, dispatchFile, adviceWhitelist, previewDraft, reminderStore, logger };
220
405
  const handler = async (req, res) => {
221
406
  const remote = req.socket?.remoteAddress;
222
407
  if (!LOOPBACKS.has(remote)) return json(res, 403, { ok: false, error: '仅限本机访问' });
223
408
  const url = new URL(req.url ?? '/', 'http://localhost');
224
409
  const sub = (url.pathname.replace(/^\/subagent-profiles/, '') || '/').replace(/\/+$/, '') || '/';
225
410
  try {
226
- if (req.method === 'GET' && (sub === '/' || sub === '/list')) return handleList(deps, res);
227
- if (req.method === 'GET' && sub === '/options/summary') return handleSummary(deps, res);
228
- if (req.method === 'GET' && sub === '/options/versions') return handleVersions(res);
229
- if (req.method === 'GET' && sub === '/options/efforts') return handleEfforts(deps, url, res);
230
- if (req.method === 'GET' && sub === '/options/tools') return handleTools(deps, res);
231
- if (req.method === 'POST' && sub === '/options/refresh') return handleRefresh(deps, res);
232
- if (req.method === 'POST' && sub === '/set-enabled') return handleSetEnabled(deps, req, res);
233
- if (req.method === 'POST' && sub === '/add') return handleAdd(deps, req, res);
234
- if (req.method === 'POST' && sub === '/remove') return handleRemove(deps, req, res);
235
- if (req.method === 'POST' && sub === '/reset') return handleReset(deps, req, res);
236
- if (req.method === 'POST' && sub === '/reset-all') return handleResetAll(deps, res);
237
- if (req.method === 'POST' && sub === '/set-profile-enabled') return handleSetProfileEnabled(deps, req, res);
238
- json(res, 404, { ok: false, error: `未知路由 ${sub}` });
411
+ // 写路由统一做 CSRF 传输授权(loopback 之后、路由分发之前);GET 只读不设防。
412
+ if (req.method === 'POST') {
413
+ const violation = csrfViolation(req);
414
+ if (violation !== null) {
415
+ deps.logger.warn(
416
+ `[dsh-subagent-profile] CSRF 防护拒绝:${violation.reason}` +
417
+ `(origin=${violation.origin || ''},contentType=${violation.contentType || ''})`
418
+ );
419
+ return json(res, 403, { ok: false, error: `CSRF 防护拒绝:${violation.reason}` });
420
+ }
421
+ }
422
+ return routeRequest(deps, req, res, url, sub);
239
423
  } catch (error) {
240
424
  // 通用 500 不回显内部错误信息(防泄漏),详情只进宿主日志。
241
425
  deps.logger.error('[dsh-subagent-profile] settings route error:', error instanceof Error ? (error.stack ?? error.message) : String(error));
@@ -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)。