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,5 +1,7 @@
1
1
  import { type ChildProcess, 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 type { Command } from "commander";
5
7
  import type { EmitContext, HarneryProgramContext } from "../commander.ts";
@@ -7,11 +9,22 @@ import type { EmitContext, HarneryProgramContext } from "../commander.ts";
7
9
  import { resolveCoordRoot } from "../core/agents/coord-client.ts";
8
10
  import { resolveBinName } from "../core/config.ts";
9
11
  import {
12
+ acquireNoClobberLease,
13
+ createTunnelReadinessVerifier,
14
+ pidAlive,
15
+ processStartToken,
16
+ readServer,
17
+ type ServerRecord,
18
+ serversDir,
19
+ signalIfSameGeneration,
10
20
  tryRegisterServer,
11
21
  tunnelServerId,
12
22
  tunnelServerInput,
13
23
  unregisterServer,
24
+ verifyProcessGeneration,
25
+ withServerRegistrationCustody,
14
26
  } from "../core/servers/index.ts";
27
+ import { writePrivateJsonAtomic } from "../core/storage/atomic-json.ts";
15
28
  import {
16
29
  processLogDestination,
17
30
  runProcessLogWorker,
@@ -25,26 +38,20 @@ import {
25
38
  planCurrentAddresses,
26
39
  } from "../lib/tunnel/current-address.ts";
27
40
  import { ALLOW_PATHS_ENV, normalizeAllowPaths } from "../lib/tunnel/path-scope.ts";
28
- import {
29
- connectProbe,
30
- findPidsByCommandLine,
31
- listeningPorts,
32
- portListening,
33
- } from "../lib/tunnel/processes.ts";
41
+ import { connectProbe, listeningPorts, portListening } from "../lib/tunnel/processes.ts";
34
42
  import {
35
43
  clearState,
36
44
  DEFAULT_INSTANCE,
37
45
  ensureCloudflared,
38
46
  gateLogFile,
39
47
  isProcessAlive,
40
- isTunnelStateLive,
41
- listStates,
42
48
  providerLogFile,
43
49
  readConfig,
44
- readState,
45
50
  type TailscaleMode,
46
51
  type TunnelProvider,
47
52
  type TunnelState,
53
+ tunnelDir,
54
+ tunnelReadiness,
48
55
  writeConfig,
49
56
  writeState,
50
57
  } from "../lib/tunnel/state.ts";
@@ -335,53 +342,6 @@ function sleep(ms: number): Promise<void> {
335
342
  return new Promise((r) => setTimeout(r, ms));
336
343
  }
337
344
 
338
- /**
339
- * Kill every process whose command line matches `pattern` (an extended regular
340
- * expression), skipping our own PID and any already-killed. Returns the count
341
- * killed. Used as a fallback so orphaned gate/cloudflared processes get cleaned
342
- * even when the state file was lost (which otherwise left them squatting on the
343
- * port).
344
- */
345
- function killByPattern(pattern: string, alreadyKilled: Set<number>): number {
346
- let killed = 0;
347
- for (const pid of findPidsByCommandLine(pattern)) {
348
- if (!pid || pid === process.pid || alreadyKilled.has(pid)) continue;
349
- try {
350
- process.kill(pid);
351
- alreadyKilled.add(pid);
352
- killed++;
353
- } catch {
354
- /* race: already gone */
355
- }
356
- }
357
- return killed;
358
- }
359
-
360
- /**
361
- * Command-line pattern for ONE instance's gate, identified by its port. Matches
362
- * both launch forms: `bun run .../gate.ts --name N --port P` and a host CLI
363
- * re-executing itself as `<cli> tunnel gate --name N --port P`. Port boundary is
364
- * guarded with `( |$)` so port 9001 doesn't match 90011.
365
- */
366
- function gatePattern(gatePort: number): string {
367
- return `(gate\\.ts|tunnel gate) .*--port ${gatePort}( |$)`;
368
- }
369
-
370
- /**
371
- * Sweep stray gate + cloudflared processes for ONE instance, identified by its
372
- * gate port. Both signatures are port-scoped so tearing down one tunnel never
373
- * touches another:
374
- * - gate: see gatePattern (the port is on the gate's argv)
375
- * - cloudflared: `--url http://localhost:<port>` (order-independent, so it
376
- * matches regardless of the `--protocol http2` flag we also pass).
377
- */
378
- function sweepStrays(gatePort: number, alreadyKilled: Set<number>): number {
379
- return (
380
- killByPattern(gatePattern(gatePort), alreadyKilled) +
381
- killByPattern(`--url http://localhost:${gatePort}( |$)`, alreadyKilled)
382
- );
383
- }
384
-
385
345
  /**
386
346
  * Pick a gate port for a new instance. An explicit `--gate-port` is honored
387
347
  * (and rejected if it's already taken); otherwise scan upward from 9001 for the
@@ -389,7 +349,7 @@ function sweepStrays(gatePort: number, alreadyKilled: Set<number>): number {
389
349
  */
390
350
  async function allocateGatePort(preferred: number | undefined): Promise<number> {
391
351
  const used = new Set<number>(
392
- listStates()
352
+ listSelectedStates()
393
353
  .filter((s) => isProcessAlive(s.gate_pid))
394
354
  .map((s) => s.gate_port),
395
355
  );
@@ -522,13 +482,14 @@ function runTailscaleShare(
522
482
  }
523
483
 
524
484
  function stopTailscaleShare(state: TunnelState): boolean {
525
- if (state.provider !== "tailscale" || !state.tailscale_mode) return true;
526
- const cmd = tailscaleCommand(state.tailscale_mode);
485
+ if (state.provider !== "tailscale") return true;
486
+ if (!originalTailscaleRoute(state)) return false;
487
+ const cmd = tailscaleCommand(state.tailscale_mode!);
527
488
  const logPath = tunnelLogDestinations(state.name, "tailscale").provider;
528
489
  const args = [
529
490
  cmd,
530
- `--https=${state.tailscale_https_port ?? 443}`,
531
- `--set-path=${state.tailscale_path ?? "/"}`,
491
+ `--https=${state.tailscale_https_port}`,
492
+ `--set-path=${state.tailscale_path}`,
532
493
  "off",
533
494
  ];
534
495
  const status = runRotatingProcessSync({
@@ -541,9 +502,9 @@ function stopTailscaleShare(state: TunnelState): boolean {
541
502
  // surviving serve/funnel mapping could later re-expose whatever next binds
542
503
  // that port. Surface it loudly so the operator can clear it by hand.
543
504
  emit.text(
544
- `\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path ?? "/"}).\n` +
505
+ `\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path}).\n` +
545
506
  ` It may still be exposing this machine. Clear it with:\n` +
546
- ` tailscale ${cmd} --https=${state.tailscale_https_port ?? 443} --set-path=${state.tailscale_path ?? "/"} off\n` +
507
+ ` tailscale ${cmd} --https=${state.tailscale_https_port} --set-path=${state.tailscale_path} off\n` +
547
508
  ` Log: ${logPath}\n\n`,
548
509
  );
549
510
  return false;
@@ -559,13 +520,48 @@ function contextVhost(): string | null {
559
520
  }
560
521
 
561
522
  function providerIsAlive(state: TunnelState): boolean {
562
- if (state.provider === "tailscale") return true;
563
- const pid = state.cloudflared_pid ?? state.provider_pid;
523
+ if (!canonicalProviderFields(state)) return false;
524
+ if (state.provider === "tailscale") return state.provider_state === "ready";
525
+ const pid = state.cloudflared_pid;
564
526
  return typeof pid === "number" && isProcessAlive(pid);
565
527
  }
566
528
 
567
- function tunnelIsAlive(state: TunnelState): boolean {
568
- return isTunnelStateLive(state);
529
+ function canonicalProviderFields(state: TunnelState): boolean {
530
+ return (
531
+ !Object.hasOwn(state, "provider_pid") &&
532
+ !Object.hasOwn(state, "provider_start_token") &&
533
+ (state.provider !== "tailscale" ||
534
+ (state.cloudflared_pid === undefined && state.cloudflared_start_token === undefined))
535
+ );
536
+ }
537
+
538
+ function originalProcessWitnesses(state: TunnelState): boolean {
539
+ if (
540
+ !canonicalProviderFields(state) ||
541
+ !Number.isSafeInteger(state.gate_pid) ||
542
+ state.gate_pid < 1 ||
543
+ typeof state.gate_start_token !== "string" ||
544
+ !state.gate_start_token ||
545
+ typeof state.registration_id !== "string" ||
546
+ !state.registration_id
547
+ )
548
+ return false;
549
+ if (state.provider === "tailscale")
550
+ return (
551
+ originalTailscaleRoute(state) &&
552
+ (state.provider_state === "ready" || state.provider_state === "pending")
553
+ );
554
+ return (
555
+ state.provider === "cloudflare" &&
556
+ Number.isSafeInteger(state.cloudflared_pid) &&
557
+ state.cloudflared_pid! > 0 &&
558
+ typeof state.cloudflared_start_token === "string" &&
559
+ !!state.cloudflared_start_token
560
+ );
561
+ }
562
+
563
+ function readinessOf(state: TunnelState) {
564
+ return tunnelReadiness(state, createTunnelReadinessVerifier({ coordRoot: registryRoot() }));
569
565
  }
570
566
 
571
567
  interface GateSpawnOpts {
@@ -588,8 +584,7 @@ interface GateSpawnOpts {
588
584
  * re-reads — that snapshot is precisely why `reload` has to exist.
589
585
  */
590
586
  function spawnGate(o: GateSpawnOpts, launch: GateLaunch): ChildProcess {
591
- // `--name`/`--port` on argv mirror the env vars; they're what makes the gate
592
- // process distinguishable per-instance in a command-line search (see sweepStrays).
587
+ // Keep the instance name and port visible in process diagnostics.
593
588
  const gateProc = spawnRotatingProcess({
594
589
  path: o.gateLogPath,
595
590
  command: launch.command,
@@ -610,7 +605,115 @@ function spawnGate(o: GateSpawnOpts, launch: GateLaunch): ChildProcess {
610
605
  return gateProc;
611
606
  }
612
607
 
608
+ export interface TunnelRoute {
609
+ path: string;
610
+ httpsPort: number;
611
+ }
612
+ function originalTailscaleRoute(state: TunnelState): boolean {
613
+ return (
614
+ (state.tailscale_mode === "serve" || state.tailscale_mode === "funnel") &&
615
+ typeof state.tailscale_path === "string" &&
616
+ state.tailscale_path.startsWith("/") &&
617
+ Number.isSafeInteger(state.tailscale_https_port) &&
618
+ state.tailscale_https_port! > 0 &&
619
+ state.tailscale_https_port! <= 65535
620
+ );
621
+ }
622
+ function routeOf(state: TunnelState | null): TunnelRoute | null {
623
+ return state?.provider === "tailscale" && originalTailscaleRoute(state)
624
+ ? { path: state.tailscale_path!, httpsPort: state.tailscale_https_port! }
625
+ : null;
626
+ }
627
+ function routeKey(route: TunnelRoute): string {
628
+ return `${route.httpsPort}:${route.path}`;
629
+ }
630
+
631
+ /** Serialize the instance and every shared provider route before any external effect. */
632
+ export function withTunnelLifecycleCustody<T>(
633
+ name: string,
634
+ requested: TunnelRoute | null,
635
+ effect: () => PromiseLike<T>,
636
+ ): Promise<T>;
637
+ export function withTunnelLifecycleCustody<T>(
638
+ name: string,
639
+ requested: TunnelRoute | null,
640
+ effect: () => T,
641
+ ): T;
642
+ export function withTunnelLifecycleCustody<T>(
643
+ name: string,
644
+ requested: TunnelRoute | null,
645
+ effect: () => T | PromiseLike<T>,
646
+ ): T | Promise<T> {
647
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(name)) throw new Error("invalid tunnel lifecycle name");
648
+ const leases: Array<ReturnType<typeof acquireNoClobberLease>> = [];
649
+ const release = () => {
650
+ for (const lease of leases.splice(0).reverse()) lease.release();
651
+ };
652
+ const acquire = (category: string, key: string) => {
653
+ const scope = `tunnel-${category}-${createHash("sha256").update(key).digest("hex")}`;
654
+ leases.push(
655
+ acquireNoClobberLease({
656
+ path: resolve(
657
+ tunnelDir(),
658
+ ".custody",
659
+ category,
660
+ createHash("sha256").update(key).digest("hex"),
661
+ ),
662
+ scope,
663
+ authoritySha256: createHash("sha256").update(scope).digest("hex"),
664
+ staleAfterMs: 1,
665
+ }),
666
+ );
667
+ };
668
+ try {
669
+ acquire("instance", name.toLowerCase());
670
+ const retained = listSelectedStates();
671
+ const existing = routeOf(retained.find((state) => state.name === name) ?? null);
672
+ const routes = [
673
+ ...new Set(
674
+ [existing, requested].filter((route): route is TunnelRoute => !!route).map(routeKey),
675
+ ),
676
+ ].sort();
677
+ for (const key of routes) acquire("route", key);
678
+ for (const other of retained) {
679
+ if (other.name.toLowerCase() === name.toLowerCase()) {
680
+ if (other.name !== name)
681
+ throw new Error(
682
+ `tunnel instance ${name} aliases retained spelling ${other.name}; lifecycle custody is unavailable`,
683
+ );
684
+ continue;
685
+ }
686
+ const route = routeOf(other);
687
+ if (route && routes.includes(routeKey(route)))
688
+ throw new Error(
689
+ `Tailscale route ${routeKey(route)} is retained by tunnel ${other.name}; provider custody is unavailable`,
690
+ );
691
+ }
692
+ const result = effect();
693
+ if (
694
+ result !== null &&
695
+ (typeof result === "object" || typeof result === "function") &&
696
+ typeof (result as PromiseLike<T>).then === "function"
697
+ )
698
+ return Promise.resolve(result).finally(release);
699
+ release();
700
+ return result as T;
701
+ } catch (error) {
702
+ release();
703
+ throw error;
704
+ }
705
+ }
706
+
613
707
  async function up(opts: UpOpts): Promise<void> {
708
+ const name = resolveName(opts.name);
709
+ const requested =
710
+ resolveProvider(opts.provider) === "tailscale"
711
+ ? { path: resolveTailscalePath(opts.path, name), httpsPort: resolveHttpsPort(opts.httpsPort) }
712
+ : null;
713
+ return withTunnelLifecycleCustody(name, requested, () => upWithCustody(opts));
714
+ }
715
+
716
+ async function upWithCustody(opts: UpOpts): Promise<void> {
614
717
  const launch = currentGateLaunch();
615
718
  if (!launch) {
616
719
  emit.error({ code: "tunnel_requires_bun", message: requiresBunMessage() });
@@ -630,8 +733,17 @@ async function up(opts: UpOpts): Promise<void> {
630
733
  provider === "tailscale" ? resolveHttpsPort(opts.httpsPort) : undefined;
631
734
  const allowPaths = resolveAllowPaths(opts.allowPath);
632
735
 
633
- const existing = readState(name);
634
- if (existing && tunnelIsAlive(existing)) {
736
+ const existing = readSelectedState(name);
737
+ const readiness = existing ? readinessOf(existing) : null;
738
+ if (readiness === "unknown") {
739
+ emit.error({
740
+ code: "tunnel_custody_unknown",
741
+ message: `Tunnel [${name}] readiness is unknown; retained its state and refused cleanup or replacement.`,
742
+ });
743
+ process.exit(1);
744
+ return;
745
+ }
746
+ if (existing && readiness === "ready") {
635
747
  if (existing.provider !== provider) {
636
748
  emit.error({
637
749
  code: "tunnel_instance_in_use",
@@ -654,16 +766,21 @@ async function up(opts: UpOpts): Promise<void> {
654
766
  emit.text(` Paths: ${formatPaths(existing.allow_paths)}\n`);
655
767
  return;
656
768
  }
657
- if (existing) downOne(name, new Set());
769
+ if (existing) {
770
+ downOneWithCustody(name, new Set());
771
+ if (readSelectedState(name)) {
772
+ emit.error({
773
+ code: "tunnel_custody_unknown",
774
+ message: `Tunnel [${name}] has a live or unverifiable process; retained its state and refused a replacement.`,
775
+ });
776
+ process.exit(1);
777
+ }
778
+ }
658
779
 
659
780
  // Allocate the gate port (after clearing dead state so its old port frees up
660
781
  // for reuse). Explicit --gate-port is validated; otherwise auto-scan.
661
782
  const gatePort = await allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
662
783
 
663
- // Self-heal: clear any orphaned gate/cloudflared on THIS instance's port from
664
- // a prior crashed or state-cleared run so the gate port is free before we bind.
665
- if (sweepStrays(gatePort, new Set())) await sleep(500);
666
-
667
784
  const cfg = readConfig();
668
785
  if (provider === "cloudflare" && cfg.allowed_ips.length === 0) {
669
786
  emit.error({
@@ -711,17 +828,13 @@ async function up(opts: UpOpts): Promise<void> {
711
828
  launch,
712
829
  );
713
830
 
831
+ const gateStartToken = processStartToken(gateProc.pid!);
714
832
  await sleep(800);
715
833
 
716
834
  // A host CLI that re-executes itself takes longer to load than a bare script,
717
835
  // so wait for the gate to actually listen before the provider forwards to it.
718
836
  if (!isProcessAlive(gateProc.pid!) || !(await waitForPortBound(gatePort, 15_000))) {
719
- try {
720
- process.kill(gateProc.pid!);
721
- } catch {
722
- /* already dead */
723
- }
724
- sweepStrays(gatePort, new Set());
837
+ signalIfSameGeneration(gateProc.pid!, gateStartToken, "SIGTERM");
725
838
  emit.error({
726
839
  code: "tunnel_gate_failed",
727
840
  message: `Gate failed to start. Check log: ${gateLogPath}`,
@@ -732,6 +845,8 @@ async function up(opts: UpOpts): Promise<void> {
732
845
  let url: string;
733
846
  let registered = true;
734
847
  let cloudflaredPid: number | undefined;
848
+ let cloudflaredStartToken: string | undefined;
849
+ let pendingReceipt: ServerRecord | null = null;
735
850
 
736
851
  if (provider === "cloudflare") {
737
852
  // Force HTTP/2 transport. The default QUIC transport wedges at precheck on
@@ -747,20 +862,13 @@ async function up(opts: UpOpts): Promise<void> {
747
862
  });
748
863
  cfdProc.unref();
749
864
  cloudflaredPid = cfdProc.pid!;
865
+ cloudflaredStartToken = processStartToken(cloudflaredPid) ?? undefined;
750
866
 
751
867
  const ready = await waitForReady(providerLogPath, 30_000);
752
868
  registered = ready.registered;
753
869
  if (!ready.url) {
754
- try {
755
- process.kill(gateProc.pid!);
756
- } catch {
757
- /* already dead */
758
- }
759
- try {
760
- process.kill(cloudflaredPid);
761
- } catch {
762
- /* already dead */
763
- }
870
+ signalIfSameGeneration(gateProc.pid!, gateStartToken, "SIGTERM");
871
+ signalIfSameGeneration(cloudflaredPid, cloudflaredStartToken, "SIGTERM");
764
872
  emit.error({
765
873
  code: "tunnel_url_timeout",
766
874
  message: `Failed to obtain tunnel URL within 30s. Check log: ${providerLogPath}`,
@@ -776,12 +884,38 @@ async function up(opts: UpOpts): Promise<void> {
776
884
  // `heal` would then be unable to see or clean up.
777
885
  url = tailscaleUrl(tailscalePath!, tailscaleHttpsPort!);
778
886
  const gateTarget = `http://127.0.0.1:${gatePort}`;
779
- runTailscaleShare(
780
- tailscaleMode!,
781
- gateTarget,
782
- tailscalePath!,
783
- tailscaleHttpsPort!,
784
- providerLogPath,
887
+ const pending: TunnelState = {
888
+ name,
889
+ provider,
890
+ url,
891
+ gate_pid: gateProc.pid!,
892
+ gate_start_token: gateStartToken ?? undefined,
893
+ started_at: new Date().toISOString(),
894
+ target,
895
+ vhost,
896
+ gate_port: gatePort,
897
+ allow_paths: allowPaths,
898
+ tailscale_mode: tailscaleMode,
899
+ tailscale_path: tailscalePath,
900
+ tailscale_https_port: tailscaleHttpsPort,
901
+ provider_state: "pending",
902
+ };
903
+ pendingReceipt = recordTunnelServer(pending);
904
+ if (!pendingReceipt) {
905
+ const evidence = recordRefusedTunnelLaunch(pending);
906
+ signalIfSameGeneration(pending.gate_pid, pending.gate_start_token, "SIGTERM");
907
+ throw new Error(
908
+ `Tailscale admission refused before provider effect; retained witnesses at ${evidence}`,
909
+ );
910
+ }
911
+ beginTailscaleRoute(pending, pendingReceipt, () =>
912
+ runTailscaleShare(
913
+ tailscaleMode!,
914
+ gateTarget,
915
+ tailscalePath!,
916
+ tailscaleHttpsPort!,
917
+ providerLogPath,
918
+ ),
785
919
  );
786
920
  }
787
921
 
@@ -789,7 +923,10 @@ async function up(opts: UpOpts): Promise<void> {
789
923
  name,
790
924
  provider,
791
925
  url,
926
+ ...(provider === "tailscale" ? { provider_state: "ready" as const } : {}),
792
927
  gate_pid: gateProc.pid!,
928
+ gate_start_token: gateStartToken ?? undefined,
929
+ cloudflared_start_token: cloudflaredStartToken,
793
930
  cloudflared_pid: cloudflaredPid,
794
931
  started_at: new Date().toISOString(),
795
932
  target,
@@ -800,8 +937,22 @@ async function up(opts: UpOpts): Promise<void> {
800
937
  tailscale_path: tailscalePath,
801
938
  tailscale_https_port: tailscaleHttpsPort,
802
939
  };
803
- writeState(state);
804
- recordTunnelServer(state);
940
+ const receipt = provider === "tailscale" ? pendingReceipt : recordTunnelServer(state);
941
+ if (receipt) state.registration_id = receipt.registration_id;
942
+ try {
943
+ if (!receipt) throw new Error("registration refused");
944
+ publishTunnelState(state, receipt);
945
+ } catch {
946
+ const evidence = recordRefusedTunnelLaunch(state);
947
+ signalIfSameGeneration(state.gate_pid, state.gate_start_token, "SIGTERM");
948
+ if (state.cloudflared_pid)
949
+ signalIfSameGeneration(state.cloudflared_pid, state.cloudflared_start_token, "SIGTERM");
950
+ emit.error({
951
+ code: "tunnel_registration_refused",
952
+ message: `Tunnel [${name}] registration custody was refused; saved process witnesses at ${evidence}.`,
953
+ });
954
+ process.exit(1);
955
+ }
805
956
 
806
957
  const bin = resolveBinName();
807
958
  const stopHint =
@@ -836,43 +987,191 @@ function registryRoot(): string {
836
987
  return resolveCoordRoot() ?? process.cwd();
837
988
  }
838
989
 
839
- function recordTunnelServer(state: TunnelState): void {
840
- const coordRoot = registryRoot();
841
- tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
990
+ function unknownTunnelFrame(name: string): Error {
991
+ return Object.assign(
992
+ new Error(
993
+ `Tunnel [${name}] state is unreadable or its custody is unknown; retained the frame.`,
994
+ ),
995
+ {
996
+ code: "tunnel_custody_unknown",
997
+ },
998
+ );
999
+ }
1000
+
1001
+ function readSelectedState(name: string): TunnelState | null {
1002
+ const path = resolve(
1003
+ tunnelDir(),
1004
+ name === DEFAULT_INSTANCE ? "state.json" : `state-${name}.json`,
1005
+ );
1006
+ let bytes: string;
1007
+ try {
1008
+ bytes = readFileSync(path, "utf8");
1009
+ } catch (error) {
1010
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
1011
+ throw unknownTunnelFrame(name);
1012
+ }
1013
+ try {
1014
+ const raw = JSON.parse(bytes) as TunnelState;
1015
+ if (
1016
+ !raw ||
1017
+ typeof raw !== "object" ||
1018
+ Array.isArray(raw) ||
1019
+ raw.name !== name ||
1020
+ (raw.provider !== "cloudflare" && raw.provider !== "tailscale") ||
1021
+ (raw.provider === "tailscale" && !originalTailscaleRoute(raw)) ||
1022
+ typeof raw.url !== "string" ||
1023
+ !raw.url ||
1024
+ typeof raw.target !== "string" ||
1025
+ !raw.target ||
1026
+ typeof raw.vhost !== "string" ||
1027
+ !Number.isSafeInteger(raw.gate_pid) ||
1028
+ raw.gate_pid < 1 ||
1029
+ !Number.isSafeInteger(raw.gate_port) ||
1030
+ raw.gate_port < 1 ||
1031
+ raw.gate_port > 65535 ||
1032
+ !Array.isArray(raw.allow_paths) ||
1033
+ raw.allow_paths.some((path) => typeof path !== "string")
1034
+ )
1035
+ throw unknownTunnelFrame(name);
1036
+ return raw;
1037
+ } catch {
1038
+ throw unknownTunnelFrame(name);
1039
+ }
1040
+ }
1041
+
1042
+ function listSelectedStates(): TunnelState[] {
1043
+ let files: string[];
1044
+ try {
1045
+ files = readdirSync(tunnelDir());
1046
+ } catch (error) {
1047
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
1048
+ throw unknownTunnelFrame("inventory");
1049
+ }
1050
+ const states: TunnelState[] = [];
1051
+ for (const file of files) {
1052
+ const name = file === "state.json" ? DEFAULT_INSTANCE : /^state-(.+)\.json$/.exec(file)?.[1];
1053
+ if (!name) continue;
1054
+ const state = readSelectedState(name);
1055
+ if (state) states.push(state);
1056
+ }
1057
+ return states.sort((a, b) =>
1058
+ (typeof b.started_at === "string" ? b.started_at : "").localeCompare(
1059
+ typeof a.started_at === "string" ? a.started_at : "",
1060
+ ),
1061
+ );
842
1062
  }
843
1063
 
844
- /** Tear down a single instance by name. Returns the number of processes killed. */
845
- function portHeldByOtherLiveInstance(port: number, name: string): boolean {
846
- return listStates().some(
847
- (s) => s.name !== name && s.gate_port === port && isProcessAlive(s.gate_pid),
1064
+ function matchesTunnelReceipt(state: TunnelState, receipt: ServerRecord): boolean {
1065
+ return (
1066
+ originalProcessWitnesses(state) &&
1067
+ tunnelServerId(state.name) === receipt.id &&
1068
+ receipt.host === hostname() &&
1069
+ receipt.kind === "service" &&
1070
+ receipt.type === "tunnel" &&
1071
+ receipt.port === state.gate_port &&
1072
+ receipt.url === state.url &&
1073
+ receipt.registration_id === state.registration_id &&
1074
+ receipt.pid === state.gate_pid &&
1075
+ receipt.start_token === state.gate_start_token
848
1076
  );
849
1077
  }
850
1078
 
1079
+ /** Retain a refused launch without overwriting another launch's canonical state. */
1080
+ export function recordRefusedTunnelLaunch(state: TunnelState): string {
1081
+ const path = resolve(tunnelDir(), `failed-${state.name}-${randomUUID()}.json`);
1082
+ writePrivateJsonAtomic(path, state);
1083
+ return path;
1084
+ }
1085
+
1086
+ /** A canonical state frame may be published only by its current registration. */
1087
+ export function publishTunnelState(state: TunnelState, receipt: ServerRecord): void {
1088
+ if (!matchesTunnelReceipt(state, receipt))
1089
+ throw new Error("tunnel state registration custody does not match");
1090
+ withServerRegistrationCustody(receipt, { coordRoot: registryRoot() }, () => writeState(state));
1091
+ }
1092
+
1093
+ /** Retain pending route intent before asking the provider to change external state. */
1094
+ export function beginTailscaleRoute<T>(
1095
+ state: TunnelState,
1096
+ receipt: ServerRecord,
1097
+ effect: () => T,
1098
+ ): T {
1099
+ if (state.provider !== "tailscale" || state.provider_state !== "pending")
1100
+ throw new Error("Tailscale route intent is not pending");
1101
+ publishTunnelState(state, receipt);
1102
+ return withServerRegistrationCustody(receipt, { coordRoot: registryRoot() }, effect);
1103
+ }
1104
+
1105
+ function recordTunnelServer(state: TunnelState): ServerRecord | null {
1106
+ if (!state.gate_start_token) return null;
1107
+ const coordRoot = registryRoot();
1108
+ const record = tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
1109
+ if (record) state.registration_id = record.registration_id;
1110
+ return record;
1111
+ }
1112
+
1113
+ function tunnelRegistration(state: TunnelState): { known: boolean; record: ServerRecord | null } {
1114
+ if (!originalProcessWitnesses(state)) return { known: false, record: null };
1115
+ const coordRoot = registryRoot();
1116
+ const id = tunnelServerId(state.name);
1117
+ let record: ServerRecord | null;
1118
+ try {
1119
+ record = readServer(id, { coordRoot });
1120
+ } catch {
1121
+ return { known: false, record: null };
1122
+ }
1123
+ const present = existsSync(resolve(serversDir(coordRoot), `${id}.json`));
1124
+ if (!present) return { known: false, record: null };
1125
+ return { known: !!record && matchesTunnelReceipt(state, record), record };
1126
+ }
1127
+
1128
+ /** Unknown saved identities retain state and never authorize a signal. */
851
1129
  export function downOne(name: string, killed: Set<number>): number {
1130
+ try {
1131
+ return withTunnelLifecycleCustody(name, null, () => downOneWithCustody(name, killed));
1132
+ } catch {
1133
+ return 0;
1134
+ }
1135
+ }
1136
+
1137
+ function downOneWithCustody(name: string, killed: Set<number>): number {
852
1138
  const before = killed.size;
853
- const state = readState(name);
854
- if (state) {
855
- if (state.provider === "tailscale") stopTailscaleShare(state);
856
- for (const pid of [state.gate_pid, state.cloudflared_pid, state.provider_pid]) {
857
- if (typeof pid !== "number") continue;
858
- if (isProcessAlive(pid)) {
859
- try {
860
- process.kill(pid);
861
- killed.add(pid);
862
- } catch {
863
- /* race: already gone */
864
- }
1139
+ const state = readSelectedState(name);
1140
+ if (!state) return 0;
1141
+ const registration = tunnelRegistration(state);
1142
+ if (!registration.known) return 0;
1143
+ const targets = [
1144
+ { pid: state.gate_pid, token: state.gate_start_token },
1145
+ { pid: state.cloudflared_pid, token: state.cloudflared_start_token },
1146
+ ].filter(
1147
+ (target): target is { pid: number; token: string | undefined } =>
1148
+ typeof target.pid === "number",
1149
+ );
1150
+ for (const target of targets) {
1151
+ if (pidAlive(target.pid) && verifyProcessGeneration(target.pid, target.token) !== "same")
1152
+ return 0;
1153
+ }
1154
+ if (!registration.record) return 0;
1155
+ let cleared = false;
1156
+ try {
1157
+ withServerRegistrationCustody(registration.record, { coordRoot: registryRoot() }, () => {
1158
+ for (const target of targets) {
1159
+ if (!pidAlive(target.pid) || killed.has(target.pid)) continue;
1160
+ const result = signalIfSameGeneration(target.pid, target.token, "SIGTERM");
1161
+ if (!result.sent) throw new Error("tunnel process custody is unknown");
1162
+ killed.add(target.pid);
865
1163
  }
866
- }
1164
+ // Observation and deletion share custody; an old stopper cannot clear a winner.
1165
+ if (targets.some((target) => pidAlive(target.pid))) return;
1166
+ if (JSON.stringify(readSelectedState(name)) !== JSON.stringify(state)) return;
1167
+ if (state.provider === "tailscale" && !stopTailscaleShare(state)) return;
1168
+ clearState(name);
1169
+ cleared = true;
1170
+ });
1171
+ } catch {
1172
+ return killed.size - before;
867
1173
  }
868
- // Fallback: sweep orphans on this instance's gate port, even when state was
869
- // lost; they'd otherwise squat on the port and break the next `up`. Skip it
870
- // when another live instance now owns that port: a stale record's port is
871
- // reused after a reboot, and sweeping it would kill the live tunnel there.
872
- const port = state?.gate_port ?? DEFAULT_GATE_PORT;
873
- if (!portHeldByOtherLiveInstance(port, name)) sweepStrays(port, killed);
874
- clearState(name);
875
- unregisterServer(tunnelServerId(name), { coordRoot: registryRoot() });
1174
+ if (cleared) unregisterServer(registration.record, { coordRoot: registryRoot() });
876
1175
  return killed.size - before;
877
1176
  }
878
1177
 
@@ -880,14 +1179,15 @@ function down(opts: DownOpts): void {
880
1179
  const killed = new Set<number>();
881
1180
 
882
1181
  if (opts.all) {
883
- const states = listStates();
1182
+ const states = listSelectedStates();
884
1183
  if (states.length === 0) {
885
1184
  emit.text("No tunnels up. Nothing to stop.\n");
886
1185
  return;
887
1186
  }
888
1187
  for (const s of states) downOne(s.name, killed);
1188
+ const retained = states.filter((state) => readSelectedState(state.name)).length;
889
1189
  emit.text(
890
- `Stopped ${states.length} tunnel(s) [${states.map((s) => s.name).join(", ")}], ${killed.size} process(es).\n`,
1190
+ `Requested stop for ${states.length} tunnel(s); ${killed.size} process signal(s), ${retained} state record(s) retained pending verified exit.\n`,
891
1191
  );
892
1192
  return;
893
1193
  }
@@ -895,8 +1195,8 @@ function down(opts: DownOpts): void {
895
1195
  const name = resolveName(opts.name);
896
1196
  // Bare `down` targets the default instance. If it's not up but named ones
897
1197
  // are, don't silently no-op; point the operator at them.
898
- if (name === DEFAULT_INSTANCE && !readState(DEFAULT_INSTANCE)) {
899
- const others = listStates();
1198
+ if (name === DEFAULT_INSTANCE && !readSelectedState(DEFAULT_INSTANCE)) {
1199
+ const others = listSelectedStates();
900
1200
  if (others.length > 0) {
901
1201
  emit.text(
902
1202
  `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`,
@@ -907,9 +1207,9 @@ function down(opts: DownOpts): void {
907
1207
 
908
1208
  downOne(name, killed);
909
1209
  emit.text(
910
- killed.size === 0
911
- ? `No tunnel processes found for [${name}]. Nothing to stop.\n`
912
- : `Stopped ${killed.size} process(es). Tunnel [${name}] down.\n`,
1210
+ readSelectedState(name)
1211
+ ? `Tunnel [${name}] state retained pending verified process exit or custody; ${killed.size} signal(s) sent.\n`
1212
+ : `Tunnel [${name}] has no remaining recorded processes.\n`,
913
1213
  );
914
1214
  }
915
1215
 
@@ -945,10 +1245,29 @@ async function waitForPortBound(port: number, timeoutMs: number): Promise<boolea
945
1245
  * edge reconnects without noticing.
946
1246
  */
947
1247
  export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; message: string }> {
1248
+ try {
1249
+ return await withTunnelLifecycleCustody(state.name, routeOf(state), () =>
1250
+ reloadOneWithCustody(state),
1251
+ );
1252
+ } catch {
1253
+ return {
1254
+ ok: false,
1255
+ message: `[${state.name}] lifecycle or provider route custody is unavailable; retained the tunnel.`,
1256
+ };
1257
+ }
1258
+ }
1259
+
1260
+ async function reloadOneWithCustody(state: TunnelState): Promise<{ ok: boolean; message: string }> {
948
1261
  const bin = resolveBinName();
949
1262
  const name = state.name;
950
1263
  const gatePort = state.gate_port;
951
1264
 
1265
+ if (!canonicalProviderFields(state))
1266
+ return {
1267
+ ok: false,
1268
+ message: `[${name}] obsolete or ambiguous provider authority is unknown; retained the tunnel.`,
1269
+ };
1270
+
952
1271
  if (!providerIsAlive(state)) {
953
1272
  return {
954
1273
  ok: false,
@@ -974,28 +1293,54 @@ export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; mess
974
1293
  };
975
1294
  }
976
1295
 
977
- // Kill ONLY the gate. sweepStrays() is deliberately avoided here: it also
978
- // matches `--url http://localhost:<port>`, which is the provider process we
979
- // are going out of our way to keep alive.
980
- const killed = new Set<number>();
981
- if (isProcessAlive(state.gate_pid)) {
982
- try {
983
- process.kill(state.gate_pid);
984
- killed.add(state.gate_pid);
985
- } catch {
986
- /* race: already gone */
1296
+ const registration = tunnelRegistration(state);
1297
+ if (!registration.known || !registration.record)
1298
+ return { ok: false, message: `[${name}] registration custody changed; retained the tunnel.` };
1299
+ const stopGate = async (): Promise<{ ok: boolean; message: string }> => {
1300
+ if (
1301
+ state.provider === "cloudflare" &&
1302
+ state.cloudflared_pid &&
1303
+ verifyProcessGeneration(state.cloudflared_pid, state.cloudflared_start_token) !== "same"
1304
+ )
1305
+ return {
1306
+ ok: false,
1307
+ message: `[${name}] provider generation is unknown or changed; retained the tunnel.`,
1308
+ };
1309
+ if (pidAlive(state.gate_pid)) {
1310
+ if (!registration.record)
1311
+ return {
1312
+ ok: false,
1313
+ message: `[${name}] registration custody is unavailable; retained the tunnel.`,
1314
+ };
1315
+ const result = signalIfSameGeneration(state.gate_pid, state.gate_start_token, "SIGTERM");
1316
+ if (!result.sent)
1317
+ return {
1318
+ ok: false,
1319
+ message: `[${name}] gate generation is unknown or changed; retained the tunnel.`,
1320
+ };
987
1321
  }
988
- }
989
- killByPattern(gatePattern(gatePort), killed);
990
-
991
- if (!(await waitForPortFree(gatePort, 5_000))) {
1322
+ if (!(await waitForPortFree(gatePort, 5_000)))
1323
+ return {
1324
+ ok: false,
1325
+ message: `[${name}] port ${gatePort} never released; retained the tunnel state.`,
1326
+ };
1327
+ return { ok: true, message: "gate released" };
1328
+ };
1329
+ let released: { ok: boolean; message: string };
1330
+ try {
1331
+ released = await withServerRegistrationCustody(
1332
+ registration.record,
1333
+ { coordRoot: registryRoot() },
1334
+ stopGate,
1335
+ );
1336
+ } catch {
992
1337
  return {
993
1338
  ok: false,
994
- message:
995
- `[${name}] port ${gatePort} never released, so the old gate looks wedged. ` +
996
- `Tunnel is down; check \`${bin} tunnel status\`.`,
1339
+ message: `[${name}] registration custody is unavailable; retained the tunnel.`,
997
1340
  };
998
1341
  }
1342
+ if (!released.ok) return released;
1343
+ if (registration.record) unregisterServer(registration.record, { coordRoot: registryRoot() });
999
1344
 
1000
1345
  const cfg = readConfig();
1001
1346
  const gateProc = spawnGate(
@@ -1012,6 +1357,7 @@ export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; mess
1012
1357
  launch,
1013
1358
  );
1014
1359
 
1360
+ const gateStartToken = processStartToken(gateProc.pid!);
1015
1361
  await sleep(800);
1016
1362
  const pid = gateProc.pid;
1017
1363
  if (
@@ -1027,8 +1373,24 @@ export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; mess
1027
1373
  };
1028
1374
  }
1029
1375
 
1030
- writeState({ ...state, gate_pid: pid });
1031
- recordTunnelServer({ ...state, gate_pid: pid });
1376
+ const next = {
1377
+ ...state,
1378
+ gate_pid: pid,
1379
+ gate_start_token: gateStartToken ?? undefined,
1380
+ registration_id: undefined,
1381
+ };
1382
+ const nextReceipt = recordTunnelServer(next);
1383
+ try {
1384
+ if (!nextReceipt) throw new Error("registration refused");
1385
+ publishTunnelState(next, nextReceipt);
1386
+ } catch {
1387
+ const evidence = recordRefusedTunnelLaunch(next);
1388
+ signalIfSameGeneration(pid, gateStartToken, "SIGTERM");
1389
+ return {
1390
+ ok: false,
1391
+ message: `[${name}] new gate registration custody was refused; retained witnesses at ${evidence}.`,
1392
+ };
1393
+ }
1032
1394
  const allowNote =
1033
1395
  state.provider === "cloudflare" ? ` Allowlist: ${cfg.allowed_ips.length} IP(s).` : "";
1034
1396
  return { ok: true, message: `[${name}] gate reloaded, URL unchanged: ${state.url}.${allowNote}` };
@@ -1038,7 +1400,7 @@ async function reload(opts: ReloadOpts): Promise<void> {
1038
1400
  const bin = resolveBinName();
1039
1401
 
1040
1402
  if (opts.all) {
1041
- const all = listStates();
1403
+ const all = listSelectedStates();
1042
1404
  // Only live tunnels are reloadable, and a box commonly accumulates stale
1043
1405
  // state files from long-dead runs. Reporting those as failures would make
1044
1406
  // the common post-`allow add` path exit non-zero for no real reason.
@@ -1067,7 +1429,7 @@ async function reload(opts: ReloadOpts): Promise<void> {
1067
1429
  }
1068
1430
 
1069
1431
  const name = resolveName(opts.name);
1070
- const state = readState(name);
1432
+ const state = readSelectedState(name);
1071
1433
  if (!state) {
1072
1434
  emit.error({
1073
1435
  code: "tunnel_not_found",
@@ -1081,8 +1443,9 @@ async function reload(opts: ReloadOpts): Promise<void> {
1081
1443
  if (!r.ok) process.exit(1);
1082
1444
  }
1083
1445
 
1084
- function instanceState(state: TunnelState): "up" | "stale" {
1085
- return tunnelIsAlive(state) ? "up" : "stale";
1446
+ function instanceState(state: TunnelState): "up" | "stale" | "unknown" {
1447
+ const readiness = readinessOf(state);
1448
+ return readiness === "ready" ? "up" : readiness === "dead" ? "stale" : "unknown";
1086
1449
  }
1087
1450
 
1088
1451
  function fmtUptime(startedAt: string): string {
@@ -1097,9 +1460,9 @@ function fmtUptime(startedAt: string): string {
1097
1460
  function statusDetail(state: TunnelState): void {
1098
1461
  const gateAlive = isProcessAlive(state.gate_pid);
1099
1462
  const providerAlive = providerIsAlive(state);
1100
- const providerPid = state.cloudflared_pid ?? state.provider_pid;
1463
+ const providerPid = state.cloudflared_pid;
1101
1464
  const cfg = readConfig();
1102
- emit.text(`${gateAlive && providerAlive ? "up" : "stale"} [${state.name}]\n`);
1465
+ emit.text(`${instanceState(state)} [${state.name}]\n`);
1103
1466
  emit.text(
1104
1467
  ` Provider: ${state.provider}${state.tailscale_mode ? ` (${state.tailscale_mode})` : ""}\n`,
1105
1468
  );
@@ -1122,7 +1485,7 @@ function statusDetail(state: TunnelState): void {
1122
1485
  function status(opts: StatusOpts): void {
1123
1486
  // Named → detailed single block.
1124
1487
  if (opts.name) {
1125
- const state = readState(resolveName(opts.name));
1488
+ const state = readSelectedState(resolveName(opts.name));
1126
1489
  if (!state) {
1127
1490
  emit.text(`down [${resolveName(opts.name)}]\n`);
1128
1491
  return;
@@ -1132,7 +1495,7 @@ function status(opts: StatusOpts): void {
1132
1495
  }
1133
1496
 
1134
1497
  // No name → table of every instance.
1135
- const states = listStates();
1498
+ const states = listSelectedStates();
1136
1499
  if (states.length === 0) {
1137
1500
  emit.text("down\n");
1138
1501
  return;
@@ -1175,7 +1538,7 @@ function status(opts: StatusOpts): void {
1175
1538
 
1176
1539
  function logs(opts: LogsOpts): void {
1177
1540
  const name = resolveName(opts.name);
1178
- const state = readState(name);
1541
+ const state = readSelectedState(name);
1179
1542
  const provider = state?.provider ?? "cloudflare";
1180
1543
  const destinations = tunnelLogDestinations(name, provider);
1181
1544
  const path = opts.provider ? destinations.provider : destinations.gate;
@@ -1212,7 +1575,9 @@ const sameEntryIn = (list: readonly string[], entry: string) =>
1212
1575
  * restarts were owed.
1213
1576
  */
1214
1577
  function allowChangedHint(): void {
1215
- const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
1578
+ const live = listSelectedStates().filter(
1579
+ (s) => s.provider === "cloudflare" && providerIsAlive(s),
1580
+ );
1216
1581
  if (live.length === 0) return;
1217
1582
  emit.text(
1218
1583
  `Each gate reads the allowlist once at start, so this applies on reload ` +
@@ -1354,7 +1719,9 @@ export async function refreshCurrentAddress(
1354
1719
  }
1355
1720
  if (!plan.changed || !options.reload) return result;
1356
1721
 
1357
- const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
1722
+ const live = listSelectedStates().filter(
1723
+ (s) => s.provider === "cloudflare" && providerIsAlive(s),
1724
+ );
1358
1725
  for (const s of live) {
1359
1726
  const r = await reloadOne(s);
1360
1727
  emit.text(`${r.ok ? "ok " : "FAIL"} ${r.message}\n`);