@sentropic/h2a-runtime 0.98.5 → 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 (55) 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 +11 -5
  6. package/dist/index.js.map +1 -1
  7. package/dist/native-host.d.ts +3 -1
  8. package/dist/native-host.d.ts.map +1 -1
  9. package/dist/native-host.js +16 -5
  10. package/dist/native-host.js.map +1 -1
  11. package/dist/native-terminal/client.d.ts +2 -2
  12. package/dist/native-terminal/client.d.ts.map +1 -1
  13. package/dist/native-terminal/client.js +2 -2
  14. package/dist/native-terminal/client.js.map +1 -1
  15. package/dist/native-terminal/host.d.ts +21 -5
  16. package/dist/native-terminal/host.d.ts.map +1 -1
  17. package/dist/native-terminal/host.js +104 -72
  18. package/dist/native-terminal/host.js.map +1 -1
  19. package/dist/native-terminal/journal.d.ts.map +1 -1
  20. package/dist/native-terminal/journal.js +2 -1
  21. package/dist/native-terminal/journal.js.map +1 -1
  22. package/dist/native-terminal/op.d.ts +3 -1
  23. package/dist/native-terminal/op.d.ts.map +1 -1
  24. package/dist/native-terminal/op.js +62 -42
  25. package/dist/native-terminal/op.js.map +1 -1
  26. package/dist/native-terminal/process.d.ts.map +1 -1
  27. package/dist/native-terminal/process.js +4 -2
  28. package/dist/native-terminal/process.js.map +1 -1
  29. package/dist/native-terminal/publication-refusal.d.ts +22 -0
  30. package/dist/native-terminal/publication-refusal.d.ts.map +1 -0
  31. package/dist/native-terminal/publication-refusal.js +26 -0
  32. package/dist/native-terminal/publication-refusal.js.map +1 -0
  33. package/dist/native-terminal/server.d.ts +9 -2
  34. package/dist/native-terminal/server.d.ts.map +1 -1
  35. package/dist/native-terminal/server.js +335 -138
  36. package/dist/native-terminal/server.js.map +1 -1
  37. package/dist/native-terminal/supervisor.d.ts.map +1 -1
  38. package/dist/native-terminal/supervisor.js +9 -0
  39. package/dist/native-terminal/supervisor.js.map +1 -1
  40. package/dist/proc-identity.d.ts +63 -1
  41. package/dist/proc-identity.d.ts.map +1 -1
  42. package/dist/proc-identity.js +98 -1
  43. package/dist/proc-identity.js.map +1 -1
  44. package/dist/pty.d.ts.map +1 -1
  45. package/dist/pty.js +18 -0
  46. package/dist/pty.js.map +1 -1
  47. package/dist/registry.d.ts +15 -0
  48. package/dist/registry.d.ts.map +1 -1
  49. package/dist/registry.js +17 -4
  50. package/dist/registry.js.map +1 -1
  51. package/dist/tmux.d.ts +2 -0
  52. package/dist/tmux.d.ts.map +1 -1
  53. package/dist/tmux.js +14 -2
  54. package/dist/tmux.js.map +1 -1
  55. package/package.json +3 -2
@@ -1,5 +1,5 @@
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";
@@ -9,7 +9,8 @@ import { NativeTerminalHost, defaultOwnerHostProbe, readProcessStartTime, } from
9
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";
10
10
  import { NATIVE_TERMINAL_SOCKET_MAX_PATH_BYTES, assertNativeTerminalSocketPathWithinLimit, assertPrivateNativeTerminalSocketDirectory, inspectPrivateNativeTerminalSocket, sameNativeTerminalSocket, } from "./socket-path.js";
11
11
  import { listNativeTerminalPgidEntries, loadRegistry } from "../registry.js";
12
- import { readBootId, readPidNamespaceId } from "../proc-identity.js";
12
+ import { classifyDurableProcFrame, readProcessFrame, } from "../proc-identity.js";
13
+ import { NativeTerminalPublicationRefusal } from "./publication-refusal.js";
13
14
  const SOCKET_PUBLICATION_LOCK_TIMEOUT_MS = 5_000;
14
15
  const SOCKET_PUBLICATION_LOCK_RETRY_MS = 25;
