@sentropic/h2a 0.97.11 → 0.98.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.
Files changed (137) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/dist/bin.js +5 -1
  4. package/dist/bin.js.map +1 -1
  5. package/dist/cli-command-map.d.ts.map +1 -1
  6. package/dist/cli-command-map.js +2 -1
  7. package/dist/cli-command-map.js.map +1 -1
  8. package/dist/cli-contract.js +1 -1
  9. package/dist/cli-contract.js.map +1 -1
  10. package/dist/cli.d.ts +1 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +35 -10
  13. package/dist/cli.js.map +1 -1
  14. package/dist/index.d.ts +4 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/runtime/cluster-mesh-messaging.d.ts +21 -0
  19. package/dist/runtime/cluster-mesh-messaging.d.ts.map +1 -0
  20. package/dist/runtime/cluster-mesh-messaging.js +129 -0
  21. package/dist/runtime/cluster-mesh-messaging.js.map +1 -0
  22. package/dist/runtime/identity/readers.d.ts.map +1 -1
  23. package/dist/runtime/identity/readers.js +25 -2
  24. package/dist/runtime/identity/readers.js.map +1 -1
  25. package/dist/runtime/local-files/succession-lock.d.ts +105 -17
  26. package/dist/runtime/local-files/succession-lock.d.ts.map +1 -1
  27. package/dist/runtime/local-files/succession-lock.js +320 -108
  28. package/dist/runtime/local-files/succession-lock.js.map +1 -1
  29. package/dist/runtime/mcp/agent-launch.d.ts +6 -3
  30. package/dist/runtime/mcp/agent-launch.d.ts.map +1 -1
  31. package/dist/runtime/mcp/agent-launch.js +146 -4
  32. package/dist/runtime/mcp/agent-launch.js.map +1 -1
  33. package/dist/runtime/mcp/handlers.d.ts +7 -2
  34. package/dist/runtime/mcp/handlers.d.ts.map +1 -1
  35. package/dist/runtime/mcp/handlers.js +9 -4
  36. package/dist/runtime/mcp/handlers.js.map +1 -1
  37. package/dist/runtime/mcp/identity-state.d.ts +5 -4
  38. package/dist/runtime/mcp/identity-state.d.ts.map +1 -1
  39. package/dist/runtime/mcp/identity-state.js +43 -4
  40. package/dist/runtime/mcp/identity-state.js.map +1 -1
  41. package/dist/runtime/mcp/server.d.ts +6 -2
  42. package/dist/runtime/mcp/server.d.ts.map +1 -1
  43. package/dist/runtime/mcp/server.js +6 -4
  44. package/dist/runtime/mcp/server.js.map +1 -1
  45. package/dist/runtime/mcp/stdio.d.ts +5 -1
  46. package/dist/runtime/mcp/stdio.d.ts.map +1 -1
  47. package/dist/runtime/mcp/stdio.js +117 -24
  48. package/dist/runtime/mcp/stdio.js.map +1 -1
  49. package/dist/runtime/mcp/tools.d.ts.map +1 -1
  50. package/dist/runtime/mcp/tools.js +4 -3
  51. package/dist/runtime/mcp/tools.js.map +1 -1
  52. package/dist/runtime/mcp-central.js +2 -2
  53. package/dist/runtime/mcp-central.js.map +1 -1
  54. package/dist/runtime/mcp-http/hosted-mcp-server.d.ts.map +1 -1
  55. package/dist/runtime/mcp-http/hosted-mcp-server.js +3 -0
  56. package/dist/runtime/mcp-http/hosted-mcp-server.js.map +1 -1
  57. package/dist/runtime/send.d.ts +11 -5
  58. package/dist/runtime/send.d.ts.map +1 -1
  59. package/dist/runtime/send.js +30 -4
  60. package/dist/runtime/send.js.map +1 -1
  61. package/dist/runtime/upgrade/index.d.ts +5 -5
  62. package/dist/runtime/upgrade/index.d.ts.map +1 -1
  63. package/dist/runtime/upgrade/index.js +16 -13
  64. package/dist/runtime/upgrade/index.js.map +1 -1
  65. package/focus-app/client/_app/immutable/chunks/zL3j6erO.js +2 -0
  66. package/focus-app/client/_app/immutable/chunks/zL3j6erO.js.br +0 -0
  67. package/focus-app/client/_app/immutable/chunks/zL3j6erO.js.gz +0 -0
  68. package/focus-app/client/_app/immutable/entry/{app.D_yfk55R.js → app.Dfo3Ymae.js} +2 -2
  69. package/focus-app/client/_app/immutable/entry/app.Dfo3Ymae.js.br +0 -0
  70. package/focus-app/client/_app/immutable/entry/app.Dfo3Ymae.js.gz +0 -0
  71. package/focus-app/client/_app/immutable/entry/start.BSQLtKUn.js +1 -0
  72. package/focus-app/client/_app/immutable/entry/start.BSQLtKUn.js.br +0 -0
  73. package/focus-app/client/_app/immutable/entry/start.BSQLtKUn.js.gz +0 -0
  74. package/focus-app/client/_app/immutable/nodes/{1.Jo1uWTw0.js → 1.B83XRsuv.js} +1 -1
  75. package/focus-app/client/_app/immutable/nodes/1.B83XRsuv.js.br +0 -0
  76. package/focus-app/client/_app/immutable/nodes/1.B83XRsuv.js.gz +0 -0
  77. package/focus-app/client/_app/version.json +1 -1
  78. package/focus-app/client/_app/version.json.br +1 -1
  79. package/focus-app/client/_app/version.json.gz +0 -0
  80. package/focus-app/h2a-focus.json +61 -61
  81. package/focus-app/handler.js +8 -8
  82. package/focus-app/index.js +8 -8
  83. package/focus-app/server/chunks/chunks/{AppShell.js-BoWxjmfr.js → AppShell.js-DT15_EBV.js} +2 -2
  84. package/focus-app/server/chunks/chunks/{AppShell.js-BoWxjmfr.js.map → AppShell.js-DT15_EBV.js.map} +1 -1
  85. package/focus-app/server/chunks/chunks/{exports.js-D45A8dcc.js → exports.js-BrfiJlPR.js} +3 -3
  86. package/focus-app/server/chunks/chunks/{exports.js-D45A8dcc.js.map → exports.js-BrfiJlPR.js.map} +1 -1
  87. package/focus-app/server/chunks/chunks/{index.js-e3L5MwfB.js → index.js-BY6_68Zf.js} +2 -2
  88. package/focus-app/server/chunks/chunks/{index.js-e3L5MwfB.js.map → index.js-BY6_68Zf.js.map} +1 -1
  89. package/focus-app/server/chunks/chunks/{internal.js-DAV-A6NO.js → internal.js-BDHE9Kn1.js} +3 -3
  90. package/focus-app/server/chunks/chunks/{internal.js-DAV-A6NO.js.map → internal.js-BDHE9Kn1.js.map} +1 -1
  91. package/focus-app/server/chunks/chunks/{root.js-CEypm32A.js → root.js-CZyc7gzj.js} +2 -2
  92. package/focus-app/server/chunks/chunks/{root.js-CEypm32A.js.map → root.js-CZyc7gzj.js.map} +1 -1
  93. package/focus-app/server/chunks/chunks/{utils.js-Bzpr6vQU.js → utils.js-DBwpgn00.js} +178 -51
  94. package/focus-app/server/chunks/chunks/utils.js-DBwpgn00.js.map +1 -0
  95. package/focus-app/server/chunks/entries/endpoints/api/actions/launch/{_server.ts.js-Cah_Ivgw.js → _server.ts.js-H2iyKDJx.js} +2 -2
  96. package/focus-app/server/chunks/entries/endpoints/api/actions/launch/{_server.ts.js-Cah_Ivgw.js.map → _server.ts.js-H2iyKDJx.js.map} +1 -1
  97. package/focus-app/server/chunks/entries/endpoints/api/decisions/inject/{_server.ts.js-DW5wvcgH.js → _server.ts.js-BuR515Qd.js} +2 -2
  98. package/focus-app/server/chunks/entries/endpoints/api/decisions/inject/{_server.ts.js-DW5wvcgH.js.map → _server.ts.js-BuR515Qd.js.map} +1 -1
  99. package/focus-app/server/chunks/entries/endpoints/api/dossiers/agent-memory/include/{_server.ts.js-C2jIx8Jr.js → _server.ts.js-Cs32vOYa.js} +2 -2
  100. package/focus-app/server/chunks/entries/endpoints/api/dossiers/agent-memory/include/{_server.ts.js-C2jIx8Jr.js.map → _server.ts.js-Cs32vOYa.js.map} +1 -1
  101. package/focus-app/server/chunks/entries/endpoints/api/h2a/targets/{_server.ts.js-BkAMLcHu.js → _server.ts.js-DbEqL92p.js} +2 -2
  102. package/focus-app/server/chunks/entries/endpoints/api/h2a/targets/{_server.ts.js-BkAMLcHu.js.map → _server.ts.js-DbEqL92p.js.map} +1 -1
  103. package/focus-app/server/chunks/entries/fallbacks/{error.svelte.js-BWuS92Wk.js → error.svelte.js-BzO57FOz.js} +5 -5
  104. package/focus-app/server/chunks/entries/fallbacks/{error.svelte.js-BWuS92Wk.js.map → error.svelte.js-BzO57FOz.js.map} +1 -1
  105. package/focus-app/server/chunks/entries/pages/{_layout.svelte.js-CKspI0Bw.js → _layout.svelte.js-BHXu1zZ0.js} +3 -3
  106. package/focus-app/server/chunks/entries/pages/{_layout.svelte.js-CKspI0Bw.js.map → _layout.svelte.js-BHXu1zZ0.js.map} +1 -1
  107. package/focus-app/server/chunks/entries/pages/{_page.svelte.js-B8EWZmZj.js → _page.svelte.js-DgcoCR_G.js} +4 -4
  108. package/focus-app/server/chunks/entries/pages/{_page.svelte.js-B8EWZmZj.js.map → _page.svelte.js-DgcoCR_G.js.map} +1 -1
  109. package/focus-app/server/chunks/entries/pages/dossier/agent-memory/{_page.svelte.js-BZvcKeya.js → _page.svelte.js-D8qUTYi2.js} +4 -4
  110. package/focus-app/server/chunks/entries/pages/dossier/agent-memory/{_page.svelte.js-BZvcKeya.js.map → _page.svelte.js-D8qUTYi2.js.map} +1 -1
  111. package/focus-app/server/chunks/{handler-BCBpURX0.js → handler-BidpBSFZ.js} +4 -4
  112. package/focus-app/server/chunks/{handler-BCBpURX0.js.map → handler-BidpBSFZ.js.map} +1 -1
  113. package/focus-app/server/chunks/{index.js-CZWB6FqQ.js → index.js-BNQ-AMZE.js} +5 -5
  114. package/focus-app/server/chunks/{index.js-CZWB6FqQ.js.map → index.js-BNQ-AMZE.js.map} +1 -1
  115. package/focus-app/server/chunks/{manifest.js-C2CyTiUe.js → manifest.js-CAtvBIjA.js} +10 -10
  116. package/focus-app/server/chunks/{manifest.js-C2CyTiUe.js.map → manifest.js-CAtvBIjA.js.map} +1 -1
  117. package/focus-app/server/chunks/nodes/{0.js-Cp9RMJ1x.js → 0.js-DyONiS9Y.js} +2 -2
  118. package/focus-app/server/chunks/nodes/{0.js-Cp9RMJ1x.js.map → 0.js-DyONiS9Y.js.map} +1 -1
  119. package/focus-app/server/chunks/nodes/1.js-C16cgDY2.js +9 -0
  120. package/focus-app/server/chunks/nodes/{1.js-DMWmTj0d.js.map → 1.js-C16cgDY2.js.map} +1 -1
  121. package/focus-app/server/chunks/nodes/{2.js-Dv8Q6LQc.js → 2.js-C16ONuI7.js} +2 -2
  122. package/focus-app/server/chunks/nodes/{2.js-Dv8Q6LQc.js.map → 2.js-C16ONuI7.js.map} +1 -1
  123. package/focus-app/server/chunks/nodes/{3.js-DJH-zOae.js → 3.js-DIZzznQP.js} +2 -2
  124. package/focus-app/server/chunks/nodes/{3.js-DJH-zOae.js.map → 3.js-DIZzznQP.js.map} +1 -1
  125. package/package.json +4 -4
  126. package/focus-app/client/_app/immutable/chunks/BgBtu3IQ.js +0 -2
  127. package/focus-app/client/_app/immutable/chunks/BgBtu3IQ.js.br +0 -0
  128. package/focus-app/client/_app/immutable/chunks/BgBtu3IQ.js.gz +0 -0
  129. package/focus-app/client/_app/immutable/entry/app.D_yfk55R.js.br +0 -0
  130. package/focus-app/client/_app/immutable/entry/app.D_yfk55R.js.gz +0 -0
  131. package/focus-app/client/_app/immutable/entry/start.CoQWYNTs.js +0 -1
  132. package/focus-app/client/_app/immutable/entry/start.CoQWYNTs.js.br +0 -2
  133. package/focus-app/client/_app/immutable/entry/start.CoQWYNTs.js.gz +0 -0
  134. package/focus-app/client/_app/immutable/nodes/1.Jo1uWTw0.js.br +0 -4
  135. package/focus-app/client/_app/immutable/nodes/1.Jo1uWTw0.js.gz +0 -0
  136. package/focus-app/server/chunks/chunks/utils.js-Bzpr6vQU.js.map +0 -1
  137. package/focus-app/server/chunks/nodes/1.js-DMWmTj0d.js +0 -9
