harnery 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/README.md +3 -3
  2. package/dist/commander.js +1 -0
  3. package/dist/commands/agents.d.ts.map +1 -1
  4. package/dist/commands/agents.js +41 -48
  5. package/dist/commands/browse.d.ts.map +1 -1
  6. package/dist/commands/browse.js +38 -11
  7. package/dist/commands/diagnostics.d.ts.map +1 -1
  8. package/dist/commands/diagnostics.js +10 -0
  9. package/dist/commands/doctor.d.ts +12 -0
  10. package/dist/commands/doctor.d.ts.map +1 -1
  11. package/dist/commands/doctor.js +22 -7
  12. package/dist/commands/files.d.ts +2 -2
  13. package/dist/commands/files.d.ts.map +1 -1
  14. package/dist/commands/files.js +7 -4
  15. package/dist/commands/governor.js +22 -10
  16. package/dist/commands/inbox.d.ts +1 -1
  17. package/dist/commands/inbox.d.ts.map +1 -1
  18. package/dist/commands/inbox.js +55 -2
  19. package/dist/commands/rm.d.ts.map +1 -1
  20. package/dist/commands/rm.js +28 -4
  21. package/dist/commands/servers.d.ts.map +1 -1
  22. package/dist/commands/servers.js +26 -17
  23. package/dist/commands/supervisor.d.ts.map +1 -1
  24. package/dist/commands/supervisor.js +7 -2
  25. package/dist/commands/toc.d.ts +14 -0
  26. package/dist/commands/toc.d.ts.map +1 -1
  27. package/dist/commands/toc.js +7 -4
  28. package/dist/commands/tunnel.d.ts +15 -0
  29. package/dist/commands/tunnel.d.ts.map +1 -1
  30. package/dist/commands/tunnel.js +453 -153
  31. package/dist/commands/turn-rollback.d.ts +5 -0
  32. package/dist/commands/turn-rollback.d.ts.map +1 -0
  33. package/dist/commands/turn-rollback.js +76 -0
  34. package/dist/commands/workflow.d.ts.map +1 -1
  35. package/dist/commands/workflow.js +68 -30
  36. package/dist/core/agents/coord-client.d.ts.map +1 -1
  37. package/dist/core/agents/coord-client.js +18 -0
  38. package/dist/core/agents/live-authority-v3.d.ts +1 -0
  39. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  40. package/dist/core/agents/live-authority-v3.js +11 -0
  41. package/dist/core/agents/live-lifecycle-v3.d.ts +4 -2
  42. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
  43. package/dist/core/agents/live-lifecycle-v3.js +28 -7
  44. package/dist/core/agents/mailbox.d.ts +9 -8
  45. package/dist/core/agents/mailbox.d.ts.map +1 -1
  46. package/dist/core/agents/mailbox.js +24 -17
  47. package/dist/core/agents/render/prompt-context.d.ts +2 -0
  48. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  49. package/dist/core/agents/render/prompt-context.js +8 -12
  50. package/dist/core/agents/render/session-context.d.ts +2 -8
  51. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  52. package/dist/core/agents/render/session-context.js +7 -30
  53. package/dist/core/agents/rules/stop-hook.js +3 -3
  54. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  55. package/dist/core/agents/state/heartbeat-writer.js +25 -14
  56. package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
  57. package/dist/core/agents/state/live-coordination-writer.js +5 -0
  58. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  59. package/dist/core/agents/state/pidmap.js +5 -0
  60. package/dist/core/agents/state/proc-start.d.ts +19 -1
  61. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  62. package/dist/core/agents/state/proc-start.js +46 -1
  63. package/dist/core/agents/state/process-handle.d.ts +26 -0
  64. package/dist/core/agents/state/process-handle.d.ts.map +1 -0
  65. package/dist/core/agents/state/process-handle.js +121 -0
  66. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  67. package/dist/core/agents/state/stale-sweep.js +180 -70
  68. package/dist/core/agents/state/unchanged-file.d.ts +44 -0
  69. package/dist/core/agents/state/unchanged-file.d.ts.map +1 -0
  70. package/dist/core/agents/state/unchanged-file.js +390 -0
  71. package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
  72. package/dist/core/artifacts/delivery-card.js +2 -1
  73. package/dist/core/context/index.d.ts +22 -10
  74. package/dist/core/context/index.d.ts.map +1 -1
  75. package/dist/core/context/index.js +381 -119
  76. package/dist/core/context/observation.d.ts +26 -0
  77. package/dist/core/context/observation.d.ts.map +1 -0
  78. package/dist/core/context/observation.js +11 -0
  79. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  80. package/dist/core/diagnostics/bundle.js +25 -14
  81. package/dist/core/diagnostics/path-identity.d.ts +3 -0
  82. package/dist/core/diagnostics/path-identity.d.ts.map +1 -0
  83. package/dist/core/diagnostics/path-identity.js +12 -0
  84. package/dist/core/diagnostics/sanitize.d.ts.map +1 -1
  85. package/dist/core/diagnostics/sanitize.js +4 -3
  86. package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
  87. package/dist/core/events/v3/authority-outbox.js +29 -0
  88. package/dist/core/events/v3/generation-custody.d.ts +5 -0
  89. package/dist/core/events/v3/generation-custody.d.ts.map +1 -0
  90. package/dist/core/events/v3/generation-custody.js +50 -0
  91. package/dist/core/events/v3/producers/coordination-recorder.d.ts +1 -0
  92. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
  93. package/dist/core/events/v3/producers/coordination-recorder.js +7 -0
  94. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  95. package/dist/core/events/v3/producers/recorder.js +77 -64
  96. package/dist/core/governor/planning.d.ts.map +1 -1
  97. package/dist/core/governor/planning.js +14 -1
  98. package/dist/core/governor/service-read.d.ts +10 -2
  99. package/dist/core/governor/service-read.d.ts.map +1 -1
  100. package/dist/core/governor/service-read.js +105 -22
  101. package/dist/core/governor/service.d.ts +20 -2
  102. package/dist/core/governor/service.d.ts.map +1 -1
  103. package/dist/core/governor/service.js +72 -47
  104. package/dist/core/hooks/cli.js +33 -5
  105. package/dist/core/inbox/contract.d.ts +2 -1
  106. package/dist/core/inbox/contract.d.ts.map +1 -1
  107. package/dist/core/inbox/index.d.ts +2 -0
  108. package/dist/core/inbox/index.d.ts.map +1 -1
  109. package/dist/core/inbox/index.js +2 -0
  110. package/dist/core/inbox/peer-messages.d.ts +37 -0
  111. package/dist/core/inbox/peer-messages.d.ts.map +1 -0
  112. package/dist/core/inbox/peer-messages.js +72 -0
  113. package/dist/core/inbox/service.d.ts +8 -1
  114. package/dist/core/inbox/service.d.ts.map +1 -1
  115. package/dist/core/inbox/service.js +67 -21
  116. package/dist/core/inbox/targets.d.ts +22 -0
  117. package/dist/core/inbox/targets.d.ts.map +1 -0
  118. package/dist/core/inbox/targets.js +108 -0
  119. package/dist/core/inbox/watcher.js +7 -3
  120. package/dist/core/journal/index.d.ts.map +1 -1
  121. package/dist/core/journal/index.js +17 -2
  122. package/dist/core/resources/contract.d.ts +3 -0
  123. package/dist/core/resources/contract.d.ts.map +1 -1
  124. package/dist/core/resources/service-status.d.ts +14 -1
  125. package/dist/core/resources/service-status.d.ts.map +1 -1
  126. package/dist/core/resources/service-status.js +23 -13
  127. package/dist/core/resources/service.d.ts +7 -13
  128. package/dist/core/resources/service.d.ts.map +1 -1
  129. package/dist/core/resources/service.js +34 -91
  130. package/dist/core/resources/status.d.ts +2 -5
  131. package/dist/core/resources/status.d.ts.map +1 -1
  132. package/dist/core/resources/status.js +36 -7
  133. package/dist/core/semantic/service.d.ts.map +1 -1
  134. package/dist/core/semantic/service.js +2 -9
  135. package/dist/core/servers/index.d.ts +34 -22
  136. package/dist/core/servers/index.d.ts.map +1 -1
  137. package/dist/core/servers/index.js +297 -144
  138. package/dist/core/servers/tunnels.d.ts +7 -7
  139. package/dist/core/servers/tunnels.d.ts.map +1 -1
  140. package/dist/core/servers/tunnels.js +47 -10
  141. package/dist/core/supervisor/contract.d.ts +12 -0
  142. package/dist/core/supervisor/contract.d.ts.map +1 -1
  143. package/dist/core/supervisor/explanations.d.ts.map +1 -1
  144. package/dist/core/supervisor/explanations.js +2 -1
  145. package/dist/core/supervisor/findings.d.ts.map +1 -1
  146. package/dist/core/supervisor/findings.js +54 -9
  147. package/dist/core/supervisor/service.d.ts +7 -1
  148. package/dist/core/supervisor/service.d.ts.map +1 -1
  149. package/dist/core/supervisor/service.js +59 -29
  150. package/dist/core/supervisor/services.d.ts.map +1 -1
  151. package/dist/core/supervisor/services.js +22 -1
  152. package/dist/core/supervisor/status.d.ts +17 -2
  153. package/dist/core/supervisor/status.d.ts.map +1 -1
  154. package/dist/core/supervisor/status.js +70 -12
  155. package/dist/core/supervisor/timeline.d.ts.map +1 -1
  156. package/dist/core/supervisor/timeline.js +2 -1
  157. package/dist/core/turn-rollback/content.d.ts +53 -0
  158. package/dist/core/turn-rollback/content.d.ts.map +1 -0
  159. package/dist/core/turn-rollback/content.js +396 -0
  160. package/dist/core/turn-rollback/index.d.ts +72 -0
  161. package/dist/core/turn-rollback/index.d.ts.map +1 -0
  162. package/dist/core/turn-rollback/index.js +443 -0
  163. package/dist/core/work/state.js +7 -7
  164. package/dist/core/workflow/engine.d.ts.map +1 -1
  165. package/dist/core/workflow/engine.js +105 -32
  166. package/dist/core/workflow/failure-evidence.d.ts +16 -0
  167. package/dist/core/workflow/failure-evidence.d.ts.map +1 -0
  168. package/dist/core/workflow/failure-evidence.js +114 -0
  169. package/dist/core/workflow/index.d.ts +5 -3
  170. package/dist/core/workflow/index.d.ts.map +1 -1
  171. package/dist/core/workflow/index.js +3 -2
  172. package/dist/core/workflow/live-session-v3.d.ts +6 -0
  173. package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
  174. package/dist/core/workflow/live-session-v3.js +126 -5
  175. package/dist/core/workflow/proof.d.ts +5 -5
  176. package/dist/core/workflow/proof.d.ts.map +1 -1
  177. package/dist/core/workflow/proof.js +115 -20
  178. package/dist/core/workflow/recovery-audit.d.ts +30 -0
  179. package/dist/core/workflow/recovery-audit.d.ts.map +1 -0
  180. package/dist/core/workflow/recovery-audit.js +116 -0
  181. package/dist/core/workflow/run-state.d.ts +20 -1
  182. package/dist/core/workflow/run-state.d.ts.map +1 -1
  183. package/dist/core/workflow/run-state.js +141 -32
  184. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  185. package/dist/core/workflow/spawn-claude.js +19 -0
  186. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  187. package/dist/core/workflow/spawn-codex.js +13 -0
  188. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  189. package/dist/core/workflow/spawn-cursor.js +19 -0
  190. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  191. package/dist/core/workflow/spawn-opencode.js +14 -0
  192. package/dist/core/workflow/types.d.ts +64 -17
  193. package/dist/core/workflow/types.d.ts.map +1 -1
  194. package/dist/core/workflow/workspaces/index.d.ts +0 -1
  195. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  196. package/dist/core/workflow/workspaces/index.js +0 -1
  197. package/dist/core/workflow/workspaces/leases.d.ts +3 -1
  198. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  199. package/dist/core/workflow/workspaces/leases.js +30 -16
  200. package/dist/core/workflow/workspaces/local-git.d.ts +6 -2
  201. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  202. package/dist/core/workflow/workspaces/local-git.js +328 -8
  203. package/dist/core/workflow/workspaces/ownership.d.ts +11 -0
  204. package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -0
  205. package/dist/core/workflow/workspaces/ownership.js +194 -0
  206. package/dist/core/workflow/workspaces/reclaim.d.ts +40 -0
  207. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  208. package/dist/core/workflow/workspaces/reclaim.js +14 -0
  209. package/dist/core/workflow/workspaces/recovery.d.ts +2 -0
  210. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  211. package/dist/core/workflow/workspaces/recovery.js +42 -10
  212. package/dist/core/workflow/workspaces/state.d.ts +1 -0
  213. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  214. package/dist/core/workflow/workspaces/state.js +21 -1
  215. package/dist/lib/browser/windows-chrome.d.ts +100 -12
  216. package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
  217. package/dist/lib/browser/windows-chrome.js +262 -73
  218. package/dist/lib/docs-links.d.ts.map +1 -1
  219. package/dist/lib/docs-links.js +45 -2
  220. package/dist/lib/guarded-remove.d.ts +7 -0
  221. package/dist/lib/guarded-remove.d.ts.map +1 -1
  222. package/dist/lib/guarded-remove.js +106 -12
  223. package/dist/lib/identities/assume.d.ts.map +1 -1
  224. package/dist/lib/identities/assume.js +4 -0
  225. package/dist/lib/tunnel/state.d.ts +13 -6
  226. package/dist/lib/tunnel/state.d.ts.map +1 -1
  227. package/dist/lib/tunnel/state.js +58 -12
  228. package/dist/lib/zip/index.d.ts +41 -0
  229. package/dist/lib/zip/index.d.ts.map +1 -0
  230. package/dist/lib/zip/index.js +398 -0
  231. package/package.json +12 -1
  232. package/src/commander.ts +10 -0
  233. package/src/commands/agents.ts +54 -63
  234. package/src/commands/browse.ts +50 -14
  235. package/src/commands/diagnostics.ts +10 -0
  236. package/src/commands/doctor.ts +26 -7
  237. package/src/commands/files.ts +13 -5
  238. package/src/commands/governor.ts +23 -10
  239. package/src/commands/inbox.ts +52 -2
  240. package/src/commands/rm.ts +43 -4
  241. package/src/commands/servers.ts +27 -26
  242. package/src/commands/supervisor.ts +7 -2
  243. package/src/commands/toc.ts +14 -6
  244. package/src/commands/tunnel.ts +533 -166
  245. package/src/commands/turn-rollback.ts +111 -0
  246. package/src/commands/workflow.ts +89 -30
  247. package/src/core/agents/coord-client.ts +20 -0
  248. package/src/core/agents/live-authority-v3.ts +17 -0
  249. package/src/core/agents/live-lifecycle-v3.ts +52 -14
  250. package/src/core/agents/mailbox.ts +26 -17
  251. package/src/core/agents/render/prompt-context.ts +10 -12
  252. package/src/core/agents/render/session-context.ts +9 -32
  253. package/src/core/agents/rules/stop-hook.ts +3 -3
  254. package/src/core/agents/state/heartbeat-writer.ts +29 -12
  255. package/src/core/agents/state/live-coordination-writer.ts +29 -0
  256. package/src/core/agents/state/pidmap.ts +13 -0
  257. package/src/core/agents/state/proc-start.ts +62 -2
  258. package/src/core/agents/state/process-handle.ts +165 -0
  259. package/src/core/agents/state/stale-sweep.ts +247 -82
  260. package/src/core/agents/state/unchanged-file.ts +456 -0
  261. package/src/core/artifacts/delivery-card.ts +7 -1
  262. package/src/core/context/index.ts +446 -136
  263. package/src/core/context/observation.ts +29 -0
  264. package/src/core/diagnostics/bundle.ts +23 -18
  265. package/src/core/diagnostics/path-identity.ts +15 -0
  266. package/src/core/diagnostics/sanitize.ts +4 -3
  267. package/src/core/events/v3/authority-outbox.ts +56 -0
  268. package/src/core/events/v3/generation-custody.ts +59 -0
  269. package/src/core/events/v3/producers/coordination-recorder.ts +15 -0
  270. package/src/core/events/v3/producers/recorder.ts +94 -68
  271. package/src/core/governor/planning.ts +20 -8
  272. package/src/core/governor/service-read.ts +128 -20
  273. package/src/core/governor/service.ts +119 -42
  274. package/src/core/hooks/cli.ts +33 -5
  275. package/src/core/inbox/contract.ts +2 -1
  276. package/src/core/inbox/index.ts +2 -0
  277. package/src/core/inbox/peer-messages.ts +94 -0
  278. package/src/core/inbox/service.ts +88 -22
  279. package/src/core/inbox/targets.ts +148 -0
  280. package/src/core/inbox/watcher.ts +8 -9
  281. package/src/core/journal/index.ts +19 -2
  282. package/src/core/resources/contract.ts +4 -0
  283. package/src/core/resources/service-status.ts +35 -12
  284. package/src/core/resources/service.ts +57 -95
  285. package/src/core/resources/status.ts +57 -8
  286. package/src/core/semantic/service.ts +2 -8
  287. package/src/core/servers/index.ts +376 -141
  288. package/src/core/servers/tunnels.ts +57 -13
  289. package/src/core/supervisor/contract.ts +13 -0
  290. package/src/core/supervisor/explanations.ts +4 -1
  291. package/src/core/supervisor/findings.ts +68 -10
  292. package/src/core/supervisor/service.ts +87 -26
  293. package/src/core/supervisor/services.ts +26 -8
  294. package/src/core/supervisor/status.ts +89 -12
  295. package/src/core/supervisor/timeline.ts +2 -1
  296. package/src/core/turn-rollback/content.ts +499 -0
  297. package/src/core/turn-rollback/index.ts +580 -0
  298. package/src/core/work/state.ts +7 -7
  299. package/src/core/workflow/engine.ts +131 -49
  300. package/src/core/workflow/failure-evidence.ts +141 -0
  301. package/src/core/workflow/index.ts +12 -2
  302. package/src/core/workflow/live-session-v3.ts +146 -5
  303. package/src/core/workflow/proof.ts +134 -26
  304. package/src/core/workflow/recovery-audit.ts +181 -0
  305. package/src/core/workflow/run-state.ts +165 -34
  306. package/src/core/workflow/spawn-claude.ts +18 -0
  307. package/src/core/workflow/spawn-codex.ts +13 -0
  308. package/src/core/workflow/spawn-cursor.ts +18 -0
  309. package/src/core/workflow/spawn-opencode.ts +14 -0
  310. package/src/core/workflow/types.ts +51 -17
  311. package/src/core/workflow/workspaces/index.ts +0 -1
  312. package/src/core/workflow/workspaces/leases.ts +48 -17
  313. package/src/core/workflow/workspaces/local-git.ts +388 -7
  314. package/src/core/workflow/workspaces/ownership.ts +232 -0
  315. package/src/core/workflow/workspaces/reclaim.ts +62 -0
  316. package/src/core/workflow/workspaces/recovery.ts +45 -10
  317. package/src/core/workflow/workspaces/state.ts +20 -0
  318. package/src/lib/browser/windows-chrome.ts +346 -86
  319. package/src/lib/docs-links.ts +49 -2
  320. package/src/lib/guarded-remove.ts +132 -14
  321. package/src/lib/identities/assume.ts +4 -0
  322. package/src/lib/tunnel/state.ts +70 -15
  323. package/src/lib/zip/index.ts +483 -0