15
16
  const SOCKET_PUBLICATION_LOCK_ID_FILE = ".h2a-native-terminal.lock-id";
@@ -169,7 +170,14 @@ function dispatch(host, context, request) {
169
170
  return host.attachObserver(sessionId(params));
170
171
  case "acquire-controller": {
171
172
  const record = requiredRecord(params, "params");
172
- 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
+ });
173
181
  context.leases.set(lease.id, lease);
174
182
  return lease;
175
183
  }
@@ -354,19 +362,6 @@ function consumeFrames(host, context, chunk) {
354
362
  context.socket.destroy();
355
363
  }
356
364
  }
357
- async function socketAcceptsConnections(socketPath, signal) {
358
- return abortable(new Promise((resolve) => {
359
- const socket = createConnection(socketPath);
360
- const cancel = () => socket.destroy();
361
- signal?.addEventListener("abort", cancel, { once: true });
362
- socket.once("close", () => signal?.removeEventListener("abort", cancel));
363
- socket.once("connect", () => {
364
- socket.destroy();
365
- resolve(true);
366
- });
367
- socket.once("error", () => resolve(false));
368
- }), signal);
369
- }
370
365
  async function socketPublicationLockSecret(socketPath, signal) {
371
366
  if (process.platform !== "linux" || process.getuid === undefined) {
372
367
  throw new Error("native terminal socket publication requires Linux abstract Unix sockets");
@@ -405,11 +400,13 @@ async function socketPublicationLockSecret(socketPath, signal) {
405
400
  || identity.isSymbolicLink()
406
401
  || identity.uid !== process.getuid()
407
402
  || (identity.mode & 0o777) !== 0o600) {
408
- 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");
409
406
  }
410
407
  const secret = (await abortable(readFile(identityPath, "utf8"), signal)).trim();
411
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)) {
412
- throw new Error(`terminal socket publication lock identity is malformed: ${identityPath}`);
409
+ throw new NativeTerminalPublicationRefusal("H2A_PUBLICATION_LOCK_ID_MALFORMED");
413
410
  }
414
411
  return secret;
415
412
  }
@@ -444,7 +441,7 @@ async function acquireSocketPublicationLock(socketPath, signal) {
444
441
  }
445
442
  async function withSocketPublicationLock(socketPath, operation, signal) {
446
443
  const deadline = new AbortController();
447
- 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);
448
445
  const boundedSignal = signal === undefined ? deadline.signal : AbortSignal.any([signal, deadline.signal]);
449
446
  try {
450
447
  const lock = await acquireSocketPublicationLock(socketPath, boundedSignal);
@@ -496,14 +493,23 @@ function rollbackEndpoint(socketPath, owned) {
496
493
  }
497
494
  catch { /* Unknown sidecar ownership is never cleanup authority. */ }
498
495
  }
