harnery 0.42.0 → 0.44.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 (343) hide show
  1. package/README.md +3 -3
  2. package/dist/commander.d.ts +16 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +18 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +95 -52
  7. package/dist/commands/browse.d.ts.map +1 -1
  8. package/dist/commands/browse.js +38 -11
  9. package/dist/commands/diagnostics.d.ts.map +1 -1
  10. package/dist/commands/diagnostics.js +10 -0
  11. package/dist/commands/doctor.d.ts +12 -0
  12. package/dist/commands/doctor.d.ts.map +1 -1
  13. package/dist/commands/doctor.js +22 -7
  14. package/dist/commands/governor.js +22 -10
  15. package/dist/commands/inbox.d.ts +1 -1
  16. package/dist/commands/inbox.d.ts.map +1 -1
  17. package/dist/commands/inbox.js +55 -2
  18. package/dist/commands/rm.d.ts.map +1 -1
  19. package/dist/commands/rm.js +28 -4
  20. package/dist/commands/servers.d.ts.map +1 -1
  21. package/dist/commands/servers.js +16 -1
  22. package/dist/commands/supervisor.d.ts.map +1 -1
  23. package/dist/commands/supervisor.js +7 -2
  24. package/dist/commands/toc.d.ts +14 -0
  25. package/dist/commands/toc.d.ts.map +1 -1
  26. package/dist/commands/toc.js +7 -4
  27. package/dist/commands/tunnel.d.ts +65 -0
  28. package/dist/commands/tunnel.d.ts.map +1 -1
  29. package/dist/commands/tunnel.js +290 -101
  30. package/dist/commands/web-fetch.d.ts +3 -2
  31. package/dist/commands/web-fetch.d.ts.map +1 -1
  32. package/dist/commands/web-fetch.js +25 -19
  33. package/dist/commands/web.d.ts +7 -1
  34. package/dist/commands/web.d.ts.map +1 -1
  35. package/dist/commands/web.js +9 -3
  36. package/dist/commands/workflow.js +24 -30
  37. package/dist/core/agents/live-authority-v3.d.ts +1 -0
  38. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  39. package/dist/core/agents/live-authority-v3.js +11 -0
  40. package/dist/core/agents/live-lifecycle-v3.d.ts +4 -2
  41. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
  42. package/dist/core/agents/live-lifecycle-v3.js +28 -7
  43. package/dist/core/agents/mailbox.d.ts +9 -8
  44. package/dist/core/agents/mailbox.d.ts.map +1 -1
  45. package/dist/core/agents/mailbox.js +24 -17
  46. package/dist/core/agents/render/prompt-context.d.ts +2 -0
  47. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  48. package/dist/core/agents/render/prompt-context.js +19 -17
  49. package/dist/core/agents/render/session-context.d.ts +2 -8
  50. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  51. package/dist/core/agents/render/session-context.js +64 -43
  52. package/dist/core/agents/rules/stop-hook.js +3 -3
  53. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  54. package/dist/core/agents/state/heartbeat-writer.js +25 -14
  55. package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
  56. package/dist/core/agents/state/live-coordination-writer.js +5 -0
  57. package/dist/core/agents/state/names.d.ts +43 -3
  58. package/dist/core/agents/state/names.d.ts.map +1 -1
  59. package/dist/core/agents/state/names.js +87 -3
  60. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  61. package/dist/core/agents/state/pidmap.js +5 -0
  62. package/dist/core/agents/state/proc-start.d.ts +19 -0
  63. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  64. package/dist/core/agents/state/proc-start.js +41 -0
  65. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  66. package/dist/core/agents/state/stale-sweep.js +180 -70
  67. package/dist/core/agents/state/unchanged-file.d.ts +44 -0
  68. package/dist/core/agents/state/unchanged-file.d.ts.map +1 -0
  69. package/dist/core/agents/state/unchanged-file.js +390 -0
  70. package/dist/core/context/index.d.ts +22 -10
  71. package/dist/core/context/index.d.ts.map +1 -1
  72. package/dist/core/context/index.js +381 -119
  73. package/dist/core/context/observation.d.ts +26 -0
  74. package/dist/core/context/observation.d.ts.map +1 -0
  75. package/dist/core/context/observation.js +11 -0
  76. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  77. package/dist/core/diagnostics/bundle.js +25 -14
  78. package/dist/core/diagnostics/path-identity.d.ts +3 -0
  79. package/dist/core/diagnostics/path-identity.d.ts.map +1 -0
  80. package/dist/core/diagnostics/path-identity.js +12 -0
  81. package/dist/core/diagnostics/sanitize.d.ts.map +1 -1
  82. package/dist/core/diagnostics/sanitize.js +4 -3
  83. package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
  84. package/dist/core/events/v3/authority-outbox.js +29 -0
  85. package/dist/core/events/v3/generation-custody.d.ts +5 -0
  86. package/dist/core/events/v3/generation-custody.d.ts.map +1 -0
  87. package/dist/core/events/v3/generation-custody.js +50 -0
  88. package/dist/core/events/v3/producers/coordination-recorder.d.ts +1 -0
  89. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
  90. package/dist/core/events/v3/producers/coordination-recorder.js +7 -0
  91. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  92. package/dist/core/events/v3/producers/recorder.js +77 -64
  93. package/dist/core/events/v3/support-storage/pack-reader.d.ts.map +1 -1
  94. package/dist/core/events/v3/support-storage/pack-reader.js +42 -16
  95. package/dist/core/governor/planning.d.ts.map +1 -1
  96. package/dist/core/governor/planning.js +14 -1
  97. package/dist/core/governor/service-read.d.ts +10 -2
  98. package/dist/core/governor/service-read.d.ts.map +1 -1
  99. package/dist/core/governor/service-read.js +105 -22
  100. package/dist/core/governor/service.d.ts +20 -2
  101. package/dist/core/governor/service.d.ts.map +1 -1
  102. package/dist/core/governor/service.js +72 -47
  103. package/dist/core/hooks/cli.js +120 -23
  104. package/dist/core/hooks/fork-lineage.d.ts +40 -0
  105. package/dist/core/hooks/fork-lineage.d.ts.map +1 -0
  106. package/dist/core/hooks/fork-lineage.js +62 -0
  107. package/dist/core/hooks/resolve/transcript.d.ts +16 -0
  108. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  109. package/dist/core/hooks/resolve/transcript.js +84 -0
  110. package/dist/core/hooks/shell-rm.d.ts +2 -0
  111. package/dist/core/hooks/shell-rm.d.ts.map +1 -0
  112. package/dist/core/hooks/shell-rm.js +105 -0
  113. package/dist/core/hooks/shell-tokens.d.ts +12 -0
  114. package/dist/core/hooks/shell-tokens.d.ts.map +1 -0
  115. package/dist/core/hooks/shell-tokens.js +97 -0
  116. package/dist/core/hooks/shell-waiter.d.ts.map +1 -1
  117. package/dist/core/hooks/shell-waiter.js +3 -93
  118. package/dist/core/inbox/contract.d.ts +2 -1
  119. package/dist/core/inbox/contract.d.ts.map +1 -1
  120. package/dist/core/inbox/index.d.ts +2 -0
  121. package/dist/core/inbox/index.d.ts.map +1 -1
  122. package/dist/core/inbox/index.js +2 -0
  123. package/dist/core/inbox/peer-messages.d.ts +37 -0
  124. package/dist/core/inbox/peer-messages.d.ts.map +1 -0
  125. package/dist/core/inbox/peer-messages.js +72 -0
  126. package/dist/core/inbox/service.d.ts +8 -1
  127. package/dist/core/inbox/service.d.ts.map +1 -1
  128. package/dist/core/inbox/service.js +67 -21
  129. package/dist/core/inbox/targets.d.ts +22 -0
  130. package/dist/core/inbox/targets.d.ts.map +1 -0
  131. package/dist/core/inbox/targets.js +108 -0
  132. package/dist/core/inbox/watcher.js +7 -3
  133. package/dist/core/journal/index.d.ts.map +1 -1
  134. package/dist/core/journal/index.js +17 -2
  135. package/dist/core/resources/contract.d.ts +3 -0
  136. package/dist/core/resources/contract.d.ts.map +1 -1
  137. package/dist/core/resources/service-status.d.ts +14 -1
  138. package/dist/core/resources/service-status.d.ts.map +1 -1
  139. package/dist/core/resources/service-status.js +23 -13
  140. package/dist/core/resources/service.d.ts +9 -1
  141. package/dist/core/resources/service.d.ts.map +1 -1
  142. package/dist/core/resources/service.js +47 -27
  143. package/dist/core/resources/status.d.ts +2 -5
  144. package/dist/core/resources/status.d.ts.map +1 -1
  145. package/dist/core/resources/status.js +36 -7
  146. package/dist/core/semantic/contract.d.ts +5 -5
  147. package/dist/core/servers/index.d.ts +24 -13
  148. package/dist/core/servers/index.d.ts.map +1 -1
  149. package/dist/core/servers/index.js +132 -92
  150. package/dist/core/servers/tunnels.d.ts +3 -5
  151. package/dist/core/servers/tunnels.d.ts.map +1 -1
  152. package/dist/core/servers/tunnels.js +3 -9
  153. package/dist/core/storage/process-log.d.ts +12 -0
  154. package/dist/core/storage/process-log.d.ts.map +1 -1
  155. package/dist/core/storage/process-log.js +10 -2
  156. package/dist/core/supervisor/contract.d.ts +12 -0
  157. package/dist/core/supervisor/contract.d.ts.map +1 -1
  158. package/dist/core/supervisor/explanations.d.ts.map +1 -1
  159. package/dist/core/supervisor/explanations.js +2 -1
  160. package/dist/core/supervisor/findings.d.ts.map +1 -1
  161. package/dist/core/supervisor/findings.js +54 -9
  162. package/dist/core/supervisor/service.d.ts +9 -1
  163. package/dist/core/supervisor/service.d.ts.map +1 -1
  164. package/dist/core/supervisor/service.js +59 -29
  165. package/dist/core/supervisor/services.d.ts.map +1 -1
  166. package/dist/core/supervisor/services.js +22 -1
  167. package/dist/core/supervisor/status.d.ts +17 -2
  168. package/dist/core/supervisor/status.d.ts.map +1 -1
  169. package/dist/core/supervisor/status.js +70 -12
  170. package/dist/core/supervisor/timeline.d.ts.map +1 -1
  171. package/dist/core/supervisor/timeline.js +2 -1
  172. package/dist/core/work/state.js +7 -7
  173. package/dist/core/workflow/engine.d.ts.map +1 -1
  174. package/dist/core/workflow/engine.js +105 -32
  175. package/dist/core/workflow/failure-evidence.d.ts +16 -0
  176. package/dist/core/workflow/failure-evidence.d.ts.map +1 -0
  177. package/dist/core/workflow/failure-evidence.js +114 -0
  178. package/dist/core/workflow/index.d.ts +1 -1
  179. package/dist/core/workflow/index.d.ts.map +1 -1
  180. package/dist/core/workflow/index.js +1 -1
  181. package/dist/core/workflow/live-session-v3.d.ts +6 -0
  182. package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
  183. package/dist/core/workflow/live-session-v3.js +126 -5
  184. package/dist/core/workflow/proof.d.ts +5 -5
  185. package/dist/core/workflow/proof.d.ts.map +1 -1
  186. package/dist/core/workflow/proof.js +115 -20
  187. package/dist/core/workflow/run-state.d.ts +3 -1
  188. package/dist/core/workflow/run-state.d.ts.map +1 -1
  189. package/dist/core/workflow/run-state.js +31 -27
  190. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  191. package/dist/core/workflow/spawn-claude.js +19 -0
  192. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  193. package/dist/core/workflow/spawn-codex.js +13 -0
  194. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  195. package/dist/core/workflow/spawn-cursor.js +19 -0
  196. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  197. package/dist/core/workflow/spawn-opencode.js +14 -0
  198. package/dist/core/workflow/types.d.ts +64 -17
  199. package/dist/core/workflow/types.d.ts.map +1 -1
  200. package/dist/core/workflow/workspaces/index.d.ts +0 -1
  201. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  202. package/dist/core/workflow/workspaces/index.js +0 -1
  203. package/dist/core/workflow/workspaces/leases.d.ts +3 -1
  204. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  205. package/dist/core/workflow/workspaces/leases.js +30 -16
  206. package/dist/core/workflow/workspaces/local-git.d.ts +6 -2
  207. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  208. package/dist/core/workflow/workspaces/local-git.js +270 -6
  209. package/dist/core/workflow/workspaces/ownership.d.ts +11 -0
  210. package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -0
  211. package/dist/core/workflow/workspaces/ownership.js +194 -0
  212. package/dist/core/workflow/workspaces/reclaim.d.ts +35 -0
  213. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  214. package/dist/core/workflow/workspaces/reclaim.js +14 -0
  215. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  216. package/dist/core/workflow/workspaces/recovery.js +21 -13
  217. package/dist/core/workflow/workspaces/state.d.ts +1 -0
  218. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  219. package/dist/core/workflow/workspaces/state.js +21 -1
  220. package/dist/lib/browser/windows-chrome.d.ts +100 -12
  221. package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
  222. package/dist/lib/browser/windows-chrome.js +262 -73
  223. package/dist/lib/docs-links.d.ts.map +1 -1
  224. package/dist/lib/docs-links.js +45 -2
  225. package/dist/lib/guarded-remove.d.ts +7 -0
  226. package/dist/lib/guarded-remove.d.ts.map +1 -1
  227. package/dist/lib/guarded-remove.js +106 -12
  228. package/dist/lib/identities/assume.d.ts.map +1 -1
  229. package/dist/lib/identities/assume.js +4 -0
  230. package/dist/lib/tunnel/allowlist.d.ts +44 -0
  231. package/dist/lib/tunnel/allowlist.d.ts.map +1 -0
  232. package/dist/lib/tunnel/allowlist.js +204 -0
  233. package/dist/lib/tunnel/current-address.d.ts +41 -0
  234. package/dist/lib/tunnel/current-address.d.ts.map +1 -0
  235. package/dist/lib/tunnel/current-address.js +123 -0
  236. package/dist/lib/tunnel/gate.js +13 -6
  237. package/dist/lib/tunnel/processes.d.ts +39 -0
  238. package/dist/lib/tunnel/processes.d.ts.map +1 -0
  239. package/dist/lib/tunnel/processes.js +121 -0
  240. package/dist/lib/tunnel/state.d.ts +28 -3
  241. package/dist/lib/tunnel/state.d.ts.map +1 -1
  242. package/dist/lib/tunnel/state.js +98 -27
  243. package/dist/lib/zip/index.d.ts +41 -0
  244. package/dist/lib/zip/index.d.ts.map +1 -0
  245. package/dist/lib/zip/index.js +398 -0
  246. package/package.json +8 -1
  247. package/src/commander.ts +32 -0
  248. package/src/commands/agents.ts +151 -74
  249. package/src/commands/browse.ts +50 -14
  250. package/src/commands/diagnostics.ts +10 -0
  251. package/src/commands/doctor.ts +26 -7
  252. package/src/commands/governor.ts +23 -10
  253. package/src/commands/inbox.ts +52 -2
  254. package/src/commands/rm.ts +43 -4
  255. package/src/commands/servers.ts +16 -4
  256. package/src/commands/supervisor.ts +7 -2
  257. package/src/commands/toc.ts +14 -6
  258. package/src/commands/tunnel.ts +394 -116
  259. package/src/commands/web-fetch.ts +30 -19
  260. package/src/commands/web.ts +9 -3
  261. package/src/commands/workflow.ts +30 -30
  262. package/src/core/agents/live-authority-v3.ts +17 -0
  263. package/src/core/agents/live-lifecycle-v3.ts +52 -14
  264. package/src/core/agents/mailbox.ts +26 -17
  265. package/src/core/agents/render/prompt-context.ts +35 -20
  266. package/src/core/agents/render/session-context.ts +92 -48
  267. package/src/core/agents/rules/stop-hook.ts +3 -3
  268. package/src/core/agents/state/heartbeat-writer.ts +29 -12
  269. package/src/core/agents/state/live-coordination-writer.ts +29 -0
  270. package/src/core/agents/state/names.ts +117 -6
  271. package/src/core/agents/state/pidmap.ts +13 -0
  272. package/src/core/agents/state/proc-start.ts +57 -0
  273. package/src/core/agents/state/stale-sweep.ts +247 -82
  274. package/src/core/agents/state/unchanged-file.ts +456 -0
  275. package/src/core/context/index.ts +446 -136
  276. package/src/core/context/observation.ts +29 -0
  277. package/src/core/diagnostics/bundle.ts +23 -18
  278. package/src/core/diagnostics/path-identity.ts +15 -0
  279. package/src/core/diagnostics/sanitize.ts +4 -3
  280. package/src/core/events/v3/authority-outbox.ts +56 -0
  281. package/src/core/events/v3/generation-custody.ts +59 -0
  282. package/src/core/events/v3/producers/coordination-recorder.ts +15 -0
  283. package/src/core/events/v3/producers/recorder.ts +94 -68
  284. package/src/core/events/v3/support-storage/pack-reader.ts +45 -19
  285. package/src/core/governor/planning.ts +20 -8
  286. package/src/core/governor/service-read.ts +128 -20
  287. package/src/core/governor/service.ts +119 -42
  288. package/src/core/hooks/cli.ts +139 -22
  289. package/src/core/hooks/fork-lineage.ts +82 -0
  290. package/src/core/hooks/resolve/transcript.ts +88 -0
  291. package/src/core/hooks/shell-rm.ts +103 -0
  292. package/src/core/hooks/shell-tokens.ts +96 -0
  293. package/src/core/hooks/shell-waiter.ts +4 -91
  294. package/src/core/inbox/contract.ts +2 -1
  295. package/src/core/inbox/index.ts +2 -0
  296. package/src/core/inbox/peer-messages.ts +94 -0
  297. package/src/core/inbox/service.ts +88 -22
  298. package/src/core/inbox/targets.ts +148 -0
  299. package/src/core/inbox/watcher.ts +8 -9
  300. package/src/core/journal/index.ts +19 -2
  301. package/src/core/resources/contract.ts +4 -0
  302. package/src/core/resources/service-status.ts +35 -12
  303. package/src/core/resources/service.ts +71 -24
  304. package/src/core/resources/status.ts +57 -8
  305. package/src/core/servers/index.ts +177 -91
  306. package/src/core/servers/tunnels.ts +3 -11
  307. package/src/core/storage/process-log.ts +17 -2
  308. package/src/core/supervisor/contract.ts +13 -0
  309. package/src/core/supervisor/explanations.ts +4 -1
  310. package/src/core/supervisor/findings.ts +68 -10
  311. package/src/core/supervisor/service.ts +88 -26
  312. package/src/core/supervisor/services.ts +26 -8
  313. package/src/core/supervisor/status.ts +89 -12
  314. package/src/core/supervisor/timeline.ts +2 -1
  315. package/src/core/work/state.ts +7 -7
  316. package/src/core/workflow/engine.ts +131 -49
  317. package/src/core/workflow/failure-evidence.ts +141 -0
  318. package/src/core/workflow/index.ts +0 -1
  319. package/src/core/workflow/live-session-v3.ts +146 -5
  320. package/src/core/workflow/proof.ts +134 -26
  321. package/src/core/workflow/run-state.ts +31 -29
  322. package/src/core/workflow/spawn-claude.ts +18 -0
  323. package/src/core/workflow/spawn-codex.ts +13 -0
  324. package/src/core/workflow/spawn-cursor.ts +18 -0
  325. package/src/core/workflow/spawn-opencode.ts +14 -0
  326. package/src/core/workflow/types.ts +51 -17
  327. package/src/core/workflow/workspaces/index.ts +0 -1
  328. package/src/core/workflow/workspaces/leases.ts +48 -17
  329. package/src/core/workflow/workspaces/local-git.ts +315 -5
  330. package/src/core/workflow/workspaces/ownership.ts +232 -0
  331. package/src/core/workflow/workspaces/reclaim.ts +58 -0
  332. package/src/core/workflow/workspaces/recovery.ts +25 -13
  333. package/src/core/workflow/workspaces/state.ts +20 -0
  334. package/src/lib/browser/windows-chrome.ts +346 -86
  335. package/src/lib/docs-links.ts +49 -2
  336. package/src/lib/guarded-remove.ts +132 -14
  337. package/src/lib/identities/assume.ts +4 -0
  338. package/src/lib/tunnel/allowlist.ts +208 -0
  339. package/src/lib/tunnel/current-address.ts +158 -0
  340. package/src/lib/tunnel/gate.ts +16 -8
  341. package/src/lib/tunnel/processes.ts +149 -0
  342. package/src/lib/tunnel/state.ts +123 -27
  343. package/src/lib/zip/index.ts +483 -0
