harnery 0.41.0 → 0.43.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 (269) hide show
  1. package/dist/commander.d.ts +16 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +21 -0
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +63 -5
  6. package/dist/commands/artifacts.d.ts.map +1 -1
  7. package/dist/commands/artifacts.js +113 -12
  8. package/dist/commands/browse.d.ts +164 -0
  9. package/dist/commands/browse.d.ts.map +1 -1
  10. package/dist/commands/browse.js +263 -51
  11. package/dist/commands/claude-desktop.d.ts.map +1 -1
  12. package/dist/commands/claude-desktop.js +52 -0
  13. package/dist/commands/disk.d.ts +6 -0
  14. package/dist/commands/disk.d.ts.map +1 -0
  15. package/dist/commands/disk.js +80 -0
  16. package/dist/commands/doctor.d.ts +8 -0
  17. package/dist/commands/doctor.d.ts.map +1 -1
  18. package/dist/commands/doctor.js +48 -1
  19. package/dist/commands/files.d.ts +16 -0
  20. package/dist/commands/files.d.ts.map +1 -1
  21. package/dist/commands/files.js +54 -2
  22. package/dist/commands/init.d.ts +2 -2
  23. package/dist/commands/init.d.ts.map +1 -1
  24. package/dist/commands/init.js +43 -9
  25. package/dist/commands/rm.d.ts +4 -0
  26. package/dist/commands/rm.d.ts.map +1 -0
  27. package/dist/commands/rm.js +87 -0
  28. package/dist/commands/servers.d.ts +7 -0
  29. package/dist/commands/servers.d.ts.map +1 -0
  30. package/dist/commands/servers.js +394 -0
  31. package/dist/commands/tunnel.d.ts +65 -0
  32. package/dist/commands/tunnel.d.ts.map +1 -1
  33. package/dist/commands/tunnel.js +372 -103
  34. package/dist/commands/web-fetch.d.ts +3 -2
  35. package/dist/commands/web-fetch.d.ts.map +1 -1
  36. package/dist/commands/web-fetch.js +25 -19
  37. package/dist/commands/web.d.ts +7 -1
  38. package/dist/commands/web.d.ts.map +1 -1
  39. package/dist/commands/web.js +9 -3
  40. package/dist/core/adapters/profiles.d.ts +9 -0
  41. package/dist/core/adapters/profiles.d.ts.map +1 -1
  42. package/dist/core/adapters/profiles.js +22 -1
  43. package/dist/core/adapters/types.d.ts +9 -0
  44. package/dist/core/adapters/types.d.ts.map +1 -1
  45. package/dist/core/agents/cli.js +13 -6
  46. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  47. package/dist/core/agents/render/prompt-context.js +11 -5
  48. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  49. package/dist/core/agents/render/session-context.js +59 -13
  50. package/dist/core/agents/state/live-coordination-view.d.ts +20 -0
  51. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  52. package/dist/core/agents/state/live-coordination-view.js +60 -0
  53. package/dist/core/agents/state/names.d.ts +43 -3
  54. package/dist/core/agents/state/names.d.ts.map +1 -1
  55. package/dist/core/agents/state/names.js +87 -3
  56. package/dist/core/artifacts/delivery-card.js +9 -2
  57. package/dist/core/artifacts/index.d.ts +140 -4
  58. package/dist/core/artifacts/index.d.ts.map +1 -1
  59. package/dist/core/artifacts/index.js +522 -49
  60. package/dist/core/config.d.ts +68 -11
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +128 -3
  63. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  64. package/dist/core/diagnostics/bundle.js +2 -2
  65. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  66. package/dist/core/events/v3/producers/recorder.js +16 -0
  67. package/dist/core/events/v3/projection.js +9 -1
  68. package/dist/core/events/v3/support-storage/pack-reader.d.ts.map +1 -1
  69. package/dist/core/events/v3/support-storage/pack-reader.js +42 -16
  70. package/dist/core/hooks/adapter/events.d.ts +17 -0
  71. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  72. package/dist/core/hooks/adapter/events.js +5 -0
  73. package/dist/core/hooks/adapter/runtime-telemetry.js +3 -1
  74. package/dist/core/hooks/adapter/wiring.d.ts +37 -7
  75. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  76. package/dist/core/hooks/adapter/wiring.js +62 -4
  77. package/dist/core/hooks/cli.js +131 -23
  78. package/dist/core/hooks/fork-lineage.d.ts +40 -0
  79. package/dist/core/hooks/fork-lineage.d.ts.map +1 -0
  80. package/dist/core/hooks/fork-lineage.js +62 -0
  81. package/dist/core/hooks/resolve/transcript.d.ts +16 -0
  82. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  83. package/dist/core/hooks/resolve/transcript.js +84 -0
  84. package/dist/core/hooks/run-markers.d.ts +37 -0
  85. package/dist/core/hooks/run-markers.d.ts.map +1 -0
  86. package/dist/core/hooks/run-markers.js +109 -0
  87. package/dist/core/hooks/shell-rm.d.ts +2 -0
  88. package/dist/core/hooks/shell-rm.d.ts.map +1 -0
  89. package/dist/core/hooks/shell-rm.js +105 -0
  90. package/dist/core/hooks/shell-tokens.d.ts +12 -0
  91. package/dist/core/hooks/shell-tokens.d.ts.map +1 -0
  92. package/dist/core/hooks/shell-tokens.js +97 -0
  93. package/dist/core/hooks/shell-waiter.d.ts.map +1 -1
  94. package/dist/core/hooks/shell-waiter.js +3 -93
  95. package/dist/core/semantic/contract.d.ts +5 -5
  96. package/dist/core/servers/index.d.ts +211 -0
  97. package/dist/core/servers/index.d.ts.map +1 -0
  98. package/dist/core/servers/index.js +560 -0
  99. package/dist/core/servers/net.d.ts +58 -0
  100. package/dist/core/servers/net.d.ts.map +1 -0
  101. package/dist/core/servers/net.js +270 -0
  102. package/dist/core/servers/tunnels.d.ts +14 -0
  103. package/dist/core/servers/tunnels.d.ts.map +1 -0
  104. package/dist/core/servers/tunnels.js +33 -0
  105. package/dist/core/storage/builtins.js +1 -0
  106. package/dist/core/storage/process-log.d.ts +12 -0
  107. package/dist/core/storage/process-log.d.ts.map +1 -1
  108. package/dist/core/storage/process-log.js +10 -2
  109. package/dist/core/workflow/engine.d.ts.map +1 -1
  110. package/dist/core/workflow/engine.js +30 -1
  111. package/dist/core/workflow/sandbox-projection.d.ts +13 -2
  112. package/dist/core/workflow/sandbox-projection.d.ts.map +1 -1
  113. package/dist/core/workflow/sandbox-projection.js +23 -0
  114. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  115. package/dist/core/workflow/spawn-claude.js +6 -1
  116. package/dist/core/workflow/spawn-codex.d.ts +2 -0
  117. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  118. package/dist/core/workflow/spawn-codex.js +20 -1
  119. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  120. package/dist/core/workflow/spawn-cursor.js +6 -1
  121. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  122. package/dist/core/workflow/spawn-opencode.js +4 -1
  123. package/dist/core/workflow/types.d.ts +47 -2
  124. package/dist/core/workflow/types.d.ts.map +1 -1
  125. package/dist/core/workflow/worker-access.d.ts +92 -0
  126. package/dist/core/workflow/worker-access.d.ts.map +1 -0
  127. package/dist/core/workflow/worker-access.js +450 -0
  128. package/dist/lib/browser/batch-steps.d.ts +18 -0
  129. package/dist/lib/browser/batch-steps.d.ts.map +1 -0
  130. package/dist/lib/browser/batch-steps.js +82 -0
  131. package/dist/lib/browser/collect/collector.d.ts +99 -0
  132. package/dist/lib/browser/collect/collector.d.ts.map +1 -0
  133. package/dist/lib/browser/collect/collector.js +202 -0
  134. package/dist/lib/browser/collect/fields.d.ts +31 -0
  135. package/dist/lib/browser/collect/fields.d.ts.map +1 -0
  136. package/dist/lib/browser/collect/fields.js +75 -0
  137. package/dist/lib/browser/collect/index.d.ts +6 -0
  138. package/dist/lib/browser/collect/index.d.ts.map +1 -0
  139. package/dist/lib/browser/collect/index.js +5 -0
  140. package/dist/lib/browser/collect/page-driver.d.ts +14 -0
  141. package/dist/lib/browser/collect/page-driver.d.ts.map +1 -0
  142. package/dist/lib/browser/collect/page-driver.js +306 -0
  143. package/dist/lib/browser/collect/presets.d.ts +52 -0
  144. package/dist/lib/browser/collect/presets.d.ts.map +1 -0
  145. package/dist/lib/browser/collect/presets.js +176 -0
  146. package/dist/lib/browser/collect/scroll-plan.d.ts +85 -0
  147. package/dist/lib/browser/collect/scroll-plan.d.ts.map +1 -0
  148. package/dist/lib/browser/collect/scroll-plan.js +118 -0
  149. package/dist/lib/browser/index.d.ts +1 -1
  150. package/dist/lib/browser/index.d.ts.map +1 -1
  151. package/dist/lib/browser/page-review-judge.d.ts +2 -0
  152. package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
  153. package/dist/lib/browser/page-review-judge.js +13 -0
  154. package/dist/lib/browser/qa-run-contracts.d.ts +10 -0
  155. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  156. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  157. package/dist/lib/browser/qa-run.js +1 -0
  158. package/dist/lib/browser/windows-chrome.d.ts +59 -0
  159. package/dist/lib/browser/windows-chrome.d.ts.map +1 -0
  160. package/dist/lib/browser/windows-chrome.js +179 -0
  161. package/dist/lib/claude-desktop-share.d.ts +136 -0
  162. package/dist/lib/claude-desktop-share.d.ts.map +1 -0
  163. package/dist/lib/claude-desktop-share.js +394 -0
  164. package/dist/lib/disk-usage.d.ts +50 -0
  165. package/dist/lib/disk-usage.d.ts.map +1 -0
  166. package/dist/lib/disk-usage.js +303 -0
  167. package/dist/lib/docs-lint.d.ts +6 -0
  168. package/dist/lib/docs-lint.d.ts.map +1 -1
  169. package/dist/lib/docs-lint.js +8 -3
  170. package/dist/lib/guarded-remove.d.ts +34 -0
  171. package/dist/lib/guarded-remove.d.ts.map +1 -0
  172. package/dist/lib/guarded-remove.js +310 -0
  173. package/dist/lib/instructions/templates.js +8 -8
  174. package/dist/lib/tunnel/allowlist.d.ts +44 -0
  175. package/dist/lib/tunnel/allowlist.d.ts.map +1 -0
  176. package/dist/lib/tunnel/allowlist.js +204 -0
  177. package/dist/lib/tunnel/current-address.d.ts +41 -0
  178. package/dist/lib/tunnel/current-address.d.ts.map +1 -0
  179. package/dist/lib/tunnel/current-address.js +123 -0
  180. package/dist/lib/tunnel/error-page.d.ts +1 -1
  181. package/dist/lib/tunnel/error-page.d.ts.map +1 -1
  182. package/dist/lib/tunnel/error-page.js +19 -8
  183. package/dist/lib/tunnel/gate.js +33 -7
  184. package/dist/lib/tunnel/path-scope.d.ts +22 -0
  185. package/dist/lib/tunnel/path-scope.d.ts.map +1 -0
  186. package/dist/lib/tunnel/path-scope.js +62 -0
  187. package/dist/lib/tunnel/processes.d.ts +39 -0
  188. package/dist/lib/tunnel/processes.d.ts.map +1 -0
  189. package/dist/lib/tunnel/processes.js +121 -0
  190. package/dist/lib/tunnel/state.d.ts +33 -3
  191. package/dist/lib/tunnel/state.d.ts.map +1 -1
  192. package/dist/lib/tunnel/state.js +115 -30
  193. package/package.json +7 -1
  194. package/schemas/config.schema.json +91 -0
  195. package/src/commander.ts +51 -0
  196. package/src/commands/agents.ts +111 -11
  197. package/src/commands/artifacts.ts +171 -19
  198. package/src/commands/browse.ts +400 -57
  199. package/src/commands/claude-desktop.ts +61 -0
  200. package/src/commands/disk.ts +114 -0
  201. package/src/commands/doctor.ts +52 -1
  202. package/src/commands/files.ts +93 -2
  203. package/src/commands/init.ts +55 -5
  204. package/src/commands/rm.ts +115 -0
  205. package/src/commands/servers.ts +506 -0
  206. package/src/commands/tunnel.ts +494 -116
  207. package/src/commands/web-fetch.ts +30 -19
  208. package/src/commands/web.ts +9 -3
  209. package/src/core/adapters/profiles.ts +22 -1
  210. package/src/core/adapters/types.ts +10 -0
  211. package/src/core/agents/cli.ts +14 -6
  212. package/src/core/agents/render/prompt-context.ts +25 -8
  213. package/src/core/agents/render/session-context.ts +84 -16
  214. package/src/core/agents/state/live-coordination-view.ts +73 -0
  215. package/src/core/agents/state/names.ts +117 -6
  216. package/src/core/artifacts/delivery-card.ts +10 -2
  217. package/src/core/artifacts/index.ts +697 -44
  218. package/src/core/config.ts +183 -4
  219. package/src/core/diagnostics/bundle.ts +2 -1
  220. package/src/core/events/v3/producers/recorder.ts +16 -0
  221. package/src/core/events/v3/projection.ts +11 -1
  222. package/src/core/events/v3/support-storage/pack-reader.ts +45 -19
  223. package/src/core/hooks/adapter/events.ts +19 -0
  224. package/src/core/hooks/adapter/runtime-telemetry.ts +3 -1
  225. package/src/core/hooks/adapter/wiring.ts +116 -8
  226. package/src/core/hooks/cli.ts +152 -22
  227. package/src/core/hooks/fork-lineage.ts +82 -0
  228. package/src/core/hooks/resolve/transcript.ts +88 -0
  229. package/src/core/hooks/run-markers.ts +132 -0
  230. package/src/core/hooks/shell-rm.ts +103 -0
  231. package/src/core/hooks/shell-tokens.ts +96 -0
  232. package/src/core/hooks/shell-waiter.ts +4 -91
  233. package/src/core/servers/index.ts +712 -0
  234. package/src/core/servers/net.ts +283 -0
  235. package/src/core/servers/tunnels.ts +39 -0
  236. package/src/core/storage/builtins.ts +1 -0
  237. package/src/core/storage/process-log.ts +17 -2
  238. package/src/core/workflow/engine.ts +38 -1
  239. package/src/core/workflow/sandbox-projection.ts +38 -2
  240. package/src/core/workflow/spawn-claude.ts +5 -1
  241. package/src/core/workflow/spawn-codex.ts +18 -1
  242. package/src/core/workflow/spawn-cursor.ts +5 -1
  243. package/src/core/workflow/spawn-opencode.ts +4 -1
  244. package/src/core/workflow/types.ts +62 -2
  245. package/src/core/workflow/worker-access.ts +525 -0
  246. package/src/lib/browser/batch-steps.ts +79 -0
  247. package/src/lib/browser/collect/collector.ts +306 -0
  248. package/src/lib/browser/collect/fields.ts +87 -0
  249. package/src/lib/browser/collect/index.ts +47 -0
  250. package/src/lib/browser/collect/page-driver.ts +327 -0
  251. package/src/lib/browser/collect/presets.ts +237 -0
  252. package/src/lib/browser/collect/scroll-plan.ts +179 -0
  253. package/src/lib/browser/index.ts +1 -0
  254. package/src/lib/browser/page-review-judge.ts +16 -0
  255. package/src/lib/browser/qa-run-contracts.ts +11 -0
  256. package/src/lib/browser/qa-run.ts +1 -0
  257. package/src/lib/browser/windows-chrome.ts +227 -0
  258. package/src/lib/claude-desktop-share.ts +511 -0
  259. package/src/lib/disk-usage.ts +347 -0
  260. package/src/lib/docs-lint.ts +8 -3
  261. package/src/lib/guarded-remove.ts +376 -0
  262. package/src/lib/instructions/templates.ts +8 -8
  263. package/src/lib/tunnel/allowlist.ts +208 -0
  264. package/src/lib/tunnel/current-address.ts +158 -0
  265. package/src/lib/tunnel/error-page.ts +25 -9
  266. package/src/lib/tunnel/gate.ts +38 -9
  267. package/src/lib/tunnel/path-scope.ts +73 -0
  268. package/src/lib/tunnel/processes.ts +149 -0
  269. package/src/lib/tunnel/state.ts +143 -30