499
- 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) {
500
506
  signal?.throwIfAborted();
501
507
  if (process.env.H2A_TEST_FAIL_OWNER_WRITE) {
502
508
  throw new Error("simulated failure recording endpoint owner");
503
509
  }
504
510
  const { pid, startTime } = processOwner;
505
- const bootId = readBootId(), pidNamespace = readPidNamespaceId();
506
- if (startTime === undefined || bootId === undefined || pidNamespace === undefined) {
511
+ frame = stampFrame ?? frame;
512
+ if (startTime === undefined || frame.bootId === undefined || frame.pidNamespace === undefined) {
507
513
  throw new Error("cannot certify native host process identity");
508
514
  }
509
515
  const temporary = `${socketPath}.owner.${process.pid}.${randomUUID()}.tmp`;
@@ -527,7 +533,10 @@ async function recordEndpointOwner(socketPath, socket, processOwner = { pid: pro
527
533
  signal?.throwIfAborted();
528
534
  // Write through the already-open inode: cancellation can unlink it without
529
535
  // a delayed write recreating the temporary pathname.
530
- 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);
531
540
  signal?.throwIfAborted();
532
541
  await abortable(rename(temporary, `${socketPath}.owner`), signal);
533
542
  }
@@ -563,12 +572,12 @@ async function recordEndpointOwner(socketPath, socket, processOwner = { pid: pro
563
572
  * identity. A live or unreadable owner keeps the endpoint unknown.
564
573
  * No socket is removed by an inventory; publication owns reclamation.
565
574
  */
566
- export async function proveNativeTerminalEndpointAbsent(socketPath, failure) {
575
+ export async function proveNativeTerminalEndpointAbsent(socketPath, failure, readFrame = readProcessFrame, ownerProbe = defaultOwnerHostProbe) {
567
576
  const code = failure?.code;
568
577
  if (code !== "ENOENT" && code !== "ECONNREFUSED")
569
578
  return false;
570
579
  await ensurePrivateSocketDirectory(socketPath);
571
- return withSocketPublicationLock(socketPath, signal => proveEndpointAbsentUnderLock(socketPath, signal));
580
+ return withSocketPublicationLock(socketPath, signal => proveEndpointAbsentUnderLock(socketPath, signal, readPublicationFrame(readFrame), ownerProbe));
572
581
  }
573
582
  function canonicalizePath(targetPath) {
574
583
  const resolved = resolve(targetPath);
@@ -587,7 +596,7 @@ function canonicalizePath(targetPath) {
587
596
  }
588
597
  }
589
598
  }
590
- 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) {
591
600
  if (process.env.H2A_TEST_PROC_UNAVAILABLE) {
592
601
  return { state: "unknown", reason: "simulated /proc observation unavailable" };
593
602
  }
@@ -611,6 +620,15 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
611
620
  reason: `cannot canonicalize target socket path: ${socketPath}`,
612
621
  };
613
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
+ }
614
632
  for (const entry of entries) {
615
633
  if (!/^\d+$/.test(entry))
616
634
  continue;
@@ -654,14 +672,24 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
654
672
  if (!isHost)
655
673
  continue;
656
674
  const candidateCanonical = canonicalizePath(candidateSocket);
657
- if (!candidateCanonical) {
658
- // The alias used at host startup was deleted; canonicalization cannot prove absence
659
- return {
660
- state: "unknown",
661
- reason: `cannot canonicalize host socket path ${candidateSocket} for host pid ${pid}`,
662
- };
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. */ }
663
681
  }
664
- 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)
665
693
  continue;
666
694
  const candDir = dirname(candidateSocket);
667
695
  let isStagingCandidate = false;
@@ -689,20 +717,20 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
689
717
  };
690
718
  }
691
719
  let hasSocketFd = false;
692
- let unixSockets;
720
+ const descriptorLinks = new Map();
721
+ let unreadableDescriptor;
693
722
  for (const fd of fds) {
694
- let link;
695
723
  try {
696
- link = readlinkSync(join(fdDir, fd));
724
+ descriptorLinks.set(fd, readlinkSync(join(fdDir, fd)));
697
725
  }
698
726
  catch (err) {
699
727
  if (err.code === "ENOENT" || err.code === "ESRCH")
700
728
  continue;
701
- return {
702
- state: "unknown",
703
- reason: `cannot readlink /proc/${pid}/fd/${fd}: ${err instanceof Error ? err.message : String(err)}`,
704
- };
729
+ unreadableDescriptor = `cannot readlink /proc/${pid}/fd/${fd}: ${String(err)}`;
705
730
  }
731
+ }
732
+ let unixSockets;
733
+ for (const link of descriptorLinks.values()) {
706
734
  const inode = /^socket:\[(\d+)\]$/.exec(link)?.[1];
707
735
  if (!inode)
708
736
  continue;
@@ -722,25 +750,70 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
722
750
  // Stdio and journal IPC are Unix socket pairs too. Only the native
723
751
  // listener's canonical or PID-attributed staged address proves service,
724
752
  // including a listener whose filesystem pathname has been unlinked.
725
- if (!boundPath || (boundPath !== candidateSocket && !(dirname(boundPath) === candDir
726
- && isStagedSocketName(basename(boundPath), candidateSocket, pid))))
753
+ if (!boundPath || !boundPath.startsWith("/"))
727
754
  continue;
728
755
  const canonicalBound = canonicalizePath(boundPath);
729
- if (!canonicalBound)
730
- return { state: "unknown", reason: `cannot canonicalize bound socket for host pid ${pid}` };
731
- if (canonicalBound === targetCanonical || (dirname(canonicalBound) === dirname(targetCanonical)
732
- && isStagedSocketName(basename(canonicalBound), candidateSocket, 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) {
733
765
  hasSocketFd = true;
734
766
  break;
735
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)) {
801
+ hasSocketFd = true;
802
+ break;
803
+ }
804
+ if (canonicalBound !== undefined && !candidateAliased)
805
+ continue;
806
+ return { state: "unknown", reason: `cannot attribute bound socket directory for host pid ${pid}` };
736
807
  }
808
+ if (!hasSocketFd && unreadableDescriptor)
809
+ return { state: "unknown", reason: unreadableDescriptor };
737
810
  if (!hasSocketFd)
738
811
  continue;
739
812
  const startTime = readProcessStartTime(pid);
740
813
  if (startTime === undefined) {
741
814
  return { state: "unknown", reason: `cannot read start time for host pid ${pid}` };
742
815
  }
743
- const probe = defaultOwnerHostProbe({ pid, startTime });
816
+ const probe = ownerProbe({ pid, startTime });
744
817
  if (probe === "alive") {
745
818
  return { state: "live", pid, startTime };
746
819
  }
@@ -750,7 +823,71 @@ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST
750
823
  }
