@twinsearth/w2m-dsh-plugin 1.9.16 → 1.9.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/README.md +3 -3
  5. package/bin/w2m-joint.mjs +36 -5
  6. package/bin/w2m-scrub.mjs +78 -0
  7. package/codex/mcp-servers.toml +1 -1
  8. package/docs/acceptance/1.9.17/RELEASE-1917.json +20 -0
  9. package/docs/acceptance/1.9.19/RELEASE-1919.json +21 -0
  10. package/docs/acceptance/2.0.0-beta.1/RELEASE-200B1.json +23 -0
  11. package/docs/acceptance-ops/2026-10-28/AG-SMOKE.json +24 -0
  12. package/docs/acceptance-ops/2026-10-28/G0-3-106.53.197.47.json +35 -0
  13. package/docs/acceptance-ops/2026-10-28/G0-3-202.182.123.154.json +35 -0
  14. package/docs/acceptance-ops/2026-10-28/G0-3-43.142.74.71.json +35 -0
  15. package/docs/acceptance-ops/2026-10-28/R1-OPERATOR-TOKEN.json +19 -0
  16. package/docs/acceptance-ops/2026-10-28/R3-CAPABILITIES.json +51 -0
  17. package/docs/acceptance-ops/2026-10-28/R8-LEDGER-JOURNAL.json +21 -0
  18. package/lib/client.js +474 -368
  19. package/package.json +4 -3
  20. package/plugin.json +1 -1
  21. package/src/agent/agent.mjs +6 -1
  22. package/src/agent/sinks/dir-sink.mjs +59 -13
  23. package/src/plugin/fleet-chat-route.mjs +7 -1
  24. package/src/plugin/joint-dev/audit-file.mjs +70 -37
  25. package/src/plugin/joint-dev/merge-engine.mjs +102 -0
  26. package/src/plugin/joint-dev/plan-store.mjs +117 -32
  27. package/src/plugin/joint-dev/relay-dispatch.mjs +171 -0
  28. package/src/plugin/operator-key.mjs +73 -0
  29. package/src/plugin/operator-token-route.mjs +71 -0
  30. package/src/plugin/tools-joint.mjs +97 -0
  31. package/src/plugin/tools.mjs +8 -3
  32. package/src/relay/fleet-messages.mjs +22 -0
  33. package/src/relay/offer-journal.mjs +71 -0
  34. package/src/relay/p0-projection.mjs +10 -0
  35. package/src/relay/report.mjs +40 -2
  36. package/src/relay/scrub.mjs +94 -81
  37. package/src/relay/server.mjs +2043 -2038
  38. package/src/relay/state.mjs +3351 -3300
  39. package/src/util/artifact-ref.mjs +88 -0
  40. package/src/util/redline-core.mjs +2 -1
