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.
- package/README.md +77 -40
- package/README.zh.md +111 -74
- package/docs/screenshots/dispatch-card.png +0 -0
- package/docs/screenshots/settings-page1.png +0 -0
- package/docs/screenshots/settings-page2.png +0 -0
- package/index.mjs +276 -81
- package/lib/client.js +3218 -166
- package/lib/core/adoption-reminder.mjs +48 -0
- package/lib/core/adoption-tracker.mjs +430 -0
- package/lib/core/background-ledger.mjs +71 -0
- package/lib/core/catalog-cache.mjs +45 -7
- package/lib/core/catalog.mjs +6 -6
- package/lib/core/cost-evidence.mjs +145 -0
- package/lib/core/cost-guard.mjs +71 -44
- package/lib/core/decision-trace.mjs +413 -0
- package/lib/core/delegation.mjs +111 -50
- package/lib/core/dispatch-gates.mjs +153 -0
- package/lib/core/dispatch-guard.mjs +156 -0
- package/lib/core/dispatch-schema.mjs +103 -14
- package/lib/core/dispatch-tool.mjs +220 -204
- package/lib/core/draft-gates.mjs +45 -0
- package/lib/core/drafts-store.mjs +45 -0
- package/lib/core/escape.mjs +130 -0
- package/lib/core/evolution-advice.mjs +224 -0
- package/lib/core/evolution-ledger.mjs +300 -0
- package/lib/core/evolution-summary.mjs +255 -0
- package/lib/core/http-routes.mjs +256 -72
- package/lib/core/intersection.mjs +6 -9
- package/lib/core/presets-sync.mjs +161 -43
- package/lib/core/prices.mjs +46 -0
- package/lib/core/profile-directory.mjs +139 -0
- package/lib/core/profile-provider.mjs +42 -39
- package/lib/core/profiles-store.mjs +103 -76
- package/lib/core/pure.mjs +110 -66
- package/lib/core/reminder-store.mjs +172 -0
- package/lib/core/shims.mjs +67 -76
- package/lib/core/whitelist.mjs +23 -17
- package/package.json +82 -83
- package/presets/orchestrator/agent.cordis.yml +59 -87
- package/presets/orchestrator/NOTICE +0 -3
package/lib/core/http-routes.mjs
CHANGED
|
@@ -1,32 +1,24 @@
|
|
|
1
|
-
// lib/core/http-routes.mjs —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
89
|
+
// 轻量摘要:enabled/advice 开关/模型目录/预设名册/审计分级。
|
|
82
90
|
async function handleSummary(deps, res) {
|
|
83
91
|
const snapshot = await deps.catalog.getSnapshot();
|
|
84
|
-
return json(res, 200, {
|
|
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
|
|
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
|
|
262
|
+
return json(res, 400, { ok: false, error: 'subagent-profiles: profile id 必须为非空字符串' });
|
|
127
263
|
}
|
|
128
|
-
//
|
|
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
|
|
137
|
-
//
|
|
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
|
-
//
|
|
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}"
|
|
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}"
|
|
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}"
|
|
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
|
-
//
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
227
|
-
if (req.method === '
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// parent
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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)。
|