@twinsearth/w2m-dsh-plugin 1.9.11 → 1.9.12

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/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "1.9.11",
4
+ "version": "1.9.12",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -1,46 +1,108 @@
1
1
  /**
2
2
  * joint-dev 计划持久化与人工审批门(本地文件,0600)。
3
- * 未审批的计划禁止 dispatch;审批绑定 plan_sha256 与候选人。
3
+ *
4
+ * 安全不变量(v2 预置):
5
+ * · plan_sha256 必须由本模块重算,与文件内容不符即 PLAN_TAMPERED;
6
+ * · planSha 必须是 64 位小写十六进制,禁止路径穿越;
7
+ * · 审批绑定 plan_sha256 + approver + authority + 过期时间,approval_sha256 读回时重算;
8
+ * · 未审批的计划禁止 dispatch(assertDispatchAllowed 是唯一入口)。
9
+ *
10
+ * 审批只由操作员 CLI/UI 写入,不暴露为 agent 工具。
4
11
  */
5
12
  import { createHash } from 'node:crypto';
6
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
7
14
  import { join } from 'node:path';
8
15
 
9
16
  const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
17
+ const PLAN_SHA = /^[0-9a-f]{64}$/;
18
+ const DEFAULT_APPROVAL_TTL_MS = 24 * 60 * 60 * 1000;
19
+
10
20
  const planDir = (stateDir) => join(stateDir, 'joint-dev', 'plans');
11
21
  const approvalDir = (stateDir) => join(stateDir, 'joint-dev', 'approvals');
12
22
 
23
+ /** Hash of the plan body WITHOUT plan_sha256 — the same rule buildPlan uses. */
24
+ export function planHashOf(plan) {
25
+ if (plan === null || typeof plan !== 'object') return null;
26
+ const { plan_sha256: _ignored, ...canonical } = plan;
27
+ return sha256(JSON.stringify(canonical));
28
+ }
29
+
30
+ function readPlanFile(stateDir, planSha) {
31
+ if (!PLAN_SHA.test(String(planSha ?? ''))) return { ok: false, code: 'PLAN_SHA_INVALID' };
32
+ const path = join(planDir(stateDir), planSha + '.json');
33
+ if (!existsSync(path)) return { ok: false, code: 'PLAN_NOT_FOUND' };
34
+ let parsed;
35
+ try { parsed = JSON.parse(readFileSync(path, 'utf8')); } catch { return { ok: false, code: 'PLAN_UNREADABLE' }; }
36
+ const plan = parsed?.plan;
37
+ if (plan === null || typeof plan !== 'object') return { ok: false, code: 'PLAN_INVALID' };
38
+ const recomputed = planHashOf(plan);
39
+ if (recomputed !== plan.plan_sha256 || recomputed !== planSha) {
40
+ return { ok: false, code: 'PLAN_TAMPERED', detail: { declared: plan.plan_sha256, recomputed, path } };
41
+ }
42
+ return { ok: true, plan, path, saved_at: parsed.saved_at ?? null };
43
+ }
44
+
13
45
  export function savePlan({ stateDir, plan, now = Date.now() }) {
14
- if (typeof plan?.plan_sha256 !== 'string' || plan.plan_sha256.length !== 64) return { ok: false, code: 'PLAN_INVALID' };
46
+ if (plan === null || typeof plan !== 'object' || !PLAN_SHA.test(String(plan.plan_sha256 ?? ''))) {
47
+ return { ok: false, code: 'PLAN_INVALID' };
48
+ }
49
+ const recomputed = planHashOf(plan);
50
+ if (recomputed !== plan.plan_sha256) {
51
+ return { ok: false, code: 'PLAN_HASH_MISMATCH', detail: { declared: plan.plan_sha256, recomputed } };
52
+ }
15
53
  mkdirSync(planDir(stateDir), { recursive: true });
16
54
  const path = join(planDir(stateDir), plan.plan_sha256 + '.json');
17
55
  if (existsSync(path)) return { ok: true, path, reused: true };
18
- writeFileSync(path, JSON.stringify({ plan, saved_at: new Date(now).toISOString() }, null, 2), { encoding: 'utf8', mode: 0o600 });
56
+ const tmp = path + '.tmp-' + process.pid;
57
+ writeFileSync(tmp, JSON.stringify({ plan, saved_at: new Date(now).toISOString() }, null, 2), { encoding: 'utf8', mode: 0o600 });
58
+ // rename is atomic on the same volume; a crash leaves only the tmp file behind.
59
+ renameSync(tmp, path);
19
60
  return { ok: true, path, reused: false };
20
61
  }