@@ -2,7 +2,8 @@
2
2
  * Succession lock (v4) — single-machine succession protocol.
3
3
  *
4
4
  * Extracted from `runtime/upgrade/index.ts` (Lot 4, step 1: neutral
5
- * intra-package move — zero behaviour change). This is the shared lock primitive
5
+ * intra-package move). Lot 4 §2 adds fail-closed host and boot provenance before
6
+ * any death proof. This is the shared lock primitive
6
7
  * used by the auto-upgrade prefix lock today, and (later lots) by the identity
7
8
  * binding lock. It is a LEAF: it imports only `node:fs/os/crypto/child_process`
8
9
  * and nothing from the store, so `upgrade/index.ts` and `local-files/locks.ts`
@@ -11,11 +12,11 @@
11
12
  * The full protocol proof-sketch (I1–I7, Lemmas A–C) lives with the code below,
12
13
  * moved unchanged from its original location.
13
14
  */
14
- import { randomBytes } from "node:crypto";
15
+ import { createHash, randomBytes } from "node:crypto";
15
16
  import { spawnSync } from "node:child_process";
16
17
  import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, statSync, unlinkSync, writeSync } from "node:fs";
17
18
  import { hostname } from "node:os";
18
- import { basename, join } from "node:path";
19
+ import { basename, dirname, join } from "node:path";
19
20
  // ---------------------------------------------------------------------------