@@ -191,6 +191,8 @@ import {
191
191
  import { registerChatRoutes } from './fleet-chat-route.mjs';
192
192
  import { registerExecCapabilityRoutes } from './exec-capability-route.mjs';
193
193
  import { registerPluginUpdateRoutes } from './update-route.mjs';
194
+ import { registerOperatorTokenRoute } from './operator-token-route.mjs';
195
+ import { registerJointTools } from './tools-joint.mjs';
194
196
  import { fingerprintOf, readOperatorToken } from './operator-token.mjs';
195
197
  import { registerCaptureRoutes } from './capture-route.mjs';
196
198
  import { defaultExecCapabilityFile } from '../util/exec-capability.mjs';
@@ -284,7 +286,7 @@ const MAX_STAGES = 16;
284
286
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
285
287
  * overrides it at runtime for tests and packagers.
286
288
  */
287
- const PLUGIN_VERSION = '1.9.16';
289
+ const PLUGIN_VERSION = '1.9.19';
288
290
 
289
291
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
290
292
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -3583,6 +3585,7 @@ export async function apply(ctx, config = {}) {
3583
3585
  // v1.6.8: the plugin panel's "更新" button: GET checks npm latest, POST installs through the
3584
3586
  // verified updater (SHA-256 checked, old package backed up, restart required afterwards).
3585
3587
  const _updateRoutes = registerPluginUpdateRoutes(ctx, { profileDir: () => cfg.profileDir });
3588
+ const _operatorTokenRoute = registerOperatorTokenRoute(ctx, { configuredToken: () => cfg.operatorToken });
3586
3589
 
3587
3590
  // v1.7.1: host-side screenshot + clipboard fallbacks. The DSH desktop shell does not grant the
3588
3591
  // renderer getDisplayMedia/navigator.clipboard, so the chat panel needs a host route that does not
@@ -3625,6 +3628,8 @@ export async function apply(ctx, config = {}) {
3625
3628
  // -------------------------------------------------------------------------------------------
3626
3629
  // 1. w2m_device - read-only
3627
3630
  // -------------------------------------------------------------------------------------------
3631
+ registerJointTools(ctx, { defineTool, stateDir: cfg.stateDir, machineName: cfg.machineName, output: jsonOutput });
3632
+
3628
3633
  ctx.tools.register(defineTool({
3629
3634
  name: 'w2m_devices',
3630
3635
  description:
@@ -4895,8 +4900,8 @@ const result = await request({
4895
4900
  http_status: probe.status,
4896
4901
  protocol_version: health.protocol_version ?? null,
4897
4902
  error: probe.ok ? null : (probe.error?.code ?? `HTTP ${probe.status}`),
4898
- relay_id: health.relay_id ?? null,
4899
- relay_version: health.relay_version ?? health.version ?? null,
4903
+ relay_id: health.relay_id ?? null,
4904
+ relay_version: health.relay_version ?? health.version ?? null,
4900
4905
  features: Array.isArray(health.features) ? health.features : null,
4901
4906
  uptime_ms: typeof health.uptime_ms === 'number' ? health.uptime_ms : null,
4902
4907
  started_at: health.started_at ?? null,
@@ -101,6 +101,10 @@ export function createFleetMessages(options = {}) {
101
101
  if (sessionId === '') return { ok: false, code: 'BAD_SESSION' };
102
102
  const machineId = typeof input?.machine_id === 'string' ? input.machine_id.trim() : '';
103
103
  if (machineId === '') return { ok: false, code: 'BAD_MACHINE' };
104
+ // v1.9.18: chat carries text and slash-command text only; images never travel over the relay.
105
+ const MEDIA_FIELDS = ['image', 'image_url', 'image_base64', 'image_bytes', 'picture', 'photo', 'thumb', 'thumbnail', 'attachment', 'attachments', 'file', 'files', 'data', 'base64', 'blob', 'binary', 'buffer', 'media', 'sticker_image', 'sticker_data', 'gif', 'png', 'jpeg', 'webp'];
106
+ const mediaField = Object.keys(input ?? {}).find((key) => MEDIA_FIELDS.includes(key.toLowerCase()));
107
+ if (mediaField !== undefined) return { ok: false, code: 'CHAT_FIELD_FORBIDDEN', detail: { field: mediaField } };
104
108
  if (typeof input?.text !== 'string') return { ok: false, code: 'NOT_TEXT' };
105
109
  const text = input.text;
106
110
  if (text.trim() === '') return { ok: false, code: 'EMPTY_TEXT' };
@@ -130,6 +134,24 @@ export function createFleetMessages(options = {}) {
130
134
  enc = { v: 1, alg: 'aes-256-gcm', iv: rawEnc.iv, tag: rawEnc.tag };
131
135
  }
132
136
 
137
+ // v1.9.18: a plain-text message may not smuggle an image payload. Encrypted text is exempt:
138
+ // it is base64 ciphertext by design and the relay cannot (and must not) inspect it.
139
+ if (enc === null) {
140
+ if (/data:(?:image|video|audio)\//i.test(text) || /!\[[^\]]*\]\(/i.test(text)) {
141
+ return { ok: false, code: 'TEXT_IMAGE_FORBIDDEN' };
142
+ }
143
+ const block = text.match(/[A-Za-z0-9+/]{256,}={0,2}/);
144
+ if (block !== null) {
145
+ try {
146
+ const head = Buffer.from(block[0].slice(0, 32), 'base64');
147
+ const magic = head.slice(0, 4).toString('latin1');
148
+ if ((head[0] === 0x89 && head.slice(1, 4).toString('latin1') === 'PNG') || (head[0] === 0xff && head[1] === 0xd8) || magic.startsWith('GIF8') || magic.startsWith('RIFF')) {
149
+ return { ok: false, code: 'TEXT_BINARY_FORBIDDEN' };
150
+ }
151
+ } catch { /* not base64 that matters */ }
152
+ }
153
+ }
154
+
133
155
  // v1.2.5 idempotency: replay the first message instead of storing the text twice.
134
156
  const idempotencyKey = typeof input?.idempotency_key === 'string' && input.idempotency_key.trim() !== ''
135
157
  ? input.idempotency_key.trim().slice(0, 64)
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Offer journal (v2 R8): the command an in-flight task will be offered with lives HERE, not in the
3
+ * ledger.
4
+ *
5
+ * The ledger must never carry `command_argv` (it is the relay's durable, copyable record), yet a
6
+ * relay restart still needs the command to re-offer unclaimed work. The journal is that narrow,
7
+ * short-lived control-plane store: one 0600 file per task, written before the task is announced,
8
+ * removed when the task settles or is cancelled. It is not an artifact and carries no output.
9
+ */
10
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ const journalDir = (stateDir) => join(stateDir, 'offers');
14
+ const journalPath = (stateDir, taskId) => join(journalDir(stateDir), String(taskId).replace(/[^A-Za-z0-9._-]/g, '_') + '.json');
15
+
16
+ export function writeOfferJournal(stateDir, taskId, payload) {
17
+ if (typeof stateDir !== 'string' || stateDir === '' || typeof taskId !== 'string' || taskId === '') {
18
+ return { ok: false, code: 'JOURNAL_INPUT_INVALID' };
19
+ }
20
+ mkdirSync(journalDir(stateDir), { recursive: true });
21
+ const path = journalPath(stateDir, taskId);
22
+ const tmp = path + '.tmp-' + process.pid;
23
+ const text = JSON.stringify({
24
+ v: 1,
25
+ task_id: taskId,
26
+ argv: Array.isArray(payload?.argv) ? payload.argv : [],
27
+ stages: Array.isArray(payload?.stages) ? payload.stages : null,
28
+ reduce_argv: Array.isArray(payload?.reduce_argv) ? payload.reduce_argv : null,
29
+ written_at: new Date().toISOString(),
30
+ });
31
+ const fd = openSync(tmp, 'w', 0o600);
32
+ try {
33
+ writeSync(fd, text);
34
+ fsyncSync(fd);
35
+ } finally {
36
+ closeSync(fd);
37
+ }
38
+ renameSync(tmp, path);
39
+ return { ok: true, path };
40
+ }
41
+
42
+ export function readOfferJournal(stateDir, taskId) {
43
+ const path = journalPath(stateDir, taskId);
44
+ if (!existsSync(path)) return null;
45
+ try {
46
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
47
+ if (parsed?.task_id !== taskId) return null;
48
+ return parsed;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ export function deleteOfferJournal(stateDir, taskId) {
55
+ try { unlinkSync(journalPath(stateDir, taskId)); return true; } catch { return false; }
56
+ }
57
+
58
+ /** Remove journals whose task no longer exists (settled, cancelled, or lost). */
59
+ export function pruneOfferJournals(stateDir, liveTaskIds) {
60
+ const dir = journalDir(stateDir);
61
+ if (!existsSync(dir)) return 0;
62
+ const live = liveTaskIds instanceof Set ? liveTaskIds : new Set(liveTaskIds ?? []);
63
+ let removed = 0;
64
+ for (const name of readdirSync(dir)) {
65
+ if (!name.endsWith('.json')) continue;
66
+ const taskId = name.slice(0, -5);
67
+ if (live.has(taskId)) continue;
68
+ try { unlinkSync(join(dir, name)); removed += 1; } catch { /* best effort */ }
69
+ }
70
+ return removed;
71
+ }
@@ -81,6 +81,16 @@ export function projectLedgerLine(line) {
81
81
  try { record = JSON.parse(line); } catch { return { changed: false, line }; }
82
82
  // Real ledger lines nest the envelope one level down: {type:'result.stored', result:{envelope:{...}}}.
83
83
  // Handle that shape first, then the flat result, then a bare envelope record.
84
+ // v2 R8: a legacy task record still carries the command itself. Project it to hash + length,
85
+ // so the scrub can bring old ledgers in line with "persist only metadata".
86
+ if (record?.type === 'task.created' && record.task && typeof record.task === 'object') {
87
+ const task = record.task;
88
+ if (!Array.isArray(task.command_argv)) return { changed: false, line };
89
+ task.command_argv_len = task.command_argv.length;
90
+ delete task.command_argv;
91
+ record.projection = { dropped: ['task.command_argv'], at: new Date().toISOString() };
92
+ return { changed: true, line: JSON.stringify(record) };
93
+ }
84
94
  let holder = null;
85
95
  if (record?.result && typeof record.result === 'object' && record.result.envelope && typeof record.result.envelope === 'object') holder = { parent: record.result, key: 'envelope' };
86
96
  else if (record?.result && typeof record.result === 'object') holder = { parent: record, key: 'result' };
@@ -524,6 +524,41 @@ export function aggregate(task, resultRecords = [], opts = {}) {
524
524
  }
525
525
 
526
526
  /** Convenience wrapper: pull the task + its results out of a RabbitState. */
527
+ /** S3: a read-only progress view. phase/progress are live-only (null after a restart). */
528
+ export function taskProgress(state, taskId, opts = {}) {
529
+ const task = state.getTask(taskId);
530
+ if (!task) return null;
531
+ const aggregate = aggregateTask(state, taskId, opts);
532
+ const summary = state.taskSummary(task);
533
+ const machines = aggregate.machines.map((machine) => {
534
+ const lease = task.leases.get(machine.machine_id) ?? null;
535
+ return {
536
+ machine_id: machine.machine_id,
537
+ lease_state: machine.lease_state,
538
+ outcome: machine.outcome,
539
+ exit_code: machine.exit_code ?? null,
540
+ phase: lease?.phase ?? null,
541
+ progress: typeof lease?.progress === 'number' ? lease.progress : null,
542
+ last_heartbeat_at: Number.isFinite(lease?.last_heartbeat_ms) ? rfc3339(lease.last_heartbeat_ms) : null,
543
+ lease_until: Number.isFinite(lease?.lease_until_ms) ? rfc3339(lease.lease_until_ms) : null,
544
+ };
545
+ });
546
+ return {
547
+ task_id: taskId,
548
+ mode: task.mode,
549
+ plan_sha256: task.plan_sha256 ?? null,
550
+ approval_sha256: task.approval_sha256 ?? null,
551
+ step_id: task.step_id ?? null,
552
+ command_hash: aggregate.expected?.command_hash ?? task.command_hash ?? null,
553
+ command_argv_len: Array.isArray(task.command_argv) ? task.command_argv.length : 0,
554
+ status: summary.status,
555
+ verdict: aggregate.status,
556
+ terminal: aggregate.status !== 'pending',
557
+ counts: aggregate.counts,
558
+ waiting_on: summary.waiting_on,
559
+ machines,
560
+ };
561
+ }
527
562
  export function aggregateTask(state, taskId, opts = {}) {
528
563
  const task = state.getTask(taskId);
529
564
  if (!task) throw new ProtocolError('NOT_FOUND', 'unknown task_id', { task_id: taskId });
@@ -614,10 +649,13 @@ export function renderReportMarkdown(agg, task = null) {
614
649
  L.push('');
615
650
  }
616
651
  if (task) {
617
- L.push('## 原始命令');
652
+ // R8: the markdown report is readable by any paired device token, so it must never carry the
653
+ // command itself. The hash is the command identity; the length is the only shape we expose.
654
+ L.push('## 命令指纹');
618
655
  L.push('');
619
656
  L.push('```');
620
- L.push(jcs(task.command_argv));
657
+ L.push('command_hash: ' + (typeof task.command_hash === 'string' ? task.command_hash : '-'));
658
+ L.push('command_argv_len: ' + (Array.isArray(task.command_argv) ? task.command_argv.length : '-'));
621
659
  L.push('```');
622
660
  L.push('');
623
661
  }
@@ -1,81 +1,94 @@
1
- /**
2
- * relay 状态清理(P0):旧 blob 清除 + ledger 内容投影重建。
3
- * 只处理已落盘的数据,不联网;dry-run 先报清单,apply 先备份再原子替换。
4
- */
5
- import {
6
- copyFileSync, existsSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync,
7
- } from 'node:fs';
8
- import { createHash } from 'node:crypto';
9
- import { join } from 'node:path';
10
-
11
- import { projectLedgerLine } from './p0-projection.mjs';
12
-
13
- export const LEDGER_CONTENT_FIELDS = ['stdout_head', 'stdout_tail', 'stderr_head', 'stderr_tail', 'command_argv', 'pipeline_hash_material', 'changed_files'];
14
- const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
15
-
16
-
17
- /** 清理 ledger:默认 dry-run;apply 时备份 .bak-<ts> 后原子替换。 */
18
- export function scrubLedger(ledgerPath, { dryRun = true, now = Date.now() } = {}) {
19
- if (!existsSync(ledgerPath)) return { ok: true, exists: false, changed: 0, lines: 0, backup: null };
20
- const text = readFileSync(ledgerPath, 'utf8');
21
- const lines = text.split('\n').filter((line) => line.trim() !== '');
22
- let changed = 0;
23
- const rewritten = lines.map((line) => {
24
- const projected = projectLedgerLine(line);
25
- if (projected.changed) changed += 1;
26
- return projected.line;
27
- });
28
- if (dryRun || changed === 0) return { ok: true, exists: true, changed, lines: lines.length, backup: null, dryRun };
29
- const backup = ledgerPath + '.bak-' + now;
30
- copyFileSync(ledgerPath, backup);
31
- const tmp = ledgerPath + '.tmp-' + process.pid;
32
- writeFileSync(tmp, rewritten.join('\n') + '\n', 'utf8');
33
- renameSync(tmp, ledgerPath);
34
- return { ok: true, exists: true, changed, lines: lines.length, backup, dryRun: false };
35
- }
36
-
37
- /** 清点/删除 relay blob 目录(内容寻址文件)。 */
38
- export function scrubBlobs(blobDir, { dryRun = true, now = Date.now() } = {}) {
39
- if (!existsSync(blobDir)) return { ok: true, exists: false, files: [], bytes: 0, deleted: 0, manifest: null };
40
- const files = [];
41
- let bytes = 0;
42
- for (const name of readdirSync(blobDir)) {
43
- const path = join(blobDir, name);
44
- if (!statSync(path).isFile()) continue;
45
- files.push(name);
46
- bytes += statSync(path).size;
47
- }
48
- const manifest = {
49
- at: new Date(now).toISOString(),
50
- files: files.map((name) => ({ name, sha256: sha256(readFileSync(join(blobDir, name))) })),
51
- };
52
- if (dryRun) return { ok: true, exists: true, files, bytes, deleted: 0, manifest, dryRun };
53
- for (const name of files) unlinkSync(join(blobDir, name));
54
- return { ok: true, exists: true, files, bytes, deleted: files.length, manifest, dryRun: false };
55
- }
56
-
57
- /** 验收:扫描目录中是否仍存在内容字段/数据面 marker。 */
58
- export function scanForContent(rootDir, markers = []) {
59
- const hits = [];
60
- const walk = (dir) => {
61
- for (const name of readdirSync(dir)) {
62
- // Backups/temporaries are not live state: they hold the bytes the scrub just moved out.
63
- if (/\.bak-|\.tmp-|\.corrupt-/.test(name)) continue;
64
- const path = join(dir, name);
65
- const stat = statSync(path);
66
- if (stat.isDirectory()) { walk(path); continue; }
67
- if (stat.size > 32 * 1024 * 1024) continue;
68
- // The projection metadata names the dropped fields; scanning it for those names would report
69
- // the scrub itself as a leak, so it is removed before the marker check.
70
- const text = readFileSync(path, 'utf8').replace(/"projection"\s*:\s*\{[^{}]*\}/g, '');
71
- for (const field of LEDGER_CONTENT_FIELDS) {
72
- if (text.includes('"' + field + '"')) hits.push({ file: path, marker: field });
73
- }
74
- for (const marker of markers) {
75
- if (marker !== '' && text.includes(marker)) hits.push({ file: path, marker });
76
- }
77
- }
78
- };
79
- if (existsSync(rootDir)) walk(rootDir);
80
- return { ok: hits.length === 0, hits };
81
- }
1
+ /**
2
+ * relay 状态清理(P0):旧 blob 清除 + ledger 内容投影重建。
3
+ * 只处理已落盘的数据,不联网;dry-run 先报清单,apply 先备份再原子替换。
4
+ */
5
+ import {
6
+ copyFileSync, existsSync, readdirSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync,
7
+ } from 'node:fs';
8
+ import { createHash } from 'node:crypto';
9
+ import { join } from 'node:path';
10
+
11
+ import { projectLedgerLine } from './p0-projection.mjs';
12
+
13
+ export const LEDGER_CONTENT_FIELDS = ['stdout_head', 'stdout_tail', 'stderr_head', 'stderr_tail', 'command_argv', 'pipeline_hash_material', 'changed_files'];
14
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
15
+
16
+
17
+ /** 清理 ledger:默认 dry-run;apply 时备份 .bak-<ts> 后原子替换。 */
18
+ export function scrubLedger(ledgerPath, { dryRun = true, now = Date.now() } = {}) {
19
+ if (!existsSync(ledgerPath)) return { ok: true, exists: false, changed: 0, lines: 0, backup: null };
20
+ const text = readFileSync(ledgerPath, 'utf8');
21
+ const lines = text.split('\n').filter((line) => line.trim() !== '');
22
+ let changed = 0;
23
+ const rewritten = lines.map((line) => {
24
+ const projected = projectLedgerLine(line);
25
+ if (projected.changed) changed += 1;
26
+ return projected.line;
27
+ });
28
+ if (dryRun || changed === 0) return { ok: true, exists: true, changed, lines: lines.length, backup: null, dryRun };
29
+ const backup = ledgerPath + '.bak-' + now;
30
+ copyFileSync(ledgerPath, backup);
31
+ const tmp = ledgerPath + '.tmp-' + process.pid;
32
+ writeFileSync(tmp, rewritten.join('\n') + '\n', 'utf8');
33
+ renameSync(tmp, ledgerPath);
34
+ return { ok: true, exists: true, changed, lines: lines.length, backup, dryRun: false };
35
+ }
36
+
37
+ /** 清点/删除 relay blob 目录(内容寻址;真实布局是 blobs/<aa>/<sha>,递归处理)。 */
38
+ export function scrubBlobs(blobDir, { dryRun = true, now = Date.now() } = {}) {
39
+ if (!existsSync(blobDir)) return { ok: true, exists: false, files: [], bytes: 0, deleted: 0, manifest: null };
40
+ const files = [];
41
+ let bytes = 0;
42
+ const walk = (dir, rel) => {
43
+ for (const name of readdirSync(dir)) {
44
+ const path = join(dir, name);
45
+ const stat = statSync(path);
46
+ const relPath = rel === '' ? name : rel + '/' + name;
47
+ if (stat.isDirectory()) { walk(path, relPath); continue; }
48
+ files.push(relPath);
49
+ bytes += stat.size;
50
+ }
51
+ };
52
+ walk(blobDir, '');
53
+ const manifest = {
54
+ at: new Date(now).toISOString(),
55
+ files: files.map((name) => ({ name, sha256: sha256(readFileSync(join(blobDir, name.split('/').join('/')))) })),
56
+ };
57
+ if (dryRun) return { ok: true, exists: true, files, bytes, deleted: 0, manifest, dryRun };
58
+ for (const name of files) unlinkSync(join(blobDir, name));
59
+ // Remove the empty sharding directories the files lived in, but never the blob root itself.
60
+ const prune = (dir) => {
61
+ for (const name of readdirSync(dir)) {
62
+ const path = join(dir, name);
63
+ if (statSync(path).isDirectory()) { prune(path); try { rmdirSync(path); } catch { /* not empty */ } }
64
+ }
65
+ };
66
+ prune(blobDir);
67
+ return { ok: true, exists: true, files, bytes, deleted: files.length, manifest, dryRun: false };
68
+ }
69
+
70
+ /** 验收:扫描目录中是否仍存在内容字段/数据面 marker。 */
71
+ export function scanForContent(rootDir, markers = []) {
72
+ const hits = [];
73
+ const walk = (dir) => {
74
+ for (const name of readdirSync(dir)) {
75
+ // Backups/temporaries are not live state: they hold the bytes the scrub just moved out.
76
+ if (/\.bak-|\.tmp-|\.corrupt-/.test(name)) continue;
77
+ const path = join(dir, name);
78
+ const stat = statSync(path);
79
+ if (stat.isDirectory()) { walk(path); continue; }
80
+ if (stat.size > 32 * 1024 * 1024) continue;
81
+ // The projection metadata names the dropped fields; scanning it for those names would report
82
+ // the scrub itself as a leak, so it is removed before the marker check.
83
+ const text = readFileSync(path, 'utf8').replace(/"projection"\s*:\s*\{[^{}]*\}/g, '');
84
+ for (const field of LEDGER_CONTENT_FIELDS) {
85
+ if (text.includes('"' + field + '"')) hits.push({ file: path, marker: field });
86
+ }
87
+ for (const marker of markers) {
88
+ if (marker !== '' && text.includes(marker)) hits.push({ file: path, marker });
89
+ }
90
+ }
91
+ };
92
+ if (existsSync(rootDir)) walk(rootDir);
93
+ return { ok: hits.length === 0, hits };
94
+ }