21
62
 
22
63
  export function loadPlan({ stateDir, planSha }) {
23
- const path = join(planDir(stateDir), planSha + '.json');
24
- if (!existsSync(path)) return { ok: false, code: 'PLAN_NOT_FOUND' };
25
- return { ok: true, ...JSON.parse(readFileSync(path, 'utf8')), path };
64
+ const loaded = readPlanFile(stateDir, planSha);
65
+ if (loaded.ok !== true) return loaded;
66
+ return { ok: true, plan: loaded.plan, saved_at: loaded.saved_at, path: loaded.path };
67
+ }
68
+
69
+ export function listPlans({ stateDir, limit = 50 }) {
70
+ const dir = planDir(stateDir);
71
+ if (!existsSync(dir)) return { ok: true, plans: [], total: 0 };
72
+ const entries = [];
73
+ for (const name of readdirSync(dir)) {
74
+ if (!name.endsWith('.json')) continue;
75
+ const path = join(dir, name);
76
+ try {
77
+ const stat = statSync(path);
78
+ entries.push({ plan_sha256: name.slice(0, -5), saved_at_ms: stat.mtimeMs });
79
+ } catch { /* a vanished plan is simply absent */ }
80
+ }
81
+ entries.sort((a, b) => b.saved_at_ms - a.saved_at_ms);
82
+ return { ok: true, plans: entries.slice(0, Math.max(1, Math.min(200, limit))), total: entries.length };
26
83
  }
27
84
 
28
85
  /**
29
- * 人工审批。authority 建议 ≥70;审批只由操作员 CLI/UI 写入,不暴露给 agent 工具。
86
+ * 人工审批。authority 最低 70;TTL 默认 24h;过期/哈希不符/计划被替换时 dispatch 拒绝。
30
87
  */