@@ -1,15 +1,18 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { hostname } from "node:os";
3
5
  import { resolve } from "node:path";
4
6
  import { resolveCoordRoot } from "../core/agents/coord-client.js";
5
7
  import { resolveBinName } from "../core/config.js";
6
- import { tryRegisterServer, tunnelServerId, tunnelServerInput, unregisterServer, } from "../core/servers/index.js";
8
+ import { acquireNoClobberLease, createTunnelReadinessVerifier, pidAlive, processStartToken, readServer, serversDir, signalIfSameGeneration, tryRegisterServer, tunnelServerId, tunnelServerInput, unregisterServer, verifyProcessGeneration, withServerRegistrationCustody, } from "../core/servers/index.js";
9
+ import { writePrivateJsonAtomic } from "../core/storage/atomic-json.js";
7
10
  import { processLogDestination, runProcessLogWorker, runRotatingProcessSync, spawnRotatingProcess, } from "../core/storage/process-log.js";
8
11
  import { normalizeAllowEntry } from "../lib/tunnel/allowlist.js";
9
12
  import { detectPublicAddresses, planCurrentAddresses, } from "../lib/tunnel/current-address.js";
10
13
  import { ALLOW_PATHS_ENV, normalizeAllowPaths } from "../lib/tunnel/path-scope.js";
