@mycadet/cli 0.12.7 → 0.12.9-dev.202610131452.9acab64

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 (185) hide show
  1. package/README.md +2 -2
  2. package/knip.json +1 -1
  3. package/node_modules/@mycadet/assistant-api/events/cloud-disconnected.ts +21 -0
  4. package/node_modules/@mycadet/assistant-api/events/host-bash.ts +1 -1
  5. package/node_modules/@mycadet/assistant-api/events/host-cu.ts +1 -1
  6. package/node_modules/@mycadet/assistant-api/events/host-file.ts +1 -1
  7. package/node_modules/@mycadet/assistant-api/events/notification-intent.ts +1 -1
  8. package/node_modules/@mycadet/assistant-api/events/show-cloud-login.ts +18 -0
  9. package/node_modules/@mycadet/assistant-api/index.ts +11 -11
  10. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/package.json +2 -2
  11. package/node_modules/@mycadet/{service-contracts/src/platform-credential.ts → assistant-api/node_modules/@mycadet/service-contracts/src/cloud-credential.ts} +9 -9
  12. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/handles.ts +3 -3
  13. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/index.ts +1 -1
  14. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +11 -5
  15. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/remote-web-pairing.ts +1 -1
  16. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/rpc.ts +14 -14
  17. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/trust-rules.ts +5 -5
  18. package/node_modules/@mycadet/assistant-api/responses/home.ts +1 -1
  19. package/node_modules/@mycadet/assistant-api/surfaces.ts +4 -4
  20. package/node_modules/@mycadet/avatar-manifest/src/__tests__/notification-avatar.test.ts +1 -1
  21. package/node_modules/@mycadet/avatar-manifest/src/notification-avatar.ts +2 -2
  22. package/node_modules/@mycadet/environments/src/seeds.ts +6 -6
  23. package/node_modules/@mycadet/environments/src/shell.test.ts +1 -1
  24. package/node_modules/@mycadet/environments/src/types.ts +6 -6
  25. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/avatar-manifest/src/__tests__/notification-avatar.test.ts +1 -1
  26. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/avatar-manifest/src/notification-avatar.ts +2 -2
  27. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/environments/src/seeds.ts +6 -6
  28. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/environments/src/shell.test.ts +1 -1
  29. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/environments/src/types.ts +6 -6
  30. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/package.json +2 -2
  31. package/node_modules/@mycadet/{assistant-api/node_modules/@mycadet/service-contracts/src/platform-credential.ts → local-mode/node_modules/@mycadet/service-contracts/src/cloud-credential.ts} +9 -9
  32. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/handles.ts +3 -3
  33. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/index.ts +1 -1
  34. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +11 -5
  35. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/remote-web-pairing.ts +1 -1
  36. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/rpc.ts +14 -14
  37. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/trust-rules.ts +5 -5
  38. package/node_modules/@mycadet/local-mode/src/__tests__/pair.test.ts +5 -5
  39. package/node_modules/@mycadet/local-mode/src/__tests__/retire.test.ts +1 -1
  40. package/node_modules/@mycadet/local-mode/src/config.ts +2 -2
  41. package/node_modules/@mycadet/local-mode/src/index.ts +1 -1
  42. package/node_modules/@mycadet/local-mode/src/lockfile-contract.test.ts +3 -3
  43. package/node_modules/@mycadet/local-mode/src/lockfile-contract.ts +10 -5
  44. package/node_modules/@mycadet/local-mode/src/lockfile.test.ts +15 -15
  45. package/node_modules/@mycadet/local-mode/src/lockfile.ts +9 -9
  46. package/node_modules/@mycadet/local-mode/src/pair.ts +2 -2
  47. package/node_modules/@mycadet/local-mode/src/retire.ts +3 -3
  48. package/node_modules/@mycadet/local-mode/src/teleport-backup-policy.ts +2 -2
  49. package/node_modules/@mycadet/local-mode/src/unpair.ts +2 -2
  50. package/node_modules/@mycadet/service-contracts/package.json +2 -2
  51. package/node_modules/@mycadet/{local-mode/node_modules/@mycadet/service-contracts/src/platform-credential.ts → service-contracts/src/cloud-credential.ts} +9 -9
  52. package/node_modules/@mycadet/service-contracts/src/handles.ts +3 -3
  53. package/node_modules/@mycadet/service-contracts/src/index.ts +1 -1
  54. package/node_modules/@mycadet/service-contracts/src/ingress.ts +11 -5
  55. package/node_modules/@mycadet/service-contracts/src/remote-web-pairing.ts +1 -1
  56. package/node_modules/@mycadet/service-contracts/src/rpc.ts +14 -14
  57. package/node_modules/@mycadet/service-contracts/src/trust-rules.ts +5 -5
  58. package/package.json +6 -4
  59. package/src/__tests__/assistant-api-key-resolution.test.ts +6 -6
  60. package/src/__tests__/{assistant-client-platform-auth.test.ts → assistant-client-cloud-auth.test.ts} +10 -10
  61. package/src/__tests__/assistant-client-refresh.test.ts +2 -2
  62. package/src/__tests__/backup.test.ts +86 -91
  63. package/src/__tests__/client-token.test.ts +1 -1
  64. package/src/__tests__/client-tui-refresh.test.ts +1 -1
  65. package/src/__tests__/client-web-config.test.ts +1 -2
  66. package/src/__tests__/{platform-client.test.ts → cloud-client.test.ts} +59 -59
  67. package/src/__tests__/{platform-releases.test.ts → cloud-releases.test.ts} +28 -18
  68. package/src/__tests__/{cloudflare-tunnel.test.ts → cloudflare.test.ts} +1 -1
  69. package/src/__tests__/connect-import.test.ts +55 -39
  70. package/src/__tests__/debug.test.ts +38 -38
  71. package/src/__tests__/ensure-binary.test.ts +182 -0
  72. package/src/__tests__/guardian-token.test.ts +149 -56
  73. package/src/__tests__/hatch-provider-secrets.test.ts +2 -2
  74. package/src/__tests__/helpers/guardian-token.ts +22 -0
  75. package/src/__tests__/helpers/http.ts +10 -0
  76. package/src/__tests__/nginx-ingress.test.ts +15 -20
  77. package/src/__tests__/ngrok-spawn.test.ts +49 -0
  78. package/src/__tests__/orphan-detection.test.ts +1 -1
  79. package/src/__tests__/pair.test.ts +149 -85
  80. package/src/__tests__/{ps-platform-status.test.ts → ps-cloud-status.test.ts} +80 -26
  81. package/src/__tests__/ps-table.test.ts +79 -0
  82. package/src/__tests__/{recover.test.ts → restore.test.ts} +29 -28
  83. package/src/__tests__/retire.test.ts +58 -199
  84. package/src/__tests__/root-process-lockfile.test.ts +99 -0
  85. package/src/__tests__/sleep.test.ts +19 -19
  86. package/src/__tests__/statefulset.test.ts +8 -8
  87. package/src/__tests__/{tailscale-tunnel.test.ts → tailscale.test.ts} +2 -2
  88. package/src/__tests__/teleport-backup.test.ts +3 -3
  89. package/src/__tests__/teleport.test.ts +180 -211
  90. package/src/__tests__/tunnel.test.ts +490 -270
  91. package/src/__tests__/uninstall.test.ts +150 -9
  92. package/src/__tests__/upgrade-local.test.ts +3 -3
  93. package/src/__tests__/upgrade-replay-env.test.ts +12 -12
  94. package/src/__tests__/wake.test.ts +260 -43
  95. package/src/__tests__/workos-pkce.test.ts +2 -2
  96. package/src/commands/backup.ts +24 -24
  97. package/src/commands/client.ts +76 -78
  98. package/src/commands/completions.test.ts +104 -0
  99. package/src/commands/completions.ts +87 -0
  100. package/src/commands/debug.ts +20 -20
  101. package/src/commands/exec.ts +12 -12
  102. package/src/commands/flags.ts +2 -2
  103. package/src/commands/hatch.ts +27 -24
  104. package/src/commands/login.ts +22 -22
  105. package/src/commands/logs.ts +8 -8
  106. package/src/commands/pair.ts +140 -37
  107. package/src/commands/ps.ts +232 -86
  108. package/src/commands/restart.ts +30 -0
  109. package/src/commands/restore.ts +140 -22
  110. package/src/commands/retire.ts +48 -150
  111. package/src/commands/rollback.ts +20 -26
  112. package/src/commands/setup.ts +2 -2
  113. package/src/commands/sleep.ts +2 -2
  114. package/src/commands/teleport.ts +110 -129
  115. package/src/commands/terminal.ts +158 -18
  116. package/src/commands/tunnel.ts +156 -175
  117. package/src/commands/uninstall.ts +137 -54
  118. package/src/commands/unpair.ts +3 -3
  119. package/src/commands/upgrade.ts +30 -54
  120. package/src/commands/use.ts +1 -1
  121. package/src/commands/wake.ts +166 -50
  122. package/src/index.ts +18 -9
  123. package/src/lib/__tests__/{platform-client-signed-url.test.ts → cloud-client-signed-url.test.ts} +16 -16
  124. package/src/lib/__tests__/docker.test.ts +168 -20
  125. package/src/lib/__tests__/job-polling.test.ts +2 -2
  126. package/src/lib/__tests__/local-gateway-cli.test.ts +42 -0
  127. package/src/lib/__tests__/local-runtime-client.test.ts +4 -4
  128. package/src/lib/__tests__/runtime-url.test.ts +1 -1
  129. package/src/lib/assistant-api-key-resolution.ts +5 -5
  130. package/src/lib/assistant-client.ts +8 -8
  131. package/src/lib/assistant-config.ts +58 -12
  132. package/src/lib/{platform-client.ts → cloud-client.ts} +105 -99
  133. package/src/lib/cloud-login.ts +23 -24
  134. package/src/lib/{platform-releases.ts → cloud-releases.ts} +11 -9
  135. package/src/lib/cloud-values.ts +3 -0
  136. package/src/lib/color.ts +4 -0
  137. package/src/lib/config-utils.ts +1 -1
  138. package/src/lib/detached-process.ts +1 -1
  139. package/src/lib/docker.ts +300 -111
  140. package/src/lib/ensure-binary.ts +171 -0
  141. package/src/lib/environments/__tests__/paths.test.ts +29 -3
  142. package/src/lib/environments/__tests__/resolve.test.ts +26 -26
  143. package/src/lib/environments/paths.ts +16 -8
  144. package/src/lib/environments/resolve.ts +10 -12
  145. package/src/lib/flag-args.test.ts +1 -1
  146. package/src/lib/flag-args.ts +1 -1
  147. package/src/lib/guardian-token.ts +37 -5
  148. package/src/lib/hatch-local.ts +32 -29
  149. package/src/lib/hatch-next-steps.ts +5 -9
  150. package/src/lib/health-check.ts +6 -6
  151. package/src/lib/ingress-config.ts +8 -35
  152. package/src/lib/job-polling.ts +2 -2
  153. package/src/lib/live-voice/connection.ts +12 -12
  154. package/src/lib/live-voice/tunnel.test.ts +11 -11
  155. package/src/lib/live-voice/tunnel.ts +15 -15
  156. package/src/lib/local-runtime-client.ts +20 -20
  157. package/src/lib/local.ts +46 -34
  158. package/src/lib/nginx-ingress.ts +40 -15
  159. package/src/lib/pair.ts +1 -1
  160. package/src/lib/process.ts +1 -1
  161. package/src/lib/provider-secrets.ts +3 -3
  162. package/src/lib/retire-apple-container.ts +1 -1
  163. package/src/lib/runtime-url.ts +8 -8
  164. package/src/lib/statefulset.ts +2 -2
  165. package/src/lib/step-runner.ts +6 -1
  166. package/src/lib/sync-cloud-assistants.ts +19 -19
  167. package/src/lib/teleport-backup.ts +4 -4
  168. package/src/lib/terminal-client.ts +17 -17
  169. package/src/lib/terminal-session.ts +24 -24
  170. package/src/lib/{tunnel-edge.ts → tunnel.ts} +199 -11
  171. package/src/lib/tunnels/cadet.ts +29 -0
  172. package/src/lib/{cloudflare-tunnel.ts → tunnels/cloudflare.ts} +36 -15
  173. package/src/lib/tunnels/gateway-notify.ts +61 -0
  174. package/src/lib/{ngrok.ts → tunnels/ngrok.ts} +350 -89
  175. package/src/lib/{tailscale-tunnel.ts → tunnels/tailscale.ts} +53 -9
  176. package/src/lib/upgrade-lifecycle.ts +8 -21
  177. package/src/lib/upgrade-preflight.ts +2 -2
  178. package/src/lib/workos-pkce.ts +5 -5
  179. package/node_modules/@mycadet/assistant-api/events/platform-disconnected.ts +0 -21
  180. package/node_modules/@mycadet/assistant-api/events/show-platform-login.ts +0 -20
  181. package/src/commands/recover.ts +0 -131
  182. package/src/commands/roadmap.ts +0 -459
  183. package/src/commands/ssh.ts +0 -144
  184. package/src/commands/workflows.ts +0 -305
  185. package/src/lib/managed-cloud.ts +0 -3
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Tunnel-level gateway notification: once ANY tunnel provider has a public
3
+ * URL, the gateway learns it here — ngrok-agnostic on purpose, so every
4
+ * provider conforms by returning its `publicUrl` and the dispatch (`cadet
5
+ * tunnel`'s tunnel level, or the daemon's auto-tunnel) calls this.
6
+ */
7
+
8
+ import { lookupAssistantByIdentifier } from "../assistant-config.js";
9
+ import { GATEWAY_PORT } from "../constants.js";
10
+ import { loadGuardianToken } from "../guardian-token.js";
11
+ import { loopbackSafeFetch } from "../loopback-fetch.js";
12
+
13
+ /**
14
+ * Where the gateway for this run's assistant answers. The entry's recorded
15
+ * loopback URL when one resolves; the global gateway port otherwise.
16
+ */
17
+ function gatewayUrlFor(assistantId: string | undefined): string {
18
+ if (assistantId) {
19
+ const result = lookupAssistantByIdentifier(assistantId);
20
+ if (result.status === "found") {
21
+ const url = result.entry.localUrl ?? result.entry.runtimeUrl;
22
+ if (url) return url;
23
+ }
24
+ }
25
+ return `http://127.0.0.1:${GATEWAY_PORT}`;
26
+ }
27
+
28
+ /**
29
+ * Tell the gateway the tunnel's public URL so it records the ingress domain
30
+ * in its own config, the same way its own tunnel machinery does. Best-effort:
31
+ * a gateway that is down never fails the tunnel that fronts it.
32
+ */
33
+ export async function notifyGatewayOfPublicUrl(
34
+ publicUrl: string,
35
+ assistantId: string | undefined,
36
+ ): Promise<void> {
37
+ try {
38
+ const token = assistantId
39
+ ? loadGuardianToken(assistantId)?.accessToken
40
+ : undefined;
41
+ const res = await loopbackSafeFetch(
42
+ `${gatewayUrlFor(assistantId)}/v1/ingress/domains`,
43
+ {
44
+ method: "POST",
45
+ headers: {
46
+ "Content-Type": "application/json",
47
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
48
+ },
49
+ body: JSON.stringify({ publicBaseUrl: publicUrl }),
50
+ signal: AbortSignal.timeout(5_000),
51
+ },
52
+ );
53
+ if (!res.ok) {
54
+ throw new Error(`gateway responded ${res.status}`);
55
+ }
56
+ } catch (err) {
57
+ console.warn(
58
+ ` ⚠ Could not record the ingress domain with the gateway: ${err instanceof Error ? err.message : String(err)}. The tunnel still runs.`,
59
+ );
60
+ }
61
+ }
@@ -1,18 +1,32 @@
1
1
  import { execFileSync, spawn, type ChildProcess } from "node:child_process";