@@ -4,12 +4,33 @@ import { resolve } from "node:path";
4
4
  import type { Command } from "commander";
5
5
  import type { EmitContext, HarneryProgramContext } from "../commander.ts";
6
6
 
7
+ import { resolveCoordRoot } from "../core/agents/coord-client.ts";
7
8
  import { resolveBinName } from "../core/config.ts";
9
+ import {
10
+ tryRegisterServer,
11
+ tunnelServerId,
12
+ tunnelServerInput,
13
+ unregisterServer,
14
+ } from "../core/servers/index.ts";
8
15
  import {
9
16
  processLogDestination,
17
+ runProcessLogWorker,
10
18
  runRotatingProcessSync,
11
19
  spawnRotatingProcess,
12
20
  } from "../core/storage/process-log.ts";
21
+ import { normalizeAllowEntry } from "../lib/tunnel/allowlist.ts";
22
+ import {
23
+ type DetectOptions,
24
+ detectPublicAddresses,
25
+ planCurrentAddresses,
26
+ } from "../lib/tunnel/current-address.ts";
27
+ 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";
13
34
  import {
14
35
  clearState,
15
36
  DEFAULT_INSTANCE,
@@ -35,10 +56,14 @@ import {
35
56
  * upstream. Cloudflare quick tunnels add an IP allowlist at the gate via the
36
57
  * Cloudflare-set `CF-Connecting-IP` header; Tailscale Serve/Funnel exposes the
37
58
  * same gate through tailscaled, with Tailscale owning the access boundary.
59
+ * Every provider also passes through a path scope: `up` requires at least one
60
+ * `--allow-path <prefix>`, and the gate refuses any request outside it.
38
61
  *
39
- * State + config persisted under `.cache/tunnel/`. cloudflared auto-installs
40
- * to ~/.local/bin/ on first run (Linux only; macOS users `brew install`);
41
- * Tailscale requires an installed/authenticated `tailscale` CLI.
62
+ * State + config persisted under `.cache/tunnel/` (or `$HARNERY_TUNNEL_DIR`).
63
+ * cloudflared comes from `$HARNERY_CLOUDFLARED` / the config's `cloudflared_path`
64
+ * when set, else PATH, else auto-installs to ~/.local/bin/ on Linux (macOS and
65
+ * Windows hosts install it themselves); Tailscale requires an installed and
66
+ * authenticated `tailscale` CLI.
42
67
  */
43
68
 
44
69
  const DEFAULT_TARGET = "127.0.0.1:8001";
@@ -55,6 +80,7 @@ interface UpOpts {
55
80
  visibility?: string;
56
81
  path?: string;
57
82
  httpsPort?: string;
83
+ allowPath?: string[];
58
84
  }
59
85
 
60
86
  interface DownOpts {
@@ -165,23 +191,144 @@ function resolveHttpsPort(raw: string | undefined): number {
165
191
  return port;
166
192
  }
167
193
 
194
+ /**
195
+ * The path scope for `up`. At least one prefix is required: a tunnel forwards
196
+ * to a whole local app, and publishing all of it has to be a stated choice
197
+ * (`--allow-path /`), never the result of leaving a flag off.
198
+ */
199
+ function resolveAllowPaths(raw: string[] | undefined): string[] {
200
+ let paths: string[];
201
+ try {
202
+ paths = normalizeAllowPaths(raw ?? []);
203
+ } catch (error) {
204
+ emit.error({
205
+ code: "tunnel_path_scope_invalid",
206
+ message: error instanceof Error ? error.message : String(error),
207
+ });
208
+ process.exit(1);
209
+ }
210
+ if (paths.length === 0) {
211
+ emit.error({
212
+ code: "tunnel_path_scope_empty",
213
+ message:
214
+ "No path scope; refusing to start. Name what the tunnel shares with --allow-path <prefix> " +
215
+ "(repeatable), or pass --allow-path / to publish the whole upstream.",
216
+ });
217
+ process.exit(1);
218
+ }
219
+ return paths;
220
+ }
221
+
222
+ function samePaths(a: readonly string[], b: readonly string[]): boolean {
223
+ return a.length === b.length && [...a].sort().join("\n") === [...b].sort().join("\n");
224
+ }
225
+
226
+ function formatPaths(paths: readonly string[]): string {
227
+ return paths.length === 0 ? "(none: every path refused)" : paths.join(", ");
228
+ }
229
+
230
+ function collectPath(value: string, previous: string[]): string[] {
231
+ return [...previous, value];
232
+ }
233
+
168
234
  function gateScriptPath(): string {
169
235
  return resolve(import.meta.dirname, "..", "lib", "tunnel", "gate.ts");
170
236
  }
171
237
 
238
+ /** The gate worker's `--name`/`--port` marker, also what the stray sweep matches. */
239
+ const GATE_SUBCOMMAND = ["tunnel", "gate"] as const;
240
+
241
+ export interface GateLaunch {
242
+ command: string;
243
+ /** Arguments before the gate's own `--name` and `--port`. */
244
+ arguments: string[];
245
+ }
246
+
247
+ export interface GateLaunchInputs {
248
+ /** Absolute path of `lib/tunnel/gate.ts` beside this file, whether or not it exists. */
249
+ gateScript: string;
250
+ gateScriptExists: boolean;
251
+ /** True when this process runs on Bun (so `execPath` is a Bun binary). */
252
+ underBun: boolean;
253
+ execPath: string;
254
+ /** The script this process was started with (`argv[1]`): the host CLI's entry or bundle. */
255
+ entryScript: string | undefined;
256
+ entryScriptExists: boolean;
257
+ bunOnPath: boolean;
258
+ }
259
+
260
+ /**
261
+ * How to start the gate worker, which is a `Bun.serve` process (HTTP and
262
+ * WebSocket reverse proxy):
263
+ * 1. From a source or package checkout, `gate.ts` sits beside this module and
264
+ * runs directly.
265
+ * 2. Inside a single-file bundle there is no such file. The CLI then
266
+ * re-executes itself with the hidden `tunnel gate` task, which loads the
267
+ * gate in-process. This uses the running Bun binary, so Bun need not be
268
+ * on PATH.
269
+ * Null means neither works (a Node host with no Bun, or an unknown entry).
270
+ */
271
+ export function resolveGateLaunch(i: GateLaunchInputs): GateLaunch | null {
272
+ if (i.gateScriptExists && (i.underBun || i.bunOnPath)) {
273
+ return {
274
+ command: i.underBun ? i.execPath : "bun",
275
+ arguments: ["run", i.gateScript],
276
+ };
277
+ }
278
+ if (i.underBun && i.entryScript && i.entryScriptExists) {
279
+ return { command: i.execPath, arguments: [i.entryScript, ...GATE_SUBCOMMAND] };
280
+ }
281
+ return null;
282
+ }
283
+
172
284
  /**
173
- * `harn tunnel` is the one command that hard-requires Bun: the gate worker is a
174
- * `Bun.serve` process (HTTP + WebSocket reverse proxy), spawned as `bun run
175
- * gate.ts`. Everything else in harnery runs on Node, but this can't until the
176
- * gate is ported off `Bun.serve` (node:http + `ws`). Detect Bun up front so the
177
- * failure is a clear message rather than an opaque ENOENT from the gate spawn.
285
+ * How to start the log wrapper that rotates each process's output. Beside a
286
+ * source or package checkout the default (this package's own wrapper file)
287
+ * works; inside a single-file bundle the CLI re-executes itself with the hidden
288
+ * `tunnel log-worker` task instead.
178
289
  */
179
- function bunAvailable(): boolean {
180
- return spawnSync("bun", ["--version"], { stdio: "ignore" }).status === 0;
290
+ export function resolveWorkerLaunch(i: GateLaunchInputs): GateLaunch | undefined {
291
+ if (i.gateScriptExists) return undefined;
292
+ if (i.underBun && i.entryScript && i.entryScriptExists) {
293
+ return { command: i.execPath, arguments: [i.entryScript, "tunnel", "log-worker"] };
294
+ }
295
+ return undefined;
296
+ }
297
+
298
+ function currentLaunchInputs(): GateLaunchInputs {
299
+ const gateScript = gateScriptPath();
300
+ const entryScript = process.argv[1] ? resolve(process.argv[1]) : undefined;
301
+ const underBun = typeof process.versions.bun === "string";
302
+ const gateScriptExists = existsSync(gateScript);
303
+ return {
304
+ gateScript,
305
+ gateScriptExists,
306
+ underBun,
307
+ execPath: process.execPath,
308
+ entryScript,
309
+ entryScriptExists: entryScript ? existsSync(entryScript) : false,
310
+ bunOnPath: !underBun && gateScriptExists && bunOnPath(),
311
+ };
312
+ }
313
+
314
+ function currentGateLaunch(): GateLaunch | null {
315
+ return resolveGateLaunch(currentLaunchInputs());
316
+ }
317
+
318
+ function currentWorkerLaunch(): GateLaunch | undefined {
319
+ return resolveWorkerLaunch(currentLaunchInputs());
181
320
  }
182
321
 
322
+ function bunOnPath(): boolean {
323
+ return spawnSync("bun", ["--version"], { stdio: "ignore", windowsHide: true }).status === 0;
324
+ }
325
+
326
+ const requiresBunMessage = () =>
327
+ `${resolveBinName()} tunnel requires Bun: the gate worker is a Bun.serve process. ` +
328
+ "Install Bun (https://bun.sh) and re-run. (Every other command runs on Node.)";
329
+
183
330
  function tailscaleAvailable(): boolean {
184
- return spawnSync("tailscale", ["version"], { stdio: "ignore" }).status === 0;
331
+ return spawnSync("tailscale", ["version"], { stdio: "ignore", windowsHide: true }).status === 0;
185
332
  }
186
333
 
187
334
  function sleep(ms: number): Promise<void> {
@@ -189,17 +336,15 @@ function sleep(ms: number): Promise<void> {
189
336
  }
190
337
 
191
338
  /**
192
- * Kill every process whose command line matches `pattern` (via `pgrep -f`),
193
- * skipping our own PID and any already-killed. Returns the count killed. Used
194
- * as a fallback so orphaned gate/cloudflared processes get cleaned even when
195
- * the state file was lost (which otherwise left them squatting on the port).
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).
196
344
  */
197
345
  function killByPattern(pattern: string, alreadyKilled: Set<number>): number {
198
- const r = spawnSync("pgrep", ["-f", pattern], { encoding: "utf-8" });
199
- if (r.status !== 0 || typeof r.stdout !== "string") return 0;
200
346
  let killed = 0;
201
- for (const line of r.stdout.split("\n")) {
202
- const pid = Number(line.trim());
347
+ for (const pid of findPidsByCommandLine(pattern)) {
203
348
  if (!pid || pid === process.pid || alreadyKilled.has(pid)) continue;
204
349
  try {
205
350
  process.kill(pid);
@@ -212,63 +357,49 @@ function killByPattern(pattern: string, alreadyKilled: Set<number>): number {
212
357
  return killed;
213
358
  }
214
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
+
215
370
  /**
216
371
  * Sweep stray gate + cloudflared processes for ONE instance, identified by its
217
372
  * gate port. Both signatures are port-scoped so tearing down one tunnel never
218
373
  * touches another:
219
- * - gate: `gate.ts ... --port <port>` (the port is on the gate's argv)
374
+ * - gate: see gatePattern (the port is on the gate's argv)
220
375
  * - cloudflared: `--url http://localhost:<port>` (order-independent, so it
221
376
  * matches regardless of the `--protocol http2` flag we also pass).
222
- * Port boundary is guarded with `( |$)` so port 9001 doesn't match 90011.
223
377
  */
224
378
  function sweepStrays(gatePort: number, alreadyKilled: Set<number>): number {
225
379
  return (
226
- killByPattern(`gate\\.ts.*--port ${gatePort}( |$)`, alreadyKilled) +
380
+ killByPattern(gatePattern(gatePort), alreadyKilled) +
227
381
  killByPattern(`--url http://localhost:${gatePort}( |$)`, alreadyKilled)
228
382
  );
229
383
  }
230
384
 
231
- /**
232
- * Ports currently bound by a LISTEN socket (best-effort, platform-dependent).
233
- *
234
- * `ss` (iproute2) is Linux-only. Without the lsof fallback this returns an empty
235
- * set on macOS/BSD. That does not fail loudly. It reports every port as free, so
236
- * `allocateGatePort` can hand out an occupied port and the reload port-release
237
- * check becomes inert.
238
- */
239
- function listeningPorts(): Set<number> {
240
- const ports = new Set<number>();
241
- const ss = spawnSync("ss", ["-tlnH"], { encoding: "utf-8" });
242
- if (ss.status === 0 && typeof ss.stdout === "string") {
243
- for (const m of ss.stdout.matchAll(/:(\d+)\s/g)) ports.add(Number(m[1]));
244
- return ports;
245
- }
246
- // Rows look like: `bun 123 user 4u IPv4 0x… 0t0 TCP 127.0.0.1:58055 (LISTEN)`.
247
- // Status can be nonzero while stdout still holds usable rows (lsof reports a
248
- // failure when any single process is unreadable), so judge it on the output.
249
- const lsof = spawnSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], { encoding: "utf-8" });
250
- if (typeof lsof.stdout === "string") {
251
- for (const m of lsof.stdout.matchAll(/:(\d+) \(LISTEN\)$/gm)) ports.add(Number(m[1]));
252
- }
253
- return ports;
254
- }
255
-
256
385
  /**
257
386
  * Pick a gate port for a new instance. An explicit `--gate-port` is honored
258
387
  * (and rejected if it's already taken); otherwise scan upward from 9001 for the
259
388
  * first port that's neither held by a live instance nor currently listening.
260
389
  */
261
- function allocateGatePort(preferred: number | undefined): number {
390
+ async function allocateGatePort(preferred: number | undefined): Promise<number> {
262
391
  const used = new Set<number>(
263
392
  listStates()
264
393
  .filter((s) => isProcessAlive(s.gate_pid))
265
394
  .map((s) => s.gate_port),
266
395
  );
396
+ // No discovery tool (null) is not "every port free": probe candidates by connecting.
267
397
  const listening = listeningPorts();
268
- const taken = (p: number) => used.has(p) || listening.has(p);
398
+ const taken = async (p: number) =>
399
+ used.has(p) || (listening ? listening.has(p) : await connectProbe(p));
269
400
 
270
401
  if (preferred !== undefined) {
271
- if (taken(preferred)) {
402
+ if (await taken(preferred)) {
272
403
  emit.error({
273
404
  code: "tunnel_port_taken",
274
405
  message: `Gate port ${preferred} is already in use. Omit --gate-port to auto-allocate, or pick a free one.`,
@@ -278,7 +409,7 @@ function allocateGatePort(preferred: number | undefined): number {
278
409
  return preferred;
279
410
  }
280
411
  for (let p = DEFAULT_GATE_PORT; p <= MAX_GATE_PORT; p++) {
281
- if (!taken(p)) return p;
412
+ if (!(await taken(p))) return p;
282
413
  }
283
414
  emit.error({
284
415
  code: "tunnel_no_free_port",
@@ -444,6 +575,7 @@ interface GateSpawnOpts {
444
575
  vhost: string;
445
576
  provider: TunnelProvider;
446
577
  allowedIps: string[];
578
+ allowPaths: string[];
447
579
  gateLogPath: string;
448
580
  }
449
581
 
@@ -455,13 +587,14 @@ interface GateSpawnOpts {
455
587
  * is passed as an env var, which the gate snapshots at module load and never
456
588
  * re-reads — that snapshot is precisely why `reload` has to exist.
457
589
  */
458
- function spawnGate(o: GateSpawnOpts): ChildProcess {
590
+ function spawnGate(o: GateSpawnOpts, launch: GateLaunch): ChildProcess {
459
591
  // `--name`/`--port` on argv mirror the env vars; they're what makes the gate
460
- // process distinguishable per-instance in `pgrep -f` (see sweepStrays).
592
+ // process distinguishable per-instance in a command-line search (see sweepStrays).
461
593
  const gateProc = spawnRotatingProcess({
462
594
  path: o.gateLogPath,
463
- command: "bun",
464
- arguments: ["run", gateScriptPath(), "--name", o.name, "--port", String(o.gatePort)],
595
+ command: launch.command,
596
+ arguments: [...launch.arguments, "--name", o.name, "--port", String(o.gatePort)],
597
+ worker: currentWorkerLaunch(),
465
598
  env: {
466
599
  ...process.env,
467
600
  HARNERY_TUNNEL_ALLOW: o.allowedIps.join(","),
@@ -470,6 +603,7 @@ function spawnGate(o: GateSpawnOpts): ChildProcess {
470
603
  HARNERY_TUNNEL_TARGET: o.target,
471
604
  HARNERY_TUNNEL_VHOST: o.vhost,
472
605
  HARNERY_TUNNEL_PORT: String(o.gatePort),
606
+ [ALLOW_PATHS_ENV]: o.allowPaths.join(","),
473
607
  },
474
608
  });
475
609
  gateProc.unref();
@@ -477,13 +611,9 @@ function spawnGate(o: GateSpawnOpts): ChildProcess {
477
611
  }
478
612
 
479
613
  async function up(opts: UpOpts): Promise<void> {
480
- if (!bunAvailable()) {
481
- emit.error({
482
- code: "tunnel_requires_bun",
483
- message:
484
- "harn tunnel requires Bun: the gate worker is a Bun.serve process. " +
485
- "Install Bun (https://bun.sh) and re-run. (Every other harn command runs on Node.)",
486
- });
614
+ const launch = currentGateLaunch();
615
+ if (!launch) {
616
+ emit.error({ code: "tunnel_requires_bun", message: requiresBunMessage() });
487
617
  process.exit(1);
488
618
  }
489
619
  const name = resolveName(opts.name);
@@ -498,6 +628,7 @@ async function up(opts: UpOpts): Promise<void> {
498
628
  provider === "tailscale" ? resolveTailscalePath(opts.path, name) : undefined;
499
629
  const tailscaleHttpsPort =
500
630
  provider === "tailscale" ? resolveHttpsPort(opts.httpsPort) : undefined;
631
+ const allowPaths = resolveAllowPaths(opts.allowPath);
501
632
 
502
633
  const existing = readState(name);
503
634
  if (existing && tunnelIsAlive(existing)) {
@@ -508,16 +639,26 @@ async function up(opts: UpOpts): Promise<void> {
508
639
  });
509
640
  process.exit(1);
510
641
  }
642
+ if (!samePaths(existing.allow_paths, allowPaths)) {
643
+ emit.error({
644
+ code: "tunnel_instance_in_use",
645
+ message:
646
+ `Tunnel [${name}] is already up sharing ${formatPaths(existing.allow_paths)}. ` +
647
+ `Stop it before starting it with ${formatPaths(allowPaths)}.`,
648
+ });
649
+ process.exit(1);
650
+ }
511
651
  emit.text(`Already up [${name}]: ${existing.url}\n`);
512
652
  emit.text(` Provider: ${existing.provider}\n`);
513
653
  emit.text(` Forwarding: ${existing.target} (Host: ${existing.vhost})\n`);
654
+ emit.text(` Paths: ${formatPaths(existing.allow_paths)}\n`);
514
655
  return;
515
656
  }
516
657
  if (existing) downOne(name, new Set());
517
658
 
518
659
  // Allocate the gate port (after clearing dead state so its old port frees up
519
660
  // for reuse). Explicit --gate-port is validated; otherwise auto-scan.
520
- const gatePort = allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
661
+ const gatePort = await allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
521
662
 
522
663
  // Self-heal: clear any orphaned gate/cloudflared on THIS instance's port from
523
664
  // a prior crashed or state-cleared run so the gate port is free before we bind.
@@ -527,12 +668,23 @@ async function up(opts: UpOpts): Promise<void> {
527
668
  if (provider === "cloudflare" && cfg.allowed_ips.length === 0) {
528
669
  emit.error({
529
670
  code: "tunnel_allowlist_empty",
530
- message: "Allowlist is empty; refusing to start. Add an IP first: harn tunnel allow add <ip>",
671
+ message: `Allowlist is empty; refusing to start. Add an IP first: ${resolveBinName()} tunnel allow add <ip> (or --current)`,
531
672
  });
532
673
  process.exit(1);
533
674
  }
534
675
 
535
- const cloudflaredBin = provider === "cloudflare" ? ensureCloudflared() : null;
676
+ let cloudflaredBin: string | null = null;
677
+ if (provider === "cloudflare") {
678
+ try {
679
+ cloudflaredBin = ensureCloudflared();
680
+ } catch (error) {
681
+ emit.error({
682
+ code: "tunnel_cloudflared_missing",
683
+ message: error instanceof Error ? error.message : String(error),
684
+ });
685
+ process.exit(1);
686
+ }
687
+ }
536
688
  if (provider === "tailscale" && !tailscaleAvailable()) {
537
689
  emit.error({
538
690
  code: "tunnel_tailscale_missing",
@@ -545,19 +697,31 @@ async function up(opts: UpOpts): Promise<void> {
545
697
  const gateLogPath = logPaths.gate;
546
698
  const providerLogPath = logPaths.provider;
547
699
 
548
- const gateProc = spawnGate({
549
- name,
550
- gatePort,
551
- target,
552
- vhost,
553
- provider,
554
- allowedIps: cfg.allowed_ips,
555
- gateLogPath,
556
- });
700
+ const gateProc = spawnGate(
701
+ {
702
+ name,
703
+ gatePort,
704
+ target,
705
+ vhost,
706
+ provider,
707
+ allowedIps: cfg.allowed_ips,
708
+ allowPaths,
709
+ gateLogPath,
710
+ },
711
+ launch,
712
+ );
557
713
 
558
714
  await sleep(800);
559
715
 
560
- if (!isProcessAlive(gateProc.pid!)) {
716
+ // A host CLI that re-executes itself takes longer to load than a bare script,
717
+ // so wait for the gate to actually listen before the provider forwards to it.
718
+ 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());
561
725
  emit.error({
562
726
  code: "tunnel_gate_failed",
563
727
  message: `Gate failed to start. Check log: ${gateLogPath}`,
@@ -579,6 +743,7 @@ async function up(opts: UpOpts): Promise<void> {
579
743
  path: providerLogPath,
580
744
  command: cloudflaredBin!,
581
745
  arguments: ["tunnel", "--protocol", "http2", "--url", `http://localhost:${gatePort}`],
746
+ worker: currentWorkerLaunch(),
582
747
  });
583
748
  cfdProc.unref();
584
749
  cloudflaredPid = cfdProc.pid!;
@@ -630,18 +795,22 @@ async function up(opts: UpOpts): Promise<void> {
630
795
  target,
631
796
  vhost,
632
797
  gate_port: gatePort,
798
+ allow_paths: allowPaths,
633
799
  tailscale_mode: tailscaleMode,
634
800
  tailscale_path: tailscalePath,
635
801
  tailscale_https_port: tailscaleHttpsPort,
636
802
  };
637
803
  writeState(state);
804
+ recordTunnelServer(state);
638
805
 
806
+ const bin = resolveBinName();
639
807
  const stopHint =
640
- name === DEFAULT_INSTANCE ? "harn tunnel down" : `harn tunnel down --name ${name}`;
808
+ name === DEFAULT_INSTANCE ? `${bin} tunnel down` : `${bin} tunnel down --name ${name}`;
641
809
  emit.text(`\n Instance: ${name}\n`);
642
810
  emit.text(` Provider: ${provider}${tailscaleMode ? ` (${tailscaleMode})` : ""}\n`);
643
811
  emit.text(` URL: ${url}\n\n`);
644
812
  emit.text(` Forwarding: ${target} (Host: ${vhost})\n`);
813
+ emit.text(` Paths: ${formatPaths(allowPaths)}\n`);
645
814
  emit.text(` Gate port: ${gatePort}\n`);
646
815
  if (provider === "cloudflare") {
647
816
  emit.text(` Allowed IPs: ${cfg.allowed_ips.join(", ")}\n\n`);
@@ -655,15 +824,31 @@ async function up(opts: UpOpts): Promise<void> {
655
824
  }
656
825
  if (!registered) {
657
826
  emit.text(
658
- ` ⚠ Edge connection didn't register within 30s (QUIC can wedge on a cold\n start). If the URL 404s or times out, bounce it: ${stopHint} && harn tunnel up\n\n`,
827
+ ` ⚠ Edge connection didn't register within 30s (QUIC can wedge on a cold\n start). If the URL 404s or times out, bounce it: ${stopHint} && ${bin} tunnel up\n\n`,
659
828
  );
660
829
  }
661
830
  emit.text(` Stop: ${stopHint}\n`);
662
- emit.text(" Status: harn tunnel status\n");
831
+ emit.text(` Status: ${bin} tunnel status\n`);
832
+ }
833
+
834
+ /** The server registry lists tunnels beside every other local server. */
835
+ function registryRoot(): string {
836
+ return resolveCoordRoot() ?? process.cwd();
837
+ }
838
+
839
+ function recordTunnelServer(state: TunnelState): void {
840
+ const coordRoot = registryRoot();
841
+ tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
663
842
  }
664
843
 
665
844
  /** Tear down a single instance by name. Returns the number of processes killed. */
666
- function downOne(name: string, killed: Set<number>): number {
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),
848
+ );
849
+ }
850
+
851
+ export function downOne(name: string, killed: Set<number>): number {
667
852
  const before = killed.size;
668
853
  const state = readState(name);
669
854
  if (state) {
@@ -681,9 +866,13 @@ function downOne(name: string, killed: Set<number>): number {
681
866
  }
682
867
  }
683
868
  // Fallback: sweep orphans on this instance's gate port, even when state was
684
- // lost; they'd otherwise squat on the port and break the next `up`.
685
- sweepStrays(state?.gate_port ?? DEFAULT_GATE_PORT, killed);
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);
686
874
  clearState(name);
875
+ unregisterServer(tunnelServerId(name), { coordRoot: registryRoot() });
687
876
  return killed.size - before;
688
877
  }
689
878
 
@@ -710,7 +899,7 @@ function down(opts: DownOpts): void {
710
899
  const others = listStates();
711
900
  if (others.length > 0) {
712
901
  emit.text(
713
- `No default tunnel running. Other tunnels up: ${others.map((s) => s.name).join(", ")}.\nUse \`harn tunnel down --name <name>\` or \`harn tunnel down --all\`.\n`,
902
+ `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`,
714
903
  );
715
904
  return;
716
905
  }
@@ -728,20 +917,20 @@ function down(opts: DownOpts): void {
728
917
  async function waitForPortFree(port: number, timeoutMs: number): Promise<boolean> {
729
918
  const deadline = Date.now() + timeoutMs;
730
919
  while (Date.now() < deadline) {
731
- if (!listeningPorts().has(port)) return true;
920
+ if (!(await portListening(port))) return true;
732
921
  await sleep(100);
733
922
  }
734
- return !listeningPorts().has(port);
923
+ return !(await portListening(port));
735
924
  }
736
925
 
737
926
  /** Poll until `port` has a LISTEN socket (or the deadline passes). */
738
927
  async function waitForPortBound(port: number, timeoutMs: number): Promise<boolean> {
739
928
  const deadline = Date.now() + timeoutMs;
740
929
  while (Date.now() < deadline) {
741
- if (listeningPorts().has(port)) return true;
930
+ if (await portListening(port)) return true;
742
931
  await sleep(100);
743
932
  }
744
- return listeningPorts().has(port);
933
+ return portListening(port);
745
934
  }
746
935
 
747
936
  /**
@@ -768,8 +957,21 @@ export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; mess
768
957
  `Reloading the gate can't bring it back — run \`${bin} tunnel up --name ${name}\`.`,
769
958
  };
770
959
  }
771
- if (!bunAvailable()) {
772
- return { ok: false, message: `[${name}] bun is not on PATH, so the gate can't be respawned.` };
960
+ const launch = currentGateLaunch();
961
+ if (!launch) {
962
+ return {
963
+ ok: false,
964
+ message: `[${name}] Bun is not available, so the gate can't be respawned.`,
965
+ };
966
+ }
967
+ if (state.allow_paths.length === 0) {
968
+ return {
969
+ ok: false,
970
+ message:
971
+ `[${name}] has no recorded path scope, so a reloaded gate would refuse every request. ` +
972
+ `Restart it with \`${bin} tunnel down --name ${name}\` and ` +
973
+ `\`${bin} tunnel up --name ${name} --allow-path <prefix>\`.`,
974
+ };
773
975
  }
774
976
 
775
977
  // Kill ONLY the gate. sweepStrays() is deliberately avoided here: it also
@@ -784,7 +986,7 @@ export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; mess
784
986
  /* race: already gone */
785
987
  }
786
988
  }
787
- killByPattern(`gate\\.ts.*--port ${gatePort}( |$)`, killed);
989
+ killByPattern(gatePattern(gatePort), killed);
788
990
 
789
991
  if (!(await waitForPortFree(gatePort, 5_000))) {
790
992
  return {
@@ -796,22 +998,26 @@ export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; mess
796
998
  }
797
999
 
798
1000
  const cfg = readConfig();
799
- const gateProc = spawnGate({
800
- name,
801
- gatePort,
802
- target: state.target,
803
- vhost: state.vhost,
804
- provider: state.provider,
805
- allowedIps: cfg.allowed_ips,
806
- gateLogPath: tunnelLogDestinations(name, state.provider).gate,
807
- });
1001
+ const gateProc = spawnGate(
1002
+ {
1003
+ name,
1004
+ gatePort,
1005
+ target: state.target,
1006
+ vhost: state.vhost,
1007
+ provider: state.provider,
1008
+ allowedIps: cfg.allowed_ips,
1009
+ allowPaths: state.allow_paths,
1010
+ gateLogPath: tunnelLogDestinations(name, state.provider).gate,
1011
+ },
1012
+ launch,
1013
+ );
808
1014
 
809
1015
  await sleep(800);
810
1016
  const pid = gateProc.pid;
811
1017
  if (
812
1018
  typeof pid !== "number" ||
813
1019
  !isProcessAlive(pid) ||
814
- !(await waitForPortBound(gatePort, 5_000))
1020
+ !(await waitForPortBound(gatePort, 15_000))
815
1021
  ) {
816
1022
  return {
817
1023
  ok: false,
@@ -822,6 +1028,7 @@ export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; mess
822
1028
  }
823
1029
 
824
1030
  writeState({ ...state, gate_pid: pid });
1031
+ recordTunnelServer({ ...state, gate_pid: pid });
825
1032
  const allowNote =
826
1033
  state.provider === "cloudflare" ? ` Allowlist: ${cfg.allowed_ips.length} IP(s).` : "";
827
1034
  return { ok: true, message: `[${name}] gate reloaded, URL unchanged: ${state.url}.${allowNote}` };
@@ -898,6 +1105,7 @@ function statusDetail(state: TunnelState): void {
898
1105
  );
899
1106
  emit.text(` URL: ${state.url}\n`);
900
1107
  emit.text(` Forwarding: ${state.target} (Host: ${state.vhost})\n`);
1108
+ emit.text(` Paths: ${formatPaths(state.allow_paths)}\n`);
901
1109
  emit.text(` Gate port: ${state.gate_port}\n`);
902
1110
  if (state.provider === "cloudflare") {
903
1111
  emit.text(` Allowed IPs: ${cfg.allowed_ips.join(", ")}\n`);
@@ -941,6 +1149,7 @@ function status(opts: StatusOpts): void {
941
1149
  state: instanceState(s),
942
1150
  url: s.url,
943
1151
  fwd: `${s.target} (${s.vhost})`,
1152
+ paths: formatPaths(s.allow_paths),
944
1153
  port: String(s.gate_port),
945
1154
  up: fmtUptime(s.started_at),
946
1155
  }));
@@ -950,15 +1159,16 @@ function status(opts: StatusOpts): void {
950
1159
  state: 5,
951
1160
  url: Math.max(3, ...rows.map((r) => r.url.length)),
952
1161
  fwd: Math.max(10, ...rows.map((r) => r.fwd.length)),
1162
+ paths: Math.max(5, ...rows.map((r) => r.paths.length)),
953
1163
  port: 4,
954
1164
  };
955
1165
  const pad = (s: string, n: number) => s.padEnd(n);
956
1166
  emit.text(
957
- `${pad("NAME", w.name)} ${pad("PROVIDER", w.provider)} ${pad("STATE", w.state)} ${pad("URL", w.url)} ${pad("FORWARDING", w.fwd)} ${pad("PORT", w.port)} UPTIME\n`,
1167
+ `${pad("NAME", w.name)} ${pad("PROVIDER", w.provider)} ${pad("STATE", w.state)} ${pad("URL", w.url)} ${pad("FORWARDING", w.fwd)} ${pad("PATHS", w.paths)} ${pad("PORT", w.port)} UPTIME\n`,
958
1168
  );
959
1169
  for (const r of rows) {
960
1170
  emit.text(
961
- `${pad(r.name, w.name)} ${pad(r.provider, w.provider)} ${pad(r.state, w.state)} ${pad(r.url, w.url)} ${pad(r.fwd, w.fwd)} ${pad(r.port, w.port)} ${r.up}\n`,
1171
+ `${pad(r.name, w.name)} ${pad(r.provider, w.provider)} ${pad(r.state, w.state)} ${pad(r.url, w.url)} ${pad(r.fwd, w.fwd)} ${pad(r.paths, w.paths)} ${pad(r.port, w.port)} ${r.up}\n`,
962
1172
  );
963
1173
  }
964
1174
  }
@@ -984,9 +1194,16 @@ function allowList(): void {
984
1194
  emit.text("(empty)\n");
985
1195
  return;
986
1196
  }
987
- for (const ip of cfg.allowed_ips) emit.text(`${ip}\n`);
1197
+ const automatic = cfg.auto_allowed ?? [];
1198
+ for (const ip of cfg.allowed_ips) {
1199
+ emit.text(`${ip}${sameEntryIn(automatic, ip) ? " (automatic)" : ""}\n`);
1200
+ }
988
1201
  }
989
1202
 
1203
+ const canonicalEntry = (entry: string) => normalizeAllowEntry(entry) ?? entry;
1204
+ const sameEntryIn = (list: readonly string[], entry: string) =>
1205
+ list.some((e) => canonicalEntry(e) === canonicalEntry(entry));
1206
+
990
1207
  /**
991
1208
  * Trailer for `allow add`/`allow rm`. Each gate snapshots the allowlist at
992
1209
  * spawn, so a config edit alone changes nothing until the gates restart.
@@ -1004,31 +1221,156 @@ function allowChangedHint(): void {
1004
1221
  );
1005
1222
  }
1006
1223
 
1007
- function allowAdd(ip: string): void {
1224
+ interface AllowAddOpts {
1225
+ current?: boolean;
1226
+ }
1227
+
1228
+ async function allowAdd(entry: string | undefined, opts: AllowAddOpts = {}): Promise<void> {
1229
+ if (opts.current) {
1230
+ if (entry) {
1231
+ emit.error({
1232
+ code: "tunnel_allow_current_with_entry",
1233
+ message: "Pass either an address or --current, not both.",
1234
+ });
1235
+ process.exit(1);
1236
+ }
1237
+ const result = await refreshCurrentAddress({ emit, reload: true });
1238
+ if (!result.ok) process.exit(1);
1239
+ return;
1240
+ }
1241
+ if (!entry) {
1242
+ emit.error({
1243
+ code: "tunnel_allow_entry_missing",
1244
+ message: `Name an address or range to allow, or use --current: ${resolveBinName()} tunnel allow add <ip|cidr>`,
1245
+ });
1246
+ process.exit(1);
1247
+ }
1248
+ const canonical = normalizeAllowEntry(entry);
1249
+ if (!canonical) {
1250
+ emit.error({
1251
+ code: "tunnel_allow_entry_invalid",
1252
+ message:
1253
+ `"${entry}" is not an IP address or CIDR range. ` +
1254
+ "Examples: 203.0.113.8, 203.0.113.0/24, 2601:db8:1:2::/64.",
1255
+ });
1256
+ process.exit(1);
1257
+ }
1008
1258
  const cfg = readConfig();
1009
- if (cfg.allowed_ips.includes(ip)) {
1010
- emit.text(`${ip} already in allowlist.\n`);
1259
+ if (sameEntryIn(cfg.allowed_ips, canonical)) {
1260
+ // Adding by hand an entry the refresh owns adopts it: later refreshes keep it.
1261
+ if (cfg.auto_allowed && sameEntryIn(cfg.auto_allowed, canonical)) {
1262
+ cfg.auto_allowed = cfg.auto_allowed.filter((e) => canonicalEntry(e) !== canonical);
1263
+ writeConfig(cfg);
1264
+ emit.text(`${canonical} is now a manual entry; automatic refreshes will keep it.\n`);
1265
+ return;
1266
+ }
1267
+ emit.text(`${canonical} already in allowlist.\n`);
1011
1268
  return;
1012
1269
  }
1013
- cfg.allowed_ips.push(ip);
1270
+ cfg.allowed_ips.push(canonical);
1014
1271
  writeConfig(cfg);
1015
- emit.text(`Added ${ip}.\n`);
1272
+ emit.text(`Added ${canonical}.\n`);
1016
1273
  allowChangedHint();
1017
1274
  }
1018
1275
 
1019
- function allowRm(ip: string): void {
1276
+ function allowRm(entry: string): void {
1020
1277
  const cfg = readConfig();
1021
- const idx = cfg.allowed_ips.indexOf(ip);
1278
+ const wanted = canonicalEntry(entry);
1279
+ const idx = cfg.allowed_ips.findIndex((e) => e === entry || canonicalEntry(e) === wanted);
1022
1280
  if (idx === -1) {
1023
- emit.text(`${ip} not in allowlist.\n`);
1281
+ emit.text(`${entry} not in allowlist.\n`);
1024
1282
  return;
1025
1283
  }
1026
- cfg.allowed_ips.splice(idx, 1);
1284
+ const [removed] = cfg.allowed_ips.splice(idx, 1);
1285
+ if (cfg.auto_allowed) {
1286
+ cfg.auto_allowed = cfg.auto_allowed.filter(
1287
+ (e) => canonicalEntry(e) !== canonicalEntry(removed as string),
1288
+ );
1289
+ }
1027
1290
  writeConfig(cfg);
1028
- emit.text(`Removed ${ip}.\n`);
1291
+ emit.text(`Removed ${removed}.\n`);
1029
1292
  allowChangedHint();
1030
1293
  }
1031
1294
 
1295
+ export interface RefreshCurrentOptions {
1296
+ emit?: EmitContext;
1297
+ /** Reload running Cloudflare gates after an allowlist change, keeping their URLs. */
1298
+ reload: boolean;
1299
+ /** Speak only when the allowlist changed or no address could be detected. */
1300
+ quiet?: boolean;
1301
+ /** Lookup timeout and retries; a sync that must not stall offline passes small ones. */
1302
+ detectOptions?: DetectOptions;
1303
+ /** Replaces the lookup (tests). */
1304
+ detect?: typeof detectPublicAddresses;
1305
+ }
1306
+
1307
+ export interface RefreshCurrentResult {
1308
+ /** False only when a requested reload of a live gate failed. A failed lookup is a warning, not a failure. */
1309
+ ok: boolean;
1310
+ /** The allowlist gained or lost an entry. */
1311
+ changed: boolean;
1312
+ /** Entries newly allowed. */
1313
+ added: string[];
1314
+ removed: string[];
1315
+ warnings: string[];
1316
+ }
1317
+
1318
+ /**
1319
+ * `tunnel allow add --current`: allow this machine's own public addresses, as
1320
+ * Cloudflare sees them, and drop the ones allowed by the previous refresh.
1321
+ * Exported so a host CLI can run it before `tunnel up` and from its own sync.
1322
+ */
1323
+ export async function refreshCurrentAddress(
1324
+ options: RefreshCurrentOptions,
1325
+ ): Promise<RefreshCurrentResult> {
1326
+ if (options.emit) emit = options.emit;
1327
+ const detected = await (options.detect ?? detectPublicAddresses)(options.detectOptions);
1328
+ const cfg = readConfig();
1329
+ const plan = planCurrentAddresses(cfg, detected);
1330
+ const noAddress = !detected.v4 && !detected.v6;
1331
+ // Quiet runs (a host's sync) speak only when something changed or nothing
1332
+ // could be detected; "no IPv6 on this network" is not news every time.
1333
+ if (!options.quiet || noAddress || plan.changed) {
1334
+ for (const warning of plan.warnings) emit.text(` ⚠ ${warning}\n`);
1335
+ }
1336
+ const result: RefreshCurrentResult = {
1337
+ ok: true,
1338
+ changed: plan.changed,
1339
+ added: plan.added,
1340
+ removed: plan.removed,
1341
+ warnings: plan.warnings,
1342
+ };
1343
+ if (noAddress) return result;
1344
+
1345
+ writeConfig(plan.config);
1346
+ if (!options.quiet || plan.changed) {
1347
+ const found = [detected.v4, detected.v6].filter(Boolean).join(", ");
1348
+ emit.text(`This machine reaches Cloudflare as ${found}.\n`);
1349
+ emit.text(
1350
+ plan.changed
1351
+ ? `Allowlist updated${plan.added.length ? `: added ${plan.added.join(", ")}` : ""}${plan.removed.length ? `; removed ${plan.removed.join(", ")}` : ""}.\n`
1352
+ : "Allowlist already covers this machine.\n",
1353
+ );
1354
+ }
1355
+ if (!plan.changed || !options.reload) return result;
1356
+
1357
+ const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
1358
+ for (const s of live) {
1359
+ const r = await reloadOne(s);
1360
+ emit.text(`${r.ok ? "ok " : "FAIL"} ${r.message}\n`);
1361
+ if (!r.ok) result.ok = false;
1362
+ }
1363
+ return result;
1364
+ }
1365
+
1366
+ /** Hidden task: run the gate worker in this process (the self-launch form of the gate). */
1367
+ async function runGateInProcess(): Promise<void> {
1368
+ await import("../lib/tunnel/gate.ts");
1369
+ // The gate's server keeps the process alive. Never resolve, so a host's
1370
+ // post-command exit or output finalization cannot end the worker.
1371
+ await new Promise<never>(() => {});
1372
+ }
1373
+
1032
1374
  let emit: EmitContext;
1033
1375
  let context: HarneryProgramContext | undefined;
1034
1376
 
@@ -1071,6 +1413,13 @@ export function registerTunnelCommand(
1071
1413
  "Tailscale only: URL path mount (default: / for default, /<name> for named instances)",
1072
1414
  )
1073
1415
  .option("--https-port <port>", "Tailscale only: HTTPS listen port (default: 443)", "443")
1416
+ .option(
1417
+ "--allow-path <prefix>",
1418
+ "URL path prefix the tunnel shares (repeatable, required). Every other path is refused; " +
1419
+ "pass / to publish the whole upstream.",
1420
+ collectPath,
1421
+ [],
1422
+ )
1074
1423
  .action(up);
1075
1424
 
1076
1425
  cmd
@@ -1106,9 +1455,38 @@ export function registerTunnelCommand(
1106
1455
 
1107
1456
  const allow = cmd
1108
1457
  .command("allow")
1109
- .description("Manage the Cloudflare CF-Connecting-IP allowlist")
1458
+ .description(
1459
+ "Manage the Cloudflare CF-Connecting-IP allowlist (addresses and CIDR ranges, IPv4 and IPv6)",
1460
+ )
1110
1461
  .action(allowList);
1111
- allow.command("add <ip>").description("Add an IP to the allowlist").action(allowAdd);
1112
- allow.command("rm <ip>").description("Remove an IP from the allowlist").action(allowRm);
1113
- allow.command("list").description("List allowed IPs (default action)").action(allowList);
1462
+ allow
1463
+ .command("add [entry]")
1464
+ .description(
1465
+ "Add an IP address or CIDR range (203.0.113.0/24, 2601:db8:1:2::/64), or --current for this machine",
1466
+ )
1467
+ .option(
1468
+ "--current",
1469
+ "allow this machine's public addresses as Cloudflare sees them (IPv4 /32 and IPv6 /64), " +
1470
+ "replace the ones the previous --current added, and reload running gates",
1471
+ )
1472
+ .action(allowAdd);
1473
+ allow
1474
+ .command("rm <entry>")
1475
+ .description("Remove an address or range from the allowlist")
1476
+ .action(allowRm);
1477
+ allow.command("list").description("List allowed entries (default action)").action(allowList);
1478
+
1479
+ cmd
1480
+ .command("log-worker <specification>", { hidden: true })
1481
+ .description("Internal: run the output-rotating wrapper for one tunnel process")
1482
+ .action(async (specification: string) => {
1483
+ await runProcessLogWorker(specification);
1484
+ });
1485
+
1486
+ cmd
1487
+ .command("gate", { hidden: true })
1488
+ .description("Internal: run the gate worker in this process")
1489
+ .option("--name <name>", "instance name")
1490
+ .option("--port <port>", "port to listen on")
1491
+ .action(runGateInProcess);
1114
1492
  }