751
824
  return { state: "absent" };
752
825
  }
753
- 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) {
754
891
  signal?.throwIfAborted();
755
892
  let socket;
756
893
  try {
@@ -760,95 +897,135 @@ async function proveEndpointAbsentUnderLock(socketPath, signal) {
760
897
  if (error.code !== "ENOENT")
761
898
  throw error;
762
899
  }
763
- if (socket) {
764
- // A listener may have appeared since the failed inventory connection.
765
- // Only an explicit refusal qualifies; permissions and all other errors
766
- // remain unknown, including a changed socket incarnation.
767
- const refusal = await abortable(new Promise((resolve, reject) => {
768
- const connection = createConnection(socketPath);
769
- const cancel = () => connection.destroy();
770
- signal?.addEventListener("abort", cancel, { once: true });
771
- connection.once("close", () => signal?.removeEventListener("abort", cancel));
772
- connection.setTimeout(1_000, () => { connection.destroy(); reject(new Error("terminal host connection timed out")); });
773
- connection.once("connect", () => { connection.destroy(); resolve(false); });
774
- connection.once("error", error => error.code === "ECONNREFUSED" ? resolve(true) : reject(error));
775
- }), signal);
776
- if (!refusal || !sameNativeTerminalSocket(socket, await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal)))
777
- return false;
778
- }
779
- // Independent proof of host vivacity: a live host that lost its pathname
780
- // but still serves must never allow a second writer.
781
- // /proc read errors => unknown, never absent.
782
- const activeScan = findActiveHostServingSocket(socketPath);
783
- if (activeScan.state === "live") {
784
- return false;
785
- }
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");
786
909
  if (activeScan.state === "unknown") {
787
- 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
+ });
788
916
  }
917
+ let sidecar;
918
+ const ownerPath = `${socketPath}.owner`;
789
919
  try {
790
- const ownerPath = `${socketPath}.owner`;
791
920
  const info = await abortable(lstat(ownerPath), signal);
792
921
  if (!info.isFile() || info.uid !== process.getuid?.() || (info.mode & 0o777) !== 0o600) {
793
- throw new Error(`native endpoint owner identity is not a private owned file: ${ownerPath}`);
922
+ throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_OWNER_MALFORMED");
794
923
  }
795
- const owner = JSON.parse(await abortable(readFile(ownerPath, "utf8"), signal));
796
- if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0 || !Number.isSafeInteger(owner.startTime) ||
797
- owner.startTime < 0 || !Number.isSafeInteger(owner.dev) || !Number.isSafeInteger(owner.ino) ||
798
- typeof owner.bootId !== "string" || typeof owner.pidNamespace !== "string") {
799
- 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");
800
947
  }
