@sentropic/h2a-runtime 0.98.1 → 0.98.3

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 (130) hide show
  1. package/dist/agent-launch-args.d.ts +1 -1
  2. package/dist/agent-launch-args.d.ts.map +1 -1
  3. package/dist/agent-launch-args.js +16 -1
  4. package/dist/agent-launch-args.js.map +1 -1
  5. package/dist/auth-bundle.d.ts.map +1 -1
  6. package/dist/auth-bundle.js +1 -0
  7. package/dist/auth-bundle.js.map +1 -1
  8. package/dist/cli-help-groups.d.ts.map +1 -1
  9. package/dist/cli-help-groups.js +1 -0
  10. package/dist/cli-help-groups.js.map +1 -1
  11. package/dist/delegate.d.ts.map +1 -1
  12. package/dist/delegate.js +14 -2
  13. package/dist/delegate.js.map +1 -1
  14. package/dist/gateway-host/config-file.d.ts +19 -0
  15. package/dist/gateway-host/config-file.d.ts.map +1 -0
  16. package/dist/gateway-host/config-file.js +54 -0
  17. package/dist/gateway-host/config-file.js.map +1 -0
  18. package/dist/gateway-host/daemon.d.ts +61 -0
  19. package/dist/gateway-host/daemon.d.ts.map +1 -0
  20. package/dist/gateway-host/daemon.js +274 -0
  21. package/dist/gateway-host/daemon.js.map +1 -0
  22. package/dist/gateway-host/host.d.ts +34 -0
  23. package/dist/gateway-host/host.d.ts.map +1 -0
  24. package/dist/gateway-host/host.js +228 -0
  25. package/dist/gateway-host/host.js.map +1 -0
  26. package/dist/{llm-gateway-runtime/session-ledger.d.ts → gateway-host/ledger.d.ts} +4 -4
  27. package/dist/gateway-host/ledger.d.ts.map +1 -0
  28. package/dist/{llm-gateway-runtime/session-ledger.js → gateway-host/ledger.js} +1 -1
  29. package/dist/gateway-host/ledger.js.map +1 -0
  30. package/dist/{llm-gateway-runtime/sticky.d.ts → gateway-host/sessions.d.ts} +3 -1
  31. package/dist/gateway-host/sessions.d.ts.map +1 -0
  32. package/dist/{llm-gateway-runtime/sticky.js → gateway-host/sessions.js} +6 -1
  33. package/dist/gateway-host/sessions.js.map +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +38 -18
  36. package/dist/index.js.map +1 -1
  37. package/dist/llm-mesh-accounts.d.ts +55 -0
  38. package/dist/llm-mesh-accounts.d.ts.map +1 -0
  39. package/dist/llm-mesh-accounts.js +188 -0
  40. package/dist/llm-mesh-accounts.js.map +1 -0
  41. package/dist/native-host.d.ts +1 -1
  42. package/dist/native-host.d.ts.map +1 -1
  43. package/dist/native-host.js +84 -21
  44. package/dist/native-host.js.map +1 -1
  45. package/dist/native-terminal/attach-recovery.d.ts +9 -0
  46. package/dist/native-terminal/attach-recovery.d.ts.map +1 -0
  47. package/dist/native-terminal/attach-recovery.js +54 -0
  48. package/dist/native-terminal/attach-recovery.js.map +1 -0
  49. package/dist/native-terminal/fleet.d.ts +2 -1
  50. package/dist/native-terminal/fleet.d.ts.map +1 -1
  51. package/dist/native-terminal/fleet.js +10 -1
  52. package/dist/native-terminal/fleet.js.map +1 -1
  53. package/dist/native-terminal/host.d.ts +1 -0
  54. package/dist/native-terminal/host.d.ts.map +1 -1
  55. package/dist/native-terminal/host.js +5 -2
  56. package/dist/native-terminal/host.js.map +1 -1
  57. package/dist/native-terminal/journal.d.ts +48 -0
  58. package/dist/native-terminal/journal.d.ts.map +1 -0
  59. package/dist/native-terminal/journal.js +305 -0
  60. package/dist/native-terminal/journal.js.map +1 -0
  61. package/dist/native-terminal/op.d.ts +3 -0
  62. package/dist/native-terminal/op.d.ts.map +1 -1
  63. package/dist/native-terminal/op.js +148 -33
  64. package/dist/native-terminal/op.js.map +1 -1
  65. package/dist/native-terminal/process.d.ts.map +1 -1
  66. package/dist/native-terminal/process.js +134 -46
  67. package/dist/native-terminal/process.js.map +1 -1
  68. package/dist/native-terminal/replay-buffer.d.ts +3 -0
  69. package/dist/native-terminal/replay-buffer.d.ts.map +1 -1
  70. package/dist/native-terminal/replay-buffer.js +11 -0
  71. package/dist/native-terminal/replay-buffer.js.map +1 -1
  72. package/dist/native-terminal/reset-relay.d.ts +11 -0
  73. package/dist/native-terminal/reset-relay.d.ts.map +1 -0
  74. package/dist/native-terminal/reset-relay.js +38 -0
  75. package/dist/native-terminal/reset-relay.js.map +1 -0
  76. package/dist/native-terminal/server.d.ts +8 -0
  77. package/dist/native-terminal/server.d.ts.map +1 -1
  78. package/dist/native-terminal/server.js +535 -58
  79. package/dist/native-terminal/server.js.map +1 -1
  80. package/dist/native-terminal/terminal-modes.d.ts +15 -0
  81. package/dist/native-terminal/terminal-modes.d.ts.map +1 -0
  82. package/dist/native-terminal/terminal-modes.js +180 -0
  83. package/dist/native-terminal/terminal-modes.js.map +1 -0
  84. package/dist/profile-menu.d.ts.map +1 -1
  85. package/dist/profile-menu.js +2 -0
  86. package/dist/profile-menu.js.map +1 -1
  87. package/dist/profiles.d.ts +2 -0
  88. package/dist/profiles.d.ts.map +1 -1
  89. package/dist/profiles.js +6 -0
  90. package/dist/profiles.js.map +1 -1
  91. package/dist/protocol-local.d.ts +1 -1
  92. package/dist/protocol-local.d.ts.map +1 -1
  93. package/dist/protocol-local.js +1 -0
  94. package/dist/protocol-local.js.map +1 -1
  95. package/dist/registry.d.ts +1 -1
  96. package/dist/registry.d.ts.map +1 -1
  97. package/dist/registry.js +4 -1
  98. package/dist/registry.js.map +1 -1
  99. package/dist/restore.d.ts +1 -1
  100. package/dist/restore.d.ts.map +1 -1
  101. package/dist/{llm-routing-config.d.ts → routing-preferences.d.ts} +2 -2
  102. package/dist/routing-preferences.d.ts.map +1 -0
  103. package/dist/{llm-routing-config.js → routing-preferences.js} +16 -13
  104. package/dist/routing-preferences.js.map +1 -0
  105. package/dist/sentropic/auth-token.d.ts +1 -1
  106. package/dist/sentropic/auth-token.js +1 -1
  107. package/dist/status-projection.d.ts +1 -1
  108. package/dist/status-projection.d.ts.map +1 -1
  109. package/dist/throttle-signatures.d.ts.map +1 -1
  110. package/dist/throttle-signatures.js +3 -0
  111. package/dist/throttle-signatures.js.map +1 -1
  112. package/dist/tmux.d.ts +17 -1
  113. package/dist/tmux.d.ts.map +1 -1
  114. package/dist/tmux.js +10 -4
  115. package/dist/tmux.js.map +1 -1
  116. package/package.json +8 -4
  117. package/dist/llm-gateway-runtime/index.d.ts +0 -10
  118. package/dist/llm-gateway-runtime/index.d.ts.map +0 -1
  119. package/dist/llm-gateway-runtime/index.js +0 -147
  120. package/dist/llm-gateway-runtime/index.js.map +0 -1
  121. package/dist/llm-gateway-runtime/session-ledger.d.ts.map +0 -1
  122. package/dist/llm-gateway-runtime/session-ledger.js.map +0 -1
  123. package/dist/llm-gateway-runtime/sticky.d.ts.map +0 -1
  124. package/dist/llm-gateway-runtime/sticky.js.map +0 -1
  125. package/dist/llm-mesh.d.ts +0 -154
  126. package/dist/llm-mesh.d.ts.map +0 -1
  127. package/dist/llm-mesh.js +0 -490
  128. package/dist/llm-mesh.js.map +0 -1
  129. package/dist/llm-routing-config.d.ts.map +0 -1
  130. package/dist/llm-routing-config.js.map +0 -1
