dsh-any-agent 0.5.0 → 0.5.1

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/src/hub.mjs CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  mkdirSync,
20
20
  mkdtempSync,
21
21
  readFileSync,
22
+ realpathSync,
22
23
  writeFileSync,
23
24
  existsSync,
24
25
  renameSync,
@@ -26,7 +27,7 @@ import {
26
27
  rmSync,
27
28
  readdirSync,
28
29
  } from 'node:fs';
29
- import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
30
+ import { dirname, isAbsolute, join, parse, resolve, sep } from 'node:path';
30
31
  import { homedir, tmpdir } from 'node:os';
31
32
  import { spawnSync } from 'node:child_process';
32
33
  import {
@@ -34,6 +35,7 @@ import {
34
35
  APPROVAL_POLICIES,
35
36
  DEFAULT_PERMISSION,
36
37
  DEFAULT_REASONING_EFFORT,
38
+ DEFAULT_REVIEW,
37
39
  IDLE_TTL_MS,
38
40
  LIST_PROBE_TTL_MS,
39
41
  MAX_LIVE,
@@ -42,6 +44,8 @@ import {
42
44
  PROMPT_TIMEOUT_MS,
43
45
  REAP_INTERVAL_MS,
44
46
  REASONING_EFFORTS,
47
+ REVIEWS_DIR,
48
+ REVIEW_MODES,
45
49
  RUNS_DIR,
46
50
  SENTINEL_INCLUDE_REASONING,
47
51
  SENTINEL_TTL_MS,
@@ -53,6 +57,16 @@ import {
53
57
  assertWritableDir,
54
58
  defaultApprovalPolicy,
55
59
  } from './config.mjs';
60
+ import {
61
+ REVIEW_VERDICTS,
62
+ buildReviewTicket,
63
+ normalizeReviewMode,
64
+ readReviewRecord,
65
+ reviewIdForRun,
66
+ writeReviewRecord,
67
+ } from './review.mjs';
68
+ import { sanitizeApprovalContext } from './redact.mjs';
69
+ import { assertSafeId } from './ids.mjs';
56
70
  import { AcpProcess } from './acp.mjs';
57
71
  import { SessionHost } from './host.mjs';
58
72
  import { rssMb } from './procstat.mjs';
@@ -177,6 +191,46 @@ const MAX_RUNS = 20;
177
191
  /** 回合记录里 prompt 预览的长度。 */
178
192
  const MAX_RUN_PREVIEW = 200;
179
193
 
194
+ /**
195
+ * 清掉 `dir` 下超过 TTL 的文件与空会话目录;`.tmp` 残留不论年龄都按同一 cutoff 清。
196
+ * 只遍历一层会话目录,绝不递归进别处 —— 调用方(哨兵/审核目录)都是本服务自己的。
197
+ */
198
+ function pruneDirTree(dir, ttlMs) {
199
+ if (!ttlMs || !existsSync(dir)) return { pruned: 0, dirs_removed: 0 };
200
+ let pruned = 0;
201
+ let dirsRemoved = 0;
202
+ const cutoff = Date.now() - ttlMs;
203
+ try {
204
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
205
+ if (!entry.isDirectory()) continue;
206
+ const sub = join(dir, entry.name);
207
+ for (const f of readdirSync(sub, { withFileTypes: true })) {
208
+ if (!f.isFile()) continue;
209
+ const fp = join(sub, f.name);
210
+ try {
211
+ if (statSync(fp).mtimeMs < cutoff) {
212
+ rmSync(fp, { force: true });
213
+ pruned++;
214
+ }
215
+ } catch {
216
+ /* 单个文件失败就跳过 */
217
+ }
218
+ }
219
+ try {
220
+ if (readdirSync(sub).length === 0) {
221
+ rmSync(sub, { recursive: true, force: true });
222
+ dirsRemoved++;
223
+ }
224
+ } catch {
225
+ /* 目录清理失败无所谓 */
226
+ }
227
+ }
228
+ } catch {
229
+ /* 目录读不了就跳过 */
230
+ }
231
+ return { pruned, dirs_removed: dirsRemoved };
232
+ }
233
+
180
234
  /** 按 ACP 权限应答的规范形状挑选项。 */
181
235
  function pickOption(options, want) {
182
236
  const list = Array.isArray(options) ? options : [];
@@ -184,26 +238,6 @@ function pickOption(options, want) {
184
238
  return match?.optionId;
185
239
  }
186
240
 
187
- /** Only expose review context in memory; redact common credential fields and literals. */
188
- function sanitizeApprovalContext(value, key = '') {
189
- if (/^(?:api[_-]?key|authorization|proxy[_-]?authorization|cookie|set[_-]?cookie|password|passwd|secret|token|access[_-]?token|refresh[_-]?token|credentials?|headers|env|environment)$/i.test(key)) {
190
- return '[REDACTED]';
191
- }
192
- if (typeof value === 'string') {
193
- return value
194
- .replace(/\b(?:sk|ak)-[A-Za-z0-9_-]{8,}/g, '[REDACTED]')
195
- .replace(/\bBearer\s+[^\s'";]+/gi, 'Bearer [REDACTED]')
196
- .replace(/((?:api[_-]?key|password|passwd|secret|(?:access[_-]?)?token)\s*(?:=|:)\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s;]+)/gi, '$1[REDACTED]')
197
- .replace(/(--(?:api[_-]?key|password|passwd|secret|token)\s+)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s;]+)/gi, '$1[REDACTED]')
198
- .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[REDACTED]@');
199
- }
200
- if (Array.isArray(value)) return value.map((item) => sanitizeApprovalContext(item));
201
- if (value && typeof value === 'object') {
202
- return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, sanitizeApprovalContext(item, name)]));
203
- }
204
- return value ?? null;
205
- }
206
-
207
241
  function approvalContext(params, toolCall, desc, options) {
208
242
  const input = toolCall.rawInput ?? toolCall.input ?? null;
209
243
  return sanitizeApprovalContext({
@@ -276,6 +310,17 @@ export function sanitizeResultForSentinel(result) {
276
310
  return input === undefined ? safeMeta : { ...safeMeta, input: sanitizeApprovalContext(input) };
277
311
  });
278
312
  }
313
+ // 审核单里可能有原任务预览、审核理由与证据摘要;哨兵会保留 7 天,同样要脱敏。
314
+ if (rest.review && typeof rest.review === 'object') {
315
+ const rv = { ...rest.review };
316
+ for (const key of ['prompt_preview', 'notes']) {
317
+ if (typeof rv[key] === 'string') rv[key] = sanitizeApprovalContext(rv[key], key);
318
+ }
319
+ if (Array.isArray(rv.checklist)) rv.checklist = rv.checklist.map((x) => sanitizeApprovalContext(x));
320
+ if (Array.isArray(rv.findings)) rv.findings = rv.findings.map((x) => sanitizeApprovalContext(x));
321
+ if (rv.evidence) rv.evidence = sanitizeApprovalContext(rv.evidence);
322
+ rest.review = rv;
323
+ }
279
324
  // 始终给出布尔值:调用方才能区分“思考被剥离”与“本来没有思考”。
280
325
  const out = { ...rest, thinking_omitted: Boolean(thinking) && !SENTINEL_INCLUDE_REASONING };
281
326
  if (SENTINEL_INCLUDE_REASONING && thinking !== undefined) out.thinking = thinking;
@@ -301,6 +346,8 @@ export class Conversation {
301
346
  provider: state.provider ?? null,
302
347
  model: state.model ?? null,
303
348
  };
349
+ /** 调用方审核策略(off / advisory)。可在 dsh_send 单次覆盖。 */
350
+ this.review = REVIEW_MODES.includes(state.review) ? state.review : DEFAULT_REVIEW;
304
351
  this.lastConfigApplied = null;
305
352
  /** 是否已回读并确认会话记录的权限档(null=尚未核对,true/false=结果)。 */
306
353
  this.permissionVerified = null;
@@ -367,6 +414,7 @@ export class Conversation {
367
414
  reasoningEffort: this.cfg.reasoningEffort,
368
415
  provider: this.cfg.provider,
369
416
  model: this.cfg.model,
417
+ review: this.review,
370
418
  workspace: this.workspace,
371
419
  };
372
420
  }
