dsh-session-ops 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/admin.js ADDED
@@ -0,0 +1,904 @@
1
+ /**
2
+ * `dsh-session-ops` 的**删除半边**(原 `dsh-session-admin` 插件的 Host 半边)。
3
+ *
4
+ * 这一半的代码搬自原 `dsh-session-admin` 的 `lib/index.js`,只有四处差异:
5
+ * - 插件 entry 的 `name` / `inject` / `SETTINGS_NS` / `Config` / `resolveConfig` / `apply`
6
+ * 上移到 `lib/index.js`——合并后的包只有一个 entry、一份配置 schema、一个 `apply`;
7
+ * - `API_PREFIX` 改名 `ADMIN_API_PREFIX`(搬迁半边占着 `MOVE_API_PREFIX`);
8
+ * - 配置键 `allowHosts` 改名 `adminAllowHosts`(两半的信任栅栏语义不同,见 `lib/index.js`);
9
+ * - 原 `apply` 里"建配置/建日志/订阅 volatile/看 enabled"那段上移到入口,剩下的接线
10
+ * 成为 `mountAdmin(ctx, config, log)`。
11
+ * 其余一字未动:扫描与范围筛选、逐条守卫、`/session-admin/api` 的方法表、
12
+ * `session_admin` 工具定义、Host 头版信任栅栏,连注释都保持原样。
13
+ *
14
+ * 能力:把会话的持久化数据真正从磁盘移除。一个会话在磁盘上最多占三处:
15
+ * - `<home>/sessions/<项目目录键>/<session-id>/` ← 会话日志(jsonl.zstd)
16
+ * - `<home>/storages/session_projcache/sessions/<session-id>.json` ← 投影缓存
17
+ * - `<home>/rewind-snapshots/<session-id>/` ← 回退快照(可有可无)
18
+ * 三处一起删,并顺手清掉因此变空的项目目录。
19
+ *
20
+ * 刻意零 `@deepseek-ai/*` 运行时依赖:只用 Node 内置模块和一份 schemastery 配置
21
+ * schema。工具注册用普通对象(与 `dsh-subagent-in-process-driver` 同款),不做
22
+ * `defineTool` 导入——profile 的 node_modules 里未必解析得到 `@deepseek-ai/dsh-tools`。
23
+ * 代价是 `parameters` / `output.schema` 都必须自己写成**已编译的 JSON Schema**:
24
+ * register 不做编译,只把对象原样透传给 provider(详见 `TOOL_PARAMETERS` 注释)。
25
+ *
26
+ * @module dsh-session-ops/admin
27
+ */
28
+ import { existsSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
29
+ import { tmpdir } from 'node:os';
30
+ import { join, resolve, sep } from 'node:path';
31
+ import { readSessionHeader } from './store/frames.js';
32
+ import { dshHome } from './store/host.js';
33
+ import { createApiHandler } from './store/http.js';
34
+ import { decodeProjectKey } from './store/paths.js';
35
+ import { releaseSessionEverywhere, residentSessionIds, runningSessionIds, } from './store/registry.js';
36
+ import { messageOf } from './store/util.js';
37
+ /** HTTP 路由前缀(两半各一条,合并后仍然分开)。 */
38
+ export const ADMIN_API_PREFIX = '/session-admin/api';
39
+ // ---------------------------------------------------------------------------
40
+ // 日志 / $DSH_HOME(实现在共享的 `lib/store/host.js`)
41
+ // ---------------------------------------------------------------------------
42
+ /**
43
+ * `$DSH_HOME`(环境变量优先,默认 `~/.dsh`)。
44
+ *
45
+ * 实现在 `lib/store/host.js`——它与搬迁半边用的是同一份;
46
+ * 这里再导出,保持本仓库的公开面不变。
47
+ */
48
+ export { dshHome };
49
+ // ---------------------------------------------------------------------------
50
+ // 路径
51
+ // ---------------------------------------------------------------------------
52
+ /**
53
+ * 把 `sessions/` 下的项目目录键还原成路径。
54
+ *
55
+ * 实现在 `lib/store/paths.js`:它与搬迁半边的 `projectKey`
56
+ * 是同一套规则的两个方向,规则只该有一份。这里再导出,保持本仓库的公开面不变。
57
+ */
58
+ export { decodeProjectKey };
59
+ /** 路径是否落在某个根目录内部(防目录穿越)。 */
60
+ export function isInside(root, candidate) {
61
+ const r = resolve(root);
62
+ const c = resolve(candidate);
63
+ return c === r || c.startsWith(r.endsWith(sep) ? r : r + sep);
64
+ }
65
+ /** 读取工作区注册表;缺失或损坏时退化成「没有工作区」。 */
66
+ function readWorkspace(home) {
67
+ const file = join(home, 'storages', 'workspace.json');
68
+ const empty = { workspaces: [], accounted: new Set(), archived: new Set() };
69
+ if (!existsSync(file))
70
+ return empty;
71
+ try {
72
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
73
+ const byId = new Map();
74
+ const accounted = new Set();
75
+ const workspaces = [];
76
+ for (const [workspaceId, workspace] of Object.entries(parsed?.tables?.workspaces ?? {})) {
77
+ const sessionIds = Array.isArray(workspace.sessionIds) ? workspace.sessionIds : [];
78
+ // 注意:workspace.json 里的工作区对象自身不带 id,它只存在于外层键上,
79
+ // 所以这里必须重新组装一份带 workspaceId 的视图再进索引——直接存原始对象
80
+ // 会让所有工作区守卫静默失效(owner.workspaceId === undefined)。
81
+ const view = { workspaceId, path: workspace.path, title: workspace.title, sessionIds };
82
+ for (const id of sessionIds) {
83
+ accounted.add(id);
84
+ byId.set(id, view);
85
+ }
86
+ workspaces.push(view);
87
+ }
88
+ const archived = new Set(Array.isArray(parsed?.global?.archivedSessionIds) ? parsed.global.archivedSessionIds : []);
89
+ return { workspaces, accounted, archived, byId };
90
+ }
91
+ catch {
92
+ return empty;
93
+ }
94
+ }
95
+ /** 会话目录的 mtime——轻量扫描时用作 `updatedAt` 的替代。 */
96
+ function dirMtime(dir) {
97
+ try {
98
+ return statSync(dir).mtimeMs;
99
+ }
100
+ catch {
101
+ return 0;
102
+ }
103
+ }
104
+ /** 为一个会话目录算出磁盘占用。 */
105
+ function measure(dir) {
106
+ let bytes = 0;
107
+ let files = 0;
108
+ let newest = 0;
109
+ const stack = [dir];
110
+ while (stack.length > 0) {
111
+ const current = stack.pop();
112
+ let entries;
113
+ try {
114
+ entries = readdirSync(current, { withFileTypes: true });
115
+ }
116
+ catch {
117
+ continue;
118
+ }
119
+ for (const entry of entries) {
120
+ const child = join(current, entry.name);
121
+ if (entry.isDirectory()) {
122
+ stack.push(child);
123
+ continue;
124
+ }
125
+ try {
126
+ const stat = statSync(child);
127
+ bytes += stat.size;
128
+ files += 1;
129
+ if (stat.mtimeMs > newest)
130
+ newest = stat.mtimeMs;
131
+ }
132
+ catch {
133
+ /* 单个文件读不到就跳过 */
134
+ }
135
+ }
136
+ }
137
+ return { bytes, files, updatedAt: newest };
138
+ }
139
+ /**
140
+ * 构造一个会话管理员。所有根目录都可注入,便于在测试里指向沙箱。
141
+ * @param options - `home`(DSH 主目录)、`tempRoots`(视为「临时目录」的前缀)、
142
+ * `releaseSession`(删文件**之前**把一个会话从登记表里摘掉的异步回调;见 `remove`)。
143
+ * @returns 扫描 / 过滤 / 删除三个入口。
144
+ */
145
+ export function createSessionAdmin(options = {}) {
146
+ const home = options.home ?? dshHome();
147
+ const sessionsRoot = join(home, 'sessions');
148
+ const projcacheRoot = join(home, 'storages', 'session_projcache', 'sessions');
149
+ const rewindRoot = join(home, 'rewind-snapshots');
150
+ const tempRoots = options.tempRoots ?? [tmpdir()];
151
+ const releaseSession = typeof options.releaseSession === 'function' ? options.releaseSession : async () => { };
152
+ const headerCache = new Map();
153
+ /**
154
+ * 扫描磁盘上的全部会话。
155
+ * @param options2 - `measure: false` 跳过逐会话的体积统计(要遍历每个会话目录,
156
+ * 是这里最贵的一步)。角标那种「只要个数」的调用方用它把开销压到最低,
157
+ * 代价是 `bytes` / `files` 为 0、`updatedAt` 退化成目录 mtime。
158
+ * @returns 每个会话一条记录,按更新时间从新到旧。
159
+ */
160
+ function scan(options2 = {}) {
161
+ const measureBytes = options2.measure !== false;
162
+ const workspace = readWorkspace(home);
163
+ const temps = tempRoots.map((root) => resolve(root).toLowerCase());
164
+ const records = [];
165
+ let projects = [];
166
+ try {
167
+ projects = readdirSync(sessionsRoot, { withFileTypes: true })
168
+ .filter((entry) => entry.isDirectory())
169
+ .map((entry) => entry.name);
170
+ }
171
+ catch {
172
+ return [];
173
+ }
174
+ for (const project of projects) {
175
+ const projectDir = join(sessionsRoot, project);
176
+ let sessionDirs;
177
+ try {
178
+ sessionDirs = readdirSync(projectDir, { withFileTypes: true })
179
+ .filter((entry) => entry.isDirectory())
180
+ .map((entry) => entry.name);
181
+ }
182
+ catch {
183
+ continue;
184
+ }
185
+ for (const id of sessionDirs) {
186
+ const dir = join(projectDir, id);
187
+ const header = readSessionHeader(dir, headerCache);
188
+ const cached = readProjCache(id);
189
+ const cwd = header?.cwd ?? cached.cwd ?? decodeProjectKey(project);
190
+ const measured = measureBytes ? measure(dir) : { bytes: 0, files: 0, updatedAt: dirMtime(dir) };
191
+ const owner = workspace.byId?.get(id);
192
+ const cwdMissing = cwd === undefined || cwd === '' || !existsSync(cwd);
193
+ records.push({
194
+ id,
195
+ dir,
196
+ project,
197
+ cwd: cwd ?? null,
198
+ title: cached.title ?? null,
199
+ origin: header?.origin ?? null,
200
+ parentSessionId: header?.parentSession ?? null,
201
+ isSeeded: header?.isSeeded ?? null,
202
+ headerReadable: header !== undefined,
203
+ workspaceId: owner?.workspaceId ?? null,
204
+ workspaceTitle: owner?.title ?? null,
205
+ ungrouped: owner === undefined,
206
+ archived: workspace.archived.has(id),
207
+ subagent: header?.origin === 'subagent',
208
+ cwdMissing,
209
+ temp: cwdMissing ||
210
+ (cwd !== undefined && temps.some((root) => cwd.toLowerCase().startsWith(root))),
211
+ bytes: measured.bytes,
212
+ files: measured.files,
213
+ updatedAt: measured.updatedAt,
214
+ hasProjCache: cached.found,
215
+ hasRewind: existsSync(join(rewindRoot, id)),
216
+ });
217
+ }
218
+ }
219
+ records.sort((a, b) => b.updatedAt - a.updatedAt);
220
+ return records;
221
+ }
222
+ /** projcache 里的一条摘要(标题 / cwd 的兜底来源)。 */
223
+ function readProjCache(id) {
224
+ const file = join(projcacheRoot, `${id}.json`);
225
+ if (!existsSync(file))
226
+ return { found: false, cwd: undefined, title: null };
227
+ try {
228
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
229
+ const record = parsed?.record ?? {};
230
+ const title = record?.rows?.title?.val;
231
+ return {
232
+ found: true,
233
+ // 投影缓存是宿主私有格式:这里不做类型校验,非字符串的 cwd 会在调用方
234
+ // 被「目录是否存在」那一步自然淘汰(与改动前的行为一致)。
235
+ cwd: record?.identity?.cwd,
236
+ title: typeof title === 'string' && title !== '' ? title : null,
237
+ };
238
+ }
239
+ catch {
240
+ return { found: true, cwd: undefined, title: null };
241
+ }
242
+ }
243
+ /**
244
+ * 按范围筛选会话。
245
+ *
246
+ * `ungrouped` 刻意与侧边栏的「未分组」**逐行等价**:DSH 的列表会隐藏子代理行与
247
+ * 归档行,所以这里也必须排掉它们,否则面板上显示 172 条而侧边栏只有 110 条,
248
+ * 两边对不上号,批量删除就会比用户以为的多删 62 个。要动那两类,用它们自己的
249
+ * 范围(`archived` / `subagent`)。
250
+ * @param records - `scan()` 的结果。
251
+ * @param query - `scope` / `cwdPrefix` / `search`。
252
+ * @returns 命中的记录。
253
+ */
254
+ function select(records, query = {}) {
255
+ const scope = query.scope ?? 'all';
256
+ const prefix = query.cwdPrefix === undefined ? null : resolve(query.cwdPrefix).toLowerCase();
257
+ const search = query.search === undefined ? null : String(query.search).toLowerCase();
258
+ return records.filter((record) => {
259
+ if (scope === 'ungrouped' && (!record.ungrouped || record.archived || record.subagent))
260
+ return false;
261
+ if (scope === 'temp' && !record.temp)
262
+ return false;
263
+ if (scope === 'archived' && !record.archived)
264
+ return false;
265
+ if (scope === 'subagent' && !record.subagent)
266
+ return false;
267
+ if (scope === 'orphan' && record.hasProjCache)
268
+ return false;
269
+ if (prefix !== null) {
270
+ if (record.cwd === null)
271
+ return false;
272
+ if (!record.cwd.toLowerCase().startsWith(prefix))
273
+ return false;
274
+ }
275
+ if (search !== null) {
276
+ const haystack = `${record.title ?? ''} ${record.cwd ?? ''} ${record.id}`.toLowerCase();
277
+ if (!haystack.includes(search))
278
+ return false;
279
+ }
280
+ return true;
281
+ });
282
+ }
283
+ /**
284
+ * 删除若干会话的持久化数据。
285
+ *
286
+ * 每条要么被删掉,要么带着一个明确的拒绝理由留下——调用方拿到的是逐条的账,
287
+ * 不是一个布尔值。
288
+ *
289
+ * 删文件**之前**会先 `await releaseSession(id)`(见 `createSessionAdmin` 的
290
+ * `releaseSession` 选项):登记表里对会话的引用必须赶在目录消失前摘掉,否则就是
291
+ * 一条谁也看不见、只会在被选中时炸成 ENOENT 的悬空引用。摘失败的那条不删文件。
292
+ *
293
+ * @param ids - 目标会话 id。
294
+ * @param options2 - `dryRun` / `includeWorkspace` / `includeArchived` /
295
+ * `includeSubagent` / `includeLive` / `live`(**正在跑**、因此现在不能删的 id 集合)/
296
+ * `callerSessionId` / `maxBatch`。
297
+ * @returns 逐条结果与汇总。
298
+ */
299
+ async function remove(ids, options2 = {}) {
300
+ const records = new Map(scan().map((record) => [record.id, record]));
301
+ const live = options2.live ?? new Set();
302
+ const maxBatch = options2.maxBatch ?? 200;
303
+ const dryRun = options2.dryRun === true;
304
+ const results = [];
305
+ let removed = 0;
306
+ let reclaimed = 0;
307
+ for (const id of ids) {
308
+ const record = records.get(id);
309
+ if (record === undefined) {
310
+ results.push({ id, status: 'skipped', reason: 'not-found' });
311
+ continue;
312
+ }
313
+ if (options2.callerSessionId !== undefined && id === options2.callerSessionId) {
314
+ results.push({ id, status: 'skipped', reason: 'caller-session' });
315
+ continue;
316
+ }
317
+ /*
318
+ * `live` 是「**正在跑**」的集合,不是「Host 仍挂着」的集合。这个区分是必需的:
319
+ *
320
+ * 会话在创建 agent 时被 enter(dsh-agent-loop/index.js:1714),而 agent 只在被
321
+ * dispose 时才 detach —— 长期运行的 `dsh web` 里,只要碰过就基本一直挂着。若把
322
+ * 「挂着」当作不可删,删除功能(以及待删除队列)在真实使用中会永久失效。
323
+ *
324
+ * 那为什么删「挂着但空闲」的是安全的?因为删除会移除**整个会话目录**,而持久化
325
+ * 的追加写是 `open(path, "a")`(dsh-session-persistence-jsonl/index.js:3049):
326
+ * 它只在文件不存在时创建文件,**不会创建父目录**。目录已经没了,于是后续写入直接
327
+ * 拿到 ENOENT 并失败——一个响亮、局部的错误,而不是生成一个「无头日志」把整个
328
+ * profile 的启动搞崩(那才是真正要避免的结局)。已实测该 open 语义。
329
+ */
330
+ if (live.has(id) && options2.includeLive !== true) {
331
+ results.push({ id, status: 'skipped', reason: 'running-session' });
332
+ continue;
333
+ }
334
+ if (record.workspaceId !== null && options2.includeWorkspace !== true) {
335
+ results.push({ id, status: 'skipped', reason: 'workspace-session' });
336
+ continue;
337
+ }
338
+ if (record.archived && options2.includeArchived !== true) {
339
+ results.push({ id, status: 'skipped', reason: 'archived-session' });
340
+ continue;
341
+ }
342
+ if (record.subagent && options2.includeSubagent !== true) {
343
+ results.push({ id, status: 'skipped', reason: 'subagent-session' });
344
+ continue;
345
+ }
346
+ if (removed >= maxBatch) {
347
+ results.push({ id, status: 'skipped', reason: 'batch-limit' });
348
+ continue;
349
+ }
350
+ // 目录穿越防线:会话目录必须真的在 sessions/ 里。
351
+ if (!isInside(sessionsRoot, record.dir)) {
352
+ results.push({ id, status: 'skipped', reason: 'outside-sessions-root' });
353
+ continue;
354
+ }
355
+ if (dryRun) {
356
+ results.push({ id, status: 'would-remove', bytes: record.bytes });
357
+ removed += 1;
358
+ reclaimed += record.bytes;
359
+ continue;
360
+ }
361
+ /*
362
+ * 摘登记表**必须早于删文件**。
363
+ *
364
+ * `workspace.json` 里有三处会记住会话:工作区的成员表、归档集合、置顶集合。
365
+ * 删日志不碰它们中的任何一个,残留平时也看不见——直到有人选中那条死行、
366
+ * 去打开已经不存在的日志,才炸成 ENOENT(并且是「本轮运行失败」这种
367
+ * 让人摸不着头脑的样子)。
368
+ *
369
+ * 更关键的是顺序:`Workspace.sessionIds` 是**按会话头索引过滤过的视图**,
370
+ * 目录一删,成员关系就再也查不到,残留也就永远摘不掉了。
371
+ *
372
+ * 摘不掉就不删:留下一条有据可查的失败,好过制造一个悬空引用。
373
+ */
374
+ try {
375
+ await releaseSession(id);
376
+ }
377
+ catch (error) {
378
+ results.push({ id, status: 'failed', reason: `workspace: ${messageOf(error)}` });
379
+ continue;
380
+ }
381
+ const errors = [];
382
+ try {
383
+ rmSync(record.dir, { recursive: true, force: true });
384
+ }
385
+ catch (error) {
386
+ errors.push(`sessions: ${messageOf(error)}`);
387
+ }
388
+ const cacheFile = join(projcacheRoot, `${id}.json`);
389
+ if (existsSync(cacheFile)) {
390
+ try {
391
+ rmSync(cacheFile, { force: true });
392
+ }
393
+ catch (error) {
394
+ errors.push(`projcache: ${messageOf(error)}`);
395
+ }
396
+ }
397
+ const rewindDir = join(rewindRoot, id);
398
+ if (existsSync(rewindDir) && isInside(rewindRoot, rewindDir)) {
399
+ try {
400
+ rmSync(rewindDir, { recursive: true, force: true });
401
+ }
402
+ catch (error) {
403
+ errors.push(`rewind: ${messageOf(error)}`);
404
+ }
405
+ }
406
+ if (errors.length > 0) {
407
+ results.push({ id, status: 'failed', reason: errors.join('; ') });
408
+ continue;
409
+ }
410
+ results.push({ id, status: 'removed', bytes: record.bytes });
411
+ removed += 1;
412
+ reclaimed += record.bytes;
413
+ }
414
+ const pruned = dryRun ? [] : pruneEmptyProjects();
415
+ return { dryRun, removed, reclaimed, pruned, results };
416
+ }
417
+ /** 清掉 `sessions/` 下因此变空的项目目录。 */
418
+ function pruneEmptyProjects() {
419
+ const pruned = [];
420
+ let projects;
421
+ try {
422
+ projects = readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory());
423
+ }
424
+ catch {
425
+ return pruned;
426
+ }
427
+ for (const entry of projects) {
428
+ const dir = join(sessionsRoot, entry.name);
429
+ try {
430
+ if (readdirSync(dir).length === 0) {
431
+ rmSync(dir, { recursive: true, force: true });
432
+ pruned.push(entry.name);
433
+ }
434
+ }
435
+ catch {
436
+ /* 清不掉就留着 */
437
+ }
438
+ }
439
+ return pruned;
440
+ }
441
+ return { home, sessionsRoot, projcacheRoot, rewindRoot, scan, select, remove };
442
+ }
443
+ /** 拆 `host[:port]`(支持 `[::1]:3080` 形态)。 */
444
+ function parseAuthority(value) {
445
+ const text = String(value).trim();
446
+ if (text === '')
447
+ return undefined;
448
+ if (text.startsWith('[')) {
449
+ const end = text.indexOf(']');
450
+ if (end === -1)
451
+ return undefined;
452
+ return { hostname: text.slice(1, end), host: text.toLowerCase() };
453
+ }
454
+ const colon = text.lastIndexOf(':');
455
+ const hostname = colon === -1 ? text : text.slice(0, colon);
456
+ if (hostname === '')
457
+ return undefined;
458
+ return { hostname, host: text.toLowerCase() };
459
+ }
460
+ /** 本机回环字面量。 */
461
+ function isLoopback(hostname) {
462
+ const h = hostname.toLowerCase();
463
+ return h === '127.0.0.1' || h === 'localhost' || h === '::1' || h.endsWith('.localhost');
464
+ }
465
+ /** 配置里的额外 authority 是否覆盖该请求。 */
466
+ function isAllowedAuthority(authority, allowHosts) {
467
+ return allowHosts.some((entry) => {
468
+ const parsed = parseAuthority(entry);
469
+ if (parsed === undefined)
470
+ return false;
471
+ return parsed.host === authority.host || parsed.hostname === authority.hostname;
472
+ });
473
+ }
474
+ /**
475
+ * 判断一个请求是否有权访问本插件的路由。
476
+ *
477
+ * 本机 `dsh web` 常被绑到 `0.0.0.0`(局域网访问),所以「能连上端口」不等于
478
+ * 「是这台机器上的人」:非回环 authority 默认一律拒绝,除非在配置的
479
+ * `allowHosts` 里显式列出。同时拒绝 `sec-fetch-site: cross-site` 和跨源 Origin。
480
+ *
481
+ * **这份栅栏刻意留在本仓库**:`dsh-session-move` 那份按 socket 的 `remoteAddress`
482
+ * 判断回环、`allowHosts` 只做精确字符串匹配,既不解析 authority 也不看
483
+ * `sec-fetch-site`/`Origin`——两边语义不同,合并必然改掉其中一边的对外行为。
484
+ * 共享包只提供路由骨架(`createApiHandler`),栅栏以回调形式传进去。
485
+ * @param req - Node HTTP 请求。
486
+ * @param allowHosts - 额外信任的 authority 列表。
487
+ * @returns 是否放行。
488
+ */
489
+ export function isTrustedRequest(req, allowHosts = []) {
490
+ const rawHost = req?.headers?.host;
491
+ if (typeof rawHost !== 'string')
492
+ return false;
493
+ const authority = parseAuthority(rawHost);
494
+ if (authority === undefined)
495
+ return false;
496
+ if (!isLoopback(authority.hostname) && !isAllowedAuthority(authority, allowHosts))
497
+ return false;
498
+ if (req.headers?.['sec-fetch-site'] === 'cross-site')
499
+ return false;
500
+ const origin = req.headers?.origin;
501
+ if (origin === undefined)
502
+ return true;
503
+ try {
504
+ // 头可能是数组(重复头);`URL` 的入参本来就是字符串,这里按原语义做一次 ToString。
505
+ return new URL(String(origin)).hostname === authority.hostname;
506
+ }
507
+ catch {
508
+ return false;
509
+ }
510
+ }
511
+ /** `GET` 与 `POST` 共用的方法表。 */
512
+ function buildApi(admin, config, log, holds) {
513
+ return {
514
+ /**
515
+ * 浏览器半边需要的界面开关。
516
+ *
517
+ * 客户端读不到 Host 侧的部署配置,所以凡是「要不要渲染某个入口」这类决定,
518
+ * 都得由 Host 显式告诉它。配置缺失或请求失败时客户端一律按「不渲染危险入口」
519
+ * 处理,因此这里的默认值必须是安全的那一侧。
520
+ */
521
+ async config() {
522
+ return {
523
+ headerDeleteButton: config.headerDeleteButton === true,
524
+ allowWorkspaceSessions: config.allowWorkspaceSessions === true,
525
+ allowLiveSessions: config.allowLiveSessions === true,
526
+ maxBatch: config.maxBatch,
527
+ };
528
+ },
529
+ /**
530
+ * 只要个数的轻量查询,供电标这类高频调用方使用。
531
+ *
532
+ * 与 `list` 的区别是跳过体积统计——那一步要递归遍历每个会话目录,是扫描里
533
+ * 最贵的部分。角标只关心「有多少条」,不需要知道它们多大。
534
+ */
535
+ async count(payload) {
536
+ const records = admin.scan({ measure: false });
537
+ return {
538
+ total: records.length,
539
+ matched: admin.select(records, payload).length,
540
+ scope: payload?.scope ?? 'all',
541
+ };
542
+ },
543
+ /** 扫描会话并按范围返回。 */
544
+ async list(payload) {
545
+ const records = admin.scan();
546
+ const selected = admin.select(records, payload);
547
+ const groupCounts = {};
548
+ for (const record of records) {
549
+ const key = record.workspaceTitle ?? 'ungrouped';
550
+ groupCounts[key] = (groupCounts[key] ?? 0) + 1;
551
+ }
552
+ return {
553
+ total: records.length,
554
+ matched: selected.length,
555
+ groups: groupCounts,
556
+ sessions: selected.slice(0, payload?.limit ?? 2000).map((record) => ({
557
+ id: record.id,
558
+ title: record.title,
559
+ cwd: record.cwd,
560
+ workspaceTitle: record.workspaceTitle,
561
+ ungrouped: record.ungrouped,
562
+ archived: record.archived,
563
+ subagent: record.subagent,
564
+ temp: record.temp,
565
+ cwdMissing: record.cwdMissing,
566
+ live: holds.resident().has(record.id),
567
+ running: holds.running().has(record.id),
568
+ bytes: record.bytes,
569
+ files: record.files,
570
+ updatedAt: record.updatedAt,
571
+ })),
572
+ truncated: selected.length > (payload?.limit ?? 2000),
573
+ };
574
+ },
575
+ /** 删除:先按范围解析出目标集合,再逐条过守卫。 */
576
+ async delete(payload) {
577
+ const records = admin.scan();
578
+ const explicit = Array.isArray(payload?.sessionIds) ? payload.sessionIds : null;
579
+ const targets = explicit !== null
580
+ ? explicit
581
+ : admin.select(records, payload).map((record) => record.id);
582
+ if (targets.length === 0)
583
+ return { removed: 0, reclaimed: 0, results: [], pruned: [] };
584
+ const result = await admin.remove(targets, {
585
+ dryRun: payload?.dryRun === true,
586
+ includeWorkspace: payload?.includeWorkspace === true || config.allowWorkspaceSessions,
587
+ includeArchived: payload?.includeArchived === true,
588
+ includeSubagent: payload?.includeSubagent === true,
589
+ includeLive: payload?.includeLive === true || config.allowLiveSessions,
590
+ live: holds.running(),
591
+ callerSessionId: typeof payload?.callerSessionId === 'string' ? payload.callerSessionId : undefined,
592
+ maxBatch: config.maxBatch,
593
+ });
594
+ if (result.removed > 0 && result.dryRun !== true) {
595
+ log('info', `deleted ${result.removed} session(s), reclaimed ${result.reclaimed} bytes`);
596
+ }
597
+ return result;
598
+ },
599
+ };
600
+ }
601
+ // ---------------------------------------------------------------------------
602
+ // 模型侧工具
603
+ // ---------------------------------------------------------------------------
604
+ const SCOPE_VALUES = ['all', 'ungrouped', 'temp', 'archived', 'subagent', 'orphan'];
605
+ /**
606
+ * 工具的输入 schema。
607
+ *
608
+ * 必须是**真正的 JSON Schema**:`ctx.tools.register()` 收的是「已编译」的
609
+ * `ToolDefinition`,不会再做编译。逐属性 `required: true` 的 schemastery 方言只有
610
+ * `defineTool()` 会转成 JSON Schema(见 dsh-tools 的
611
+ * `parameterSchemaSpecToJsonSchema`),而 register 会把传进来的对象原样透传给
612
+ * provider:
613
+ * register → schemas() → requestWithMessages() → `{type:'function', function:{parameters}}`
614
+ * 没有根 `type: 'object'` 时,DeepSeek 直接回 400:
615
+ * `Invalid schema for function 'session_admin': schema must be a JSON Schema of 'type: "object"', got 'type: null'.`
616
+ * 注册本身不会报错(`register()` 只校验 `output.schema`),所以故障只在每次请求时
617
+ * 爆出来。字段类型一律照 `assertSupportedJsonSchema` 的子集写
618
+ * (type/oneOf/properties/required/additionalProperties/items/enum/const + 注解)。
619
+ */
620
+ const TOOL_PARAMETERS = {
621
+ type: 'object',
622
+ required: ['action'],
623
+ properties: {
624
+ action: {
625
+ type: 'string',
626
+ enum: ['list', 'delete'],
627
+ description: 'list = enumerate matching sessions; delete = remove them.',
628
+ },
629
+ scope: {
630
+ type: 'string',
631
+ enum: SCOPE_VALUES,
632
+ description: 'Which sessions are in range. Defaults to all.',
633
+ },
634
+ sessionIds: {
635
+ type: 'array',
636
+ items: { type: 'string', description: 'One exact session id.' },
637
+ description: 'Explicit ids to delete. When present, scope only narrows nothing — these ids are the target set.',
638
+ },
639
+ cwdPrefix: {
640
+ type: 'string',
641
+ description: 'Only sessions whose working directory starts with this path.',
642
+ },
643
+ search: {
644
+ type: 'string',
645
+ description: 'Case-insensitive substring match over title, working directory, and session id.',
646
+ },
647
+ includeWorkspace: {
648
+ type: 'boolean',
649
+ description: 'Allow deleting sessions attached to a workspace (default false).',
650
+ },
651
+ includeArchived: {
652
+ type: 'boolean',
653
+ description: 'Allow deleting archived sessions (default false).',
654
+ },
655
+ includeSubagent: {
656
+ type: 'boolean',
657
+ description: 'Allow deleting subagent child sessions (default false).',
658
+ },
659
+ dryRun: {
660
+ type: 'boolean',
661
+ description: 'Report what would be deleted without touching anything.',
662
+ },
663
+ confirm: {
664
+ type: 'boolean',
665
+ description: 'Must be true for action="delete" to actually remove data.',
666
+ },
667
+ limit: {
668
+ type: 'integer',
669
+ description: 'Maximum rows returned by action="list" (default 200).',
670
+ },
671
+ },
672
+ };
673
+ /**
674
+ * 工具的输出 schema。
675
+ *
676
+ * 同样是**真正的 JSON Schema**,`required` 是对象级的字符串数组。`register()` 会对
677
+ * 它跑 `assertSupportedJsonSchema`,写成逐属性 `required: true` 的方言会直接抛
678
+ * `JsonSchemaError`,而插件加载失败会连带整个 profile 启动失败。
679
+ *
680
+ * 也只声明自己完全掌控类型的字段:会话行里 `title` / `cwd` / `workspaceTitle`
681
+ * 都可能是 null,一律交给 `additionalProperties` 兜住,不做类型约束。
682
+ */
683
+ const TOOL_OUTPUT = {
684
+ type: 'object',
685
+ additionalProperties: true,
686
+ required: ['ok', 'summary'],
687
+ properties: {
688
+ ok: { type: 'boolean', description: 'Whether the call itself succeeded.' },
689
+ summary: { type: 'string', description: 'One-line human summary of the outcome.' },
690
+ total: { type: 'integer' },
691
+ matched: { type: 'integer' },
692
+ removed: { type: 'integer' },
693
+ skipped: { type: 'integer' },
694
+ reclaimedBytes: { type: 'integer' },
695
+ dryRun: { type: 'boolean' },
696
+ rejections: {
697
+ type: 'array',
698
+ description: 'Rows the guards refused, with the machine-readable reason.',
699
+ items: {
700
+ type: 'object',
701
+ additionalProperties: true,
702
+ required: ['id', 'reason'],
703
+ properties: {
704
+ id: { type: 'string' },
705
+ reason: { type: 'string' },
706
+ },
707
+ },
708
+ },
709
+ },
710
+ };
711
+ /** 人可读的字节数。 */
712
+ function human(bytes) {
713
+ if (!Number.isFinite(bytes))
714
+ return '0 B';
715
+ const units = ['B', 'KB', 'MB', 'GB'];
716
+ let value = bytes;
717
+ let unit = 0;
718
+ while (value >= 1024 && unit < units.length - 1) {
719
+ value /= 1024;
720
+ unit += 1;
721
+ }
722
+ return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
723
+ }
724
+ /**
725
+ * 会话管理工具的注册体。
726
+ * @param admin - 会话管理员。
727
+ * @param config - 插件配置。
728
+ * @param log - 日志函数。
729
+ * @param holds - `{resident, running}`:进程仍挂着的会话集合,以及其中**正在跑**的那部分。
730
+ * @returns `ctx.tools.register` 接受的普通对象定义。
731
+ */
732
+ function buildTool(admin, config, log, holds) {
733
+ const api = buildApi(admin, config, log, holds);
734
+ return {
735
+ name: 'session_admin',
736
+ description: 'List or permanently delete persisted DSH sessions on this machine. DSH itself can only archive (hide) a session; this tool removes its durable data (session log, projection cache, rewind snapshots) and drops the session from its Workspace membership, archive set and pin set, so no dangling reference is left behind. ' +
737
+ 'Always start with action="list" plus a scope to see exactly what is in range, then call action="delete" with the SAME scope and confirm=true. ' +
738
+ 'Scope values: all (every persisted session), ungrouped (exactly the rows the sidebar shows under Ungrouped — no workspace, and neither archived nor a subagent child), temp (sessions whose working directory is gone or under the OS temp dir), archived, subagent, orphan (no session log left, only a stale cache entry). ' +
739
+ 'Sessions that belong to a workspace, are currently open/running, are archived, or are subagent children are REFUSED unless you pass the matching include* flag; report those refusals to the user instead of retrying blindly. ' +
740
+ 'Deletion is irreversible — there is no undo and no trash.',
741
+ parameters: TOOL_PARAMETERS,
742
+ output: {
743
+ schema: TOOL_OUTPUT,
744
+ render: (_args, value) => [{ type: 'text', text: value.summary }],
745
+ },
746
+ presentCall: (args) => ({
747
+ card: 'generic',
748
+ title: args.action === 'delete' ? 'Delete sessions' : 'List sessions',
749
+ kind: 'other',
750
+ rawInput: { action: args.action, scope: args.scope ?? 'all' },
751
+ }),
752
+ async execute(args, exec) {
753
+ const scope = args.scope ?? 'all';
754
+ const callerSessionId = exec?.agent?.session?.id;
755
+ if (args.action === 'list') {
756
+ const payload = {
757
+ scope,
758
+ cwdPrefix: args.cwdPrefix,
759
+ search: args.search,
760
+ limit: Math.max(1, Math.min(Number(args.limit ?? 200), 2000)),
761
+ };
762
+ const value = await api.list(payload);
763
+ const head = `Scope "${scope}": ${value.matched} of ${value.total} sessions match.`;
764
+ return {
765
+ ok: true,
766
+ total: value.total,
767
+ matched: value.matched,
768
+ sessions: value.sessions,
769
+ summary: `${head} ${value.truncated ? '(list truncated) ' : ''}${value.sessions
770
+ .slice(0, 12)
771
+ .map((s) => `${s.id} — ${s.title ?? '(untitled)'} [${s.cwd ?? 'unknown cwd'}]`)
772
+ .join(' | ')}`,
773
+ };
774
+ }
775
+ if (args.confirm !== true) {
776
+ // 不真删:把它们当作一次预演,把「确认后会发生什么」原样回给模型。
777
+ const payload = {
778
+ scope,
779
+ sessionIds: args.sessionIds,
780
+ cwdPrefix: args.cwdPrefix,
781
+ search: args.search,
782
+ dryRun: true,
783
+ includeWorkspace: args.includeWorkspace === true,
784
+ includeArchived: args.includeArchived === true,
785
+ includeSubagent: args.includeSubagent === true,
786
+ };
787
+ const value = await api.delete(payload);
788
+ const rejections = value.results.filter((r) => r.status === 'skipped');
789
+ return {
790
+ ok: true,
791
+ dryRun: true,
792
+ removed: 0,
793
+ matched: value.results.length,
794
+ reclaimedBytes: value.reclaimed,
795
+ rejections: rejections.map((r) => ({ id: r.id, reason: r.reason })),
796
+ summary: `DRY RUN — confirm is not true, nothing was deleted. ${value.results.length} session(s) in range, ${value.reclaimed} bytes would be reclaimed. ${rejections.length} would be refused (${summarizeReasons(rejections)}). Re-issue with confirm=true to proceed.`,
797
+ };
798
+ }
799
+ const value = await api.delete({
800
+ scope,
801
+ sessionIds: args.sessionIds,
802
+ cwdPrefix: args.cwdPrefix,
803
+ search: args.search,
804
+ dryRun: false,
805
+ includeWorkspace: args.includeWorkspace === true,
806
+ includeArchived: args.includeArchived === true,
807
+ includeSubagent: args.includeSubagent === true,
808
+ callerSessionId,
809
+ });
810
+ const failures = value.results.filter((r) => r.status === 'failed');
811
+ const rejections = value.results.filter((r) => r.status === 'skipped');
812
+ return {
813
+ ok: true,
814
+ removed: value.removed,
815
+ skipped: rejections.length,
816
+ reclaimedBytes: value.reclaimed,
817
+ dryRun: false,
818
+ rejections: rejections.map((r) => ({ id: r.id, reason: r.reason })),
819
+ summary: `Deleted ${value.removed} session(s), reclaimed ${human(value.reclaimed)}.` +
820
+ (value.pruned.length > 0 ? ` Pruned ${value.pruned.length} empty project dir(s).` : '') +
821
+ (rejections.length > 0 ? ` Refused ${rejections.length}: ${summarizeReasons(rejections)}.` : '') +
822
+ (failures.length > 0 ? ` Failed ${failures.length}: ${failures.map((r) => `${r.id} (${r.reason})`).join('; ')}.` : ''),
823
+ };
824
+ },
825
+ };
826
+ }
827
+ /** 把拒绝理由压成 `reason×n` 形式的一句话。 */
828
+ function summarizeReasons(results) {
829
+ const counts = new Map();
830
+ for (const result of results)
831
+ counts.set(result.reason, (counts.get(result.reason) ?? 0) + 1);
832
+ return [...counts.entries()].map(([reason, count]) => `${reason}×${count}`).join(', ');
833
+ }
834
+ /**
835
+ * 挂载删除半边:路由 + 工具。
836
+ *
837
+ * 与搬迁半边刻意不同的三点(原样保留的理由):
838
+ * - 信任栅栏按 `Host` 头判断(`isTrustedRequest`),额外拒绝 `sec-fetch-site: cross-site`
839
+ * 与跨源 `Origin`;搬迁半边看 socket 的 `remoteAddress`;
840
+ * - 响应带 `content-length`;
841
+ * - 请求体按**宽松模式**读(空白 body 走 `JSON.parse` 报错,非对象不拦)。
842
+ *
843
+ * @param ctx - Host cordis 上下文(`webServer` / `tools` / `sessions` / `workspaceRegistry`)。
844
+ * @param config - 已解包的**活配置对象**:身份由入口保持,volatile 更新时原地覆盖,
845
+ * 所以下面这些闭包自动看到最新值。
846
+ * @param log - `(level, message) => void`(入口传的是 admin 那套行格式的文件日志)。
847
+ * @returns 无。
848
+ */
849
+ export function mountAdmin(ctx, config, log) {
850
+ /**
851
+ * 把一个即将被删除的会话从它登记过的每个持久结构里摘掉。
852
+ *
853
+ * 实现(工作区成员表 + 归档集合 + 置顶集合,且**必须早于删文件**)在共享包
854
+ * `releaseSessionEverywhere` 里——`dsh-session-move` 面对的是同一张登记表,
855
+ * 「成员表是按会话头过滤过的视图」这条事实只该写一遍。
856
+ *
857
+ * @param sessionId - 即将删除的会话 id。
858
+ * @returns 全部持久写入完成之后 resolve。
859
+ */
860
+ const releaseSession = (sessionId) => releaseSessionEverywhere(ctx.workspaceRegistry, sessionId);
861
+ const admin = createSessionAdmin({ releaseSession });
862
+ /**
863
+ * Host 进程里仍挂着的会话(`ctx.sessions.list()`)。
864
+ *
865
+ * 注意这**不等于**「正在用」:会话是在创建 agent 时被 enter 的
866
+ * (dsh-agent-loop/index.js:1714 `agent.ctx.sessions.enter(session)`),而 agent 只在
867
+ * 被 dispose 时才 detach——一个长期运行的 `dsh web` 里,只要碰过就基本一直挂着。
868
+ * 所以这个集合只用来做界面提示,不用来做删除守卫。
869
+ */
870
+ const residentIds = () => residentSessionIds(ctx.get('sessions'));
871
+ /**
872
+ * 真正不能删的:**这一轮还在跑**的会话。删它会让进行中的写入失败、把这一轮弄坏,
873
+ * 所以一律拒绝(`allowLiveSessions` 可强行放行)。
874
+ *
875
+ * 驻留但空闲的会话不在这个集合里,理由见 `remove()` 里 live 守卫的注释:整目录删除
876
+ * 之后 Host 再写只会得到 ENOENT(`open(path,'a')` 不创建父目录),不会生成无头日志。
877
+ *
878
+ * 拿不到 agent 服务时**保守退化**为「全部驻留会话都算在跑」——宁可拒绝,不可误删。
879
+ * 这条退化策略与 `dsh-session-move` 的「未知」策略不同,所以两份都留在共享包里
880
+ * (`runningSessionIds` 与 `agentSessionStatus`),各自用各自的那一个。
881
+ */
882
+ const runningIds = () => runningSessionIds(residentIds(), ctx.get('agents'));
883
+ const holds = { resident: residentIds, running: runningIds };
884
+ const api = buildApi(admin, config, log, holds);
885
+ log('info', `mounted: home=${admin.home} prefix=${ADMIN_API_PREFIX} allowHosts=[${config.adminAllowHosts.join(', ')}]`);
886
+ ctx.effect(() => ctx.webServer.register({
887
+ kind: 'prefix',
888
+ path: ADMIN_API_PREFIX,
889
+ // 路由骨架(403/404/200/400 的响应体、方法名解析、GET 走 query)在共享包里;
890
+ // 三件"只有本插件才有的策略"以参数传进去:
891
+ // - 信任栅栏(Host 头 + Origin/sec-fetch-site 版,见 isTrustedRequest);
892
+ // - 响应带 content-length(本插件原有行为);
893
+ // - 请求体按**宽松模式**读(默认),与 move 的严格模式不同。
894
+ handler: createApiHandler({
895
+ prefix: ADMIN_API_PREFIX,
896
+ methods: api,
897
+ trustRequest: (req) => isTrustedRequest(req, config.adminAllowHosts),
898
+ contentLength: true,
899
+ log,
900
+ }),
901
+ }), 'dsh-session-admin: api route');
902
+ ctx.effect(() => ctx.tools.register(buildTool(admin, config, log, holds)), 'dsh-session-admin: session_admin tool');
903
+ }
904
+ // 2026-09-25:本文件由 dsh-session-admin/lib/index.js 逐字搬入 dsh-session-ops(合并包)。