2
- import { closeSync, existsSync, mkdirSync, openSync } from "node:fs";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ writeFileSync,
9
+ } from "node:fs";
3
10
  import { dirname, join } from "node:path";
4
11
 
5
- import { GATEWAY_PORT } from "./constants.js";
12
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
13
+
14
+ import { GATEWAY_PORT } from "../constants.js";
15
+ import { clearRootProcess, stampRootProcess } from "../assistant-config.js";
16
+ import { ensureBinaryInstalled, readLineText } from "../ensure-binary.js";
6
17
  import {
7
18
  clearIngressUrl,
8
19
  getDefaultWorkspaceDir,
9
- loadNgrokDomain,
10
20
  loadRawConfig,
11
21
  saveIngressUrl,
12
- saveNgrokDomain,
13
- } from "./ingress-config.js";
14
- import { loopbackSafeFetch } from "./loopback-fetch.js";
22
+ } from "../ingress-config.js";
23
+ import { getConfigDir } from "../environments/paths.js";
24
+ import { getCurrentEnvironment } from "../environments/resolve.js";
25
+ import { loopbackSafeFetch } from "../loopback-fetch.js";
26
+ import { readProcessCommandLine } from "../process.js";
15
27
  import { z } from "zod";
28
+ import type { ProviderMeta, ProviderTunnelResult } from "../tunnel.js";
29
+ import { notifyGatewayOfPublicUrl } from "./gateway-notify.js";
16
30
 