@@ -398,6 +446,8 @@ export class Conversation {
398
446
  title_hint: this.titleHint ?? null,
399
447
  cwd: this.cwd,
400
448
  permission: this.permission,
449
+ /** 会话默认审核策略;单次 dsh_send 可以覆盖。 */
450
+ review: this.review,
401
451
  /** true=已回读会话记录且一致;false/null=缓存暂时没有记录(read-only 仍由审批层兜底)。 */
402
452
  permission_verified: this.permissionVerified,
403
453
  on_approval: this.onApproval,
@@ -854,6 +904,17 @@ export class Conversation {
854
904
  workspace_changed: false,
855
905
  elapsed_ms: 0,
856
906
  };
907
+ const reviewMode = runRecord.review_mode ?? this.review;
908
+ if (reviewMode !== 'off') {
909
+ runRecord.result.review = buildReviewTicket({
910
+ conversationId: this.id,
911
+ runId: runRecord.run_id,
912
+ promptPreview: runRecord.prompt_preview,
913
+ result: runRecord.result,
914
+ mode: reviewMode,
915
+ permission: this.permission,
916
+ });
917
+ }
857
918
  try {
858
919
  if (typeof this.#hub.writeRunSentinel === 'function') {
859
920
  const file = this.#hub.writeRunSentinel(this.id, runRecord);
@@ -902,13 +963,15 @@ export class Conversation {
902
963
  *
903
964
  * @returns 等到回合结束 → 完整 result;没等到(wait=false 或等超时)→ 收据对象
904
965
  */
905
- async prompt(text, { reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, run, wait = true } = {}) {
966
+ async prompt(text, { reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, run, wait = true, review } = {}) {
906
967
  if (this.busy) {
907
968
  throw new Error(
908
969
  `会话 ${this.id} 正在跑另一个回合。可以:dsh_interject 插话(打断并改口 / 排队),或 dsh_interrupt 打断。`,
909
970
  );
910
971
  }
911
- const runRecord = run ?? this.createRun(text);
972
+ const runRecord = run ?? this.createRun(text, { review });
973
+ const reviewMode = runRecord.review_mode ?? normalizeReviewMode(review ?? this.review);
974
+ runRecord.review_mode = reviewMode;
912
975
  runRecord.status = 'running';
913
976
  runRecord.started_at = now();
914
977
  this.currentRun = runRecord;
@@ -955,6 +1018,26 @@ export class Conversation {
955
1018
  const t0 = now();
956
1019
  let settled = false;
957
1020
 
1021
+ /**
1022
+ * 把"调用方审核单"挂到回合结果上。
1023
+ *
1024
+ * 审核单必须随结果一起返回(正文、structuredContent、哨兵都是同一份 result),
1025
+ * 否则调用方在异步收活时只会看到一段自述,很容易直接把它当成完成事实。
1026
+ */
1027
+ const attachReview = (result) => {
1028
+ if (reviewMode !== 'off') {
1029
+ result.review = buildReviewTicket({
1030
+ conversationId: this.id,
1031
+ runId: runRecord.run_id,
1032
+ promptPreview: runRecord.prompt_preview,
1033
+ result,
1034
+ mode: reviewMode,
1035
+ permission: this.permission,
1036
+ });
1037
+ }
1038
+ return result;
1039
+ };
1040
+
958
1041
  /** 回合真正结束时收尾。无论有没有人在等,都**只执行一次**。 */
959
1042
  const finalize = (stopReason, failure) => {
960
1043
  if (settled) return runRecord.result;
@@ -1008,6 +1091,7 @@ export class Conversation {
1008
1091
  diff_stat: after,
1009
1092
  elapsed_ms: now() - t0,
1010
1093
  };
1094
+ attachReview(result);
1011
1095
  runRecord.ended_at = now();
1012
1096
  runRecord.elapsed_ms = result.elapsed_ms;
1013
1097
  runRecord.status = failure ? 'error' : stopReason === 'cancelled' ? 'cancelled' : 'done';
@@ -1040,6 +1124,7 @@ export class Conversation {
1040
1124
  workspace_changed: false,
1041
1125
  elapsed_ms: now() - t0,
1042
1126
  };
1127
+ attachReview(fallback);
1043
1128
  runRecord.ended_at = now();
1044
1129
  runRecord.status = 'error';
1045
1130
  runRecord.error = fallback.error;
@@ -1152,13 +1237,15 @@ export class Conversation {
1152
1237
  }
1153
1238
 
1154
1239
  /** 建一条回合记录。派活时先建,好让调用方立刻拿到 run_id(异步模式的关键)。 */
1155
- createRun(text, { clientRunId = null } = {}) {
1240
+ createRun(text, { clientRunId = null, review = this.review } = {}) {
1156
1241
  const run = {
1157
1242
  run_id: `run-${Date.now().toString(36)}-${++this.runSeq}`,
1158
1243
  conversation_id: this.id,
1159
1244
  client_run_id: clientRunId ?? null,
1160
1245
  prompt_preview: text.length > MAX_RUN_PREVIEW ? `${text.slice(0, MAX_RUN_PREVIEW)}…` : text,
1161
1246
  status: 'queued',
1247
+ /** 本回合的审核策略(off / advisory);由 dsh_send / dsh_interject 或会话默认决定。 */
1248
+ review_mode: normalizeReviewMode(review, this.review),
1162
1249
  started_at: null,
1163
1250
  ended_at: null,
1164
1251
  elapsed_ms: null,
@@ -1189,7 +1276,7 @@ export class Conversation {
1189
1276
  // ── 会话级队列(忙时非阻塞入队)────────────────────────────────
1190
1277
 
1191
1278
  /** 入队;返回 run 记录与 1-based 位置。队列只在内存,重启丢失。 */
1192
- enqueue(text, { reasoning = 'hide', clientRunId = null, preview = null } = {}) {
1279
+ enqueue(text, { reasoning = 'hide', clientRunId = null, preview = null, review = this.review } = {}) {
1193
1280
  const MAX_QUEUE = 8;
1194
1281
  if (clientRunId) {
1195
1282
  const existing = this.getRunByClientId(clientRunId);
@@ -1199,7 +1286,7 @@ export class Conversation {
1199
1286
  throw new Error(`队列已满(最多 ${MAX_QUEUE} 条);先用 dsh_queue list 清理,或等当前回合结束`);
1200
1287
  }
1201
1288
  // 预览用调用方原文,执行用可能已注入团队前缀的 text。
1202
- const run = this.createRun(preview ?? text, { clientRunId });
1289
+ const run = this.createRun(preview ?? text, { clientRunId, review });
1203
1290
  run.status = 'queued';
1204
1291
  this.queue.push({ run, text, reasoning, clientRunId, enqueued_at: now() });
1205
1292
  return { run, position: this.queue.length, deduped: false };
@@ -1236,6 +1323,16 @@ export class Conversation {
1236
1323
  workspace_changed: false,
1237
1324
  elapsed_ms: 0,
1238
1325
  };
1326
+ if (run.review_mode !== 'off') {
1327
+ run.result.review = buildReviewTicket({
1328
+ conversationId: this.id,
1329
+ runId: run.run_id,
1330
+ promptPreview: run.prompt_preview,
1331
+ result: run.result,
1332
+ mode: run.review_mode,
1333
+ permission: this.permission,
1334
+ });
1335
+ }
1239
1336
  // 取消也是终态 → 写哨兵,避免等待方永远空等。
1240
1337
  const file = this.#hub.writeRunSentinel(this.id, run);
1241
1338
  run.result.sentinel_file = file ?? this.#hub.runSentinelPath(this.id, run.run_id);
@@ -1289,6 +1386,142 @@ export class Conversation {
1289
1386
  return this.runs.length ? this.runs[this.runs.length - 1] : null;
1290
1387
  }
1291
1388
 
1389
+ /**
1390
+ * 异步收据里的"待审核"审核单:回合还没结束,只有清单和 run_id,
1391
+ * 告诉调用方"拿到哨兵结果后先审核,再当完成"。
1392
+ */
1393
+ pendingReviewTicket(runId) {
1394
+ const run = this.runs.find((x) => x.run_id === runId) ?? null;
1395
+ const mode = run?.review_mode ?? this.review;
1396
+ if (mode === 'off') return null;
1397
+ return buildReviewTicket({
1398
+ conversationId: this.id,
1399
+ runId,
1400
+ promptPreview: run?.prompt_preview ?? null,
1401
+ result: null,
1402
+ mode,
1403
+ permission: this.permission,
1404
+ pending: true,
1405
+ });
1406
+ }
1407
+
1408
+ /**
1409
+ * 记录调用方对某个已完成回合的审核裁决。
1410
+ *
1411
+ * 三种来源按优先级取事实:
1412
+ * 1) 内存里的 run(含完整 result);
1413
+ * 2) 哨兵文件(服务重启 / run 记录被挤出内存后仍然可审核);
1414
+ * 3) 已有的审核记录(允许修订裁决)。
1415
+ *
1416
+ * 注意:**不阻塞后续派活,也不自动回灌给 DSH**。审核只是把"调用方已经看过并给出结论"
1417
+ * 这件事落成可审计的事实;要修就由调用方再 dsh_send。
1418
+ */
1419
+ submitReview(runId, { verdict, notes, findings } = {}) {
1420
+ if (typeof runId !== 'string' || runId.trim() === '') {
1421
+ throw new Error('run_id 不能为空');
1422
+ }
1423
+ assertSafeId(runId, 'run_id');
1424
+ if (!REVIEW_VERDICTS.includes(verdict)) {
1425
+ throw new Error(`verdict 必须是 ${REVIEW_VERDICTS.join(' | ')} 之一`);
1426
+ }
1427
+ const cleanNotes = typeof notes === 'string' ? notes.trim() : '';
1428
+ const cleanFindings = Array.isArray(findings)
1429
+ ? findings.filter((f) => typeof f === 'string' && f.trim() !== '').map((f) => f.trim())
1430
+ : [];
1431
+ if (verdict !== 'pass' && !cleanNotes && !cleanFindings.length) {
1432
+ throw new Error('verdict=fail/partial 时必须提供 notes(或 findings)说明理由');
1433
+ }
1434
+
1435
+ const run = this.runs.find((x) => x.run_id === runId) ?? null;
1436
+ let result = run?.result ?? null;
1437
+ let promptPreview = run?.prompt_preview ?? null;
1438
+ let mode = run?.review_mode ?? this.review;
1439
+
1440
+ if (run) {
1441
+ if (run.status === 'running' || run.status === 'queued') {
1442
+ throw new Error(`回合 ${runId} 还没结束,不能审核(等哨兵文件出现或 dsh_get 显示完成后重试)`);
1443
+ }
1444
+ if (!result) throw new Error(`回合 ${runId} 没有可用结果,无法审核`);
1445
+ } else {
1446
+ // 内存里没有这条 run:以哨兵为准。它只在回合终止时写入,所以天然是"已完成"。
1447
+ let sentinel = null;
1448
+ try {
1449
+ sentinel = JSON.parse(readFileSync(this.#hub.runSentinelPath(this.id, runId), 'utf8'));
1450
+ } catch {
1451
+ /* 下面的审核记录回退会给出更准确的错误 */
1452
+ }
1453
+ if (sentinel) {
1454
+ result = sentinel.result ?? null;
1455
+ if (result?.review && typeof result.review === 'object') {
1456
+ promptPreview = result.review.prompt_preview ?? promptPreview;
1457
+ mode = result.review.mode ?? mode;
1458
+ }
1459
+ }
1460
+ }
1461
+
1462
+ const existing = readReviewRecord(this.id, runId);
1463
+ if (!result && !existing) {
1464
+ throw new Error(
1465
+ `未知 run_id: ${runId}(内存里没有、也没有哨兵/审核记录;请确认 run_id 是否正确,` +
1466
+ `或先用 dsh_get / 哨兵文件取回结果)`,
1467
+ );
1468
+ }
1469
+
1470
+ const ticket =
1471
+ result?.review && typeof result.review === 'object'
1472
+ ? { ...result.review }
1473
+ : existing
1474
+ ? { ...existing }
1475
+ : buildReviewTicket({
1476
+ conversationId: this.id,
1477
+ runId,
1478
+ promptPreview,
1479
+ result: result ?? {},
1480
+ mode,
1481
+ permission: this.permission,
1482
+ });
1483
+ if (!ticket.review_id) ticket.review_id = reviewIdForRun(runId);
1484
+ ticket.conversation_id = ticket.conversation_id ?? this.id;
1485
+ ticket.run_id = ticket.run_id ?? runId;
1486
+ ticket.mode = ticket.mode ?? mode;
1487
+
1488
+ const reviewedAt = new Date().toISOString();
1489
+ const { file, payload } = writeReviewRecord({
1490
+ ticket,
1491
+ verdict,
1492
+ notes: cleanNotes,
1493
+ findings: cleanFindings,
1494
+ reviewedAt,
1495
+ });
1496
+ const review = {
1497
+ ...ticket,
1498
+ status: verdict,
1499
+ verdict,
1500
+ notes: payload.notes,
1501
+ findings: payload.findings,
1502
+ reviewed_at: reviewedAt,
1503
+ review_file: file,
1504
+ };
1505
+ if (run) {
1506
+ run.review = review;
1507
+ if (run.result && typeof run.result === 'object') run.result.review = review;
1508
+ }
1509
+ // 哨兵是调用方异步收活的来源;把裁决回写进去,避免它一直显示 pending。
1510
+ this.#hub.updateRunSentinelReview(this.id, runId, {
1511
+ status: review.status,
1512
+ verdict: review.verdict,
1513
+ notes: review.notes,
1514
+ findings: review.findings,
1515
+ reviewed_at: reviewedAt,
1516
+ review_file: file,
1517
+ });
1518
+ return {
1519
+ ...review,
1520
+ review_file: file,
1521
+ sentinel_file: this.#hub.runSentinelPath(this.id, runId),
1522
+ };
1523
+ }
1524
+
1292
1525
  /** 等到当前回合结束(不忙则立即返回 true;`timeoutMs <= 0` 一直等;超时返回 false)。 */
1293
1526
  async waitIdle(timeoutMs = 30 * 60 * 1000) {
1294
1527
  if (!this.busy) return true;
@@ -1327,9 +1560,9 @@ export class Conversation {
1327
1560
  * 之所以无竞态:dsh-acp 在让 prompt 请求返回**之前**就清空了 inflight 槽位,
1328
1561
  * 且只在 agent 完全 idle 后才 settle(lib/index.js:1003-1011)。
1329
1562
  */
1330
- async interject(message, { mode = 'interject', reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, clientRunId = null } = {}) {
1563
+ async interject(message, { mode = 'interject', reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, clientRunId = null, review } = {}) {
1331
1564
  // 先建 run:幂等键要在"等待上一轮"之前就登记,否则重试会造出第二个 run。
1332
- const run = this.createRun(message, { clientRunId });
1565
+ const run = this.createRun(message, { clientRunId, review });
1333
1566
  if (!this.busy) {
1334
1567
  const r = await this.prompt(message, { reasoning, timeoutMs, run });
1335
1568
  return { ...r, interjected: 'idle', waited_ms: 0 };
@@ -1338,7 +1571,7 @@ export class Conversation {
1338
1571
  if (mode === 'interject') {
1339
1572
  this.proc?.notify('session/cancel', { sessionId: this.id });
1340
1573
  }
1341
- // 排队等待的时长跟随调用方的 timeout_ms(0 = 一直等),不再固定 30 分钟。
1574
+ // 排队等待的时长跟随调用方的 timeoutMs(0 = 一直等),不再固定 30 分钟。
1342
1575
  const idle = await this.waitIdle(timeoutMs);
1343
1576
  const waited = now() - t0;
1344
1577
  if (!idle) {
@@ -1358,6 +1591,17 @@ export class Conversation {
1358
1591
  workspace_changed: false,
1359
1592
  elapsed_ms: 0,
1360
1593
  };
1594
+ // 取消同样是一次终态结果:审核单照挂,调用方仍可记录"这次没跑成"。
1595
+ if (run.review_mode !== 'off') {
1596
+ run.result.review = buildReviewTicket({
1597
+ conversationId: this.id,
1598
+ runId: run.run_id,
1599
+ promptPreview: run.prompt_preview,
1600
+ result: run.result,
1601
+ mode: run.review_mode,
1602
+ permission: this.permission,
1603
+ });
1604
+ }
1361
1605
  const file = this.#hub.writeRunSentinel?.(this.id, run);
1362
1606
  run.result.sentinel_file = file ?? this.#hub.runSentinelPath?.(this.id, run.run_id) ?? null;
1363
1607
  throw err;
@@ -1511,9 +1755,26 @@ export function homeWorkspaceProblem(cwd, permission) {
1511
1755
  if (!home) return null;
1512
1756
  const caseInsensitive = process.platform === 'win32' || fsCaseInsensitive();
1513
1757
  const norm = (p) => {
1514
- const r = resolve(p);
1758
+ // 解析 symlink:一个指向家目录/根目录的软链不能绕过这两个边界。
1759
+ // 路径不存在时(测试里的 "some-project")回落到 resolve。
1760
+ let r;
1761
+ try {
1762
+ r = realpathSync(p);
1763
+ } catch {
1764
+ r = resolve(p);
1765
+ }
1515
1766
  return caseInsensitive ? r.toLowerCase() : r;
1516
1767
  };
1768
+ // 根目录 = 整个磁盘。workspace-write 的沙箱根会跟着 cwd 走,
1769
+ // 于是“只写工作区”退化成“可写全盘”,必须显式要求 danger-full-access。
1770
+ const fsRoot = resolve(parse(cwd).root);
1771
+ if (norm(cwd) === norm(fsRoot)) {
1772
+ return [
1773
+ 'cwd 不能是文件系统根目录: ' + cwd,
1774
+ ' workspace-write 的沙箱根会变成整个磁盘,等于绕过“只写工作区”的权限档。',
1775
+ ' 请传一个具体的项目目录;确实要全盘访问请显式用 permission=danger-full-access。',
1776
+ ].join('\n');
1777
+ }
1517
1778
  if (norm(cwd) !== norm(home)) return null;
1518
1779
 
1519
1780
  const temp = tmpdir();
@@ -1557,6 +1818,7 @@ export class Hub {
1557
1818
  // 看起来正常,但注册表/哨兵/锁登记全部丢失,异步等待永远醒不过来。
1558
1819
  assertWritableDir(dirname(STATE_FILE), '状态目录');
1559
1820
  assertWritableDir(RUNS_DIR, '哨兵目录');
1821
+ assertWritableDir(REVIEWS_DIR, '审核记录目录');
1560
1822
  assertWritableDir(LOCKS_DIR, '锁登记目录');
1561
1823
 
1562
1824
  // 旧版本的默认状态文件在包安装目录里;一次性迁移到新的用户可写目录。
@@ -1631,6 +1893,8 @@ export class Hub {
1631
1893
 
1632
1894
  /** 某个 run 的完成哨兵文件路径。 */
1633
1895
  runSentinelPath(conversationId, runId) {
1896
+ assertSafeId(conversationId, 'conversation_id');
1897
+ assertSafeId(runId, 'run_id');
1634
1898
  return join(RUNS_DIR, conversationId, `${runId}.json`);
1635
1899
  }
1636
1900
 
@@ -1666,6 +1930,34 @@ export class Hub {
1666
1930
  }
1667
1931
  }
1668
1932
 
1933
+ /**
1934
+ * 把调用方的审核裁决回写进该 run 的哨兵文件(best-effort)。
1935
+ *
1936
+ * 为什么回写:异步调用方收活主要靠读哨兵。若审核后哨兵仍写着 `review.status=pending`,
1937
+ * 下一次读文件会得到一个与事实相反的状态。原子重写不破坏闩锁语义:文件始终存在,
1938
+ * 内容要么是旧的完整版本,要么是新的完整版本。
1939
+ */
1940
+ updateRunSentinelReview(conversationId, runId, review) {
1941
+ const file = this.runSentinelPath(conversationId, runId);
1942
+ try {
1943
+ const payload = JSON.parse(readFileSync(file, 'utf8'));
1944
+ const result = payload.result && typeof payload.result === 'object' ? payload.result : {};
1945
+ result.review = {
1946
+ ...(result.review && typeof result.review === 'object' ? result.review : {}),
1947
+ ...review,
1948
+ };
1949
+ payload.result = result;
1950
+ payload.reviewed_at = review.reviewed_at ?? null;
1951
+ const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
1952
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
1953
+ renameSync(tmp, file);
1954
+ return true;
1955
+ } catch (e) {
1956
+ this.log.info(`[hub] 回写哨兵审核状态失败(${file}): ${e.message}`);
1957
+ return false;
1958
+ }
1959
+ }
1960
+
1669
1961
  /**
1670
1962
  * 启动时清理过期哨兵(默认 7 天前)与崩溃残留的 .tmp 文件。
1671
1963
  *
@@ -1673,39 +1965,16 @@ export class Hub {
1673
1965
  * 只在服务启动时做一次,因此绝不会删掉"当前正在等待的"哨兵(那时它还不需要存在)。
1674
1966
  */
1675
1967
  pruneSentinels({ ttlMs = SENTINEL_TTL_MS } = {}) {
1676
- if (!ttlMs || !existsSync(RUNS_DIR)) return { pruned: 0, dirs_removed: 0 };
1677
- let pruned = 0;
1678
- let dirsRemoved = 0;
1679
- const cutoff = Date.now() - ttlMs;
1680
- try {
1681
- for (const entry of readdirSync(RUNS_DIR, { withFileTypes: true })) {
1682
- if (!entry.isDirectory()) continue;
1683
- const dir = join(RUNS_DIR, entry.name);
1684
- for (const f of readdirSync(dir, { withFileTypes: true })) {
1685
- if (!f.isFile()) continue;
1686
- const fp = join(dir, f.name);
1687
- try {
1688
- if (statSync(fp).mtimeMs < cutoff) {
1689
- rmSync(fp, { force: true });
1690
- pruned++;
1691
- }
1692
- } catch {
1693
- /* 单个文件失败就跳过 */
1694
- }
1695
- }
1696
- try {
1697
- if (readdirSync(dir).length === 0) {
1698
- rmSync(dir, { recursive: true, force: true });
1699
- dirsRemoved++;
1700
- }
1701
- } catch {
1702
- /* 目录清理失败无所谓 */
1703
- }
1704
- }
1705
- } catch (e) {
1706
- this.log.info(`[hub] 清理哨兵失败: ${e.message}`);
1707
- }
1708
- return { pruned, dirs_removed: dirsRemoved };
1968
+ const runs = pruneDirTree(RUNS_DIR, ttlMs);
1969
+ // 审核记录同样含正文/理由,按同一 TTL 清理;目录分开是为了语义清晰,
1970
+ // 也避免一次清理把"刚提交的裁决"和"已完成的哨兵"混为一谈。
1971
+ const reviews = pruneDirTree(REVIEWS_DIR, ttlMs);
1972
+ return {
1973
+ pruned: runs.pruned,
1974
+ dirs_removed: runs.dirs_removed,
1975
+ reviews_pruned: reviews.pruned,
1976
+ review_dirs_removed: reviews.dirs_removed,
1977
+ };
1709
1978
  }
1710
1979
 
1711
1980
  /** 删除某个 run 的哨兵(起跑前防御性清残留;调用方消费后也可自行删)。 */
@@ -1718,6 +1987,8 @@ export class Hub {
1718
1987
  }
1719
1988
 
1720
1989
  async get(id) {
1990
+ // 会话 id 会参与 meta/tombstone/会话存储的路径拼接;先挡住路径分隔符与 `..`。
1991
+ assertSafeId(id, 'conversation_id');
1721
1992
  // ★ forget 是终态:墓碑存在时,任何按 id 的查询都必须明确失败,
1722
1993
  // 否则 DSH 会话存储里的记录会让它悄悄复活("forget 不持久")。
1723
1994
  if (isForgotten(id)) {
@@ -1793,6 +2064,7 @@ export class Hub {
1793
2064
  createdAt: persisted?.createdAt,
1794
2065
  lastUsedAt: persisted?.lastUsedAt,
1795
2066
  turnCount: persisted?.turnCount,
2067
+ review: REVIEW_MODES.includes(persisted?.review) ? persisted.review : DEFAULT_REVIEW,
1796
2068
  workspace: persisted?.workspace ?? null,
1797
2069
  },
1798
2070
  this,
@@ -1880,7 +2152,7 @@ export class Hub {
1880
2152
  }
1881
2153
 
1882
2154
  /** 新建一个会话:拉起进程 + ACP 初始化 + session/new。 */
1883
- async create({ cwd, permission = DEFAULT_PERMISSION, onApproval, titleHint, reasoningEffort, provider, model, keepAlive = false } = {}) {
2155
+ async create({ cwd, permission = DEFAULT_PERMISSION, onApproval, titleHint, reasoningEffort, provider, model, keepAlive = false, review } = {}) {
1884
2156
  if (!cwd) {
1885
2157
  throw new Error(
1886
2158
  '没有可用的工作目录。你既没传 cwd,客户端也没有提供当前打开的项目(MCP roots)。\n' +
@@ -1898,6 +2170,12 @@ export class Hub {
1898
2170
  if (!PERMISSION_TIERS.includes(permission)) {
1899
2171
  throw new Error(`permission 必须是 ${PERMISSION_TIERS.join(' | ')} 之一`);
1900
2172
  }
2173
+ // title_hint 会写进注册表与侧车,每一轮 save() 都会重复落盘;不设上限就是一个
2174
+ // 稳定的磁盘/内存放大点(调用方可以传任意长度)。可读标签 200 字足够。
2175
+ if (titleHint !== undefined && titleHint !== null) {
2176
+ if (typeof titleHint !== 'string') throw new Error('title_hint 必须是字符串');
2177
+ if (titleHint.length > 200) throw new Error('title_hint 最长 200 字符');
2178
+ }
1901
2179
  // ★ 0.5.0:danger-full-access 不再默认可用。它把整机写权限交给调用方,
1902
2180
  // 而本服务可能被任何 MCP 客户端拉起;必须由机器所有者显式放行。
1903
2181
  if (permission === 'danger-full-access' && !ALLOW_FULL_ACCESS) {
@@ -1922,6 +2200,10 @@ export class Hub {
1922
2200
  if (!REASONING_EFFORTS.includes(effort)) {
1923
2201
  throw new Error(`reasoning_effort 必须是 ${REASONING_EFFORTS.join(' | ')} 之一`);
1924
2202
  }
2203
+ const reviewMode = normalizeReviewMode(review ?? DEFAULT_REVIEW);
2204
+ if (!REVIEW_MODES.includes(reviewMode)) {
2205
+ throw new Error(`review 必须是 ${REVIEW_MODES.join(' | ')} 之一`);
2206
+ }
1925
2207
  if (model && !provider) {
1926
2208
  throw new Error('指定 model 时必须同时给 provider(ACP 的模型标识是 [provider, model] 二元组)');
1927
2209
  }
@@ -1957,6 +2239,7 @@ export class Hub {
1957
2239
  reasoningEffort: effort,
1958
2240
  provider: provider ?? null,
1959
2241
  model: model ?? null,
2242
+ review: reviewMode,
1960
2243
  },
1961
2244
  this,
1962
2245
  );
package/src/ids.mjs ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 会话 id / run id 的路径安全校验。
3
+ *
4
+ * 这两个 id 会直接参与 `<state>/runs/<conversation_id>/<run_id>.json` 与
5
+ * `<state>/reviews/<conversation_id>/<run_id>.review.json` 的路径拼接。
6
+ * DSH 生成的是 UUID / `run-<base36>-<n>`,但 MCP 调用方可以传任意字符串;
7
+ * 带 `/`、`\`、`:` 或 `..` 的 id 一旦进入 join(),就能读写状态目录之外的文件。
8
+ *
9
+ * 规则刻意保守:单段、字母数字开头、只允许 `._-`,最长 200。合法的 DSH
10
+ * UUID 和本服务生成的 run_id 都满足;不满足的 id 在触盘之前就拒绝。
11
+ */
12
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
13
+
14
+ export function assertSafeId(value, label = 'id') {
15
+ if (typeof value !== 'string' || value === '.' || value === '..' || !SAFE_ID.test(value)) {
16
+ throw new Error(
17
+ `${label} 非法:只允许字母/数字开头、由字母/数字/._- 组成的单段 id(最长 200): ${JSON.stringify(value)}`,
18
+ );
19
+ }
20
+ return value;
21
+ }