11
- import { connectProbe, findPidsByCommandLine, listeningPorts, portListening, } from "../lib/tunnel/processes.js";
12
- import { clearState, DEFAULT_INSTANCE, ensureCloudflared, gateLogFile, isProcessAlive, isTunnelStateLive, listStates, providerLogFile, readConfig, readState, writeConfig, writeState, } from "../lib/tunnel/state.js";
14
+ import { connectProbe, listeningPorts, portListening } from "../lib/tunnel/processes.js";
15
+ import { clearState, DEFAULT_INSTANCE, ensureCloudflared, gateLogFile, isProcessAlive, providerLogFile, readConfig, tunnelDir, tunnelReadiness, writeConfig, writeState, } from "../lib/tunnel/state.js";
13
16
  /**
14
17
  * `tunnel`: provider-backed tunnel in front of a local upstream.
15
18
  *
@@ -218,57 +221,13 @@ function tailscaleAvailable() {
218
221
  function sleep(ms) {
219
222
  return new Promise((r) => setTimeout(r, ms));
220
223
  }
221
- /**
222
- * Kill every process whose command line matches `pattern` (an extended regular
223
- * expression), skipping our own PID and any already-killed. Returns the count
224
- * killed. Used as a fallback so orphaned gate/cloudflared processes get cleaned
225
- * even when the state file was lost (which otherwise left them squatting on the
226
- * port).
227
- */
228
- function killByPattern(pattern, alreadyKilled) {
229
- let killed = 0;
230
- for (const pid of findPidsByCommandLine(pattern)) {
231
- if (!pid || pid === process.pid || alreadyKilled.has(pid))
232
- continue;
233
- try {
234
- process.kill(pid);
235
- alreadyKilled.add(pid);
236
- killed++;
237
- }
238
- catch {
239
- /* race: already gone */
240
- }
241
- }
242
- return killed;
243
- }
244
- /**
245
- * Command-line pattern for ONE instance's gate, identified by its port. Matches
246
- * both launch forms: `bun run .../gate.ts --name N --port P` and a host CLI
247
- * re-executing itself as `<cli> tunnel gate --name N --port P`. Port boundary is
248
- * guarded with `( |$)` so port 9001 doesn't match 90011.
249
- */
250
- function gatePattern(gatePort) {
251
- return `(gate\\.ts|tunnel gate) .*--port ${gatePort}( |$)`;
252
- }
253
- /**
254
- * Sweep stray gate + cloudflared processes for ONE instance, identified by its
255
- * gate port. Both signatures are port-scoped so tearing down one tunnel never
256
- * touches another:
257
- * - gate: see gatePattern (the port is on the gate's argv)
258
- * - cloudflared: `--url http://localhost:<port>` (order-independent, so it
259
- * matches regardless of the `--protocol http2` flag we also pass).
260
- */
261
- function sweepStrays(gatePort, alreadyKilled) {
262
- return (killByPattern(gatePattern(gatePort), alreadyKilled) +
263
- killByPattern(`--url http://localhost:${gatePort}( |$)`, alreadyKilled));
264
- }
265
224
  /**
266
225
  * Pick a gate port for a new instance. An explicit `--gate-port` is honored
267
226
  * (and rejected if it's already taken); otherwise scan upward from 9001 for the
268
227
  * first port that's neither held by a live instance nor currently listening.
269
228
  */