17
31
  const NGROK_API_URL = "http://127.0.0.1:4040/api/tunnels";
18
32
  const NGROK_POLL_INTERVAL_MS = 500;
@@ -42,6 +56,14 @@ export const IngressConfigSchema = z.object({
42
56
  publicBaseUrl: z.string().optional(),
43
57
  });
44
58
 
59
+ /** Per-provider copy `cadet tunnel`'s help renders for ngrok. */
60
+ export const NGROK_TUNNEL_META: ProviderMeta = {
61
+ reach: "Public tunnel that webhook integrations can reach",
62
+ install: "install: brew install ngrok/ngrok/ngrok",
63
+ domainExample: "my-assistant.ngrok.app",
64
+ supportsReservedDomain: true,
65
+ };
66
+
45
67
  /**
46
68
  * Check whether ngrok is installed and accessible on the PATH.
47
69
  * Returns the version string if installed, null otherwise.
@@ -60,6 +82,52 @@ export function getNgrokVersion(): string | null {
60
82
  }
61
83
  }
62
84
 
85
+ /** Whether captured agent output says the startup failure was authentication. */
86
+ export function isNgrokAuthError(output: string): boolean {
87
+ return /authtoken|authenticat|unauthorized|usage limit|ERR_NGROK_10\d\d|ERR_NGROK_40\d\d/i.test(
88
+ output,
89
+ );
90
+ }
91
+
92
+ /** An ngrok agent that exited during startup, with its captured output. */
93
+ export class NgrokStartupError extends Error {
94
+ readonly exitCode: number;
95
+ readonly output: string;
96
+
97
+ constructor(exitCode: number, output: string) {
98
+ super(`ngrok exited with code ${exitCode}`);
99
+ this.exitCode = exitCode;
100
+ this.output = output;
101
+ }
102
+ }
103
+
104
+ /** Paste-in attempts before the tunnel command gives up and says how. */
105
+ const MAX_AUTH_RETRIES = 2;
106
+
107
+ /**
108
+ * Run `ngrok config add-authtoken <token>` — writing the user's default
109
+ * ngrok config, which is exactly the config the tunnel spawn loads. The
110
+ * token is never echoed: output is piped and any failure message has the
111
+ * token scrubbed.
112
+ */
113
+ export function saveNgrokAuthToken(token: string): boolean {
114
+ try {
115
+ execFileSync("ngrok", ["config", "add-authtoken", token], {
116
+ encoding: "utf-8",
117
+ timeout: 10_000,
118
+ stdio: ["ignore", "pipe", "pipe"],
119
+ windowsHide: true,
120
+ });
121
+ return true;
122
+ } catch (err) {
123
+ const message = err instanceof Error ? err.message : String(err);
124
+ console.error(
125
+ `⚠ Couldn't save the authtoken: ${message.replaceAll(token, "***")}`,
126
+ );
127
+ return false;
128
+ }
129
+ }
130
+
63
131
  /**
64
132
  * Query the ngrok local API for running tunnels.
65
133
  * Returns the list of tunnels, or null if the API is unreachable.
@@ -118,9 +186,106 @@ function staleAgentDiagnostic(tunnels: NgrokTunnel[], port: number): string {
118
186
 
119
187
  /** Recovery copy for a saved domain whose reservation may have lapsed. */