@@ -1,13 +1,40 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { chmod, link, lstat, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
2
+ import { fstatSync, lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, statSync, unlinkSync } from "node:fs";
3
+ import { chmod, link, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
4
  import { createConnection, createServer } from "node:net";
4
- import { basename, dirname, isAbsolute, join } from "node:path";
5
- import { NativeTerminalHost, } from "./host.js";
5
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { NativeTerminalHost, defaultOwnerHostProbe, readProcessStartTime, } from "./host.js";
6
7
  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";
7
8
  import { assertNativeTerminalSocketPathWithinLimit, assertPrivateNativeTerminalSocketDirectory, inspectPrivateNativeTerminalSocket, sameNativeTerminalSocket, } from "./socket-path.js";
9
+ import { listNativeTerminalPgidEntries, loadRegistry } from "../registry.js";
10
+ import { readBootId, readPidNamespaceId } from "../proc-identity.js";
8
11
  const SOCKET_PUBLICATION_LOCK_TIMEOUT_MS = 5_000;
9
12
  const SOCKET_PUBLICATION_LOCK_RETRY_MS = 25;
10
13
  const SOCKET_PUBLICATION_LOCK_ID_FILE = ".h2a-native-terminal.lock-id";
14
+ const SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS = 100;
15
+ async function abortable(operation, signal) {
16
+ if (signal === undefined)
17
+ return operation;
18
+ let aborted;
19
+ try {
20
+ const result = await Promise.race([
21
+ operation,
22
+ new Promise((_, reject) => {
23
+ aborted = () => reject(signal.reason);
24
+ if (signal.aborted)
25
+ aborted();
26
+ else
27
+ signal.addEventListener("abort", aborted, { once: true });
28
+ }),
29
+ ]);
30
+ signal.throwIfAborted();
31
+ return result;
32
+ }
33
+ finally {
34
+ if (aborted !== undefined)
35
+ signal.removeEventListener("abort", aborted);
36
+ }
37
+ }
11
38
  function requiredRecord(value, label) {
12
39
  if (!isRecord(value))
13
40
  throw new TypeError(`${label} must be an object`);
@@ -323,17 +350,20 @@ function consumeFrames(host, context, chunk) {
323
350
  context.socket.destroy();
324
351
  }
325
352
  }
326
- async function socketAcceptsConnections(socketPath) {
327
- return new Promise((resolve) => {
353
+ async function socketAcceptsConnections(socketPath, signal) {
354
+ return abortable(new Promise((resolve) => {
328
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));
329
359
  socket.once("connect", () => {
330
360
  socket.destroy();
331
361
  resolve(true);
332
362
  });
333
363
  socket.once("error", () => resolve(false));
334
- });
364
+ }), signal);
335
365
  }
336
- async function socketPublicationLockSecret(socketPath) {
366
+ async function socketPublicationLockSecret(socketPath, signal) {
337
367
  if (process.platform !== "linux" || process.getuid === undefined) {
338
368
  throw new Error("native terminal socket publication requires Linux abstract Unix sockets");
339
369
  }
@@ -341,13 +371,14 @@ async function socketPublicationLockSecret(socketPath) {
341
371
  const identityPath = join(directory, SOCKET_PUBLICATION_LOCK_ID_FILE);
342
372
  const temporaryPath = join(directory, `.${SOCKET_PUBLICATION_LOCK_ID_FILE}.${process.pid}.${randomUUID()}.tmp`);
343
373
  try {
344
- await writeFile(temporaryPath, `${randomUUID()}\n`, {
374
+ await abortable(writeFile(temporaryPath, `${randomUUID()}\n`, {
345
375
  flag: "wx",
346
376
  mode: 0o600,
347
- });
348
- await chmod(temporaryPath, 0o600);
377
+ signal,
378
+ }), signal);
379
+ await abortable(chmod(temporaryPath, 0o600), signal);
349
380
  try {
350
- await link(temporaryPath, identityPath);
381
+ await abortable(link(temporaryPath, identityPath), signal);
351
382
  }
352
383
  catch (error) {
353
384
  if (error.code !== "EEXIST")
@@ -356,82 +387,484 @@ async function socketPublicationLockSecret(socketPath) {
356
387
  }
357
388
  finally {
358
389
  try {
359
- await unlink(temporaryPath);
390
+ await abortable(unlink(temporaryPath), signal);
360
391
  }
361
392
  catch (error) {
362
- if (error.code !== "ENOENT")
393
+ if (signal?.aborted)
394
+ unlinkIfPresent(temporaryPath);
395
+ else if (error.code !== "ENOENT")
363
396
  throw error;
364
397
  }
365
398
  }
366
- const identity = await lstat(identityPath);
399
+ const identity = await abortable(lstat(identityPath), signal);
367
400
  if (!identity.isFile()
368
401
  || identity.isSymbolicLink()
369
402
  || identity.uid !== process.getuid()
370
403
  || (identity.mode & 0o777) !== 0o600) {
371
404
  throw new Error(`terminal socket publication lock identity is not a private owned file: ${identityPath}`);
372
405
  }
373
- const secret = (await readFile(identityPath, "utf8")).trim();
406
+ const secret = (await abortable(readFile(identityPath, "utf8"), signal)).trim();
374
407
  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)) {
375
408
  throw new Error(`terminal socket publication lock identity is malformed: ${identityPath}`);
376
409
  }
377
410
  return secret;
378
411
  }
379
- async function acquireSocketPublicationLock(socketPath) {
380
- const secret = await socketPublicationLockSecret(socketPath);
412
+ async function acquireSocketPublicationLock(socketPath, signal) {
413
+ const secret = await socketPublicationLockSecret(socketPath, signal);
381
414
  const address = `\0h2a-terminal-lock-${createHash("sha256")
382
415
  .update(`${process.getuid?.() ?? "unknown"}:${socketPath}:${secret}`)
383
416
  .digest("hex")}`;
384
417
  const deadline = Date.now() + SOCKET_PUBLICATION_LOCK_TIMEOUT_MS;
385
418
  for (;;) {
386
- const lock = createServer();
419
+ signal?.throwIfAborted();
420
+ const lock = createServer(socket => socket.destroy());
387
421
  try {
388
- await new Promise((resolve, reject) => {
422
+ await abortable(new Promise((resolve, reject) => {
389
423
  lock.once("error", reject);
390
424
  lock.listen(address, () => {
391
425
  lock.removeListener("error", reject);
392
426
  resolve();
393
427
  });
394
- });
428
+ }), signal);
395
429
  return lock;
396
430
  }
397
431
  catch (error) {
432
+ lock.close();
398
433
  if (error.code !== "EADDRINUSE"
399
434
  || Date.now() >= deadline) {
400
435
  throw error;
401
436
  }
402
- await new Promise((resolve) => setTimeout(resolve, SOCKET_PUBLICATION_LOCK_RETRY_MS));
437
+ await abortable(new Promise((resolve) => setTimeout(resolve, SOCKET_PUBLICATION_LOCK_RETRY_MS)), signal);
403
438
  }
404
439
  }
405
440
  }
406
- async function withSocketPublicationLock(socketPath, operation) {
407
- const lock = await acquireSocketPublicationLock(socketPath);
441
+ async function withSocketPublicationLock(socketPath, operation, signal) {
442
+ 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 boundedSignal = signal === undefined ? deadline.signal : AbortSignal.any([signal, deadline.signal]);
408
445
  try {
409
- return await operation();
446
+ const lock = await acquireSocketPublicationLock(socketPath, boundedSignal);
447
+ try {
448
+ boundedSignal.throwIfAborted();
449
+ return await operation(boundedSignal);
450
+ }
451
+ finally {
452
+ await abortable(new Promise((resolve, reject) => {
453
+ lock.close((error) => error && error.code !== "ERR_SERVER_NOT_RUNNING" ? reject(error) : resolve());
454
+ }), AbortSignal.timeout(SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS));
455
+ }
410
456
  }
411
457
  finally {
412
- await new Promise((resolve, reject) => {
413
- lock.close((error) => error ? reject(error) : resolve());
414
- });
458
+ clearTimeout(timer);
459
+ }
460
+ }
461
+ function unlinkIfPresent(path) {
462
+ try {
463
+ unlinkSync(path);
464
+ }
465
+ catch (error) {
466
+ if (error.code !== "ENOENT")
467
+ throw error;
468
+ }
469
+ }
470
+ function unlinkOwnedPath(path, owned) {
471
+ try {
472
+ const current = lstatSync(path);
473
+ if (sameNativeTerminalSocket(current, owned))
474
+ unlinkSync(path);
475
+ }
476
+ catch (error) {
477
+ if (error.code !== "ENOENT")
478
+ throw error;
479
+ }
480
+ }
481
+ function rollbackEndpoint(socketPath, owned) {
482
+ unlinkOwnedPath(socketPath, owned);
483
+ const ownerPath = `${socketPath}.owner`;
484
+ try {
485
+ const identity = lstatSync(ownerPath);
486
+ if (!identity.isFile() || identity.uid !== process.getuid?.() || (identity.mode & 0o777) !== 0o600)
487
+ return;
488
+ const owner = JSON.parse(readFileSync(ownerPath, "utf8"));
489
+ if (owner.pid === process.pid && owner.startTime === readProcessStartTime(process.pid)
490
+ && sameNativeTerminalSocket(owner, owned))
491
+ unlinkOwnedPath(ownerPath, identity);
492
+ }
493
+ catch { /* Unknown sidecar ownership is never cleanup authority. */ }
494
+ }
495
+ async function recordEndpointOwner(socketPath, socket, processOwner = { pid: process.pid, startTime: readProcessStartTime(process.pid) }, signal) {
496
+ signal?.throwIfAborted();
497
+ if (process.env.H2A_TEST_FAIL_OWNER_WRITE) {
498
+ throw new Error("simulated failure recording endpoint owner");
499
+ }
500
+ const { pid, startTime } = processOwner;
501
+ const bootId = readBootId(), pidNamespace = readPidNamespaceId();
502
+ if (startTime === undefined || bootId === undefined || pidNamespace === undefined) {
503
+ throw new Error("cannot certify native host process identity");
504
+ }
505
+ const temporary = `${socketPath}.owner.${process.pid}.${randomUUID()}.tmp`;
506
+ let file;
507
+ try {
508
+ const openedFile = await abortable(open(temporary, "wx", 0o600).then((opened) => {
509
+ if (signal?.aborted) {
510
+ // A cancelled open may finish after rollback. Its unique pathname and
511
+ // descriptor still belong to this publication, never to a successor.
512
+ try {
513
+ unlinkOwnedPath(temporary, fstatSync(opened.fd));
514
+ }
515
+ finally {
516
+ void opened.close().catch(() => { });
517
+ }
518
+ signal.throwIfAborted();
519
+ }
520
+ file = opened;
521
+ return opened;
522
+ }), signal);
523
+ signal?.throwIfAborted();
524
+ // Write through the already-open inode: cancellation can unlink it without
525
+ // a delayed write recreating the temporary pathname.
526
+ await abortable(writeFile(openedFile, JSON.stringify({ ...socket, pid, startTime, bootId, pidNamespace }), signal === undefined ? {} : { signal }), signal);
527
+ signal?.throwIfAborted();
528
+ await abortable(rename(temporary, `${socketPath}.owner`), signal);
529
+ }
530
+ finally {
531
+ // Invalidate the rename source before releasing the publication lock. A
532
+ // delayed rename either already committed (and is rolled back by its
533
+ // publisher) or can no longer install an owner after cancellation.
534
+ try {
535
+ await abortable(unlink(temporary).catch(error => { if (error.code !== "ENOENT")
536
+ throw error; }), signal);
537
+ }
538
+ catch (error) {
539
+ if (!signal?.aborted)
540
+ throw error;
541
+ // No asynchronous rollback can be left targeting a shared pathname.
542
+ // Invalidate the unique source now, before the exclusion lock is closed.
543
+ if (file !== undefined)
544
+ unlinkOwnedPath(temporary, fstatSync(file.fd));
545
+ else
546
+ unlinkIfPresent(temporary);
547
+ signal.throwIfAborted();
548
+ }
549
+ finally {
550
+ // An aborted write may still be unwinding. Its descriptor owns only the
551
+ // unlinked inode and must not hold up listener/lock cleanup.
552
+ void file?.close().catch(() => { });
553
+ }
554
+ }
555
+ }
556
+ /**
557
+ * ENOENT/ECONNREFUSED are candidates, never proof by themselves. Serialize
558
+ * with publication and re-read both the socket and its durable process
559
+ * identity. A live or unreadable owner keeps the endpoint unknown.
560
+ * No socket is removed by an inventory; publication owns reclamation.
561
+ */
562
+ export async function proveNativeTerminalEndpointAbsent(socketPath, failure) {
563
+ const code = failure?.code;
564
+ if (code !== "ENOENT" && code !== "ECONNREFUSED")
565
+ return false;
566
+ await ensurePrivateSocketDirectory(socketPath);
567
+ return withSocketPublicationLock(socketPath, signal => proveEndpointAbsentUnderLock(socketPath, signal));
568
+ }
569
+ function canonicalizePath(targetPath) {
570
+ const resolved = resolve(targetPath);
571
+ try {
572
+ return realpathSync(resolved);
573
+ }
574
+ catch {
575
+ const dir = dirname(resolved);
576
+ const base = basename(resolved);
577
+ try {
578
+ const realDir = realpathSync(dir);
579
+ return join(realDir, base);
580
+ }
581
+ catch {
582
+ return undefined;
583
+ }
584
+ }
585
+ }
586
+ function findActiveHostServingSocket(socketPath, procRoot = process.env.H2A_TEST_PROC_ROOT ?? "/proc") {
587
+ if (process.env.H2A_TEST_PROC_UNAVAILABLE) {
588
+ return { state: "unknown", reason: "simulated /proc observation unavailable" };
589
+ }
590
+ if (process.platform !== "linux") {
591
+ return { state: "unknown", reason: "platform does not support /proc host observation" };
592
+ }
593
+ let entries;
594
+ try {
595
+ entries = readdirSync(procRoot);
596
+ }
597
+ catch (error) {
598
+ return {
599
+ state: "unknown",
600
+ reason: `cannot read ${procRoot}: ${error instanceof Error ? error.message : String(error)}`,
601
+ };
602
+ }
603
+ const targetCanonical = canonicalizePath(socketPath);
604
+ if (!targetCanonical) {
605
+ return {
606
+ state: "unknown",
607
+ reason: `cannot canonicalize target socket path: ${socketPath}`,
608
+ };
609
+ }
610
+ for (const entry of entries) {
611
+ if (!/^\d+$/.test(entry))
612
+ continue;
613
+ const pid = Number(entry);
614
+ if (pid === process.pid)
615
+ continue;
616
+ const pidDir = join(procRoot, entry);
617
+ let procStat;
618
+ try {
619
+ procStat = statSync(pidDir);
620
+ }
621
+ catch (err) {
622
+ if (err.code === "ENOENT" || err.code === "ESRCH")
623
+ continue;
624
+ return {
625
+ state: "unknown",
626
+ reason: `cannot stat /proc/${pid}: ${err instanceof Error ? err.message : String(err)}`,
627
+ };
628
+ }
629
+ if (process.getuid && procStat.uid !== process.getuid()) {
630
+ continue;
631
+ }
632
+ let cmdline;
633
+ try {
634
+ cmdline = readFileSync(join(pidDir, "cmdline"), "utf8");
635
+ }
636
+ catch (err) {
637
+ if (err.code === "ENOENT" || err.code === "ESRCH")
638
+ continue;
639
+ return {
640
+ state: "unknown",
641
+ reason: `cannot read /proc/${pid}/cmdline: ${err instanceof Error ? err.message : String(err)}`,
642
+ };
643
+ }
644
+ const args = cmdline.split("\0");
645
+ const idx = args.indexOf("--socket");
646
+ if (idx === -1 || idx + 1 >= args.length)
647
+ continue;
648
+ const candidateSocket = args[idx + 1];
649
+ const isHost = args.some(arg => arg.includes("process.js") || (arg.includes("native-terminal") && !arg.includes("op.js")));
650
+ if (!isHost)
651
+ continue;
652
+ 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
+ };
659
+ }
660
+ if (candidateCanonical !== targetCanonical)
661
+ continue;
662
+ const candDir = dirname(candidateSocket);
663
+ const candStem = basename(candidateSocket).slice(0, 8).replace(/[^A-Za-z0-9_.-]/g, "_");
664
+ let isStagingCandidate = false;
665
+ try {
666
+ const dirEntries = readdirSync(candDir);
667
+ isStagingCandidate = dirEntries.some(name => name.startsWith(`.${candStem}.${pid}.`));
668
+ }
669
+ catch { }
670
+ if (isStagingCandidate)
671
+ continue;
672
+ const fdDir = join(pidDir, "fd");
673
+ let fds;
674
+ try {
675
+ fds = readdirSync(fdDir);
676
+ }
677
+ catch (err) {
678
+ if (err.code === "ENOENT" || err.code === "ESRCH")
679
+ continue;
680
+ return {
681
+ state: "unknown",
682
+ reason: `cannot inspect /proc/${pid}/fd: ${err instanceof Error ? err.message : String(err)}`,
683
+ };
684
+ }
685
+ let hasSocketFd = false;
686
+ let unixSockets;
687
+ for (const fd of fds) {
688
+ let link;
689
+ try {
690
+ link = readlinkSync(join(fdDir, fd));
691
+ }
692
+ catch (err) {
693
+ if (err.code === "ENOENT" || err.code === "ESRCH")
694
+ continue;
695
+ return {
696
+ state: "unknown",
697
+ reason: `cannot readlink /proc/${pid}/fd/${fd}: ${err instanceof Error ? err.message : String(err)}`,
698
+ };
699
+ }
700
+ const inode = /^socket:\[(\d+)\]$/.exec(link)?.[1];
701
+ if (!inode)
702
+ continue;
703
+ if (!unixSockets) {
704
+ try {
705
+ unixSockets = new Map(readFileSync(join(pidDir, "net", "unix"), "utf8")
706
+ .split("\n").flatMap(line => {
707
+ const row = /^\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+(\d+)(?:\s+(.*))?$/.exec(line);
708
+ return row?.[2] ? [[row[1], row[2]]] : [];
709
+ }));
710
+ }
711
+ catch (error) {
712
+ return { state: "unknown", reason: `cannot inspect /proc/${pid}/net/unix: ${String(error)}` };
713
+ }
714
+ }
715
+ const boundPath = unixSockets.get(inode);
716
+ // Stdio and journal IPC are Unix socket pairs too. Only the native
717
+ // listener's canonical or PID-attributed staged address proves service,
718
+ // 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"))))
721
+ continue;
722
+ 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}.`))) {
727
+ hasSocketFd = true;
728
+ break;
729
+ }
730
+ }
731
+ if (!hasSocketFd)
732
+ continue;
733
+ const startTime = readProcessStartTime(pid);
734
+ if (startTime === undefined) {
735
+ return { state: "unknown", reason: `cannot read start time for host pid ${pid}` };
736
+ }
737
+ const probe = defaultOwnerHostProbe({ pid, startTime });
738
+ if (probe === "alive") {
739
+ return { state: "live", pid, startTime };
740
+ }
741
+ if (probe === "unresolvable") {
742
+ return { state: "unknown", reason: `host pid ${pid} liveness probe is unresolvable` };
743
+ }
744
+ }
745
+ return { state: "absent" };
746
+ }
747
+ async function proveEndpointAbsentUnderLock(socketPath, signal) {
748
+ signal?.throwIfAborted();
749
+ let socket;
750
+ try {
751
+ socket = await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal);
752
+ }
753
+ catch (error) {
754
+ if (error.code !== "ENOENT")
755
+ throw error;
756
+ }
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
+ }
780
+ if (activeScan.state === "unknown") {
781
+ throw new Error(`terminal host state is unprovable (${activeScan.reason}): ${socketPath}`);
782
+ }
783
+ try {
784
+ const ownerPath = `${socketPath}.owner`;
785
+ const info = await abortable(lstat(ownerPath), signal);
786
+ 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}`);
788
+ }
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}`);
794
+ }
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
+ }
806
+ catch (error) {
807
+ if (error.code !== "ENOENT")
808
+ throw error;
415
809
  }
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
+ 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
+ const registry = loadRegistry();
830
+ if (registry.state === "unknown" || registry.unreadable.length > 0)
831
+ 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()))
834
+ return false;
835
+ if (owners.some(owner => owner.startTime === undefined || defaultOwnerHostProbe(owner) !== "dead"))
836
+ 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);
842
+ }
843
+ return socket === undefined || owners.length > 0;
416
844
  }
417
- async function removeStaleSocket(socketPath) {
845
+ async function removeStaleSocket(socketPath, signal) {
418
846
  for (;;) {
847
+ signal?.throwIfAborted();
419
848
  let stale;
420
849
  try {
421
- stale = await inspectPrivateNativeTerminalSocket(socketPath);
850
+ stale = await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal);
422
851
  }
423
852
  catch (error) {
424
853
  if (error.code === "ENOENT")
425
854
  return;
426
855
  throw error;
427
856
  }
428
- if (await socketAcceptsConnections(socketPath)) {
857
+ if (await socketAcceptsConnections(socketPath, signal)) {
429
858
  throw new Error(`terminal host socket is already active: ${socketPath}`);
430
859
  }
860
+ if (!await proveEndpointAbsentUnderLock(socketPath, signal)) {
861
+ throw new Error(`terminal host owner death is unproven; refusing stale socket reclamation: ${socketPath}`);
862
+ }
431
863
  try {
432
- const current = await inspectPrivateNativeTerminalSocket(socketPath);
864
+ const current = await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal);
433
865
  if (sameNativeTerminalSocket(stale, current)) {
434
- await unlink(socketPath);
866
+ // Do not leave an uncancellable deletion queued against a successor.
867
+ unlinkOwnedPath(socketPath, stale);
435
868
  return;
436
869
  }
437
870
  }
@@ -457,44 +890,73 @@ async function ensurePrivateSocketDirectory(socketPath) {
457
890
  }
458
891
  function stagingSocketPath(socketPath) {
459
892
  const directory = dirname(socketPath);
460
- const stem = basename(socketPath).slice(0, 24).replace(/[^A-Za-z0-9_.-]/g, "_");
893
+ const stem = basename(socketPath).slice(0, 8).replace(/[^A-Za-z0-9_.-]/g, "_");
461
894
  const stagedPath = join(directory, `.${stem}.${process.pid}.${randomUUID().slice(0, 8)}.sock`);
462
895
  // The staged name is longer than the published one; a publishable path can
463
896
  // still overflow the kernel sun_path budget at the bind step.
464
897
  assertNativeTerminalSocketPathWithinLimit(stagedPath, "terminal host staging socket path");
465
898
  return stagedPath;
466
899
  }
467
- async function publishSocket(stagedPath, socketPath) {
468
- const staged = await inspectPrivateNativeTerminalSocket(stagedPath);
900
+ async function publishSocket(stagedPath, socketPath, signal) {
901
+ signal.throwIfAborted();
902
+ const staged = await abortable(inspectPrivateNativeTerminalSocket(stagedPath), signal);
903
+ try {
904
+ await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal);
905
+ }
906
+ catch (error) {
907
+ if (error.code !== "ENOENT")
908
+ throw error;
909
+ if (!await proveEndpointAbsentUnderLock(socketPath, signal)) {
910
+ throw new Error(`terminal host owner death is unproven; refusing socket publication: ${socketPath}`);
911
+ }
912
+ }
469
913
  for (let attempt = 0; attempt < 200; attempt += 1) {
914
+ signal.throwIfAborted();
470
915
  try {
471
- await link(stagedPath, socketPath);
472
- const published = await inspectPrivateNativeTerminalSocket(socketPath);
916
+ await abortable(link(stagedPath, socketPath), signal);
917
+ const published = await abortable(inspectPrivateNativeTerminalSocket(socketPath), signal);
473
918
  if (!sameNativeTerminalSocket(staged, published)) {
474
919
  throw new Error("terminal host published socket identity does not match its staged socket");
475
920
  }
476
- await unlink(stagedPath);
921
+ await abortable(unlink(stagedPath), signal);
477
922
  return published;
478
923
  }
479
924
  catch (error) {
480
925
  if (error.code !== "EEXIST")
481
926
  throw error;
482
- await removeStaleSocket(socketPath);
927
+ await removeStaleSocket(socketPath, signal);
483
928
  }
484
929
  }
485
930
  throw new Error("terminal host socket publication retry limit exceeded");
486
931
  }
487
932
  export async function startNativeTerminalHostServer(options) {
933
+ const deadline = new AbortController();
934
+ const timer = setTimeout(() => deadline.abort(new Error("terminal host socket publication timed out")), SOCKET_PUBLICATION_LOCK_TIMEOUT_MS);
935
+ const signal = options.signal === undefined ? deadline.signal : AbortSignal.any([options.signal, deadline.signal]);
936
+ try {
937
+ return await initializeNativeTerminalHostServer({ ...options, signal });
938
+ }
939
+ finally {
940
+ clearTimeout(timer);
941
+ }
942
+ }
943
+ async function initializeNativeTerminalHostServer(options) {
944
+ options.signal?.throwIfAborted();
488
945
  if (!isAbsolute(options.socketPath))
489
946
  throw new Error("terminal host socket path must be absolute");
490
- await ensurePrivateSocketDirectory(options.socketPath);
947
+ await abortable(ensurePrivateSocketDirectory(options.socketPath), options.signal);
491
948
  const sockets = new Set();
492
949
  const responseBudget = { pendingBytes: 0 };
950
+ let readyForRequests = false;
493
951
  const server = createServer((socket) => {
494
952
  if (sockets.size >= NATIVE_TERMINAL_MAX_CONNECTIONS) {
495
953
  socket.destroy();
496
954
  return;
497
955
  }
956
+ // A visible socket is not yet a completed publication: its durable owner
957
+ // record and publication lock must finish before health/PTY admission.
958
+ if (!readyForRequests)
959
+ socket.pause();
498
960
  const context = {
499
961
  socket,
500
962
  leases: new Map(),
@@ -526,28 +988,46 @@ export async function startNativeTerminalHostServer(options) {
526
988
  const stagedPath = stagingSocketPath(options.socketPath);
527
989
  let ownedSocket;
528
990
  try {
529
- await new Promise((resolve, reject) => {
991
+ await abortable(new Promise((resolve, reject) => {
530
992
  server.once("error", reject);
531
993
  server.listen(stagedPath, () => {
532
994
  server.removeListener("error", reject);
533
995
  resolve();
534
996
  });
535
- });
536
- await chmod(stagedPath, 0o600);
537
- ownedSocket = await withSocketPublicationLock(options.socketPath, () => publishSocket(stagedPath, options.socketPath));
997
+ }), options.signal);
998
+ // Capture the listener's inode before any publication await can suspend.
999
+ ownedSocket = lstatSync(stagedPath);
1000
+ await abortable(chmod(stagedPath, 0o600), options.signal);
1001
+ await withSocketPublicationLock(options.socketPath, async (signal) => {
1002
+ try {
1003
+ signal.throwIfAborted();
1004
+ const published = await publishSocket(stagedPath, options.socketPath, signal);
1005
+ await recordEndpointOwner(options.socketPath, published, undefined, signal);
1006
+ return published;
1007
+ }
1008
+ catch (pubError) {
1009
+ // A link may have committed without resolving its promise. Invalidate
1010
+ // its source first, then remove only this listener's published inode.
1011
+ unlinkOwnedPath(stagedPath, ownedSocket);
1012
+ rollbackEndpoint(options.socketPath, ownedSocket);
1013
+ throw pubError;
1014
+ }
1015
+ }, options.signal);
538
1016
  }
539
1017
  catch (error) {
540
- await new Promise((resolve) => server.close(() => resolve()));
541
- try {
542
- await unlink(stagedPath);
543
- }
544
- catch (cleanupError) {
545
- if (cleanupError.code !== "ENOENT") {
546
- throw cleanupError;
547
- }
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);
548
1024
  }
1025
+ await abortable(closed, AbortSignal.timeout(SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS));
549
1026
  throw error;
550
1027
  }
1028
+ readyForRequests = true;
1029
+ for (const socket of sockets)
1030
+ socket.resume();
551
1031
  let closing;
552
1032
  return Object.freeze({
553
1033
  socketPath: options.socketPath,
@@ -568,15 +1048,12 @@ export async function startNativeTerminalHostServer(options) {
568
1048
  }
569
1049
  for (const socket of sockets)
570
1050
  socket.destroy();
571
- await new Promise((resolve, reject) => {
1051
+ await abortable(new Promise((resolve, reject) => {
572
1052
  server.close((error) => error ? reject(error) : resolve());
573
- });
1053
+ }), AbortSignal.timeout(SOCKET_PUBLICATION_CLEANUP_TIMEOUT_MS));
574
1054
  await withSocketPublicationLock(options.socketPath, async () => {
575
1055
  try {
576
- const current = await inspectPrivateNativeTerminalSocket(options.socketPath);
577
- if (sameNativeTerminalSocket(current, ownedSocket)) {
578
- await unlink(options.socketPath);
579
- }
1056
+ rollbackEndpoint(options.socketPath, ownedSocket);
580
1057
  }
581
1058
  catch (error) {
582
1059
  if (error.code !== "ENOENT")