801
- if (socket && !sameNativeTerminalSocket(socket, owner))
802
- return false;
803
- if (owner.bootId !== readBootId() || owner.pidNamespace !== readPidNamespaceId())
804
- return false;
805
- if (defaultOwnerHostProbe(owner) !== "dead")
806
- return false;
807
- if (socket)
808
- return true;
809
- // A pre-upgrade publisher may have replaced and then lost the pathname
810
- // without updating this sidecar. Its durable PTY owners must also be dead.
811
948
  }
812
949
  catch (error) {
813
950
  if (error.code !== "ENOENT")
814
951
  throw error;
815
952
  }
816
- // Pre-upgrade hosts already persist this identity for each PTY. A stale
817
- // socket without any attribution is unprovable; never infer death from
818
- // a bare PID, a socket's age, or an empty/error-collapsed registry.
819
953
  const snapshot = listNativeTerminalPgidEntries();
820
- if (!snapshot.known) {
821
- // This decoder deliberately calls an ENOENT registry "unknown" for
822
- // orphan reaping. For endpoint discovery, no file means no attribution;
823
- // it proves absence only when the socket is also absent under the lock.
824
- if (snapshot.reason === "registry is absent")
825
- return socket === undefined;
826
- throw new Error(`native endpoint owner registry is unknown: ${snapshot.reason}`);
827
- }
828
- const canonicalTarget = canonicalizePath(socketPath);
829
- if (!canonicalTarget) {
830
- throw new Error(`cannot canonicalize socket path: ${socketPath}`);
831
- }
832
- const owners = snapshot.entries
833
- .filter(entry => entry.owner?.socketPath && canonicalizePath(entry.owner.socketPath) === canonicalTarget)
834
- .map(entry => entry.owner);
835
954
  const registry = loadRegistry();
955
+ if (!snapshot.known && snapshot.reason !== "registry is absent")
956
+ throw new NativeTerminalPublicationRefusal("H2A_ENDPOINT_REGISTRY_UNKNOWN");
836
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"))
837
974
  return false;
838
- const rows = registry.entries.filter(entry => entry.ownerHostSocketPath && canonicalizePath(entry.ownerHostSocketPath) === canonicalTarget);
839
- 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))
840
993
  return false;
841
- if (owners.some(owner => owner.startTime === undefined || defaultOwnerHostProbe(owner) !== "dead"))
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)
1009
+ return false;
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)
842
1019
  return false;
843
- if (socket && owners.length > 0) {
844
- // Preserve the death proof before the supervisor reaps/prunes the last
845
- // pre-upgrade PTY row. Reclamation must still have attribution then.
846
- const owner = owners[0];
847
- 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 });
848
1025
  }
849
- return socket === undefined || owners.length > 0;
1026
+ return true;
850
1027
  }