120
188
  function savedDomainRecoveryHint(domain: string): string {
121
- return `The saved ngrok domain '${domain}' (ingress.ngrok.domain in the workspace config) may no longer be reserved. Run \`cadet tunnel --provider ngrok --clear-domain\` to drop it and tunnel without one.`;
189
+ return `The saved ngrok domain '${domain}' (${ngrokConfigPath()}) may no longer be reserved. Run \`cadet tunnel --provider ngrok --clear-domain\` to drop it and tunnel without one.`;
190
+ }
191
+
192
+ const NGROK_TUNNEL_NAME = "cadet";
193
+
194
+ const NgrokTunnelDefSchema = z.looseObject({
195
+ proto: z.string().optional(),
196
+ addr: z.string().optional(),
197
+ domain: z.string().optional(),
198
+ });
199
+
200
+ const NgrokConfigFileSchema = z.looseObject({
201
+ version: z.string().optional(),
202
+ tunnels: z.record(z.string(), NgrokTunnelDefSchema).optional(),
203
+ });
204
+
205
+ type NgrokConfigFile = z.infer<typeof NgrokConfigFileSchema>;
206
+ type NgrokTunnelDef = z.infer<typeof NgrokTunnelDefSchema>;
207
+
208
+ /**
209
+ * The ngrok-format config file this CLI owns in the XDG config dir. It no
210
+ * longer participates in spawning (plain `ngrok http` loads the user's
211
+ * default config, keeping their authtoken in play); it is the source of
212
+ * truth for the reserved domain at this layer — the workspace config no
213
+ * longer records one.
214
+ */
215
+ export function ngrokConfigPath(): string {
216
+ return join(getConfigDir(getCurrentEnvironment()), "tunnels", "ngrok.yaml");
217
+ }
218
+
219
+ function readNgrokConfigFile(): NgrokConfigFile {
220
+ const path = ngrokConfigPath();
221
+ if (!existsSync(path)) return {};
222
+ try {
223
+ const parsed = NgrokConfigFileSchema.safeParse(
224
+ parseYaml(readFileSync(path, "utf-8")),
225
+ );
226
+ return parsed.success ? parsed.data : {};
227
+ } catch {
228
+ return {};
229
+ }
230
+ }
231
+
232
+ function writeNgrokConfigFile(config: NgrokConfigFile): void {
233
+ const path = ngrokConfigPath();
234
+ mkdirSync(dirname(path), { recursive: true });
235
+ writeFileSync(path, stringifyYaml(config));
236
+ }
237
+
238
+ /**
239
+ * Persist a reserved ngrok domain in the XDG ngrok config; null clears it.
240
+ *
241
+ * The reserved domain is an ngrok reservation concept (cloudflare quick
242
+ * tunnels and tailscale serve have no equivalent), which is why this lives in
243
+ * the ngrok provider module rather than the shared ingress-config helpers.
244
+ */
245
+ export function saveNgrokDomain(domain: string | null): void {
246
+ const config = readNgrokConfigFile();
247
+ const cadet: NgrokTunnelDef = {
248
+ ...config.tunnels?.[NGROK_TUNNEL_NAME],
249
+ proto: "http",
250
+ };
251
+ if (domain) {
252
+ cadet.domain = domain;
253
+ } else {
254
+ delete cadet.domain;
255
+ }
256
+ const tunnels: Record<string, NgrokTunnelDef> = { ...config.tunnels };
257
+ tunnels[NGROK_TUNNEL_NAME] = cadet;
258
+ const next: NgrokConfigFile = { ...config, version: config.version ?? "2" };
259
+ next.tunnels = tunnels;
260
+ writeNgrokConfigFile(next);
261
+ }
262
+
263
+ /** Read the reserved ngrok domain from the XDG ngrok config, if saved. */
264
+ export function loadNgrokDomain(): string | null {
265
+ const domain = readNgrokConfigFile().tunnels?.[NGROK_TUNNEL_NAME]?.domain;
266
+ return domain?.trim() ? domain : null;
122
267
  }