20
21
  // Prefix lock (v4): single-machine succession protocol.
21
22
  //
@@ -97,28 +98,87 @@ export function safeAtIso(at) {
97
98
  }
98
99
  return `epoch-ms:${at}`;
99
100
  }
100
- function readHostId() {
101
+ const MACHINE_ID_RE = /^[0-9a-f]{32}$/;
102
+ const IO_PLATFORM_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
103
+ const STATIC_COMMAND_ENV = { LC_ALL: "C", TZ: "UTC0" };
104
+ function isNullMachineId(value) {
105
+ return /^0+$/.test(value.replaceAll("-", ""));
106
+ }
107
+ function sysctlPath(platform) {
108
+ return platform === "darwin"
109
+ ? "/usr/sbin/sysctl"
110
+ : platform === "freebsd" || platform === "openbsd"
111
+ ? "/sbin/sysctl"
112
+ : "/usr/sbin/sysctl";
113
+ }
114
+ function weakHost(hostnameReader) {
101
115
  try {
102
- const v = readFileSync("/etc/machine-id", "utf8").trim();
103
- if (v)
104
- return v;
116
+ const host = hostnameReader().trim();
117
+ if (host)
118
+ return { host, hostKind: "weak" };
105
119
  }
106
120
  catch {
107
- // fall through to hostname
121
+ // fall through to sentinel
108
122
  }
109
- try {
110
- const h = hostname().trim();
111
- if (h)
112
- return h;
123
+ return { host: "unknown-host", hostKind: "weak" };
124
+ }
125
+ function parseIoPlatformUuid(output) {
126
+ const matches = output
127
+ .split("\n")
128
+ .map((line) => line.trim().match(/^"IOPlatformUUID"\s*=\s*"([0-9a-f-]+)"$/i)?.[1])
129
+ .filter((value) => value !== undefined);
130
+ if (matches.length !== 1 || !IO_PLATFORM_UUID_RE.test(matches[0]) || isNullMachineId(matches[0]))
131
+ return undefined;
132
+ return matches[0].toLowerCase();
133
+ }
134
+ /**
135
+ * Reads a machine-scoped host identity. The injected readers make source selection
136
+ * deterministic in tests without consulting process environment variables.
137
+ */
138
+ export function readHostId(deps = {}) {
139
+ const platform = deps.platform ?? process.platform;
140
+ const readFile = deps.readFile ?? ((path) => readFileSync(path, "utf8"));
141
+ const hostnameReader = deps.hostname ?? hostname;
142
+ if (platform === "linux") {
143
+ try {
144
+ const host = readFile("/etc/machine-id").trim();
145
+ if (MACHINE_ID_RE.test(host) && !isNullMachineId(host))
146
+ return { host, hostKind: "machine-id" };
147
+ }
148
+ catch {
149
+ // fall through to a weak hostname
150
+ }
113
151
  }
114
- catch {
115
- // fall through to sentinel
152
+ else if (platform === "darwin") {
153
+ try {
154
+ const ioreg = deps.ioreg ?? (() => {
155
+ const r = (deps.spawn ?? spawnSync)("/usr/sbin/ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
156
+ encoding: "utf8",
157
+ timeout: 2000,
158
+ env: STATIC_COMMAND_ENV
159
+ });
160
+ return { status: r.status, stdout: typeof r.stdout === "string" ? r.stdout : null };
161
+ });
162
+ const result = ioreg();
163
+ const host = result.status === 0 && typeof result.stdout === "string"
164
+ ? parseIoPlatformUuid(result.stdout)
165
+ : undefined;
166
+ if (host !== undefined)
167
+ return { host, hostKind: "machine-id" };
168
+ }
169
+ catch {
170
+ // fall through to a weak hostname
171
+ }
116
172
  }
117
- return "unknown-host";
173
+ return weakHost(hostnameReader);
118
174
  }
119
- function readBootId() {
175
+ /** Reads the current boot identity; readers are injectable for platform simulations. */
176
+ export function readBootId(deps = {}) {
177
+ const platform = deps.platform ?? process.platform;
178
+ const readFile = deps.readFile ?? ((path) => readFileSync(path, "utf8"));
179
+ const spawn = deps.spawn ?? spawnSync;
120
180
  try {
121
- const v = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
181
+ const v = readFile("/proc/sys/kernel/random/boot_id").trim();
122
182
  if (v)
123
183
  return v;
124
184
  }
@@ -127,10 +187,10 @@ function readBootId() {
127
187
  }
128
188
  // Outside Linux prefer the stable session UUID (TZ-independent) when present.
129
189
  try {
130
- const r = spawnSync("sysctl", ["-n", "kern.bootsessionuuid"], {
190
+ const r = spawn(sysctlPath(platform), ["-n", "kern.bootsessionuuid"], {
131
191
  encoding: "utf8",
132
192
  timeout: 2000,
133
- env: { ...process.env, LC_ALL: "C", TZ: "UTC0" }
193
+ env: STATIC_COMMAND_ENV
134
194
  });
135
195
  const v = (r.stdout ?? "").trim();
136
196
  if (r.status === 0 && v)
@@ -140,10 +200,10 @@ function readBootId() {
140
200
  // best-effort
141
201
  }
142
202
  try {
143
- const r = spawnSync("sysctl", ["-n", "kern.boottime"], {
203
+ const r = spawn(sysctlPath(platform), ["-n", "kern.boottime"], {
144
204
  encoding: "utf8",
145
205
  timeout: 2000,
146
- env: { ...process.env, LC_ALL: "C", TZ: "UTC0" }
206
+ env: STATIC_COMMAND_ENV
147
207
  });
148
208
  const v = (r.stdout ?? "").trim();
149
209
  if (r.status === 0 && v)
@@ -156,9 +216,10 @@ function readBootId() {
156
216
  }
157
217
  // B2 decomposition: distinguish "this platform has no namespaces" (a KNOWN fact —
158
218
  // one space per host) from "the namespace exists but is unreadable" (a genuine
159
- // unknown). Only the latter is null; the former is the sentinel "host" so same-host
160
- // liveness stays decidable (macOS/Windows/BSD). `platform`/`readLink` are injected
161
- // only by tests (macOS/Windows/no-/proc sims); production passes none.
219
+ // unknown). Only the latter is null; the former is the sentinel "host". Lot 4 §2
220
+ // permits that no-namespace liveness proof only on darwin; Windows and BSD still fail
221
+ // closed as undecidable. `platform`/`readLink` are injected only by tests
222
+ // (macOS/Windows/no-/proc sims); production passes none.
162
223
  //
163
224
  // readPidNs is the CONSERVATIVE gate that decides reclaim at all: an unknown (null)
164
225
  // pid namespace makes even a PID-absent holder undecidable, because "absent" in an
@@ -210,9 +271,9 @@ function procMapsToSelf() {
210
271
  * so a reader using a different source never concludes "dead" from a
211
272
  * format/TZ-fragile comparison (C1).
212
273
  */
213
- // `platform`/`mapsToSelf` are injected only by tests (mis-mapped-/proc / non-Linux
214
- // sims); production passes none.
215
- export function procStartInfo(pid, platform = process.platform, mapsToSelf = procMapsToSelf) {
274
+ // `platform`/`mapsToSelf`/`spawn` are injected only by tests (mis-mapped-/proc /
275
+ // non-Linux sims); production passes none.
276
+ export function procStartInfo(pid, platform = process.platform, mapsToSelf = procMapsToSelf, spawn = spawnSync) {
216
277
  // A start time is only trusted from a source whose value is STABLE for the life of the
217
278
  // process (never moving under a wall-clock step), else a clock jump while the lock is held
218
279
  // could make a live holder's start "differ" ⇒ a false death ⇒ two holders (I3).
@@ -224,7 +285,8 @@ export function procStartInfo(pid, platform = process.platform, mapsToSelf = pro
224
285
  // - darwin: `ps lstart` is the ABSOLUTE fork wall-clock time (p_starttime), stable ⇒ trusted.
225
286
  // - R-BSD: on FreeBSD/OpenBSD `ps` start is boot-relative and its boot time is re-derived on
226
287
  // a clock step, so it is NOT stable. Every other non-Linux platform (incl. Windows, no ps)
227
- // ⇒ undefined (undatable ⇒ live). In doubt, never dead.
288
+ // ⇒ undefined. Lock liveness on those platforms is already undecidable under Lot 4 §2.
289
+ // In doubt, never dead.
228
290
  if (platform === "linux") {
229
291
  if (!mapsToSelf())
230
292
  return undefined;
@@ -247,10 +309,10 @@ export function procStartInfo(pid, platform = process.platform, mapsToSelf = pro
247
309
  if (platform !== "darwin")
248
310
  return undefined; // only darwin ps is a stable source
249
311
  try {
250
- const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
312
+ const r = spawn("/bin/ps", ["-o", "lstart=", "-p", String(pid)], {
251
313
  encoding: "utf8",
252
314
  timeout: 5000,
253
- env: { ...process.env, LC_ALL: "C", TZ: "UTC0" }
315
+ env: STATIC_COMMAND_ENV
254
316
  });
255
317
  const v = (r.stdout ?? "").trim();
256
318
  if (r.status === 0 && v)
@@ -271,24 +333,28 @@ function startSource(s) {
271
333
  let ME_CACHE;
272
334
  /** This process's identity, computed once and memoized. */
273
335
  export function me() {
274
- ME_CACHE ??= {
275
- host: readHostId(),
276
- boot: readBootId(),
277
- ns: readPidNs(),
278
- timeNs: readTimeNs(),
279
- pid: process.pid,
280
- start: procStartInfo(process.pid)?.start ?? null
281
- };
336
+ ME_CACHE ??= (() => {
337
+ const host = readHostId();
338
+ return {
339
+ host: host.host,
340
+ hostKind: host.hostKind,
341
+ boot: readBootId(),
342
+ ns: readPidNs(),
343
+ timeNs: readTimeNs(),
344
+ pid: process.pid,
345
+ start: procStartInfo(process.pid)?.start ?? null
346
+ };
347
+ })();
282
348
  return ME_CACHE;
283
349
  }
284
350
  /** Fresh random token, >= 96 bits, never republished (H3). */
285
351
  function newToken() {
286
352
  return randomBytes(16).toString("hex");
287
353
  }
288
- export function makeLockRec(token, target) {
289
- const self = me();
354
+ function makeLockRecFor(self, token, target) {
290
355
  return {
291
356
  host: self.host,
357
+ hostKind: self.hostKind,
292
358
  boot: self.boot,
293
359
  ns: self.ns,
294
360
  timeNs: self.timeNs,
@@ -299,11 +365,14 @@ export function makeLockRec(token, target) {
299
365
  at: Date.now()
300
366
  };
301
367
  }
368
+ export function makeLockRec(token, target) {
369
+ return makeLockRecFor(me(), token, target);
370
+ }
302
371
  export function parseLockRec(raw) {
303
372
  if (typeof raw !== "object" || raw === null)
304
373
  throw new Error("bad lock record");
305
374
  const o = raw;
306
- const { host, boot, ns, timeNs, pid, start, token, target, at } = o;
375
+ const { host, hostKind, boot, ns, timeNs, pid, start, token, target, operator, at } = o;
307
376
  // timeNs is a required field like host/boot/ns/pid/start: an absent field is a
308
377
  // malformed record → corrupt → fail-closed. No back-compat exception is carved
309
378
  // for a "v4 without timeNs" — the redesign was never published, so no such lock
@@ -312,6 +381,12 @@ export function parseLockRec(raw) {
312
381
  // as undatable ⇒ "live" (never reclaim, never a false death), not undecidable.
313
382
  if (typeof host !== "string" || host.length === 0)
314
383
  throw new Error("bad host");
384
+ // A missing provenance was written by versions <= 0.97.9. It is valid legacy
385
+ // state, but classifyLiveness treats it as unknown and therefore undecidable.
386
+ // Unknown values are corrupt and fail closed. Lot 5 must carry an instance UUID in
387
+ // a separate field rather than extending this persisted hostKind enum.
388
+ if (hostKind !== undefined && hostKind !== "machine-id" && hostKind !== "weak")
389
+ throw new Error("bad hostKind");
315
390
  if (boot !== null && typeof boot !== "string")
316
391
  throw new Error("bad boot");
317
392
  if (ns !== null && typeof ns !== "string")
@@ -322,14 +397,17 @@ export function parseLockRec(raw) {
322
397
  throw new Error("bad pid");
323
398
  if (start !== null && typeof start !== "string")
324
399
  throw new Error("bad start");
325
- if (typeof token !== "string" || !LOCK_TOKEN_RE.test(token))
400
+ if (typeof token !== "string" || token.startsWith("legacy-") || !LOCK_TOKEN_RE.test(token))
326
401
  throw new Error("bad token");
327
402
  if (target !== undefined && typeof target !== "string")
328
403
  throw new Error("bad target");
404
+ if (operator !== undefined && (operator !== true || target === undefined))
405
+ throw new Error("bad operator");
329
406
  if (typeof at !== "number" || !Number.isFinite(at))
330
407
  throw new Error("bad at");
331
408
  return {
332
409
  host,
410
+ ...(hostKind !== undefined ? { hostKind } : {}),
333
411
  boot,
334
412
  ns,
335
413
  timeNs,
@@ -337,18 +415,81 @@ export function parseLockRec(raw) {
337
415
  start,
338
416
  token,
339
417
  ...(target !== undefined ? { target } : {}),
418
+ ...(operator === true ? { operator } : {}),
340
419
  at
341
420
  };
342
421
  }
343
- /** opus `read`: ENOENT -> absent, anything else unreadable -> corrupt (I1). */
344
- export function readLockRecord(path) {
422
+ function parseLegacyLockHolder(raw, bytes) {
423
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
424
+ throw new Error("bad legacy lock record");
425
+ const o = raw;
426
+ const keys = Object.keys(o);
427
+ if (!keys.includes("pid")
428
+ || !keys.includes("hostname")
429
+ || !keys.includes("startedAt")
430
+ || !keys.every((key) => key === "pid" || key === "hostname" || key === "startedAt" || key === "protocol" || key === "fenceEpoch")) {
431
+ throw new Error("bad legacy lock fields");
432
+ }
433
+ if (typeof o.pid !== "number" || !Number.isInteger(o.pid) || o.pid <= 0)
434
+ throw new Error("bad legacy pid");
435
+ if (typeof o.hostname !== "string")
436
+ throw new Error("bad legacy hostname");
437
+ if (typeof o.startedAt !== "string")
438
+ throw new Error("bad legacy startedAt");
439
+ if (o.protocol !== undefined && typeof o.protocol !== "string")
440
+ throw new Error("bad legacy protocol");
441
+ if (o.fenceEpoch !== undefined && typeof o.fenceEpoch !== "string")
442
+ throw new Error("bad legacy fenceEpoch");
443
+ return {
444
+ kind: "legacy",
445
+ // Do not trim, decode/re-encode, or JSON.stringify the input: a final `\n`
446
+ // is part of this fencing token by design and is covered by T-legacy.
447
+ token: `legacy-${createHash("sha256").update(bytes).digest("hex")}`,
448
+ pid: o.pid,
449
+ hostname: o.hostname,
450
+ startedAt: o.startedAt,
451
+ ...(o.protocol !== undefined ? { protocol: o.protocol } : {}),
452
+ ...(o.fenceEpoch !== undefined ? { fenceEpoch: o.fenceEpoch } : {})
453
+ };
454
+ }
455
+ /** Advisory reader: distinguish failed reads from malformed file content. */
456
+ function readLockHolderDetailed(path) {
457
+ let bytes;
345
458
  try {
346
- return parseLockRec(JSON.parse(readFileSync(path, "utf8")));
459
+ bytes = readFileSync(path);
347
460
  }
348
461
  catch (e) {
349
- return e?.code === "ENOENT" ? "absent" : "corrupt";
462
+ return errnoOf(e) === "ENOENT" ? "absent" : { kind: "io-error", code: errnoOf(e) };
463
+ }
464
+ try {
465
+ const parsed = JSON.parse(bytes.toString("utf8"));
466
+ try {
467
+ return parseLockRec(parsed);
468
+ }
469
+ catch {
470
+ return parseLegacyLockHolder(parsed, bytes);
471
+ }
472
+ }
473
+ catch {
474
+ return "corrupt";
350
475
  }
351
476
  }
477
+ /** Legacy tokens fingerprint the exact raw bytes; read errors remain corrupt. */
478
+ export function readLockHolder(path) {
479
+ const holder = readLockHolderDetailed(path);
480
+ return typeof holder === "object" && holder.kind === "io-error" ? "corrupt" : holder;
481
+ }
482
+ /**
483
+ * v4-only reader retained for existing protocol callers. A readable legacy holder
484
+ * remains `corrupt` here so a caller that was not explicitly upgraded to the
485
+ * distinct legacy view continues to fail closed.
486
+ */
487
+ export function readLockRecord(path) {
488
+ const holder = readLockHolder(path);
489
+ return holder !== "absent" && holder !== "corrupt" && holder.kind === "legacy"
490
+ ? "corrupt"
491
+ : holder;
492
+ }
352
493
  /**
353
494
  * opus `publish` (I1): a name appears only with complete, durable content.
354
495
  * Write tmp (wx + byte-count-checked write + fsync + close), then `link(2)`;
@@ -427,19 +568,24 @@ export function publishLockRecord(path, rec) {
427
568
  * safe "live" (undatable), and a corrupt "legacy" start yields "undecidable" (C1).
428
569
  */
429
570
  export function classifyLiveness(r, self, deps = {}) {
571
+ // Legacy records have no machine/boot/namespace/start provenance. In particular,
572
+ // even a same-host ESRCH PID may have been reused, so never probe it and never
573
+ // promote the holder to dead. The operator escape hatch consumes this token later.
574
+ if (r.kind === "legacy")
575
+ return { verdict: "undecidable", datable: false };
430
576
  const platform = deps.platform ?? process.platform;
431
577
  const probe = deps.probe ?? procStartInfo;
578
+ // Lot 4 §2 permits a death proof only for a strong machine identity on Linux or
579
+ // darwin, with the same known host and boot. Check this before every PID/start probe:
580
+ // cloned images can share a machine-id and an initial pid namespace inode.
581
+ if (platform !== "linux" && platform !== "darwin")
582
+ return { verdict: "undecidable", datable: false };
583
+ if (r.hostKind !== "machine-id" || self.hostKind !== "machine-id")
584
+ return { verdict: "undecidable", datable: false };
432
585
  if (r.host !== self.host)
433
- return { verdict: "undecidable", datable: false }; // other machine
434
- // B1: only a Linux boot_id is a stable, trustworthy boot identity, so a difference
435
- // there is a previous boot ⇒ dead. Off Linux a boot difference must NOT short-circuit
436
- // to undecidable (that wedged a Mac rebooted mid-lock forever); fall through to kill(0)
437
- // (PID absent ⇒ dead, on every platform) then the start comparison (only conclusive
438
- // where the start is datable — Linux /proc and darwin ps; on BSD/Windows it is undatable
439
- // ⇒ live, so after a reboot a reused PID blocks until it exits, surfaced by R2).
440
- if (r.boot && self.boot && r.boot !== self.boot && platform === "linux") {
441
- return { verdict: "dead", datable: false };
442
- }
586
+ return { verdict: "undecidable", datable: false };
587
+ if (r.boot === null || self.boot === null || r.boot !== self.boot)
588
+ return { verdict: "undecidable", datable: false };
443
589
  // An unreadable namespace on EITHER side is a genuine unknown — two records with a
444
590
  // null namespace must never be treated as co-located (null-equality).
445
591
  if (r.ns === null || self.ns === null)
@@ -500,21 +646,94 @@ export function livenessOf(r, self, deps = {}) {
500
646
  export function isCertainlyDead(r) {
501
647
  return classifyLiveness(r, me()).verdict === "dead";
502
648
  }
503
- function lockDenied(reason) {
504
- return { acquired: false, release: () => { }, reason };
505
- }
506
- function invokeLockHook(fn, ctx) {
507
- if (!fn)
508
- return;
509
- try {
510
- fn(ctx);
511
- }
512
- catch {
513
- // Observation-only: a test hook must never break the protocol.
649
+ /**
650
+ * Token-fenced escape hatch, using the same one-shot SUCC election as acquisition.
651
+ * It never owns or republishes LOCK and never sends a destructive process signal.
652
+ * An automatic successor already elected for g excludes the operator. After g is
653
+ * removed, a pending automatic retire sees absence and may publish a fresh LOCK.
654
+ */
655
+ export function breakLockAsOperator(lockPath, options) {
656
+ const g = options?.expectToken;
657
+ if (typeof g !== "string" || !LOCK_TOKEN_RE.test(g)
658
+ || (g.startsWith("legacy-") && !/^legacy-[a-f0-9]{64}$/.test(g))) {
659
+ return { broken: false, reason: "invalid-token", legacyRecords: 0 };
660
+ }
661
+ const holder = readLockHolder(lockPath);
662
+ if (holder === "absent" || holder === "corrupt") {
663
+ return { broken: false, reason: holder, legacyRecords: 0 };
664
+ }
665
+ const legacyRecords = holder.kind === "legacy" ? 1 : 0;
666
+ const context = {
667
+ token: holder.token, pid: holder.pid, legacyRecords,
668
+ ...(legacyRecords === 1 ? { diagnostic: {
669
+ code: "legacy-record",
670
+ command: `h2a identity unlock --token ${holder.token} --assert-dead`
671
+ } } : {})
672
+ };
673
+ const refused = (reason) => ({ broken: false, reason, ...context });
674
+ if (holder.token !== g)
675
+ return refused("token-mismatch");
676
+ const operatorSelf = me();
677
+ const verdict = classifyLiveness(holder, operatorSelf).verdict;
678
+ if (verdict === "live")
679
+ return refused("live");
680
+ if (verdict === "undecidable" && options.assertDead !== true)
681
+ return refused("assert-dead-required");
682
+ let t = g;
683
+ for (let depth = 0; depth < PREFIX_LOCK_MAX_CHAIN; depth++) {
684
+ // A retry never silently changes the requested target to a replacement owner.
685
+ const current = readLockHolder(lockPath);
686
+ if (current === "corrupt")
687
+ return refused("corrupt");
688
+ if (current === "absent" || current.token !== g)
689
+ return refused("token-mismatch");
690
+ const successor = {
691
+ ...makeLockRecFor(operatorSelf, newToken(), g),
692
+ ...(verdict === "undecidable" ? { operator: true } : {})
693
+ };
694
+ const published = publishLockRecord(succPathFor(lockPath, t), successor);
695
+ if (published.status === "error")
696
+ return refused(`error:${published.code}`);
697
+ if (published.status === "retry")
698
+ return refused("busy");
699
+ if (published.status === "exists") {
700
+ const link = readLockRecord(succPathFor(lockPath, t));
701
+ if (link === "absent")
702
+ return refused("busy");
703
+ if (link === "corrupt" || link.target !== g)
704
+ return refused("dead-undecidable");
705
+ const linkVerdict = classifyLiveness(link, operatorSelf).verdict;
706
+ if (linkVerdict !== "dead")
707
+ return refused(linkVerdict === "live" ? "busy" : "dead-undecidable");
708
+ t = link.token;
709
+ continue;
710
+ }
711
+ // Election makes this the sole successor. Re-read as the last action before
712
+ // unlink, including the raw-byte legacy token, so a winning LOCK survives.
713
+ const confirmed = readLockHolder(lockPath);
714
+ if (confirmed === "corrupt")
715
+ return refused("corrupt");
716
+ if (confirmed === "absent" || confirmed.token !== g) {
717
+ purgeSuccession(dirname(lockPath), lockPath, g);
718
+ return refused("token-mismatch");
719
+ }
720
+ try {
721
+ unlinkSync(lockPath);
722
+ }
723
+ catch (error) {
724
+ if (errnoOf(error) !== "ENOENT")
725
+ return refused(`error:${errnoOf(error)}`);
726
+ }
727
+ purgeSuccession(dirname(lockPath), lockPath, g);
728
+ return { broken: true, ...context };
514
729
  }
730
+ return refused("dead-undecidable");
515
731
  }
516
- /** opus `acquirePrefixLock`, plus M5 reasons and test-only critical hooks. */
517
- export function acquirePrefixLock(prefix, hooks = {}) {
732
+ function lockDenied(reason) {
733
+ return { acquired: false, stillHeld: () => false, release: () => { }, reason };
734
+ }
735
+ /** Production acquisition. Extra JavaScript arguments cannot inject dependencies. */
736
+ export function acquirePrefixLock(prefix, options = {}) {
518
737
  const lockPath = lockPathFor(prefix);
519
738
  try {
520
739
  mkdirSync(prefix, { recursive: true });
@@ -524,26 +743,39 @@ export function acquirePrefixLock(prefix, hooks = {}) {
524
743
  }
525
744
  const self = me();
526
745
  for (let round = 0; round < PREFIX_LOCK_MAX_ROUNDS; round++) {
527
- if (round === 0) {
528
- invokeLockHook(hooks.beforePublishLock, { prefix, lockPath, path: lockPath, round, depth: 0 });
746
+ if (options?.readFirst) {
747
+ const first = readLockHolderDetailed(lockPath);
748
+ // An I/O failure says nothing about presence; retain ordinary errno handling.
749
+ if (typeof first === "object" && first.kind === "io-error") {
750
+ // Fall through to ordinary publication.
751
+ }
752
+ else if (first !== "absent") {
753
+ if (first === "corrupt")
754
+ return lockDenied("dead-undecidable");
755
+ const firstLiveness = classifyLiveness(first, self).verdict;
756
+ if (firstLiveness !== "dead") {
757
+ return lockDenied(firstLiveness === "live" ? "busy" : "dead-undecidable");
758
+ }
759
+ }
760
+ // A preliminary death authorizes nothing: publish, then read/classify afresh.
529
761
  }
530
762
  const tok = newToken();
531
- const pub = publishLockRecord(lockPath, makeLockRec(tok));
763
+ const pub = publishLockRecord(lockPath, makeLockRecFor(self, tok));
532
764
  if (pub.status === "ok")
533
765
  return makeLease(prefix, lockPath, tok);
534
766
  if (pub.status === "error")
535
767
  return lockDenied(`error:${pub.code}`);
536
768
  if (pub.status === "retry")
537
769
  continue;
538
- const cur = readLockRecord(lockPath);
770
+ const cur = readLockRecord(lockPath); // fresh after EEXIST; sole entry to succession
539
771
  if (cur === "absent")
540
772
  continue; // released meanwhile
541
773
  if (cur === "corrupt")
542
774
  return lockDenied("dead-undecidable"); // fail closed
543
- const live = livenessOf(cur, self);
775
+ const live = classifyLiveness(cur, self).verdict;
544
776
  if (live !== "dead")
545
777
  return lockDenied(live === "live" ? "busy" : "dead-undecidable");
546
- const next = succeedDeadToken(prefix, lockPath, cur.token, hooks, round);
778
+ const next = succeedDeadToken(prefix, lockPath, cur.token, self);
547
779
  if (next !== "retry")
548
780
  return next;
549
781
  }
@@ -554,23 +786,13 @@ export function acquirePrefixLock(prefix, hooks = {}) {
554
786
  * SUCC(g) -> SUCC(r0) -> ...; publishing a link elects the sole live
555
787
  * successor (Lemma B). "retry" means the chain settled meanwhile (re-read).
556
788
  */
557
- function succeedDeadToken(prefix, lockPath, g, hooks, round) {
558
- const self = me();
789
+ function succeedDeadToken(prefix, lockPath, g, self) {
559
790
  let t = g;
560
791
  for (let depth = 0; depth < PREFIX_LOCK_MAX_CHAIN; depth++) {
561
792
  const tok = newToken();
562
- const pub = publishLockRecord(succPathFor(lockPath, t), makeLockRec(tok, g));
793
+ const pub = publishLockRecord(succPathFor(lockPath, t), makeLockRecFor(self, tok, g));
563
794
  if (pub.status === "ok") {
564
- invokeLockHook(hooks.afterPublishSucc, {
565
- prefix,
566
- lockPath,
567
- path: succPathFor(lockPath, t),
568
- token: tok,
569
- target: g,
570
- round,
571
- depth
572
- });
573
- return retireDeadToken(prefix, lockPath, g, hooks, round, depth);
795
+ return retireDeadToken(prefix, lockPath, g, self);
574
796
  }
575
797
  if (pub.status === "error")
576
798
  return lockDenied(`error:${pub.code}`);
@@ -581,7 +803,7 @@ function succeedDeadToken(prefix, lockPath, g, hooks, round) {
581
803
  return "retry"; // chain already settled
582
804
  if (s === "corrupt" || s.target !== g)
583
805
  return lockDenied("dead-undecidable");
584
- const live = livenessOf(s, self);
806
+ const live = classifyLiveness(s, self).verdict;
585
807
  if (live !== "dead")
586
808
  return lockDenied(live === "live" ? "busy" : "dead-undecidable");
587
809
  t = s.token; // it died: succeed it
@@ -593,19 +815,11 @@ function succeedDeadToken(prefix, lockPath, g, hooks, round) {
593
815
  * opus `retire`: targeted removal (S) of exactly g (Lemma C), then purge the
594
816
  * now-inert SUCC files targeting g (I5), then publish a fresh token.
595
817
  */
596
- function retireDeadToken(prefix, lockPath, g, hooks, round, depth) {
818
+ function retireDeadToken(prefix, lockPath, g, self) {
597
819
  const cur = readLockRecord(lockPath);
598
820
  if (cur === "corrupt")
599
821
  return lockDenied("dead-undecidable"); // keep our SUCC: fail closed
600
822
  if (cur !== "absent" && cur.token === g) {
601
- invokeLockHook(hooks.beforeRetireUnlink, {
602
- prefix,
603
- lockPath,
604
- path: lockPath,
605
- target: g,
606
- round,
607
- depth
608
- });
609
823
  // Lemma C (targeted removal), hardened: re-read LOCK as the LAST step before the
610
824
  // unlink and remove it ONLY while it is STILL exactly g. Successor uniqueness
611
825
  // (Lemma B) + monotonicity (I2) prove LOCK cannot legally change from g under this
@@ -631,14 +845,6 @@ function retireDeadToken(prefix, lockPath, g, hooks, round, depth) {
631
845
  catch (e) {
632
846
  unlinkCode = errnoOf(e);
633
847
  }
634
- invokeLockHook(hooks.afterRetireUnlink, {
635
- prefix,
636
- lockPath,
637
- path: lockPath,
638
- target: g,
639
- round,
640
- depth
641
- });
642
848
  // LOCK != g not established: keep our SUCC file, fail closed.
643
849
  if (unlinkCode !== undefined && unlinkCode !== "ENOENT") {
644
850
  return lockDenied(`error:${unlinkCode}`);
@@ -649,7 +855,7 @@ function retireDeadToken(prefix, lockPath, g, hooks, round, depth) {
649
855
  // From here LOCK != g forever (I2): every SUCC targeting g is inert.
650
856
  purgeSuccession(prefix, lockPath, g); // unlink SUCC files whose target === g
651
857
  const tok = newToken();
652
- const pub = publishLockRecord(lockPath, makeLockRec(tok));
858
+ const pub = publishLockRecord(lockPath, makeLockRecFor(self, tok));
653
859
  if (pub.status === "ok")
654
860
  return makeLease(prefix, lockPath, tok);
655
861
  if (pub.status === "exists" || pub.status === "retry")
@@ -662,6 +868,12 @@ function retireDeadToken(prefix, lockPath, g, hooks, round, depth) {
662
868
  */
663
869
  function makeLease(prefix, lockPath, token) {
664
870
  let done = false;
871
+ const stillHeld = () => {
872
+ if (done)
873
+ return false;
874
+ const cur = readLockRecord(lockPath);
875
+ return cur !== "absent" && cur !== "corrupt" && cur.token === token;
876
+ };
665
877
  const release = () => {
666
878
  if (done)
667
879
  return;
@@ -690,7 +902,7 @@ function makeLease(prefix, lockPath, token) {
690
902
  // best-effort
691
903
  }
692
904
  collectLockDebris(prefix, lockPath, token); // holder-only GC (I5-safe)
693
- return { acquired: true, release, token };
905
+ return { acquired: true, stillHeld, release, token };
694
906
  }
695
907
  /** Unlink SUCC files whose target === g (called only when LOCK != g can hold). */
696
908
  function purgeSuccession(prefix, lockPath, g) {