851
- async function removeStaleSocket(socketPath, signal) {
1028
+ async function removeStaleSocket(socketPath, signal, frame, ownerProbe = defaultOwnerHostProbe) {
852
1029
  for (;;) {
853
1030
  signal?.throwIfAborted();
854
1031
  let stale;
@@ -860,11 +1037,8 @@ async function removeStaleSocket(socketPath, signal) {
860
1037
  return;
861
1038
  throw error;
862
1039
  }
863
- if (await socketAcceptsConnections(socketPath, signal)) {
864
- throw new Error(`terminal host socket is already active: ${socketPath}`);
865
- }
866
- if (!await proveEndpointAbsentUnderLock(socketPath, signal)) {
867
- 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");
868
1042
  }
869
1043
  try {
870
1044
  const current = await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal);
@@ -958,7 +1132,7 @@ function stagingSocketPath(socketPath) {
958
1132
  assertNativeTerminalSocketPathWithinLimit(stagedPath, "terminal host staging socket path");
959
1133
  return stagedPath;
960
1134
  }
961
- async function publishSocket(stagedPath, socketPath, signal) {
1135
+ async function publishSocket(stagedPath, socketPath, signal, frame, ownerProbe = defaultOwnerHostProbe) {
962
1136
  signal.throwIfAborted();
963
1137
  const staged = await abortable(inspectPrivateNativeTerminalSocket(stagedPath), signal);
964
1138
  try {
@@ -967,8 +1141,8 @@ async function publishSocket(stagedPath, socketPath, signal) {
967
1141
  catch (error) {
968
1142
  if (error.code !== "ENOENT")
969
1143
  throw error;
970
- if (!await proveEndpointAbsentUnderLock(socketPath, signal)) {
971
- 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");
972
1146
  }
973
1147
  }
974
1148
  for (let attempt = 0; attempt < 200; attempt += 1) {
@@ -985,7 +1159,7 @@ async function publishSocket(stagedPath, socketPath, signal) {
985
1159
  catch (error) {
986
1160
  if (error.code !== "EEXIST")
987
1161
  throw error;
988
- await removeStaleSocket(socketPath, signal);
1162
+ await removeStaleSocket(socketPath, signal, frame, ownerProbe);
989
1163
  }
990
1164
  }
991
1165
  throw new Error("terminal host socket publication retry limit exceeded");
@@ -1005,7 +1179,7 @@ const LISTENER_EXHAUSTION_WINDOW_MS = 60_000;
1005
1179
  const UNUSABLE_LISTENER_ERRNOS = new Set(["ERR_SERVER_CLOSED", "EADDRINUSE", "EADDRNOTAVAIL", "EBADF", "EINVAL", "ENOTSOCK", "EACCES"]);
1006
1180
  export async function startNativeTerminalHostServer(options) {
1007
1181
  const deadline = new AbortController();
1008
- 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);
1009
1183
  const signal = options.signal === undefined ? deadline.signal : AbortSignal.any([options.signal, deadline.signal]);
1010
1184
  try {
1011
1185
  return await initializeNativeTerminalHostServer({ ...options, signal });
@@ -1067,13 +1241,27 @@ async function initializeNativeTerminalHostServer(options) {
1067
1241
  sockets.delete(socket);
1068
1242
  });
1069
1243
  });
1070
- 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
+ };
1071
1255
  let ownedSocket;
1072
1256
  // Assigned once the published close is built; the listener-failure handler
1073
1257
  // (installed before that object exists) tears the server down through it.
1074
1258
  let closePublished;
1075
1259
  let initializationFailure;
1076
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);
1077
1265
  await abortable(new Promise((resolve, reject) => {
1078
1266
  server.once("error", reject);
1079
1267
  server.listen(stagedPath, () => {
@@ -1142,12 +1330,16 @@ async function initializeNativeTerminalHostServer(options) {
1142
1330
  };
1143
1331
  // Capture the listener's inode before any publication await can suspend.
1144
1332
  ownedSocket = lstatSync(stagedPath);
1333
+ if (!sameNativeTerminalSocket(fstatSync(directoryFd), statSync(dirname(stagedPath)))) {
1334
+ throw new Error("terminal host socket directory changed during bind");
1335
+ }
1145
1336
  await abortable(chmod(stagedPath, 0o600), options.signal);
1146
1337
  await withSocketPublicationLock(options.socketPath, async (signal) => {
1147
1338
  try {
1148
1339
  signal.throwIfAborted();
1149
- const published = await publishSocket(stagedPath, options.socketPath, signal);
1150
- 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);
1151
1343
  return published;
1152
1344
  }
1153
1345
  catch (pubError) {
@@ -1163,14 +1355,19 @@ async function initializeNativeTerminalHostServer(options) {
1163
1355
  }
1164
1356
  }
1165
1357
  catch (error) {
1166
- for (const socket of sockets)
1167
- socket.destroy();
1168
- const closed = new Promise((resolve) => server.close(() => resolve()));
1169
- if (ownedSocket !== undefined) {
1170
- unlinkOwnedPath(stagedPath, ownedSocket);
1171
- 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();
1172
1370
  }
1173
- await abortable(closed, AbortSignal.timeout(SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS));
1174
1371
  throw error;
1175
1372
  }
1176
1373
  readyForRequests = true;
@@ -1208,7 +1405,7 @@ async function initializeNativeTerminalHostServer(options) {
1208
1405
  });
1209
1406
  if (stopError !== undefined)
1210
1407
  throw stopError;
1211
- })();
1408
+ })().finally(closeDirectory);
1212
1409
  return closing;
1213
1410
  };
1214
1411
  closePublished = doClose;