@sentropic/h2a-runtime 0.98.4 → 0.98.6

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 (72) hide show
  1. package/dist/identity-cull/cull.d.ts.map +1 -1
  2. package/dist/identity-cull/cull.js +35 -89
  3. package/dist/identity-cull/cull.js.map +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +32 -8
  6. package/dist/index.js.map +1 -1
  7. package/dist/llm-mesh-accounts.d.ts +15 -2
  8. package/dist/llm-mesh-accounts.d.ts.map +1 -1
  9. package/dist/llm-mesh-accounts.js +13 -3
  10. package/dist/llm-mesh-accounts.js.map +1 -1
  11. package/dist/native-host.d.ts +3 -1
  12. package/dist/native-host.d.ts.map +1 -1
  13. package/dist/native-host.js +16 -5
  14. package/dist/native-host.js.map +1 -1
  15. package/dist/native-terminal/client.d.ts +2 -2
  16. package/dist/native-terminal/client.d.ts.map +1 -1
  17. package/dist/native-terminal/client.js +2 -2
  18. package/dist/native-terminal/client.js.map +1 -1
  19. package/dist/native-terminal/crash-safe-log.d.ts +17 -0
  20. package/dist/native-terminal/crash-safe-log.d.ts.map +1 -0
  21. package/dist/native-terminal/crash-safe-log.js +79 -0
  22. package/dist/native-terminal/crash-safe-log.js.map +1 -0
  23. package/dist/native-terminal/fault-counters.d.ts +38 -0
  24. package/dist/native-terminal/fault-counters.d.ts.map +1 -0
  25. package/dist/native-terminal/fault-counters.js +46 -0
  26. package/dist/native-terminal/fault-counters.js.map +1 -0
  27. package/dist/native-terminal/host.d.ts +21 -5
  28. package/dist/native-terminal/host.d.ts.map +1 -1
  29. package/dist/native-terminal/host.js +202 -109
  30. package/dist/native-terminal/host.js.map +1 -1
  31. package/dist/native-terminal/journal.d.ts +5 -1
  32. package/dist/native-terminal/journal.d.ts.map +1 -1
  33. package/dist/native-terminal/journal.js +6 -2
  34. package/dist/native-terminal/journal.js.map +1 -1
  35. package/dist/native-terminal/op.d.ts +3 -1
  36. package/dist/native-terminal/op.d.ts.map +1 -1
  37. package/dist/native-terminal/op.js +62 -42
  38. package/dist/native-terminal/op.js.map +1 -1
  39. package/dist/native-terminal/process.d.ts.map +1 -1
  40. package/dist/native-terminal/process.js +41 -12
  41. package/dist/native-terminal/process.js.map +1 -1
  42. package/dist/native-terminal/publication-refusal.d.ts +22 -0
  43. package/dist/native-terminal/publication-refusal.d.ts.map +1 -0
  44. package/dist/native-terminal/publication-refusal.js +26 -0
  45. package/dist/native-terminal/publication-refusal.js.map +1 -0
  46. package/dist/native-terminal/server.d.ts +40 -2
  47. package/dist/native-terminal/server.d.ts.map +1 -1
  48. package/dist/native-terminal/server.js +528 -180
  49. package/dist/native-terminal/server.js.map +1 -1
  50. package/dist/native-terminal/socket-path.d.ts +1 -0
  51. package/dist/native-terminal/socket-path.d.ts.map +1 -1
  52. package/dist/native-terminal/socket-path.js +3 -3
  53. package/dist/native-terminal/socket-path.js.map +1 -1
  54. package/dist/native-terminal/supervisor.d.ts.map +1 -1
  55. package/dist/native-terminal/supervisor.js +69 -1
  56. package/dist/native-terminal/supervisor.js.map +1 -1
  57. package/dist/proc-identity.d.ts +63 -1
  58. package/dist/proc-identity.d.ts.map +1 -1
  59. package/dist/proc-identity.js +98 -1
  60. package/dist/proc-identity.js.map +1 -1
  61. package/dist/pty.d.ts.map +1 -1
  62. package/dist/pty.js +18 -0
  63. package/dist/pty.js.map +1 -1
  64. package/dist/registry.d.ts +15 -0
  65. package/dist/registry.d.ts.map +1 -1
  66. package/dist/registry.js +17 -4
  67. package/dist/registry.js.map +1 -1
  68. package/dist/tmux.d.ts +2 -0
  69. package/dist/tmux.d.ts.map +1 -1
  70. package/dist/tmux.js +14 -2
  71. package/dist/tmux.js.map +1 -1
  72. package/package.json +3 -2
