@bitkyc08/opencodex 2.67.0 → 2.68.0-preview.20260927

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 (257) hide show
  1. package/bin/ocx.mjs +3 -0
  2. package/gui/dist/assets/App-B5-cAEd2.js +51 -0
  3. package/gui/dist/assets/App-DCBismRi.css +1 -0
  4. package/gui/dist/assets/Tray-DFnMiyD0.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-BcXblvem.js +86 -0
  7. package/gui/dist/assets/quota-summary-i84eOU3o.js +1 -0
  8. package/gui/dist/index.html +21 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-image-guard.ts +13 -1
  11. package/src/adapters/anthropic-image-normalize.ts +3 -2
  12. package/src/adapters/anthropic-output-schema.ts +36 -0
  13. package/src/adapters/anthropic.ts +10 -2
  14. package/src/adapters/base.ts +7 -0
  15. package/src/adapters/codebuddy/live-models.ts +180 -0
  16. package/src/adapters/coding-agent/protocol.ts +91 -12
  17. package/src/adapters/coding-agent/turn.ts +32 -27
  18. package/src/adapters/devin/cloud-direct/index.ts +1 -0
  19. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
  20. package/src/adapters/devin.ts +9 -6
  21. package/src/adapters/google-http.ts +4 -4
  22. package/src/adapters/google.ts +93 -3
  23. package/src/adapters/kiro/adapter.ts +6 -2
  24. package/src/adapters/kiro/stream.ts +12 -2
  25. package/src/adapters/kiro/usage.ts +3 -1
  26. package/src/adapters/kiro-errors.ts +11 -1
  27. package/src/adapters/kiro-events.ts +27 -1
  28. package/src/adapters/kiro-refusal.ts +30 -0
  29. package/src/adapters/kiro-retry.ts +71 -31
  30. package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
  31. package/src/adapters/openai-chat/messages.ts +31 -4
  32. package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
  33. package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
  34. package/src/adapters/openai-chat/tool-schema.ts +6 -2
  35. package/src/adapters/openai-chat.ts +2 -2
  36. package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
  37. package/src/adapters/openai-responses/passthrough.ts +9 -3
  38. package/src/adapters/opencode-go-additional-tools.ts +19 -10
  39. package/src/adapters/physical-send.ts +10 -3
  40. package/src/adapters/registry.ts +2 -1
  41. package/src/adapters/responses-tool-schema.ts +31 -3
  42. package/src/adapters/run-turn-queue.ts +63 -23
  43. package/src/adapters/unique-tool-call-ids.ts +63 -0
  44. package/src/adapters/xai-web-search.ts +32 -2
  45. package/src/bridge/sse.ts +14 -2
  46. package/src/chat/inbound.ts +20 -6
  47. package/src/claude/agents-inject.ts +4 -0
  48. package/src/claude/desktop-3p.ts +53 -13
  49. package/src/claude/desktop-profile.ts +41 -4
  50. package/src/claude/inbound-model-options.ts +14 -2
  51. package/src/claude/inbound.ts +1 -1
  52. package/src/claude/intercept/connect-proxy.ts +17 -1
  53. package/src/claude/intercept/local-ca.ts +7 -1
  54. package/src/claude/message-threads.ts +28 -0
  55. package/src/cli/account-api.ts +13 -0
  56. package/src/cli/account-auth.ts +49 -2
  57. package/src/cli/account-extended.ts +4 -2
  58. package/src/cli/account.ts +4 -2
  59. package/src/cli/capabilities.ts +17 -0
  60. package/src/cli/claude.ts +3 -1
  61. package/src/cli/dispatch.ts +62 -5
  62. package/src/cli/index.ts +84 -79
  63. package/src/cli/minimax.ts +4 -2
  64. package/src/cli/opencode.ts +6 -1
  65. package/src/cli/restart-handoff.ts +110 -0
  66. package/src/cli/status.ts +51 -0
  67. package/src/client/connect.ts +34 -15
  68. package/src/client/link-ingress.ts +102 -0
  69. package/src/client/link-join.ts +37 -16
  70. package/src/client/link-relay.ts +228 -45
  71. package/src/client/link-state.ts +54 -0
  72. package/src/client/link-status.ts +39 -0
  73. package/src/client/link-teardown.ts +2 -2
  74. package/src/client/link-tunnel.ts +615 -86
  75. package/src/client/machine-api.ts +10 -4
  76. package/src/client/machine-listener.ts +57 -10
  77. package/src/client/runtime.ts +210 -35
  78. package/src/clients/config-export/constants.ts +1 -12
  79. package/src/clients/config-export/contracts.ts +2 -0
  80. package/src/clients/config-export/model-metadata.ts +9 -3
  81. package/src/clients/config-export/omp.ts +1 -1
  82. package/src/clients/config-export/zcode-store.ts +2 -2
  83. package/src/clients/config-export.ts +36 -3
  84. package/src/codex/app-server-processes.ts +32 -0
  85. package/src/codex/app-server-restart-service.ts +20 -3
  86. package/src/codex/catalog/access-programs.ts +50 -0
  87. package/src/codex/catalog/build-entries.ts +13 -0
  88. package/src/codex/catalog/derive-entry.ts +1 -0
  89. package/src/codex/catalog/model-hints.ts +11 -0
  90. package/src/codex/catalog/parsing.ts +6 -2
  91. package/src/codex/catalog/provider-models.ts +81 -0
  92. package/src/codex/catalog/retained-sync.ts +3 -0
  93. package/src/codex/codex-write-lock.ts +2 -2
  94. package/src/codex/convergence.ts +2 -0
  95. package/src/codex/desired-state.ts +14 -4
  96. package/src/codex/home.ts +21 -3
  97. package/src/codex/inject/restore.ts +17 -0
  98. package/src/codex/inject/routing-classify.ts +3 -2
  99. package/src/codex/inject-coordination.ts +4 -1
  100. package/src/codex/inject.ts +8 -1
  101. package/src/codex/journal.ts +6 -2
  102. package/src/codex/management-convergence.ts +9 -0
  103. package/src/codex/model-entitlements.ts +28 -5
  104. package/src/codex/routing/idle-window.ts +58 -0
  105. package/src/codex/routing-drift.ts +134 -0
  106. package/src/codex/routing-healer.ts +419 -0
  107. package/src/codex/routing.ts +10 -0
  108. package/src/codex/runtime.ts +188 -57
  109. package/src/codex/sibling-handoff.ts +71 -0
  110. package/src/codex/sibling-start.ts +139 -0
  111. package/src/codex/sync.ts +5 -3
  112. package/src/combos/failover.ts +32 -5
  113. package/src/combos/request.ts +11 -3
  114. package/src/combos/reset-window.ts +10 -5
  115. package/src/combos/resolve.ts +36 -6
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/load-degrade.ts +2 -0
  118. package/src/config/paths.ts +7 -1
  119. package/src/config/process-state.ts +10 -1
  120. package/src/config/schema/compaction-recovery.ts +15 -0
  121. package/src/config/schema/config-schema.ts +2 -0
  122. package/src/config/schema/leaf-validators.ts +9 -3
  123. package/src/generated/compatibility-version.json +360 -204
  124. package/src/images/loop.ts +47 -32
  125. package/src/integrations/catalog-refresh.ts +4 -0
  126. package/src/integrations/omp-yaml-source.ts +1 -0
  127. package/src/lib/claude-request-projection.ts +101 -0
  128. package/src/lib/local-account-switch-capability.ts +48 -0
  129. package/src/lib/local-upstream.ts +146 -0
  130. package/src/lib/package-tree-integrity.ts +25 -3
  131. package/src/lib/package-tree-retarget.ts +130 -0
  132. package/src/lib/process-control.ts +4 -1
  133. package/src/lib/system-restart-contract.ts +106 -0
  134. package/src/lib/upstream-retry.ts +57 -1
  135. package/src/link/ports.ts +9 -0
  136. package/src/link/ssh-argv.ts +16 -0
  137. package/src/link/ssh-runner.ts +70 -2
  138. package/src/link/tunnel-state.ts +58 -9
  139. package/src/oauth/account-quota-rank.ts +29 -18
  140. package/src/oauth/generic-account-failover.ts +179 -22
  141. package/src/oauth/index.ts +17 -4
  142. package/src/oauth/kiro-account-load.ts +107 -0
  143. package/src/oauth/kiro-device-login.ts +312 -0
  144. package/src/oauth/kiro-terminal-failover.ts +28 -0
  145. package/src/oauth/login-flow-state.ts +7 -1
  146. package/src/oauth/pool-settings-capability.ts +23 -14
  147. package/src/oauth/store.ts +56 -3
  148. package/src/oauth/types.ts +4 -0
  149. package/src/plugins/loader.ts +350 -0
  150. package/src/plugins/upstream-hooks.ts +119 -0
  151. package/src/protocols/encoders/adapter-events.ts +9 -2
  152. package/src/providers/account-quota-disk.ts +42 -4
  153. package/src/providers/codebuddy-models.ts +6 -2
  154. package/src/providers/command-code-efforts.ts +33 -10
  155. package/src/providers/kiro-account-state-disk.ts +44 -0
  156. package/src/providers/kiro-model-catalog.ts +162 -0
  157. package/src/providers/kiro-models.ts +5 -4
  158. package/src/providers/kiro-quota-metrics.ts +35 -0
  159. package/src/providers/kiro-usage.ts +118 -14
  160. package/src/providers/quota/account-cache.ts +43 -7
  161. package/src/providers/quota/antigravity.ts +8 -3
  162. package/src/providers/quota/kiro-account-probe.ts +12 -0
  163. package/src/providers/quota/vendor-probes-key.ts +31 -26
  164. package/src/providers/quota/vendor-probes-oauth.ts +16 -7
  165. package/src/providers/quota-types.ts +14 -0
  166. package/src/providers/quota.ts +21 -21
  167. package/src/providers/registry/entries-extended.ts +5 -3
  168. package/src/providers/request-pacing.ts +410 -12
  169. package/src/remote-control/workspace-codex-runtime.ts +4 -3
  170. package/src/responses/citation-markers.ts +132 -65
  171. package/src/responses/hosted-tool-policy.ts +14 -3
  172. package/src/responses/parser-content.ts +3 -2
  173. package/src/responses/parser.ts +4 -1
  174. package/src/responses/schema.ts +4 -0
  175. package/src/responses/visualization-directives.ts +182 -0
  176. package/src/server/chat-native-sse.ts +24 -12
  177. package/src/server/chat-native.ts +137 -68
  178. package/src/server/claude-messages.ts +147 -12
  179. package/src/server/index/link-listener.ts +7 -0
  180. package/src/server/index/optional-listeners.ts +4 -1
  181. package/src/server/index/package-tree-guard.ts +35 -16
  182. package/src/server/index/serve-options.ts +4 -2
  183. package/src/server/index/startup-warnings.ts +6 -0
  184. package/src/server/index.ts +7 -7
  185. package/src/server/inference/client-encoder-delivery.ts +3 -0
  186. package/src/server/inference/context.ts +30 -2
  187. package/src/server/local-account-switch-auth.ts +79 -0
  188. package/src/server/management/agent-settings-routes.ts +11 -3
  189. package/src/server/management/config-routes.ts +28 -11
  190. package/src/server/management/context.ts +2 -0
  191. package/src/server/management/link-routes.ts +123 -39
  192. package/src/server/management/logs-usage-routes.ts +9 -42
  193. package/src/server/management/model-rows.ts +1 -0
  194. package/src/server/management/oauth-account-routes.ts +67 -12
  195. package/src/server/management/route-registry.ts +5 -5
  196. package/src/server/management/sibling-guard.ts +60 -0
  197. package/src/server/management/storage-log-guard-routes.ts +72 -6
  198. package/src/server/management/system-restart.ts +92 -135
  199. package/src/server/management/system-routes.ts +1 -1
  200. package/src/server/management-api.ts +31 -3
  201. package/src/server/management-auth.ts +3 -0
  202. package/src/server/port-reclaim.ts +111 -1
  203. package/src/server/proxy-liveness.ts +16 -0
  204. package/src/server/relay.ts +16 -15
  205. package/src/server/request-log-filter.ts +28 -27
  206. package/src/server/request-log.ts +7 -3
  207. package/src/server/request-metrics.ts +28 -0
  208. package/src/server/responses/adapter-continuation.ts +151 -21
  209. package/src/server/responses/adapter-delivery.ts +21 -7
  210. package/src/server/responses/adapter-dispatch.ts +239 -51
  211. package/src/server/responses/codex-ws-pool.ts +5 -4
  212. package/src/server/responses/codex-ws-request.ts +4 -1
  213. package/src/server/responses/compact.ts +8 -4
  214. package/src/server/responses/compaction-recovery-policy.ts +116 -0
  215. package/src/server/responses/compaction-recovery.ts +330 -0
  216. package/src/server/responses/core-combo-failure.ts +1 -0
  217. package/src/server/responses/core-combo.ts +5 -1
  218. package/src/server/responses/core-lifetime.ts +22 -0
  219. package/src/server/responses/core-options.ts +16 -1
  220. package/src/server/responses/core.ts +14 -4
  221. package/src/server/responses/empty-completion-guard.ts +2 -0
  222. package/src/server/responses/fetch-helpers.ts +82 -31
  223. package/src/server/responses/passthrough-delivery.ts +3 -2
  224. package/src/server/responses/passthrough-dispatch.ts +12 -2
  225. package/src/server/responses/passthrough-execution.ts +2 -2
  226. package/src/server/responses/request-send-budget.ts +21 -5
  227. package/src/server/responses/request-transport.ts +116 -18
  228. package/src/server/responses/reset-replay.ts +32 -0
  229. package/src/server/responses/run-turn-execution.ts +136 -19
  230. package/src/server/responses/sidecar-execution.ts +41 -8
  231. package/src/server/responses/terminal-guard.ts +2 -0
  232. package/src/server/responses/ws-upstream.ts +44 -4
  233. package/src/server/restart-replacement.ts +382 -0
  234. package/src/server/startup-health-cache.ts +23 -4
  235. package/src/server/stop-teardown.ts +35 -1
  236. package/src/server/system-env.ts +3 -0
  237. package/src/service/windows-taskxml.ts +11 -7
  238. package/src/service/windows-wrapper-exit.ts +8 -0
  239. package/src/stall-timeout.ts +35 -10
  240. package/src/storage/policy-job.ts +4 -0
  241. package/src/storage/scanner.ts +177 -29
  242. package/src/storage/storage-mutation-coordinator.ts +17 -0
  243. package/src/tray/windows-tray.ps1 +50 -23
  244. package/src/types/config.ts +4 -0
  245. package/src/types/provider.ts +5 -1
  246. package/src/types/request.ts +13 -2
  247. package/src/update/index.ts +3 -1
  248. package/src/update/job.ts +6 -2
  249. package/src/update/mise-launcher-target.ts +100 -0
  250. package/src/update/worker-launch.ts +46 -0
  251. package/src/usage/log.ts +2 -0
  252. package/src/web-search/loop.ts +5 -2
  253. package/gui/dist/assets/App-BJsT8Icc.css +0 -1
  254. package/gui/dist/assets/App-D3pNGiN4.js +0 -51
  255. package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
  256. package/gui/dist/assets/index-CouvvtMV.js +0 -86
  257. package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