31
- export function approvePlan({ stateDir, planSha, approver, authority, note = '', now = Date.now() }) {
32
- if (typeof planSha !== 'string' || planSha.length !== 64) return { ok: false, code: 'PLAN_SHA_INVALID' };
33
- if (typeof approver !== 'string' || approver === '') return { ok: false, code: 'APPROVER_REQUIRED' };
88
+ export function approvePlan({ stateDir, planSha, approver, authority, note = '', now = Date.now(), ttlMs = DEFAULT_APPROVAL_TTL_MS }) {
89
+ if (!PLAN_SHA.test(String(planSha ?? ''))) return { ok: false, code: 'PLAN_SHA_INVALID' };
90
+ if (typeof approver !== 'string' || approver.trim() === '') return { ok: false, code: 'APPROVER_REQUIRED' };
34
91
  if (!Number.isInteger(authority) || authority < 70) return { ok: false, code: 'APPROVAL_AUTHORITY_TOO_LOW', detail: { authority } };
35
- if (!existsSync(join(planDir(stateDir), planSha + '.json'))) return { ok: false, code: 'PLAN_NOT_FOUND' };
92
+ if (!Number.isInteger(ttlMs) || ttlMs < 60_000 || ttlMs > 7 * 24 * 60 * 60 * 1000) {
93
+ return { ok: false, code: 'APPROVAL_TTL_INVALID', detail: { ttlMs } };
94
+ }
95
+ const loaded = readPlanFile(stateDir, planSha);
96
+ if (loaded.ok !== true) return loaded;
36
97
  mkdirSync(approvalDir(stateDir), { recursive: true });
37
98
  const body = {
38
- v: 1,
99
+ v: 2,
39
100
  plan_sha256: planSha,
40
101
  approver,
41
102
  authority,
42
103
  note: String(note).slice(0, 200),
43
104
  approved_at: new Date(now).toISOString(),
105
+ expires_at: new Date(now + ttlMs).toISOString(),
44
106
  };
45
107
  const record = { ...body, approval_sha256: sha256(JSON.stringify(body)) };
46
108
  const path = join(approvalDir(stateDir), planSha + '.json');
@@ -48,19 +110,28 @@ export function approvePlan({ stateDir, planSha, approver, authority, note = '',
48
110
  return { ok: true, approval: record, path };
49
111
  }
50
112
 
51
- export function loadApproval({ stateDir, planSha }) {
113
+ export function loadApproval({ stateDir, planSha, now = Date.now() }) {
114
+ if (!PLAN_SHA.test(String(planSha ?? ''))) return { ok: false, code: 'PLAN_SHA_INVALID' };
52
115
  const path = join(approvalDir(stateDir), planSha + '.json');
53
116
  if (!existsSync(path)) return { ok: false, code: 'APPROVAL_REQUIRED' };
54
- const record = JSON.parse(readFileSync(path, 'utf8'));
55
- if (record.plan_sha256 !== planSha) return { ok: false, code: 'APPROVAL_STALE' };
117
+ let record;
118
+ try { record = JSON.parse(readFileSync(path, 'utf8')); } catch { return { ok: false, code: 'APPROVAL_UNREADABLE' }; }
119
+ if (record?.plan_sha256 !== planSha) return { ok: false, code: 'APPROVAL_STALE' };
120
+ const { approval_sha256: declared, ...body } = record;
121
+ if (sha256(JSON.stringify(body)) !== declared) return { ok: false, code: 'APPROVAL_SIGNATURE_INVALID' };
122
+ if (!Number.isInteger(record.authority) || record.authority < 70) {
123
+ return { ok: false, code: 'APPROVAL_AUTHORITY_TOO_LOW', detail: { authority: record.authority } };
124
+ }
125
+ const expires = Date.parse(record.expires_at ?? '');
126
+ if (Number.isFinite(expires) && now > expires) return { ok: false, code: 'APPROVAL_EXPIRED', detail: { expires_at: record.expires_at } };
56
127
  return { ok: true, approval: record };
57
128
  }
58
129
 
59
- /** dispatch 前置门:计划必须存在且已审批。 */
60
- export function assertDispatchAllowed({ stateDir, planSha }) {
130
+ /** dispatch 前置门:计划必须存在、未被篡改,且已审批、未过期。 */
131
+ export function assertDispatchAllowed({ stateDir, planSha, now = Date.now() }) {
61
132
  const plan = loadPlan({ stateDir, planSha });
62
- if (plan.ok !== true) return { ok: false, code: 'PLAN_NOT_FOUND' };
63
- const approval = loadApproval({ stateDir, planSha });
64
- if (approval.ok !== true) return { ok: false, code: 'APPROVAL_REQUIRED' };
133
+ if (plan.ok !== true) return { ok: false, code: plan.code ?? 'PLAN_NOT_FOUND' };
134
+ const approval = loadApproval({ stateDir, planSha, now });
135
+ if (approval.ok !== true) return { ok: false, code: approval.code ?? 'APPROVAL_REQUIRED' };
65
136
  return { ok: true, plan: plan.plan, approval: approval.approval };
66
137
  }
@@ -283,7 +283,7 @@ const MAX_STAGES = 16;
283
283
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
284
284
  * overrides it at runtime for tests and packagers.
285
285
  */
286
- const PLUGIN_VERSION = '1.9.11';
286
+ const PLUGIN_VERSION = '1.9.12';
287
287
 
288
288
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
289
289
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -4249,6 +4249,7 @@ export async function apply(ctx, config = {}) {
4249
4249
  const originMachineId =
4250
4250
  typeof p2p.machineId === 'string' && p2p.machineId !== '' ? p2p.machineId : null;
4251
4251
 
4252
+
4252
4253
  /**
4253
4254
  * v0.4.x: the punch this dispatch will use, published with the task so the executor can join
4254
4255
  * the same session instead of waiting to be reached. See {@link buildP2POfferBlock} for why the
@@ -4313,7 +4314,33 @@ export async function apply(ctx, config = {}) {
4313
4314
  p2p: p2pOffer,
4314
4315
  };
4315
4316
 
4316
- const result = await request({
4317
+ // v2/S1: a narrowed dispatch is revalidated against the relay roster immediately before the
4318
+ // POST, so a machine that went away after the composer selection is a named refusal rather
4319
+ // than a task that expires with no executor.
4320
+ const narrowedTo = selectionAppliedAs === 'executor_machine_id' || selectionAppliedAs === 'target_machines'
4321
+ ? (selection?.machine_id ?? null)
4322
+ : (executorMachineId ?? null);
4323
+ if (narrowedTo !== null) {
4324
+ // A roster read is a best-effort guard: if it fails, the relay itself still decides and the
4325
+ // post-dispatch lease check below remains the authority on whether the narrowing held.
4326
+ let preflight = null;
4327
+ try {
4328
+ preflight = await readFleet({ rabbitUrl: base, token, localMachineId: null, signal: exec?.signal, request });
4329
+ } catch { preflight = null; }
4330
+ if (preflight.ok === true) {
4331
+ const listed = (preflight.devices ?? []).find((device) => device?.machine_id === narrowedTo);
4332
+ const online = listed !== undefined && (listed.state === 'online' || listed.state === undefined);
4333
+ if (!online) {
4334
+ throw new W2MError(
4335
+ 'TARGET_UNAVAILABLE',
4336
+ `TARGET_UNAVAILABLE: the target machine ${narrowedTo} is not online on the relay right now; nothing was dispatched and no other machine was chosen`,
4337
+ { hint: 'pick another machine in the composer, or clear the selection to dispatch to the whole fleet' },
4338
+ );
4339
+ }
4340
+ }
4341
+ }
4342
+
4343
+ const result = await request({
4317
4344
  rabbitUrl: base,
4318
4345
  token,
4319
4346
  signal: exec?.signal,
@@ -4346,7 +4373,29 @@ export async function apply(ctx, config = {}) {
4346
4373
  );
4347
4374
  }
4348
4375
 
4349
- /**
4376
+ // v2/S1: the relay's own lease list is the proof that the narrowing was honoured. A lease on a
4377
+ // machine that was not named means the dispatch widened (old relay ignoring the field, or a
4378
+ // routing bug): cancel and refuse rather than let the wrong machines run.
4379
+ if (narrowedTo !== null) {
4380
+ const leases = Array.isArray(result.json?.leases) ? result.json.leases : [];
4381
+ const executing = leases.filter((lease) => lease?.state !== 'observing');
4382
+ const foreign = executing.filter((lease) => lease?.machine_id !== narrowedTo);
4383
+ if (foreign.length > 0) {
4384
+ const taskId = result.json?.task_id ?? null;
4385
+ if (taskId !== null) {
4386
+ try {
4387
+ await request({ rabbitUrl: base, token, signal: exec?.signal, method: 'POST', pathname: '/v1/tasks/' + encodeURIComponent(taskId) + '/cancel', body: { reason: 'SELECTION_NOT_HONOURED' } });
4388
+ } catch { /* the refusal below is still the answer */ }
4389
+ }
4390
+ throw new W2MError(
4391
+ 'SELECTION_NOT_HONOURED',
4392
+ 'SELECTION_NOT_HONOURED: this dispatch named ' + narrowedTo + ', but the relay leased ' + (foreign.map((lease) => lease.machine_id).join(', ') || 'no machine') + '; the task was cancelled',
4393
+ { hint: 'upgrade the relay to a version that honours target_machines/executor_machine_id, or clear the selection' },
4394
+ );
4395
+ }
4396
+ }
4397
+
4398
+ /**
4350
4399
  * v0.4.0 §7: best-effort direct pushes, after the relay has the task.
4351
4400
  *
4352
4401
  * Order matters and is deliberate. The task exists on the relay *before* anything is punched,
@@ -55,6 +55,23 @@ export function projectResultEnvelope(envelope) {
55
55
  dropped.push(field);
56
56
  }
57
57
  }
58
+ // NESTED: pipeline.stages[].command_argv and friends are content too. The top-level sweep above
59
+ // cannot see them, and a real ledger line carries exactly that shape.
60
+ const NESTED_CONTENT = [...CONTENT_FIELDS, 'changed_files'];
61
+ const walk = (value, path) => {
62
+ if (value === null || typeof value !== 'object') return;
63
+ if (Array.isArray(value)) { value.forEach((entry, index) => walk(entry, path + '[' + index + ']')); return; }
64
+ for (const key of Object.keys(value)) {
65
+ const keyPath = path === '' ? key : path + '.' + key;
66
+ if (NESTED_CONTENT.includes(key)) {
67
+ delete value[key];
68
+ dropped.push(keyPath);
69
+ continue;
70
+ }
71
+ walk(value[key], keyPath);
72
+ }
73
+ };
74
+ walk(projected, '');
58
75
  return { projected, dropped, argv_hash: argvHash };
59
76
  }
60
77
 
@@ -62,11 +79,16 @@ export function projectResultEnvelope(envelope) {
62
79
  export function projectLedgerLine(line) {
63
80
  let record;
64
81
  try { record = JSON.parse(line); } catch { return { changed: false, line }; }
65
- const target = record?.result ?? record?.envelope;
66
- if (target === null || typeof target !== 'object') return { changed: false, line };
67
- const { projected, dropped } = projectResultEnvelope(target);
82
+ // Real ledger lines nest the envelope one level down: {type:'result.stored', result:{envelope:{...}}}.
83
+ // Handle that shape first, then the flat result, then a bare envelope record.
84
+ let holder = null;
85
+ if (record?.result && typeof record.result === 'object' && record.result.envelope && typeof record.result.envelope === 'object') holder = { parent: record.result, key: 'envelope' };
86
+ else if (record?.result && typeof record.result === 'object') holder = { parent: record, key: 'result' };
87
+ else if (record?.envelope && typeof record.envelope === 'object') holder = { parent: record, key: 'envelope' };
88
+ if (holder === null) return { changed: false, line };
89
+ const { projected, dropped } = projectResultEnvelope(holder.parent[holder.key]);
68
90
  if (dropped.length === 0) return { changed: false, line };
69
- if (record.result !== undefined) record.result = projected; else record.envelope = projected;
91
+ holder.parent[holder.key] = projected;
70
92
  record.projection = { dropped, at: new Date().toISOString() };
71
93
  return { changed: true, line: JSON.stringify(record) };
72
94
  }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Relay blob purge: retire the data-plane store that old relays (1.6.4) still accepted.
3
+ *
4
+ * Layout note (measured on a real relay): blobs live at `<state>/blobs/<aa>/<sha256>`, i.e. ONE
5
+ * level of fan-out directories, and `blob-sessions/` may hold in-flight chunks. The earlier
6
+ * scrubBlobs() only looked at the top level, which is why a dry run against a real state directory
7
+ * reported zero files while the relay was still holding megabytes.
8
+ *
9
+ * Rules:
10
+ * - dry-run by default; `apply` is required to touch anything
11
+ * - delete or quarantine (move outside the relay state tree); a manifest of what was removed is
12
+ * written next to the quarantine so an audit can prove what existed
13
+ * - after the purge the directory is re-listed; a non-empty result is `BLOBS_REAPPEARED`
14
+ * (that means something is still writing, e.g. an old relay that was not retired first)
15
+ * - stop the relay before applying: it keeps appending while running
16
+ */
17
+ import {
18
+ existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync,
19
+ } from 'node:fs';
20
+ import { createHash } from 'node:crypto';
21
+ import { dirname, join, relative } from 'node:path';
22
+
23
+ export const PURGE_CODES = Object.freeze({
24
+ MISSING: 'BLOBS_MISSING',
25
+ NOT_A_DIR: 'BLOBS_NOT_A_DIR',
26
+ REAPPEARED: 'BLOBS_REAPPEARED',
27
+ QUARANTINE_FAILED: 'QUARANTINE_FAILED',
28
+ });
29
+
30
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
31
+
32
+ /** Recursively list blob files (and in-flight session dirs when asked). */
33
+ export function listBlobs(blobDir, { includeSessions = true } = {}) {
34
+ if (!existsSync(blobDir)) return { ok: false, code: PURGE_CODES.MISSING, files: [] };
35
+ if (!statSync(blobDir).isDirectory()) return { ok: false, code: PURGE_CODES.NOT_A_DIR, files: [] };
36
+ const files = [];
37
+ const walk = (dir) => {
38
+ for (const name of readdirSync(dir)) {
39
+ const path = join(dir, name);
40
+ const stat = statSync(path);
41
+ if (stat.isDirectory()) {
42
+ const rel = relative(blobDir, path).split('\\').join('/');
43
+ if (!includeSessions && rel.startsWith('blob-sessions')) continue;
44
+ walk(path);
45
+ continue;
46
+ }
47
+ if (!stat.isFile()) continue;
48
+ files.push({ rel: relative(blobDir, path).split('\\').join('/'), size: stat.size });
49
+ }
50
+ };
51
+ walk(blobDir);
52
+ files.sort((a, b) => a.rel.localeCompare(b.rel));
53
+ return { ok: true, files };
54
+ }
55
+
56
+ /** Prune empty directories under `dir`, leaving `dir` itself in place. */
57
+ function pruneEmpty(dir) {
58
+ for (const name of readdirSync(dir)) {
59
+ const path = join(dir, name);
60
+ if (statSync(path).isDirectory()) {
61
+ pruneEmpty(path);
62
+ if (readdirSync(path).length === 0) rmdirSync(path);
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Purge the blob store.
69
+ *
70
+ * @param {string} blobDir `<state>/blobs`
71
+ * @param {{apply?: boolean, quarantineDir?: string|null, now?: number, includeSessions?: boolean}} [options]
72
+ */
73
+ export function purgeBlobs(blobDir, options = {}) {
74
+ const listed = listBlobs(blobDir, { includeSessions: options.includeSessions !== false });
75
+ if (listed.ok !== true) return { ok: false, ...listed, exists: false, count: 0, bytes: 0 };
76
+ const files = listed.files;
77
+ const bytes = files.reduce((sum, file) => sum + file.size, 0);
78
+ const base = {
79
+ ok: true,
80
+ dir: blobDir,
81
+ exists: true,
82
+ count: files.length,
83
+ bytes,
84
+ sample: files.slice(0, 5).map((file) => file.rel),
85
+ };
86
+ if (options.apply !== true) return { ...base, dry_run: true, removed: 0, quarantined: 0 };
87
+ if (files.length === 0) return { ...base, dry_run: false, removed: 0, quarantined: 0, remaining: 0 };
88
+
89
+ const now = Number.isInteger(options.now) ? options.now : Date.now();
90
+ let quarantine = null;
91
+ let manifest = null;
92
+ if (typeof options.quarantineDir === 'string' && options.quarantineDir !== '') {
93
+ quarantine = join(options.quarantineDir, 'purge-' + now);
94
+ try {
95
+ for (const file of files) {
96
+ const target = join(quarantine, file.rel);
97
+ mkdirSync(dirname(target), { recursive: true });
98
+ renameSync(join(blobDir, file.rel), target);
99
+ }
100
+ manifest = join(quarantine, 'manifest.json');
101
+ writeFileSync(
102
+ manifest,
103
+ JSON.stringify(
104
+ {
105
+ at: new Date(now).toISOString(),
106
+ source: blobDir,
107
+ count: files.length,
108
+ bytes,
109
+ files: files.map((file) => ({ rel: file.rel, size: file.size, sha256: sha256(readFileSync(join(quarantine, file.rel))) })),
110
+ },
111
+ null,
112
+ 2,
113
+ ) + '\n',
114
+ 'utf8',
115
+ );
116
+ } catch (error) {
117
+ return { ...base, ok: false, code: PURGE_CODES.QUARANTINE_FAILED, error: String(error) };
118
+ }
119
+ } else {
120
+ for (const file of files) unlinkSync(join(blobDir, file.rel));
121
+ }
122
+ pruneEmpty(blobDir);
123
+
124
+ const remaining = listBlobs(blobDir).files;
125
+ const result = {
126
+ ...base,
127
+ dry_run: false,
128
+ removed: quarantine === null ? files.length : 0,
129
+ quarantined: quarantine === null ? 0 : files.length,
130
+ quarantine,
131
+ manifest,
132
+ remaining: remaining.length,
133
+ remaining_bytes: remaining.reduce((sum, file) => sum + file.size, 0),
134
+ };
135
+ if (remaining.length > 0) return { ...result, ok: false, code: PURGE_CODES.REAPPEARED };
136
+ return result;
137
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Relay ledger repair: quarantine corrupt JSONL lines and atomically rewrite the rest.
3
+ *
4
+ * The relay's ledger is append-only (`<state>/ledger.jsonl`, one JSON object per line). A crash
5
+ * during an append can leave a torn line, and `Persistence` reports it rather than guessing:
6
+ * `ledger_lines_skipped` and a `warn(ledger.jsonl, ...)` entry survive every restart. This tool is
7
+ * the operator half of that report.
8
+ *
9
+ * Rules (deliberately conservative):
10
+ * - a line that `JSON.parse` rejects is NEVER repaired by guessing; it is moved to a quarantine
11
+ * file byte for byte, so the operator can inspect what was lost
12
+ * - dry-run is the default; writing needs `apply: true`
13
+ * - the original is copied to `<file>.bak-<timestamp>` (mode 0600) before the atomic rename
14
+ * - the read is bracketed by `stat`: if the relay appended while we were reading, the tool
15
+ * refuses with `LEDGER_BUSY` instead of silently dropping the new line
16
+ */
17
+ import {
18
+ chmodSync,
19
+ closeSync,
20
+ copyFileSync,
21
+ existsSync,
22
+ fsyncSync,
23
+ openSync,
24
+ readFileSync,
25
+ renameSync,
26
+ statSync,
27
+ unlinkSync,
28
+ writeSync,
29
+ } from 'node:fs';
30
+ import { createHash } from 'node:crypto';
31
+ import { dirname, join } from 'node:path';
32
+
33
+ export const LEDGER_REPAIR_CODES = Object.freeze({
34
+ MISSING: 'LEDGER_MISSING',
35
+ BUSY: 'LEDGER_BUSY',
36
+ NOT_A_FILE: 'LEDGER_NOT_A_FILE',
37
+ WRITE_FAILED: 'LEDGER_WRITE_FAILED',
38
+ });
39
+
40
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
41
+
42
+ /** Write bytes and fsync them, so a rename cannot publish a page the disk has not seen. */
43
+ function writeDurable(file, data, mode) {
44
+ const fd = openSync(file, 'w', mode);
45
+ try {
46
+ writeSync(fd, data);
47
+ fsyncSync(fd);
48
+ } finally {
49
+ closeSync(fd);
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Inspect (and optionally repair) a ledger file.
55
+ *
56
+ * @param {string} file absolute or relative path to `ledger.jsonl`
57
+ * @param {{apply?: boolean, now?: number, quarantineDir?: string}} [options]
58
+ * @returns {object} report; `ok:false` carries one of {@link LEDGER_REPAIR_CODES}
59
+ */
60
+ export function repairLedger(file, options = {}) {
61
+ if (typeof file !== 'string' || file === '') throw new TypeError('repairLedger: a file path is required');
62
+ if (!existsSync(file)) return { ok: false, code: LEDGER_REPAIR_CODES.MISSING, path: file };
63
+ const before = statSync(file);
64
+ if (!before.isFile()) return { ok: false, code: LEDGER_REPAIR_CODES.NOT_A_FILE, path: file };
65
+
66
+ const raw = readFileSync(file);
67
+ const afterRead = statSync(file);
68
+ if (afterRead.mtimeMs !== before.mtimeMs || afterRead.size !== before.size) {
69
+ return { ok: false, code: LEDGER_REPAIR_CODES.BUSY, path: file, size: before.size, size_now: afterRead.size };
70
+ }
71
+
72
+ const text = raw.toString('utf8');
73
+ const hasFinalNewline = text.endsWith('\n');
74
+ const lines = text.split('\n');
75
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
76
+
77
+ const valid = [];
78
+ const invalid = [];
79
+ lines.forEach((line, index) => {
80
+ if (line.trim() === '') {
81
+ // A blank line carries no record; dropping it changes no replay outcome.
82
+ invalid.push({ line: index + 1, reason: 'EMPTY_LINE', raw: line });
83
+ return;
84
+ }
85
+ try {
86
+ JSON.parse(line);
87
+ valid.push(line);
88
+ } catch (error) {
89
+ invalid.push({ line: index + 1, reason: error instanceof Error ? error.message : String(error), raw: line });
90
+ }
91
+ });
92
+
93
+ const changed = invalid.length > 0;
94
+ const base = {
95
+ ok: true,
96
+ path: file,
97
+ bytes_before: raw.length,
98
+ sha256_before: sha256(raw),
99
+ total_lines: lines.length,
100
+ valid_lines: valid.length,
101
+ invalid_count: invalid.length,
102
+ invalid: invalid.map((entry) => ({ line: entry.line, reason: entry.reason })),
103
+ changed,
104
+ };
105
+ if (!changed) return { ...base, dry_run: options.apply !== true, written: false };
106
+ if (options.apply !== true) {
107
+ return {
108
+ ...base,
109
+ dry_run: true,
110
+ written: false,
111
+ note: 'dry run: pass apply:true (CLI --apply) to quarantine the invalid lines and rewrite the file',
112
+ };
113
+ }
114
+
115
+ const now = Number.isInteger(options.now) ? options.now : Date.now();
116
+ const backup = `${file}.bak-${now}`;
117
+ try {
118
+ copyFileSync(file, backup);
119
+ chmodSync(backup, 0o600);
120
+ } catch (error) {
121
+ return { ...base, dry_run: false, written: false, ok: false, code: LEDGER_REPAIR_CODES.WRITE_FAILED, error: String(error) };
122
+ }
123
+
124
+ const quarantineDir = options.quarantineDir ?? dirname(file);
125
+ const quarantine = join(quarantineDir, `ledger.corrupt-${now}.jsonl`);
126
+ try {
127
+ writeDurable(quarantine, Buffer.from(invalid.map((entry) => entry.raw).join('\n') + '\n', 'utf8'), 0o600);
128
+ } catch (error) {
129
+ return { ...base, dry_run: false, written: false, ok: false, code: LEDGER_REPAIR_CODES.WRITE_FAILED, error: String(error), backup };
130
+ }
131
+
132
+ const next = valid.length === 0 ? '' : valid.join('\n') + (hasFinalNewline ? '\n' : '');
133
+ const tmp = `${file}.repair-${now}.tmp`;
134
+ try {
135
+ writeDurable(tmp, Buffer.from(next, 'utf8'), 0o600);
136
+ renameSync(tmp, file);
137
+ } catch (error) {
138
+ try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* best effort */ }
139
+ return { ...base, dry_run: false, written: false, ok: false, code: LEDGER_REPAIR_CODES.WRITE_FAILED, error: String(error), backup, quarantine };
140
+ }
141
+
142
+ const written = readFileSync(file);
143
+ const reread = written.toString('utf8').split('\n').filter((line) => line.trim() !== '');
144
+ for (const line of reread) {
145
+ try { JSON.parse(line); } catch (error) {
146
+ return {
147
+ ...base, dry_run: false, written: true, backup, quarantine,
148
+ ok: false, code: LEDGER_REPAIR_CODES.WRITE_FAILED,
149
+ error: 'post-write verification failed: ' + (error instanceof Error ? error.message : String(error)),
150
+ };
151
+ }
152
+ }
153
+
154
+ return {
155
+ ...base,
156
+ dry_run: false,
157
+ written: true,
158
+ backup,
159
+ quarantine,
160
+ bytes_after: written.length,
161
+ sha256_after: sha256(written),
162
+ lines_after: reread.length,
163
+ verified: true,
164
+ };
165
+ }
@@ -8,25 +8,11 @@ import {
8
8
  import { createHash } from 'node:crypto';
9
9
  import { join } from 'node:path';
10
10
 
11
+ import { projectLedgerLine } from './p0-projection.mjs';
12
+
11
13
  export const LEDGER_CONTENT_FIELDS = ['stdout_head', 'stdout_tail', 'stderr_head', 'stderr_tail', 'command_argv', 'pipeline_hash_material', 'changed_files'];
12
14
  const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
13
15
 
14
- function projectLedgerLine(line) {
15
- let record;
16
- try { record = JSON.parse(line); } catch { return { changed: false, line }; }
17
- const result = record?.result ?? record?.envelope ?? null;
18
- if (result === null || typeof result !== 'object') return { changed: false, line };
19
- let changed = false;
20
- for (const field of LEDGER_CONTENT_FIELDS) {
21
- if (field in result) { delete result[field]; changed = true; }
22
- }
23
- if ('patch_sha256' in result) { result.patch_sha256 = null; changed = true; }
24
- if ('workspace' in result && typeof result.workspace === 'object' && result.workspace !== null) {
25
- if ('changed_files' in result.workspace) { delete result.workspace.changed_files; changed = true; }
26
- if ('patch_sha256' in result.workspace) { result.workspace.patch_sha256 = null; changed = true; }
27
- }
28
- return { changed, line: changed ? JSON.stringify(record) : line };
29
- }
30
16
 
31
17
  /** 清理 ledger:默认 dry-run;apply 时备份 .bak-<ts> 后原子替换。 */
32
18
  export function scrubLedger(ledgerPath, { dryRun = true, now = Date.now() } = {}) {
@@ -73,11 +59,15 @@ export function scanForContent(rootDir, markers = []) {
73
59
  const hits = [];
74
60
  const walk = (dir) => {
75
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;
76
64
  const path = join(dir, name);
77
65
  const stat = statSync(path);
78
66
  if (stat.isDirectory()) { walk(path); continue; }
79
67
  if (stat.size > 32 * 1024 * 1024) continue;
80
- const text = readFileSync(path, 'utf8');
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, '');
81
71
  for (const field of LEDGER_CONTENT_FIELDS) {
82
72
  if (text.includes('"' + field + '"')) hits.push({ file: path, marker: field });
83
73
  }