270
229
  async function allocateGatePort(preferred) {
271
- const used = new Set(listStates()
230
+ const used = new Set(listSelectedStates()
272
231
  .filter((s) => isProcessAlive(s.gate_pid))
273
232
  .map((s) => s.gate_port));
274
233
  // No discovery tool (null) is not "every port free": probe candidates by connecting.
@@ -383,14 +342,16 @@ function runTailscaleShare(mode, targetUrl, path, httpsPort, logPath) {
383
342
  }
384
343
  }
385
344
  function stopTailscaleShare(state) {
386
- if (state.provider !== "tailscale" || !state.tailscale_mode)
345
+ if (state.provider !== "tailscale")
387
346
  return true;
347
+ if (!originalTailscaleRoute(state))
348
+ return false;
388
349
  const cmd = tailscaleCommand(state.tailscale_mode);
389
350
  const logPath = tunnelLogDestinations(state.name, "tailscale").provider;
390
351
  const args = [
391
352
  cmd,
392
- `--https=${state.tailscale_https_port ?? 443}`,
393
- `--set-path=${state.tailscale_path ?? "/"}`,
353
+ `--https=${state.tailscale_https_port}`,
354
+ `--set-path=${state.tailscale_path}`,
394
355
  "off",
395
356
  ];
396
357
  const status = runRotatingProcessSync({
@@ -402,9 +363,9 @@ function stopTailscaleShare(state) {
402
363
  // The gate is torn down and its port is freed for reuse regardless, so a
403
364
  // surviving serve/funnel mapping could later re-expose whatever next binds
404
365
  // that port. Surface it loudly so the operator can clear it by hand.
405
- emit.text(`\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path ?? "/"}).\n` +
366
+ emit.text(`\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path}).\n` +
406
367
  ` It may still be exposing this machine. Clear it with:\n` +
407
- ` tailscale ${cmd} --https=${state.tailscale_https_port ?? 443} --set-path=${state.tailscale_path ?? "/"} off\n` +
368
+ ` tailscale ${cmd} --https=${state.tailscale_https_port} --set-path=${state.tailscale_path} off\n` +
408
369
  ` Log: ${logPath}\n\n`);
409
370
  return false;
410
371
  }
@@ -417,13 +378,39 @@ function contextVhost() {
417
378
  return resolved ?? null;
418
379
  }
419
380
  function providerIsAlive(state) {
381
+ if (!canonicalProviderFields(state))
382
+ return false;
420
383
  if (state.provider === "tailscale")
421
- return true;
422
- const pid = state.cloudflared_pid ?? state.provider_pid;
384
+ return state.provider_state === "ready";
385
+ const pid = state.cloudflared_pid;
423
386
  return typeof pid === "number" && isProcessAlive(pid);
424
387
  }
425
- function tunnelIsAlive(state) {
426
- return isTunnelStateLive(state);
388
+ function canonicalProviderFields(state) {
389
+ return (!Object.hasOwn(state, "provider_pid") &&
390
+ !Object.hasOwn(state, "provider_start_token") &&
391
+ (state.provider !== "tailscale" ||
392
+ (state.cloudflared_pid === undefined && state.cloudflared_start_token === undefined)));
393
+ }
394
+ function originalProcessWitnesses(state) {
395
+ if (!canonicalProviderFields(state) ||
396
+ !Number.isSafeInteger(state.gate_pid) ||
397
+ state.gate_pid < 1 ||
398
+ typeof state.gate_start_token !== "string" ||
399
+ !state.gate_start_token ||
400
+ typeof state.registration_id !== "string" ||
401
+ !state.registration_id)
402
+ return false;
403
+ if (state.provider === "tailscale")
404
+ return (originalTailscaleRoute(state) &&
405
+ (state.provider_state === "ready" || state.provider_state === "pending"));
406
+ return (state.provider === "cloudflare" &&
407
+ Number.isSafeInteger(state.cloudflared_pid) &&
408
+ state.cloudflared_pid > 0 &&
409
+ typeof state.cloudflared_start_token === "string" &&
410
+ !!state.cloudflared_start_token);
411
+ }
412
+ function readinessOf(state) {
413
+ return tunnelReadiness(state, createTunnelReadinessVerifier({ coordRoot: registryRoot() }));
427
414
  }
428
415
  /**
429
416
  * Spawn the detached gate worker.
@@ -434,8 +421,7 @@ function tunnelIsAlive(state) {
434
421
  * re-reads — that snapshot is precisely why `reload` has to exist.
435
422
  */
436
423
  function spawnGate(o, launch) {
437
- // `--name`/`--port` on argv mirror the env vars; they're what makes the gate
438
- // process distinguishable per-instance in a command-line search (see sweepStrays).
424
+ // Keep the instance name and port visible in process diagnostics.
439
425
  const gateProc = spawnRotatingProcess({
440
426
  path: o.gateLogPath,
441
427
  command: launch.command,
@@ -454,7 +440,79 @@ function spawnGate(o, launch) {
454
440
  gateProc.unref();
455
441
  return gateProc;
456
442
  }
443
+ function originalTailscaleRoute(state) {
444
+ return ((state.tailscale_mode === "serve" || state.tailscale_mode === "funnel") &&
445
+ typeof state.tailscale_path === "string" &&
446
+ state.tailscale_path.startsWith("/") &&
447
+ Number.isSafeInteger(state.tailscale_https_port) &&
448
+ state.tailscale_https_port > 0 &&
449
+ state.tailscale_https_port <= 65535);
450
+ }
451
+ function routeOf(state) {
452
+ return state?.provider === "tailscale" && originalTailscaleRoute(state)
453
+ ? { path: state.tailscale_path, httpsPort: state.tailscale_https_port }
454
+ : null;
455
+ }
456
+ function routeKey(route) {
457
+ return `${route.httpsPort}:${route.path}`;
458
+ }
459
+ export function withTunnelLifecycleCustody(name, requested, effect) {
460
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(name))
461
+ throw new Error("invalid tunnel lifecycle name");
462
+ const leases = [];
463
+ const release = () => {
464
+ for (const lease of leases.splice(0).reverse())
465
+ lease.release();
466
+ };
467
+ const acquire = (category, key) => {
468
+ const scope = `tunnel-${category}-${createHash("sha256").update(key).digest("hex")}`;
469
+ leases.push(acquireNoClobberLease({
470
+ path: resolve(tunnelDir(), ".custody", category, createHash("sha256").update(key).digest("hex")),
471
+ scope,
472
+ authoritySha256: createHash("sha256").update(scope).digest("hex"),
473
+ staleAfterMs: 1,
474
+ }));
475
+ };
476
+ try {
477
+ acquire("instance", name.toLowerCase());
478
+ const retained = listSelectedStates();
479
+ const existing = routeOf(retained.find((state) => state.name === name) ?? null);
480
+ const routes = [
481
+ ...new Set([existing, requested].filter((route) => !!route).map(routeKey)),
482
+ ].sort();
483
+ for (const key of routes)
484
+ acquire("route", key);
485
+ for (const other of retained) {
486
+ if (other.name.toLowerCase() === name.toLowerCase()) {
487
+ if (other.name !== name)
488
+ throw new Error(`tunnel instance ${name} aliases retained spelling ${other.name}; lifecycle custody is unavailable`);
489
+ continue;
490
+ }
491
+ const route = routeOf(other);
492
+ if (route && routes.includes(routeKey(route)))
493
+ throw new Error(`Tailscale route ${routeKey(route)} is retained by tunnel ${other.name}; provider custody is unavailable`);
494
+ }
495
+ const result = effect();
496
+ if (result !== null &&
497
+ (typeof result === "object" || typeof result === "function") &&
498
+ typeof result.then === "function")
499
+ return Promise.resolve(result).finally(release);
500
+ release();
501
+ return result;
502
+ }
503
+ catch (error) {
504
+ release();
505
+ throw error;
506
+ }
507
+ }
457
508
  async function up(opts) {
509
+ const name = resolveName(opts.name);
510
+ const requested = resolveProvider(opts.provider) === "tailscale"
511
+ ? { path: resolveTailscalePath(opts.path, name), httpsPort: resolveHttpsPort(opts.httpsPort) }
512
+ : null;
513
+ return withTunnelLifecycleCustody(name, requested, () => upWithCustody(opts));
514
+ }
515
+ async function upWithCustody(opts) {
458
516
  const launch = currentGateLaunch();
459
517
  if (!launch) {
460
518
  emit.error({ code: "tunnel_requires_bun", message: requiresBunMessage() });
@@ -470,8 +528,17 @@ async function up(opts) {
470
528
  const tailscalePath = provider === "tailscale" ? resolveTailscalePath(opts.path, name) : undefined;
471
529
  const tailscaleHttpsPort = provider === "tailscale" ? resolveHttpsPort(opts.httpsPort) : undefined;
472
530
  const allowPaths = resolveAllowPaths(opts.allowPath);
473
- const existing = readState(name);
474
- if (existing && tunnelIsAlive(existing)) {
531
+ const existing = readSelectedState(name);
532
+ const readiness = existing ? readinessOf(existing) : null;
533
+ if (readiness === "unknown") {
534
+ emit.error({
535
+ code: "tunnel_custody_unknown",
536
+ message: `Tunnel [${name}] readiness is unknown; retained its state and refused cleanup or replacement.`,
537
+ });
538
+ process.exit(1);
539
+ return;
540
+ }
541
+ if (existing && readiness === "ready") {
475
542
  if (existing.provider !== provider) {
476
543
  emit.error({
477
544
  code: "tunnel_instance_in_use",
@@ -493,15 +560,19 @@ async function up(opts) {
493
560
  emit.text(` Paths: ${formatPaths(existing.allow_paths)}\n`);
494
561
  return;
495
562
  }
496
- if (existing)
497
- downOne(name, new Set());
563
+ if (existing) {
564
+ downOneWithCustody(name, new Set());
565
+ if (readSelectedState(name)) {
566
+ emit.error({
567
+ code: "tunnel_custody_unknown",
568
+ message: `Tunnel [${name}] has a live or unverifiable process; retained its state and refused a replacement.`,
569
+ });
570
+ process.exit(1);
571
+ }
572
+ }
498
573
  // Allocate the gate port (after clearing dead state so its old port frees up
499
574
  // for reuse). Explicit --gate-port is validated; otherwise auto-scan.
500
575
  const gatePort = await allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
501
- // Self-heal: clear any orphaned gate/cloudflared on THIS instance's port from
502
- // a prior crashed or state-cleared run so the gate port is free before we bind.
503
- if (sweepStrays(gatePort, new Set()))
504
- await sleep(500);
505
576
  const cfg = readConfig();
506
577
  if (provider === "cloudflare" && cfg.allowed_ips.length === 0) {
507
578
  emit.error({
@@ -543,17 +614,12 @@ async function up(opts) {
543
614
  allowPaths,
544
615
  gateLogPath,
545
616
  }, launch);
617
+ const gateStartToken = processStartToken(gateProc.pid);
546
618
  await sleep(800);
547
619
  // A host CLI that re-executes itself takes longer to load than a bare script,
548
620
  // so wait for the gate to actually listen before the provider forwards to it.
549
621
  if (!isProcessAlive(gateProc.pid) || !(await waitForPortBound(gatePort, 15_000))) {
550
- try {
551
- process.kill(gateProc.pid);
552
- }
553
- catch {
554
- /* already dead */
555
- }
556
- sweepStrays(gatePort, new Set());
622
+ signalIfSameGeneration(gateProc.pid, gateStartToken, "SIGTERM");
557
623
  emit.error({
558
624
  code: "tunnel_gate_failed",
559
625
  message: `Gate failed to start. Check log: ${gateLogPath}`,
@@ -563,6 +629,8 @@ async function up(opts) {
563
629
  let url;
564
630
  let registered = true;
565
631
  let cloudflaredPid;
632
+ let cloudflaredStartToken;
633
+ let pendingReceipt = null;
566
634
  if (provider === "cloudflare") {
567
635
  // Force HTTP/2 transport. The default QUIC transport wedges at precheck on
568
636
  // constrained hosts (e.g. WSL, where UDP receive buffers can't grow and ICMP
@@ -577,21 +645,12 @@ async function up(opts) {
577
645
  });
578
646
  cfdProc.unref();
579
647
  cloudflaredPid = cfdProc.pid;
648
+ cloudflaredStartToken = processStartToken(cloudflaredPid) ?? undefined;
580
649
  const ready = await waitForReady(providerLogPath, 30_000);
581
650
  registered = ready.registered;
582
651
  if (!ready.url) {
583
- try {
584
- process.kill(gateProc.pid);
585
- }
586
- catch {
587
- /* already dead */
588
- }
589
- try {
590
- process.kill(cloudflaredPid);
591
- }
592
- catch {
593
- /* already dead */
594
- }
652
+ signalIfSameGeneration(gateProc.pid, gateStartToken, "SIGTERM");
653
+ signalIfSameGeneration(cloudflaredPid, cloudflaredStartToken, "SIGTERM");
595
654
  emit.error({
596
655
  code: "tunnel_url_timeout",
597
656
  message: `Failed to obtain tunnel URL within 30s. Check log: ${providerLogPath}`,
@@ -608,13 +667,38 @@ async function up(opts) {
608
667
  // `heal` would then be unable to see or clean up.
609
668
  url = tailscaleUrl(tailscalePath, tailscaleHttpsPort);
610
669
  const gateTarget = `http://127.0.0.1:${gatePort}`;
611
- runTailscaleShare(tailscaleMode, gateTarget, tailscalePath, tailscaleHttpsPort, providerLogPath);
670
+ const pending = {
671
+ name,
672
+ provider,
673
+ url,
674
+ gate_pid: gateProc.pid,
675
+ gate_start_token: gateStartToken ?? undefined,
676
+ started_at: new Date().toISOString(),
677
+ target,
678
+ vhost,
679
+ gate_port: gatePort,
680
+ allow_paths: allowPaths,
681
+ tailscale_mode: tailscaleMode,
682
+ tailscale_path: tailscalePath,
683
+ tailscale_https_port: tailscaleHttpsPort,
684
+ provider_state: "pending",
685
+ };
686
+ pendingReceipt = recordTunnelServer(pending);
687
+ if (!pendingReceipt) {
688
+ const evidence = recordRefusedTunnelLaunch(pending);
689
+ signalIfSameGeneration(pending.gate_pid, pending.gate_start_token, "SIGTERM");
690
+ throw new Error(`Tailscale admission refused before provider effect; retained witnesses at ${evidence}`);
691
+ }
692
+ beginTailscaleRoute(pending, pendingReceipt, () => runTailscaleShare(tailscaleMode, gateTarget, tailscalePath, tailscaleHttpsPort, providerLogPath));
612
693
  }
613
694
  const state = {
614
695
  name,
615
696
  provider,
616
697
  url,
698
+ ...(provider === "tailscale" ? { provider_state: "ready" } : {}),
617
699
  gate_pid: gateProc.pid,
700
+ gate_start_token: gateStartToken ?? undefined,
701
+ cloudflared_start_token: cloudflaredStartToken,
618
702
  cloudflared_pid: cloudflaredPid,
619
703
  started_at: new Date().toISOString(),
620
704
  target,
@@ -625,8 +709,25 @@ async function up(opts) {
625
709
  tailscale_path: tailscalePath,
626
710
  tailscale_https_port: tailscaleHttpsPort,
627
711
  };
628
- writeState(state);
629
- recordTunnelServer(state);
712
+ const receipt = provider === "tailscale" ? pendingReceipt : recordTunnelServer(state);
713
+ if (receipt)
714
+ state.registration_id = receipt.registration_id;
715
+ try {
716
+ if (!receipt)
717
+ throw new Error("registration refused");
718
+ publishTunnelState(state, receipt);
719
+ }
720
+ catch {
721
+ const evidence = recordRefusedTunnelLaunch(state);
722
+ signalIfSameGeneration(state.gate_pid, state.gate_start_token, "SIGTERM");
723
+ if (state.cloudflared_pid)
724
+ signalIfSameGeneration(state.cloudflared_pid, state.cloudflared_start_token, "SIGTERM");
725
+ emit.error({
726
+ code: "tunnel_registration_refused",
727
+ message: `Tunnel [${name}] registration custody was refused; saved process witnesses at ${evidence}.`,
728
+ });
729
+ process.exit(1);
730
+ }
630
731
  const bin = resolveBinName();
631
732
  const stopHint = name === DEFAULT_INSTANCE ? `${bin} tunnel down` : `${bin} tunnel down --name ${name}`;
632
733
  emit.text(`\n Instance: ${name}\n`);
@@ -654,72 +755,211 @@ async function up(opts) {
654
755
  function registryRoot() {
655
756
  return resolveCoordRoot() ?? process.cwd();
656
757
  }
758
+ function unknownTunnelFrame(name) {
759
+ return Object.assign(new Error(`Tunnel [${name}] state is unreadable or its custody is unknown; retained the frame.`), {
760
+ code: "tunnel_custody_unknown",
761
+ });
762
+ }
763
+ function readSelectedState(name) {
764
+ const path = resolve(tunnelDir(), name === DEFAULT_INSTANCE ? "state.json" : `state-${name}.json`);
765
+ let bytes;
766
+ try {
767
+ bytes = readFileSync(path, "utf8");
768
+ }
769
+ catch (error) {
770
+ if (error.code === "ENOENT")
771
+ return null;
772
+ throw unknownTunnelFrame(name);
773
+ }
774
+ try {
775
+ const raw = JSON.parse(bytes);
776
+ if (!raw ||
777
+ typeof raw !== "object" ||
778
+ Array.isArray(raw) ||
779
+ raw.name !== name ||
780
+ (raw.provider !== "cloudflare" && raw.provider !== "tailscale") ||
781
+ (raw.provider === "tailscale" && !originalTailscaleRoute(raw)) ||
782
+ typeof raw.url !== "string" ||
783
+ !raw.url ||
784
+ typeof raw.target !== "string" ||
785
+ !raw.target ||
786
+ typeof raw.vhost !== "string" ||
787
+ !Number.isSafeInteger(raw.gate_pid) ||
788
+ raw.gate_pid < 1 ||
789
+ !Number.isSafeInteger(raw.gate_port) ||
790
+ raw.gate_port < 1 ||
791
+ raw.gate_port > 65535 ||
792
+ !Array.isArray(raw.allow_paths) ||
793
+ raw.allow_paths.some((path) => typeof path !== "string"))
794
+ throw unknownTunnelFrame(name);
795
+ return raw;
796
+ }
797
+ catch {
798
+ throw unknownTunnelFrame(name);
799
+ }
800
+ }
801
+ function listSelectedStates() {
802
+ let files;
803
+ try {
804
+ files = readdirSync(tunnelDir());
805
+ }
806
+ catch (error) {
807
+ if (error.code === "ENOENT")
808
+ return [];
809
+ throw unknownTunnelFrame("inventory");
810
+ }
811
+ const states = [];
812
+ for (const file of files) {
813
+ const name = file === "state.json" ? DEFAULT_INSTANCE : /^state-(.+)\.json$/.exec(file)?.[1];
814
+ if (!name)
815
+ continue;
816
+ const state = readSelectedState(name);
817
+ if (state)
818
+ states.push(state);
819
+ }
820
+ return states.sort((a, b) => (typeof b.started_at === "string" ? b.started_at : "").localeCompare(typeof a.started_at === "string" ? a.started_at : ""));
821
+ }
822
+ function matchesTunnelReceipt(state, receipt) {
823
+ return (originalProcessWitnesses(state) &&
824
+ tunnelServerId(state.name) === receipt.id &&
825
+ receipt.host === hostname() &&
826
+ receipt.kind === "service" &&
827
+ receipt.type === "tunnel" &&
828
+ receipt.port === state.gate_port &&
829
+ receipt.url === state.url &&
830
+ receipt.registration_id === state.registration_id &&
831
+ receipt.pid === state.gate_pid &&
832
+ receipt.start_token === state.gate_start_token);
833
+ }
834
+ /** Retain a refused launch without overwriting another launch's canonical state. */
835
+ export function recordRefusedTunnelLaunch(state) {
836
+ const path = resolve(tunnelDir(), `failed-${state.name}-${randomUUID()}.json`);
837
+ writePrivateJsonAtomic(path, state);
838
+ return path;
839
+ }
840
+ /** A canonical state frame may be published only by its current registration. */
841
+ export function publishTunnelState(state, receipt) {
842
+ if (!matchesTunnelReceipt(state, receipt))
843
+ throw new Error("tunnel state registration custody does not match");
844
+ withServerRegistrationCustody(receipt, { coordRoot: registryRoot() }, () => writeState(state));
845
+ }
846
+ /** Retain pending route intent before asking the provider to change external state. */
847
+ export function beginTailscaleRoute(state, receipt, effect) {
848
+ if (state.provider !== "tailscale" || state.provider_state !== "pending")
849
+ throw new Error("Tailscale route intent is not pending");
850
+ publishTunnelState(state, receipt);
851
+ return withServerRegistrationCustody(receipt, { coordRoot: registryRoot() }, effect);
852
+ }
657
853
  function recordTunnelServer(state) {
854
+ if (!state.gate_start_token)
855
+ return null;
658
856
  const coordRoot = registryRoot();
659
- tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
857
+ const record = tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
858
+ if (record)
859
+ state.registration_id = record.registration_id;
860
+ return record;
660
861
  }
661
- /** Tear down a single instance by name. Returns the number of processes killed. */
662
- function portHeldByOtherLiveInstance(port, name) {
663
- return listStates().some((s) => s.name !== name && s.gate_port === port && isProcessAlive(s.gate_pid));
862
+ function tunnelRegistration(state) {
863
+ if (!originalProcessWitnesses(state))
864
+ return { known: false, record: null };
865
+ const coordRoot = registryRoot();
866
+ const id = tunnelServerId(state.name);
867
+ let record;
868
+ try {
869
+ record = readServer(id, { coordRoot });
870
+ }
871
+ catch {
872
+ return { known: false, record: null };
873
+ }
874
+ const present = existsSync(resolve(serversDir(coordRoot), `${id}.json`));
875
+ if (!present)
876
+ return { known: false, record: null };
877
+ return { known: !!record && matchesTunnelReceipt(state, record), record };
664
878
  }
879
+ /** Unknown saved identities retain state and never authorize a signal. */
665
880
  export function downOne(name, killed) {
881
+ try {
882
+ return withTunnelLifecycleCustody(name, null, () => downOneWithCustody(name, killed));
883
+ }
884
+ catch {
885
+ return 0;
886
+ }
887
+ }
888
+ function downOneWithCustody(name, killed) {
666
889
  const before = killed.size;
667
- const state = readState(name);
668
- if (state) {
669
- if (state.provider === "tailscale")
670
- stopTailscaleShare(state);
671
- for (const pid of [state.gate_pid, state.cloudflared_pid, state.provider_pid]) {
672
- if (typeof pid !== "number")
673
- continue;
674
- if (isProcessAlive(pid)) {
675
- try {
676
- process.kill(pid);
677
- killed.add(pid);
678
- }
679
- catch {
680
- /* race: already gone */
681
- }
890
+ const state = readSelectedState(name);
891
+ if (!state)
892
+ return 0;
893
+ const registration = tunnelRegistration(state);
894
+ if (!registration.known)
895
+ return 0;
896
+ const targets = [
897
+ { pid: state.gate_pid, token: state.gate_start_token },
898
+ { pid: state.cloudflared_pid, token: state.cloudflared_start_token },
899
+ ].filter((target) => typeof target.pid === "number");
900
+ for (const target of targets) {
901
+ if (pidAlive(target.pid) && verifyProcessGeneration(target.pid, target.token) !== "same")
902
+ return 0;
903
+ }
904
+ if (!registration.record)
905
+ return 0;
906
+ let cleared = false;
907
+ try {
908
+ withServerRegistrationCustody(registration.record, { coordRoot: registryRoot() }, () => {
909
+ for (const target of targets) {
910
+ if (!pidAlive(target.pid) || killed.has(target.pid))
911
+ continue;
912
+ const result = signalIfSameGeneration(target.pid, target.token, "SIGTERM");
913
+ if (!result.sent)
914
+ throw new Error("tunnel process custody is unknown");
915
+ killed.add(target.pid);
682
916
  }
683
- }
917
+ // Observation and deletion share custody; an old stopper cannot clear a winner.
918
+ if (targets.some((target) => pidAlive(target.pid)))
919
+ return;
920
+ if (JSON.stringify(readSelectedState(name)) !== JSON.stringify(state))
921
+ return;
922
+ if (state.provider === "tailscale" && !stopTailscaleShare(state))
923
+ return;
924
+ clearState(name);
925
+ cleared = true;
926
+ });
927
+ }
928
+ catch {
929
+ return killed.size - before;
684
930
  }
685
- // Fallback: sweep orphans on this instance's gate port, even when state was
686
- // lost; they'd otherwise squat on the port and break the next `up`. Skip it
687
- // when another live instance now owns that port: a stale record's port is
688
- // reused after a reboot, and sweeping it would kill the live tunnel there.
689
- const port = state?.gate_port ?? DEFAULT_GATE_PORT;
690
- if (!portHeldByOtherLiveInstance(port, name))
691
- sweepStrays(port, killed);
692
- clearState(name);
693
- unregisterServer(tunnelServerId(name), { coordRoot: registryRoot() });
931
+ if (cleared)
932
+ unregisterServer(registration.record, { coordRoot: registryRoot() });
694
933
  return killed.size - before;
695
934
  }
696
935
  function down(opts) {
697
936
  const killed = new Set();
698
937
  if (opts.all) {
699
- const states = listStates();
938
+ const states = listSelectedStates();
700
939
  if (states.length === 0) {
701
940
  emit.text("No tunnels up. Nothing to stop.\n");
702
941
  return;
703
942
  }
704
943
  for (const s of states)
705
944
  downOne(s.name, killed);
706
- emit.text(`Stopped ${states.length} tunnel(s) [${states.map((s) => s.name).join(", ")}], ${killed.size} process(es).\n`);
945
+ const retained = states.filter((state) => readSelectedState(state.name)).length;
946
+ emit.text(`Requested stop for ${states.length} tunnel(s); ${killed.size} process signal(s), ${retained} state record(s) retained pending verified exit.\n`);
707
947
  return;
708
948
  }
709
949
  const name = resolveName(opts.name);
710
950
  // Bare `down` targets the default instance. If it's not up but named ones
711
951
  // are, don't silently no-op; point the operator at them.
712
- if (name === DEFAULT_INSTANCE && !readState(DEFAULT_INSTANCE)) {
713
- const others = listStates();
952
+ if (name === DEFAULT_INSTANCE && !readSelectedState(DEFAULT_INSTANCE)) {
953
+ const others = listSelectedStates();
714
954
  if (others.length > 0) {
715
955
  emit.text(`No default tunnel running. Other tunnels up: ${others.map((s) => s.name).join(", ")}.\nUse \`${resolveBinName()} tunnel down --name <name>\` or \`${resolveBinName()} tunnel down --all\`.\n`);
716
956
  return;
717
957
  }
718
958
  }
719
959
  downOne(name, killed);
720
- emit.text(killed.size === 0
721
- ? `No tunnel processes found for [${name}]. Nothing to stop.\n`
722
- : `Stopped ${killed.size} process(es). Tunnel [${name}] down.\n`);
960
+ emit.text(readSelectedState(name)
961
+ ? `Tunnel [${name}] state retained pending verified process exit or custody; ${killed.size} signal(s) sent.\n`
962
+ : `Tunnel [${name}] has no remaining recorded processes.\n`);
723
963
  }
724
964
  /** Poll until `port` has no LISTEN socket (or the deadline passes). */
725
965
  async function waitForPortFree(port, timeoutMs) {
@@ -753,9 +993,25 @@ async function waitForPortBound(port, timeoutMs) {
753
993
  * edge reconnects without noticing.
754
994
  */
755
995
  export async function reloadOne(state) {
996
+ try {
997
+ return await withTunnelLifecycleCustody(state.name, routeOf(state), () => reloadOneWithCustody(state));
998
+ }
999
+ catch {
1000
+ return {
1001
+ ok: false,
1002
+ message: `[${state.name}] lifecycle or provider route custody is unavailable; retained the tunnel.`,
1003
+ };
1004
+ }
1005
+ }
1006
+ async function reloadOneWithCustody(state) {
756
1007
  const bin = resolveBinName();
757
1008
  const name = state.name;
758
1009
  const gatePort = state.gate_port;
1010
+ if (!canonicalProviderFields(state))
1011
+ return {
1012
+ ok: false,
1013
+ message: `[${name}] obsolete or ambiguous provider authority is unknown; retained the tunnel.`,
1014
+ };
759
1015
  if (!providerIsAlive(state)) {
760
1016
  return {
761
1017
  ok: false,
@@ -778,27 +1034,51 @@ export async function reloadOne(state) {
778
1034
  `\`${bin} tunnel up --name ${name} --allow-path <prefix>\`.`,
779
1035
  };
780
1036
  }
781
- // Kill ONLY the gate. sweepStrays() is deliberately avoided here: it also
782
- // matches `--url http://localhost:<port>`, which is the provider process we
783
- // are going out of our way to keep alive.
784
- const killed = new Set();
785
- if (isProcessAlive(state.gate_pid)) {
786
- try {
787
- process.kill(state.gate_pid);
788
- killed.add(state.gate_pid);
789
- }
790
- catch {
791
- /* race: already gone */
1037
+ const registration = tunnelRegistration(state);
1038
+ if (!registration.known || !registration.record)
1039
+ return { ok: false, message: `[${name}] registration custody changed; retained the tunnel.` };
1040
+ const stopGate = async () => {
1041
+ if (state.provider === "cloudflare" &&
1042
+ state.cloudflared_pid &&
1043
+ verifyProcessGeneration(state.cloudflared_pid, state.cloudflared_start_token) !== "same")
1044
+ return {
1045
+ ok: false,
1046
+ message: `[${name}] provider generation is unknown or changed; retained the tunnel.`,
1047
+ };
1048
+ if (pidAlive(state.gate_pid)) {
1049
+ if (!registration.record)
1050
+ return {
1051
+ ok: false,
1052
+ message: `[${name}] registration custody is unavailable; retained the tunnel.`,
1053
+ };
1054
+ const result = signalIfSameGeneration(state.gate_pid, state.gate_start_token, "SIGTERM");
1055
+ if (!result.sent)
1056
+ return {
1057
+ ok: false,
1058
+ message: `[${name}] gate generation is unknown or changed; retained the tunnel.`,
1059
+ };
792
1060
  }
1061
+ if (!(await waitForPortFree(gatePort, 5_000)))
1062
+ return {
1063
+ ok: false,
1064
+ message: `[${name}] port ${gatePort} never released; retained the tunnel state.`,
1065
+ };
1066
+ return { ok: true, message: "gate released" };
1067
+ };
1068
+ let released;
1069
+ try {
1070
+ released = await withServerRegistrationCustody(registration.record, { coordRoot: registryRoot() }, stopGate);
793
1071
  }
794
- killByPattern(gatePattern(gatePort), killed);
795
- if (!(await waitForPortFree(gatePort, 5_000))) {
1072
+ catch {
796
1073
  return {
797
1074
  ok: false,
798
- message: `[${name}] port ${gatePort} never released, so the old gate looks wedged. ` +
799
- `Tunnel is down; check \`${bin} tunnel status\`.`,
1075
+ message: `[${name}] registration custody is unavailable; retained the tunnel.`,
800
1076
  };
801
1077
  }
1078
+ if (!released.ok)
1079
+ return released;
1080
+ if (registration.record)
1081
+ unregisterServer(registration.record, { coordRoot: registryRoot() });
802
1082
  const cfg = readConfig();
803
1083
  const gateProc = spawnGate({
804
1084
  name,
@@ -810,6 +1090,7 @@ export async function reloadOne(state) {
810
1090
  allowPaths: state.allow_paths,
811
1091
  gateLogPath: tunnelLogDestinations(name, state.provider).gate,
812
1092
  }, launch);
1093
+ const gateStartToken = processStartToken(gateProc.pid);
813
1094
  await sleep(800);
814
1095
  const pid = gateProc.pid;
815
1096
  if (typeof pid !== "number" ||
@@ -821,15 +1102,33 @@ export async function reloadOne(state) {
821
1102
  `Tunnel is down; check \`${bin} tunnel logs --name ${name}\`.`,
822
1103
  };
823
1104
  }
824
- writeState({ ...state, gate_pid: pid });
825
- recordTunnelServer({ ...state, gate_pid: pid });
1105
+ const next = {
1106
+ ...state,
1107
+ gate_pid: pid,
1108
+ gate_start_token: gateStartToken ?? undefined,
1109
+ registration_id: undefined,
1110
+ };
1111
+ const nextReceipt = recordTunnelServer(next);
1112
+ try {
1113
+ if (!nextReceipt)
1114
+ throw new Error("registration refused");
1115
+ publishTunnelState(next, nextReceipt);
1116
+ }
1117
+ catch {
1118
+ const evidence = recordRefusedTunnelLaunch(next);
1119
+ signalIfSameGeneration(pid, gateStartToken, "SIGTERM");
1120
+ return {
1121
+ ok: false,
1122
+ message: `[${name}] new gate registration custody was refused; retained witnesses at ${evidence}.`,
1123
+ };
1124
+ }
826
1125
  const allowNote = state.provider === "cloudflare" ? ` Allowlist: ${cfg.allowed_ips.length} IP(s).` : "";
827
1126
  return { ok: true, message: `[${name}] gate reloaded, URL unchanged: ${state.url}.${allowNote}` };
828
1127
  }
829
1128
  async function reload(opts) {
830
1129
  const bin = resolveBinName();
831
1130
  if (opts.all) {
832
- const all = listStates();
1131
+ const all = listSelectedStates();
833
1132
  // Only live tunnels are reloadable, and a box commonly accumulates stale
834
1133
  // state files from long-dead runs. Reporting those as failures would make
835
1134
  // the common post-`allow add` path exit non-zero for no real reason.
@@ -855,7 +1154,7 @@ async function reload(opts) {
855
1154
  return;
856
1155
  }
857
1156
  const name = resolveName(opts.name);
858
- const state = readState(name);
1157
+ const state = readSelectedState(name);
859
1158
  if (!state) {
860
1159
  emit.error({
861
1160
  code: "tunnel_not_found",
@@ -870,7 +1169,8 @@ async function reload(opts) {
870
1169
  process.exit(1);
871
1170
  }
872
1171
  function instanceState(state) {
873
- return tunnelIsAlive(state) ? "up" : "stale";
1172
+ const readiness = readinessOf(state);
1173
+ return readiness === "ready" ? "up" : readiness === "dead" ? "stale" : "unknown";
874
1174
  }
875
1175
  function fmtUptime(startedAt) {
876
1176
  const secs = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
@@ -886,9 +1186,9 @@ function fmtUptime(startedAt) {
886
1186
  function statusDetail(state) {
887
1187
  const gateAlive = isProcessAlive(state.gate_pid);
888
1188
  const providerAlive = providerIsAlive(state);
889
- const providerPid = state.cloudflared_pid ?? state.provider_pid;
1189
+ const providerPid = state.cloudflared_pid;
890
1190
  const cfg = readConfig();
891
- emit.text(`${gateAlive && providerAlive ? "up" : "stale"} [${state.name}]\n`);
1191
+ emit.text(`${instanceState(state)} [${state.name}]\n`);
892
1192
  emit.text(` Provider: ${state.provider}${state.tailscale_mode ? ` (${state.tailscale_mode})` : ""}\n`);
893
1193
  emit.text(` URL: ${state.url}\n`);
894
1194
  emit.text(` Forwarding: ${state.target} (Host: ${state.vhost})\n`);
@@ -909,7 +1209,7 @@ function statusDetail(state) {
909
1209
  function status(opts) {
910
1210
  // Named → detailed single block.
911
1211
  if (opts.name) {
912
- const state = readState(resolveName(opts.name));
1212
+ const state = readSelectedState(resolveName(opts.name));
913
1213
  if (!state) {
914
1214
  emit.text(`down [${resolveName(opts.name)}]\n`);
915
1215
  return;
@@ -918,7 +1218,7 @@ function status(opts) {
918
1218
  return;
919
1219
  }
920
1220
  // No name → table of every instance.
921
- const states = listStates();
1221
+ const states = listSelectedStates();
922
1222
  if (states.length === 0) {
923
1223
  emit.text("down\n");
924
1224
  return;
@@ -955,7 +1255,7 @@ function status(opts) {
955
1255
  }
956
1256
  function logs(opts) {
957
1257
  const name = resolveName(opts.name);
958
- const state = readState(name);
1258
+ const state = readSelectedState(name);
959
1259
  const provider = state?.provider ?? "cloudflare";
960
1260
  const destinations = tunnelLogDestinations(name, provider);
961
1261
  const path = opts.provider ? destinations.provider : destinations.gate;
@@ -989,7 +1289,7 @@ const sameEntryIn = (list, entry) => list.some((e) => canonicalEntry(e) === cano
989
1289
  * restarts were owed.
990
1290
  */
991
1291
  function allowChangedHint() {
992
- const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
1292
+ const live = listSelectedStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
993
1293
  if (live.length === 0)
994
1294
  return;
995
1295
  emit.text(`Each gate reads the allowlist once at start, so this applies on reload ` +
@@ -1096,7 +1396,7 @@ export async function refreshCurrentAddress(options) {
1096
1396
  }
1097
1397
  if (!plan.changed || !options.reload)
1098
1398
  return result;
1099
- const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
1399
+ const live = listSelectedStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
1100
1400
  for (const s of live) {
1101
1401
  const r = await reloadOne(s);
1102
1402
  emit.text(`${r.ok ? "ok " : "FAIL"} ${r.message}\n`);