123
268
 
269
+ /**
270
+ * Check whether a PID belongs to an ngrok process via its command line, so a
271
+ * recorded pid is verified before it is signaled (PID reuse guard).
272
+ */
273
+ export function isNgrokProcess(pid: number): boolean {
274
+ try {
275
+ return /ngrok/i.test(readProcessCommandLine(pid));
276
+ } catch {
277
+ return false;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Matches the adoption line the tunnel workflows print when they find an
283
+ * already-running tunnel, so the detached-tunnel supervisor can detect
284
+ * adoption without this module's implementation details leaking into the
285
+ * command layer.
286
+ */
287
+ export const ADOPTED_TUNNEL_RE = /Found existing ngrok tunnel: (\S+)/;
288
+
124
289
  /** Whether a tunnel public URL's host equals the given reserved domain. */
125
290
  function urlMatchesDomain(publicUrl: string, domain: string): boolean {
126
291
  try {
@@ -132,16 +297,34 @@ function urlMatchesDomain(publicUrl: string, domain: string): boolean {
132
297
  }
133
298
  }
134
299
 
300
+ /** The argv for an ngrok agent forwarding the edge port, domain when set. */
301
+ export function buildNgrokSpawnArgs(
302
+ targetPort: number,
303
+ domain?: string,
304
+ ): string[] {
305
+ const args = ["http", `127.0.0.1:${targetPort}`, "--log=stdout"];
306
+ if (domain) {
307
+ args.push(`--domain=${domain}`);
308
+ }
309
+ return args;
310
+ }
311
+
135
312
  /**
136
313
  * Start an ngrok process tunneling HTTP traffic to the given local port.
137
314
  *
315
+ * Plain `ngrok http` is spawned deliberately: ngrok only loads the user's
316
+ * default config — where `ngrok config add-authtoken` writes — when no
317
+ * `--config` is passed, so this is what keeps the authtoken in play. The
318
+ * explicit `127.0.0.1:` host avoids a bare port, which ngrok expands to
319
+ * `localhost`, whose ::1 answer resolves first on macOS and races the edge's
320
+ * IPv4 bind.
321
+ *
138
322
  * When `logFilePath` is provided, stdout/stderr are redirected to that file
139
323
  * instead of being piped. This avoids keeping pipe handles open in the
140
324
  * parent process — which would either prevent the CLI from exiting (if
141
325
  * handles are left open) or send SIGPIPE to ngrok (if destroyed).
142
326
  *
143
- * When `domain` is set, the tunnel binds that reserved ngrok domain via
144
- * `--domain=<domain>`.
327
+ * When `domain` is set, the spawned tunnel binds that reserved ngrok domain.
145
328
  *
146
329
  * Returns the spawned child process.
147
330
  */
@@ -159,14 +342,7 @@ export function startNgrokProcess(
159
342
  stdio = ["ignore", fd, fd];
160
343
  }
161
344
 
162
- // Explicit over a bare port, which ngrok expands to `localhost`, whose ::1
163
- // answer resolves first on macOS and races the edge's IPv4 bind.
164
- const args = ["http", `127.0.0.1:${targetPort}`, "--log=stdout"];
165
- if (domain) {
166
- args.push(`--domain=${domain}`);
167
- }
168
-
169
- const child = spawn("ngrok", args, {
345
+ const child = spawn("ngrok", buildNgrokSpawnArgs(targetPort, domain), {
170
346
  detached: true,
171
347
  stdio,
172
348
  windowsHide: true,
@@ -225,10 +401,7 @@ export function hasWebhookIntegrations(
225
401
  const telegram = TelegramIntegrationSchema.safeParse(config.telegram);
226
402
  if (telegram.success && telegram.data.botUsername) return true;
227
403
  const twilio = TwilioIntegrationSchema.safeParse(config.twilio);
228
- if (
229
- twilio.success &&
230
- (twilio.data.accountSid || twilio.data.phoneNumber)
231
- ) {
404
+ if (twilio.success && (twilio.data.accountSid || twilio.data.phoneNumber)) {
232
405
  return true;
233
406
  }
234
407
  return false;
@@ -282,7 +455,7 @@ export async function maybeStartNgrokTunnel(
282
455
  workspaceDir: string,
283
456
  assistantId?: string,
284
457
  ): Promise<ChildProcess | null> {
285
- // Managed/containerized deployments route webhooks through the platform's
458
+ // Managed/containerized deployments route webhooks through the cloud's
286
459
  // callback proxy. ngrok is not needed and would not be reachable from the
287
460
  // platform anyway — skip it entirely.
288
461
  const isContainerized =
@@ -295,7 +468,7 @@ export async function maybeStartNgrokTunnel(
295
468
  const version = getNgrokVersion();
296
469
  if (!version) return null;
297
470
 
298
- const savedDomain = loadNgrokDomain(workspaceDir) ?? undefined;
471
+ const savedDomain = loadNgrokDomain() ?? undefined;
299
472
 
300
473
  // Reuse an existing tunnel if one is already running
301
474
  const runningTunnels = (await queryNgrokTunnels()) ?? [];
@@ -312,6 +485,10 @@ export async function maybeStartNgrokTunnel(
312
485
  }
313
486
  console.log(` Found existing ngrok tunnel: ${existingUrl}`);
314
487
  saveIngressUrl(workspaceDir, existingUrl, assistantId, "ngrok");
488
+ await notifyGatewayOfPublicUrl(existingUrl, assistantId);
489
+ // The adopted tunnel belongs to some other agent we do not own a pid for,
490
+ // so any recorded tunnel process entry would be stale from here on.
491
+ clearRootProcess("tunnel");
315
492
  return null;
316
493
  }
317
494
  if (runningTunnels.length > 0) {
@@ -339,6 +516,15 @@ export async function maybeStartNgrokTunnel(
339
516
  try {
340
517
  const publicUrl = await waitForNgrokUrl(targetPort, savedDomain);
341
518
  saveIngressUrl(workspaceDir, publicUrl, assistantId, "ngrok");
519
+ await notifyGatewayOfPublicUrl(publicUrl, assistantId);
520
+ if (ngrokProcess.pid) {
521
+ stampRootProcess("tunnel", {
522
+ pid: ngrokProcess.pid,
523
+ provider: "ngrok",
524
+ startedAt: new Date().toISOString(),
525
+ assistantId,
526
+ });
527
+ }
342
528
  console.log(` Tunnel established: ${publicUrl}`);
343
529
 
344
530
  return ngrokProcess;
@@ -350,6 +536,7 @@ export async function maybeStartNgrokTunnel(
350
536
  console.warn(` ⚠ ${savedDomainRecoveryHint(savedDomain)}`);
351
537
  }
352
538
  if (!ngrokProcess.killed) ngrokProcess.kill("SIGTERM");
539
+ clearRootProcess("tunnel");
353
540
  return null;
354
541
  }
355
542
  }
@@ -370,23 +557,33 @@ export interface RunNgrokTunnelOptions {
370
557
 
371
558
  /**
372
559
  * Run the ngrok tunnel workflow: check installation, find or start a tunnel,
373
- * save the public URL to config, and block until exit or signal.
560
+ * and save the public URL to config. Hands the URL back so the tunnel level
561
+ * can notify the gateway of it; `waitForExit` settles when the run ends
562
+ * (process exit, signal, or detach).
374
563
  */
375
564
  export async function runNgrokTunnel(
376
565
  opts: RunNgrokTunnelOptions = {},
377
- ): Promise<void> {
378
- const version = getNgrokVersion();
566
+ ): Promise<ProviderTunnelResult> {
567
+ const version = await ensureBinaryInstalled({
568
+ binary: "ngrok",
569
+ purpose: "the ngrok tunnel",
570
+ packages: {
571
+ brew: ["brew", "install", "ngrok/ngrok/ngrok"],
572
+ snap: ["sudo", "snap", "install", "ngrok"],
573
+ choco: ["choco", "install", "-y", "ngrok"],
574
+ winget: [
575
+ "winget",
576
+ "install",
577
+ "--silent",
578
+ "--accept-package-agreements",
579
+ "--accept-source-agreements",
580
+ "ngrok.ngrok",
581
+ ],
582
+ },
583
+ manualHint: "See https://ngrok.com/download for how to install",
584
+ probe: getNgrokVersion,
585
+ });
379
586
  if (!version) {
380
- console.error("Error: ngrok is not installed.");
381
- console.error("");
382
- console.error("Install ngrok:");
383
- console.error(" macOS: brew install ngrok/ngrok/ngrok");
384
- console.error(" Linux: sudo snap install ngrok");
385
- console.error("");
386
- console.error("Then authenticate: ngrok config add-authtoken <your-token>");
387
- console.error(
388
- " Get your token at: https://dashboard.ngrok.com/get-started/your-authtoken",
389
- );
390
587
  process.exit(1);
391
588
  }
392
589
 
@@ -397,7 +594,7 @@ export async function runNgrokTunnel(
397
594
 
398
595
  // The saved domain is standing intent: a run without --domain reuses it,
399
596
  // and only an explicit --domain rewrites it.
400
- const domain = opts.domain ?? loadNgrokDomain(workspaceDir) ?? undefined;
597
+ const domain = opts.domain ?? loadNgrokDomain() ?? undefined;
401
598
  if (domain && !opts.domain) {
402
599
  console.log(`Using saved ngrok domain: ${domain}`);
403
600
  }
@@ -426,8 +623,10 @@ export async function runNgrokTunnel(
426
623
  }
427
624
  console.log(`Found existing ngrok tunnel: ${existingUrl}`);
428
625
  saveIngressUrl(workspaceDir, existingUrl, opts.assistantId, "ngrok");
626
+ // Not our child process: a recorded entry would be stale or foreign.
627
+ clearRootProcess("tunnel");
429
628
  if (opts.domain) {
430
- saveNgrokDomain(workspaceDir, opts.domain);
629
+ saveNgrokDomain(opts.domain);
431
630
  }
432
631
  console.log("Ingress URL saved to config.");
433
632
  console.log("");
@@ -436,23 +635,26 @@ export async function runNgrokTunnel(
436
635
  );
437
636
 
438
637
  // Block until SIGINT/SIGTERM
439
- await new Promise<void>((resolve) => {
638
+ const waitForExit = new Promise<void>((resolve) => {
440
639
  process.on("SIGINT", () => resolve());
441
640
  process.on("SIGTERM", () => resolve());
442
641
  });
443
- return;
642
+ return { publicUrl: existingUrl, waitForExit };
444
643
  }
445
644
 
446
645
  console.log(`Starting ngrok tunnel to localhost:${port}...`);
447
646
 
448
647
  let publicUrl: string | undefined;
449
-
450
- const ngrokProcess = startNgrokProcess(port, undefined, domain);
648
+ // Points at the live agent across auth retries; signals and cleanup act on
649
+ // whichever attempt is current.
650
+ let currentNgrok: ChildProcess | null = null;
651
+ let authAttempts = 0;
451
652
 
452
653
  const cleanup = () => {
453
- if (!ngrokProcess.killed) {
454
- ngrokProcess.kill("SIGTERM");
654
+ if (currentNgrok && !currentNgrok.killed) {
655
+ currentNgrok.kill("SIGTERM");
455
656
  }
657
+ clearRootProcess("tunnel");
456
658
  if (publicUrl) {
457
659
  console.log("\nClearing ingress URL from config...");
458
660
  clearIngressUrl(workspaceDir, opts.assistantId);
@@ -468,57 +670,116 @@ export async function runNgrokTunnel(
468
670
  process.exit(0);
469
671
  });
470
672
 
471
- ngrokProcess.on("error", (err: Error) => {
472
- console.error(`ngrok process error: ${err.message}`);
473
- process.exit(1);
474
- });
673
+ while (publicUrl === undefined) {
674
+ const ngrokProcess = startNgrokProcess(port, undefined, domain);
675
+ currentNgrok = ngrokProcess;
475
676
 
476
- ngrokProcess.on("exit", (code: number | null) => {
477
- if (code !== null && code !== 0) {
478
- console.error(`ngrok exited with code ${code}.`);
479
- console.error(
480
- "Check that ngrok is authenticated: ngrok config add-authtoken <token>",
481
- );
677
+ // Captured per attempt so an authentication failure can be told apart
678
+ // from other startup failures and recovered with a pasted-in token.
679
+ const ngrokOutput: string[] = [];
680
+
681
+ ngrokProcess.on("error", (err: Error) => {
682
+ console.error(`ngrok process error: ${err.message}`);
482
683
  process.exit(1);
483
- }
484
- });
684
+ });
485
685
 
486
- // Pipe ngrok stdout/stderr to console for visibility
487
- ngrokProcess.stdout?.on("data", (data: Buffer) => {
488
- const line = data.toString().trim();
489
- if (line) console.log(`[ngrok] ${line}`);
490
- });
491
- ngrokProcess.stderr?.on("data", (data: Buffer) => {
492
- const line = data.toString().trim();
493
- if (line) console.error(`[ngrok] ${line}`);
494
- });
686
+ // Pipe ngrok stdout/stderr to console for visibility
687
+ ngrokProcess.stdout?.on("data", (data: Buffer) => {
688
+ const line = data.toString().trim();
689
+ if (line) {
690
+ ngrokOutput.push(line);
691
+ console.log(`[ngrok] ${line}`);
692
+ }
693
+ });
694
+ ngrokProcess.stderr?.on("data", (data: Buffer) => {
695
+ const line = data.toString().trim();
696
+ if (line) {
697
+ ngrokOutput.push(line);
698
+ console.error(`[ngrok] ${line}`);
699
+ }
700
+ });
495
701
 
496
- try {
497
- publicUrl = await waitForNgrokUrl(port, domain);
498
- } catch (err) {
499
- cleanup();
500
- if (domain && !opts.domain) {
501
- console.error(savedDomainRecoveryHint(domain));
702
+ // An agent that exits during startup rejects this instead of leaving
703
+ // waitForNgrokUrl to poll a dead API for its full timeout.
704
+ const exitFailure = new Promise<never>((_, reject) => {
705
+ ngrokProcess.on("exit", (code: number | null) => {
706
+ if (code !== null && code !== 0) {
707
+ reject(new NgrokStartupError(code, ngrokOutput.join("\n")));
708
+ }
709
+ });
710
+ });
711
+
712
+ let url: string;
713
+ try {
714
+ url = await Promise.race([waitForNgrokUrl(port, domain), exitFailure]);
715
+ } catch (err) {
716
+ if (err instanceof NgrokStartupError && isNgrokAuthError(err.output)) {
717
+ if (authAttempts >= MAX_AUTH_RETRIES) {
718
+ console.error("\nngrok still rejects the tunnel as unauthenticated.");
719
+ console.error(
720
+ "Authenticate manually: ngrok config add-authtoken <your-token>",
721
+ );
722
+ console.error(
723
+ " Get your token at: https://dashboard.ngrok.com/get-started/your-authtoken",
724
+ );
725
+ process.exit(1);
726
+ }
727
+ console.log("");
728
+ console.log("⚠ ngrok rejected the tunnel: it isn't authenticated.");
729
+ console.log(
730
+ " Get your authtoken at: https://dashboard.ngrok.com/get-started/your-authtoken",
731
+ );
732
+ const token = (
733
+ await readLineText(
734
+ "Paste your ngrok authtoken to continue (Enter to cancel): ",
735
+ )
736
+ ).trim();
737
+ if (!token) {
738
+ console.log("No authtoken entered.");
739
+ process.exit(1);
740
+ }
741
+ authAttempts++;
742
+ if (saveNgrokAuthToken(token)) {
743
+ console.log("✅ ngrok authtoken saved. Retrying the tunnel...");
744
+ }
745
+ continue;
746
+ }
747
+ cleanup();
748
+ if (domain && !opts.domain) {
749
+ console.error(savedDomainRecoveryHint(domain));
750
+ }
751
+ throw err;
502
752
  }
503
- throw err;
504
- }
753
+ publicUrl = url;
505
754
 
506
- console.log("");
507
- console.log(`Tunnel established: ${publicUrl}`);
508
- console.log(`Forwarding to: localhost:${port}`);
755
+ console.log("");
756
+ console.log(`Tunnel established: ${publicUrl}`);
757
+ console.log(`Forwarding to: localhost:${port}`);
758
+
759
+ // The domain is standing intent, not tunnel state: cleanup clears the
760
+ // ingress URL but leaves the domain saved for wake/daemon restores.
761
+ saveIngressUrl(workspaceDir, publicUrl, opts.assistantId, "ngrok");
762
+ if (ngrokProcess.pid) {
763
+ stampRootProcess("tunnel", {
764
+ pid: ngrokProcess.pid,
765
+ provider: "ngrok",
766
+ startedAt: new Date().toISOString(),
767
+ assistantId: opts.assistantId,
768
+ });
769
+ }
770
+ if (opts.domain) {
771
+ saveNgrokDomain(opts.domain);
772
+ }
773
+ console.log("Ingress URL saved to config.");
774
+ console.log("");
775
+ console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
509
776
 
510
- // The domain is standing intent, not tunnel state: cleanup clears the
511
- // ingress URL but leaves the domain saved for wake/daemon restores.
512
- saveIngressUrl(workspaceDir, publicUrl, opts.assistantId, "ngrok");
513
- if (opts.domain) {
514
- saveNgrokDomain(workspaceDir, opts.domain);
777
+ // Keep running until the ngrok process exits or we receive a signal
778
+ const waitForExit = new Promise<void>((resolve) => {
779
+ ngrokProcess.on("exit", () => resolve());
780
+ });
781
+ return { publicUrl, waitForExit };
515
782
  }
516
- console.log("Ingress URL saved to config.");
517
- console.log("");
518
- console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
519
-
520
- // Keep running until the ngrok process exits or we receive a signal
521
- await new Promise<void>((resolve) => {
522
- ngrokProcess.on("exit", () => resolve());
523
- });
783
+ // Unreachable: the loop only exits via return, process.exit, or a throw.
784
+ throw new Error("ngrok tunnel did not start.");
524
785
  }