@@ -1,13 +1,16 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { fstatSync, lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, statSync, unlinkSync } from "node:fs";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, statSync, unlinkSync } from "node:fs";
3
3
  import { chmod, link, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
4
4
  import { createConnection, createServer } from "node:net";
5
5
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { writeHostLogLine } from "./crash-safe-log.js";
7
+ import { HOST_FAULT_COUNTERS, recordHostFault } from "./fault-counters.js";
6
8
  import { NativeTerminalHost, defaultOwnerHostProbe, readProcessStartTime, } from "./host.js";
7
9
  import { NATIVE_TERMINAL_MAX_FRAME_BYTES, NATIVE_TERMINAL_MAX_CONNECTIONS, NATIVE_TERMINAL_MAX_ERROR_MESSAGE_CHARS, NATIVE_TERMINAL_MAX_IDENTIFIER_CHARS, NATIVE_TERMINAL_MAX_PENDING_RESPONSE_BYTES_PER_CONNECTION, NATIVE_TERMINAL_MAX_PENDING_RESPONSE_BYTES_TOTAL, NATIVE_TERMINAL_MAX_PENDING_RESPONSES_PER_CONNECTION, NATIVE_TERMINAL_PROTOCOL_VERSION, isNativeTerminalStopSignal, isRecord, parseNativeTerminalRequest, } from "./protocol.js";
8
- import { assertNativeTerminalSocketPathWithinLimit, assertPrivateNativeTerminalSocketDirectory, inspectPrivateNativeTerminalSocket, sameNativeTerminalSocket, } from "./socket-path.js";
10
+ import { NATIVE_TERMINAL_SOCKET_MAX_PATH_BYTES, assertNativeTerminalSocketPathWithinLimit, assertPrivateNativeTerminalSocketDirectory, inspectPrivateNativeTerminalSocket, sameNativeTerminalSocket, } from "./socket-path.js";
9
11
  import { listNativeTerminalPgidEntries, loadRegistry } from "../registry.js";
10
- import { readBootId, readPidNamespaceId } from "../proc-identity.js";
12
+ import { classifyDurableProcFrame, readProcessFrame, } from "../proc-identity.js";
13
+ import { NativeTerminalPublicationRefusal } from "./publication-refusal.js";
11
14
  const SOCKET_PUBLICATION_LOCK_TIMEOUT_MS = 5_000;
12
15
  const SOCKET_PUBLICATION_LOCK_RETRY_MS = 25;
13
16
  const SOCKET_PUBLICATION_LOCK_ID_FILE = ".h2a-native-terminal.lock-id";
@@ -167,7 +170,14 @@ function dispatch(host, context, request) {
167
170
  return host.attachObserver(sessionId(params));
168
171
  case "acquire-controller": {
169
172
  const record = requiredRecord(params, "params");
170
- const lease = host.acquireController(requiredIdentifier(record.id, "session id"), requiredIdentifier(record.controllerId, "controller id"), controllerActivity(record.activity));
173
+ if (record.takeover !== undefined && typeof record.takeover !== "boolean") {
174
+ throw new TypeError("terminal controller takeover must be a boolean");
175
+ }
176
+ const lease = host.acquireController(requiredIdentifier(record.id, "session id"), requiredIdentifier(record.controllerId, "controller id"), controllerActivity(record.activity), {
177
+ ...(record.takeover !== undefined ? { takeover: record.takeover } : {}),
178
+ ...(record.pid !== undefined ? { pid: requiredInteger(record.pid, "controller pid") } : {}),
179
+ ...(record.tty !== undefined ? { tty: requiredString(record.tty, "controller tty") } : {}),
180
+ });
171
181
  context.leases.set(lease.id, lease);
172
182
  return lease;
173
183
  }
@@ -308,6 +318,7 @@ function consumeFrames(host, context, chunk) {
308
318
  continue;
309
319
  if (Buffer.byteLength(line) > NATIVE_TERMINAL_MAX_FRAME_BYTES) {
310
320
  writeError(context, "invalid", "invalid-request", new Error("terminal request exceeds the frame limit"));
321
+ recordHostFault(HOST_FAULT_COUNTERS.hostClientFrameDestroys);
311
322
  context.socket.destroy();
312
323
  return;
313
324
  }
@@ -347,22 +358,10 @@ function consumeFrames(host, context, chunk) {
347
358
  }
348
359
  if (Buffer.byteLength(context.buffer) > NATIVE_TERMINAL_MAX_FRAME_BYTES) {
349
360
  writeError(context, "invalid", "invalid-request", new Error("terminal request exceeds the frame limit"));
361
+ recordHostFault(HOST_FAULT_COUNTERS.hostClientFrameDestroys);
350
362
  context.socket.destroy();
351
363
  }
352
364
  }
353
- async function socketAcceptsConnections(socketPath, signal) {
354
- return abortable(new Promise((resolve) => {
355
- const socket = createConnection(socketPath);
356
- const cancel = () => socket.destroy();
357
- signal?.addEventListener("abort", cancel, { once: true });
358
- socket.once("close", () => signal?.removeEventListener("abort", cancel));
359
- socket.once("connect", () => {
360
- socket.destroy();
361
- resolve(true);
362
- });
363
- socket.once("error", () => resolve(false));
364
- }), signal);
365
- }
366
365
  async function socketPublicationLockSecret(socketPath, signal) {
367
366
  if (process.platform !== "linux" || process.getuid === undefined) {
368
367
  throw new Error("native terminal socket publication requires Linux abstract Unix sockets");
@@ -401,11 +400,13 @@ async function socketPublicationLockSecret(socketPath, signal) {
401
400
  || identity.isSymbolicLink()
402
401
  || identity.uid !== process.getuid()
403
402
  || (identity.mode & 0o777) !== 0o600) {
404
- throw new Error(`terminal socket publication lock identity is not a private owned file: ${identityPath}`);
403
+ // T4: a lock-id that is not a private owned file is a coded refusal,
404
+ // never an undifferentiated startup crash.
405
+ throw new NativeTerminalPublicationRefusal("H2A_PUBLICATION_LOCK_ID_MALFORMED");
405
406
  }
406
407
  const secret = (await abortable(readFile(identityPath, "utf8"), signal)).trim();
407
408
  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(secret)) {
408
- throw new Error(`terminal socket publication lock identity is malformed: ${identityPath}`);
409
+ throw new NativeTerminalPublicationRefusal("H2A_PUBLICATION_LOCK_ID_MALFORMED");
409
410
  }
410
411
  return secret;
411
412
  }
@@ -440,7 +441,7 @@ async function acquireSocketPublicationLock(socketPath, signal) {
440
441
  }
441
442
  async function withSocketPublicationLock(socketPath, operation, signal) {
442
443
  const deadline = new AbortController();
443
- const timer = setTimeout(() => deadline.abort(new Error("terminal socket publication lock timed out")), SOCKET_PUBLICATION_LOCK_TIMEOUT_MS);
444
+ const timer = setTimeout(() => deadline.abort(new NativeTerminalPublicationRefusal("H2A_PUBLICATION_LOCK_TIMEOUT")), SOCKET_PUBLICATION_LOCK_TIMEOUT_MS);
444
445
  const boundedSignal = signal === undefined ? deadline.signal : AbortSignal.any([signal, deadline.signal]);
445
446
  try {
446
447
  const lock = await acquireSocketPublicationLock(socketPath, boundedSignal);
@@ -492,14 +493,23 @@ function rollbackEndpoint(socketPath, owned) {
492
493
  }
493
494
  catch { /* Unknown sidecar ownership is never cleanup authority. */ }
494
495
  }
495
- async function recordEndpointOwner(socketPath, socket, processOwner = { pid: process.pid, startTime: readProcessStartTime(process.pid) }, signal) {
496
+ async function recordEndpointOwner(socketPath, socket, processOwner = { pid: process.pid, startTime: readProcessStartTime(process.pid) }, signal, frame,
497
+ /**
498
+ * Stamp a frame OTHER than the reader's current one: used only by the
499
+ * proof-preservation pass, which re-records an identity that a durable row
500
+ * already PROVED dead at a PREVIOUS boot of this machine. Every stamped
501
+ * value (pid, start-time, boot, namespace, machine) then comes from that
502
+ * one row — a coherent epoch — never a mix of a dead boot's numbers with
503
+ * the current boot's frame.
504
+ */
505
+ stampFrame) {
496
506
  signal?.throwIfAborted();
497
507
  if (process.env.H2A_TEST_FAIL_OWNER_WRITE) {
498
508
  throw new Error("simulated failure recording endpoint owner");
499
509
  }
500
510
  const { pid, startTime } = processOwner;
501
- const bootId = readBootId(), pidNamespace = readPidNamespaceId();
502
- if (startTime === undefined || bootId === undefined || pidNamespace === undefined) {
511
+ frame = stampFrame ?? frame;
512
+ if (startTime === undefined || frame.bootId === undefined || frame.pidNamespace === undefined) {
503
513
  throw new Error("cannot certify native host process identity");
504
514
  }
505
515
  const temporary = `${socketPath}.owner.${process.pid}.${randomUUID()}.tmp`;
@@ -523,7 +533,10 @@ async function recordEndpointOwner(socketPath, socket, processOwner = { pid: pro
523
533
  signal?.throwIfAborted();
524
534
  // Write through the already-open inode: cancellation can unlink it without
525
535
  // a delayed write recreating the temporary pathname.
526
- await abortable(writeFile(openedFile, JSON.stringify({ ...socket, pid, startTime, bootId, pidNamespace }), signal === undefined ? {} : { signal }), signal);
536
+ await abortable(writeFile(openedFile, JSON.stringify({ ...socket, pid, startTime, bootId: frame.bootId,
537
+ pidNamespace: frame.pidNamespace, ...(frame.machineId !== undefined ? { machineId: frame.machineId } : {}),
538
+ ...(frame.nsNet !== undefined ? { nsNet: frame.nsNet } : {}),
539
+ ...(frame.bootTimeMs !== undefined ? { bootTimeMs: frame.bootTimeMs } : {}) }), signal === undefined ? {} : { signal }), signal);
527
540
  signal?.throwIfAborted();
528
541
  await abortable(rename(temporary, `${socketPath}.owner`), signal);
529
542
  }
@@ -559,12 +572,12 @@ async function recordEndpointOwner(socketPath, socket, processOwner = { pid: pro
559
572
  * identity. A live or unreadable owner keeps the endpoint unknown.
560
573
  * No socket is removed by an inventory; publication owns reclamation.
561
574
  */
562
- export async function proveNativeTerminalEndpointAbsent(socketPath, failure) {
575
+ export async function proveNativeTerminalEndpointAbsent(socketPath, failure, readFrame = readProcessFrame, ownerProbe = defaultOwnerHostProbe) {
563
576
  const code = failure?.code;
564
577
  if (code !== "ENOENT" && code !== "ECONNREFUSED")
565
578
  return false;
566
579
  await ensurePrivateSocketDirectory(socketPath);
567
- return withSocketPublicationLock(socketPath, signal => proveEndpointAbsentUnderLock(socketPath, signal));
580
+ return withSocketPublicationLock(socketPath, signal => proveEndpointAbsentUnderLock(socketPath, signal, readPublicationFrame(readFrame), ownerProbe));
568
581
  }
569
582
  function canonicalizePath(targetPath) {
570
583
  const resolved = resolve(targetPath);
@@ -583,7 +596,7 @@ function canonicalizePath(targetPath) {
583
596
  }
584
597
  }
585
598
  }
586
- function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST_PROC_ROOT ?? "/proc") {
599
+ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST_PROC_ROOT ?? "/proc", ownerProbe = defaultOwnerHostProbe) {
587
600
  if (process.env.H2A_TEST_PROC_UNAVAILABLE) {
588
601
  return { state: "unknown", reason: "simulated /proc observation unavailable" };
589
602
  }
@@ -607,6 +620,15 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
607
620
  reason: `cannot canonicalize target socket path: ${socketPath}`,
608
621
  };
609
622
  }
623
+ const targetDirectory = statSync(dirname(targetCanonical));
624
+ let targetSocket;
625
+ try {
626
+ targetSocket = statSync(targetCanonical);
627
+ }
628
+ catch (error) {
629
+ if (error.code !== "ENOENT")
630
+ throw error;
631
+ }
610
632
  for (const entry of entries) {
611
633
  if (!/^\d+$/.test(entry))
612
634
  continue;
@@ -650,23 +672,35 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
650
672
  if (!isHost)
651
673
  continue;
652
674
  const candidateCanonical = canonicalizePath(candidateSocket);
653
- if (!candidateCanonical) {
654
- // The alias used at host startup was deleted; canonicalization cannot prove absence
655
- return {
656
- state: "unknown",
657
- reason: `cannot canonicalize host socket path ${candidateSocket} for host pid ${pid}`,
658
- };
675
+ let sameCandidateSocket = false;
676
+ if (targetSocket) {
677
+ try {
678
+ sameCandidateSocket = sameNativeTerminalSocket(statSync(candidateSocket), targetSocket);
679
+ }
680
+ catch { /* An absent published name supplies no direct inode evidence. */ }
659
681
  }
660
- if (candidateCanonical !== targetCanonical)
682
+ // Staged stems collide between the primary and lf1 endpoints. A native
683
+ // publication keeps its full basename; only direct socket identity can
684
+ // override a different endpoint name or a different resolved path.
685
+ if (!sameCandidateSocket && basename(resolve(candidateSocket)) !== basename(targetCanonical))
686
+ continue;
687
+ // An ancestor alias can have been retargeted since bind. Its current
688
+ // resolution alone cannot exclude the old target; pinned bind evidence can.
689
+ const candidateAliased = candidateCanonical !== undefined && candidateCanonical !== resolve(candidateSocket);
690
+ const couldServeTarget = candidateCanonical === undefined
691
+ || candidateCanonical === targetCanonical || sameCandidateSocket || candidateAliased;
692
+ if (!couldServeTarget)
661
693
  continue;
662
694
  const candDir = dirname(candidateSocket);
663
- const candStem = basename(candidateSocket).slice(0, 8).replace(/[^A-Za-z0-9_.-]/g, "_");
664
695
  let isStagingCandidate = false;
665
696
  try {
666
697
  const dirEntries = readdirSync(candDir);
667
- isStagingCandidate = dirEntries.some(name => name.startsWith(`.${candStem}.${pid}.`));
698
+ isStagingCandidate = dirEntries.some(name => isStagedSocketName(name, candidateSocket, pid));
699
+ }
700
+ catch {
701
+ // Staging-directory inspection is optional; the descriptor/inode
702
+ // attribution below still decides live, absent, or unknown.
668
703
  }
669
- catch { }
670
704
  if (isStagingCandidate)
671
705
  continue;
672
706
  const fdDir = join(pidDir, "fd");
@@ -683,20 +717,20 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
683
717
  };
684
718
  }
685
719
  let hasSocketFd = false;
686
- let unixSockets;
720
+ const descriptorLinks = new Map();
721
+ let unreadableDescriptor;
687
722
  for (const fd of fds) {
688
- let link;
689
723
  try {
690
- link = readlinkSync(join(fdDir, fd));
724
+ descriptorLinks.set(fd, readlinkSync(join(fdDir, fd)));
691
725
  }
692
726
  catch (err) {
693
727
  if (err.code === "ENOENT" || err.code === "ESRCH")
694
728
  continue;
695
- return {
696
- state: "unknown",
697
- reason: `cannot readlink /proc/${pid}/fd/${fd}: ${err instanceof Error ? err.message : String(err)}`,
698
- };
729
+ unreadableDescriptor = `cannot readlink /proc/${pid}/fd/${fd}: ${String(err)}`;
699
730
  }
731
+ }
732
+ let unixSockets;
733
+ for (const link of descriptorLinks.values()) {
700
734
  const inode = /^socket:\[(\d+)\]$/.exec(link)?.[1];
701
735
  if (!inode)
702
736
  continue;
@@ -716,25 +750,70 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
716
750
  // Stdio and journal IPC are Unix socket pairs too. Only the native
717
751
  // listener's canonical or PID-attributed staged address proves service,
718
752
  // including a listener whose filesystem pathname has been unlinked.
719
- if (!boundPath || (boundPath !== candidateSocket && !(dirname(boundPath) === candDir
720
- && basename(boundPath).startsWith(`.${candStem}.${pid}.`) && boundPath.endsWith(".sock"))))
753
+ if (!boundPath || !boundPath.startsWith("/"))
721
754
  continue;
722
755
  const canonicalBound = canonicalizePath(boundPath);
723
- if (!canonicalBound)
724
- return { state: "unknown", reason: `cannot canonicalize bound socket for host pid ${pid}` };
725
- if (canonicalBound === targetCanonical || (dirname(canonicalBound) === dirname(targetCanonical)
726
- && basename(canonicalBound).startsWith(`.${candStem}.${pid}.`))) {
756
+ let sameSocket = false;
757
+ if (targetSocket) {
758
+ try {
759
+ sameSocket = sameNativeTerminalSocket(statSync(boundPath), targetSocket);
760
+ }
761
+ catch { /* An unlinked staging name has no filesystem inode to compare. */ }
762
+ }
763
+ const stagedForTarget = isStagedSocketName(basename(boundPath), socketPath, pid);
764
+ if (sameSocket) {
765
+ hasSocketFd = true;
766
+ break;
767
+ }
768
+ // Prefer the pinned directory even when its old pathname resolves again:
769
+ // a replacement directory at that name is a different endpoint. The bind
770
+ // directory is canonical, so its FD stays attributable after alias loss.
771
+ // Never compare the sockfs inode with a VFS inode.
772
+ // The Unix-table row above belongs to a socket this PID actually holds.
773
+ const nativeListener = boundPath === candidateSocket
774
+ || isStagedSocketName(basename(boundPath), candidateSocket, pid);
775
+ if (!nativeListener)
776
+ continue;
777
+ let boundDirectory;
778
+ for (const [fd, path] of descriptorLinks) {
779
+ if (path !== dirname(boundPath) && path !== `${dirname(boundPath)} (deleted)`)
780
+ continue;
781
+ try {
782
+ const info = statSync(join(fdDir, fd));
783
+ if (info.isDirectory() && (path === dirname(boundPath) || info.nlink === 0)) {
784
+ boundDirectory = info;
785
+ break;
786
+ }
787
+ }
788
+ catch { /* Missing directory evidence remains unknown below. */ }
789
+ }
790
+ if (boundDirectory && !sameNativeTerminalSocket(boundDirectory, targetDirectory))
791
+ continue;
792
+ if (boundDirectory && (stagedForTarget || basename(boundPath) === basename(targetCanonical))) {
793
+ hasSocketFd = true;
794
+ break;
795
+ }
796
+ // Older hosts have no directory FD. Resolvable bind evidence can still
797
+ // attribute them; only an opaque listener of this same endpoint remains
798
+ // unknown. Distinct endpoint names were excluded before FD inspection.
799
+ if (canonicalBound === targetCanonical || (canonicalBound !== undefined
800
+ && dirname(canonicalBound) === dirname(targetCanonical) && stagedForTarget)) {
727
801
  hasSocketFd = true;
728
802
  break;
729
803
  }
804
+ if (canonicalBound !== undefined && !candidateAliased)
805
+ continue;
806
+ return { state: "unknown", reason: `cannot attribute bound socket directory for host pid ${pid}` };
730
807
  }
808
+ if (!hasSocketFd && unreadableDescriptor)
809
+ return { state: "unknown", reason: unreadableDescriptor };
731
810
  if (!hasSocketFd)
732
811
  continue;
733
812
  const startTime = readProcessStartTime(pid);
734
813
  if (startTime === undefined) {
735
814
  return { state: "unknown", reason: `cannot read start time for host pid ${pid}` };
736
815
  }
737
- const probe = defaultOwnerHostProbe({ pid, startTime });
816
+ const probe = ownerProbe({ pid, startTime });
738
817
  if (probe === "alive") {
739
818
  return { state: "live", pid, startTime };
740
819
  }
@@ -744,7 +823,71 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
744
823
  }
745
824
  return { state: "absent" };
746
825
  }
747
- async function proveEndpointAbsentUnderLock(socketPath, signal) {
826
+ function readPublicationFrame(readFrame) {
827
+ const frame = readFrame();
828
+ if (frame.machineId === undefined || !/^[0-9a-f]{32}$/.test(frame.machineId) || /^0+$/.test(frame.machineId)) {
829
+ throw new NativeTerminalPublicationRefusal("H2A_MACHINE_ID_UNREADABLE");
830
+ }
831
+ if (classifyDurableProcFrame(frame, frame) !== "current") {
832
+ throw new NativeTerminalPublicationRefusal("H2A_PROC_FRAME_UNKNOWN");
833
+ }
834
+ return frame;
835
+ }
836
+ /** A refused connect says only that nobody accepts now. All five refusals
837
+ * must complete within the global budget; every attempt has its own timer.
838
+ * Abort destroys the socket and cancels the interval before the lock releases. */
839
+ async function repeatedlyRefusesConnection(socketPath, socketPresent, signal) {
840
+ const deadline = new AbortController();
841
+ const timer = setTimeout(() => deadline.abort(new NativeTerminalPublicationRefusal("H2A_ENDPOINT_PROBE_TIMEOUT")), 2_000);
842
+ const bounded = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
843
+ try {
844
+ for (let attempt = 0; attempt < 5; attempt++) {
845
+ bounded.throwIfAborted();
846
+ const refusal = await new Promise((resolve, reject) => {
847
+ const connection = createConnection(socketPath);
848
+ const finish = (error, refused = false) => {
849
+ clearTimeout(unitTimer);
850
+ bounded.removeEventListener("abort", cancel);
851
+ connection.destroy();
852
+ if (error !== undefined)
853
+ reject(error);
854
+ else
855
+ resolve(refused);
856
+ };
857
+ const cancel = () => finish(bounded.reason);
858
+ const unitTimer = setTimeout(() => finish(new NativeTerminalPublicationRefusal("H2A_ENDPOINT_PROBE_TIMEOUT")), 500);
859
+ bounded.addEventListener("abort", cancel, { once: true });
860
+ connection.once("connect", () => finish());
861
+ connection.once("error", error => {
862
+ const code = error.code;
863
+ if (code === "ECONNREFUSED" || (!socketPresent && code === "ENOENT"))
864
+ finish(undefined, true);
865
+ else
866
+ finish(error);
867
+ });
868
+ });
869
+ if (!refusal)
870
+ return false;
871
+ if (attempt < 4) {
872
+ await new Promise((resolve, reject) => {
873
+ const cancel = () => { clearTimeout(interval); reject(bounded.reason); };
874
+ const interval = setTimeout(() => { bounded.removeEventListener("abort", cancel); resolve(); }, 250);
875
+ bounded.addEventListener("abort", cancel, { once: true });
876
+ if (bounded.aborted)
877
+ cancel();
878
+ });
879
+ }
880
+ }
881
+ bounded.throwIfAborted();
882
+ return true;
883
+ }
884
+ finally {
885
+ clearTimeout(timer);
886
+ }
887
+ }
888
+ /** Caller holds withSocketPublicationLock through classification, probes,
889
+ * inode recheck, reclamation, link and owner stamping (R2). */
890
+ async function proveEndpointAbsentUnderLock(socketPath, signal, currentFrame, ownerProbe = defaultOwnerHostProbe) {
748
891
  signal?.throwIfAborted();
749
892
  let socket;
750
893
  try {
@@ -754,95 +897,135 @@ async function proveEndpointAbsentUnderLock(socketPath, signal) {
754
897
  if (error.code !== "ENOENT")
755
898
  throw error;
756
899
  }
757
- if (socket) {
758
- // A listener may have appeared since the failed inventory connection.
759
- // Only an explicit refusal qualifies; permissions and all other errors
760
- // remain unknown, including a changed socket incarnation.
761
- const refusal = await abortable(new Promise((resolve, reject) => {
762
- const connection = createConnection(socketPath);
763
- const cancel = () => connection.destroy();
764
- signal?.addEventListener("abort", cancel, { once: true });
765
- connection.once("close", () => signal?.removeEventListener("abort", cancel));
766
- connection.setTimeout(1_000, () => { connection.destroy(); reject(new Error("terminal host connection timed out")); });
767
- connection.once("connect", () => { connection.destroy(); resolve(false); });
768
- connection.once("error", error => error.code === "ECONNREFUSED" ? resolve(true) : reject(error));
769
- }), signal);
770
- if (!refusal || !sameNativeTerminalSocket(socket, await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal)))
771
- return false;
772
- }
773
- // Independent proof of host vivacity: a live host that lost its pathname
774
- // but still serves must never allow a second writer.
775
- // /proc read errors => unknown, never absent.
776
- const activeScan = findActiveHostServingSocket(socketPath);
777
- if (activeScan.state === "live") {
778
- return false;
779
- }
900
+ // Normative order: active scan, all three sources, classification, probes.
901
+ // A live host serving this endpoint — including an OLDER published host
902
+ // during an upgrade (scenario 9) — is the strongest refusal there is: the
903
+ // candidate must never unlink or republish over it.
904
+ // Seam symmetry (F4): the scan liveness probe is the INJECTED ownerProbe so
905
+ // the "zero probes for stale-boot sources" assertions observe this path too.
906
+ const activeScan = findActiveHostServingSocket(socketPath, undefined, ownerProbe);
907
+ if (activeScan.state === "live")
908
+ throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_LIVE_OWNER");
780
909
  if (activeScan.state === "unknown") {
781
- throw new Error(`terminal host state is unprovable (${activeScan.reason}): ${socketPath}`);
910
+ // The frozen literal carries the sense (T8); the scan's own reason (e.g.
911
+ // which /proc view was unreadable) rides as `cause` so operators keep the
912
+ // diagnostic the old generic throw used to inline.
913
+ throw Object.assign(new NativeTerminalPublicationRefusal("H2A_ENDPOINT_SCAN_UNKNOWN"), {
914
+ cause: new Error(activeScan.reason),
915
+ });
782
916
  }
917
+ let sidecar;
918
+ const ownerPath = `${socketPath}.owner`;
783
919
  try {
784
- const ownerPath = `${socketPath}.owner`;
785
920
  const info = await abortable(lstat(ownerPath), signal);
786
921
  if (!info.isFile() || info.uid !== process.getuid?.() || (info.mode & 0o777) !== 0o600) {
787
- throw new Error(`native endpoint owner identity is not a private owned file: ${ownerPath}`);
922
+ throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_OWNER_MALFORMED");
788
923
  }
789
- const owner = JSON.parse(await abortable(readFile(ownerPath, "utf8"), signal));
790
- if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0 || !Number.isSafeInteger(owner.startTime) ||
791
- owner.startTime < 0 || !Number.isSafeInteger(owner.dev) || !Number.isSafeInteger(owner.ino) ||
792
- typeof owner.bootId !== "string" || typeof owner.pidNamespace !== "string") {
793
- throw new Error(`native endpoint owner identity is malformed: ${ownerPath}`);
924
+ try {
925
+ const parsed = JSON.parse(await abortable(readFile(ownerPath, "utf8"), signal));
926
+ // Valid JSON that is not an object (null, a bare string, a number) is
927
+ // the same coded refusal (T4): the shape is validated BEFORE any
928
+ // property access, never an undifferentiated TypeError crash (exit 1).
929
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
930
+ throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_OWNER_MALFORMED");
931
+ }
932
+ sidecar = parsed;
933
+ }
934
+ catch (error) {
935
+ if (signal?.aborted || error instanceof NativeTerminalPublicationRefusal)
936
+ throw error;
937
+ // Unparseable sidecar content is a coded refusal (T4), never an
938
+ // undifferentiated crash: the file is local fail-closed evidence.
939
+ throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_OWNER_MALFORMED");
940
+ }
941
+ if (!Number.isSafeInteger(sidecar.pid) || sidecar.pid <= 0 || !Number.isSafeInteger(sidecar.startTime) ||
942
+ sidecar.startTime < 0 || !Number.isSafeInteger(sidecar.dev) || !Number.isSafeInteger(sidecar.ino) ||
943
+ typeof sidecar.bootId !== "string" || typeof sidecar.pidNamespace !== "string" ||
944
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(sidecar.bootId) || !/^\d+$/.test(sidecar.pidNamespace) ||
945
+ (sidecar.machineId !== undefined && typeof sidecar.machineId !== "string")) {
946
+ throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_OWNER_MALFORMED");
794
947
  }
795
- if (socket && !sameNativeTerminalSocket(socket, owner))
796
- return false;
797
- if (owner.bootId !== readBootId() || owner.pidNamespace !== readPidNamespaceId())
798
- return false;
799
- if (defaultOwnerHostProbe(owner) !== "dead")
800
- return false;
801
- if (socket)
802
- return true;
803
- // A pre-upgrade publisher may have replaced and then lost the pathname
804
- // without updating this sidecar. Its durable PTY owners must also be dead.
805
948
  }
806
949
  catch (error) {
807
950
  if (error.code !== "ENOENT")
808
951
  throw error;
809
952
  }
810
- // Pre-upgrade hosts already persist this identity for each PTY. A stale
811
- // socket without any attribution is unprovable; never infer death from
812
- // a bare PID, a socket's age, or an empty/error-collapsed registry.
813
953
  const snapshot = listNativeTerminalPgidEntries();
814
- if (!snapshot.known) {
815
- // This decoder deliberately calls an ENOENT registry "unknown" for
816
- // orphan reaping. For endpoint discovery, no file means no attribution;
817
- // it proves absence only when the socket is also absent under the lock.
818
- if (snapshot.reason === "registry is absent")
819
- return socket === undefined;
820
- throw new Error(`native endpoint owner registry is unknown: ${snapshot.reason}`);
821
- }
822
- const canonicalTarget = canonicalizePath(socketPath);
823
- if (!canonicalTarget) {
824
- throw new Error(`cannot canonicalize socket path: ${socketPath}`);
825
- }
826
- const owners = snapshot.entries
827
- .filter(entry => entry.owner?.socketPath && canonicalizePath(entry.owner.socketPath) === canonicalTarget)
828
- .map(entry => entry.owner);
829
954
  const registry = loadRegistry();
955
+ if (!snapshot.known && snapshot.reason !== "registry is absent")
956
+ throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_REGISTRY_UNKNOWN");
830
957
  if (registry.state === "unknown" || registry.unreadable.length > 0)
958
+ throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_REGISTRY_UNKNOWN");
959
+ const canonicalTarget = canonicalizePath(socketPath);
960
+ if (!canonicalTarget)
961
+ throw new Error(`cannot canonicalize socket path: ${socketPath}`);
962
+ const matches = (path) => path !== undefined && canonicalizePath(path) === canonicalTarget;
963
+ const attributedRows = registry.entries.filter(row => matches(row.ownerHostSocketPath));
964
+ const attributedPgid = snapshot.known ? snapshot.entries.filter(entry => matches(entry.owner?.socketPath)) : [];
965
+ // Legacy sidecars are local, unattributed evidence (R3); registry and pgid
966
+ // records without a machine anchor remain blocking foreign frames (R1).
967
+ const sidecarVerdict = sidecar?.machineId === undefined ? undefined : classifyDurableProcFrame(sidecar, currentFrame);
968
+ const classifiedPgid = attributedPgid.map(entry => ({ entry, verdict: classifyDurableProcFrame(entry.frame, currentFrame) }));
969
+ const classifiedRows = attributedRows.map(row => ({ row, verdict: classifyDurableProcFrame({
970
+ pidNamespace: row.pgidPidNamespace, bootId: row.pgidBootId, machineId: row.pgidMachineId,
971
+ }, currentFrame) }));
972
+ if ((socket && sidecar && !sameNativeTerminalSocket(socket, sidecar)) || sidecarVerdict === "foreign-frame" ||
973
+ classifiedPgid.some(item => item.verdict === "foreign-frame") || classifiedRows.some(item => item.verdict === "foreign-frame"))
831
974
  return false;
832
- const rows = registry.entries.filter(entry => entry.ownerHostSocketPath && canonicalizePath(entry.ownerHostSocketPath) === canonicalTarget);
833
- if (rows.some(row => row.pgidBootId !== readBootId() || row.pgidPidNamespace !== readPidNamespaceId()))
975
+ // Owners are derived only AFTER every source has been classified.
976
+ const currentOwners = [];
977
+ if (sidecarVerdict === "current" && sidecar)
978
+ currentOwners.push(sidecar);
979
+ for (const { entry, verdict } of classifiedPgid)
980
+ if (verdict === "current" && entry.owner)
981
+ currentOwners.push(entry.owner);
982
+ for (const { row, verdict } of classifiedRows)
983
+ if (verdict === "current") {
984
+ if (row.ownerHostPid === undefined)
985
+ return false;
986
+ currentOwners.push({ pid: row.ownerHostPid, ...(row.ownerHostStartTime !== undefined ? { startTime: row.ownerHostStartTime } : {}) });
987
+ }
988
+ const uniqueOwners = [...new Map(currentOwners.map(owner => [`${owner.pid}:${owner.startTime}`, owner])).values()];
989
+ const ownersDead = uniqueOwners.every(owner => owner.startTime !== undefined && ownerProbe(owner) === "dead");
990
+ const sidecarProvesDeath = sidecarVerdict === "stale-boot" || (sidecarVerdict === "current" && ownersDead);
991
+ // Even a run of refusals cannot prove a new, unattributed holder dead.
992
+ if (!await repeatedlyRefusesConnection(socketPath, socket !== undefined, signal))
993
+ return false;
994
+ let preBootInode = false;
995
+ try {
996
+ const final = await abortable(lstat(socketPath), signal);
997
+ if (!socket || !sameNativeTerminalSocket(socket, final))
998
+ return false;
999
+ preBootInode = currentFrame.bootTimeMs !== undefined && Number.isFinite(currentFrame.bootTimeMs) && currentFrame.bootTimeMs > 0 &&
1000
+ final.birthtimeMs > 0 && final.birthtimeMs < currentFrame.bootTimeMs;
1001
+ }
1002
+ catch (error) {
1003
+ if (error.code !== "ENOENT")
1004
+ throw error;
1005
+ if (socket)
1006
+ return false;
1007
+ }
1008
+ if (!ownersDead)
834
1009
  return false;
835
- if (owners.some(owner => owner.startTime === undefined || defaultOwnerHostProbe(owner) !== "dead"))
1010
+ const staleProof = classifiedRows.find(item => item.verdict === "stale-boot" && item.row.ownerHostPid !== undefined && item.row.ownerHostStartTime !== undefined)?.row;
1011
+ const currentDead = uniqueOwners[0];
1012
+ // Stale rows prove the death of their AUTHORS, never the absence of a NEW
1013
+ // holder of the pathname (R4-probe): they cannot bypass the age/liveness
1014
+ // proof. Without a sidecar death proof or a CURRENT-boot owner positively
1015
+ // probed dead, an existing socket yields publication only to the pre-boot
1016
+ // inode proof (R3 combo 4): an inode created during this boot may have a
1017
+ // live, unattributed holder that refused every connect probe.
1018
+ if (socket && !sidecarProvesDeath && currentDead === undefined && !preBootInode)
836
1019
  return false;
837
- if (socket && owners.length > 0) {
838
- // Preserve the death proof before the supervisor reaps/prunes the last
839
- // pre-upgrade PTY row. Reclamation must still have attribution then.
840
- const owner = owners[0];
841
- await recordEndpointOwner(socketPath, socket, { pid: owner.pid, startTime: owner.startTime }, signal);
1020
+ if (socket && currentDead !== undefined) {
1021
+ await recordEndpointOwner(socketPath, socket, { pid: currentDead.pid, startTime: currentDead.startTime }, signal, currentFrame);
1022
+ }
1023
+ else if (socket && !sidecarProvesDeath && staleProof !== undefined) {
1024
+ await recordEndpointOwner(socketPath, socket, { pid: staleProof.ownerHostPid, startTime: staleProof.ownerHostStartTime }, signal, currentFrame, { bootId: staleProof.pgidBootId, pidNamespace: staleProof.pgidPidNamespace, machineId: staleProof.pgidMachineId });
842
1025
  }
843
- return socket === undefined || owners.length > 0;
1026
+ return true;
844
1027
  }
845
- async function removeStaleSocket(socketPath, signal) {
1028
+ async function removeStaleSocket(socketPath, signal, frame, ownerProbe = defaultOwnerHostProbe) {
846
1029
  for (;;) {
847
1030
  signal?.throwIfAborted();
848
1031
  let stale;
@@ -854,11 +1037,8 @@ async function removeStaleSocket(socketPath, signal) {
854
1037
  return;
855
1038
  throw error;
856
1039
  }
857
- if (await socketAcceptsConnections(socketPath, signal)) {
858
- throw new Error(`terminal host socket is already active: ${socketPath}`);
859
- }
860
- if (!await proveEndpointAbsentUnderLock(socketPath, signal)) {
861
- throw new Error(`terminal host owner death is unproven; refusing stale socket reclamation: ${socketPath}`);
1040
+ if (!await proveEndpointAbsentUnderLock(socketPath, signal, frame, ownerProbe)) {
1041
+ throw new NativeTerminalPublicationRefusal("H2A_STALE_SOCKET_UNPROVEN");
862
1042
  }
863
1043
  try {
864
1044
  const current = await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal);
@@ -888,16 +1068,71 @@ async function ensurePrivateSocketDirectory(socketPath) {
888
1068
  await assertPrivateNativeTerminalSocketDirectory(socketPath);
889
1069
  }
890
1070
  }
1071
+ /**
1072
+ * Staged-socket naming grammar, shared by the generator (`stagingSocketPath`)
1073
+ * and the recognizers in `findActiveHostServingSocket`. The staged name MUST
1074
+ * remain recognizable: a live host that lost its published pathname is only
1075
+ * attributable through its staged name, and refusing to attribute it would
1076
+ * admit a second writer. Never edit the generator without the recognizers.
1077
+ *
1078
+ * Two grammars, with an explicit compatibility order (R5 v5):
1079
+ *
1080
+ * 1. LEGACY (≤ 0.98.4): `.<8 stem bytes>.<pid>.<8 hex>.sock` — the ONLY
1081
+ * grammar the PUBLISHED 0.98.4 reader recognizes when it attributes, via
1082
+ * /proc, a live host whose published pathname is gone. Chosen WHENEVER the
1083
+ * kernel sun_path budget allows it, so a mixed 0.98.4/0.98.5 fleet never
1084
+ * loses the anti-second-writer attribution in either direction.
1085
+ * 2. SHORT: `.<3 stem bytes>.<pid>.<8 hex>` — the fallback when the legacy
1086
+ * name would overflow the sun_path budget. RESTRICTION (explicit, R5 v5):
1087
+ * in such deep paths a 0.98.4 reader can no longer attribute a
1088
+ * pathname-less 0.98.5 host through /proc — but a 0.98.4 host could not
1089
+ * even bind there (its own staged name would overflow first), so the
1090
+ * exposure is confined to deep development/deployment directories, and
1091
+ * 0.98.5 readers still refuse the double publication on both grammars.
1092
+ */
1093
+ const STAGED_SOCKET_UUID_HEX_LENGTH = 8;
1094
+ function stagedSocketStem(publishedPath, stemLength) {
1095
+ return basename(publishedPath).slice(0, stemLength).replace(/[^A-Za-z0-9_.-]/g, "_");
1096
+ }
1097
+ /**
1098
+ * Grammar-exact recognizer: `.<stem>.<pid>.<exactly 8 lowercase hex>` for the
1099
+ * short grammar, plus `.sock` for the legacy grammar. A bare `startsWith`
1100
+ * would accept forged or truncated names as attribution evidence.
1101
+ */
1102
+ export function isStagedSocketName(name, publishedPath, pid) {
1103
+ for (const [stemLength, legacy] of [[8, true], [3, false]]) {
1104
+ const prefix = `.${stagedSocketStem(publishedPath, stemLength)}.${pid}.`;
1105
+ if (!name.startsWith(prefix))
1106
+ continue;
1107
+ const tail = name.slice(prefix.length);
1108
+ const expectedTailLength = STAGED_SOCKET_UUID_HEX_LENGTH + (legacy ? ".sock".length : 0);
1109
+ if (tail.length !== expectedTailLength)
1110
+ continue;
1111
+ if (!/^[0-9a-f]+$/.test(tail.slice(0, STAGED_SOCKET_UUID_HEX_LENGTH)))
1112
+ continue;
1113
+ if (legacy && !tail.endsWith(".sock"))
1114
+ continue;
1115
+ return true;
1116
+ }
1117
+ return false;
1118
+ }
891
1119
  function stagingSocketPath(socketPath) {
892
1120
  const directory = dirname(socketPath);
893
- const stem = basename(socketPath).slice(0, 8).replace(/[^A-Za-z0-9_.-]/g, "_");
894
- const stagedPath = join(directory, `.${stem}.${process.pid}.${randomUUID().slice(0, 8)}.sock`);
895
- // The staged name is longer than the published one; a publishable path can
896
- // still overflow the kernel sun_path budget at the bind step.
1121
+ const uuid = randomUUID().slice(0, STAGED_SOCKET_UUID_HEX_LENGTH);
1122
+ // Legacy first: it is the grammar the published 0.98.4 reader can still
1123
+ // attribute (see the restriction note above). Only a sun_path overflow may
1124
+ // demote the staged name to the short grammar.
1125
+ const legacy = join(directory, `.${stagedSocketStem(socketPath, 8)}.${process.pid}.${uuid}.sock`);
1126
+ if (Buffer.byteLength(legacy, "utf8") <= NATIVE_TERMINAL_SOCKET_MAX_PATH_BYTES) {
1127
+ return legacy;
1128
+ }
1129
+ const stagedPath = join(directory, `.${stagedSocketStem(socketPath, 3)}.${process.pid}.${uuid}`);
1130
+ // Even the short name must fit: a publishable path can still overflow the
1131
+ // kernel sun_path budget at the bind step.
897
1132
  assertNativeTerminalSocketPathWithinLimit(stagedPath, "terminal host staging socket path");
898
1133
  return stagedPath;
899
1134
  }
900
- async function publishSocket(stagedPath, socketPath, signal) {
1135
+ async function publishSocket(stagedPath, socketPath, signal, frame, ownerProbe = defaultOwnerHostProbe) {
901
1136
  signal.throwIfAborted();
902
1137
  const staged = await abortable(inspectPrivateNativeTerminalSocket(stagedPath), signal);
903
1138
  try {
@@ -906,8 +1141,8 @@ async function publishSocket(stagedPath, socketPath, signal) {
906
1141
  catch (error) {
907
1142
  if (error.code !== "ENOENT")
908
1143
  throw error;
909
- if (!await proveEndpointAbsentUnderLock(socketPath, signal)) {
910
- throw new Error(`terminal host owner death is unproven; refusing socket publication: ${socketPath}`);
1144
+ if (!await proveEndpointAbsentUnderLock(socketPath, signal, frame, ownerProbe)) {
1145
+ throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_OWNER_UNPROVEN");
911
1146
  }
912
1147
  }
913
1148
  for (let attempt = 0; attempt < 200; attempt += 1) {
@@ -924,14 +1159,27 @@ async function publishSocket(stagedPath, socketPath, signal) {
924
1159
  catch (error) {
925
1160
  if (error.code !== "EEXIST")
926
1161
  throw error;
927
- await removeStaleSocket(socketPath, signal);
1162
+ await removeStaleSocket(socketPath, signal, frame, ownerProbe);
928
1163
  }
929
1164
  }
930
1165
  throw new Error("terminal host socket publication retry limit exceeded");
931
1166
  }
1167
+ /**
1168
+ * Accept/connection-side transport errors: the listener itself keeps serving.
1169
+ */
1170
+ const RECOVERABLE_LISTENER_ERRNOS = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED"]);
1171
+ /** Accept-path fd exhaustion: transient while it clears, unusable when it
1172
+ * persists (v5 R7 predicate: >= 3 consecutive accept errors within 60 s). */
1173
+ const EXHAUSTED_LISTENER_ERRNOS = new Set(["EMFILE", "ENFILE"]);
1174
+ const LISTENER_EXHAUSTION_PERSISTENCE_THRESHOLD = 3;
1175
+ const LISTENER_EXHAUSTION_WINDOW_MS = 60_000;
1176
+ /** The listening socket can no longer serve: late EADDRINUSE, an invalid fd,
1177
+ * an unusable address. Known-dead — exit cleanly and let the supervisor own
1178
+ * the respawn. */
1179
+ const UNUSABLE_LISTENER_ERRNOS = new Set(["ERR_SERVER_CLOSED", "EADDRINUSE", "EADDRNOTAVAIL", "EBADF", "EINVAL", "ENOTSOCK", "EACCES"]);
932
1180
  export async function startNativeTerminalHostServer(options) {
933
1181
  const deadline = new AbortController();
934
- const timer = setTimeout(() => deadline.abort(new Error("terminal host socket publication timed out")), SOCKET_PUBLICATION_LOCK_TIMEOUT_MS);
1182
+ const timer = setTimeout(() => deadline.abort(new NativeTerminalPublicationRefusal("H2A_SOCKET_PUBLICATION_TIMEOUT")), SOCKET_PUBLICATION_LOCK_TIMEOUT_MS);
935
1183
  const signal = options.signal === undefined ? deadline.signal : AbortSignal.any([options.signal, deadline.signal]);
936
1184
  try {
937
1185
  return await initializeNativeTerminalHostServer({ ...options, signal });
@@ -948,7 +1196,10 @@ async function initializeNativeTerminalHostServer(options) {
948
1196
  const sockets = new Set();
949
1197
  const responseBudget = { pendingBytes: 0 };
950
1198
  let readyForRequests = false;
1199
+ let listenerExhaustionEvents = [];
951
1200
  const server = createServer((socket) => {
1201
+ // A successful accept breaks the consecutive failure series (R7 v5).
1202
+ listenerExhaustionEvents = [];
952
1203
  if (sockets.size >= NATIVE_TERMINAL_MAX_CONNECTIONS) {
953
1204
  socket.destroy();
954
1205
  return;
@@ -969,13 +1220,18 @@ async function initializeNativeTerminalHostServer(options) {
969
1220
  socket.setEncoding("utf8");
970
1221
  socket.setNoDelay(true);
971
1222
  socket.on("error", () => {
972
- // Per-connection transport failures are contained to this client.
1223
+ // R7.5 named exemption: expected, potentially high-volume transport
1224
+ // errors. Count per connection instead of producing a log per event.
1225
+ recordHostFault(HOST_FAULT_COUNTERS.hostSocketConnectionErrors);
973
1226
  });
974
1227
  socket.on("data", (chunk) => {
975
1228
  try {
976
1229
  consumeFrames(options.host, context, chunk);
977
1230
  }
978
1231
  catch {
1232
+ // R7.5 named exemption: a bad client costs only this connection;
1233
+ // count frame destroys instead of logging every malformed stream.
1234
+ recordHostFault(HOST_FAULT_COUNTERS.hostClientFrameDestroys);
979
1235
  socket.destroy();
980
1236
  }
981
1237
  });
@@ -985,9 +1241,27 @@ async function initializeNativeTerminalHostServer(options) {
985
1241
  sockets.delete(socket);
986
1242
  });
987
1243
  });
988
- const stagedPath = stagingSocketPath(options.socketPath);
1244
+ // bind(2) retains this literal path in /proc/net/unix. Resolve its directory
1245
+ // before binding so it agrees with the retained directory FD even if an
1246
+ // ancestor symlink is later removed. Keep the published path and grammar.
1247
+ const stagedPath = stagingSocketPath(join(realpathSync(dirname(options.socketPath)), basename(options.socketPath)));
1248
+ let directoryFd;
1249
+ const closeDirectory = () => {
1250
+ if (directoryFd !== undefined) {
1251
+ closeSync(directoryFd);
1252
+ directoryFd = undefined;
1253
+ }
1254
+ };
989
1255
  let ownedSocket;
1256
+ // Assigned once the published close is built; the listener-failure handler
1257
+ // (installed before that object exists) tears the server down through it.
1258
+ let closePublished;
1259
+ let initializationFailure;
990
1260
  try {
1261
+ // Keep the real directory inode observable through /proc/<pid>/fd even
1262
+ // after its pathname is deleted. The bind name and staging grammar stay
1263
+ // compatible with older readers; no private-state census is introduced.
1264
+ directoryFd = openSync(dirname(stagedPath), constants.O_RDONLY | constants.O_DIRECTORY);
991
1265
  await abortable(new Promise((resolve, reject) => {
992
1266
  server.once("error", reject);
993
1267
  server.listen(stagedPath, () => {
@@ -995,14 +1269,77 @@ async function initializeNativeTerminalHostServer(options) {
995
1269
  resolve();
996
1270
  });
997
1271
  }), options.signal);
1272
+ // The listener that turned listen()-path errors into a rejected startup
1273
+ // is gone by design; without a replacement, any error surfacing on the
1274
+ // net.Server AFTER listen would be an unhandled `error` event — an
1275
+ // uncaughtException that kills the host (the 2026-10-10/11 crash-loop's
1276
+ // class of failure). This durable handler CLASSIFIES instead of
1277
+ // absorbing everything (R7):
1278
+ // - recoverable transport errors (accept/connection side): log + survive;
1279
+ // - a known-unusable listener: log + stop serving + clean exit path;
1280
+ // - anything unclassifiable: log + stop serving + let the wired owner
1281
+ // exit non-zero (exit 3) — the supervisor's bounded respawn owns it.
1282
+ // Per-connection transport errors stay on each socket's own handler.
1283
+ server.on("error", (error) => {
1284
+ const errno = error.code;
1285
+ if (errno !== undefined && RECOVERABLE_LISTENER_ERRNOS.has(errno)) {
1286
+ listenerExhaustionEvents = [];
1287
+ recordHostFault(HOST_FAULT_COUNTERS.serverListenerErrorRecoverable);
1288
+ writeHostLogLine(`terminal host listener recovered from a transport error (${errno}, contained): ${String(error)}`);
1289
+ return;
1290
+ }
1291
+ // fd exhaustion on the accept path is survivable while it clears; the
1292
+ // v5 R7 persistence predicate promotes it to a known-unusable listener.
1293
+ if (errno !== undefined && EXHAUSTED_LISTENER_ERRNOS.has(errno)) {
1294
+ const now = Date.now();
1295
+ listenerExhaustionEvents.push(now);
1296
+ listenerExhaustionEvents = listenerExhaustionEvents.filter((at) => now - at < LISTENER_EXHAUSTION_WINDOW_MS);
1297
+ if (listenerExhaustionEvents.length >= LISTENER_EXHAUSTION_PERSISTENCE_THRESHOLD) {
1298
+ writeHostLogLine(`terminal host listener accept path is persistently exhausted (${listenerExhaustionEvents.length} ${errno} within ${LISTENER_EXHAUSTION_WINDOW_MS}ms); stopping`);
1299
+ listenerExhaustionEvents = [];
1300
+ escalateListenerFailure({ kind: "unusable", errno, error });
1301
+ return;
1302
+ }
1303
+ recordHostFault(HOST_FAULT_COUNTERS.serverListenerErrorExhausted);
1304
+ writeHostLogLine(`terminal host listener accept path exhausted (${errno}, contained while it clears; ${listenerExhaustionEvents.length}/${LISTENER_EXHAUSTION_PERSISTENCE_THRESHOLD} in the window): ${String(error)}`);
1305
+ return;
1306
+ }
1307
+ const kind = errno !== undefined && UNUSABLE_LISTENER_ERRNOS.has(errno) ? "unusable" : "unknown";
1308
+ escalateListenerFailure({ kind, errno, error });
1309
+ });
1310
+ const escalateListenerFailure = (failure) => {
1311
+ recordHostFault(failure.kind === "unusable"
1312
+ ? HOST_FAULT_COUNTERS.serverListenerErrorUnusable
1313
+ : HOST_FAULT_COUNTERS.serverListenerErrorUnknown);
1314
+ const label = failure.kind === "unknown" ? "unknown listener error" : "unusable listener error";
1315
+ writeHostLogLine(`${label} (${failure.errno ?? "no errno"}): ${String(failure.error)}; stopping`);
1316
+ if (options.onListenerFailure !== undefined) {
1317
+ options.onListenerFailure(failure);
1318
+ return;
1319
+ }
1320
+ if (closePublished === undefined) {
1321
+ initializationFailure = failure;
1322
+ return;
1323
+ }
1324
+ // Library default: no owner owns the process exit — stop serving
1325
+ // (sessions, sockets, publication rollback) without crashing the caller.
1326
+ closePublished?.({ stopSessions: true }).catch(() => {
1327
+ // The failure is already logged and counted; teardown errors must not
1328
+ // turn a contained listener failure into an unhandled rejection.
1329
+ });
1330
+ };
998
1331
  // Capture the listener's inode before any publication await can suspend.
999
1332
  ownedSocket = lstatSync(stagedPath);
1333
+ if (!sameNativeTerminalSocket(fstatSync(directoryFd), statSync(dirname(stagedPath)))) {
1334
+ throw new Error("terminal host socket directory changed during bind");
1335
+ }
1000
1336
  await abortable(chmod(stagedPath, 0o600), options.signal);
1001
1337
  await withSocketPublicationLock(options.socketPath, async (signal) => {
1002
1338
  try {
1003
1339
  signal.throwIfAborted();
1004
- const published = await publishSocket(stagedPath, options.socketPath, signal);
1005
- await recordEndpointOwner(options.socketPath, published, undefined, signal);
1340
+ const frame = readPublicationFrame(options.readFrame ?? readProcessFrame);
1341
+ const published = await publishSocket(stagedPath, options.socketPath, signal, frame, options.ownerProbe ?? defaultOwnerHostProbe);
1342
+ await recordEndpointOwner(options.socketPath, published, undefined, signal, frame);
1006
1343
  return published;
1007
1344
  }
1008
1345
  catch (pubError) {
@@ -1013,58 +1350,69 @@ async function initializeNativeTerminalHostServer(options) {
1013
1350
  throw pubError;
1014
1351
  }
1015
1352
  }, options.signal);
1353
+ if (initializationFailure !== undefined) {
1354
+ throw new Error(initializationFailure.kind === "unknown" ? "unknown listener error" : "unusable listener error");
1355
+ }
1016
1356
  }
1017
1357
  catch (error) {
1018
- for (const socket of sockets)
1019
- socket.destroy();
1020
- const closed = new Promise((resolve) => server.close(() => resolve()));
1021
- if (ownedSocket !== undefined) {
1022
- unlinkOwnedPath(stagedPath, ownedSocket);
1023
- rollbackEndpoint(options.socketPath, ownedSocket);
1358
+ try {
1359
+ for (const socket of sockets)
1360
+ socket.destroy();
1361
+ const closed = new Promise((resolve) => server.close(() => resolve()));
1362
+ if (ownedSocket !== undefined) {
1363
+ unlinkOwnedPath(stagedPath, ownedSocket);
1364
+ rollbackEndpoint(options.socketPath, ownedSocket);
1365
+ }
1366
+ await abortable(closed, AbortSignal.timeout(SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS));
1367
+ }
1368
+ finally {
1369
+ closeDirectory();
1024
1370
  }
1025
- await abortable(closed, AbortSignal.timeout(SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS));
1026
1371
  throw error;
1027
1372
  }
1028
1373
  readyForRequests = true;
1029
1374
  for (const socket of sockets)
1030
1375
  socket.resume();
1031
1376
  let closing;
1032
- return Object.freeze({
1033
- socketPath: options.socketPath,
1034
- close(closeOptions = {}) {
1035
- closing ??= (async () => {
1036
- let stopError;
1037
- if (closeOptions.stopSessions) {
1038
- try {
1039
- const signal = closeOptions.signal ?? "SIGTERM";
1040
- if (!isNativeTerminalStopSignal(signal)) {
1041
- throw new TypeError("invalid terminal host shutdown signal");
1042
- }
1043
- options.host.stopAll(signal);
1044
- }
1045
- catch (error) {
1046
- stopError = error;
1377
+ const doClose = (closeOptions = {}) => {
1378
+ closing ??= (async () => {
1379
+ let stopError;
1380
+ if (closeOptions.stopSessions) {
1381
+ try {
1382
+ const signal = closeOptions.signal ?? "SIGTERM";
1383
+ if (!isNativeTerminalStopSignal(signal)) {
1384
+ throw new TypeError("invalid terminal host shutdown signal");
1047
1385
  }
1386
+ options.host.stopAll(signal);
1048
1387
  }
1049
- for (const socket of sockets)
1050
- socket.destroy();
1051
- await abortable(new Promise((resolve, reject) => {
1052
- server.close((error) => error ? reject(error) : resolve());
1053
- }), AbortSignal.timeout(SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS));
1054
- await withSocketPublicationLock(options.socketPath, async () => {
1055
- try {
1056
- rollbackEndpoint(options.socketPath, ownedSocket);
1057
- }
1058
- catch (error) {
1059
- if (error.code !== "ENOENT")
1060
- throw error;
1061
- }
1062
- });
1063
- if (stopError !== undefined)
1064
- throw stopError;
1065
- })();
1066
- return closing;
1067
- },
1388
+ catch (error) {
1389
+ stopError = error;
1390
+ }
1391
+ }
1392
+ for (const socket of sockets)
1393
+ socket.destroy();
1394
+ await abortable(new Promise((resolve, reject) => {
1395
+ server.close((error) => error ? reject(error) : resolve());
1396
+ }), AbortSignal.timeout(SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS));
1397
+ await withSocketPublicationLock(options.socketPath, async () => {
1398
+ try {
1399
+ rollbackEndpoint(options.socketPath, ownedSocket);
1400
+ }
1401
+ catch (error) {
1402
+ if (error.code !== "ENOENT")
1403
+ throw error;
1404
+ }
1405
+ });
1406
+ if (stopError !== undefined)
1407
+ throw stopError;
1408
+ })().finally(closeDirectory);
1409
+ return closing;
1410
+ };
1411
+ closePublished = doClose;
1412
+ return Object.freeze({
1413
+ socketPath: options.socketPath,
1414
+ server,
1415
+ close: doClose,
1068
1416
  });
1069
1417
  }
1070
1418
  //# sourceMappingURL=server.js.map