dsh-pentester 1.0.0 → 2.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/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { existsSync, readFileSync, readdirSync } from "node:fs";
2
- import { dirname, join, posix } from "node:path";
2
+ import { dirname, isAbsolute, join, posix, relative, sep } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { performance } from "node:perf_hooks";
4
5
  import Schema from "@deepseek-ai/schemastery";
5
- import { appendFile, copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { appendFile, copyFile, cp, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
6
7
  import { homedir } from "node:os";
7
- import { createHash, randomBytes } from "node:crypto";
8
+ import { createHash } from "node:crypto";
8
9
  import { Readable } from "node:stream";
9
10
  import { pipeline } from "node:stream/promises";
10
11
  import tar from "tar-stream";
@@ -26,6 +27,7 @@ const SUBAGENT_PROVIDER = "spawn";
26
27
  * DSH tools.restrict() 对未知名字做严格存在性校验。
27
28
  */
28
29
  const WORKER_TOOL_FILTER = { deny: [
30
+ "pentester_start",
29
31
  "pentester_delegate",
30
32
  "pentester_cancel_delegation",
31
33
  "pentester_advance_stage",
@@ -265,35 +267,213 @@ function isStageId(value) {
265
267
  return STAGE_IDS.includes(value);
266
268
  }
267
269
  //#endregion
268
- //#region src/store.ts
270
+ //#region src/targets.ts
269
271
  /**
270
- * store.ts — the single run.json snapshot per Project (docs/workspace.md).
271
- * Atomic write via tmp+rename; no event log, no replay.
272
+ * targets.ts — Target Registry: multi-target workspace isolation.
273
+ *
274
+ * Project workspace is shared only as a container for Target workspaces.
275
+ * Every Target has an isolated Root Session, PentestRun, PTES timeline,
276
+ * filesystem tree, Git history and Toolbox runtime.
277
+ *
278
+ * TargetContext is the mandatory resolver for all target-specific code.
279
+ * No target-scoped function accepts a bare projectDir.
272
280
  */
273
- const RUN_DIR = ".dsh-pentester";
274
- const RUN_FILE = "run.json";
275
- /** 工作区交付树:`<project>/workspace` = 容器 `/workspace`(共享 volume 的宿主镜像)。 */
276
281
  const WORKSPACE_DIR = "workspace";
277
- var StoreError = class extends Error {};
278
- function runFilePath(projectDir) {
279
- return join(workspaceDir(projectDir), RUN_DIR, RUN_FILE);
282
+ const RUN_DIR$1 = ".dsh-pentester";
283
+ const RUN_FILE = "run.json";
284
+ const TARGETS_FILE = "targets.json";
285
+ const TARGETS_SUBDIR = "targets";
286
+ const TARGET_REGISTRY_SCHEMA = 1;
287
+ /**
288
+ * Conservative canonicalization: trim, lowercase hostname portion,
289
+ * strip trailing slash/path for URLs. Preserves port and scheme distinctions.
290
+ * Never merges semantically different targets.
291
+ */
292
+ function canonicalizeTarget(raw) {
293
+ let cleaned = raw.trim();
294
+ if (cleaned === "") return cleaned;
295
+ let scheme = "";
296
+ const schemeMatch = /^(https?:\/\/)/i.exec(cleaned);
297
+ if (schemeMatch !== null) {
298
+ scheme = schemeMatch[1].toLowerCase();
299
+ cleaned = cleaned.slice(schemeMatch[1].length);
300
+ }
301
+ const hostEnd = cleaned.indexOf("/");
302
+ if (hostEnd !== -1) cleaned = cleaned.slice(0, hostEnd);
303
+ cleaned = cleaned.replace(/:$/, "");
304
+ const portMatch = /^(.+):(\d+)$/.exec(cleaned);
305
+ if (portMatch !== null) cleaned = `${portMatch[1].toLowerCase()}:${portMatch[2]}`;
306
+ else cleaned = cleaned.toLowerCase();
307
+ return scheme === "" ? cleaned : `${scheme}${cleaned}`;
308
+ }
309
+ /** 8-char hex digest for stable short id. */
310
+ function makeTargetId(canonicalTarget) {
311
+ return createHash("sha1").update(canonicalTarget).digest("hex").slice(0, 8);
312
+ }
313
+ /**
314
+ * Slug-safe directory name from a target string.
315
+ * Replaces path-unsafe characters with hyphens.
316
+ * Format: <readable-slug>-<short-hash>
317
+ */
318
+ function makeTargetDirName(target, id) {
319
+ return `${target.replace(/^https?:\/\//, "").replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 40)}-${id}`;
280
320
  }
281
- function workspaceDir(projectDir) {
321
+ function projectWorkspaceDir(projectDir) {
282
322
  return join(projectDir, WORKSPACE_DIR);
283
323
  }
324
+ function targetRegistryPath(projectDir) {
325
+ return join(projectWorkspaceDir(projectDir), RUN_DIR$1, TARGETS_FILE);
326
+ }
327
+ function targetsRoot(projectDir) {
328
+ return join(projectWorkspaceDir(projectDir), TARGETS_SUBDIR);
329
+ }
330
+ function runFilePath(ctx) {
331
+ return join(ctx.targetRoot, RUN_DIR$1, RUN_FILE);
332
+ }
333
+ function eventsFilePath(ctx) {
334
+ return join(ctx.targetRoot, RUN_DIR$1, "events.jsonl");
335
+ }
336
+ function targetInputDir(ctx) {
337
+ return join(ctx.targetRoot, "target", "inputs");
338
+ }
339
+ function stagesDir(ctx) {
340
+ return join(ctx.targetRoot, "stages");
341
+ }
342
+ function stageDelegationsDir(ctx, stage) {
343
+ return join(stagesDir(ctx), stageDir(stage), "delegations");
344
+ }
345
+ /** Host-side delegation directory: `<targetRoot>/stages/<NN>-<stage>/delegations/D-00N`. */
346
+ function delegationHostDir(ctx, stage, id) {
347
+ return join(stageDelegationsDir(ctx, stage), id);
348
+ }
349
+ /** Container-side delegation directory: `/workspace/stages/<NN>-<stage>/delegations/D-00N`. */
350
+ function delegationContainerDir(stage, id) {
351
+ return `/workspace/stages/${stageDir(stage)}/delegations/${id}`;
352
+ }
353
+ var TargetRegistryError = class extends Error {};
354
+ /**
355
+ * Load the workspace target registry. Returns empty registry if file does not
356
+ * exist (no targets yet). Throws on corruption.
357
+ */
358
+ async function loadTargetRegistry(projectDir) {
359
+ const file = targetRegistryPath(projectDir);
360
+ if (!existsSync(file)) return {
361
+ schemaVersion: TARGET_REGISTRY_SCHEMA,
362
+ targets: []
363
+ };
364
+ let parsed;
365
+ try {
366
+ parsed = JSON.parse(await readFile(file, "utf8"));
367
+ } catch (error) {
368
+ throw new TargetRegistryError(`targets.json is corrupted: ${error instanceof Error ? error.message : String(error)}`);
369
+ }
370
+ if (parsed === null || typeof parsed !== "object") throw new TargetRegistryError("targets.json must be an object");
371
+ const record = parsed;
372
+ if (record.schemaVersion !== TARGET_REGISTRY_SCHEMA) throw new TargetRegistryError(`targets.json: unsupported schemaVersion ${String(record.schemaVersion)}`);
373
+ if (!Array.isArray(record.targets)) throw new TargetRegistryError("targets.json: targets must be an array");
374
+ return record;
375
+ }
376
+ /** Atomic write via tmp+rename. */
377
+ async function saveTargetRegistry(projectDir, registry) {
378
+ const file = targetRegistryPath(projectDir);
379
+ await mkdir(join(projectWorkspaceDir(projectDir), RUN_DIR$1), { recursive: true });
380
+ const tmp = `${file}.${crypto.randomUUID().slice(0, 8)}.tmp`;
381
+ await writeFile(tmp, `${JSON.stringify(registry, null, 2)}\n`);
382
+ await rename(tmp, file);
383
+ }
384
+ const registryQueue = /* @__PURE__ */ new Map();
385
+ /**
386
+ * Serial read-modify-write for targets.json. Keyed by projectDir.
387
+ * Concurrent inserts from different sessions are serialized to prevent
388
+ * lost updates.
389
+ */
390
+ async function updateTargetRegistry(projectDir, fn) {
391
+ const next = (registryQueue.get(projectDir) ?? Promise.resolve()).then(async () => {
392
+ const registry = await loadTargetRegistry(projectDir);
393
+ await fn(registry);
394
+ await saveTargetRegistry(projectDir, registry);
395
+ });
396
+ registryQueue.set(projectDir, next.catch(() => void 0).then(() => {}));
397
+ return next;
398
+ }
399
+ function listTargetRecords(registry) {
400
+ return registry.targets;
401
+ }
402
+ function resolveTargetByCanonicalTarget(registry, canonicalTarget) {
403
+ return registry.targets.find((t) => t.canonicalTarget === canonicalTarget);
404
+ }
405
+ function resolveTargetByRootSession(registry, sessionId) {
406
+ return registry.targets.find((t) => t.rootSessionId === sessionId);
407
+ }
408
+ /**
409
+ * Find a TargetRecord whose PentestRun contains a Delegation with the given
410
+ * worker sessionId. Returns undefined if no active run matches.
411
+ * This is a heavier query that requires loading each target's run.json.
412
+ */
413
+ async function resolveTargetByWorkerSession(projectDir, registry, sessionId) {
414
+ for (const record of registry.targets) {
415
+ const runFile = runFilePath(buildTargetContext(projectDir, record));
416
+ if (!existsSync(runFile)) continue;
417
+ try {
418
+ const run = JSON.parse(await readFile(runFile, "utf8"));
419
+ const delegations = run?.delegations;
420
+ if (!Array.isArray(delegations)) continue;
421
+ if (delegations.some((d) => d.sessionId === sessionId)) return {
422
+ record,
423
+ run
424
+ };
425
+ } catch {
426
+ continue;
427
+ }
428
+ }
429
+ }
430
+ function buildTargetContext(projectDir, record) {
431
+ const targetRootPath = join(targetsRoot(projectDir), record.dirName);
432
+ return {
433
+ projectDir,
434
+ workspaceRoot: projectWorkspaceDir(projectDir),
435
+ targetId: record.id,
436
+ target: record.target,
437
+ canonicalTarget: record.canonicalTarget,
438
+ dirName: record.dirName,
439
+ targetRoot: targetRootPath,
440
+ rootSessionId: record.rootSessionId
441
+ };
442
+ }
284
443
  /**
285
- * 非异常版加载:文件不存在返回 null(不要用 try/catch 判存在性);
286
- * 文件损坏仍抛 StoreError(不吞损坏,防止用新 Run 覆盖坏文件)。
444
+ * Resolve the TargetContext for the given Root session.
445
+ * Returns undefined if this session is not bound to any target.
287
446
  */
447
+ async function resolveSessionTargetContext(projectDir, sessionId) {
448
+ const record = resolveTargetByRootSession(await loadTargetRegistry(projectDir), sessionId);
449
+ if (record === void 0) return void 0;
450
+ return buildTargetContext(projectDir, record);
451
+ }
452
+ /**
453
+ * Resolve TargetContext for a worker session (via delegation binding).
454
+ * Returns undefined if no delegation matches this session.
455
+ */
456
+ async function resolveWorkerTargetContext(projectDir, sessionId) {
457
+ const result = await resolveTargetByWorkerSession(projectDir, await loadTargetRegistry(projectDir), sessionId);
458
+ if (result === void 0) return void 0;
459
+ return buildTargetContext(projectDir, result.record);
460
+ }
461
+ //#endregion
462
+ //#region src/store.ts
288
463
  /**
289
- * 唯一 PentestRun 解析入口(docs/plan.md §6):文件不存在 → null;
290
- * 文件损坏 StoreError。插件侧所有消费者统一走这里。
464
+ * store.ts — target-scoped PentestRun persistence (docs/workspace.md).
465
+ * Atomic write via tmp+rename; no event log, no replay.
466
+ *
467
+ * All run I/O requires a TargetContext (from src/targets.ts). Bare projectDir
468
+ * is no longer accepted for run-level operations.
291
469
  */
292
- async function resolvePentestRun(projectDir) {
293
- return tryLoadRun(projectDir);
470
+ const RUN_DIR = ".dsh-pentester";
471
+ var StoreError = class extends Error {};
472
+ async function resolvePentestRun(ctx) {
473
+ return tryLoadRun(ctx);
294
474
  }
295
- async function tryLoadRun(projectDir) {
296
- const file = runFilePath(projectDir);
475
+ async function tryLoadRun(ctx) {
476
+ const file = runFilePath(ctx);
297
477
  if (!existsSync(file)) return null;
298
478
  let parsed;
299
479
  try {
@@ -303,14 +483,13 @@ async function tryLoadRun(projectDir) {
303
483
  }
304
484
  return validateRun(parsed, file);
305
485
  }
306
- async function saveRun(projectDir, run) {
307
- const file = runFilePath(projectDir);
308
- await mkdir(join(workspaceDir(projectDir), RUN_DIR), { recursive: true });
486
+ async function saveRun(ctx, run) {
487
+ const file = runFilePath(ctx);
488
+ await mkdir(join(ctx.targetRoot, RUN_DIR), { recursive: true });
309
489
  const tmp = `${file}.${crypto.randomUUID().slice(0, 8)}.tmp`;
310
490
  await writeFile(tmp, `${JSON.stringify(run, null, 2)}\n`);
311
491
  await rename(tmp, file);
312
492
  }
313
- /** Linear advance only; the host never judges information sufficiency. */
314
493
  function advanceStage(run, reason) {
315
494
  if (run.currentStage === null || run.status === "completed") throw new StoreError("run is already completed; cannot advance");
316
495
  const current = run.currentStage;
@@ -335,8 +514,6 @@ function advanceStage(run, reason) {
335
514
  });
336
515
  return target;
337
516
  }
338
- /** 当前 stage 状态(缺省:currentStage=active,其余=pending)。 */
339
- /** 把当前 stage 标为 reviewing(最后一个 delegation 结算时调用)。 */
340
517
  function markStageReviewing(run) {
341
518
  if (run.currentStage === null) return;
342
519
  run.stageStatuses = {
@@ -344,14 +521,6 @@ function markStageReviewing(run) {
344
521
  [run.currentStage]: "reviewing"
345
522
  };
346
523
  }
347
- /** 当前 stage 的 delegation 目录:`workspace/stages/<NN>-<stage>/delegations/`。 */
348
- function stageDelegationsDir(projectDir, stage) {
349
- return join(workspaceDir(projectDir), "stages", stageDir(stage), "delegations");
350
- }
351
- /** 某 delegation 的工作区目录:`workspace/stages/<NN>-<stage>/delegations/D-00N`。 */
352
- function delegationWorkspaceDir(projectDir, stage, id) {
353
- return join(stageDelegationsDir(projectDir, stage), id);
354
- }
355
524
  function nextDelegationId(run) {
356
525
  let max = 0;
357
526
  for (const delegation of run.delegations) {
@@ -366,7 +535,7 @@ function findDelegation(run, id) {
366
535
  function validateRun(value, file) {
367
536
  if (value === null || typeof value !== "object") throw new StoreError(`${file}: run.json must be an object`);
368
537
  const record = value;
369
- if (record.schemaVersion !== 1) throw new StoreError(`${file}: unsupported schemaVersion ${String(record.schemaVersion)}`);
538
+ if (record.schemaVersion !== 2 && record.schemaVersion !== 1) throw new StoreError(`${file}: unsupported schemaVersion ${String(record.schemaVersion)}`);
370
539
  if (record.currentStage !== null && (typeof record.currentStage !== "string" || !isStageId(record.currentStage))) throw new StoreError(`${file}: invalid currentStage`);
371
540
  if (!Array.isArray(record.delegations)) throw new StoreError(`${file}: delegations must be an array`);
372
541
  return record;
@@ -374,33 +543,34 @@ function validateRun(value, file) {
374
543
  //#endregion
375
544
  //#region src/workspace.ts
376
545
  /**
377
- * workspace.ts — 工作区布局初始化与宿主侧产物。
546
+ * workspace.ts — target-scoped workspace initialization and host-side artifacts.
378
547
  *
379
- * 固定骨架(每次 run 结构完全一致,见 docs/workspace.md):
548
+ * New layout (per docs/workspace.md, updated for multi-target):
380
549
  *
381
- * <project>/workspace/ = 容器 /workspace(共享 volume 的宿主镜像)
382
- * ├── .gitignore # traffic/ *.pcap *.mitm tmp/ cache/ *.log
383
- * ├── STATUS.md # 宿主自动生成的可读状态
384
- * ├── target/ target.json scope/ roe/ inputs/
385
- * ├── assets/ assets.json
386
- * ├── stages/ <NN>-<stage>/ summary.md delegations/
387
- * ├── findings/
388
- * ├── report/
389
- * └── traffic/
390
- *
391
- * 宿主侧状态(不随 volume 同步):<project>/.dsh-pentester/run.json、
392
- * workspace.json、events.jsonl(append-only)。
550
+ * <project>/workspace/
551
+ * ├── .dsh-pentester/
552
+ * │ └── targets.json ← Target registry (workspace-level)
553
+ * └── targets/
554
+ * └── <target-id>/
555
+ * ├── .git/
556
+ * ├── .dsh-pentester/
557
+ * ├── run.json
558
+ * ├── workspace.json
559
+ * │ └── events.jsonl
560
+ * ├── target/
561
+ * ├── assets/
562
+ * ├── findings/
563
+ * ├── stages/
564
+ * ├── report/
565
+ * └── traffic/
393
566
  */
394
- function workspaceFilePath(projectDir) {
395
- return join(workspaceDir(projectDir), RUN_DIR, "workspace.json");
396
- }
397
- function eventsFilePath(projectDir) {
398
- return join(workspaceDir(projectDir), RUN_DIR, "events.jsonl");
567
+ function workspaceFilePath(ctx) {
568
+ return join(ctx.targetRoot, RUN_DIR, "workspace.json");
399
569
  }
400
- /** 追加一条事件(失败不阻塞主流程)。 */
401
- async function appendEvent(projectDir, event) {
570
+ /** Append an event to the target's events.jsonl (best-effort, never blocks). */
571
+ async function appendEvent(ctx, event) {
402
572
  try {
403
- await appendFile(eventsFilePath(projectDir), `${JSON.stringify({
573
+ await appendFile(eventsFilePath(ctx), `${JSON.stringify({
404
574
  ts: event.ts,
405
575
  type: event.type,
406
576
  data: event.data
@@ -408,14 +578,14 @@ async function appendEvent(projectDir, event) {
408
578
  } catch {}
409
579
  }
410
580
  /**
411
- * 初始化工作区固定骨架 + 宿主侧状态文件。幂等:已存在则只补齐缺失目录。
412
- * @param target 初始目标(pre-engagement 确认后写入 target/target.json)。
413
- * @param scope 用户确认后的 Scope 文本(写入 target/scope.md,不做机器 enforcement)。
414
- * @param roe 用户确认后的 RoE / Authorization 事实(写入 target/roe.md,不做程序级语义解析)。
581
+ * Initialize a target workspace with the full skeleton.
582
+ * Creates: .dsh-pentester/, target/, assets/, findings/, stages/,
583
+ * report/, traffic/, .gitignore.
584
+ * Idempotent: only creates missing directories.
415
585
  */
416
- async function initWorkspace(projectDir, options = {}) {
417
- const root = workspaceDir(projectDir);
418
- const metaPath = workspaceFilePath(projectDir);
586
+ async function initTargetWorkspace(ctx, options = {}) {
587
+ const root = ctx.targetRoot;
588
+ const metaPath = workspaceFilePath(ctx);
419
589
  await mkdir(join(root, RUN_DIR), { recursive: true });
420
590
  let meta;
421
591
  if (existsSync(metaPath)) meta = JSON.parse(await readFile(metaPath, "utf8"));
@@ -429,7 +599,7 @@ async function initWorkspace(projectDir, options = {}) {
429
599
  gitBranch: "main"
430
600
  };
431
601
  await writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
432
- await appendEvent(projectDir, {
602
+ await appendEvent(ctx, {
433
603
  ts: meta.createdAt,
434
604
  type: "run.created",
435
605
  data: { target: meta.target }
@@ -454,37 +624,30 @@ async function initWorkspace(projectDir, options = {}) {
454
624
  };
455
625
  await writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
456
626
  }
457
- await writeTarget(projectDir, meta.target);
627
+ await writeTarget(ctx, meta.target);
458
628
  if (options.scope !== void 0) await writeFile(join(root, "target", "scope.md"), options.scope.endsWith("\n") ? options.scope : `${options.scope}\n`, "utf8");
459
629
  if (options.roe !== void 0) await writeFile(join(root, "target", "roe.md"), options.roe.endsWith("\n") ? options.roe : `${options.roe}\n`, "utf8");
460
630
  return meta;
461
631
  }
462
- /** target/target.json(幂等;仅当文件不存在或 target 变化时更新)。 */
463
- async function writeTarget(projectDir, target) {
632
+ /** Write target/target.json (idempotent). */
633
+ async function writeTarget(ctx, target) {
464
634
  if (target === void 0) return;
465
- const file = join(workspaceDir(projectDir), "target", "target.json");
635
+ const file = join(ctx.targetRoot, "target", "target.json");
466
636
  if ((existsSync(file) ? JSON.parse(await readFile(file, "utf8")) : void 0)?.target === target) return;
467
637
  await writeFile(file, `${JSON.stringify({
468
638
  target,
469
639
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
470
640
  }, null, 2)}\n`, "utf8");
471
641
  }
472
- /**
473
- * stage summary.md(Stage handoff)。内容由 Root 通过
474
- * pentester_advance_stage(summary) 提供,Host 只负责写磁盘、不解析。
475
- */
476
- async function writeStageSummary(projectDir, stage, summary) {
642
+ /** Write stage summary.md. */
643
+ async function writeStageSummary(ctx, stage, summary) {
477
644
  const text = summary.trim();
478
645
  if (text === "") return;
479
- await writeFile(join(workspaceDir(projectDir), "stages", stageDir(stage), "summary.md"), `# ${stageDir(stage)}\n\n${text}\n`, "utf8");
646
+ await writeFile(join(ctx.targetRoot, "stages", stageDir(stage), "summary.md"), `# ${stageDir(stage)}\n\n${text}\n`, "utf8");
480
647
  }
481
- /**
482
- * 提升某 delegation 目录中的 canonical 产物(仅 threat-model.md)。
483
- * assets.json findings.md 现在由 promoteAssets / promoteFindings
484
- * (src/pentest/)负责聚合提升,不再走简单 copyFile。
485
- */
486
- async function promoteDeliverables(projectDir, dir) {
487
- const root = workspaceDir(projectDir);
648
+ /** Promote threat-model.md from delegation dir to canonical findings/. */
649
+ async function promoteDeliverables(ctx, dir) {
650
+ const root = ctx.targetRoot;
488
651
  for (const [name, relative] of [["threat-model.md", "findings/threat-model.md"]]) {
489
652
  const source = join(dir, name);
490
653
  if (!existsSync(source)) continue;
@@ -493,13 +656,9 @@ async function promoteDeliverables(projectDir, dir) {
493
656
  await copyFile(source, target);
494
657
  }
495
658
  }
496
- /**
497
- * Pre-engagement 用户确认的完整授权信息写入
498
- * `target/inputs/pre-engagement.md` 作为原始输入 provenance。
499
- * 在 bootstrap(首次 advance)时调用。
500
- */
501
- async function writePreEngagementInput(projectDir, options) {
502
- const inputsDir = join(workspaceDir(projectDir), "target", "inputs");
659
+ /** Write pre-engagement provenance to target/inputs/pre-engagement.md. */
660
+ async function writePreEngagementInput(ctx, options) {
661
+ const inputsDir = targetInputDir(ctx);
503
662
  await mkdir(inputsDir, { recursive: true });
504
663
  const content = [
505
664
  "# Pre-engagement Confirmation",
@@ -507,7 +666,7 @@ async function writePreEngagementInput(projectDir, options) {
507
666
  `**Recorded**: ${(/* @__PURE__ */ new Date()).toISOString()}`,
508
667
  "",
509
668
  "## Target",
510
- ...options.targets.map((t) => `- ${t}`),
669
+ `- ${options.target}`,
511
670
  "",
512
671
  "## Scope",
513
672
  options.scope,
@@ -532,7 +691,7 @@ async function writePreEngagementInput(projectDir, options) {
532
691
  * durable Session that survives across turns and can accept followup messages
533
692
  * from Root or user. The plugin only manages PTES domain state.
534
693
  *
535
- * 目录契约(docs/workspace.md):`workspace/stages/<NN>-<stage>/delegations/D-00N/`
694
+ * 目录契约:`<targetRoot>/stages/<NN>-<stage>/delegations/D-00N/`
536
695
  * input/ work/ artifacts/ evidence/ result.md
537
696
  * Worker 只写自己的 delegation 目录;宿主写 input/task_prompt.md 快照。
538
697
  * result.md 是当前 Delegation 的"最新正式可交付结果",不是 child Session
@@ -562,11 +721,6 @@ const PROFILE_CATEGORIES = {
562
721
  "threat-model": []
563
722
  };
564
723
  var DelegationError = class extends Error {};
565
- /**
566
- * Worker persona(官方 ContinuableStartSpec.request.persona):常驻规则 +
567
- * AgentProfile 角色。Worker 是长期存在的 continuable child,可能收到多轮
568
- * 来自 Root 或用户的消息。
569
- */
570
724
  const WORKER_BASELINE = `You are a long-lived PTES execution worker — a continuable child agent.
571
725
 
572
726
  You may receive multiple rounds of tasks from:
@@ -604,7 +758,10 @@ Standing rules:
604
758
  - One round ending does NOT mean your Delegation is permanently over. You may
605
759
  receive follow-up messages from Root or a user.
606
760
  - Your final response each round must summarize: what you did, what you found,
607
- which files you wrote, and what you recommend investigating next.`;
761
+ which files you wrote, and what you recommend investigating next.
762
+ - Set the timeout duration for invoking the tool to 1800000 milliseconds.
763
+ For operations such as enumeration, directory scanning, brute force cracking, etc.,
764
+ a longer duration such as 1 hour or 2 hours can be set.`;
608
765
  function buildWorkerPersona(profile, catalogText, toolPrompt) {
609
766
  const parts = [WORKER_BASELINE];
610
767
  if (toolPrompt !== void 0 && toolPrompt.length > 0) parts.push(toolPrompt);
@@ -615,16 +772,15 @@ function buildWorkerPersona(profile, catalogText, toolPrompt) {
615
772
  /**
616
773
  * Worker 首轮 user message(官方 ContinuableStartSpec.request.prompt):
617
774
  * standalone task context。官方 continuable child 不继承 parent transcript,
618
- * 所以这里必须自包含:Delegation ID / Stage / Target / Scope / RoE /
619
- * 目录路径 / 完成契约 / task_prompt。
775
+ * 所以必须自包含。
620
776
  */
621
777
  function buildWorkerTaskPrompt(options) {
622
- const { run, delegation, projectDir } = options;
623
- const stage = stageDefinition(delegation.stageId);
624
- const containerDir = delegation.workspaceDir.startsWith(projectDir) ? delegation.workspaceDir.slice(projectDir.length).replace(/\\/g, "/") : `/workspace/stages/${stage === void 0 ? delegation.stageId : stage.id}`;
778
+ const { run, delegation, stageId } = options;
779
+ const stage = stageDefinition(stageId);
780
+ const containerDir = delegationContainerDir(stageId, delegation.id);
625
781
  const metadata = [
626
782
  `Delegation ID: ${delegation.id}`,
627
- `Stage: ${delegation.stageId}${stage === void 0 ? "" : ` — ${stage.name}`}`,
783
+ `Stage: ${stageId}${stage === void 0 ? "" : ` — ${stage.name}`}`,
628
784
  `Project workspace root: /workspace (READ-ONLY outside your own directory)`,
629
785
  `Your deliverable directory: ${containerDir}`,
630
786
  ` work/ scratch working files (default container cwd)`,
@@ -633,7 +789,7 @@ function buildWorkerTaskPrompt(options) {
633
789
  ` result.md current formal deliverable — write/update it as results accumulate`,
634
790
  `Write policy: write files ONLY inside ${containerDir}; never modify`,
635
791
  ` .git/, .dsh-pentester/, target/, other delegation directories or the workspace root.`,
636
- run.targets.length > 0 ? `Targets: ${run.targets.join(", ")}` : "Targets: (not yet recorded)",
792
+ `Target: ${run.target || "(not yet recorded)"}`,
637
793
  run.rulesOfEngagement === void 0 ? "" : `Rules of Engagement: ${run.rulesOfEngagement}`
638
794
  ].filter((line) => line !== "");
639
795
  return [
@@ -656,11 +812,6 @@ function buildWorkerTaskPrompt(options) {
656
812
  ` use the report tool to notify the Root Agent.`
657
813
  ].join("\n");
658
814
  }
659
- /**
660
- * 把 tools.json 的 categories 渲染成 compact 文本,注入 Worker persona。
661
- * 先输出 profile 匹配的 Recommended 工具,再输出 Other。
662
- * raw 格式由 gen-tools-json.sh 固定(categories: { name, bin, description, examples }[])。
663
- */
664
815
  function renderToolCatalog(raw, profileId) {
665
816
  const data = raw;
666
817
  if (data === null || data === void 0) return "";
@@ -698,25 +849,13 @@ var DelegationService = class {
698
849
  this.dsh = dsh;
699
850
  this.docker = docker;
700
851
  }
701
- /**
702
- * 创建一个或多个 continuable Worker。
703
- *
704
- * 流程:
705
- * validate PentestRun → validate Stage → validate AgentProfile →
706
- * allocate D-xxx → scaffold Delegation → build standalone initial prompt →
707
- * persist Delegation → ctx.subagents.startContinuable() →
708
- * 得到 childId + messageId → Delegation.childSessionId = childId →
709
- * Delegation.status = active → persist run.json → return
710
- *
711
- * startContinuable() resolve 的含义只是初始 prompt 已进入 child inbox,
712
- * 不是 Worker 已完成任务。
713
- */
714
852
  async delegate(deps, assignments) {
715
853
  if (assignments.length === 0) throw new DelegationError("assignments must not be empty");
716
854
  if (deps.run.status === "completed" || deps.run.currentStage === null) throw new DelegationError("PentestRun is completed; cannot delegate new work");
717
855
  const stage = stageDefinition(deps.run.currentStage);
718
856
  if (stage === void 0) throw new DelegationError(`unknown stage: ${deps.run.currentStage}`);
719
857
  if (!this.dsh.subagentAvailable) throw new DelegationError("subagent infrastructure unavailable: ctx.subagents provider \"spawn\" is not registered");
858
+ const ctx = deps.targetContext;
720
859
  const created = [];
721
860
  for (const assignment of assignments) {
722
861
  const profile = deps.profiles.get(assignment.agent);
@@ -725,8 +864,7 @@ var DelegationService = class {
725
864
  if (assignment.objective.trim() === "" || assignment.taskPrompt.trim() === "") throw new DelegationError("objective and task_prompt must not be empty");
726
865
  if (/delegations\/D-\d+/.test(assignment.taskPrompt)) throw new DelegationError("task_prompt must not contain concrete delegation paths (e.g. delegations/D-001). The Host manages D-ID allocation and directory structure. Reference deliverables by their logical names (e.g. \"the recon scan results from intelligence-gathering\").");
727
866
  const id = nextDelegationId(deps.run);
728
- const dir = delegationWorkspaceDir(deps.projectDir, deps.run.currentStage, id);
729
- await scaffoldDelegationDir(dir, {
867
+ await scaffoldDelegationDir(delegationHostDir(ctx, deps.run.currentStage, id), {
730
868
  id,
731
869
  stage: deps.run.currentStage,
732
870
  agent: profile.id,
@@ -739,13 +877,12 @@ var DelegationService = class {
739
877
  agentId: profile.id,
740
878
  objective: assignment.objective,
741
879
  taskPrompt: assignment.taskPrompt,
742
- workspaceDir: dir,
743
880
  status: "starting",
744
881
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
745
882
  };
746
883
  deps.run.delegations.push(delegation);
747
- await saveRun(deps.projectDir, deps.run);
748
- await appendEvent(deps.projectDir, {
884
+ await saveRun(ctx, deps.run);
885
+ await appendEvent(ctx, {
749
886
  ts: (/* @__PURE__ */ new Date()).toISOString(),
750
887
  type: "delegation.created",
751
888
  data: {
@@ -756,23 +893,20 @@ var DelegationService = class {
756
893
  });
757
894
  created.push(delegation);
758
895
  }
759
- await saveRun(deps.projectDir, deps.run);
760
- await this.docker.pushWorkspace(deps.run.id, deps.projectDir).catch(() => void 0);
896
+ await saveRun(ctx, deps.run);
897
+ await this.docker.pushWorkspace(deps.run.id, ctx).catch(() => void 0);
761
898
  const toolbox = await this.docker.ensureImage("kali");
762
899
  const containerId = await this.docker.ensureRunContainer({
763
900
  runId: deps.run.id,
901
+ targetId: ctx.targetId,
902
+ target: ctx.canonicalTarget,
764
903
  toolbox,
765
- projectDir: deps.projectDir
904
+ targetRoot: ctx.targetRoot
766
905
  });
767
906
  for (const delegation of created) await this.dispatch(deps, delegation, containerId);
768
- await saveRun(deps.projectDir, deps.run);
907
+ await saveRun(ctx, deps.run);
769
908
  return created;
770
909
  }
771
- /**
772
- * 派发一个 delegation:创建 continuable child。
773
- * startContinuable() resolve 时 child 已接受 prompt 但尚未开始执行。
774
- * 之后 child 作为 durable Session 持续存在,可接受 followup。
775
- */
776
910
  async dispatch(deps, delegation, containerId) {
777
911
  const profile = deps.profiles.get(delegation.agentId);
778
912
  let catalogText;
@@ -789,15 +923,15 @@ var DelegationService = class {
789
923
  prompt: buildWorkerTaskPrompt({
790
924
  run: deps.run,
791
925
  delegation,
792
- projectDir: deps.projectDir
926
+ stageId: delegation.stageId
793
927
  }),
794
928
  ...profile.model === void 0 ? {} : { model: profile.model }
795
929
  };
796
930
  const start = await this.dsh.startContinuableWorker(input, deps.parent, deps.signal);
797
931
  delegation.sessionId = start.childId;
798
932
  delegation.status = "active";
799
- await saveRun(deps.projectDir, deps.run);
800
- await appendEvent(deps.projectDir, {
933
+ await saveRun(deps.targetContext, deps.run);
934
+ await appendEvent(deps.targetContext, {
801
935
  ts: (/* @__PURE__ */ new Date()).toISOString(),
802
936
  type: "delegation.dispatched",
803
937
  data: {
@@ -807,15 +941,6 @@ var DelegationService = class {
807
941
  }
808
942
  });
809
943
  }
810
- /**
811
- * 关闭 PTES Delegation。
812
- *
813
- * 流程:
814
- * resolve D-ID → resolve childSessionId → 如有 live turn:interrupt child →
815
- * Delegation.status = closed → persist。
816
- *
817
- * 不删除 durable Child Session transcript。用户以后仍然可以看到历史。
818
- */
819
944
  async cancel(deps, delegationId, reason) {
820
945
  const delegation = findDelegation(deps.run, delegationId);
821
946
  if (delegation === void 0) throw new DelegationError(`unknown delegation: ${delegationId}`);
@@ -828,8 +953,8 @@ var DelegationService = class {
828
953
  delegation.status = "closed";
829
954
  delegation.resultSummary = reason ?? "closed by Root";
830
955
  delegation.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
831
- await saveRun(deps.projectDir, deps.run);
832
- await appendEvent(deps.projectDir, {
956
+ await saveRun(deps.targetContext, deps.run);
957
+ await appendEvent(deps.targetContext, {
833
958
  ts: (/* @__PURE__ */ new Date()).toISOString(),
834
959
  type: "delegation.closed",
835
960
  data: {
@@ -839,29 +964,21 @@ var DelegationService = class {
839
964
  });
840
965
  return delegation;
841
966
  }
842
- /**
843
- * 当前 stage 全部 delegation 是否都已 settled(没有 active/starting)。
844
- * 用于 advance 前的机械检查。
845
- */
846
967
  stageHasPendingWork(run, stageId) {
847
968
  return run.delegations.some((d) => d.stageId === stageId && (d.status === "active" || d.status === "starting"));
848
969
  }
849
- /**
850
- * 当前 stage 全部 delegation 中没有 active/starting → 标记 reviewing。
851
- */
852
970
  async maybeMarkReviewing(deps) {
853
971
  if (deps.run.currentStage === null) return;
854
972
  if (this.stageHasPendingWork(deps.run, deps.run.currentStage)) return;
855
973
  markStageReviewing(deps.run);
856
- await saveRun(deps.projectDir, deps.run);
857
- await appendEvent(deps.projectDir, {
974
+ await saveRun(deps.targetContext, deps.run);
975
+ await appendEvent(deps.targetContext, {
858
976
  ts: (/* @__PURE__ */ new Date()).toISOString(),
859
977
  type: "stage.reviewing",
860
978
  data: { stage: deps.run.currentStage }
861
979
  });
862
980
  }
863
981
  };
864
- /** 预建 delegation 目录骨架 + input/task_prompt 快照。 */
865
982
  async function scaffoldDelegationDir(dir, info) {
866
983
  for (const sub of [
867
984
  "input",
@@ -943,8 +1060,8 @@ function readJsonArray(file) {
943
1060
  * deduplicate, and write the canonical `assets/assets.json` and (if any
944
1061
  * services found) `assets/services.json`.
945
1062
  */
946
- async function promoteAssets(projectDir, stage, delegationDirs, delegationIds) {
947
- const root = workspaceDir(projectDir);
1063
+ async function promoteAssets(ctx, stage, delegationDirs, delegationIds) {
1064
+ const root = ctx.targetRoot;
948
1065
  const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
949
1066
  const assetMap = /* @__PURE__ */ new Map();
950
1067
  for (let index = 0; index < delegationDirs.length; index += 1) {
@@ -1004,8 +1121,8 @@ async function promoteAssets(projectDir, stage, delegationDirs, delegationIds) {
1004
1121
  * If a delegation produces `artifacts/findings.md`, its content is copied
1005
1122
  * into the canonical detail file with a provenance header.
1006
1123
  */
1007
- async function promoteFindings(projectDir, stage, delegationDirs, delegationIds) {
1008
- const root = workspaceDir(projectDir);
1124
+ async function promoteFindings(ctx, stage, delegationDirs, delegationIds) {
1125
+ const root = ctx.targetRoot;
1009
1126
  const findingsDir = join(root, "findings");
1010
1127
  await mkdir(findingsDir, { recursive: true });
1011
1128
  const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -1055,8 +1172,268 @@ function loadExistingIndex(findingsDir) {
1055
1172
  }
1056
1173
  }
1057
1174
  //#endregion
1175
+ //#region src/pentest/pre-engagement.ts
1176
+ /**
1177
+ * pre-engagement.ts — fixed one-batch Pre-engagement questionnaire.
1178
+ *
1179
+ * The Root Agent calls ask_user_question EXACTLY ONCE with every question
1180
+ * in a single array. The host validates this contract mechanically before
1181
+ * pentester_start executes any side effect.
1182
+ *
1183
+ * Two planes:
1184
+ * 1. Pure domain constants (question IDs, definitions, answer helpers).
1185
+ * 2. Host-side session-event validator (reads persisted ask_user_question
1186
+ * invocations from the Root Session via sessionQuery).
1187
+ */
1188
+ /** Fixed question IDs in strict order. */
1189
+ const PRE_ENGAGEMENT_QUESTION_IDS = [
1190
+ "target_confirm",
1191
+ "scope_confirm",
1192
+ "exclusions_confirm",
1193
+ "roe_confirm",
1194
+ "language_confirm",
1195
+ "final_confirm"
1196
+ ];
1197
+ /**
1198
+ * Parse the ask_user_question result's answers array into an id→answer map.
1199
+ */
1200
+ function parseAnswers(answers) {
1201
+ const map = /* @__PURE__ */ new Map();
1202
+ if (answers !== void 0) {
1203
+ for (const entry of answers) if (typeof entry.id === "string") map.set(entry.id, entry);
1204
+ }
1205
+ return map;
1206
+ }
1207
+ /**
1208
+ * Check whether a single answer is a "confirmed" (not "rejected") choice.
1209
+ * For questions that use options, we check the selected label.
1210
+ */
1211
+ function isAnswerAccepted(questionId, answer) {
1212
+ if (answer === void 0) return false;
1213
+ const selected = (answer.selected ?? [])[0];
1214
+ if (selected === void 0) return false;
1215
+ switch (questionId) {
1216
+ case "target_confirm": return selected !== "目标不正确";
1217
+ case "scope_confirm": return selected !== "范围不正确";
1218
+ case "exclusions_confirm": return selected === "无额外排除项";
1219
+ case "roe_confirm": return selected === "标准 RoE" || selected === "仅非侵入式测试";
1220
+ case "language_confirm": return selected === "中文" || selected === "English";
1221
+ case "final_confirm": return selected === "确认并开始";
1222
+ }
1223
+ }
1224
+ /**
1225
+ * True when ALL six questions received confirmed answers.
1226
+ */
1227
+ function isPreEngagementConfirmed(answersById) {
1228
+ return PRE_ENGAGEMENT_QUESTION_IDS.every((id) => isAnswerAccepted(id, answersById.get(id)));
1229
+ }
1230
+ /**
1231
+ * Validate that the current Root Session contains exactly one completed
1232
+ * ask_user_question invocation with the six canonical Pre-engagement questions
1233
+ * in the canonical order, all confirmed, matching the start arguments.
1234
+ *
1235
+ * Reads from the session event log via sessionQuery — never trusts model
1236
+ * arguments or browser state.
1237
+ */
1238
+ async function validatePreEngagementBatch(sessionQuery, sessionId, startArgs) {
1239
+ const events = (await sessionQuery.readSession(sessionId)).events;
1240
+ let bestCall;
1241
+ let bestResult;
1242
+ for (let i = events.length - 1; i >= 0; i--) {
1243
+ const event = events[i];
1244
+ if (event.type !== "tool/call") continue;
1245
+ const data = event.data;
1246
+ if (data === void 0 || data.name !== "ask_user_question") continue;
1247
+ if (isExistingTargetActionCall(data)) continue;
1248
+ const result = findMatchingResult(events, data.callId);
1249
+ if (result === void 0) continue;
1250
+ bestCall = data;
1251
+ bestResult = result;
1252
+ break;
1253
+ }
1254
+ if (bestCall === void 0 || bestResult === void 0) return {
1255
+ ok: false,
1256
+ code: "pre_engagement_missing",
1257
+ message: "No completed ask_user_question invocation found in this session. Ask all six Pre-engagement questions together in one ask_user_question call: " + PRE_ENGAGEMENT_QUESTION_IDS.join(", ") + "."
1258
+ };
1259
+ let questions;
1260
+ try {
1261
+ const parsed = JSON.parse(bestCall.arguments);
1262
+ questions = Array.isArray(parsed.questions) ? parsed.questions : [];
1263
+ } catch {
1264
+ return {
1265
+ ok: false,
1266
+ code: "pre_engagement_missing",
1267
+ message: "The ask_user_question invocation has malformed arguments."
1268
+ };
1269
+ }
1270
+ const questionIds = questions.map((q) => typeof q?.id === "string" ? q.id : "");
1271
+ if (questionIds.length !== PRE_ENGAGEMENT_QUESTION_IDS.length) return {
1272
+ ok: false,
1273
+ code: "pre_engagement_wrong_question_count",
1274
+ message: `Pre-engagement requires exactly ${PRE_ENGAGEMENT_QUESTION_IDS.length} questions, got ${questionIds.length}. Re-ask all six questions together in one batch.`
1275
+ };
1276
+ for (let i = 0; i < PRE_ENGAGEMENT_QUESTION_IDS.length; i++) if (questionIds[i] !== PRE_ENGAGEMENT_QUESTION_IDS[i]) return {
1277
+ ok: false,
1278
+ code: "pre_engagement_wrong_question_order",
1279
+ message: `Pre-engagement question at position ${i + 1} must be "${PRE_ENGAGEMENT_QUESTION_IDS[i]}", got "${questionIds[i]}". The six questions must be in this exact order: ` + PRE_ENGAGEMENT_QUESTION_IDS.join(", ") + "."
1280
+ };
1281
+ if (new Set(questionIds).size !== questionIds.length) return {
1282
+ ok: false,
1283
+ code: "pre_engagement_wrong_question_order",
1284
+ message: "Pre-engagement question IDs must be unique. Re-ask all six questions together."
1285
+ };
1286
+ if (bestResult.error !== void 0) return {
1287
+ ok: false,
1288
+ code: "pre_engagement_incomplete",
1289
+ message: `The Pre-engagement questionnaire was not answered (error: ${bestResult.error.code}). Re-ask all six questions together.`
1290
+ };
1291
+ let answers;
1292
+ try {
1293
+ const rawContent = bestResult.message.content;
1294
+ const toolResultBlock = rawContent.find((b) => b.type === "tool-result");
1295
+ if (toolResultBlock !== void 0) {
1296
+ if (toolResultBlock.isError === true) return {
1297
+ ok: false,
1298
+ code: "pre_engagement_incomplete",
1299
+ message: "The Pre-engagement questionnaire result returned an error. Re-ask all six questions together."
1300
+ };
1301
+ const innerText = (toolResultBlock.content ?? []).find((b) => b.type === "text");
1302
+ if (innerText === void 0 || innerText.text === void 0) return {
1303
+ ok: false,
1304
+ code: "pre_engagement_incomplete",
1305
+ message: "The Pre-engagement questionnaire result is empty (tool-result block has no text content). Re-ask all six questions together."
1306
+ };
1307
+ const parsed = JSON.parse(innerText.text);
1308
+ answers = Array.isArray(parsed.answers) ? parsed.answers : [];
1309
+ } else {
1310
+ const textBlock = rawContent.find((b) => b.type === "text");
1311
+ if (textBlock !== void 0 && textBlock.text !== void 0) {
1312
+ const parsed = JSON.parse(textBlock.text);
1313
+ answers = Array.isArray(parsed.answers) ? parsed.answers : [];
1314
+ } else return {
1315
+ ok: false,
1316
+ code: "pre_engagement_incomplete",
1317
+ message: `The Pre-engagement questionnaire result is in an unrecognized format. Found content types: ${JSON.stringify(rawContent.map((b) => b.type))}. Re-ask all six questions together.`
1318
+ };
1319
+ }
1320
+ } catch {
1321
+ return {
1322
+ ok: false,
1323
+ code: "pre_engagement_incomplete",
1324
+ message: "The Pre-engagement questionnaire result is malformed. Re-ask all six questions together."
1325
+ };
1326
+ }
1327
+ const answersById = parseAnswers(answers);
1328
+ const unanswered = PRE_ENGAGEMENT_QUESTION_IDS.filter((id) => !answersById.has(id));
1329
+ if (unanswered.length > 0) return {
1330
+ ok: false,
1331
+ code: "pre_engagement_incomplete",
1332
+ message: `Pre-engagement questions not answered: ${unanswered.join(", ")}. Re-ask all six questions together.`
1333
+ };
1334
+ if (!isPreEngagementConfirmed(answersById)) return {
1335
+ ok: false,
1336
+ code: "pre_engagement_not_confirmed",
1337
+ message: "Not all Pre-engagement questions were confirmed. The user must confirm all six questions before starting."
1338
+ };
1339
+ if (!isAnswerAccepted("final_confirm", answersById.get("final_confirm"))) return {
1340
+ ok: false,
1341
+ code: "pre_engagement_not_confirmed",
1342
+ message: "The final confirmation was not accepted. The user must select \"确认并开始\" to proceed."
1343
+ };
1344
+ const targetAnswer = answersById.get("target_confirm");
1345
+ if (targetAnswer !== void 0) {
1346
+ const selected = (targetAnswer.selected ?? [])[0];
1347
+ if (selected !== void 0 && selected !== "目标不正确") {}
1348
+ }
1349
+ const proofAnswers = Object.fromEntries(PRE_ENGAGEMENT_QUESTION_IDS.map((id) => [id, answersById.get(id)]));
1350
+ return {
1351
+ ok: true,
1352
+ invocationCallId: bestCall.callId,
1353
+ answers: proofAnswers
1354
+ };
1355
+ }
1356
+ /**
1357
+ * Require a valid pre-engagement proof. Throws with a descriptive error
1358
+ * message on failure.
1359
+ */
1360
+ async function requirePreEngagementProof(sessionQuery, sessionId, startArgs) {
1361
+ const result = await validatePreEngagementBatch(sessionQuery, sessionId, startArgs);
1362
+ if (!result.ok) throw new Error(`[${result.code}] ${result.message}`);
1363
+ return result;
1364
+ }
1365
+ /**
1366
+ * Check whether an ask_user_question call is the existing_target_action
1367
+ * selector ("Continue/Restart") rather than the Pre-engagement batch.
1368
+ */
1369
+ function isExistingTargetActionCall(data) {
1370
+ try {
1371
+ const parsed = JSON.parse(data.arguments);
1372
+ const questions = Array.isArray(parsed.questions) ? parsed.questions : [];
1373
+ return questions.length === 1 && typeof questions[0]?.id === "string" && questions[0].id === "existing_target_action";
1374
+ } catch {
1375
+ return false;
1376
+ }
1377
+ }
1378
+ /**
1379
+ * Find the tool/result event matching a given tool/call by callId.
1380
+ * Checks both `data.message.source.callId` (standard) and `data.callId` (fallback).
1381
+ */
1382
+ function findMatchingResult(events, callId) {
1383
+ for (const event of events) {
1384
+ if (event.type !== "tool/result") continue;
1385
+ const data = event.data;
1386
+ if (data === void 0) continue;
1387
+ if (data.message?.source?.callId === callId) return data;
1388
+ if (data.callId === callId) return data;
1389
+ }
1390
+ }
1391
+ //#endregion
1058
1392
  //#region src/tools.ts
1059
1393
  const ROOT_TOOL_SCHEMAS = {
1394
+ pentester_start: {
1395
+ type: "object",
1396
+ additionalProperties: false,
1397
+ required: [
1398
+ "target",
1399
+ "scope",
1400
+ "rules_of_engagement",
1401
+ "language",
1402
+ "summary",
1403
+ "mode"
1404
+ ],
1405
+ properties: {
1406
+ target: {
1407
+ type: "string",
1408
+ description: "Primary target (hostname, IP, URL)."
1409
+ },
1410
+ scope: {
1411
+ type: "string",
1412
+ description: "Authorized scope description."
1413
+ },
1414
+ rules_of_engagement: {
1415
+ type: "string",
1416
+ description: "Rules of Engagement text."
1417
+ },
1418
+ language: {
1419
+ type: "string",
1420
+ description: "Output language (e.g. zh-CN, en)."
1421
+ },
1422
+ summary: {
1423
+ type: "string",
1424
+ description: "Pre-engagement confirmation summary."
1425
+ },
1426
+ mode: {
1427
+ type: "string",
1428
+ enum: ["new", "restart"],
1429
+ description: "new for fresh target, restart for existing target."
1430
+ },
1431
+ expected_target_id: {
1432
+ type: "string",
1433
+ description: "For restart: expected target id to prevent race."
1434
+ }
1435
+ }
1436
+ },
1060
1437
  pentester_delegate: {
1061
1438
  type: "object",
1062
1439
  additionalProperties: false,
@@ -1076,7 +1453,7 @@ const ROOT_TOOL_SCHEMAS = {
1076
1453
  properties: {
1077
1454
  agent: {
1078
1455
  type: "string",
1079
- description: "AgentProfile id attached to the current Stage (see Available AgentProfiles in your run state)."
1456
+ description: "AgentProfile id attached to the current Stage."
1080
1457
  },
1081
1458
  objective: {
1082
1459
  type: "string",
@@ -1084,7 +1461,7 @@ const ROOT_TOOL_SCHEMAS = {
1084
1461
  },
1085
1462
  task_prompt: {
1086
1463
  type: "string",
1087
- description: "Full task context for the worker: known facts, gaps to close, references to existing deliverables."
1464
+ description: "Full task context for the worker."
1088
1465
  }
1089
1466
  }
1090
1467
  }
@@ -1103,28 +1480,10 @@ const ROOT_TOOL_SCHEMAS = {
1103
1480
  type: "object",
1104
1481
  additionalProperties: false,
1105
1482
  required: ["summary"],
1106
- properties: {
1107
- summary: {
1108
- type: "string",
1109
- description: "Stage handoff summary (what was done, confirmed, key results, next-stage focus). The host writes it to stages/<stage>/summary.md verbatim and never parses it."
1110
- },
1111
- run: {
1112
- type: "object",
1113
- additionalProperties: false,
1114
- description: "Only for the FIRST advance of a new run (bootstrap): records the user-confirmed targets/RoE and output language. The host creates the PentestRun and completes pre-engagement.",
1115
- properties: {
1116
- targets: {
1117
- type: "array",
1118
- items: { type: "string" }
1119
- },
1120
- rules_of_engagement: { type: "string" },
1121
- language: {
1122
- type: "string",
1123
- description: "Output language for host-generated files (e.g. zh-CN, en)."
1124
- }
1125
- }
1126
- }
1127
- }
1483
+ properties: { summary: {
1484
+ type: "string",
1485
+ description: "Stage handoff summary."
1486
+ } }
1128
1487
  },
1129
1488
  pentester_rollback_stage: {
1130
1489
  type: "object",
@@ -1133,7 +1492,7 @@ const ROOT_TOOL_SCHEMAS = {
1133
1492
  properties: {
1134
1493
  stage: {
1135
1494
  type: "string",
1136
- description: "Domain StageId to roll back to (e.g. intelligence-gathering); its completed checkpoint becomes the new timeline root."
1495
+ description: "Domain StageId to roll back to."
1137
1496
  },
1138
1497
  reason: {
1139
1498
  type: "string",
@@ -1171,6 +1530,13 @@ function registerRootTools(tools, deps) {
1171
1530
  text: JSON.stringify(value)
1172
1531
  }]
1173
1532
  };
1533
+ tools.register({
1534
+ name: "pentester_start",
1535
+ description: ROOT_TOOL_DESCRIPTIONS.pentester_start,
1536
+ parameters: ROOT_TOOL_SCHEMAS.pentester_start,
1537
+ output,
1538
+ execute: (args, exec) => startRun(deps, args, exec)
1539
+ });
1174
1540
  tools.register({
1175
1541
  name: "pentester_delegate",
1176
1542
  description: ROOT_TOOL_DESCRIPTIONS.pentester_delegate,
@@ -1200,6 +1566,7 @@ function registerRootTools(tools, deps) {
1200
1566
  execute: (args, exec) => rollback(deps, args, exec)
1201
1567
  });
1202
1568
  return [
1569
+ "pentester_start",
1203
1570
  "pentester_delegate",
1204
1571
  "pentester_cancel_delegation",
1205
1572
  "pentester_advance_stage",
@@ -1207,44 +1574,231 @@ function registerRootTools(tools, deps) {
1207
1574
  ];
1208
1575
  }
1209
1576
  const ROOT_TOOL_DESCRIPTIONS = {
1210
- pentester_delegate: "Creates a new long-lived continuable PTES worker. IMPORTANT: Before calling this tool, first tell the user which worker you are creating, what it will do, and why the current PTES stage needs it. Do not call this tool silently. Each call creates a NEW durable child Session. For follow-up work with an existing child, do not call pentester_delegate again; use send_message through the continuation interface.",
1211
- pentester_cancel_delegation: "Close a PTES Delegation. Interrupts the live child turn if active, then marks the Delegation as closed. The child Session transcript is preserved for future reference. Use send_message/interrupt_agent for follow-up or interruption without closing.",
1212
- pentester_advance_stage: "Advance to the next PTES stage after you judge the exit criteria satisfied. The host only verifies linear order and that no active/starting delegations remain. On the FIRST call (no PentestRun yet) pass run (targets / rules_of_engagement / language) + summary: the host bootstraps the workspace, run.json, target files, git and completes pre-engagement.",
1213
- pentester_rollback_stage: "Roll the timeline back to a completed stage checkpoint and start a rework branch from the next stage. Closes active delegations, saves a WIP backup branch, never rewrites history."
1577
+ pentester_start: "Initialize or restart a PentestRun for a target. Must be called before any other pentester tool. Mode \"new\" creates a fresh target; mode \"restart\" resets an existing target (old data destroyed).",
1578
+ pentester_delegate: "Creates a new long-lived continuable PTES worker. IMPORTANT: explain to the user which worker you are creating before calling this tool.",
1579
+ pentester_cancel_delegation: "Close a PTES Delegation. Interrupts the live child turn if active, then marks the Delegation as closed.",
1580
+ pentester_advance_stage: "Advance to the next PTES stage. The host verifies linear order and that no active/starting delegations remain.",
1581
+ pentester_rollback_stage: "Roll the timeline back to a completed stage checkpoint and start a rework branch."
1214
1582
  };
1215
- async function delegate(deps, args, exec) {
1583
+ async function startRun(deps, args, exec) {
1216
1584
  const caller = requireRootCaller(exec);
1217
1585
  const projectDir = caller.cwd;
1218
- const parent = agentOf(exec);
1219
- if (parent === void 0) throw new Error("pentester_delegate requires a calling agent (exec.agent was undefined)");
1220
- const signal = signalOf(exec);
1221
- const run = await requireRun(caller, deps);
1222
- const assignments = parseAssignments(args.assignments);
1223
- const created = await deps.delegations.delegate({
1224
- projectDir,
1225
- run,
1226
- profiles: deps.profiles,
1586
+ const sessionId = caller.sessionId;
1587
+ const target = typeof args.target === "string" ? args.target.trim() : "";
1588
+ const scope = typeof args.scope === "string" ? args.scope : "";
1589
+ const roe = typeof args.rules_of_engagement === "string" ? args.rules_of_engagement : "";
1590
+ const language = typeof args.language === "string" ? args.language : "zh-CN";
1591
+ const summary = typeof args.summary === "string" ? args.summary : "";
1592
+ const mode = args.mode === "restart" ? "restart" : "new";
1593
+ const expectedTargetId = typeof args.expected_target_id === "string" ? args.expected_target_id : void 0;
1594
+ if (target === "") throw new Error("pentester_start requires a non-empty target");
1595
+ if (scope === "") throw new Error("pentester_start requires a non-empty scope");
1596
+ if (summary.trim() === "") throw new Error("pentester_start requires a non-empty summary");
1597
+ await requirePreEngagementProof(deps.sessionQuery, sessionId, {
1598
+ target,
1599
+ scope,
1600
+ language
1601
+ });
1602
+ const canonical = canonicalizeTarget(target);
1603
+ if (canonical === "") throw new Error(`invalid target: "${target}"`);
1604
+ const registry = await loadTargetRegistry(projectDir);
1605
+ const existingBinding = resolveTargetByRootSession(registry, sessionId);
1606
+ if (existingBinding !== void 0 && existingBinding.canonicalTarget !== canonical) throw new Error(`This session is already bound to target "${existingBinding.target}". Create a new Pentester Session for a different target.`);
1607
+ const existingTarget = resolveTargetByCanonicalTarget(registry, canonical);
1608
+ if (mode === "new") {
1609
+ if (existingTarget !== void 0) throw new Error(`Target "${canonical}" already exists. Use mode "restart" to restart it, or open the existing Root Session for this target.`);
1610
+ return bootstrapNewTarget(deps, projectDir, caller, registry, {
1611
+ target,
1612
+ canonical,
1613
+ scope,
1614
+ roe,
1615
+ language,
1616
+ summary
1617
+ });
1618
+ }
1619
+ if (existingTarget === void 0) throw new Error(`Target "${canonical}" does not exist. Use mode "new" to create it.`);
1620
+ if (expectedTargetId !== void 0 && expectedTargetId !== existingTarget.id) throw new Error(`Target id mismatch: expected ${expectedTargetId}, got ${existingTarget.id}. The target may have been modified concurrently.`);
1621
+ return restartTarget(deps, projectDir, caller, registry, existingTarget, {
1622
+ target,
1623
+ canonical,
1624
+ scope,
1625
+ roe,
1626
+ language,
1627
+ summary
1628
+ });
1629
+ }
1630
+ async function bootstrapNewTarget(deps, projectDir, caller, registry, opts) {
1631
+ const id = makeTargetId(opts.canonical);
1632
+ const dirName = makeTargetDirName(opts.target, id);
1633
+ await updateTargetRegistry(projectDir, (r) => {
1634
+ r.targets.push({
1635
+ id,
1636
+ target: opts.target,
1637
+ canonicalTarget: opts.canonical,
1638
+ dirName,
1639
+ rootSessionId: caller.sessionId,
1640
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1641
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1642
+ });
1643
+ });
1644
+ const ctx = buildTargetContext(projectDir, {
1645
+ id,
1646
+ target: opts.target,
1647
+ canonicalTarget: opts.canonical,
1648
+ dirName,
1227
1649
  rootSessionId: caller.sessionId,
1228
- parent,
1229
- signal
1230
- }, assignments);
1231
- return {
1232
- ...rootState(run, deps.profiles),
1233
- created: created.map((d) => ({
1234
- id: d.id,
1235
- agent: d.agentId,
1236
- status: d.status,
1237
- ...d.sessionId === void 0 ? {} : { childSessionId: d.sessionId }
1238
- }))
1650
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1651
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1652
+ });
1653
+ await initTargetWorkspace(ctx, {
1654
+ target: opts.target,
1655
+ language: opts.language,
1656
+ scope: opts.scope,
1657
+ roe: opts.roe
1658
+ });
1659
+ await writePreEngagementInput(ctx, {
1660
+ target: opts.target,
1661
+ scope: opts.scope,
1662
+ roe: opts.roe,
1663
+ language: opts.language
1664
+ });
1665
+ const run = {
1666
+ schemaVersion: 2,
1667
+ id: crypto.randomUUID(),
1668
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1669
+ target: opts.canonical,
1670
+ scope: opts.scope,
1671
+ rulesOfEngagement: opts.roe,
1672
+ language: opts.language,
1673
+ status: "active",
1674
+ currentStage: "pre-engagement",
1675
+ stageHistory: [{
1676
+ stage: "pre-engagement",
1677
+ enteredAt: (/* @__PURE__ */ new Date()).toISOString()
1678
+ }],
1679
+ delegations: []
1680
+ };
1681
+ await saveRun(ctx, run);
1682
+ await deps.git.init(ctx, opts.target);
1683
+ await writeStageSummary(ctx, "pre-engagement", opts.summary);
1684
+ advanceStage(run, opts.summary);
1685
+ await saveRun(ctx, run);
1686
+ await deps.git.checkpointStage(ctx, "pre-engagement");
1687
+ await appendEvent(ctx, {
1688
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1689
+ type: "stage.completed",
1690
+ data: {
1691
+ stage: "pre-engagement",
1692
+ next: run.currentStage
1693
+ }
1694
+ });
1695
+ await deps.pushWorkspace(ctx, run).catch(() => void 0);
1696
+ return {
1697
+ mode: "new",
1698
+ targetId: id,
1699
+ target: opts.canonical,
1700
+ ...rootState(run, deps.profiles)
1239
1701
  };
1240
1702
  }
1241
- async function cancel(deps, args, exec) {
1703
+ async function restartTarget(deps, projectDir, caller, registry, existing, opts) {
1704
+ const ctx = buildTargetContext(projectDir, existing);
1705
+ const oldRun = await resolvePentestRun(ctx);
1706
+ if (oldRun !== null) await deps.pushWorkspace(ctx, oldRun).catch(() => void 0);
1707
+ const { rm } = await import("node:fs/promises");
1708
+ await rm(ctx.targetRoot, {
1709
+ recursive: true,
1710
+ force: true
1711
+ }).catch(() => void 0);
1712
+ await initTargetWorkspace(ctx, {
1713
+ target: opts.target,
1714
+ language: opts.language,
1715
+ scope: opts.scope,
1716
+ roe: opts.roe
1717
+ });
1718
+ await writePreEngagementInput(ctx, {
1719
+ target: opts.target,
1720
+ scope: opts.scope,
1721
+ roe: opts.roe,
1722
+ language: opts.language
1723
+ });
1724
+ const run = {
1725
+ schemaVersion: 2,
1726
+ id: crypto.randomUUID(),
1727
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1728
+ target: opts.canonical,
1729
+ scope: opts.scope,
1730
+ rulesOfEngagement: opts.roe,
1731
+ language: opts.language,
1732
+ status: "active",
1733
+ currentStage: "pre-engagement",
1734
+ stageHistory: [{
1735
+ stage: "pre-engagement",
1736
+ enteredAt: (/* @__PURE__ */ new Date()).toISOString()
1737
+ }],
1738
+ delegations: []
1739
+ };
1740
+ await saveRun(ctx, run);
1741
+ await deps.git.init(ctx, opts.target);
1742
+ await updateTargetRegistry(projectDir, (r) => {
1743
+ const rec = r.targets.find((t) => t.id === existing.id);
1744
+ if (rec !== void 0) {
1745
+ rec.rootSessionId = caller.sessionId;
1746
+ rec.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1747
+ }
1748
+ });
1749
+ await writeStageSummary(ctx, "pre-engagement", opts.summary);
1750
+ advanceStage(run, opts.summary);
1751
+ await saveRun(ctx, run);
1752
+ await deps.git.checkpointStage(ctx, "pre-engagement");
1753
+ await appendEvent(ctx, {
1754
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1755
+ type: "stage.completed",
1756
+ data: {
1757
+ stage: "pre-engagement",
1758
+ next: run.currentStage
1759
+ }
1760
+ });
1761
+ await deps.pushWorkspace(ctx, run).catch(() => void 0);
1762
+ return {
1763
+ mode: "restart",
1764
+ targetId: existing.id,
1765
+ target: opts.canonical,
1766
+ ...rootState(run, deps.profiles)
1767
+ };
1768
+ }
1769
+ async function delegate(deps, args, exec) {
1242
1770
  const caller = requireRootCaller(exec);
1243
- const run = await requireRun(caller, deps);
1771
+ const projectDir = caller.cwd;
1772
+ const parent = agentOf(exec);
1773
+ if (parent === void 0) throw new Error("pentester_delegate requires a calling agent (exec.agent was undefined)");
1774
+ const signal = signalOf(exec);
1775
+ const { ctx, run } = await requireTargetRun(caller);
1776
+ const assignments = parseAssignments(args.assignments);
1777
+ const created = await deps.delegations.delegate({
1778
+ projectDir,
1779
+ targetContext: ctx,
1780
+ run,
1781
+ profiles: deps.profiles,
1782
+ rootSessionId: caller.sessionId,
1783
+ parent,
1784
+ signal
1785
+ }, assignments);
1786
+ return {
1787
+ ...rootState(run, deps.profiles),
1788
+ created: created.map((d) => ({
1789
+ id: d.id,
1790
+ agent: d.agentId,
1791
+ status: d.status,
1792
+ ...d.sessionId === void 0 ? {} : { childSessionId: d.sessionId }
1793
+ }))
1794
+ };
1795
+ }
1796
+ async function cancel(deps, args, exec) {
1797
+ const { ctx, run } = await requireTargetRun(requireRootCaller(exec));
1244
1798
  const delegationId = typeof args.delegation_id === "string" ? args.delegation_id : "";
1245
1799
  const reason = typeof args.reason === "string" ? args.reason : void 0;
1246
1800
  const cancelled = await deps.delegations.cancel({
1247
- projectDir: caller.cwd,
1801
+ targetContext: ctx,
1248
1802
  run
1249
1803
  }, delegationId, reason);
1250
1804
  return {
@@ -1256,27 +1810,25 @@ async function cancel(deps, args, exec) {
1256
1810
  };
1257
1811
  }
1258
1812
  async function advance(deps, args, exec) {
1259
- const projectDir = requireRootCaller(exec).cwd;
1813
+ const caller = requireRootCaller(exec);
1260
1814
  const summary = typeof args.summary === "string" ? args.summary : "";
1261
1815
  if (summary.trim() === "") throw new Error("pentester_advance_stage requires a non-empty summary (stage handoff text)");
1262
- let run = await resolvePentestRun(projectDir);
1263
- if (run === null) run = await bootstrapRun(deps, projectDir, parseRunInfo(args.run));
1264
- else await initWorkspace(projectDir, { language: run.language });
1816
+ const { ctx, run } = await requireTargetRun(caller);
1265
1817
  if (run.status === "completed" || run.currentStage === null) throw new Error("PentestRun is already completed; no stage to advance");
1266
1818
  const previous = run.currentStage;
1267
1819
  if (deps.delegations.stageHasPendingWork(run, previous)) throw new Error(`stage ${previous} still has active or starting delegations; close them with pentester_cancel_delegation before advancing`);
1268
- await deps.syncWorkspace(projectDir, run).catch(() => void 0);
1820
+ await deps.syncWorkspace(ctx, run).catch(() => void 0);
1269
1821
  const stageDelegations = run.delegations.filter((d) => d.stageId === previous);
1270
- const delegationDirs = stageDelegations.map((d) => d.workspaceDir);
1822
+ const delegationDirs = stageDelegations.map((d) => delegationHostDir(ctx, previous, d.id));
1271
1823
  const delegationIds = stageDelegations.map((d) => d.id);
1272
- await promoteAssets(projectDir, previous, delegationDirs, delegationIds);
1273
- await promoteFindings(projectDir, previous, delegationDirs, delegationIds);
1274
- for (const delegation of stageDelegations) await promoteDeliverables(projectDir, delegation.workspaceDir);
1275
- await writeStageSummary(projectDir, previous, summary);
1824
+ await promoteAssets(ctx, previous, delegationDirs, delegationIds);
1825
+ await promoteFindings(ctx, previous, delegationDirs, delegationIds);
1826
+ for (const delegation of stageDelegations) await promoteDeliverables(ctx, delegationHostDir(ctx, previous, delegation.id));
1827
+ await writeStageSummary(ctx, previous, summary);
1276
1828
  const target = advanceStage(run, summary);
1277
- await saveRun(projectDir, run);
1278
- const tag = await deps.git.checkpointStage(projectDir, previous);
1279
- await appendEvent(projectDir, {
1829
+ await saveRun(ctx, run);
1830
+ const tag = await deps.git.checkpointStage(ctx, previous);
1831
+ await appendEvent(ctx, {
1280
1832
  ts: (/* @__PURE__ */ new Date()).toISOString(),
1281
1833
  type: "stage.completed",
1282
1834
  data: {
@@ -1285,7 +1837,7 @@ async function advance(deps, args, exec) {
1285
1837
  next: target
1286
1838
  }
1287
1839
  });
1288
- await deps.pushWorkspace(projectDir, run).catch(() => void 0);
1840
+ await deps.pushWorkspace(ctx, run).catch(() => void 0);
1289
1841
  return {
1290
1842
  previous,
1291
1843
  ...target === null ? { completed: true } : { next: target },
@@ -1294,9 +1846,7 @@ async function advance(deps, args, exec) {
1294
1846
  };
1295
1847
  }
1296
1848
  async function rollback(deps, args, exec) {
1297
- const caller = requireRootCaller(exec);
1298
- const projectDir = caller.cwd;
1299
- const run = await requireRun(caller, deps);
1849
+ const { ctx, run } = await requireTargetRun(requireRootCaller(exec));
1300
1850
  const stageArg = typeof args.stage === "string" ? args.stage : "";
1301
1851
  const reason = typeof args.reason === "string" ? args.reason : "";
1302
1852
  if (!isStageId(stageArg)) throw new Error(`pentester_rollback_stage: unknown stage "${stageArg}"`);
@@ -1305,17 +1855,17 @@ async function rollback(deps, args, exec) {
1305
1855
  if (!run.stageHistory.some((entry) => entry.stage === stageArg)) throw new Error(`pentester_rollback_stage: stage ${stageArg} has no completed checkpoint on this timeline`);
1306
1856
  const active = run.delegations.filter((d) => d.status === "active" || d.status === "starting");
1307
1857
  for (const delegation of active) await deps.delegations.cancel({
1308
- projectDir,
1858
+ targetContext: ctx,
1309
1859
  run
1310
1860
  }, delegation.id, "rolled back by Root");
1311
1861
  let backup;
1312
- if (await deps.git.hasChanges(projectDir)) backup = await deps.git.createBackupBranch(projectDir, stageArg);
1313
- const checkpoint = await deps.git.findStageCheckpoint(projectDir, stageArg);
1862
+ if (await deps.git.hasChanges(ctx)) backup = await deps.git.createBackupBranch(ctx, stageArg);
1863
+ const checkpoint = await deps.git.findStageCheckpoint(ctx, stageArg);
1314
1864
  if (checkpoint === void 0) throw new Error(`pentester_rollback_stage: no checkpoint commit found for stage ${stageArg}`);
1315
- const branch = await deps.git.createReworkBranch(projectDir, checkpoint, stageDir(next));
1316
- const authoritativeRun = await resolvePentestRun(projectDir);
1865
+ const branch = await deps.git.createReworkBranch(ctx, checkpoint, stageDir(next));
1866
+ const authoritativeRun = await resolvePentestRun(ctx);
1317
1867
  if (authoritativeRun === null) throw new Error("pentester_rollback_stage: run.json disappeared after checkout — this is a bug");
1318
- await appendEvent(projectDir, {
1868
+ await appendEvent(ctx, {
1319
1869
  ts: (/* @__PURE__ */ new Date()).toISOString(),
1320
1870
  type: "stage.rollback",
1321
1871
  data: {
@@ -1326,7 +1876,7 @@ async function rollback(deps, args, exec) {
1326
1876
  reason
1327
1877
  }
1328
1878
  });
1329
- await deps.pushWorkspace(projectDir, authoritativeRun).catch(() => void 0);
1879
+ await deps.pushWorkspace(ctx, authoritativeRun).catch(() => void 0);
1330
1880
  return {
1331
1881
  reopenedStage: stageArg,
1332
1882
  currentStage: stageArg,
@@ -1336,56 +1886,17 @@ async function rollback(deps, args, exec) {
1336
1886
  ...rootState(authoritativeRun, deps.profiles)
1337
1887
  };
1338
1888
  }
1339
- const NO_RUN_MESSAGE = "No PentestRun exists. Complete pre-engagement and call pentester_advance_stage first — pass run (targets / rules_of_engagement / language) + summary to initialize the run.";
1340
- async function requireRun(caller, _deps) {
1889
+ const NO_RUN_MESSAGE = "No PentestRun exists. Complete pre-engagement and call pentester_start first.";
1890
+ async function requireTargetRun(caller) {
1341
1891
  const projectDir = caller.cwd;
1342
- await initWorkspace(projectDir, { language: void 0 });
1343
- const run = await resolvePentestRun(projectDir);
1892
+ const ctx = await resolveSessionTargetContext(projectDir, caller.sessionId);
1893
+ if (ctx === void 0) throw new Error("This session is not bound to any target. Start a pentest with pentester_start first.");
1894
+ const run = await resolvePentestRun(ctx);
1344
1895
  if (run === null) throw new Error(NO_RUN_MESSAGE);
1345
- return run;
1346
- }
1347
- async function bootstrapRun(deps, projectDir, runInfo) {
1348
- if (runInfo === void 0 || runInfo.targets === void 0 || runInfo.targets.length === 0) throw new Error(NO_RUN_MESSAGE);
1349
- const primary = runInfo.targets[0];
1350
- await initWorkspace(projectDir, {
1351
- target: primary,
1352
- language: runInfo.language,
1353
- scope: renderScope(runInfo.targets),
1354
- roe: runInfo.rulesOfEngagement
1355
- });
1356
- await writePreEngagementInput(projectDir, {
1357
- targets: runInfo.targets,
1358
- scope: renderScope(runInfo.targets),
1359
- roe: runInfo.rulesOfEngagement ?? "Standard RoE",
1360
- language: runInfo.language ?? "zh-CN"
1361
- });
1362
- const created = {
1363
- schemaVersion: 1,
1364
- id: crypto.randomUUID(),
1365
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1366
- targets: [...runInfo.targets],
1367
- ...runInfo.rulesOfEngagement === void 0 ? {} : { rulesOfEngagement: runInfo.rulesOfEngagement },
1368
- ...runInfo.language === void 0 ? {} : { language: runInfo.language },
1369
- status: "active",
1370
- currentStage: "pre-engagement",
1371
- stageHistory: [{
1372
- stage: "pre-engagement",
1373
- enteredAt: (/* @__PURE__ */ new Date()).toISOString()
1374
- }],
1375
- delegations: []
1896
+ return {
1897
+ ctx,
1898
+ run
1376
1899
  };
1377
- await saveRun(projectDir, created);
1378
- await deps.git.init(projectDir, primary);
1379
- return created;
1380
- }
1381
- function renderScope(targets) {
1382
- return [
1383
- "# Scope",
1384
- "",
1385
- `- Primary Target: ${targets[0] ?? ""}`,
1386
- ...targets.slice(1).map((target) => `- ${target}`),
1387
- ""
1388
- ].join("\n");
1389
1900
  }
1390
1901
  function parseAssignments(value) {
1391
1902
  if (!Array.isArray(value) || value.length === 0) throw new Error("assignments must be a non-empty array");
@@ -1400,19 +1911,6 @@ function parseAssignments(value) {
1400
1911
  };
1401
1912
  });
1402
1913
  }
1403
- function parseRunInfo(value) {
1404
- if (value === void 0) return void 0;
1405
- if (value === null || typeof value !== "object") throw new Error("invalid run info");
1406
- const record = value;
1407
- const targets = Array.isArray(record.targets) ? record.targets.map(String) : void 0;
1408
- const roe = typeof record.rules_of_engagement === "string" ? record.rules_of_engagement : void 0;
1409
- const language = typeof record.language === "string" && record.language.length > 0 ? record.language : void 0;
1410
- return {
1411
- ...targets === void 0 ? {} : { targets },
1412
- ...roe === void 0 ? {} : { rulesOfEngagement: roe },
1413
- ...language === void 0 ? {} : { language }
1414
- };
1415
- }
1416
1914
  function rootState(run, profiles) {
1417
1915
  const stage = run.currentStage === null ? void 0 : stageDefinition(run.currentStage);
1418
1916
  const allowedProfiles = (stage?.agentIds ?? []).map((id) => profiles.get(id)).filter((profile) => profile !== void 0).map((profile) => ({
@@ -1423,7 +1921,8 @@ function rootState(run, profiles) {
1423
1921
  run: {
1424
1922
  currentStage: run.currentStage,
1425
1923
  status: run.status ?? "active",
1426
- targets: run.targets
1924
+ target: run.target,
1925
+ scope: run.scope
1427
1926
  },
1428
1927
  stage: stage === void 0 ? null : {
1429
1928
  id: stage.id,
@@ -1709,9 +2208,28 @@ const CAP_ADD_ALLOWLIST = [
1709
2208
  "NET_RAW",
1710
2209
  "NET_BIND_SERVICE"
1711
2210
  ];
1712
- /** Toolbox container name: dsh-pentester-xxxxxxxx (8 lowercase hex). */
1713
- function createToolboxContainerName() {
1714
- return `dsh-pentester-${randomBytes(4).toString("hex")}`;
2211
+ /** Sanitize a target string into a valid Docker container name segment.
2212
+ *
2213
+ * - trim, lowercase
2214
+ * - strip http://, https://
2215
+ * - keep [a-z0-9._-], replace everything else with -
2216
+ * - collapse consecutive dashes
2217
+ * - strip leading/trailing non-alphanumeric
2218
+ * - cap length
2219
+ */
2220
+ function sanitizeDockerTargetName(target) {
2221
+ let name = target.trim().toLowerCase();
2222
+ name = name.replace(/^https?:\/\//, "");
2223
+ name = name.replace(/[^a-z0-9._-]/g, "-");
2224
+ name = name.replace(/-{2,}/g, "-");
2225
+ name = name.replace(/^[^a-z0-9]+/, "").replace(/[^a-z0-9]+$/, "");
2226
+ if (name.length > 114) name = name.slice(0, 114);
2227
+ if (name.length === 0) name = "pentest";
2228
+ return name;
2229
+ }
2230
+ /** Generate a human-readable Docker container name for a target. */
2231
+ function containerNameForTarget(target) {
2232
+ return `dsh-pentester-${sanitizeDockerTargetName(target)}`;
1715
2233
  }
1716
2234
  /** 固定注入 Toolbox 容器的代理环境变量(容器内部代理服务)。 */
1717
2235
  const FIXED_CONTAINER_ENV = ["HTTP_PROXY=http://127.0.0.1:8080", "HTTPS_PROXY=http://127.0.0.1:8080"];
@@ -1740,11 +2258,11 @@ function safeEnv(env) {
1740
2258
  }
1741
2259
  return safe;
1742
2260
  }
1743
- function volumeNameFor(projectDir) {
1744
- return `dsh-pentester-ws-${createHash("sha1").update(projectDir).digest("hex").slice(0, 12)}`;
2261
+ function volumeNameFor(targetId, runId) {
2262
+ return `dsh-pentester-${createHash("sha1").update(`${targetId}:${runId}`).digest("hex").slice(0, 12)}`;
1745
2263
  }
1746
- function localMirrorDir(projectDir) {
1747
- return workspaceDir(projectDir);
2264
+ function localMirrorDir(targetRoot) {
2265
+ return targetRoot;
1748
2266
  }
1749
2267
  var DockerRuntime = class {
1750
2268
  engine;
@@ -1797,7 +2315,7 @@ var DockerRuntime = class {
1797
2315
  * reused while running.
1798
2316
  */
1799
2317
  async ensureRunContainer(options) {
1800
- const volume = this.remote ? await this.ensureWorkspaceVolume(options.projectDir) : void 0;
2318
+ const volume = this.remote ? await this.ensureWorkspaceVolume(options.targetId, options.runId) : void 0;
1801
2319
  const label = "dsh.pentester.run";
1802
2320
  const existing = await this.engine.listContainers({
1803
2321
  all: true,
@@ -1814,15 +2332,11 @@ var DockerRuntime = class {
1814
2332
  ensure.finally(() => this.ensuring.delete(options.runId));
1815
2333
  }
1816
2334
  const containerId = await ensure;
1817
- if (this.remote && volume?.created === true) await this.pushLocalMirror(containerId, options.projectDir).catch(() => void 0);
2335
+ if (this.remote && volume?.created === true) await this.pushLocalMirror(containerId, options.targetRoot).catch(() => void 0);
1818
2336
  return containerId;
1819
2337
  }
1820
- /**
1821
- * 远程 workspace volume:存在则复用(§24 recovery 优先级),不存在才创建。
1822
- * `created: true` 表示本轮新建 —— 调用方据此决定是否从本地恢复数据。
1823
- */
1824
- async ensureWorkspaceVolume(projectDir) {
1825
- const volume = volumeNameFor(projectDir);
2338
+ async ensureWorkspaceVolume(targetId, runId) {
2339
+ const volume = volumeNameFor(targetId, runId);
1826
2340
  if (this.volumes.has(volume)) return {
1827
2341
  name: volume,
1828
2342
  created: false
@@ -1838,7 +2352,8 @@ var DockerRuntime = class {
1838
2352
  Name: volume,
1839
2353
  Labels: {
1840
2354
  "dsh.pentester.managed": "true",
1841
- "dsh.pentester.project": volume
2355
+ "dsh.pentester.run": runId,
2356
+ "dsh.pentester.target": targetId
1842
2357
  }
1843
2358
  });
1844
2359
  this.volumes.add(created.Name);
@@ -1851,7 +2366,7 @@ var DockerRuntime = class {
1851
2366
  const hostConfig = {
1852
2367
  Mounts: [volume === void 0 ? {
1853
2368
  Type: "bind",
1854
- Source: localMirrorDir(options.projectDir),
2369
+ Source: localMirrorDir(options.targetRoot),
1855
2370
  Target: "/workspace"
1856
2371
  } : {
1857
2372
  Type: "volume",
@@ -1862,14 +2377,15 @@ var DockerRuntime = class {
1862
2377
  AutoRemove: false
1863
2378
  };
1864
2379
  assertSafeHostConfig(hostConfig);
1865
- const containerName = createToolboxContainerName();
2380
+ const containerName = containerNameForTarget(options.target);
1866
2381
  const container = await this.engine.createContainer({
1867
2382
  name: containerName,
1868
2383
  Image: options.toolbox.image,
1869
2384
  Env: [...FIXED_CONTAINER_ENV],
1870
2385
  Labels: {
1871
2386
  [label]: options.runId,
1872
- "dsh.pentester.managed": "true"
2387
+ "dsh.pentester.managed": "true",
2388
+ "dsh.pentester.target": options.targetId
1873
2389
  },
1874
2390
  HostConfig: hostConfig,
1875
2391
  WorkingDir: "/workspace",
@@ -1908,7 +2424,7 @@ var DockerRuntime = class {
1908
2424
  * 本地 bind mount 下 host == container,直接读本地文件系统。
1909
2425
  * @returns result.md 内容(缺失 undefined)与相对文件清单。
1910
2426
  */
1911
- async readDelegationDelivery(runId, projectDir, delegationDir) {
2427
+ async readDelegationDelivery(runId, ctx, delegationDir) {
1912
2428
  if (!this.remote) {
1913
2429
  const { readdir, readFile } = await import("node:fs/promises");
1914
2430
  const { join } = await import("node:path");
@@ -1939,7 +2455,7 @@ var DockerRuntime = class {
1939
2455
  files: files.sort()
1940
2456
  };
1941
2457
  }
1942
- const containerPath = delegationDir.startsWith(projectDir) ? delegationDir.slice(projectDir.length).replace(/\\/g, "/") : "/workspace";
2458
+ const containerPath = delegationDir.startsWith(ctx.targetRoot) ? "/workspace" + delegationDir.slice(ctx.targetRoot.length).replace(/\\/g, "/") : "/workspace";
1943
2459
  const containerId = await this.findRunContainer(runId);
1944
2460
  if (containerId === void 0) return { files: [] };
1945
2461
  const files = ((await this.exec(containerId, { argv: [
@@ -1959,17 +2475,17 @@ var DockerRuntime = class {
1959
2475
  * target/、stage scaffold。排除 .git(Worker 不可改 Git)。
1960
2476
  * 本地 bind mount 下 host == container,无操作直接返回。
1961
2477
  */
1962
- async pushWorkspace(runId, projectDir) {
2478
+ async pushWorkspace(runId, ctx) {
1963
2479
  if (!this.remote) return;
1964
- const key = `${runId}:${projectDir}`;
2480
+ const key = `${runId}:${ctx.targetRoot}`;
1965
2481
  const inFlight = this.pushing.get(key);
1966
2482
  if (inFlight !== void 0) return inFlight;
1967
- const task = this.findRunContainer(runId).then((containerId) => containerId === void 0 ? void 0 : this.pushLocalMirror(containerId, projectDir)).finally(() => this.pushing.delete(key));
2483
+ const task = this.findRunContainer(runId).then((containerId) => containerId === void 0 ? void 0 : this.pushLocalMirror(containerId, ctx.targetRoot)).finally(() => this.pushing.delete(key));
1968
2484
  this.pushing.set(key, task);
1969
2485
  return task;
1970
2486
  }
1971
- async pushLocalMirror(containerId, projectDir) {
1972
- const archive = await packDirectory(localMirrorDir(projectDir), { exclude: (name) => name === ".git" });
2487
+ async pushLocalMirror(containerId, targetRoot) {
2488
+ const archive = await packDirectory(localMirrorDir(targetRoot), { exclude: (name) => name === ".git" });
1973
2489
  await this.engine.getContainer(containerId).putArchive(archive, { path: "/workspace" });
1974
2490
  }
1975
2491
  /**
@@ -1980,12 +2496,12 @@ var DockerRuntime = class {
1980
2496
  * 幽灵文件会被删除。Host 控制态(.git / .dsh-pentester)不参与 pull,
1981
2497
  * 防止 remote stale state 覆盖宿主 run.json(§19)。
1982
2498
  */
1983
- async syncWorkspace(runId, projectDir) {
2499
+ async syncWorkspace(runId, ctx) {
1984
2500
  if (!this.remote) return;
1985
- const key = `${runId}:${projectDir}`;
2501
+ const key = `${runId}:${ctx.targetRoot}`;
1986
2502
  const inFlight = this.syncing.get(key);
1987
2503
  if (inFlight !== void 0) return inFlight;
1988
- const task = this.findRunContainer(runId).then((containerId) => containerId === void 0 ? void 0 : this.pullWorkspace(containerId, projectDir)).finally(() => this.syncing.delete(key));
2504
+ const task = this.findRunContainer(runId).then((containerId) => containerId === void 0 ? void 0 : this.pullWorkspace(containerId, ctx.targetRoot)).finally(() => this.syncing.delete(key));
1989
2505
  this.syncing.set(key, task);
1990
2506
  return task;
1991
2507
  }
@@ -1999,8 +2515,8 @@ var DockerRuntime = class {
1999
2515
  });
2000
2516
  return matches.length > 0 ? String(matches[0].Id) : void 0;
2001
2517
  }
2002
- async pullWorkspace(containerId, projectDir) {
2003
- const localDir = localMirrorDir(projectDir);
2518
+ async pullWorkspace(containerId, targetRoot) {
2519
+ const localDir = localMirrorDir(targetRoot);
2004
2520
  const tarStream = await this.engine.getContainer(containerId).getArchive({ path: "/workspace" });
2005
2521
  const extract = tar.extract();
2006
2522
  const done = new Promise((resolve, reject) => {
@@ -2235,35 +2751,35 @@ function makeContainerExecExecutor(docker, toolboxName = "kali") {
2235
2751
  if (caller === void 0 || !caller.isSubagent) throw new WorkerToolError("pentester_container_exec is reserved for delegated pentester workers");
2236
2752
  const projectDir = caller.cwd;
2237
2753
  if (projectDir === void 0) throw new WorkerToolError("worker session has no trusted workspace cwd");
2238
- const run = await resolvePentestRun(projectDir);
2239
- if (run === null) throw new WorkerToolError("no PentestRun in this project; the Root Agent must start one first");
2754
+ const ctx = await resolveWorkerTargetContext(projectDir, caller.sessionId);
2755
+ if (ctx === void 0) throw new WorkerToolError(`session ${caller.sessionId} is not bound to any delegation; only delegated workers may execute commands`);
2756
+ const run = await resolvePentestRun(ctx);
2757
+ if (run === null) throw new WorkerToolError("no PentestRun for this target; the Root Agent must start one first");
2240
2758
  const delegation = run.delegations.find((candidate) => candidate.sessionId === caller.sessionId);
2241
2759
  if (delegation === void 0) throw new WorkerToolError(`session ${caller.sessionId} is not bound to any delegation; only delegated workers may execute commands`);
2242
2760
  const toolbox = await docker.ensureImage(toolboxName);
2243
2761
  const containerId = await docker.ensureRunContainer({
2244
2762
  runId: run.id,
2763
+ targetId: ctx.targetId,
2764
+ target: ctx.canonicalTarget,
2245
2765
  toolbox,
2246
- projectDir
2766
+ targetRoot: ctx.targetRoot
2247
2767
  });
2248
2768
  const input = {
2249
2769
  argv: Array.isArray(args.argv) ? args.argv.map(String) : [],
2250
- ...typeof args.cwd === "string" ? { cwd: args.cwd } : { cwd: workerContainerWorkDir(projectDir, delegation) },
2770
+ ...typeof args.cwd === "string" ? { cwd: args.cwd } : { cwd: workerContainerWorkDir(ctx, delegation) },
2251
2771
  ...isStringRecord(args.env) ? { env: args.env } : {},
2252
2772
  ...typeof args.timeoutMs === "number" ? { timeoutMs: args.timeoutMs } : {}
2253
2773
  };
2254
2774
  const result = await docker.exec(containerId, input);
2255
- docker.syncWorkspace(run.id, projectDir).catch(() => void 0);
2775
+ docker.syncWorkspace(run.id, ctx).catch(() => void 0);
2256
2776
  return result;
2257
2777
  };
2258
2778
  }
2259
2779
  /** Worker 的容器工作目录:`/workspace/stages/<NN>-<stage>/delegations/D-00N/work`。 */
2260
- function workerContainerWorkDir(projectDir, delegation) {
2261
- return `${delegation.workspaceDir.startsWith(projectDir) ? delegation.workspaceDir.slice(projectDir.length).replace(/\\/g, "/") : delegation.workspaceDir}/work`;
2780
+ function workerContainerWorkDir(ctx, delegation) {
2781
+ return `${delegationContainerDir(delegation.stageId, delegation.id)}/work`;
2262
2782
  }
2263
- /**
2264
- * 在调用者给定的 tools registry 上注册 pentester_container_exec(run-state.mjs
2265
- * 把它注册进 pentester preset 的 standing scope;Root 看不到、Worker 独享)。
2266
- */
2267
2783
  function registerContainerExecTool(tools, docker) {
2268
2784
  const execute = makeContainerExecExecutor(docker);
2269
2785
  tools.register({
@@ -2893,8 +3409,1101 @@ var PentesterDockerTypertService = class extends TypertRemoteService {
2893
3409
  return dispatchDockerRpc(this.deps, parsed.value);
2894
3410
  }
2895
3411
  };
2896
- /** 插件的 Typert manifest(仅 Docker Engine 设置面)。 */
2897
- const TYPERT_MANIFEST = {
3412
+ //#endregion
3413
+ //#region src/ui-view/snapshot.ts
3414
+ /**
3415
+ * ui-view/snapshot.ts — Build read-model views from run.json + filesystem.
3416
+ *
3417
+ * Core snapshot: run, stages, delegations, findings, trace — NO output trees.
3418
+ * Output trees: lazy via listOutput().
3419
+ * File reads: async, bounded concurrency, partial preview.
3420
+ */
3421
+ const STAGE_SHORT = {
3422
+ "pre-engagement": "Pre",
3423
+ "intelligence-gathering": "Intel",
3424
+ "threat-modeling": "Threat",
3425
+ "vulnerability-analysis": "Vuln",
3426
+ "exploitation": "Exploit",
3427
+ "post-exploitation": "Post",
3428
+ "reporting": "Report"
3429
+ };
3430
+ function stageStatus(run, stageId) {
3431
+ const statuses = run.stageStatuses ?? {};
3432
+ if (run.currentStage === stageId) return statuses[stageId] ?? "active";
3433
+ return statuses[stageId] ?? "pending";
3434
+ }
3435
+ function buildRunView(run) {
3436
+ return {
3437
+ id: run.id,
3438
+ status: run.status ?? "active",
3439
+ currentStage: run.currentStage,
3440
+ targets: [run.target],
3441
+ createdAt: run.createdAt
3442
+ };
3443
+ }
3444
+ function buildStageViews(run) {
3445
+ return STAGE_IDS.map((id) => {
3446
+ const def = STAGE_DEFINITIONS.find((d) => d.id === id);
3447
+ const delegations = run.delegations.filter((d) => d.stageId === id);
3448
+ return {
3449
+ id,
3450
+ name: def?.name ?? id,
3451
+ shortName: STAGE_SHORT[id] ?? id.slice(0, 4),
3452
+ status: stageStatus(run, id),
3453
+ delegationCount: delegations.length,
3454
+ activeDelegationCount: delegations.filter((d) => d.status === "active" || d.status === "starting").length
3455
+ };
3456
+ });
3457
+ }
3458
+ function buildDelegationViews(run) {
3459
+ return run.delegations.map((d) => ({
3460
+ id: d.id,
3461
+ stageId: d.stageId,
3462
+ agentId: d.agentId,
3463
+ ...d.sessionId === void 0 ? {} : { sessionId: d.sessionId },
3464
+ objective: d.objective,
3465
+ status: d.status,
3466
+ createdAt: d.createdAt,
3467
+ ...d.finishedAt === void 0 ? {} : { finishedAt: d.finishedAt }
3468
+ }));
3469
+ }
3470
+ async function parseFindingsIndex(ctx) {
3471
+ const indexFile = join(ctx.targetRoot, "findings", "findings.json");
3472
+ if (!existsSync(indexFile)) return [];
3473
+ try {
3474
+ const raw = await readFile(indexFile, "utf8");
3475
+ const entries = JSON.parse(raw);
3476
+ if (!Array.isArray(entries)) return [];
3477
+ return entries.filter((e) => e !== null && typeof e === "object").map((e) => {
3478
+ const detailFile = typeof e.detailFile === "string" ? e.detailFile : "";
3479
+ const id = detailFile.replace(/-findings\.md$/, "");
3480
+ return {
3481
+ id,
3482
+ title: id,
3483
+ severity: typeof e.severity === "string" ? e.severity : "info",
3484
+ sourceStage: typeof e.sourceStage === "string" ? e.sourceStage : "",
3485
+ sourceDelegationId: typeof e.sourceDelegationId === "string" ? e.sourceDelegationId : "",
3486
+ detailFile,
3487
+ updatedAt: typeof e.updatedAt === "string" ? e.updatedAt : ""
3488
+ };
3489
+ });
3490
+ } catch {
3491
+ return [];
3492
+ }
3493
+ }
3494
+ async function parseAssetsCount(ctx) {
3495
+ const assetsFile = join(ctx.targetRoot, "assets", "assets.json");
3496
+ if (!existsSync(assetsFile)) return 0;
3497
+ try {
3498
+ const raw = await readFile(assetsFile, "utf8");
3499
+ const parsed = JSON.parse(raw);
3500
+ return Array.isArray(parsed) ? parsed.length : 0;
3501
+ } catch {
3502
+ return 0;
3503
+ }
3504
+ }
3505
+ function buildTraceModel(run, ctx, findings) {
3506
+ const nodes = [];
3507
+ const edges = [];
3508
+ nodes.push({
3509
+ id: "start",
3510
+ kind: "start",
3511
+ label: "Start",
3512
+ timestamp: run.createdAt
3513
+ });
3514
+ let prevStageId = null;
3515
+ for (const stageId of STAGE_IDS) {
3516
+ const status = stageStatus(run, stageId);
3517
+ const stageNodeId = `stage:${stageId}`;
3518
+ const stageEntry = run.stageHistory.find((h) => h.stage === stageId);
3519
+ nodes.push({
3520
+ id: stageNodeId,
3521
+ kind: "stage",
3522
+ label: STAGE_SHORT[stageId] ?? stageId,
3523
+ status,
3524
+ nav: {
3525
+ tab: "stages",
3526
+ focus: stageId
3527
+ },
3528
+ ...stageEntry?.enteredAt === void 0 ? {} : { timestamp: stageEntry.enteredAt }
3529
+ });
3530
+ if (prevStageId === null) edges.push({
3531
+ id: `edge:start-${stageId}`,
3532
+ source: "start",
3533
+ target: stageNodeId,
3534
+ kind: "transition"
3535
+ });
3536
+ else edges.push({
3537
+ id: `edge:${prevStageId}-${stageId}`,
3538
+ source: `stage:${prevStageId}`,
3539
+ target: stageNodeId,
3540
+ kind: "transition"
3541
+ });
3542
+ const stageDelegations = run.delegations.filter((d) => d.stageId === stageId);
3543
+ for (const d of stageDelegations) {
3544
+ const delNodeId = `delegation:${d.id}`;
3545
+ nodes.push({
3546
+ id: delNodeId,
3547
+ kind: "delegation",
3548
+ label: `${d.id} · ${d.agentId}`,
3549
+ status: d.status,
3550
+ sublabel: d.objective.slice(0, 60),
3551
+ timestamp: d.createdAt
3552
+ });
3553
+ edges.push({
3554
+ id: `edge:${stageNodeId}-${d.id}`,
3555
+ source: stageNodeId,
3556
+ target: delNodeId,
3557
+ kind: "delegate"
3558
+ });
3559
+ }
3560
+ const stageFindings = findings.filter((f) => f.sourceStage === stageId);
3561
+ for (const f of stageFindings) {
3562
+ nodes.push({
3563
+ id: `finding:${f.id}`,
3564
+ kind: "finding",
3565
+ label: f.title,
3566
+ status: f.severity.toUpperCase(),
3567
+ nav: {
3568
+ tab: "findings",
3569
+ focus: f.id
3570
+ }
3571
+ });
3572
+ edges.push({
3573
+ id: `edge:found-${f.id}`,
3574
+ source: `delegation:${f.sourceDelegationId}`,
3575
+ target: `finding:${f.id}`,
3576
+ kind: "found"
3577
+ });
3578
+ }
3579
+ prevStageId = stageId;
3580
+ }
3581
+ const seen = /* @__PURE__ */ new Set();
3582
+ return {
3583
+ nodes,
3584
+ edges: edges.filter((e) => {
3585
+ const k = `${e.source}->${e.target}`;
3586
+ if (seen.has(k)) return false;
3587
+ seen.add(k);
3588
+ return true;
3589
+ })
3590
+ };
3591
+ }
3592
+ const snapshotPerf = typeof process !== "undefined" && process.env?.DSH_PENTESTER_PERF === "1";
3593
+ async function buildSnapshot(ctx) {
3594
+ const t0 = performance.now();
3595
+ const [run, findings, assetCount, registry] = await Promise.all([
3596
+ tryLoadRun(ctx),
3597
+ parseFindingsIndex(ctx),
3598
+ parseAssetsCount(ctx),
3599
+ loadTargetRegistry(ctx.projectDir).catch(() => null)
3600
+ ]);
3601
+ const tData = (performance.now() - t0).toFixed(1);
3602
+ if (snapshotPerf) console.log(`[SESSION_SWITCH_PERF] snapshot data-load=${tData}ms (run+findings+assets+registry)`);
3603
+ const targets = [];
3604
+ if (registry !== null) for (const record of listTargetRecords(registry)) targets.push({
3605
+ id: record.id,
3606
+ target: record.target,
3607
+ canonicalTarget: record.canonicalTarget,
3608
+ rootSessionId: record.rootSessionId,
3609
+ status: "unknown",
3610
+ currentStage: null,
3611
+ createdAt: record.createdAt
3612
+ });
3613
+ if (run === null) return {
3614
+ run: null,
3615
+ stats: {
3616
+ assets: 0,
3617
+ findings: 0,
3618
+ delegations: 0
3619
+ },
3620
+ stages: STAGE_IDS.map((id) => ({
3621
+ id,
3622
+ name: STAGE_DEFINITIONS.find((d) => d.id === id)?.name ?? id,
3623
+ shortName: STAGE_SHORT[id] ?? id.slice(0, 4),
3624
+ status: "pending",
3625
+ delegationCount: 0,
3626
+ activeDelegationCount: 0
3627
+ })),
3628
+ delegations: [],
3629
+ findings,
3630
+ trace: {
3631
+ nodes: [{
3632
+ id: "start",
3633
+ kind: "start",
3634
+ label: "Start"
3635
+ }],
3636
+ edges: []
3637
+ },
3638
+ targets
3639
+ };
3640
+ const result = {
3641
+ run: buildRunView(run),
3642
+ stats: {
3643
+ assets: assetCount,
3644
+ findings: findings.length,
3645
+ delegations: run.delegations.length
3646
+ },
3647
+ stages: buildStageViews(run),
3648
+ delegations: buildDelegationViews(run),
3649
+ findings,
3650
+ trace: buildTraceModel(run, ctx, findings),
3651
+ targets
3652
+ };
3653
+ const totalMs = (performance.now() - t0).toFixed(1);
3654
+ if (snapshotPerf) console.log(`[SESSION_SWITCH_PERF] snapshot projection=${(Number(totalMs) - Number(tData)).toFixed(1)}ms total=${totalMs}ms`);
3655
+ return result;
3656
+ }
3657
+ const outputCache = /* @__PURE__ */ new Map();
3658
+ const OUTPUT_CACHE_TTL = 1e4;
3659
+ const outputInflight = /* @__PURE__ */ new Map();
3660
+ const FS_SEMAPHORE = 16;
3661
+ let fsInFlight = 0;
3662
+ const fsWaiters = [];
3663
+ function fsAcquire() {
3664
+ if (fsInFlight < FS_SEMAPHORE) {
3665
+ fsInFlight++;
3666
+ return Promise.resolve();
3667
+ }
3668
+ return new Promise((resolve) => {
3669
+ fsWaiters.push(resolve);
3670
+ });
3671
+ }
3672
+ function fsRelease() {
3673
+ fsInFlight--;
3674
+ const next = fsWaiters.shift();
3675
+ if (next !== void 0) {
3676
+ fsInFlight++;
3677
+ next();
3678
+ }
3679
+ }
3680
+ async function buildOutputTreeForTarget(ctx, targetId, signal) {
3681
+ let targetCtx = ctx;
3682
+ if (targetId !== void 0 && targetId !== ctx.targetId) {
3683
+ const record = (await loadTargetRegistry(ctx.projectDir)).targets.find((t) => t.id === targetId);
3684
+ if (record === void 0) throw new Error("target not found");
3685
+ targetCtx = buildTargetContext(ctx.projectDir, record);
3686
+ }
3687
+ const cacheKey = targetCtx.targetId;
3688
+ const cached = outputCache.get(cacheKey);
3689
+ if (cached !== void 0 && Date.now() - cached.builtAt < OUTPUT_CACHE_TTL) {
3690
+ console.log(`[OUTPUT_SCAN] cache=hit targetId=${cacheKey}`);
3691
+ return {
3692
+ targetId: cacheKey,
3693
+ target: targetCtx.target,
3694
+ current: cacheKey === ctx.targetId,
3695
+ children: cached.tree
3696
+ };
3697
+ }
3698
+ const inflight = outputInflight.get(cacheKey);
3699
+ if (inflight !== void 0) {
3700
+ console.log(`[OUTPUT_SCAN] inflight=dedup targetId=${cacheKey}`);
3701
+ const tree = await inflight;
3702
+ return {
3703
+ targetId: cacheKey,
3704
+ target: targetCtx.target,
3705
+ current: cacheKey === ctx.targetId,
3706
+ children: tree
3707
+ };
3708
+ }
3709
+ console.log(`[OUTPUT_SCAN] cache=miss targetId=${cacheKey}`);
3710
+ const promise = buildOutputTree(targetCtx, signal);
3711
+ outputInflight.set(cacheKey, promise);
3712
+ try {
3713
+ const tree = await promise;
3714
+ outputCache.set(cacheKey, {
3715
+ tree,
3716
+ builtAt: Date.now()
3717
+ });
3718
+ return {
3719
+ targetId: cacheKey,
3720
+ target: targetCtx.target,
3721
+ current: cacheKey === ctx.targetId,
3722
+ children: tree
3723
+ };
3724
+ } finally {
3725
+ outputInflight.delete(cacheKey);
3726
+ }
3727
+ }
3728
+ async function buildOutputTree(ctx, signal) {
3729
+ const root = ctx.targetRoot;
3730
+ if (!existsSync(root)) return [];
3731
+ const entries = [];
3732
+ const stats = {
3733
+ dirs: 0,
3734
+ files: 0,
3735
+ statCalls: 0,
3736
+ readdirCalls: 0,
3737
+ maxDepth: 0,
3738
+ maxDirEntries: 0
3739
+ };
3740
+ const categoryTimings = {};
3741
+ const categoryCounts = {};
3742
+ for (const cat of [
3743
+ {
3744
+ dir: "report",
3745
+ label: "Reports",
3746
+ category: "report"
3747
+ },
3748
+ {
3749
+ dir: "findings",
3750
+ label: "Findings",
3751
+ category: "finding"
3752
+ },
3753
+ {
3754
+ dir: "assets",
3755
+ label: "Artifacts",
3756
+ category: "artifact"
3757
+ },
3758
+ {
3759
+ dir: "evidence",
3760
+ label: "Evidence",
3761
+ category: "evidence"
3762
+ }
3763
+ ]) {
3764
+ signal?.throwIfAborted();
3765
+ const catDir = join(root, cat.dir);
3766
+ if (!existsSync(catDir)) continue;
3767
+ const tCat = performance.now();
3768
+ const children = await scanDir(catDir, root, cat.category, 0, stats, signal);
3769
+ const catMs = performance.now() - tCat;
3770
+ categoryTimings[cat.dir] = catMs;
3771
+ categoryCounts[cat.dir] = children.length;
3772
+ if (children.length > 0) entries.push({
3773
+ path: cat.dir,
3774
+ name: cat.label,
3775
+ kind: "directory",
3776
+ category: cat.category,
3777
+ children: flattenSingleChildDirs(children)
3778
+ });
3779
+ }
3780
+ const stagesDir = join(root, "stages");
3781
+ if (existsSync(stagesDir)) {
3782
+ const tStages = performance.now();
3783
+ let stageEntryCount = 0;
3784
+ for (let i = 0; i < STAGE_IDS.length; i++) {
3785
+ signal?.throwIfAborted();
3786
+ const stageId = STAGE_IDS[i];
3787
+ const stageSubDir = join(stagesDir, `${String(i + 1).padStart(2, "0")}-${stageId}`);
3788
+ if (!existsSync(stageSubDir)) continue;
3789
+ const children = [];
3790
+ const summaryFile = join(stageSubDir, "summary.md");
3791
+ if (existsSync(summaryFile)) {
3792
+ stats.statCalls++;
3793
+ const st = await stat(summaryFile);
3794
+ children.push({
3795
+ path: relative(root, summaryFile),
3796
+ name: "summary.md",
3797
+ kind: "file",
3798
+ size: st.size,
3799
+ mtime: st.mtime.toISOString(),
3800
+ category: "stage",
3801
+ sourceStage: stageId
3802
+ });
3803
+ }
3804
+ const delegationsDir = join(stageSubDir, "delegations");
3805
+ if (existsSync(delegationsDir)) {
3806
+ stats.readdirCalls++;
3807
+ const delDirs = await readdir(delegationsDir, { withFileTypes: true });
3808
+ for (const delDir of delDirs) {
3809
+ if (!delDir.isDirectory()) continue;
3810
+ const delId = delDir.name;
3811
+ const resultFile = join(delegationsDir, delId, "result.md");
3812
+ if (existsSync(resultFile)) {
3813
+ stats.statCalls++;
3814
+ const st = await stat(resultFile);
3815
+ children.push({
3816
+ path: relative(root, resultFile),
3817
+ name: `${delId}/result.md`,
3818
+ kind: "file",
3819
+ size: st.size,
3820
+ mtime: st.mtime.toISOString(),
3821
+ category: "stage",
3822
+ sourceStage: stageId,
3823
+ sourceDelegationId: delId
3824
+ });
3825
+ }
3826
+ const evidenceDir = join(delegationsDir, delId, "evidence");
3827
+ if (existsSync(evidenceDir)) children.push(...flattenSingleChildDirs(await scanDir(evidenceDir, root, "evidence", 0, stats, signal, stageId, delId)));
3828
+ const artifactsDir = join(delegationsDir, delId, "artifacts");
3829
+ if (existsSync(artifactsDir)) children.push(...flattenSingleChildDirs(await scanDir(artifactsDir, root, "artifact", 0, stats, signal, stageId, delId)));
3830
+ }
3831
+ }
3832
+ if (children.length > 0) {
3833
+ stageEntryCount += children.length;
3834
+ entries.push({
3835
+ path: relative(root, stageSubDir),
3836
+ name: STAGE_SHORT[stageId] ?? stageId,
3837
+ kind: "directory",
3838
+ category: "stage",
3839
+ children: flattenSingleChildDirs(children)
3840
+ });
3841
+ }
3842
+ }
3843
+ categoryTimings["stages"] = performance.now() - tStages;
3844
+ categoryCounts["stages"] = stageEntryCount;
3845
+ }
3846
+ const totalMs = Object.values(categoryTimings).reduce((a, b) => a + b, 0);
3847
+ const catReport = Object.entries(categoryTimings).map(([cat, ms]) => `${cat}=${ms.toFixed(0)}ms entries=${categoryCounts[cat] ?? 0}`).join("\n");
3848
+ const totalEntries = Object.values(categoryCounts).reduce((a, b) => a + b, 0);
3849
+ console.log(`[OUTPUT_SCAN]\n${catReport}\nstages=${categoryTimings["stages"]?.toFixed(0) ?? "0"}ms entries=${categoryCounts["stages"] ?? 0}\ntotal=${totalMs.toFixed(0)}ms entries=${totalEntries}\ndirs=${stats.dirs} files=${stats.files} stat=${stats.statCalls} readdir=${stats.readdirCalls} maxDepth=${stats.maxDepth} maxDirEntries=${stats.maxDirEntries}`);
3850
+ return entries;
3851
+ }
3852
+ const SCAN_CONCURRENCY = 16;
3853
+ async function scanDir(dir, root, parentCategory, depth = 0, stats, signal, sourceStage, sourceDelegationId) {
3854
+ if (depth > 3) return [];
3855
+ signal?.throwIfAborted();
3856
+ try {
3857
+ if (stats) {
3858
+ stats.dirs++;
3859
+ if (depth > stats.maxDepth) stats.maxDepth = depth;
3860
+ }
3861
+ await fsAcquire();
3862
+ if (stats) stats.readdirCalls++;
3863
+ const items = await readdir(dir, { withFileTypes: true });
3864
+ fsRelease();
3865
+ if (stats && items.length > stats.maxDirEntries) stats.maxDirEntries = items.length;
3866
+ const filtered = items.filter((i) => !i.name.startsWith(".") && i.name !== "tmp" && i.name !== "cache" && i.name !== ".dsh-pentester" && i.name !== ".git");
3867
+ const entries = [];
3868
+ for (let batch = 0; batch < filtered.length; batch += SCAN_CONCURRENCY) {
3869
+ const batchItems = filtered.slice(batch, batch + SCAN_CONCURRENCY);
3870
+ const results = await Promise.all(batchItems.map(async (item) => {
3871
+ signal?.throwIfAborted();
3872
+ const fullPath = join(dir, item.name);
3873
+ const relPath = relative(root, fullPath);
3874
+ if (item.isDirectory()) {
3875
+ const children = await scanDir(fullPath, root, parentCategory, depth + 1, stats, signal, sourceStage, sourceDelegationId);
3876
+ if (children.length === 0) return null;
3877
+ return {
3878
+ path: relPath,
3879
+ name: item.name,
3880
+ kind: "directory",
3881
+ category: parentCategory,
3882
+ ...sourceStage !== void 0 ? { sourceStage } : {},
3883
+ ...sourceDelegationId !== void 0 ? { sourceDelegationId } : {},
3884
+ children
3885
+ };
3886
+ }
3887
+ try {
3888
+ if (stats) {
3889
+ stats.files++;
3890
+ stats.statCalls++;
3891
+ }
3892
+ await fsAcquire();
3893
+ const st = await stat(fullPath);
3894
+ fsRelease();
3895
+ return {
3896
+ path: relPath,
3897
+ name: item.name,
3898
+ kind: "file",
3899
+ size: st.size,
3900
+ mtime: st.mtime.toISOString(),
3901
+ category: parentCategory,
3902
+ ...sourceStage !== void 0 ? { sourceStage } : {},
3903
+ ...sourceDelegationId !== void 0 ? { sourceDelegationId } : {}
3904
+ };
3905
+ } catch {
3906
+ return null;
3907
+ }
3908
+ }));
3909
+ for (const r of results) if (r !== null) entries.push(r);
3910
+ }
3911
+ return entries.sort((a, b) => a.kind !== b.kind ? a.kind === "directory" ? -1 : 1 : a.name.localeCompare(b.name));
3912
+ } catch {
3913
+ return [];
3914
+ }
3915
+ }
3916
+ function flattenSingleChildDirs(entries) {
3917
+ const result = [];
3918
+ for (const entry of entries) {
3919
+ if (entry.kind !== "directory" || entry.children === void 0 || entry.children.length !== 1 || entry.children[0].kind !== "directory") {
3920
+ result.push(entry);
3921
+ continue;
3922
+ }
3923
+ const child = entry.children[0];
3924
+ result.push({
3925
+ path: child.path,
3926
+ name: `${entry.name}/${child.name}`,
3927
+ kind: "directory",
3928
+ category: entry.category,
3929
+ ...entry.sourceStage !== void 0 ? { sourceStage: entry.sourceStage } : {},
3930
+ ...entry.sourceDelegationId !== void 0 ? { sourceDelegationId: entry.sourceDelegationId } : {},
3931
+ children: child.children !== void 0 ? flattenSingleChildDirs(child.children) : void 0
3932
+ });
3933
+ }
3934
+ return result;
3935
+ }
3936
+ const MAX_PREVIEW = 1048576;
3937
+ async function readOutputFile(ctx, relativePath) {
3938
+ if (relativePath.includes("..") || relativePath.startsWith("/") || isAbsolute(relativePath) || relativePath.includes("\0")) return {
3939
+ ok: false,
3940
+ error: "path denied"
3941
+ };
3942
+ const root = ctx.targetRoot;
3943
+ const fullPath = join(root, relativePath);
3944
+ const { realpathSync } = await import("node:fs");
3945
+ let realRoot, realFile;
3946
+ try {
3947
+ realRoot = realpathSync(root);
3948
+ realFile = realpathSync(fullPath);
3949
+ } catch {
3950
+ return {
3951
+ ok: false,
3952
+ error: "file not found"
3953
+ };
3954
+ }
3955
+ if (!realFile.startsWith(realRoot + sep) && realFile !== realRoot) return {
3956
+ ok: false,
3957
+ error: "path escape denied"
3958
+ };
3959
+ try {
3960
+ const st = await stat(fullPath);
3961
+ if (st.isDirectory()) return {
3962
+ ok: false,
3963
+ error: "is a directory"
3964
+ };
3965
+ const ext = relativePath.split(".").pop()?.toLowerCase() ?? "";
3966
+ if ((/* @__PURE__ */ new Set([
3967
+ "png",
3968
+ "jpg",
3969
+ "jpeg",
3970
+ "gif",
3971
+ "webp",
3972
+ "bmp",
3973
+ "ico",
3974
+ "pdf",
3975
+ "zip",
3976
+ "gz",
3977
+ "tar",
3978
+ "pcap",
3979
+ "mitm",
3980
+ "bin",
3981
+ "exe",
3982
+ "dll",
3983
+ "so"
3984
+ ])).has(ext)) return {
3985
+ ok: true,
3986
+ content: `[Binary file: ${ext.toUpperCase()} — ${(st.size / 1024).toFixed(1)} KB]`,
3987
+ mime: "binary",
3988
+ size: st.size,
3989
+ mtime: st.mtime.toISOString(),
3990
+ name: relativePath.split("/").pop() ?? relativePath
3991
+ };
3992
+ const fh = await open(fullPath, "r");
3993
+ const buf = Buffer.alloc(Math.min(st.size, MAX_PREVIEW));
3994
+ await fh.read(buf, 0, buf.length, 0);
3995
+ await fh.close();
3996
+ let content = buf.toString("utf8");
3997
+ if (st.size > MAX_PREVIEW) content += `\n\n... [truncated: ${(st.size / 1024).toFixed(1)} KB total]`;
3998
+ let mime = "text";
3999
+ if (ext === "md") mime = "markdown";
4000
+ else if (ext === "json") mime = "json";
4001
+ return {
4002
+ ok: true,
4003
+ content,
4004
+ mime,
4005
+ size: st.size,
4006
+ mtime: st.mtime.toISOString(),
4007
+ name: relativePath.split("/").pop() ?? relativePath
4008
+ };
4009
+ } catch (error) {
4010
+ return {
4011
+ ok: false,
4012
+ error: error instanceof Error ? error.message : "read error"
4013
+ };
4014
+ }
4015
+ }
4016
+ //#endregion
4017
+ //#region src/ui-view/binding.ts
4018
+ const cache = /* @__PURE__ */ new Map();
4019
+ async function resolveBinding(deps, sessionId) {
4020
+ const t0 = performance.now();
4021
+ const perf = deps.verbose || typeof process !== "undefined" && process.env?.DSH_PENTESTER_PERF === "1";
4022
+ const cached = cache.get(sessionId);
4023
+ if (cached !== void 0) {
4024
+ if (perf) console.log(`[SESSION_SWITCH_PERF] binding session=${sessionId.slice(0, 8)} source=cache total=${(performance.now() - t0).toFixed(1)}ms`);
4025
+ return cached;
4026
+ }
4027
+ const tLive = performance.now();
4028
+ const projectDir = deps.getLiveSessionCwd(sessionId);
4029
+ const liveMs = (performance.now() - tLive).toFixed(1);
4030
+ let source = "live";
4031
+ let resolvedDir = projectDir;
4032
+ if (resolvedDir === void 0) {
4033
+ const tWs = performance.now();
4034
+ resolvedDir = deps.getWorkspaceForSession(sessionId);
4035
+ const wsMs = (performance.now() - tWs).toFixed(1);
4036
+ source = "workspace-index";
4037
+ if (perf) console.log(`[SESSION_SWITCH_PERF] binding session=${sessionId.slice(0, 8)} workspaceLookup=${wsMs}ms`);
4038
+ }
4039
+ if (resolvedDir === void 0 || resolvedDir === "") {
4040
+ const totalMs = (performance.now() - t0).toFixed(1);
4041
+ if (perf) console.log(`[SESSION_SWITCH_PERF] binding session=${sessionId.slice(0, 8)} source=${source} result=unbound total=${totalMs}ms`);
4042
+ return {
4043
+ kind: "unbound",
4044
+ projectDir: ""
4045
+ };
4046
+ }
4047
+ const tTarget = performance.now();
4048
+ const targetCtx = await resolveSessionTargetContext(resolvedDir, sessionId);
4049
+ const targetMs = (performance.now() - tTarget).toFixed(1);
4050
+ if (targetCtx !== void 0) {
4051
+ const result = {
4052
+ kind: "bound",
4053
+ projectDir: resolvedDir,
4054
+ targetContext: targetCtx
4055
+ };
4056
+ cache.set(sessionId, result);
4057
+ const totalMs = (performance.now() - t0).toFixed(1);
4058
+ if (perf) console.log(`[SESSION_SWITCH_PERF] binding session=${sessionId.slice(0, 8)} source=${source} cwd=${resolvedDir} liveLookup=${liveMs}ms targetLookup=${targetMs}ms total=${totalMs}ms`);
4059
+ if (Number(totalMs) > 100) console.warn(`[SESSION_SWITCH_PERF] slow binding ${totalMs}ms source=${source}`);
4060
+ return result;
4061
+ }
4062
+ const totalMs = (performance.now() - t0).toFixed(1);
4063
+ if (perf) console.log(`[SESSION_SWITCH_PERF] binding session=${sessionId.slice(0, 8)} source=${source} result=target-unresolved total=${totalMs}ms`);
4064
+ return {
4065
+ kind: "unbound",
4066
+ projectDir: resolvedDir
4067
+ };
4068
+ }
4069
+ //#endregion
4070
+ //#region src/build-id.ts
4071
+ /**
4072
+ * build-id.ts — Build identity injected at build time.
4073
+ *
4074
+ * Generated by scripts/build-id.mjs before each build.
4075
+ * Both host (tsdown) and client (esbuild) import this module.
4076
+ *
4077
+ * Format: version+shortSha (e.g. "1.1.0+391bfd6")
4078
+ */
4079
+ const BUILD_ID = "2.2.0+478786d";
4080
+ //#endregion
4081
+ //#region src/ui-view/invocations.ts
4082
+ /**
4083
+ * ui-view/invocations.ts — Pentester View 的 Typert 调用描述符与 schemas。
4084
+ *
4085
+ * 仅包含纯数据常量:客户端和宿主两侧均可安全导入,无 Node.js 依赖。
4086
+ */
4087
+ const pentesterViewInputSchema$1 = z.discriminatedUnion("kind", [
4088
+ z.object({ kind: z.literal("health") }),
4089
+ z.object({
4090
+ kind: z.literal("snapshot"),
4091
+ sessionId: z.string()
4092
+ }),
4093
+ z.object({
4094
+ kind: z.literal("listOutput"),
4095
+ sessionId: z.string(),
4096
+ targetId: z.string().optional()
4097
+ }),
4098
+ z.object({
4099
+ kind: z.literal("readOutput"),
4100
+ sessionId: z.string(),
4101
+ targetId: z.string().optional(),
4102
+ path: z.string()
4103
+ }),
4104
+ z.object({
4105
+ kind: z.literal("readFinding"),
4106
+ sessionId: z.string(),
4107
+ detailFile: z.string()
4108
+ })
4109
+ ]);
4110
+ const snapshotResultSchema = z.object({
4111
+ run: z.union([z.object({
4112
+ id: z.string(),
4113
+ status: z.enum(["active", "completed"]),
4114
+ currentStage: z.string().nullable(),
4115
+ targets: z.array(z.string()),
4116
+ createdAt: z.string()
4117
+ }), z.null()]),
4118
+ stats: z.object({
4119
+ assets: z.number(),
4120
+ findings: z.number(),
4121
+ delegations: z.number()
4122
+ }),
4123
+ stages: z.array(z.object({
4124
+ id: z.string(),
4125
+ name: z.string(),
4126
+ shortName: z.string(),
4127
+ status: z.string(),
4128
+ delegationCount: z.number(),
4129
+ activeDelegationCount: z.number()
4130
+ })),
4131
+ delegations: z.array(z.object({
4132
+ id: z.string(),
4133
+ stageId: z.string(),
4134
+ agentId: z.string(),
4135
+ sessionId: z.string().optional(),
4136
+ objective: z.string(),
4137
+ status: z.string(),
4138
+ createdAt: z.string(),
4139
+ finishedAt: z.string().optional()
4140
+ })),
4141
+ findings: z.array(z.object({
4142
+ id: z.string(),
4143
+ title: z.string(),
4144
+ severity: z.string(),
4145
+ status: z.string().optional(),
4146
+ sourceStage: z.string(),
4147
+ sourceDelegationId: z.string(),
4148
+ detailFile: z.string(),
4149
+ affectedAsset: z.string().optional(),
4150
+ description: z.string().optional(),
4151
+ updatedAt: z.string()
4152
+ })),
4153
+ trace: z.object({
4154
+ nodes: z.array(z.object({
4155
+ id: z.string(),
4156
+ kind: z.string(),
4157
+ label: z.string(),
4158
+ status: z.string().optional(),
4159
+ sublabel: z.string().optional(),
4160
+ timestamp: z.string().optional()
4161
+ })),
4162
+ edges: z.array(z.object({
4163
+ id: z.string(),
4164
+ source: z.string(),
4165
+ target: z.string(),
4166
+ kind: z.string(),
4167
+ label: z.string().optional()
4168
+ }))
4169
+ }),
4170
+ targets: z.array(z.object({
4171
+ id: z.string(),
4172
+ target: z.string(),
4173
+ canonicalTarget: z.string(),
4174
+ rootSessionId: z.string().nullable(),
4175
+ status: z.string(),
4176
+ currentStage: z.string().nullable(),
4177
+ createdAt: z.string()
4178
+ }))
4179
+ });
4180
+ const unboundResultSchema = z.object({
4181
+ kind: z.literal("unbound"),
4182
+ targets: z.array(z.object({
4183
+ id: z.string(),
4184
+ target: z.string(),
4185
+ canonicalTarget: z.string(),
4186
+ rootSessionId: z.string().nullable(),
4187
+ status: z.string(),
4188
+ currentStage: z.string().nullable(),
4189
+ createdAt: z.string()
4190
+ }))
4191
+ });
4192
+ const outputFileResultSchema = z.object({
4193
+ path: z.string(),
4194
+ name: z.string(),
4195
+ size: z.number(),
4196
+ mtime: z.string(),
4197
+ mime: z.enum([
4198
+ "text",
4199
+ "markdown",
4200
+ "json",
4201
+ "image",
4202
+ "binary"
4203
+ ]),
4204
+ content: z.string().optional()
4205
+ });
4206
+ const outputTreeResultSchema = z.object({
4207
+ targetId: z.string(),
4208
+ target: z.string(),
4209
+ current: z.boolean(),
4210
+ children: z.array(z.unknown())
4211
+ });
4212
+ /** 客户端 remote 贡献的 invocation 描述符 */
4213
+ const PENTESTER_VIEW_INVOCATION = {
4214
+ id: "dsh-pentester#pentesterView/command",
4215
+ service: "pentesterView",
4216
+ namespace: "pentesterView",
4217
+ method: "command",
4218
+ invocation: { kind: "direct" },
4219
+ cancellation: { parameter: "signal" },
4220
+ parameters: [{
4221
+ name: "input",
4222
+ wire: "input",
4223
+ source: "json",
4224
+ codec: {
4225
+ mode: "strict",
4226
+ typeSymbol: "dsh-pentester#PentesterViewInput",
4227
+ schema: pentesterViewInputSchema$1
4228
+ }
4229
+ }],
4230
+ result: {
4231
+ mode: "strict",
4232
+ typeSymbol: "dsh-pentester#PentesterViewResult",
4233
+ schema: z.object({
4234
+ ok: z.boolean(),
4235
+ value: z.union([
4236
+ snapshotResultSchema,
4237
+ outputTreeResultSchema,
4238
+ outputFileResultSchema,
4239
+ unboundResultSchema,
4240
+ z.object({
4241
+ ready: z.literal(true),
4242
+ buildId: z.string()
4243
+ })
4244
+ ]).optional(),
4245
+ error: z.string().optional(),
4246
+ code: z.string().optional()
4247
+ })
4248
+ }
4249
+ };
4250
+ //#endregion
4251
+ //#region src/ui-view/rpc.ts
4252
+ const pentesterViewInputSchema = z.discriminatedUnion("kind", [
4253
+ z.object({ kind: z.literal("health") }),
4254
+ z.object({
4255
+ kind: z.literal("snapshot"),
4256
+ sessionId: z.string()
4257
+ }),
4258
+ z.object({
4259
+ kind: z.literal("listOutput"),
4260
+ sessionId: z.string(),
4261
+ targetId: z.string().optional()
4262
+ }),
4263
+ z.object({
4264
+ kind: z.literal("readOutput"),
4265
+ sessionId: z.string(),
4266
+ targetId: z.string().optional(),
4267
+ path: z.string()
4268
+ }),
4269
+ z.object({
4270
+ kind: z.literal("readFinding"),
4271
+ sessionId: z.string(),
4272
+ detailFile: z.string()
4273
+ })
4274
+ ]);
4275
+ let requestSeq = 0;
4276
+ const HOST_PERF = typeof process !== "undefined" && process.env?.DSH_PENTESTER_PERF === "1";
4277
+ var PentesterViewTypertService = class extends TypertRemoteService {
4278
+ deps;
4279
+ constructor(ctx, deps) {
4280
+ super(ctx, "pentesterView");
4281
+ this.deps = deps;
4282
+ }
4283
+ async command(input, signal) {
4284
+ const requestId = ++requestSeq;
4285
+ const t0 = performance.now();
4286
+ if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} ENTER +0ms`);
4287
+ const parsed = pentesterViewInputSchema.safeParse(input);
4288
+ if (!parsed.success) {
4289
+ if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} INVALID_REQUEST +${(performance.now() - t0).toFixed(1)}ms`);
4290
+ return {
4291
+ ok: false,
4292
+ error: "invalid_request",
4293
+ code: "invalid_request"
4294
+ };
4295
+ }
4296
+ const data = parsed.data;
4297
+ if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} PARSED kind=${data.kind} +${(performance.now() - t0).toFixed(1)}ms`);
4298
+ try {
4299
+ signal?.throwIfAborted();
4300
+ if (data.kind === "health") {
4301
+ if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} HEALTH +${(performance.now() - t0).toFixed(1)}ms`);
4302
+ return {
4303
+ ok: true,
4304
+ value: {
4305
+ ready: true,
4306
+ buildId: BUILD_ID
4307
+ }
4308
+ };
4309
+ }
4310
+ if (data.kind === "snapshot") {
4311
+ signal?.throwIfAborted();
4312
+ if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} BINDING_START +${(performance.now() - t0).toFixed(1)}ms`);
4313
+ const tResolve = performance.now();
4314
+ const resolution = await resolveBinding(this.deps, data.sessionId);
4315
+ const resolveMs = (performance.now() - tResolve).toFixed(1);
4316
+ if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} BINDING_END kind=${resolution.kind} +${(performance.now() - t0).toFixed(1)}ms`);
4317
+ if (resolution.kind === "bound") {
4318
+ signal?.throwIfAborted();
4319
+ if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} SNAPSHOT_START +${(performance.now() - t0).toFixed(1)}ms`);
4320
+ const tBuild = performance.now();
4321
+ const snapshot = await buildSnapshot(resolution.targetContext);
4322
+ const buildMs = (performance.now() - tBuild).toFixed(1);
4323
+ if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} SNAPSHOT_END +${(performance.now() - t0).toFixed(1)}ms`);
4324
+ signal?.throwIfAborted();
4325
+ const totalMs = (performance.now() - t0).toFixed(1);
4326
+ if (HOST_PERF) {
4327
+ const payload = JSON.stringify(snapshot).length;
4328
+ console.log(`[PENTESTER_RPC_HOST] #${requestId} RETURN binding=${resolveMs}ms build=${buildMs}ms total=${totalMs}ms payload=${(payload / 1024).toFixed(0)}KB`);
4329
+ }
4330
+ if (Number(totalMs) > 1e3) console.warn(`[PENTESTER_RPC_HOST] #${requestId} slow ${totalMs}ms`);
4331
+ return {
4332
+ ok: true,
4333
+ value: snapshot
4334
+ };
4335
+ }
4336
+ if (resolution.projectDir !== "") return buildUnboundSnapshot(resolution.projectDir);
4337
+ if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} RETURN unbound +${(performance.now() - t0).toFixed(1)}ms`);
4338
+ return {
4339
+ ok: true,
4340
+ value: {
4341
+ kind: "unbound",
4342
+ targets: []
4343
+ }
4344
+ };
4345
+ }
4346
+ if (data.kind === "listOutput") {
4347
+ signal?.throwIfAborted();
4348
+ const tResolve = performance.now();
4349
+ console.log(`[PENTESTER_OUTPUT_PERF] #${requestId} ENTER`);
4350
+ console.log(`[PENTESTER_OUTPUT_PERF] #${requestId} BINDING_START`);
4351
+ const resolution = await resolveBinding(this.deps, data.sessionId);
4352
+ if (resolution.kind !== "bound") return {
4353
+ ok: false,
4354
+ error: "not bound",
4355
+ code: "no_binding"
4356
+ };
4357
+ console.log(`[PENTESTER_OUTPUT_PERF] #${requestId} BINDING_END ${(performance.now() - tResolve).toFixed(1)}ms`);
4358
+ signal?.throwIfAborted();
4359
+ console.log(`[PENTESTER_OUTPUT_PERF] #${requestId} TREE_START targetId=${data.targetId ?? "default"}`);
4360
+ const tTree = performance.now();
4361
+ const tree = await buildOutputTreeForTarget(resolution.targetContext, data.targetId, signal);
4362
+ const treeMs = (performance.now() - tTree).toFixed(1);
4363
+ console.log(`[PENTESTER_OUTPUT_PERF] #${requestId} TREE_END ${treeMs}ms`);
4364
+ signal?.throwIfAborted();
4365
+ const totalMs = (performance.now() - t0).toFixed(1);
4366
+ const payload = JSON.stringify(tree).length;
4367
+ console.log(`[PENTESTER_OUTPUT_PERF] #${requestId} RETURN total=${totalMs}ms entryCount=${tree.children.length} payload=${(payload / 1024).toFixed(0)}KB`);
4368
+ return {
4369
+ ok: true,
4370
+ value: tree
4371
+ };
4372
+ }
4373
+ if (data.kind === "readOutput") {
4374
+ signal?.throwIfAborted();
4375
+ const resolution = await resolveBinding(this.deps, data.sessionId);
4376
+ if (resolution.kind !== "bound") return {
4377
+ ok: false,
4378
+ error: "not bound",
4379
+ code: "no_binding"
4380
+ };
4381
+ const path = data.path;
4382
+ if (path === "") return {
4383
+ ok: false,
4384
+ error: "path required",
4385
+ code: "invalid_request"
4386
+ };
4387
+ let targetCtx = resolution.targetContext;
4388
+ if (data.targetId !== void 0 && data.targetId !== resolution.targetContext.targetId) {
4389
+ const record = (await loadTargetRegistry(resolution.projectDir)).targets.find((t) => t.id === data.targetId);
4390
+ if (record === void 0) return {
4391
+ ok: false,
4392
+ error: "target not found",
4393
+ code: "invalid_target"
4394
+ };
4395
+ targetCtx = buildTargetContext(resolution.projectDir, record);
4396
+ }
4397
+ const result = await readOutputFile(targetCtx, path);
4398
+ if (!result.ok) return {
4399
+ ok: false,
4400
+ error: result.error,
4401
+ code: "read_error"
4402
+ };
4403
+ return {
4404
+ ok: true,
4405
+ value: {
4406
+ path,
4407
+ name: result.name,
4408
+ size: result.size,
4409
+ mtime: result.mtime,
4410
+ mime: result.mime,
4411
+ content: result.content
4412
+ }
4413
+ };
4414
+ }
4415
+ if (data.kind === "readFinding") {
4416
+ signal?.throwIfAborted();
4417
+ const resolution = await resolveBinding(this.deps, data.sessionId);
4418
+ if (resolution.kind !== "bound") return {
4419
+ ok: false,
4420
+ error: "not bound",
4421
+ code: "no_binding"
4422
+ };
4423
+ const detailFile = data.detailFile;
4424
+ if (detailFile === "") return {
4425
+ ok: false,
4426
+ error: "detailFile required",
4427
+ code: "invalid_request"
4428
+ };
4429
+ const result = await readOutputFile(resolution.targetContext, `findings/${detailFile}`);
4430
+ if (!result.ok) return {
4431
+ ok: false,
4432
+ error: result.error,
4433
+ code: "read_error"
4434
+ };
4435
+ return {
4436
+ ok: true,
4437
+ value: {
4438
+ path: `findings/${detailFile}`,
4439
+ name: detailFile,
4440
+ size: result.size,
4441
+ mtime: result.mtime,
4442
+ mime: "markdown",
4443
+ content: result.content
4444
+ }
4445
+ };
4446
+ }
4447
+ return {
4448
+ ok: false,
4449
+ error: "unknown kind",
4450
+ code: "invalid_request"
4451
+ };
4452
+ } catch (error) {
4453
+ if (error instanceof Error && error.name === "AbortError") {
4454
+ if (HOST_PERF) console.log(`[PENTESTER_RPC_HOST] #${requestId} ABORTED +${(performance.now() - t0).toFixed(1)}ms`);
4455
+ return {
4456
+ ok: false,
4457
+ error: "aborted",
4458
+ code: "aborted"
4459
+ };
4460
+ }
4461
+ const totalMs = (performance.now() - t0).toFixed(1);
4462
+ if (HOST_PERF) console.error(`[PENTESTER_RPC_HOST] #${requestId} FAILED kind=${data.kind} +${totalMs}ms: ${error instanceof Error ? error.message : "error"}`);
4463
+ return {
4464
+ ok: false,
4465
+ error: error instanceof Error ? error.message : "failed",
4466
+ code: "snapshot_error"
4467
+ };
4468
+ }
4469
+ }
4470
+ };
4471
+ async function buildUnboundSnapshot(projectDir) {
4472
+ const registry = await loadTargetRegistry(projectDir);
4473
+ const targets = [];
4474
+ for (const record of registry.targets) {
4475
+ const run = await tryLoadRun(buildTargetContext(projectDir, record));
4476
+ targets.push({
4477
+ id: record.id,
4478
+ target: record.target,
4479
+ canonicalTarget: record.canonicalTarget,
4480
+ rootSessionId: record.rootSessionId,
4481
+ status: run?.status ?? "unknown",
4482
+ currentStage: run?.currentStage ?? null,
4483
+ createdAt: record.createdAt
4484
+ });
4485
+ }
4486
+ return {
4487
+ ok: true,
4488
+ value: {
4489
+ kind: "unbound",
4490
+ targets
4491
+ }
4492
+ };
4493
+ }
4494
+ //#endregion
4495
+ //#region src/host-manifest.ts
4496
+ /**
4497
+ * host-manifest.ts — Single unified Typert host manifest for dsh-pentester.
4498
+ *
4499
+ * Combines pentesterDocker (Settings UI) and pentesterView (Pentester panel)
4500
+ * invocations into ONE manifest. Two separate register() calls with the same
4501
+ * package name cause the first to be withdrawn (SRC fallback denied).
4502
+ *
4503
+ * Client uses the same invocation constants via PENTESTER_REMOTE and
4504
+ * PENTESTER_VIEW_REMOTE — no duplication of endpoint definitions.
4505
+ */
4506
+ const PENTESTER_HOST_MANIFEST = {
2898
4507
  package: "dsh-pentester",
2899
4508
  face: "host",
2900
4509
  schemas: [],
@@ -2907,26 +4516,35 @@ const TYPERT_MANIFEST = {
2907
4516
  members: [{
2908
4517
  kind: "method",
2909
4518
  name: "command",
2910
- signature: "command(input: object): Promise<{ok:boolean; value?: {dockerHost:string; effective:string; remote:boolean}; error?: string; code?: string}>"
4519
+ signature: "command(input: object): Promise<{ok:boolean; value?:any; error?:string; code?:string}>"
4520
+ }],
4521
+ types: []
4522
+ }, {
4523
+ key: "pentesterView",
4524
+ exportName: "PentesterViewTypertService",
4525
+ description: "Pentester View 数据服务(snapshot / readOutput)。",
4526
+ tags: [],
4527
+ members: [{
4528
+ kind: "method",
4529
+ name: "command",
4530
+ signature: "command(input: {kind:string; ...}): Promise<{ok:boolean; value?:any; error?:string; code?:string}>"
2911
4531
  }],
2912
4532
  types: []
2913
4533
  }],
2914
4534
  events: [],
2915
4535
  objects: []
2916
4536
  },
2917
- invocations: [PENTESTER_DOCKER_INVOCATION]
4537
+ invocations: [PENTESTER_DOCKER_INVOCATION, PENTESTER_VIEW_INVOCATION]
2918
4538
  };
2919
4539
  //#endregion
2920
4540
  //#region src/git.ts
2921
4541
  /**
2922
- * git.ts — GitCheckpointService:workspace git 仓库管理(宿主侧)。
4542
+ * git.ts — GitCheckpointService:target-scoped git 仓库管理(宿主侧)。
2923
4543
  *
2924
- * 仓库根 = <project>/workspace(= 容器 /workspace 的宿主镜像;.git 不随
2925
- * volume 同步,Worker 物理上无法改 Git 状态)。约定式提交:
4544
+ * 仓库根 = targetRoot(每 Target 独立 .git)。
4545
+ * 约定式提交:
2926
4546
  * - 初始:`workspace: initialize <target>`
2927
4547
  * - 阶段完成:`ptes(<NN>-<stage>): complete` + tag `ptes/<NN>-<stage>`
2928
- *
2929
- * git 命令经注入的 runner 执行(生产 = child_process.execFile,测试 = fake)。
2930
4548
  */
2931
4549
  var GitCheckpointError = class extends Error {};
2932
4550
  function realGitRunner() {
@@ -2943,7 +4561,6 @@ function realGitRunner() {
2943
4561
  });
2944
4562
  });
2945
4563
  }
2946
- /** workspace git 仓库管理(幂等 init;阶段完成 checkpoint)。 */
2947
4564
  var GitCheckpointService = class {
2948
4565
  runner;
2949
4566
  userName;
@@ -2955,23 +4572,17 @@ var GitCheckpointService = class {
2955
4572
  this.userEmail = deps.userEmail ?? "dsh-pentester@local";
2956
4573
  this.isRepoOverride = deps.isRepo;
2957
4574
  }
2958
- /** 仓库根目录(<project>/workspace)。 */
2959
- repoDir(projectDir) {
2960
- return workspaceDir(projectDir);
4575
+ repoDir(ctx) {
4576
+ return ctx.targetRoot;
2961
4577
  }
2962
- /** 是否已是 git 仓库(存在 .git)。 */
2963
- async isRepo(projectDir) {
2964
- if (this.isRepoOverride !== void 0) return this.isRepoOverride(projectDir);
2965
- return existsSync(join(this.repoDir(projectDir), ".git"));
4578
+ async isRepo(ctx) {
4579
+ if (this.isRepoOverride !== void 0) return this.isRepoOverride(ctx);
4580
+ return existsSync(join(this.repoDir(ctx), ".git"));
2966
4581
  }
2967
- /**
2968
- * 初始化仓库:`git init -b main` + 本地身份 + 初始提交。
2969
- * 幂等:已是仓库则直接返回。
2970
- */
2971
- async init(projectDir, target) {
2972
- const cwd = this.repoDir(projectDir);
4582
+ async init(ctx, target) {
4583
+ const cwd = this.repoDir(ctx);
2973
4584
  await mkdir(cwd, { recursive: true });
2974
- if (await this.isRepo(projectDir)) return;
4585
+ if (await this.isRepo(ctx)) return;
2975
4586
  await this.runner([
2976
4587
  "init",
2977
4588
  "-b",
@@ -2997,10 +4608,9 @@ var GitCheckpointService = class {
2997
4608
  await this.runner(["add", "-A"], cwd);
2998
4609
  await this.commit(cwd, `workspace: initialize ${target ?? "pentest"}`);
2999
4610
  }
3000
- /** 阶段完成 checkpoint:`git add -A` → commit → tag。返回 tag 名。 */
3001
- async checkpointStage(projectDir, stage) {
3002
- if (!await this.isRepo(projectDir)) throw new GitCheckpointError("workspace is not a git repository; run initWorkspace first");
3003
- const cwd = this.repoDir(projectDir);
4611
+ async checkpointStage(ctx, stage) {
4612
+ if (!await this.isRepo(ctx)) throw new GitCheckpointError("workspace is not a git repository; run initTargetWorkspace first");
4613
+ const cwd = this.repoDir(ctx);
3004
4614
  const dir = stageDir(stage);
3005
4615
  await this.runner(["add", "-A"], cwd);
3006
4616
  await this.commit(cwd, `ptes(${dir}): complete`);
@@ -3012,23 +4622,16 @@ var GitCheckpointService = class {
3012
4622
  ], cwd).then((out) => out.trim().length > 0, () => false)) await this.runner(["tag", tag], cwd);
3013
4623
  return tag;
3014
4624
  }
3015
- /** 当前 git 分支名(rollback / Root state 用)。 */
3016
- async currentBranch(projectDir) {
3017
- const cwd = this.repoDir(projectDir);
4625
+ async currentBranch(ctx) {
4626
+ const cwd = this.repoDir(ctx);
3018
4627
  return (await this.runner(["branch", "--show-current"], cwd).catch(() => "")).trim() || "main";
3019
4628
  }
3020
- /** working tree 是否有未提交变更(rollback 前决定是否建 backup branch)。 */
3021
- async hasChanges(projectDir) {
3022
- const cwd = this.repoDir(projectDir);
4629
+ async hasChanges(ctx) {
4630
+ const cwd = this.repoDir(ctx);
3023
4631
  return (await this.runner(["status", "--porcelain"], cwd).catch(() => "")).trim() !== "";
3024
4632
  }
3025
- /**
3026
- * HEAD 的 first-parent reachable 历史中找目标 stage 的 completion commit
3027
- * (subject `ptes(<NN>-<stage>): complete`)。不假设 tag 属于当前 branch
3028
- * namespace(§31);tag 只是人类可读 ref。找不到返回 undefined。
3029
- */
3030
- async findStageCheckpoint(projectDir, stage) {
3031
- const cwd = this.repoDir(projectDir);
4633
+ async findStageCheckpoint(ctx, stage) {
4634
+ const cwd = this.repoDir(ctx);
3032
4635
  const dir = stageDir(stage);
3033
4636
  const out = await this.runner([
3034
4637
  "log",
@@ -3043,9 +4646,8 @@ var GitCheckpointService = class {
3043
4646
  if (hash !== void 0 && subject === `ptes(${dir}): complete`) return hash;
3044
4647
  }
3045
4648
  }
3046
- /** backup branch:`backup/rollback-<ts>` + WIP commit(只在有变更时调用)。 */
3047
- async createBackupBranch(projectDir, stage) {
3048
- const cwd = this.repoDir(projectDir);
4649
+ async createBackupBranch(ctx, stage) {
4650
+ const cwd = this.repoDir(ctx);
3049
4651
  const branch = `backup/rollback-${Date.now()}`;
3050
4652
  await this.runner([
3051
4653
  "checkout",
@@ -3056,12 +4658,8 @@ var GitCheckpointService = class {
3056
4658
  await this.commit(cwd, `wip: before rollback to ${stage}`);
3057
4659
  return branch;
3058
4660
  }
3059
- /**
3060
- * checkpoint commit 建 rework branch:`rework/<next-stage-slug>-<n>`。
3061
- * n 自动找下一个可用值(已有分支 +1)。
3062
- */
3063
- async createReworkBranch(projectDir, checkpoint, nextStageSlug) {
3064
- const cwd = this.repoDir(projectDir);
4661
+ async createReworkBranch(ctx, checkpoint, nextStageSlug) {
4662
+ const cwd = this.repoDir(ctx);
3065
4663
  const existing = await this.runner(["branch", "--format=%(refname:short)"], cwd).catch(() => "");
3066
4664
  const prefix = `rework/${nextStageSlug}-`;
3067
4665
  let n = 1;
@@ -3078,9 +4676,8 @@ var GitCheckpointService = class {
3078
4676
  ], cwd);
3079
4677
  return branch;
3080
4678
  }
3081
- /** checkout 到已有分支 / commit(rollback 收尾)。 */
3082
- async checkout(projectDir, ref) {
3083
- const cwd = this.repoDir(projectDir);
4679
+ async checkout(ctx, ref) {
4680
+ const cwd = this.repoDir(ctx);
3084
4681
  await this.runner(["checkout", ref], cwd);
3085
4682
  }
3086
4683
  async commit(cwd, message) {
@@ -3101,15 +4698,44 @@ var GitCheckpointService = class {
3101
4698
  * index.ts — cordis plugin entry (contract per docs/architecture.md):
3102
4699
  * publish the pentester preset, then wire the Root tools over the
3103
4700
  * DSH agents seam。Workers 的工具面 restrict 到 pentester_container_exec。
4701
+ *
4702
+ * RPC endpoints (pentesterDocker, pentesterView) are registered in the
4703
+ * main fiber — NOT a child plugin. Their lifetime equals the dsh-pentester
4704
+ * plugin lifetime. sessions/workspaceRegistry are resolved dynamically
4705
+ * via ctx.get() on each command invocation, so their lifecycle does not
4706
+ * affect RPC endpoint registration.
3104
4707
  */
3105
4708
  const name = "dsh-pentester";
3106
- const inject = ["tools", "subagents"];
4709
+ const inject = [
4710
+ "tools",
4711
+ "subagents",
4712
+ "typert"
4713
+ ];
3107
4714
  const Config = Schema.object({
3108
4715
  verbose: Schema.boolean().default(false),
3109
4716
  dockerHost: Schema.string().default(""),
3110
4717
  toolboxImages: Schema.object({}).default({})
3111
4718
  });
3112
4719
  async function apply(ctx, config) {
4720
+ console.log(`[dsh-pentester] host build=${BUILD_ID}`);
4721
+ const perf = config.verbose || typeof process !== "undefined" && process.env?.DSH_PENTESTER_PERF === "1";
4722
+ if (perf) {
4723
+ const INTERVAL = 100;
4724
+ let expected = performance.now() + INTERVAL;
4725
+ setInterval(() => {
4726
+ const now = performance.now();
4727
+ const lag = now - expected;
4728
+ if (lag > 250) console.warn(`[HOST_EVENT_LOOP_LAG] ${lag.toFixed(0)}ms (expected interval=${INTERVAL}ms)`);
4729
+ expected = now + INTERVAL;
4730
+ }, INTERVAL).unref();
4731
+ console.log("[dsh-pentester] event loop stall detector active (interval=100ms, warn>250ms)");
4732
+ }
4733
+ if (perf) try {
4734
+ const typertLocal = ctx.typert?.local;
4735
+ if (typertLocal?.subscribe !== void 0) typertLocal.subscribe((event) => {
4736
+ if (event.key === "pentesterView/command") console.log(`[PENTESTER_RPC_LIFECYCLE] pentesterView/command ${event.kind}`);
4737
+ });
4738
+ } catch {}
3113
4739
  const profiles = loadAgentProfiles(agentsRoot());
3114
4740
  const presetDir = await publishPentesterPreset();
3115
4741
  if (presetDir !== void 0) await writeStageProfilesManifest(presetDir, profiles);
@@ -3126,35 +4752,62 @@ async function apply(ctx, config) {
3126
4752
  delegations,
3127
4753
  profiles,
3128
4754
  git,
3129
- pushWorkspace: async (projectDir, run) => {
3130
- await docker.pushWorkspace(run.id, projectDir);
4755
+ pushWorkspace: async (targetCtx, run) => {
4756
+ await docker.pushWorkspace(run.id, targetCtx);
3131
4757
  },
3132
- syncWorkspace: async (projectDir, run) => {
3133
- await docker.syncWorkspace(run.id, projectDir);
3134
- }
4758
+ syncWorkspace: async (targetCtx, run) => {
4759
+ await docker.syncWorkspace(run.id, targetCtx);
4760
+ },
4761
+ sessionQuery: { readSession: async (sessionId) => {
4762
+ const sq = ctx.get?.("sessionQuery");
4763
+ if (sq === void 0) throw new Error("sessionQuery service unavailable");
4764
+ return sq.readSession(sessionId);
4765
+ } }
3135
4766
  }),
3136
4767
  registerContainerExecTool: (tools) => registerContainerExecTool(tools, docker)
3137
4768
  });
3138
- ctx.inject(["typert"], (inner) => {
3139
- new PentesterDockerTypertService(inner, {
3140
- getSettings: () => ensureSettings(),
3141
- getConfigHost: () => config.dockerHost ?? "",
3142
- saveDockerHost: async (dockerHost) => {
3143
- const next = {
3144
- ...await ensureSettings(),
3145
- dockerHost
3146
- };
3147
- await saveSettings(next);
3148
- return next;
3149
- },
3150
- saveSettings: async (next) => {
3151
- await saveSettings(next);
3152
- },
3153
- docker,
3154
- applyHost: (host) => docker.updateHost(host)
3155
- });
3156
- inner.typert.register(TYPERT_MANIFEST);
4769
+ ctx.typert.register(PENTESTER_HOST_MANIFEST);
4770
+ new PentesterDockerTypertService(ctx, {
4771
+ getSettings: () => ensureSettings(),
4772
+ getConfigHost: () => config.dockerHost ?? "",
4773
+ saveDockerHost: async (dockerHost) => {
4774
+ const next = {
4775
+ ...await ensureSettings(),
4776
+ dockerHost
4777
+ };
4778
+ await saveSettings(next);
4779
+ return next;
4780
+ },
4781
+ saveSettings: async (next) => {
4782
+ await saveSettings(next);
4783
+ },
4784
+ applyHost: (host) => docker.updateHost(host),
4785
+ docker
3157
4786
  });
4787
+ new PentesterViewTypertService(ctx, {
4788
+ getLiveSessionCwd(sessionId) {
4789
+ try {
4790
+ const cwd = ((ctx.get?.("sessions"))?.get?.(sessionId))?.header?.cwd;
4791
+ if (typeof cwd === "string" && cwd.length > 0 && cwd.startsWith("/")) return cwd;
4792
+ } catch {}
4793
+ },
4794
+ getWorkspaceForSession(sessionId) {
4795
+ try {
4796
+ const registry = ctx.get?.("workspaceRegistry");
4797
+ if (registry?.list === void 0) return void 0;
4798
+ for (const workspace of registry.list()) if (workspace.sessionIds.includes(sessionId)) return workspace.path;
4799
+ } catch {}
4800
+ },
4801
+ verbose: config.verbose
4802
+ });
4803
+ const typertLocal = ctx.typert.local;
4804
+ const dockerEndpoint = typertLocal.get("pentesterDocker/command");
4805
+ const viewEndpoint = typertLocal.get("pentesterView/command");
4806
+ if (dockerEndpoint === void 0 || viewEndpoint === void 0) throw new Error(`dsh-pentester startup failed: RPC descriptors not registered. docker=${dockerEndpoint !== void 0 ? "ok" : "MISSING"} view=${viewEndpoint !== void 0 ? "ok" : "MISSING"}`);
4807
+ console.log(`[dsh-pentester] RPC ready build=${BUILD_ID}`);
4808
+ console.log(` pentesterDocker/command = registered`);
4809
+ console.log(` pentesterView/command = registered`);
4810
+ console.log(` host module = ${import.meta.url}`);
3158
4811
  if (config.verbose) console.log(`[dsh-pentester] loaded with ${profiles.size} agent profiles`);
3159
4812
  }
3160
4813
  function agentsRoot() {
@@ -3162,13 +4815,7 @@ function agentsRoot() {
3162
4815
  for (const candidate of [join(here, "..", "agents"), join(process.cwd(), "agents")]) if (existsSync(candidate)) return candidate;
3163
4816
  return join(process.cwd(), "agents");
3164
4817
  }
3165
- /** run-state.mjs 读取的 stage → AgentProfile 清单文件名(与 preset 同目录发布)。 */
3166
4818
  const STAGE_PROFILES_FILE = "stage-profiles.json";
3167
- /**
3168
- * 生成并发布 stage → [{id, name}] 清单(docs/plan.md §7:Root compact state
3169
- * 的 Available AgentProfiles 从 STAGE_DEFINITIONS + AgentProfile registry 生成,
3170
- * 不硬编码第二套映射)。随 preset 目录发布,run-state.mjs 同目录读取。
3171
- */
3172
4819
  async function writeStageProfilesManifest(presetDir, profiles) {
3173
4820
  const manifest = Object.fromEntries(STAGE_DEFINITIONS.map((definition) => [definition.id, definition.agentIds.map((id) => profiles.get(id)).filter((profile) => profile !== void 0).map((profile) => ({
3174
4821
  id: profile.id,
@@ -3176,7 +4823,6 @@ async function writeStageProfilesManifest(presetDir, profiles) {
3176
4823
  }))]));
3177
4824
  await writeFile(join(presetDir, STAGE_PROFILES_FILE), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
3178
4825
  }
3179
- /** 把随包发布的 pentester preset 拷贝到 DSH 用户 preset 根目录。 */
3180
4826
  async function publishPentesterPreset(home) {
3181
4827
  const source = shippedPresetDir();
3182
4828
  if (source === void 0) return void 0;