@@ -1,20 +1,43 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { lstatSync, readdirSync, readlinkSync, realpathSync, rmSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { basename, dirname, isAbsolute, parse, relative, resolve, sep } from "node:path";
4
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
5
5
  const protectedNames = new Set([".git", ".harnery", ".credentials", ".ssh", ".gnupg"]);
6
6
  const MAX_ENTRIES = 10_000;
7
+ const MAX_OWNED_ARTIFACT_ENTRIES = 100_000;
7
8
  /** Permanent removal is deliberately limited to inspected, untracked local outputs. */
8
9
  export function guardedRemove(paths, options, checkClaims = () => { }) {
9
10
  const cwd = realpathSync(process.cwd());
10
11
  const root = checkedPath(options.root, new Set(), true);
11
- const allowed = allowedProtected(options.allowProtected ?? [], root, options.liveRoots ?? []);
12
+ if (options.cleanClone) {
13
+ if (!options.recursive)
14
+ throw new Error("Clean clone removal requires --recursive");
15
+ if (options.allowProtected?.length)
16
+ throw new Error("Clean clone removal cannot be combined with --allow-protected");
17
+ if (!options.assertOwnedArtifact)
18
+ throw new Error("Clean clone removal requires one current caller-owned managed artifact");
19
+ options.assertOwnedArtifact(root);
20
+ }
21
+ const entryLimit = options.entryLimit ?? MAX_ENTRIES;
22
+ if (!Number.isSafeInteger(entryLimit) ||
23
+ entryLimit < 1 ||
24
+ entryLimit > MAX_OWNED_ARTIFACT_ENTRIES)
25
+ throw new Error(`The entry limit must be an integer from 1 to ${MAX_OWNED_ARTIFACT_ENTRIES}`);
26
+ const assertEntryLimitAuthority = () => {
27
+ if (entryLimit <= MAX_ENTRIES)
28
+ return;
29
+ if (!options.assertOwnedArtifact)
30
+ throw new Error("An entry limit above 10000 requires one current caller-owned managed artifact");
31
+ options.assertOwnedArtifact(root);
32
+ };
33
+ const allowed = allowedProtected(options.allowProtected ?? [], root, options.liveRoots ?? [], options.assertOwnedArtifact);
12
34
  const rootStat = lstatSync(root);
13
35
  const rootFingerprint = fingerprint(rootStat);
14
36
  if (!rootStat.isDirectory())
15
37
  throw new Error("--root must be a directory");
16
38
  if (root === parse(root).root || root === realpathSync(homedir()))
17
39
  throw new Error("--root cannot be a filesystem root or home directory");
40
+ assertEntryLimitAuthority();
18
41
  if (paths.length === 0 || paths.length > 100)
19
42
  throw new Error("Supply 1-100 explicit targets");
20
43
  const targets = paths.map((path) => checkedPath(path, allowed));
@@ -22,6 +45,7 @@ export function guardedRemove(paths, options, checkClaims = () => { }) {
22
45
  const removedProtected = new Set();
23
46
  const links = [];
24
47
  const gitDirs = new Set();
48
+ const cleanClones = new Set();
25
49
  let bytes = 0;
26
50
  const devices = new Set();
27
51
  for (const target of targets) {
@@ -64,6 +88,10 @@ export function guardedRemove(paths, options, checkClaims = () => { }) {
64
88
  if (stat.isDirectory() && !options.recursive)
65
89
  throw new Error(`Directory removal requires --recursive: ${target}`);
66
90
  const device = stat.dev;
91
+ if (options.cleanClone) {
92
+ assertCleanClone(target);
93
+ cleanClones.add(target);
94
+ }
67
95
  devices.add(device);
68
96
  const pending = [target];
69
97
  while (pending.length) {
@@ -72,6 +100,12 @@ export function guardedRemove(paths, options, checkClaims = () => { }) {
72
100
  if (basename(path) === ARTIFACT_RECORD && insideArtifactWorkspace(path, null))
73
101
  throw new Error(`An artifact's record is managed by the artifact commands: ${path}`);
74
102
  if (protectedNames.has(basename(path).toLowerCase())) {
103
+ if (options.cleanClone && basename(path) === ".git") {
104
+ const clone = dirname(path);
105
+ assertCleanClone(clone);
106
+ cleanClones.add(clone);
107
+ allowed.add(path);
108
+ }
75
109
  if (!allowed.has(path))
76
110
  throw new Error(protectedMessage(path));
77
111
  removedProtected.add(path);
@@ -82,16 +116,16 @@ export function guardedRemove(paths, options, checkClaims = () => { }) {
82
116
  // were already refused if they were links, in checkedPath.)
83
117
  if (path === target)
84
118
  throw new Error(`Path contains a symlink: ${path}`);
85
- if (snapshots.size >= MAX_ENTRIES)
86
- throw new Error(`Removal exceeds the ${MAX_ENTRIES}-entry limit; use the owning cleanup command`);
119
+ if (snapshots.size >= entryLimit)
120
+ throw new Error(`Removal exceeds the ${entryLimit}-entry limit; use the owning cleanup command`);
87
121
  snapshots.set(path, fingerprint(info));
88
122
  links.push({ path, target: readlinkSync(path) });
89
123
  continue;
90
124
  }
91
125
  if (info.dev !== device || (!info.isDirectory() && !info.isFile()))
92
126
  throw new Error(`Mount boundary or special file cannot be removed: ${path}`);
93
- if (snapshots.size >= MAX_ENTRIES)
94
- throw new Error(`Removal exceeds the ${MAX_ENTRIES}-entry limit; use the owning cleanup command`);
127
+ if (snapshots.size >= entryLimit)
128
+ throw new Error(`Removal exceeds the ${entryLimit}-entry limit; use the owning cleanup command`);
95
129
  snapshots.set(path, fingerprint(info));
96
130
  if (info.isFile())
97
131
  bytes += info.size;
@@ -126,6 +160,7 @@ export function guardedRemove(paths, options, checkClaims = () => { }) {
126
160
  root,
127
161
  targets,
128
162
  entries: snapshots.size,
163
+ entry_limit: entryLimit,
129
164
  bytes,
130
165
  protected: [...removedProtected].sort(),
131
166
  links,
@@ -137,12 +172,19 @@ export function guardedRemove(paths, options, checkClaims = () => { }) {
137
172
  if (checkedPath(options.root, new Set(), true) !== root ||
138
173
  fingerprint(lstatSync(root)) !== rootFingerprint)
139
174
  throw new Error("Allowed root changed during inspection");
175
+ assertEntryLimitAuthority();
176
+ if (options.cleanClone)
177
+ options.assertOwnedArtifact(root);
178
+ // Ownership may have been released or changed while the contents were inspected.
179
+ allowedProtected(options.allowProtected ?? [], root, options.liveRoots ?? [], options.assertOwnedArtifact);
140
180
  for (const target of targets) {
141
181
  checkedPath(target, allowed);
142
182
  assertUntracked(target);
143
183
  }
144
184
  for (const dir of gitDirs)
145
185
  assertNoUnpushedHistory(dir);
186
+ for (const clone of cleanClones)
187
+ assertCleanClone(clone);
146
188
  checkClaims(targets);
147
189
  for (const [path, previous] of snapshots) {
148
190
  if (fingerprint(lstatSync(path)) !== previous)
@@ -152,6 +194,44 @@ export function guardedRemove(paths, options, checkClaims = () => { }) {
152
194
  rmSync(target, { recursive: Boolean(options.recursive), force: false });
153
195
  return { ...report, applied: true };
154
196
  }
197
+ /** A disposable clone must not be the only copy of work or a registered worktree. */
198
+ function assertCleanClone(clone) {
199
+ const metadata = join(clone, ".git");
200
+ const stat = lstatSync(metadata);
201
+ if (!stat.isDirectory() || stat.isSymbolicLink())
202
+ throw new Error(`Clean clone removal requires a standalone .git directory: ${clone}`);
203
+ const git = (args) => {
204
+ const result = spawnSync("git", ["-c", "core.fsmonitor=false", "-C", clone, ...args], {
205
+ encoding: "utf8",
206
+ timeout: 10_000,
207
+ maxBuffer: 4 * 1024 * 1024,
208
+ env: {
209
+ ...process.env,
210
+ LC_ALL: "C",
211
+ GIT_DIR: undefined,
212
+ GIT_COMMON_DIR: undefined,
213
+ GIT_WORK_TREE: undefined,
214
+ GIT_INDEX_FILE: undefined,
215
+ GIT_OPTIONAL_LOCKS: "0",
216
+ },
217
+ });
218
+ if (result.error || result.status !== 0)
219
+ throw new Error(`Cannot verify clean clone ${clone}: git ${args[0]} failed`);
220
+ return result.stdout;
221
+ };
222
+ if (realpathSync(git(["rev-parse", "--show-toplevel"]).trim()) !== clone ||
223
+ realpathSync(resolve(clone, git(["rev-parse", "--git-common-dir"]).trim())) !== metadata)
224
+ throw new Error(`Clean clone removal refuses shared repository metadata: ${clone}`);
225
+ const worktrees = git(["worktree", "list", "--porcelain", "-z"])
226
+ .split("\0")
227
+ .filter((entry) => entry.startsWith("worktree "));
228
+ if (worktrees.length !== 1 || worktrees[0] !== `worktree ${clone}`)
229
+ throw new Error(`Clean clone removal refuses registered or linked worktrees: ${clone}`);
230
+ if (git(["status", "--porcelain=v1", "--untracked-files=all"]).trim())
231
+ throw new Error(`Clean clone has uncommitted or untracked work: ${clone}`);
232
+ if (git(["rev-list", "--max-count=1", "HEAD", "--all", "--reflog", "--not", "--remotes"]).trim())
233
+ throw new Error(`Clean clone has HEAD, ref or reflog commits not on any remote: ${clone}`);
234
+ }
155
235
  /** The metadata file the artifact commands keep in each artifact workspace. */
156
236
  const ARTIFACT_RECORD = ".harnery-artifact.json";
157
237
  /**
@@ -188,9 +268,9 @@ function insideAllowed(path, allowed) {
188
268
  * protected path strictly inside the root, reached without symlinks, and
189
269
  * never the Git metadata or Harnery state of a live workspace root.
190
270
  */
191
- function allowedProtected(paths, root, liveRoots) {
271
+ function allowedProtected(paths, root, liveRoots, assertOwnedArtifact) {
192
272
  const allowed = new Set();
193
- const live = liveRoots.map((dir) => resolve(dir));
273
+ const live = liveRoots.map((dir) => realpathSync(dir));
194
274
  for (const input of paths) {
195
275
  if (!input?.trim() || /[\0\r\n*?[\]$]/.test(input))
196
276
  throw new Error("Use a nonempty explicit --allow-protected path without wildcards");
@@ -213,11 +293,25 @@ function allowedProtected(paths, root, liveRoots) {
213
293
  if (!named && !(info.isDirectory() && isRepositoryMetadata(path)))
214
294
  throw new Error(`--allow-protected must name a protected path itself: ${path}`);
215
295
  for (const dir of live) {
216
- const own = [".git", ".harnery"].map((name) => resolve(dir, name));
217
- if (path === dir ||
218
- strictlyInside(path, dir) ||
219
- own.some((state) => path === state || strictlyInside(state, path)))
296
+ if (path === dir || strictlyInside(path, dir))
220
297
  throw new Error(`The live workspace's own state cannot be allowed: ${path}`);
298
+ for (const name of [".git", ".harnery"]) {
299
+ const state = resolve(dir, name);
300
+ if (path !== state && !strictlyInside(state, path))
301
+ continue;
302
+ // Only an explicitly named copied state directory inside one owned
303
+ // artifact may cross the outer live-state ancestor. Other live roots
304
+ // (including a checkout inside that artifact) still veto the path.
305
+ if (name === ".harnery" &&
306
+ basename(path) === ".harnery" &&
307
+ info.isDirectory() &&
308
+ dirname(root) === resolve(state, "artifacts") &&
309
+ assertOwnedArtifact) {
310
+ assertOwnedArtifact(root);
311
+ continue;
312
+ }
313
+ throw new Error(`The live workspace's own state cannot be allowed: ${path}`);
314
+ }
221
315
  }
222
316
  allowed.add(realpathSync(path));
223
317
  }
@@ -1 +1 @@
1
- {"version":3,"file":"assume.d.ts","sourceRoot":"","sources":["../../../src/lib/identities/assume.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AA0BH,MAAM,MAAM,uBAAuB,GAC/B,eAAe,GACf,iBAAiB,GACjB,sBAAsB,GACtB,oBAAoB,GACpB,kBAAkB,GAClB,oBAAoB,GACpB,aAAa,GACb,mBAAmB,CAAC;AAExB,qBAAa,mBAAoB,SAAQ,KAAK;aAE1B,IAAI,EAAE,uBAAuB;gBAA7B,IAAI,EAAE,uBAAuB,EAC7C,OAAO,EAAE,MAAM;CAKlB;AAED,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,OAAO,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,+DAA+D;IAC/D,qBAAqB,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC;AAOD;+EAC+E;AAC/E,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,KAAK,SAAa,EAClB,mBAAmB,GAAE,WAAW,CAAC,MAAM,CAAa,GACnD,gBAAgB,GAAG,IAAI,CA6BzB;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAC3C,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,gBAAgB,GACzB,OAAO,CAyCT;AAuED,gEAAgE;AAChE,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,GACjC,oBAAoB,CAyHtB"}
1
+ {"version":3,"file":"assume.d.ts","sourceRoot":"","sources":["../../../src/lib/identities/assume.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AA0BH,MAAM,MAAM,uBAAuB,GAC/B,eAAe,GACf,iBAAiB,GACjB,sBAAsB,GACtB,oBAAoB,GACpB,kBAAkB,GAClB,oBAAoB,GACpB,aAAa,GACb,mBAAmB,CAAC;AAExB,qBAAa,mBAAoB,SAAQ,KAAK;aAE1B,IAAI,EAAE,uBAAuB;gBAA7B,IAAI,EAAE,uBAAuB,EAC7C,OAAO,EAAE,MAAM;CAKlB;AAED,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,OAAO,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,+DAA+D;IAC/D,qBAAqB,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC;AAOD;+EAC+E;AAC/E,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,KAAK,SAAa,EAClB,mBAAmB,GAAE,WAAW,CAAC,MAAM,CAAa,GACnD,gBAAgB,GAAG,IAAI,CA6BzB;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAC3C,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,gBAAgB,GACzB,OAAO,CA6CT;AAuED,gEAAgE;AAChE,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,GACjC,oBAAoB,CAyHtB"}
@@ -85,6 +85,7 @@ export function reclaimAbandonedLocalConflict(coordRoot, conflict) {
85
85
  let sessionId = conflict.instance_id;
86
86
  let ageSecs;
87
87
  const liveRow = readLiveCoordinationRow(coordRoot, conflict.instance_id);
88
+ let expectedGenerationId = liveRow?.v3_generation_id ?? null;
88
89
  if (liveRow) {
89
90
  adapter = adapterOf(liveRow.platform);
90
91
  sessionId = liveRow.session_id || conflict.instance_id;
@@ -99,8 +100,10 @@ export function reclaimAbandonedLocalConflict(coordRoot, conflict) {
99
100
  // It is required to join the exact producer state when recording the sweep.
100
101
  adapter = adapterOf(hb.platform);
101
102
  sessionId = hb.session_id || conflict.instance_id;
103
+ expectedGenerationId = hb.v3_generation_id ?? null;
102
104
  }
103
105
  catch {
106
+ expectedGenerationId = null;
104
107
  /* content unreadable — still reclaim the file */
105
108
  }
106
109
  try {
@@ -116,6 +119,7 @@ export function reclaimAbandonedLocalConflict(coordRoot, conflict) {
116
119
  owner: conflict.instance_id,
117
120
  nativeSessionId: sessionId,
118
121
  adapter,
122
+ expectedGenerationId,
119
123
  observation: "stale_heartbeat",
120
124
  ageMs: Math.max(0, (ageSecs ?? 0) * 1_000),
121
125
  });
@@ -0,0 +1,44 @@
1
+ export type IpFamily = 4 | 6;
2
+ export interface ParsedIp {
3
+ family: IpFamily;
4
+ value: bigint;
5
+ }
6
+ export interface AllowEntry extends ParsedIp {
7
+ /** Prefix length in bits (32 or 128 for a single address). */
8
+ prefix: number;
9
+ }
10
+ /**
11
+ * Parse one address. Returns null for anything that is not an IP literal. A
12
+ * zone id (`fe80::1%en0`) is dropped, and an IPv4-mapped IPv6 address is
13
+ * reported as the IPv4 address it carries.
14
+ */
15
+ export declare function parseIp(raw: string): ParsedIp | null;
16
+ /**
17
+ * Parse an allowlist entry: `203.0.113.8`, `203.0.113.0/24`, `2601:db8::1`, or
18
+ * `2601:db8:1:2::/64`. Host bits below the prefix are cleared, so
19
+ * `203.0.113.77/24` means the network `203.0.113.0/24`. Returns null when the
20
+ * entry is not a valid address or its prefix length is out of range.
21
+ */
22
+ export declare function parseAllowEntry(raw: string): AllowEntry | null;
23
+ /** Canonical text of an address (dotted quad, or compressed lowercase IPv6). */
24
+ export declare function formatIp(ip: ParsedIp): string;
25
+ /** Canonical text of an entry; a single address carries no `/len` suffix. */
26
+ export declare function formatAllowEntry(entry: AllowEntry): string;
27
+ /**
28
+ * Canonical spelling of an allowlist entry, or null when invalid. Two spellings
29
+ * of one address or range (`2601:DB8:0:0::1`, `2601:db8::1`) normalize equal.
30
+ */
31
+ export declare function normalizeAllowEntry(raw: string): string | null;
32
+ export interface Allowlist {
33
+ entries: AllowEntry[];
34
+ /** Entries that could not be parsed; they never match anything. */
35
+ invalid: string[];
36
+ }
37
+ export declare function compileAllowlist(rawEntries: readonly string[]): Allowlist;
38
+ /** True when `ip` lies inside any entry. A missing or malformed address never matches. */
39
+ export declare function allowlistMatches(list: Allowlist, ip: string): boolean;
40
+ /** The IPv6 /64 network prefix of an address, as a canonical `…/64` entry. */
41
+ export declare function ipv6Slash64(address: string): string | null;
42
+ /** A single IPv4 address as a `/32` entry. */
43
+ export declare function ipv4Slash32(address: string): string | null;
44
+ //# sourceMappingURL=allowlist.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"allowlist.d.ts","sourceRoot":"","sources":["../../../src/lib/tunnel/allowlist.ts"],"names":[],"mappings":"AAaA,MAAM,MAAM,QAAQ,GAAG,CAAC,GAAG,CAAC,CAAC;AAE7B,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,QAAQ,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,UAAW,SAAQ,QAAQ;IAC1C,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAC;CAChB;AAyCD;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAYpD;AAQD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAmB9D;AAyBD,gFAAgF;AAChF,wBAAgB,QAAQ,CAAC,EAAE,EAAE,QAAQ,GAAG,MAAM,CAO7C;AAED,6EAA6E;AAC7E,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAG1D;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG9D;AAED,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,UAAU,EAAE,CAAC;IACtB,mEAAmE;IACnE,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAWzE;AAED,0FAA0F;AAC1F,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CASrE;AAED,8EAA8E;AAC9E,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI1D;AAED,8CAA8C;AAC9C,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI1D"}
@@ -0,0 +1,204 @@
1
+ // Address allowlist for the tunnel gate: exact addresses and CIDR ranges, IPv4
2
+ // and IPv6. Pure functions with no I/O, shared by the gate worker (matching)
3
+ // and the `tunnel allow` commands (validation + canonical spelling).
4
+ //
5
+ // An IPv6 phone and laptop on one Wi-Fi network have different addresses but
6
+ // share a /64 prefix, so a range entry such as `2601:db8:1:2::/64` admits both.
7
+ // An entry without a prefix length is a single address (/32 or /128).
8
+ //
9
+ // IPv4-mapped IPv6 addresses (`::ffff:203.0.113.8`) are IPv4 addresses reached
10
+ // through a dual-stack socket, so they parse and match as IPv4.
11
+ import { isIPv4, isIPv6 } from "node:net";
12
+ const FULL_BITS = { 4: 32, 6: 128 };
13
+ function parseIpv4(text) {
14
+ if (!isIPv4(text))
15
+ return null;
16
+ let value = 0n;
17
+ for (const part of text.split("."))
18
+ value = (value << 8n) | BigInt(Number(part));
19
+ return value;
20
+ }
21
+ function parseIpv6(text) {
22
+ if (!isIPv6(text))
23
+ return null;
24
+ let rest = text;
25
+ // A trailing dotted quad (`::ffff:1.2.3.4`, `64:ff9b::1.2.3.4`) is two groups.
26
+ const dotted = rest.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);
27
+ if (dotted) {
28
+ const v4 = parseIpv4(dotted[2]);
29
+ if (v4 === null)
30
+ return null;
31
+ rest = `${dotted[1]}${(v4 >> 16n).toString(16)}:${(v4 & 0xffffn).toString(16)}`;
32
+ }
33
+ const halves = rest.split("::");
34
+ if (halves.length > 2)
35
+ return null;
36
+ const head = halves[0] ? halves[0].split(":") : [];
37
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
38
+ const missing = 8 - head.length - tail.length;
39
+ if (halves.length === 1 ? missing !== 0 : missing < 1)
40
+ return null;
41
+ const groups = [
42
+ ...head,
43
+ ...Array.from({ length: halves.length === 2 ? missing : 0 }, () => "0"),
44
+ ...tail,
45
+ ];
46
+ if (groups.length !== 8)
47
+ return null;
48
+ let value = 0n;
49
+ for (const group of groups) {
50
+ if (!/^[0-9a-f]{1,4}$/i.test(group))
51
+ return null;
52
+ value = (value << 16n) | BigInt(Number.parseInt(group, 16));
53
+ }
54
+ return value;
55
+ }
56
+ /**
57
+ * Parse one address. Returns null for anything that is not an IP literal. A
58
+ * zone id (`fe80::1%en0`) is dropped, and an IPv4-mapped IPv6 address is
59
+ * reported as the IPv4 address it carries.
60
+ */
61
+ export function parseIp(raw) {
62
+ let text = raw.trim();
63
+ if (text.startsWith("[") && text.endsWith("]"))
64
+ text = text.slice(1, -1);
65
+ const zone = text.indexOf("%");
66
+ if (zone !== -1)
67
+ text = text.slice(0, zone);
68
+ if (!text)
69
+ return null;
70
+ const v4 = parseIpv4(text);
71
+ if (v4 !== null)
72
+ return { family: 4, value: v4 };
73
+ const v6 = parseIpv6(text);
74
+ if (v6 === null)
75
+ return null;
76
+ if (v6 >> 32n === 0xffffn)
77
+ return { family: 4, value: v6 & 0xffffffffn };
78
+ return { family: 6, value: v6 };
79
+ }
80
+ function maskFor(family, prefix) {
81
+ const bits = BigInt(FULL_BITS[family]);
82
+ const host = bits - BigInt(prefix);
83
+ return ((1n << bits) - 1n) ^ ((1n << host) - 1n);
84
+ }
85
+ /**
86
+ * Parse an allowlist entry: `203.0.113.8`, `203.0.113.0/24`, `2601:db8::1`, or
87
+ * `2601:db8:1:2::/64`. Host bits below the prefix are cleared, so
88
+ * `203.0.113.77/24` means the network `203.0.113.0/24`. Returns null when the
89
+ * entry is not a valid address or its prefix length is out of range.
90
+ */
91
+ export function parseAllowEntry(raw) {
92
+ const text = raw.trim();
93
+ const slash = text.indexOf("/");
94
+ const address = parseIp(slash === -1 ? text : text.slice(0, slash));
95
+ if (!address)
96
+ return null;
97
+ let prefix = FULL_BITS[address.family];
98
+ if (slash !== -1) {
99
+ const lengthText = text.slice(slash + 1);
100
+ if (!/^\d{1,3}$/.test(lengthText))
101
+ return null;
102
+ prefix = Number(lengthText);
103
+ // A prefix on an IPv4-mapped IPv6 entry counts from the IPv6 start, so
104
+ // `::ffff:1.2.3.0/120` is the IPv4 /24. Anything shorter is not an IPv4 range.
105
+ if (address.family === 4 && /:/.test(text.slice(0, slash))) {
106
+ if (prefix < 96 || prefix > 128)
107
+ return null;
108
+ prefix -= 96;
109
+ }
110
+ if (prefix > FULL_BITS[address.family])
111
+ return null;
112
+ }
113
+ return { ...address, prefix, value: address.value & maskFor(address.family, prefix) };
114
+ }
115
+ function compress(groups) {
116
+ // RFC 5952: collapse the longest run of two or more zero groups (first wins).
117
+ let bestStart = -1;
118
+ let bestLen = 0;
119
+ for (let i = 0; i < 8;) {
120
+ if (groups[i] !== 0) {
121
+ i++;
122
+ continue;
123
+ }
124
+ let j = i;
125
+ while (j < 8 && groups[j] === 0)
126
+ j++;
127
+ if (j - i > bestLen) {
128
+ bestStart = i;
129
+ bestLen = j - i;
130
+ }
131
+ i = j;
132
+ }
133
+ if (bestLen < 2)
134
+ return groups.map((g) => g.toString(16)).join(":");
135
+ const left = groups.slice(0, bestStart).map((g) => g.toString(16));
136
+ const right = groups.slice(bestStart + bestLen).map((g) => g.toString(16));
137
+ return `${left.join(":")}::${right.join(":")}`;
138
+ }
139
+ /** Canonical text of an address (dotted quad, or compressed lowercase IPv6). */
140
+ export function formatIp(ip) {
141
+ if (ip.family === 4) {
142
+ return [24n, 16n, 8n, 0n].map((shift) => Number((ip.value >> shift) & 0xffn)).join(".");
143
+ }
144
+ const groups = [];
145
+ for (let i = 7; i >= 0; i--)
146
+ groups.push(Number((ip.value >> BigInt(i * 16)) & 0xffffn));
147
+ return compress(groups);
148
+ }
149
+ /** Canonical text of an entry; a single address carries no `/len` suffix. */
150
+ export function formatAllowEntry(entry) {
151
+ const address = formatIp(entry);
152
+ return entry.prefix === FULL_BITS[entry.family] ? address : `${address}/${entry.prefix}`;
153
+ }
154
+ /**
155
+ * Canonical spelling of an allowlist entry, or null when invalid. Two spellings
156
+ * of one address or range (`2601:DB8:0:0::1`, `2601:db8::1`) normalize equal.
157
+ */
158
+ export function normalizeAllowEntry(raw) {
159
+ const entry = parseAllowEntry(raw);
160
+ return entry ? formatAllowEntry(entry) : null;
161
+ }
162
+ export function compileAllowlist(rawEntries) {
163
+ const entries = [];
164
+ const invalid = [];
165
+ for (const raw of rawEntries) {
166
+ const text = raw.trim();
167
+ if (!text)
168
+ continue;
169
+ const entry = parseAllowEntry(text);
170
+ if (entry)
171
+ entries.push(entry);
172
+ else
173
+ invalid.push(text);
174
+ }
175
+ return { entries, invalid };
176
+ }
177
+ /** True when `ip` lies inside any entry. A missing or malformed address never matches. */
178
+ export function allowlistMatches(list, ip) {
179
+ const address = parseIp(ip);
180
+ if (!address)
181
+ return false;
182
+ for (const entry of list.entries) {
183
+ if (entry.family !== address.family)
184
+ continue;
185
+ const mask = maskFor(entry.family, entry.prefix);
186
+ if ((address.value & mask) === entry.value)
187
+ return true;
188
+ }
189
+ return false;
190
+ }
191
+ /** The IPv6 /64 network prefix of an address, as a canonical `…/64` entry. */
192
+ export function ipv6Slash64(address) {
193
+ const ip = parseIp(address);
194
+ if (!ip || ip.family !== 6)
195
+ return null;
196
+ return formatAllowEntry({ ...ip, prefix: 64, value: ip.value & maskFor(6, 64) });
197
+ }
198
+ /** A single IPv4 address as a `/32` entry. */
199
+ export function ipv4Slash32(address) {
200
+ const ip = parseIp(address);
201
+ if (!ip || ip.family !== 4)
202
+ return null;
203
+ return `${formatIp(ip)}/32`;
204
+ }
@@ -0,0 +1,41 @@
1
+ import type { TunnelConfig } from "./state.js";
2
+ export declare const TRACE_URL_V4 = "https://1.1.1.1/cdn-cgi/trace";
3
+ export declare const TRACE_URL_V6 = "https://[2606:4700:4700::1111]/cdn-cgi/trace";
4
+ export interface DetectedAddresses {
5
+ v4?: string;
6
+ v6?: string;
7
+ /** One note per family that could not be read. */
8
+ failures: string[];
9
+ }
10
+ export interface DetectOptions {
11
+ fetch?: typeof fetch;
12
+ timeoutMs?: number;
13
+ /** Tries per family; a transient failure on a flaky network should not drop an address. */
14
+ attempts?: number;
15
+ }
16
+ /** The `ip=` value of a Cloudflare trace body, or null. */
17
+ export declare function parseTraceIp(body: string): string | null;
18
+ /** Both families in parallel. A family that fails is reported, not thrown. */
19
+ export declare function detectPublicAddresses(options?: DetectOptions): Promise<DetectedAddresses>;
20
+ export interface CurrentAddressPlan {
21
+ config: TunnelConfig;
22
+ /** Entries newly present in the allowlist. */
23
+ added: string[];
24
+ /** Automatic entries from an earlier refresh that are now gone. */
25
+ removed: string[];
26
+ /** The automatic entries after this refresh (the detected, not already listed by hand). */
27
+ automatic: string[];
28
+ changed: boolean;
29
+ warnings: string[];
30
+ }
31
+ /**
32
+ * Fold freshly detected addresses into the allowlist.
33
+ *
34
+ * When nothing was detected the config comes back untouched with a warning:
35
+ * a failed lookup must never empty the list. When at least one family was
36
+ * detected, a family that was not is treated as absent from this network, so
37
+ * its old automatic entry goes. The entries for the detected families replace
38
+ * every previous automatic entry; manual entries are kept in place.
39
+ */
40
+ export declare function planCurrentAddresses(config: TunnelConfig, detected: DetectedAddresses): CurrentAddressPlan;
41
+ //# sourceMappingURL=current-address.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"current-address.d.ts","sourceRoot":"","sources":["../../../src/lib/tunnel/current-address.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C,eAAO,MAAM,YAAY,kCAAkC,CAAC;AAC5D,eAAO,MAAM,YAAY,iDAAiD,CAAC;AAE3E,MAAM,WAAW,iBAAiB;IAChC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,kDAAkD;IAClD,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2FAA2F;IAC3F,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,2DAA2D;AAC3D,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGxD;AAoBD,8EAA8E;AAC9E,wBAAsB,qBAAqB,CACzC,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,iBAAiB,CAAC,CAgB5B;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,YAAY,CAAC;IACrB,8CAA8C;IAC9C,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,mEAAmE;IACnE,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,2FAA2F;IAC3F,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAOD;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,YAAY,EACpB,QAAQ,EAAE,iBAAiB,GAC1B,kBAAkB,CAkDpB"}
@@ -0,0 +1,123 @@
1
+ // This machine's public addresses as Cloudflare sees them, and the allowlist
2
+ // bookkeeping around them (`tunnel allow add --current`).
3
+ //
4
+ // The tunnel gate admits a visitor by the `CF-Connecting-IP` header, which is
5
+ // the address Cloudflare saw. Asking Cloudflare's own trace endpoint therefore
6
+ // returns the same address the gate will later compare, with no third party and
7
+ // no guess about NAT. The IPv4 and IPv6 probes use address literals, so each
8
+ // travels over the family it is named for and needs no DNS.
9
+ //
10
+ // Entries the refresh adds are recorded in `auto_allowed`. The next refresh
11
+ // replaces exactly those, so a machine that changes network does not pile up
12
+ // stale addresses, and an entry added by hand is never removed.
13
+ import { ipv4Slash32, ipv6Slash64, normalizeAllowEntry } from "./allowlist.js";
14
+ export const TRACE_URL_V4 = "https://1.1.1.1/cdn-cgi/trace";
15
+ export const TRACE_URL_V6 = "https://[2606:4700:4700::1111]/cdn-cgi/trace";
16
+ /** The `ip=` value of a Cloudflare trace body, or null. */
17
+ export function parseTraceIp(body) {
18
+ const m = body.match(/^ip=(\S+)\s*$/m);
19
+ return m ? m[1] : null;
20
+ }
21
+ async function probe(url, family, opts) {
22
+ let last = "no answer";
23
+ for (let attempt = 0; attempt < opts.attempts; attempt++) {
24
+ try {
25
+ const res = await opts.fetch(url, { signal: AbortSignal.timeout(opts.timeoutMs) });
26
+ if (!res.ok)
27
+ throw new Error(`HTTP ${res.status}`);
28
+ const ip = parseTraceIp(await res.text());
29
+ if (!ip)
30
+ throw new Error("the trace had no ip= line");
31
+ const entry = family === 4 ? ipv4Slash32(ip) : ipv6Slash64(ip);
32
+ if (!entry)
33
+ throw new Error(`expected an IPv${family} address, got ${ip}`);
34
+ return ip;
35
+ }
36
+ catch (error) {
37
+ last = error instanceof Error ? error.message : String(error);
38
+ }
39
+ }
40
+ throw new Error(last);
41
+ }
42
+ /** Both families in parallel. A family that fails is reported, not thrown. */
43
+ export async function detectPublicAddresses(options = {}) {
44
+ const opts = {
45
+ fetch: options.fetch ?? fetch,
46
+ timeoutMs: options.timeoutMs ?? 4_000,
47
+ attempts: options.attempts ?? 2,
48
+ };
49
+ const [v4, v6] = await Promise.allSettled([
50
+ probe(TRACE_URL_V4, 4, opts),
51
+ probe(TRACE_URL_V6, 6, opts),
52
+ ]);
53
+ const out = { failures: [] };
54
+ if (v4.status === "fulfilled")
55
+ out.v4 = v4.value;
56
+ else
57
+ out.failures.push(`IPv4: ${v4.reason.message}`);
58
+ if (v6.status === "fulfilled")
59
+ out.v6 = v6.value;
60
+ else
61
+ out.failures.push(`IPv6: ${v6.reason.message}`);
62
+ return out;
63
+ }
64
+ const sameEntry = (a, b) => (normalizeAllowEntry(a) ?? a) === (normalizeAllowEntry(b) ?? b);
65
+ const includesEntry = (list, entry) => list.some((e) => sameEntry(e, entry));
66
+ /**
67
+ * Fold freshly detected addresses into the allowlist.
68
+ *
69
+ * When nothing was detected the config comes back untouched with a warning:
70
+ * a failed lookup must never empty the list. When at least one family was
71
+ * detected, a family that was not is treated as absent from this network, so
72
+ * its old automatic entry goes. The entries for the detected families replace
73
+ * every previous automatic entry; manual entries are kept in place.
74
+ */
75
+ export function planCurrentAddresses(config, detected) {
76
+ const previousAuto = config.auto_allowed ?? [];
77
+ const wanted = [];
78
+ if (detected.v4) {
79
+ const entry = ipv4Slash32(detected.v4);
80
+ if (entry)
81
+ wanted.push(entry);
82
+ }
83
+ if (detected.v6) {
84
+ const entry = ipv6Slash64(detected.v6);
85
+ if (entry)
86
+ wanted.push(entry);
87
+ }
88
+ if (wanted.length === 0) {
89
+ return {
90
+ config,
91
+ added: [],
92
+ removed: [],
93
+ automatic: previousAuto,
94
+ changed: false,
95
+ warnings: [
96
+ `Could not detect this machine's public address (${detected.failures.join("; ") || "no answer"}). ` +
97
+ `The allowlist is unchanged${previousAuto.length ? `; the previous automatic entries stay: ${previousAuto.join(", ")}` : ""}.`,
98
+ ],
99
+ };
100
+ }
101
+ const manual = config.allowed_ips.filter((e) => !includesEntry(previousAuto, e));
102
+ // An address already listed by hand stays manual, so a later refresh keeps it.
103
+ const automatic = wanted.filter((e) => !includesEntry(manual, e));
104
+ const allowed = [...manual, ...automatic];
105
+ const before = config.allowed_ips;
106
+ const added = allowed.filter((e) => !includesEntry(before, e));
107
+ const removed = before.filter((e) => !includesEntry(allowed, e));
108
+ const warnings = [];
109
+ const missing = [detected.v4 ? null : "IPv4", detected.v6 ? null : "IPv6"].filter(Boolean);
110
+ if (missing.length > 0) {
111
+ warnings.push(`${missing.join(" and ")} was not detected, so this network is treated as having none` +
112
+ (detected.failures.length ? ` (${detected.failures.join("; ")})` : "") +
113
+ ".");
114
+ }
115
+ return {
116
+ config: { ...config, allowed_ips: allowed, auto_allowed: automatic },
117
+ added,
118
+ removed,
119
+ automatic,
120
+ changed: added.length > 0 || removed.length > 0,
121
+ warnings,
122
+ };
123
+ }