@@ -1,19 +1,29 @@
1
- import { chmodSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
1
+ import { chmodSync, mkdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
2
+ import { execFile } from "node:child_process";
3
+ import { readFile as readFileAsync } from "node:fs/promises";
2
4
  import { dirname, join } from "node:path";
5
+ import { promisify } from "node:util";
3
6
  import { atomicWriteFile, isMissingPathError } from "../config/atomic-write";
7
+ import { getConfigPath } from "../config/paths";
8
+ import { readBoundedResponseBytes } from "../lib/bounded-body";
4
9
  import { linkDir, linkKnownHostsPath } from "../link/paths";
5
10
  import { buildTunnelArgv } from "../link/ssh-argv";
6
11
  import { createSshRunner, type SshChild, type SshRunner } from "../link/ssh-runner";
7
12
  import {
13
+ CLIENT_TUNNEL_RETRY_POLICY,
8
14
  classifySshStderr,
9
15
  dueForSpawn,
16
+ failedTunnel,
10
17
  IDLE,
11
18
  reduceTunnel,
19
+ type StderrClass,
12
20
  type TunnelState,
13
21
  } from "../link/tunnel-state";
14
22
  import { isLinkPort } from "../link/ports";
23
+ import { ownsIpv4LoopbackListener } from "../server/port-reclaim";
15
24
  import { isLinkConnection, readClientConnectionState } from "./state";
16
25
  import { clientLinkStatePath, readClientLinkState, type ClientLinkState } from "./link-state";
26
+ import type { LinkTunnelGate } from "./link-relay";
17
27
 
18
28
  /**
19
29
  * The client-owned `ssh -N -L 127.0.0.1:<tunnelPort>:127.0.0.1:<peerListenerPort> <alias>`
@@ -29,6 +39,7 @@ export interface ClientLinkTunnelSpec {
29
39
 
30
40
  export interface ClientLinkTunnelHandle {
31
41
  readonly pid: number;
42
+ readonly startTime: string | null;
32
43
  readonly exited: Promise<number>;
33
44
  /** TERM, wait up to 5 s, then KILL; removes the pidfile this handle wrote. Idempotent. */
34
45
  stop(): Promise<void>;
@@ -45,21 +56,39 @@ export interface ClientLinkTunnelDeps {
45
56
  export type OrphanTunnelResult =
46
57
  | { tunnel: "reaped" }
47
58
  | { tunnel: "absent" }
48
- | { tunnel: "owned" }
59
+ | { tunnel: "owned"; pid?: number }
49
60
  | { tunnel: "unresolved"; pid: number };
50
61
 
62
+ /** A macOS process as `ps -o ppid= -o args=` shows it: the parent pid and the space-joined argv. */
63
+ export interface DarwinProcessInfo {
64
+ ppid: number;
65
+ startTime: string;
66
+ args: string;
67
+ }
68
+
51
69
  export interface OrphanReapDeps {
52
70
  configDir?: string;
53
71
  platform?: NodeJS.Platform;
54
72
  readProcessArgv?: (pid: number) => readonly string[] | null;
73
+ /** Stable process start identity; unavailable identity means the orphan is never signalled. */
74
+ readProcessStartTime?: (pid: number, platform: NodeJS.Platform) => string | null;
75
+ /** macOS: the parent pid and argv of `pid`, or null when they cannot be read. */
76
+ readProcessInfo?: (pid: number) => DarwinProcessInfo | null;
55
77
  isAlive?: (pid: number) => boolean;
56
78
  signal?: (pid: number, signal: NodeJS.Signals) => void;
57
79
  sleep?: (ms: number) => Promise<void>;
58
80
  }
59
81
 
82
+ /**
83
+ * What the periodic keyed probe last saw, for display only: it never changes a connected tunnel.
84
+ * `home_not_ready` is a Home that admitted the key but reports its own startup readiness as pending
85
+ * or failed; its link still works.
86
+ */
87
+ export type ClientTunnelProbeReason = "unauthorized" | "home_unreachable" | "home_not_ready";
88
+
60
89
  export type ClientLinkSupervisorStatus =
61
90
  | { kind: "stopped" }
62
- | { kind: "tunnel"; linkId: string; state: TunnelState; pid: number | null }
91
+ | { kind: "tunnel"; linkId: string; state: TunnelState; pid: number | null; probe?: ClientTunnelProbeReason }
63
92
  | { kind: "failed"; reason: "sidecar_invalid" };
64
93
 
65
94
  export interface ClientLinkTunnelStatusProjection {
@@ -69,7 +98,8 @@ export interface ClientLinkTunnelStatusProjection {
69
98
  reason: "sidecar_invalid";
70
99
  }
71
100
 
72
- export interface ClientLinkSupervisor {
101
+ /** The supervisor is also the relay's tunnel gate: requests wait on it while it reconnects. */
102
+ export interface ClientLinkSupervisor extends LinkTunnelGate {
73
103
  start(): void;
74
104
  /** Stops the tunnel (TERM, up to 5 s, KILL). The runtime calls this before stopping its listener. */
75
105
  stop(): Promise<void>;
@@ -89,12 +119,23 @@ export function clientLinkTunnelStatus(
89
119
  }
90
120
  }
91
121
 
122
+ /** `connectedLinkId` when the connection state could not be read (a write in flight, a bad file). */
123
+ export const CONNECTION_UNREADABLE = "unreadable";
124
+
92
125
  export interface ClientLinkSupervisorDeps extends ClientLinkTunnelDeps, OrphanReapDeps {
93
126
  readSidecar?: () => ClientLinkState | null;
94
- /** Current link id of a connected link-transport client, or null when that no longer holds. */
95
- connectedLinkId?: () => string | null;
127
+ /**
128
+ * Current link id of a connected link-transport client, null when that no longer holds, or
129
+ * `CONNECTION_UNREADABLE` when the connection state could not be read.
130
+ */
131
+ connectedLinkId?: () => string | null | typeof CONNECTION_UNREADABLE;
96
132
  /** Called once after the tunnel stopped because the link ended (the runtime recycles here). */
97
133
  onLinkEnded?: () => void;
134
+ /** The link key for the keyed readiness probe. The runtime passes its cached key source. */
135
+ linkKey?: () => string | null;
136
+ /** Async local IPv4 LISTEN ownership; an unknown or foreign owner never receives the link key. */
137
+ ownsLoopbackListener?: (port: number, pid: number) => Promise<boolean>;
138
+ fetchImpl?: typeof fetch;
98
139
  now?: () => number;
99
140
  random?: () => number;
100
141
  warn?: (message: string) => void;
@@ -115,11 +156,25 @@ export interface ClientTunnelPidfile {
115
156
  pid: number;
116
157
  argv: string[];
117
158
  ownerPid: number;
159
+ /** Captured at spawn; legacy pidfiles without it cannot authorize a signal. */
160
+ startTime: string | null;
118
161
  }
119
162
 
120
163
  const STOP_TIMEOUT_MS = 5_000;
164
+ const REAP_POLL_MS = 100;
121
165
  const TIMER_MS = 1_000;
122
- const SPAWN_GRACE_MS = 5_000;
166
+ /** Consecutive unreadable reads before the supervisor acts on them. */
167
+ export const INVALID_READ_TICKS = 3;
168
+ /** How often a connected tunnel is probed. The result is display-only. */
169
+ export const CONNECTED_PROBE_MS = 30_000;
170
+ const PROBE_TIMEOUT_MS = 5_000;
171
+ const PROBE_BACKOFF_MAX_MS = 5_000;
172
+ /** A Home `/readyz` body is a few hundred bytes; a larger one is not the Home's. */
173
+ const PROBE_BODY_MAX_BYTES = 4_096;
174
+ /** Requests that may wait on a reconnecting tunnel at once; more are answered 503 at once. */
175
+ export const CLIENT_LINK_MAX_HOLDS = 64;
176
+ const PROCESS_IDENTITY_TIMEOUT_MS = 2_000;
177
+ const execFileAsync = promisify(execFile);
123
178
 
124
179
  function sameArgv(left: readonly string[], right: readonly string[]): boolean {
125
180
  return left.length === right.length && left.every((value, index) => value === right[index]);
@@ -131,13 +186,16 @@ function parsePidfile(value: unknown): ClientTunnelPidfile | null {
131
186
  if (raw.version !== 1 || typeof raw.linkId !== "string" || typeof raw.pid !== "number"
132
187
  || !Number.isSafeInteger(raw.pid) || raw.pid < 1 || !Array.isArray(raw.argv)
133
188
  || raw.argv.length === 0 || raw.argv.some(item => typeof item !== "string")
134
- || typeof raw.ownerPid !== "number" || !Number.isSafeInteger(raw.ownerPid) || raw.ownerPid < 1) return null;
189
+ || typeof raw.ownerPid !== "number" || !Number.isSafeInteger(raw.ownerPid) || raw.ownerPid < 1
190
+ || (raw.startTime !== undefined && raw.startTime !== null
191
+ && (typeof raw.startTime !== "string" || raw.startTime.length === 0 || raw.startTime.length > 128))) return null;
135
192
  return {
136
193
  version: 1,
137
194
  linkId: raw.linkId,
138
195
  pid: raw.pid,
139
196
  argv: raw.argv as string[],
140
197
  ownerPid: raw.ownerPid,
198
+ startTime: typeof raw.startTime === "string" ? raw.startTime : null,
141
199
  };
142
200
  }
143
201
 
@@ -157,9 +215,9 @@ function writePidfile(path: string, value: ClientTunnelPidfile): void {
157
215
  if (process.platform !== "win32") chmodSync(path, 0o600);
158
216
  }
159
217
 
160
- function removePidfileIfPid(path: string, pid: number): void {
218
+ function removePidfileIfPid(path: string, pid: number, startTime: string | null): void {
161
219
  const current = readPidfile(path);
162
- if (current?.pid !== pid) return;
220
+ if (current?.pid !== pid || current.startTime !== startTime) return;
163
221
  try {
164
222
  unlinkSync(path);
165
223
  } catch (error) {
@@ -187,15 +245,101 @@ function defaultIsAlive(pid: number): boolean {
187
245
 
188
246
  function linuxProcessArgv(pid: number): readonly string[] | null {
189
247
  try {
190
- const values = readFileSync(`/proc/${pid}/cmdline`).toString().split("\0");
191
- if (values.at(-1) === "") values.pop();
192
- return values.length > 0 ? values : null;
248
+ return parseLinuxArgv(readFileSync(`/proc/${pid}/cmdline`).toString());
193
249
  } catch (error) {
194
250
  if (isMissingPathError(error)) return null;
195
251
  return null;
196
252
  }
197
253
  }
198
254
 
255
+ function parseLinuxArgv(content: string): readonly string[] | null {
256
+ const values = content.split("\0");
257
+ if (values.at(-1) === "") values.pop();
258
+ return values.length > 0 ? values : null;
259
+ }
260
+
261
+ function parseLinuxStartTime(stat: string): string | null {
262
+ const afterCommand = stat.lastIndexOf(") ");
263
+ if (afterCommand < 0) return null;
264
+ const startTime = stat.slice(afterCommand + 2).trim().split(/\s+/)[19];
265
+ return startTime && /^\d+$/.test(startTime) ? startTime : null;
266
+ }
267
+
268
+ /** Field 22 of /proc/<pid>/stat: kernel start ticks survive argv and parent changes. */
269
+ function linuxProcessStartTime(pid: number): string | null {
270
+ try {
271
+ return parseLinuxStartTime(readFileSync(`/proc/${pid}/stat`, "utf8"));
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ function parseDarwinProcessInfo(output: string): DarwinProcessInfo | null {
278
+ const match = /^\s*(\d+)\s+(\S+\s+\S+\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/.exec(output.replace(/\r?\n$/, ""));
279
+ return match ? { ppid: Number(match[1]), startTime: match[2]!, args: match[3]! } : null;
280
+ }
281
+
282
+ /** One `ps` snapshot carries parent, second-resolution start time and the full argv. */
283
+ function darwinProcessInfo(pid: number): DarwinProcessInfo | null {
284
+ try {
285
+ const result = Bun.spawnSync(["/bin/ps", "-ww", "-o", "ppid=", "-o", "lstart=", "-o", "args=", "-p", String(pid)], {
286
+ stdin: "ignore",
287
+ stdout: "pipe",
288
+ stderr: "ignore",
289
+ });
290
+ if (result.exitCode !== 0) return null;
291
+ return parseDarwinProcessInfo(result.stdout.toString());
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
296
+
297
+ type AdoptedIdentityVerdict = "match" | "mismatch" | "unknown";
298
+
299
+ /** An unreadable identity denies this admission; only readable disagreement releases adoption. */
300
+ async function inspectAdoptedIdentity(record: ClientTunnelPidfile, deps: OrphanReapDeps): Promise<AdoptedIdentityVerdict> {
301
+ if (!record.startTime) return "unknown";
302
+ const platform = deps.platform ?? process.platform;
303
+ const check = async (): Promise<AdoptedIdentityVerdict> => {
304
+ if (platform === "linux") {
305
+ const [argv, startTime] = await Promise.all([
306
+ deps.readProcessArgv
307
+ ? Promise.resolve().then(() => deps.readProcessArgv!(record.pid))
308
+ : readFileAsync(`/proc/${record.pid}/cmdline`, "utf8").then(parseLinuxArgv),
309
+ deps.readProcessStartTime
310
+ ? Promise.resolve().then(() => deps.readProcessStartTime!(record.pid, platform))
311
+ : readFileAsync(`/proc/${record.pid}/stat`, "utf8").then(parseLinuxStartTime),
312
+ ]);
313
+ if (argv === null || startTime === null) return "unknown";
314
+ return sameArgv(argv, record.argv) && startTime === record.startTime ? "match" : "mismatch";
315
+ }
316
+ if (platform === "darwin") {
317
+ const info = deps.readProcessInfo
318
+ ? await Promise.resolve().then(() => deps.readProcessInfo!(record.pid))
319
+ : await execFileAsync("/bin/ps", ["-ww", "-o", "ppid=", "-o", "lstart=", "-o", "args=", "-p", String(record.pid)], {
320
+ encoding: "utf8", timeout: PROCESS_IDENTITY_TIMEOUT_MS, maxBuffer: 64 * 1024, windowsHide: true,
321
+ }).then(result => parseDarwinProcessInfo(result.stdout));
322
+ if (info === null) return "unknown";
323
+ return info.args === record.argv.join(" ") && info.startTime === record.startTime ? "match" : "mismatch";
324
+ }
325
+ return "unknown";
326
+ };
327
+ let timer: ReturnType<typeof setTimeout> | undefined;
328
+ const deadline = new Promise<"unknown">(resolve => {
329
+ timer = setTimeout(() => resolve("unknown"), PROCESS_IDENTITY_TIMEOUT_MS);
330
+ timer.unref?.();
331
+ });
332
+ try { return await Promise.race([check().catch(() => "unknown" as const), deadline]); }
333
+ finally { if (timer) clearTimeout(timer); }
334
+ }
335
+
336
+ function processStartTime(pid: number, platform: NodeJS.Platform, deps: OrphanReapDeps): string | null {
337
+ if (deps.readProcessStartTime) return deps.readProcessStartTime(pid, platform);
338
+ if (platform === "linux") return linuxProcessStartTime(pid);
339
+ if (platform === "darwin") return (deps.readProcessInfo ?? darwinProcessInfo)(pid)?.startTime ?? null;
340
+ return null;
341
+ }
342
+
199
343
  function timerDeps(deps: ClientLinkTunnelDeps): Required<Pick<ClientLinkTunnelDeps, "setTimer" | "clearTimer">> {
200
344
  return {
201
345
  setTimer: deps.setTimer ?? ((callback, ms) => setTimeout(callback, ms)),
@@ -240,8 +384,9 @@ export function spawnClientLinkTunnel(spec: ClientLinkTunnelSpec, deps: ClientLi
240
384
  });
241
385
  const child = runner.spawnTunnel(argv);
242
386
  const pidfile = clientTunnelPidfilePath(deps.configDir);
387
+ const startTime = processStartTime(child.pid, process.platform, deps);
243
388
  try {
244
- writePidfile(pidfile, { version: 1, linkId: spec.linkId, pid: child.pid, argv: [...argv], ownerPid: process.pid });
389
+ writePidfile(pidfile, { version: 1, linkId: spec.linkId, pid: child.pid, argv: [...argv], ownerPid: process.pid, startTime });
245
390
  } catch (error) {
246
391
  try { child.kill("SIGTERM"); } catch (killError) { if ((killError as NodeJS.ErrnoException).code !== "ESRCH") throw killError; }
247
392
  throw error;
@@ -249,15 +394,16 @@ export function spawnClientLinkTunnel(spec: ClientLinkTunnelSpec, deps: ClientLi
249
394
 
250
395
  let stopPromise: Promise<void> | undefined;
251
396
  const handleExit = (): void => {
252
- removePidfileIfPid(pidfile, child.pid);
397
+ removePidfileIfPid(pidfile, child.pid, startTime);
253
398
  };
254
399
  const handle = {
255
400
  pid: child.pid,
401
+ startTime,
256
402
  exited: child.exited,
257
403
  stderr: child.stderr,
258
404
  stop(): Promise<void> {
259
405
  if (stopPromise) return stopPromise;
260
- stopPromise = stopChild(child, deps).finally(() => removePidfileIfPid(pidfile, child.pid));
406
+ stopPromise = stopChild(child, deps).finally(() => removePidfileIfPid(pidfile, child.pid, startTime));
261
407
  return stopPromise;
262
408
  },
263
409
  } satisfies ClientLinkTunnelHandle & { stderr?: Promise<string> };
@@ -265,44 +411,175 @@ export function spawnClientLinkTunnel(spec: ClientLinkTunnelSpec, deps: ClientLi
265
411
  return handle;
266
412
  }
267
413
 
414
+ type TunnelIdentity =
415
+ | { kind: "gone" }
416
+ | { kind: "other" }
417
+ | { kind: "ours"; orphaned: boolean; startTime: string }
418
+ | { kind: "unknown" };
419
+
420
+ /**
421
+ * Whether the pidfile's process is still our tunnel. Linux compares `/proc/<pid>/cmdline` with
422
+ * the recorded argv. macOS compares `ps` args with the argv joined by spaces and reports the
423
+ * process orphaned only when launchd (pid 1) is its parent. Elsewhere a live process is unknown.
424
+ */
425
+ function tunnelIdentity(
426
+ pidfile: ClientTunnelPidfile,
427
+ platform: NodeJS.Platform,
428
+ deps: OrphanReapDeps,
429
+ isAlive: (pid: number) => boolean,
430
+ ): TunnelIdentity {
431
+ if (platform === "linux") {
432
+ const actualArgv = (deps.readProcessArgv ?? linuxProcessArgv)(pidfile.pid);
433
+ if (!actualArgv) return { kind: "gone" };
434
+ if (!sameArgv(actualArgv, pidfile.argv)) return { kind: "other" };
435
+ const startTime = processStartTime(pidfile.pid, platform, deps);
436
+ if (!pidfile.startTime || !startTime) return { kind: "unknown" };
437
+ return startTime === pidfile.startTime ? { kind: "ours", orphaned: true, startTime } : { kind: "other" };
438
+ }
439
+ if (!isAlive(pidfile.pid)) return { kind: "gone" };
440
+ if (platform !== "darwin") return { kind: "unknown" };
441
+ const info = (deps.readProcessInfo ?? darwinProcessInfo)(pidfile.pid);
442
+ if (!info) return { kind: "unknown" };
443
+ if (info.args !== pidfile.argv.join(" ")) return { kind: "other" };
444
+ if (!pidfile.startTime || !info.startTime) return { kind: "unknown" };
445
+ return info.startTime === pidfile.startTime
446
+ ? { kind: "ours", orphaned: info.ppid === 1, startTime: info.startTime }
447
+ : { kind: "other" };
448
+ }
449
+
450
+ /**
451
+ * Settle a leftover tunnel pidfile before a new tunnel starts.
452
+ *
453
+ * - A pidfile whose process is gone, or is provably another program (a reused pid, as after a
454
+ * reboot), is stale: it is removed and the result is `absent`, so a new tunnel starts.
455
+ * - While the owner lives, a tunnel that is still ours (or cannot be told apart) is `owned`.
456
+ * - After the owner exits, a proven orphan is reaped: Linux on an exact `/proc` argv match, macOS
457
+ * on an exact `ps` argv match with launchd as the parent. TERM, up to five seconds, KILL.
458
+ * - Anything else is `unresolved`: never signalled; the caller watches it.
459
+ */
268
460
  export async function reapOrphanTunnel(deps: OrphanReapDeps = {}): Promise<OrphanTunnelResult> {
269
461
  const path = clientTunnelPidfilePath(deps.configDir);
270
462
  const pidfile = readPidfile(path);
271
463
  if (!pidfile) return { tunnel: "absent" };
272
464
  const isAlive = deps.isAlive ?? defaultIsAlive;
273
- if (isAlive(pidfile.ownerPid)) return { tunnel: "owned" };
274
465
  const platform = deps.platform ?? process.platform;
275
- if (platform !== "linux") return { tunnel: "unresolved", pid: pidfile.pid };
276
- const readProcessArgv = deps.readProcessArgv ?? linuxProcessArgv;
277
- const actualArgv = readProcessArgv(pidfile.pid);
278
- if (!actualArgv || !sameArgv(actualArgv, pidfile.argv)) {
279
- try { unlinkSync(path); } catch (error) { if (!isMissingPathError(error)) throw error; }
466
+ const identity = tunnelIdentity(pidfile, platform, deps, isAlive);
467
+ if (identity.kind === "gone" || identity.kind === "other") {
468
+ removePidfileIfPid(path, pidfile.pid, pidfile.startTime);
280
469
  return { tunnel: "absent" };
281
470
  }
471
+ if (isAlive(pidfile.ownerPid)) return { tunnel: "owned", pid: pidfile.pid };
472
+ if (identity.kind !== "ours" || !identity.orphaned) return { tunnel: "unresolved", pid: pidfile.pid };
473
+ // The pid may have been reused since the first read. Recheck the saved start identity and
474
+ // orphan proof immediately before each signal, including the escalation after the wait.
475
+ const stillOurOrphan = (): boolean => {
476
+ const current = tunnelIdentity(pidfile, platform, deps, isAlive);
477
+ return current.kind === "ours" && current.orphaned && current.startTime === identity.startTime;
478
+ };
479
+ if (!stillOurOrphan()) return { tunnel: "unresolved", pid: pidfile.pid };
282
480
  const signal = deps.signal ?? defaultSignal;
283
481
  const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
284
482
  try { signal(pidfile.pid, "SIGTERM"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
285
- await sleep(STOP_TIMEOUT_MS);
483
+ for (let waited = 0; waited < STOP_TIMEOUT_MS && isAlive(pidfile.pid); waited += REAP_POLL_MS) await sleep(REAP_POLL_MS);
286
484
  if (isAlive(pidfile.pid)) {
485
+ if (!stillOurOrphan()) return { tunnel: "unresolved", pid: pidfile.pid };
287
486
  try { signal(pidfile.pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
288
487
  }
289
- try { unlinkSync(path); } catch (error) { if (!isMissingPathError(error)) throw error; }
488
+ removePidfileIfPid(path, pidfile.pid, pidfile.startTime);
290
489
  return { tunnel: "reaped" };
291
490
  }
292
491
 
293
- function defaultConnectedLinkId(): string | null {
492
+ function defaultConnectedLinkId(): string | null | typeof CONNECTION_UNREADABLE {
294
493
  const state = readClientConnectionState();
494
+ if (state.kind === "invalid" || state.kind === "mismatched") return CONNECTION_UNREADABLE;
295
495
  if (state.kind !== "connected" || !isLinkConnection(state.value)) return null;
296
496
  return state.value.link?.linkId ?? null;
297
497
  }
298
498
 
499
+ function fileSignature(path: string): string | null {
500
+ try {
501
+ const stat = statSync(path);
502
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
503
+ } catch (error) {
504
+ return isMissingPathError(error) ? "missing" : null;
505
+ }
506
+ }
507
+
508
+ /**
509
+ * `read` again only when the file at `path` changed, so a steady link costs one stat per check
510
+ * instead of a parse. A read that throws, a value `keep` refuses, or a failed stat is never cached.
511
+ */
512
+ export function readWhenFileChanges<T>(path: () => string, read: () => T, keep: (value: T) => boolean = () => true): () => T {
513
+ let cached: { signature: string; value: T } | null = null;
514
+ return () => {
515
+ const signature = fileSignature(path());
516
+ if (signature !== null && cached?.signature === signature) return cached.value;
517
+ cached = null;
518
+ const value = read();
519
+ if (signature !== null && keep(value)) cached = { signature, value };
520
+ return value;
521
+ };
522
+ }
523
+
524
+ /** `ready` and `home_not_ready` both prove the link works; the rest do not. */
525
+ type ProbeResult = "ready" | ClientTunnelProbeReason;
526
+
527
+ /** Whether a 503 `/readyz` body is the Home's own readiness answer (`service: "opencodex"`). */
528
+ async function isOpencodexReadiness(response: Response, signal: AbortSignal): Promise<boolean> {
529
+ try {
530
+ const { bytes, oversized } = await readBoundedResponseBytes(response, { maxBytes: PROBE_BODY_MAX_BYTES, signal });
531
+ if (oversized) return false;
532
+ const body = JSON.parse(new TextDecoder().decode(bytes)) as unknown;
533
+ return typeof body === "object" && body !== null && (body as { service?: unknown }).service === "opencodex";
534
+ } catch {
535
+ return false;
536
+ }
537
+ }
538
+
539
+ /**
540
+ * `GET /readyz` through the tunnel with the link key; the key is sent as a header only. The Home's
541
+ * link listener answers 401 before it reaches `/readyz`, so both a 200 and a 503 carrying the
542
+ * Home's readiness body prove that the forward reaches that listener and that the key is admitted.
543
+ * The caller first proves the local LISTEN socket belongs to its SSH process; an arbitrary 200
544
+ * from a competing process never earns a keyed request.
545
+ * The 503 only means the Home's own startup readiness is pending or failed, which does not stop
546
+ * relayed requests; it is reported as `home_not_ready`, for display.
547
+ */
548
+ async function probeTunnel(fetchImpl: typeof fetch, tunnelPort: number, key: string, stop: AbortSignal): Promise<ProbeResult> {
549
+ const signal = AbortSignal.any([stop, AbortSignal.timeout(PROBE_TIMEOUT_MS)]);
550
+ try {
551
+ const response = await fetchImpl(`http://127.0.0.1:${tunnelPort}/readyz`, {
552
+ headers: { "x-opencodex-api-key": key },
553
+ cache: "no-store",
554
+ redirect: "manual",
555
+ signal,
556
+ });
557
+ if (response.status === 503) return await isOpencodexReadiness(response, signal) ? "home_not_ready" : "home_unreachable";
558
+ try { await response.body?.cancel(); } catch { /* the body is not needed */ }
559
+ if (response.status === 200) return "ready";
560
+ return response.status === 401 || response.status === 403 ? "unauthorized" : "home_unreachable";
561
+ } catch {
562
+ return "home_unreachable";
563
+ }
564
+ }
565
+
299
566
  export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}): ClientLinkSupervisor {
300
- const readSidecar = deps.readSidecar ?? (() => readClientLinkState(clientLinkStatePath(deps.configDir)));
301
- const connectedLinkId = deps.connectedLinkId ?? defaultConnectedLinkId;
567
+ const sidecarPath = (): string => clientLinkStatePath(deps.configDir);
568
+ const readSidecar = deps.readSidecar ?? readWhenFileChanges(sidecarPath, () => readClientLinkState(sidecarPath()));
569
+ const connectedLinkId = deps.connectedLinkId
570
+ ?? readWhenFileChanges(getConfigPath, defaultConnectedLinkId, value => value !== CONNECTION_UNREADABLE);
302
571
  const now = deps.now ?? (() => Date.now());
303
572
  const random = deps.random ?? Math.random;
304
- const setSupervisorTimer = deps.setTimer
305
- ?? ((callback: () => void, ms: number) => setInterval(callback, ms) as unknown as ReturnType<typeof setTimeout>);
573
+ const policy = CLIENT_TUNNEL_RETRY_POLICY;
574
+ const isAlive = deps.isAlive ?? defaultIsAlive;
575
+ const linkKey = deps.linkKey ?? (() => null);
576
+ const ownerLookup = deps.ownsLoopbackListener ?? ownsIpv4LoopbackListener;
577
+ const fetchImpl = deps.fetchImpl ?? fetch;
578
+ const setSupervisorTimer = deps.setTimer ?? ((callback: () => void, ms: number) => {
579
+ const interval = setInterval(callback, ms);
580
+ (interval as { unref?: () => void }).unref?.();
581
+ return interval as unknown as ReturnType<typeof setTimeout>;
582
+ });
306
583
  const clearSupervisorTimer = deps.clearTimer
307
584
  ?? ((timer: ReturnType<typeof setTimeout>) => clearInterval(timer as unknown as ReturnType<typeof setInterval>));
308
585
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -312,27 +589,126 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
312
589
  let initializing = false;
313
590
  let onLinkEndedCalled = false;
314
591
  let child: ClientLinkTunnelHandle | undefined;
592
+ /** A leftover tunnel this supervisor may not signal: watched, and replaced once it dies. */
593
+ let adopted: number | null = null;
594
+ let adoptedRecord: ClientTunnelPidfile | null = null;
595
+ let tunnelPort: number | null = null;
596
+ let ownerGeneration = 0;
315
597
  let state: TunnelState = IDLE;
316
598
  let linkId: string | null = null;
317
599
  let failure: ClientLinkSupervisorStatus | undefined;
318
600
  let tickFlight: Promise<void> | undefined;
601
+ /** The one keyed probe in flight, which tunnel it probes, and the abort that stop() fires. */
602
+ let probeFlight: { child: ClientLinkTunnelHandle | undefined; adopted: number | null; abort: AbortController } | undefined;
603
+ let probe: ClientTunnelProbeReason | null = null;
604
+ let nextProbeAt = 0;
605
+ let probeFailures = 0;
606
+ let invalidReads = 0;
607
+ /** A leftover tunnel pidfile has not been settled yet; nothing may spawn until it is. */
608
+ let reapPending = true;
609
+ const waiters = new Set<(connected: boolean) => void>();
610
+
611
+ /** The tunnel is being (re)established, so a request may wait for it instead of failing. */
612
+ const pending = (): boolean => !stopping && failure === undefined
613
+ && ((started && !initialized) || state.kind === "connecting" || state.kind === "reconnecting");
614
+ const invalidateOwnerProof = (): void => {
615
+ ownerGeneration += 1;
616
+ };
617
+ const ownerCandidate = (port: number): { pid: number; generation: number } | null => {
618
+ if (port !== tunnelPort) return null;
619
+ const pid = child?.pid ?? adopted;
620
+ if (pid === null || pid === undefined || (adopted !== null && !adoptedRecord)) return null;
621
+ return { pid, generation: ownerGeneration };
622
+ };
623
+ const listenerOwnedByTunnel = async (port: number): Promise<boolean> => {
624
+ const candidate = ownerCandidate(port);
625
+ if (!candidate) return false;
626
+ if (adopted !== null) {
627
+ const record = adoptedRecord;
628
+ const identity = record ? await inspectAdoptedIdentity(record, deps) : "unknown";
629
+ if (identity !== "match") {
630
+ if (identity === "mismatch" && record && adopted === candidate.pid && ownerGeneration === candidate.generation) {
631
+ // The old PID may now name another process. Never signal it; discard only our stale
632
+ // pidfile and let the next supervisor tick launch a fresh tunnel.
633
+ abortProbe();
634
+ removePidfileIfPid(clientTunnelPidfilePath(deps.configDir), candidate.pid, record.startTime);
635
+ adopted = null;
636
+ adoptedRecord = null;
637
+ tunnelPort = null;
638
+ linkId = null;
639
+ probe = null;
640
+ invalidateOwnerProof();
641
+ setState(IDLE);
642
+ }
643
+ return false;
644
+ }
645
+ }
646
+ const owned = await Promise.resolve().then(() => ownerLookup(port, candidate.pid)).catch(() => false);
647
+ return owned && !stopping && ownerGeneration === candidate.generation
648
+ && ownerCandidate(port)?.pid === candidate.pid;
649
+ };
650
+ const connected = async (): Promise<boolean> => started && !stopping && failure === undefined
651
+ && state.kind === "connected" && linkId !== null && tunnelPort !== null
652
+ && await listenerOwnedByTunnel(tunnelPort);
653
+
654
+ const settleWaiters = (): void => {
655
+ if (waiters.size === 0) return;
656
+ const ready = !stopping && failure === undefined && state.kind === "connected";
657
+ if (!ready && pending()) return;
658
+ for (const settle of [...waiters]) settle(ready);
659
+ };
660
+
661
+ const setState = (next: TunnelState): void => {
662
+ state = next;
663
+ settleWaiters();
664
+ };
665
+
666
+ const awaitingReady = (): boolean => state.kind === "connecting" || state.kind === "reconnecting"
667
+ || (state.kind === "failed" && state.inFlight === true);
319
668
 
320
669
  const readCurrent = (): { sidecar: ClientLinkState | null; invalid: boolean } => {
321
670
  try {
322
671
  return { sidecar: readSidecar(), invalid: false };
323
- } catch (error) {
672
+ } catch {
324
673
  deps.warn?.("client link sidecar could not be read");
325
674
  return { sidecar: null, invalid: true };
326
675
  }
327
676
  };
328
677
 
329
- const stopTunnel = async (): Promise<void> => {
678
+ const readConnectedLinkId = (): string | null | typeof CONNECTION_UNREADABLE => {
679
+ try {
680
+ return connectedLinkId();
681
+ } catch {
682
+ return CONNECTION_UNREADABLE;
683
+ }
684
+ };
685
+
686
+ /** Drops the probe in flight; its answer, if it still comes, is ignored. */
687
+ const abortProbe = (): void => {
688
+ const flight = probeFlight;
689
+ probeFlight = undefined;
690
+ flight?.abort.abort();
691
+ };
692
+
693
+ /** Stops our own child only; the tunnel state is left for the caller to decide. */
694
+ const killChild = async (): Promise<void> => {
330
695
  const current = child;
696
+ invalidateOwnerProof();
331
697
  child = undefined;
332
- state = reduceTunnel(state, { type: "stop" });
698
+ probe = null;
699
+ abortProbe();
333
700
  if (current) await current.stop();
334
701
  };
335
702
 
703
+ const stopTunnel = async (): Promise<void> => {
704
+ invalidateOwnerProof();
705
+ adopted = null;
706
+ adoptedRecord = null;
707
+ tunnelPort = null;
708
+ setState(reduceTunnel(state, { type: "stop" }));
709
+ await killChild();
710
+ };
711
+
336
712
  const endLink = async (): Promise<void> => {
337
713
  await stopTunnel();
338
714
  if (onLinkEndedCalled || stopping) return;
@@ -341,8 +717,14 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
341
717
  deps.onLinkEnded?.();
342
718
  };
343
719
 
720
+ const onExit = (stderrClass: StderrClass): void => {
721
+ setState(reduceTunnel(state, { type: "exit", now: now(), stderrClass }, random, policy));
722
+ };
723
+
344
724
  const spawn = (sidecar: ClientLinkState): void => {
345
- if (stopping || child || state.kind === "failed") return;
725
+ if (stopping || child || adopted !== null || reapPending) return;
726
+ const timestamp = now();
727
+ if (state.kind === "failed" && !dueForSpawn(state, timestamp)) return;
346
728
  try {
347
729
  child = spawnClientLinkTunnel({
348
730
  linkId: sidecar.linkId,
@@ -350,53 +732,175 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
350
732
  tunnelPort: sidecar.tunnelPort,
351
733
  peerListenerPort: sidecar.peerListenerPort,
352
734
  }, deps);
353
- linkId = sidecar.linkId;
354
- state = reduceTunnel(state, { type: "spawn", now: now() }, random);
355
- const current = child;
356
- void current.exited.then(async () => {
357
- if (child !== current) return;
358
- child = undefined;
359
- const stderr = (current as ClientLinkTunnelHandle & { stderr?: Promise<string> }).stderr
360
- ? await (current as ClientLinkTunnelHandle & { stderr?: Promise<string> }).stderr!.catch(() => "")
361
- : "";
362
- const next = reduceTunnel(state, { type: "exit", now: now(), stderrClass: classifySshStderr(stderr) }, random);
363
- state = next;
364
- }).catch(() => {
365
- if (child !== current) return;
366
- child = undefined;
367
- state = reduceTunnel(state, { type: "exit", now: now(), stderrClass: "network" }, random);
368
- });
369
- } catch (error) {
370
- state = { kind: "failed", since: now(), reason: "forward" };
735
+ } catch {
736
+ setState(failedTunnel("forward", timestamp, policy, state.kind === "failed" ? state.since : timestamp));
371
737
  deps.warn?.("client link tunnel could not be started");
738
+ return;
372
739
  }
740
+ invalidateOwnerProof();
741
+ linkId = sidecar.linkId;
742
+ tunnelPort = sidecar.tunnelPort;
743
+ probe = null;
744
+ probeFailures = 0;
745
+ nextProbeAt = timestamp;
746
+ setState(reduceTunnel(state, { type: "spawn", now: timestamp }, random, policy));
747
+ const current = child;
748
+ void current.exited.then(async () => {
749
+ if (child !== current) return;
750
+ invalidateOwnerProof();
751
+ child = undefined;
752
+ probe = null;
753
+ const stderr = (current as ClientLinkTunnelHandle & { stderr?: Promise<string> }).stderr
754
+ ? await (current as ClientLinkTunnelHandle & { stderr?: Promise<string> }).stderr!.catch(() => "")
755
+ : "";
756
+ onExit(classifySshStderr(stderr));
757
+ }).catch(() => {
758
+ if (child !== current) return;
759
+ invalidateOwnerProof();
760
+ child = undefined;
761
+ probe = null;
762
+ onExit("network");
763
+ });
764
+ };
765
+
766
+ /**
767
+ * Applies one keyed probe's answer. While the tunnel is being established an answer that proves
768
+ * the link (`ready`, or `home_not_ready`) promotes it to connected; otherwise the next probe backs
769
+ * off from one to five seconds, or up to 30 seconds once the link reads failed (a revoked key or a
770
+ * stopped Home costs one probe per 30 s). While connected the answer is display-only, and the next
771
+ * probe is 30 seconds out.
772
+ */
773
+ const applyProbe = (result: ProbeResult, wasConnected: boolean): void => {
774
+ const timestamp = now();
775
+ const reason = result === "ready" ? null : result;
776
+ if (wasConnected) {
777
+ if (state.kind === "connected") probe = reason;
778
+ nextProbeAt = timestamp + CONNECTED_PROBE_MS;
779
+ return;
780
+ }
781
+ if ((result === "ready" || result === "home_not_ready") && awaitingReady()) {
782
+ probe = reason;
783
+ probeFailures = 0;
784
+ nextProbeAt = timestamp + CONNECTED_PROBE_MS;
785
+ setState(reduceTunnel(state, { type: "ready", now: timestamp }, random, policy));
786
+ return;
787
+ }
788
+ probe = reason;
789
+ probeFailures += 1;
790
+ const cap = state.kind === "failed" ? CONNECTED_PROBE_MS : PROBE_BACKOFF_MAX_MS;
791
+ nextProbeAt = timestamp + Math.min(cap, 1_000 * 2 ** Math.min(probeFailures - 1, 16));
792
+ };
793
+
794
+ /**
795
+ * Starts one keyed probe only after a local socket-owner check. A slow Home never delays the
796
+ * check that notices a disconnect, and stop() aborts it instead of waiting for probe timeout.
797
+ */
798
+ const startProbe = (tunnelPort: number): void => {
799
+ const flight = { child, adopted, abort: new AbortController() };
800
+ probeFlight = flight;
801
+ const wasConnected = state.kind === "connected";
802
+ void (async () => {
803
+ if (!await listenerOwnedByTunnel(tunnelPort)) return;
804
+ if (probeFlight !== flight || stopping || flight.abort.signal.aborted
805
+ || child !== flight.child || adopted !== flight.adopted) return;
806
+ const key = linkKey();
807
+ if (!key) return;
808
+ const result = await probeTunnel(fetchImpl, tunnelPort, key, flight.abort.signal);
809
+ // An aborted probe, or one whose listener changed while it ran, proves nothing.
810
+ if (probeFlight !== flight || stopping || child !== flight.child || adopted !== flight.adopted
811
+ || !await listenerOwnedByTunnel(tunnelPort)) return;
812
+ applyProbe(result, wasConnected);
813
+ })().catch(() => undefined).finally(() => {
814
+ if (probeFlight === flight) probeFlight = undefined;
815
+ });
816
+ };
817
+
818
+ /**
819
+ * Settles a leftover tunnel pidfile once, before this supervisor's first spawn: a proven orphan
820
+ * is reaped and a live tunnel that may not be ours is watched without a keyed probe until its
821
+ * process identity and LISTEN ownership are verified. It needs a valid, matching read
822
+ * after the reap; without one it stays pending and the next check that has one runs it again.
823
+ */
824
+ const settleLeftover = async (): Promise<void> => {
825
+ const orphan = await reapOrphanTunnel(deps);
826
+ if (stopping) return;
827
+ const afterReap = readCurrent();
828
+ if (afterReap.invalid || !afterReap.sidecar || readConnectedLinkId() !== afterReap.sidecar.linkId) return;
829
+ reapPending = false;
830
+ if ((orphan.tunnel === "owned" || orphan.tunnel === "unresolved") && orphan.pid !== undefined && isAlive(orphan.pid)) {
831
+ // A leftover tunnel that may not be ours to stop: watch it, probe only after identity and
832
+ // socket ownership match, and start our own once it dies. It is never signalled.
833
+ adopted = orphan.pid;
834
+ const record = readPidfile(clientTunnelPidfilePath(deps.configDir));
835
+ const identity = record?.pid === adopted && record.linkId === afterReap.sidecar.linkId
836
+ ? tunnelIdentity(record, deps.platform ?? process.platform, deps, isAlive) : null;
837
+ adoptedRecord = identity?.kind === "ours" ? record : null;
838
+ invalidateOwnerProof();
839
+ linkId = afterReap.sidecar.linkId;
840
+ tunnelPort = afterReap.sidecar.tunnelPort;
841
+ nextProbeAt = now();
842
+ setState({ kind: "connecting", since: now() });
843
+ return;
844
+ }
845
+ spawn(afterReap.sidecar);
373
846
  };
374
847
 
375
848
  const tick = async (): Promise<void> => {
376
849
  if (stopping || !initialized) return;
377
850
  const current = readCurrent();
378
- if (current.invalid) {
379
- failure = { kind: "failed", reason: "sidecar_invalid" };
380
- await stopTunnel();
851
+ const connected = current.invalid ? null : readConnectedLinkId();
852
+ if (current.invalid || connected === CONNECTION_UNREADABLE) {
853
+ // A single unreadable read (a write in progress, a transient I/O error) must not end a
854
+ // healthy link; only INVALID_READ_TICKS in a row do.
855
+ invalidReads += 1;
856
+ if (invalidReads < INVALID_READ_TICKS) return;
857
+ if (current.invalid) {
858
+ failure = { kind: "failed", reason: "sidecar_invalid" };
859
+ await stopTunnel();
860
+ } else if (child || linkId || adopted !== null) {
861
+ await endLink();
862
+ }
863
+ settleWaiters();
381
864
  return;
382
865
  }
383
- const connected = connectedLinkId();
384
- if (!current.sidecar) {
385
- if (child || linkId) await endLink();
866
+ invalidReads = 0;
867
+ if (!current.sidecar || connected !== current.sidecar.linkId) {
868
+ if (child || linkId || adopted !== null) await endLink();
386
869
  return;
387
870
  }
388
- if (connected !== current.sidecar.linkId) {
389
- if (child || linkId) await endLink();
871
+ failure = undefined;
872
+ const sidecar = current.sidecar;
873
+ if (reapPending) {
874
+ // The start-up read was unreadable or did not match, so a leftover tunnel was never
875
+ // settled. Settle it now, before anything spawns, so an orphan cannot keep the port.
876
+ await settleLeftover();
877
+ settleWaiters();
390
878
  return;
391
879
  }
392
- failure = undefined;
393
- const timestamp = now();
394
- state = reduceTunnel(state, { type: "tick", now: timestamp }, random);
395
- if (child && state.kind === "connecting" && timestamp - state.since >= SPAWN_GRACE_MS) {
396
- state = reduceTunnel(state, { type: "ready", now: timestamp }, random);
880
+ if (adopted !== null && !isAlive(adopted)) {
881
+ // The leftover tunnel is gone: start our own on this tick.
882
+ invalidateOwnerProof();
883
+ adopted = null;
884
+ adoptedRecord = null;
885
+ tunnelPort = null;
886
+ probe = null;
887
+ setState(IDLE);
888
+ }
889
+ setState(reduceTunnel(state, { type: "tick", now: now() }, random, policy));
890
+ // An adopted tunnel is never killed. Failed identity or socket-owner proof keeps it
891
+ // unprobed and unable to promote, even when the process stays alive after a timeout.
892
+ if (state.kind === "failed" && !state.inFlight && adopted !== null) setState({ ...state, inFlight: true });
893
+ if (state.kind === "failed" && !state.inFlight && child) await killChild();
894
+ // A probe still running for a tunnel that has since exited or died answers nothing useful.
895
+ if (probeFlight && (probeFlight.child !== child || probeFlight.adopted !== adopted)) abortProbe();
896
+ // While requests are held the backoff does not apply: one probe per check (one a second), so a
897
+ // forward that just came up releases them at once. Held requests exist only while it is down.
898
+ if ((child || adopted !== null) && (state.kind === "connected" || awaitingReady()) && !probeFlight
899
+ && (now() >= nextProbeAt || waiters.size > 0)) {
900
+ startProbe(sidecar.tunnelPort);
397
901
  }
398
- if (state.kind === "failed" && child) await stopTunnel();
399
- else if (!child && (state.kind === "idle" || dueForSpawn(state, timestamp))) spawn(current.sidecar);
902
+ if (!child && adopted === null && (state.kind === "idle" || dueForSpawn(state, now()))) spawn(sidecar);
903
+ settleWaiters();
400
904
  };
401
905
 
402
906
  const runTick = (): void => {
@@ -407,27 +911,21 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
407
911
  const initialize = async (): Promise<void> => {
408
912
  if (initializing || initialized || stopping) return;
409
913
  initializing = true;
410
- const current = readCurrent();
411
- if (current.invalid) {
412
- failure = { kind: "failed", reason: "sidecar_invalid" };
413
- initialized = true;
414
- initializing = false;
415
- return;
416
- }
417
- if (current.sidecar && connectedLinkId() === current.sidecar.linkId) {
418
- const orphan = await reapOrphanTunnel(deps);
419
- if (orphan.tunnel === "owned" || orphan.tunnel === "unresolved") {
420
- linkId = current.sidecar.linkId;
421
- state = { kind: "connected", since: now() };
422
- initialized = true;
423
- initializing = false;
914
+ try {
915
+ const current = readCurrent();
916
+ if (current.invalid) {
917
+ failure = { kind: "failed", reason: "sidecar_invalid" };
424
918
  return;
425
919
  }
426
- const afterReap = readCurrent();
427
- if (!afterReap.invalid && afterReap.sidecar && connectedLinkId() === afterReap.sidecar.linkId) spawn(afterReap.sidecar);
920
+ // An unreadable or mismatched read leaves the leftover tunnel for the first check that
921
+ // reads a matching link (reapPending), so no tunnel is ever spawned over an unreaped one.
922
+ if (!current.sidecar || readConnectedLinkId() !== current.sidecar.linkId) return;
923
+ await settleLeftover();
924
+ } finally {
925
+ initialized = true;
926
+ initializing = false;
927
+ settleWaiters();
428
928
  }
429
- initialized = true;
430
- initializing = false;
431
929
  };
432
930
 
433
931
  return {
@@ -440,6 +938,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
440
938
  failure = { kind: "failed", reason: "sidecar_invalid" };
441
939
  initialized = true;
442
940
  initializing = false;
941
+ settleWaiters();
443
942
  });
444
943
  },
445
944
  async stop(): Promise<void> {
@@ -448,6 +947,8 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
448
947
  return;
449
948
  }
450
949
  stopping = true;
950
+ abortProbe();
951
+ settleWaiters();
451
952
  if (timer !== undefined) {
452
953
  clearSupervisorTimer(timer);
453
954
  timer = undefined;
@@ -458,8 +959,36 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
458
959
  },
459
960
  status(): ClientLinkSupervisorStatus {
460
961
  if (failure) return failure;
461
- if (!child && !linkId) return { kind: "stopped" };
462
- return { kind: "tunnel", linkId: linkId ?? "", state, pid: child?.pid ?? null };
962
+ if (!child && !linkId && adopted === null) return { kind: "stopped" };
963
+ return {
964
+ kind: "tunnel",
965
+ linkId: linkId ?? "",
966
+ state,
967
+ pid: child?.pid ?? adopted,
968
+ ...(probe ? { probe } : {}),
969
+ };
970
+ },
971
+ pending,
972
+ connected,
973
+ waitForConnected(timeoutMs: number, signal?: AbortSignal): Promise<boolean> {
974
+ if (state.kind === "connected") return connected();
975
+ if (!pending() || waiters.size >= CLIENT_LINK_MAX_HOLDS || signal?.aborted || !(timeoutMs > 0)) {
976
+ return Promise.resolve(false);
977
+ }
978
+ return new Promise<boolean>(resolve => {
979
+ let holdTimer: ReturnType<typeof setTimeout> | undefined;
980
+ const onAbort = (): void => settle(false);
981
+ const settle = (connected: boolean): void => {
982
+ if (!waiters.delete(settle)) return;
983
+ if (holdTimer !== undefined) clearTimeout(holdTimer);
984
+ signal?.removeEventListener("abort", onAbort);
985
+ resolve(connected);
986
+ };
987
+ waiters.add(settle);
988
+ holdTimer = setTimeout(() => settle(false), timeoutMs);
989
+ (holdTimer as { unref?: () => void }).unref?.();
990
+ signal?.addEventListener("abort", onAbort, { once: true });
991
+ });
463
992
  },
464
993
  };
465
994
  }