cartethyia 2.5.8 → 2.5.10

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 (157) hide show
  1. package/.env.example +7 -42
  2. package/README.md +11 -10
  3. package/dist/dashboard/assets/{BannedPage-BKAI54IA.js → BannedPage-Cd30Z0b9.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-CkGvxrHu.js → CliToolDetailPage-wf5u-ww6.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-Cj8MYpeY.js → CliToolsPage-DykPvuck.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-BDXBEbWT.js → CombosPage-DwvSD3vs.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-49KH4z3l.js → ConsoleLogPage-CIr7KDex.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-EHjBmNBZ.js → CustomizationPage-82X0DDdH.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
  15. package/dist/dashboard/assets/{HealthEventsModal-ByUTtj7j.js → HealthEventsModal-BYPucTTn.js} +1 -1
  16. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-CYSCKT8S.js → LoginPage-CS5mPuVF.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-C7pDb41R.js → ModelPicker-C0UCUsVJ.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
  21. package/dist/dashboard/assets/{OverviewPage-Bu3iqBMm.js → OverviewPage-Dk_ew3YU.js} +1 -1
  22. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
  23. package/dist/dashboard/assets/{ProviderDetailPage-FHdgmgjU.js → ProviderDetailPage-CAiRn2Q0.js} +1 -1
  24. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-CCvYS9CF.js → ProvidersPage-vdja5ZPK.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
  27. package/dist/dashboard/assets/{ProxyPage-HOl9cjxl.js → ProxyPage-BB4CNm4-.js} +1 -1
  28. package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
  29. package/dist/dashboard/assets/{QuotaPage-MUGzIpTK.js → QuotaPage-FEc4iNyq.js} +1 -1
  30. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
  31. package/dist/dashboard/assets/{SettingsPage-CTdF5TCa.js → SettingsPage-Dlfi5L4N.js} +1 -1
  32. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-Bra7rkKx.js → SetupPage-FadUOl9S.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-BRscd-vl.js → SortableList-Cfy6_Khv.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-BeevnFtM.js → StudioPage-CM92fgJf.js} +1 -1
  38. package/dist/dashboard/assets/StudioPage-CM92fgJf.js.gz +0 -0
  39. package/dist/dashboard/assets/{UsagePage-BWik9fpu.js → UsagePage-BT4MyQeT.js} +1 -1
  40. package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
  41. package/dist/dashboard/assets/{app-BIt446yE.js → app-B7pVRhJ1.js} +1 -1
  42. package/dist/dashboard/assets/{app-BIt446yE.js.gz → app-B7pVRhJ1.js.gz} +0 -0
  43. package/dist/dashboard/assets/{badge-CR72dasT.js → badge-Bvyr7D0u.js} +1 -1
  44. package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
  45. package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-DIvKAZih.js} +1 -1
  46. package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
  47. package/dist/dashboard/assets/{card-DS7CQEsO.js → card-D5_gYIS_.js} +1 -1
  48. package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
  49. package/dist/dashboard/assets/{clipboard-button-611pSsaX.js → clipboard-button-B6vDBtbm.js} +1 -1
  50. package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
  51. package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-CpjrgdwJ.js} +1 -1
  52. package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
  53. package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BWmt3b5y.js} +1 -1
  54. package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
  55. package/dist/dashboard/assets/{entry-B9Xvtnfj.js → entry-B-gvimrg.js} +1 -1
  56. package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
  57. package/dist/dashboard/assets/{entry-CTP8AyiS.js → entry-C_oakV3z.js} +1 -1
  58. package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
  59. package/dist/dashboard/assets/{entry-BqZ5vrM7.js → entry-Dj0cHfEc.js} +3 -3
  60. package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
  61. package/dist/dashboard/assets/{github-badge-sltk1qUi.js → github-badge-RRHHWGxh.js} +1 -1
  62. package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
  63. package/dist/dashboard/assets/{index-9ARd3FIs.js → index-Bhey_rg_.js} +2 -2
  64. package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
  65. package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C2_EQ5Uf.js} +1 -1
  66. package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
  67. package/dist/dashboard/assets/{input-V1umFiBk.js → input-BMRlQ450.js} +1 -1
  68. package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
  69. package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-XR5xP8ew.js} +1 -1
  70. package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
  71. package/dist/dashboard/assets/{list-row-1zzZYM0B.js → list-row-Cw07dioc.js} +1 -1
  72. package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
  73. package/dist/dashboard/assets/{numbers-DkfMR5Qs.js → numbers-DrdA9oSM.js} +1 -1
  74. package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
  75. package/dist/dashboard/assets/{select-D_BBhBEF.js → select-CxVVB__N.js} +1 -1
  76. package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
  77. package/dist/dashboard/assets/{stack-BHTfXdz_.js → stack-uPOwM9Hs.js} +1 -1
  78. package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
  79. package/dist/dashboard/assets/{state-mPDRlQkl.js → state-D1Yz79iL.js} +1 -1
  80. package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
  81. package/dist/dashboard/assets/{switch-DOmJxg7H.js → switch-sevsRrRo.js} +1 -1
  82. package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
  83. package/dist/dashboard/index.html +1 -1
  84. package/dist/dashboard/index.html.gz +0 -0
  85. package/dist/dashboard/precompressed-assets.json +40 -40
  86. package/package.json +1 -3
  87. package/scripts/cli/admin.ts +3 -0
  88. package/scripts/cli/console-menu.ts +299 -275
  89. package/scripts/cli/doctor.ts +1 -1
  90. package/scripts/cli/gateway.ts +1 -2
  91. package/scripts/{health.ts → cli/health.ts} +4 -4
  92. package/scripts/cli/main.ts +3 -0
  93. package/scripts/cli/prompt.ts +137 -107
  94. package/scripts/cli/start.ts +3 -0
  95. package/scripts/cli/tray.ts +167 -208
  96. package/src/config/package-paths.ts +24 -0
  97. package/src/console/backup/nine-router.ts +43 -9
  98. package/src/console/remote-support/service.ts +270 -114
  99. package/src/console/routing/pools/store.ts +1 -1
  100. package/src/console/updates/repository-updates.ts +40 -39
  101. package/src/observability/event-day-index.ts +1 -2
  102. package/src/protocol/registry.ts +2 -0
  103. package/src/providers/default-registry.ts +4 -0
  104. package/src/providers/discovery/openrouter-free-roster.ts +0 -1
  105. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +3 -2
  106. package/src/providers/integrations/buddy/capabilities.ts +4 -0
  107. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +2 -1
  108. package/src/providers/integrations/github/github.ts +3 -1
  109. package/src/providers/integrations/opencode/provider.ts +2 -3
  110. package/src/providers/integrations/trae/trae-headers.ts +17 -48
  111. package/src/providers/integrations/trae/trae.ts +22 -24
  112. package/src/providers/operations/provider-credential-service.ts +29 -3
  113. package/src/providers/provider-probe.ts +3 -0
  114. package/dist/dashboard/assets/BannedPage-BKAI54IA.js.gz +0 -0
  115. package/dist/dashboard/assets/CliToolDetailPage-CkGvxrHu.js.gz +0 -0
  116. package/dist/dashboard/assets/CliToolsPage-Cj8MYpeY.js.gz +0 -0
  117. package/dist/dashboard/assets/CombosPage-BDXBEbWT.js.gz +0 -0
  118. package/dist/dashboard/assets/ConsoleLogPage-49KH4z3l.js.gz +0 -0
  119. package/dist/dashboard/assets/CustomizationPage-EHjBmNBZ.js.gz +0 -0
  120. package/dist/dashboard/assets/HealthEventsModal-ByUTtj7j.js.gz +0 -0
  121. package/dist/dashboard/assets/LoginPage-CYSCKT8S.js.gz +0 -0
  122. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  123. package/dist/dashboard/assets/OverviewPage-Bu3iqBMm.js.gz +0 -0
  124. package/dist/dashboard/assets/ProviderDetailPage-FHdgmgjU.js.gz +0 -0
  125. package/dist/dashboard/assets/ProvidersPage-CCvYS9CF.js.gz +0 -0
  126. package/dist/dashboard/assets/ProxyPage-HOl9cjxl.js.gz +0 -0
  127. package/dist/dashboard/assets/QuotaPage-MUGzIpTK.js.gz +0 -0
  128. package/dist/dashboard/assets/SettingsPage-CTdF5TCa.js.gz +0 -0
  129. package/dist/dashboard/assets/SetupPage-Bra7rkKx.js.gz +0 -0
  130. package/dist/dashboard/assets/SortableList-BRscd-vl.js.gz +0 -0
  131. package/dist/dashboard/assets/StudioPage-BeevnFtM.js.gz +0 -0
  132. package/dist/dashboard/assets/UsagePage-BWik9fpu.js.gz +0 -0
  133. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  134. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  135. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  136. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  137. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  138. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  139. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  140. package/dist/dashboard/assets/entry-BqZ5vrM7.js.gz +0 -0
  141. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  142. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  143. package/dist/dashboard/assets/index-9ARd3FIs.js.gz +0 -0
  144. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  145. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  146. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  147. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  148. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  149. package/dist/dashboard/assets/select-D_BBhBEF.js.gz +0 -0
  150. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  151. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  152. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  153. package/scripts/build/aot.ts +0 -143
  154. package/scripts/build/build.ts +0 -24
  155. package/scripts/build/compile.ts +0 -88
  156. package/scripts/build/restart.ts +0 -120
  157. package/scripts/build/start.ts +0 -25
@@ -24,6 +24,30 @@ export function resolvePackagePath(...segments: string[]): string {
24
24
  return resolve(PACKAGE_ROOT, ...segments);
25
25
  }
26
26
 
27
+ /** Outfile `bun build --compile` writes when no `--outfile` is given. */
28
+ export const DEFAULT_BINARY_OUTFILE = "dist/cartethyia";
29
+
30
+ /**
31
+ * The artifact path `bun build --compile` actually writes for `outfile`.
32
+ *
33
+ * Bun appends `.exe` when it compiles for Windows, so the extensionless
34
+ * `dist/cartethyia` handed to it never exists on that platform. This is the one
35
+ * place that knows where the compiled binary lands; `scripts/build/` resolves
36
+ * its target from here instead of re-deriving the name, because a launcher that
37
+ * probed the requested path reported "run bun run build first" immediately
38
+ * after a successful build.
39
+ *
40
+ * It sits in `src/config/` rather than `scripts/build/` because the launcher
41
+ * ships in the npm package and `scripts/build/` does not: a shipped module that
42
+ * imported the build script crashed every installed copy of `carte` with
43
+ * `Cannot find module '../build/compile'`.
44
+ */
45
+ export function compiledBinaryPath(outfile: string = DEFAULT_BINARY_OUTFILE): string {
46
+ return process.platform === "win32" && !outfile.toLowerCase().endsWith(".exe")
47
+ ? `${outfile}.exe`
48
+ : outfile;
49
+ }
50
+
27
51
  /** User-writable directory for the npm-installed CLI's configuration. */
28
52
  export function resolveUserConfigDir(env: NodeJS.ProcessEnv = process.env): string {
29
53
  if (process.platform === "win32") {
@@ -77,12 +77,47 @@ function isoDate(value: unknown, fallback: string): string {
77
77
  }
78
78
 
79
79
  /**
80
- * The credential on a connection row. The router writes it under one of
81
- * several keys depending on how the account was added, so all of them are
82
- * checked before the row is called credential-less.
80
+ * The credential and the kind to store it under.
81
+ *
82
+ * A router connection often carries both an `apiKey` and an OAuth access
83
+ * token for the same account. The two are not interchangeable: the api key
84
+ * is a bare bearer, while the access token has to be sent through the
85
+ * provider's OAuth-shaped envelope — Cline's chat path, for example,
86
+ * prepends `workos:` to anything it treats as OAuth. If we picked `apiKey`
87
+ * (the natural first choice because it is the longer-lived, bearer-ready
88
+ * secret) while persisting the kind as `oauth`, dispatch would wrap it as
89
+ * `Bearer workos:sk_...` and the upstream would answer "Unauthorized,
90
+ * re-authenticate".
91
+ *
92
+ * Resolution: when a connection carries an api key AND nothing explicitly
93
+ * says "treat this as OAuth", the api key is the credential and the kind
94
+ * is `api_key`. The caller still writes any refresh/client secret into
95
+ * `oauth_states` — the account can be rotated to OAuth later without
96
+ * losing it — but today's dispatch sends the key as a bare bearer and
97
+ * works. Only when `authType === "oauth"` is explicit, or when there is
98
+ * no api key at all, does the credential come from the OAuth bundle.
83
99
  */
84
- function credentialOf(entry: Row): string | null {
85
- return text(entry.apiKey) ?? text(entry.accessToken) ?? text(entry.credential) ?? text(entry.token);
100
+ interface ResolvedConnectionCredential {
101
+ readonly value: string;
102
+ readonly kind: "api_key" | "oauth";
103
+ }
104
+ function credentialOf(entry: Row, provider: string): ResolvedConnectionCredential | null {
105
+ const apiKey = text(entry.apiKey);
106
+ const accessToken = text(entry.accessToken);
107
+ const fallback = text(entry.credential) ?? text(entry.token);
108
+ const authType = text(entry.authType);
109
+ const oauthExplicit = authType === "oauth";
110
+ const apiKeyExplicit = authType === "apikey" || authType === "api_key";
111
+ if (apiKey !== null && !oauthExplicit) {
112
+ return { value: apiKey, kind: "api_key" };
113
+ }
114
+ if (accessToken !== null) {
115
+ return { value: accessToken, kind: apiKeyExplicit ? "api_key" : OAUTH_PROVIDERS.has(provider) ? "oauth" : "api_key" };
116
+ }
117
+ if (fallback !== null) {
118
+ return { value: fallback, kind: oauthExplicit ? "oauth" : apiKeyExplicit ? "api_key" : OAUTH_PROVIDERS.has(provider) ? "oauth" : "api_key" };
119
+ }
120
+ return null;
86
121
  }
87
122
 
88
123
  /** OAuth-backed providers store a refresh token rather than a static key. */
@@ -232,14 +267,13 @@ export function convert9RouterBackup(input: unknown, tenantId: string): Conversi
232
267
  );
233
268
  return;
234
269
  }
235
- const credential = credentialOf(entry);
270
+ const credential = credentialOf(entry, provider);
236
271
  if (credential === null) {
237
272
  skipped.push(`${label}: no credential on the row`);
238
273
  return;
239
274
  }
240
275
  const accountId = crypto.randomUUID();
241
- const authType = text(entry.authType);
242
- const kind = authType === "oauth" || OAUTH_PROVIDERS.has(provider) ? "oauth" : "api_key";
276
+ const kind = credential.kind;
243
277
  accounts.push({
244
278
  id: accountId,
245
279
  provider_id: provider,
@@ -249,7 +283,7 @@ export function convert9RouterBackup(input: unknown, tenantId: string): Conversi
249
283
  // `SEALED_COLUMNS`. The store re-seals it under this instance's key on the
250
284
  // way in; sealing it here would put ciphertext in a payload whose only
251
285
  // portable form is the plaintext, and the restore would drop it.
252
- access_token: credential,
286
+ access_token: credential.value,
253
287
  credential_kind: kind,
254
288
  status: bool(entry.isActive, true) ? "active" : "disabled",
255
289
  consecutive_failures: 0,
@@ -1,92 +1,70 @@
1
1
  /**
2
2
  * Remote-support tunnel service.
3
3
  *
4
- * The tunnel is launched by an external script rather than by spawning
5
- * `cloudflared`/`tailscale` directly, for one reason that matters: the script
6
- * hands the publisher to the OS session, so the tunnel is not a child of this
7
- * gateway. A gateway restart, a hot reload, or a Ctrl-C in the owning terminal
8
- * therefore cannot kill an operator's live remote-support session.
4
+ * Launches `cloudflared` or `tailscale` detached from the gateway process, so
5
+ * a gateway restart, a hot reload, or a Ctrl-C in the owning terminal cannot
6
+ * kill an operator's live remote-support session. Earlier revisions shelled
7
+ * out to a `.cmd` + `.ps1` launcher pair; those were Windows-only, had to be
8
+ * carried through the npm tarball, and failed with "Launcher not found" the
9
+ * day the whitelist missed them. This module does the same work with
10
+ * `child_process.spawn` directly and runs on every host Node runs on.
9
11
  *
10
12
  * This service never installs anything. A missing binary is reported as a
11
13
  * blocker with install guidance; downloading a third-party agent on the
12
14
  * operator's behalf is out of scope.
13
15
  */
14
- import { spawn } from "node:child_process";
15
- import { existsSync } from "node:fs";
16
+ import { spawn, spawnSync } from "node:child_process";
17
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
18
  import { join } from "node:path";
17
- import { PACKAGE_ROOT } from "../../config/package-paths";
19
+ import { resolveUserConfigDir } from "../../config/package-paths";
18
20
  import {
19
21
  REMOTE_SUPPORT_INSTALL_HINT,
22
+ REMOTE_SUPPORT_TOOLS,
20
23
  type RemoteSupportBlocker,
21
24
  type RemoteSupportStatus,
22
25
  type RemoteSupportTool,
23
26
  } from "./contracts";
24
27
 
25
- /** How long the launcher may take to report a URL before we stop waiting. */
26
- const LAUNCH_TIMEOUT_MS = 40_000;
28
+ /** How long to tail the publisher's log before giving up on a URL. */
29
+ const URL_WAIT_MS = 25_000;
30
+ /** How often to re-scan the log file while waiting for the URL. */
31
+ const URL_POLL_MS = 500;
27
32
 
28
- /** Cached install detection so we don't spawn `where` on every status poll. */
33
+ /** Cached install detection so we don't spawn `where`/`which` on every poll. */
29
34
  const installedCache = new Map<RemoteSupportTool, boolean>();
30
35
 
31
36
  export interface RemoteSupportServiceConfig {
32
37
  /** Defaults to the gateway's configured port. */
33
38
  readonly port: number;
34
- /** Overridable for tests; defaults to the package root. */
35
- readonly scriptDir?: string;
36
- }
37
-
38
- interface CommandResult {
39
- readonly status: number;
40
- readonly stdout: string;
41
- }
42
-
43
- function scriptPath(config: RemoteSupportServiceConfig, name: string): string {
44
- const dir = config.scriptDir ?? join(PACKAGE_ROOT, "scripts");
45
- return join(dir, name);
46
- }
47
-
48
- function runScript(
49
- config: RemoteSupportServiceConfig,
50
- args: readonly string[],
51
- ): Promise<CommandResult> {
52
- const { promise, resolve, reject } = Promise.withResolvers<CommandResult>();
53
- const isWindows = process.platform === "win32";
54
- // Windows cannot spawn a `.cmd` directly — `spawn` rejects it with EINVAL
55
- // because a batch file is not an executable image. It has to be handed to
56
- // `cmd.exe /c`, which is also what lets the batch call `exit /b 3` and have
57
- // that code reach us as the child's status.
58
- const command = isWindows ? "cmd.exe" : "powershell";
59
- const fileName = isWindows ? "remote-support.cmd" : "remote-support.ps1";
60
- const file = scriptPath(config, fileName);
61
- const spawnArgs = isWindows
62
- ? ["/d", "/s", "/c", file, ...args]
63
- : ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", file, ...args];
64
- const child = spawn(command, spawnArgs, {
65
- stdio: ["ignore", "pipe", "pipe"],
66
- windowsHide: true,
67
- });
68
- let stdout = "";
69
- let stderr = "";
70
- child.stdout.setEncoding("utf8");
71
- child.stderr.setEncoding("utf8");
72
- child.stdout.on("data", (chunk: string) => {
73
- stdout += chunk;
74
- });
75
- child.stderr.on("data", (chunk: string) => {
76
- stderr += chunk;
77
- });
78
- const timer = setTimeout(() => child.kill(), LAUNCH_TIMEOUT_MS);
79
- child.on("error", reject);
80
- child.on("close", (status) => {
81
- clearTimeout(timer);
82
- resolve({ status: status ?? 1, stdout: `${stdout}${stderr}`.trim() });
83
- });
84
- return promise;
39
+ /** Overridable for tests. Defaults to a subdir of the user config dir. */
40
+ readonly stateDir?: string;
41
+ }
42
+
43
+ function stateDirOf(config: RemoteSupportServiceConfig): string {
44
+ return config.stateDir ?? join(resolveUserConfigDir(), "remote-support");
45
+ }
46
+
47
+ function ensureStateDir(dir: string): void {
48
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
49
+ }
50
+
51
+ function pidPath(dir: string, tool: RemoteSupportTool): string {
52
+ return join(dir, `${tool}.pid`);
53
+ }
54
+ function urlPath(dir: string, tool: RemoteSupportTool): string {
55
+ return join(dir, `${tool}.url`);
56
+ }
57
+ function logPath(dir: string, tool: RemoteSupportTool): string {
58
+ return join(dir, `${tool}.log`);
59
+ }
60
+ function errPath(dir: string, tool: RemoteSupportTool): string {
61
+ return join(dir, `${tool}.err`);
85
62
  }
86
63
 
87
64
  /**
88
- * `where`/`which` lookup, mirroring what the script does itself so the route
89
- * can answer `not_installed` with a clear message instead of a generic failure.
65
+ * `where` / `which` lookup, cached per process. Shelled out rather than coded
66
+ * because PATHEXT, Spotlight PATH injections, and nvm's shims make the
67
+ * resolved location non-trivial — the OS's own resolver gets it right.
90
68
  */
91
69
  function toolAvailable(tool: RemoteSupportTool): Promise<boolean> {
92
70
  const cached = installedCache.get(tool);
@@ -106,6 +84,179 @@ function toolAvailable(tool: RemoteSupportTool): Promise<boolean> {
106
84
  return promise;
107
85
  }
108
86
 
87
+ /** Non-blocking check: is `pid` still alive on this host? Cross-OS. */
88
+ function pidAlive(pid: number): boolean {
89
+ try {
90
+ process.kill(pid, 0);
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Reads the recorded PID file, returning `null` if the file is missing, empty,
99
+ * malformed, or names a process that is no longer alive. Dead files are
100
+ * cleaned up so a later start does not race against a stale record.
101
+ */
102
+ function readLivePid(dir: string, tool: RemoteSupportTool): number | null {
103
+ const file = pidPath(dir, tool);
104
+ if (!existsSync(file)) return null;
105
+ const raw = readFileSync(file, "utf8").trim();
106
+ const pid = Number.parseInt(raw, 10);
107
+ if (!Number.isFinite(pid) || pid <= 0) {
108
+ rmSync(file, { force: true });
109
+ return null;
110
+ }
111
+ if (!pidAlive(pid)) {
112
+ rmSync(file, { force: true });
113
+ return null;
114
+ }
115
+ return pid;
116
+ }
117
+
118
+ /**
119
+ * Terminates `pid` and its descendants. On Windows the tree has to be killed
120
+ * explicitly via `taskkill /T /F` — plain `process.kill` only signals the
121
+ * top-level process, and cloudflared under Windows spawns a worker that keeps
122
+ * the port open after the parent exits. On POSIX, SIGTERM to the group covers
123
+ * descendants spawned with `detached: true`.
124
+ */
125
+ function killTree(pid: number): void {
126
+ if (process.platform === "win32") {
127
+ spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
128
+ return;
129
+ }
130
+ try {
131
+ process.kill(-pid, "SIGTERM");
132
+ } catch {
133
+ try {
134
+ process.kill(pid, "SIGTERM");
135
+ } catch {
136
+ /* already dead */
137
+ }
138
+ }
139
+ }
140
+
141
+ /** Arguments for the detached publisher launch. Shape mirrors the old script. */
142
+ function launchArgsFor(tool: RemoteSupportTool, port: number): readonly string[] {
143
+ if (tool === "cloudflared") {
144
+ // `--url` prints the fresh *.trycloudflare.com hostname (to stderr).
145
+ return ["tunnel", "--url", `http://127.0.0.1:${port}`, "--no-autoupdate"];
146
+ }
147
+ // `serve --bg` returns immediately; the funnel listens on 443.
148
+ return ["serve", "--bg", "--https=443", `http://127.0.0.1:${port}`];
149
+ }
150
+
151
+ /** The pattern anchors to the tunnel's own hostname so Cloudflare's startup
152
+ * banner (which references `cloudflare.com`) is not mistaken for the URL. */
153
+ function urlPatternFor(tool: RemoteSupportTool): RegExp {
154
+ return tool === "cloudflared"
155
+ ? /https:\/\/[A-Za-z0-9-]+\.trycloudflare\.com/
156
+ : /https:\/\/[A-Za-z0-9.-]+\.ts\.net/;
157
+ }
158
+
159
+ /**
160
+ * Tails the publisher's stdout + stderr log files for the URL regex. Resolves
161
+ * with the first match or `null` after the overall deadline.
162
+ */
163
+ async function waitForUrl(dir: string, tool: RemoteSupportTool): Promise<string | null> {
164
+ const pattern = urlPatternFor(tool);
165
+ const deadline = Date.now() + URL_WAIT_MS;
166
+ const log = logPath(dir, tool);
167
+ const err = errPath(dir, tool);
168
+ while (Date.now() < deadline) {
169
+ for (const path of [log, err]) {
170
+ if (!existsSync(path)) continue;
171
+ const text = readFileSync(path, "utf8");
172
+ const match = pattern.exec(text);
173
+ if (match) return match[0];
174
+ }
175
+ const { promise, resolve } = Promise.withResolvers<void>();
176
+ setTimeout(resolve, URL_POLL_MS);
177
+ await promise;
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /**
183
+ * Last-resort hostname lookup for Tailscale.
184
+ *
185
+ * `tailscale serve --bg` prints nothing useful once it has backgrounded; the
186
+ * live hostname comes from the daemon's own status. The JSON shape has drifted
187
+ * across releases (`DNSName` at the root in some, nested under `Self` in
188
+ * others), so both locations are probed and the first hit wins.
189
+ */
190
+ function tailscaleUrlFromDaemon(): string | null {
191
+ const result = spawnSync("tailscale", ["serve", "status", "--json"], { encoding: "utf8", windowsHide: true });
192
+ if (result.status !== 0) return null;
193
+ let parsed: unknown;
194
+ try {
195
+ parsed = JSON.parse(result.stdout);
196
+ } catch {
197
+ return null;
198
+ }
199
+ const bag: Record<string, unknown> =
200
+ typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
201
+ const direct = typeof bag["DNSName"] === "string" ? (bag["DNSName"] as string) : null;
202
+ const self =
203
+ typeof bag["Self"] === "object" && bag["Self"] !== null ? (bag["Self"] as Record<string, unknown>) : null;
204
+ const nested = self && typeof self["DNSName"] === "string" ? (self["DNSName"] as string) : null;
205
+ const hostname = (direct ?? nested)?.trim().replace(/\/+$/, "");
206
+ return hostname && hostname.length > 0 ? `https://${hostname}` : null;
207
+ }
208
+
209
+ /**
210
+ * Starts `tool` detached, writes its logs to the state dir, records the pid,
211
+ * and waits briefly for the URL. The caller never becomes the publisher's
212
+ * parent — `detached` + `unref` + `ignore`-stdin cuts the inheritance chain.
213
+ */
214
+ async function launchDetached(
215
+ dir: string,
216
+ tool: RemoteSupportTool,
217
+ port: number,
218
+ ): Promise<{ pid: number; url: string | null; error: string | null }> {
219
+ const resolved = spawnSync(process.platform === "win32" ? "where" : "which", [tool], {
220
+ encoding: "utf8",
221
+ windowsHide: true,
222
+ });
223
+ if (resolved.status !== 0) {
224
+ return { pid: 0, url: null, error: `${tool} is not on PATH` };
225
+ }
226
+ const binary = resolved.stdout.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0);
227
+ if (!binary) {
228
+ return { pid: 0, url: null, error: `${tool} is not on PATH` };
229
+ }
230
+ ensureStateDir(dir);
231
+ rmSync(urlPath(dir, tool), { force: true });
232
+ rmSync(logPath(dir, tool), { force: true });
233
+ rmSync(errPath(dir, tool), { force: true });
234
+ const outFd = openSync(logPath(dir, tool), "a");
235
+ const errFd = openSync(errPath(dir, tool), "a");
236
+ try {
237
+ const child = spawn(binary, launchArgsFor(tool, port), {
238
+ detached: true,
239
+ windowsHide: true,
240
+ stdio: ["ignore", outFd, errFd],
241
+ });
242
+ const pid = child.pid;
243
+ if (pid === undefined) {
244
+ return { pid: 0, url: null, error: `failed to launch ${tool}` };
245
+ }
246
+ writeFileSync(pidPath(dir, tool), String(pid), "utf8");
247
+ child.unref();
248
+ let url = await waitForUrl(dir, tool);
249
+ if (!url && tool === "tailscale") {
250
+ url = tailscaleUrlFromDaemon();
251
+ }
252
+ if (url) writeFileSync(urlPath(dir, tool), url, "utf8");
253
+ return { pid, url, error: null };
254
+ } finally {
255
+ closeSync(outFd);
256
+ closeSync(errFd);
257
+ }
258
+ }
259
+
109
260
  export interface RemoteSupportService {
110
261
  status(): Promise<RemoteSupportStatus>;
111
262
  start(tool: RemoteSupportTool, port?: number): Promise<RemoteSupportStatus>;
@@ -114,37 +265,56 @@ export interface RemoteSupportService {
114
265
 
115
266
  export function createRemoteSupportService(config: RemoteSupportServiceConfig): RemoteSupportService {
116
267
  const port = config.port;
268
+ const dir = stateDirOf(config);
117
269
 
118
- /**
119
- * Install state for both publishers, cached per process.
120
- *
121
- * Every status carries it so the card can show "not installed" without the
122
- * browser probing on its own — but the probe is `where`/`which`, which is a
123
- * process spawn, so it is memoised here rather than run on each poll.
124
- */
125
270
  async function detectInstalled(): Promise<Readonly<Record<RemoteSupportTool, boolean>>> {
126
- return {
127
- cloudflared: await toolAvailable("cloudflared"),
128
- tailscale: await toolAvailable("tailscale"),
129
- } as const;
271
+ const [cloudflared, tailscale] = await Promise.all([
272
+ toolAvailable("cloudflared"),
273
+ toolAvailable("tailscale"),
274
+ ]);
275
+ return { cloudflared, tailscale } as const;
130
276
  }
131
277
 
132
- async function readStatus(blocker: RemoteSupportBlocker | null = null, message: string | null = null): Promise<RemoteSupportStatus> {
133
- const result = await runScript(config, ["status"]);
134
- const text = result.stdout;
135
- const active: RemoteSupportTool | null = text.includes("cloudflared running")
136
- ? "cloudflared"
137
- : text.includes("tailscale running")
138
- ? "tailscale"
139
- : null;
140
- let url: string | null = null;
141
- if (active !== null) {
142
- const urlResult = await runScript(config, ["url"]);
143
- const candidate = urlResult.stdout.trim().split(/\s+/).find((line) => line.startsWith("https://"));
144
- url = candidate ?? null;
278
+ /** Which publisher, if any, is currently alive on this host. */
279
+ function detectActive(): { tool: RemoteSupportTool | null; url: string | null } {
280
+ for (const tool of REMOTE_SUPPORT_TOOLS) {
281
+ // Tailscale runs as a system service — its serve daemon owns its own
282
+ // lifecycle, so a pid file is not reliable. Ask the daemon.
283
+ if (tool === "tailscale") {
284
+ const url = tailscaleUrlFromDaemon();
285
+ if (url) return { tool: "tailscale", url };
286
+ continue;
287
+ }
288
+ const pid = readLivePid(dir, tool);
289
+ if (pid === null) continue;
290
+ const recorded = existsSync(urlPath(dir, tool)) ? readFileSync(urlPath(dir, tool), "utf8").trim() : "";
291
+ return { tool, url: recorded.length > 0 ? recorded : null };
145
292
  }
293
+ return { tool: null, url: null };
294
+ }
295
+
296
+ async function readStatus(
297
+ blocker: RemoteSupportBlocker | null = null,
298
+ message: string | null = null,
299
+ ): Promise<RemoteSupportStatus> {
300
+ const { tool, url } = detectActive();
146
301
  const installed = await detectInstalled();
147
- return { active, url, blocker, message, port, installed };
302
+ return { active: tool, url, blocker, message, port, installed };
303
+ }
304
+
305
+ async function stopTool(tool: RemoteSupportTool): Promise<void> {
306
+ if (tool === "tailscale") {
307
+ // The daemon owns the serve lifecycle; `serve reset` is the sanctioned
308
+ // teardown and leaves no orphan process behind.
309
+ spawnSync("tailscale", ["serve", "reset"], { stdio: "ignore", windowsHide: true });
310
+ rmSync(urlPath(dir, tool), { force: true });
311
+ rmSync(pidPath(dir, tool), { force: true });
312
+ return;
313
+ }
314
+ const pid = readLivePid(dir, tool);
315
+ if (pid !== null) killTree(pid);
316
+ rmSync(pidPath(dir, tool), { force: true });
317
+ rmSync(urlPath(dir, tool), { force: true });
148
318
  }
149
319
 
150
320
  return {
@@ -153,27 +323,6 @@ export function createRemoteSupportService(config: RemoteSupportServiceConfig):
153
323
  },
154
324
 
155
325
  async start(tool, requestedPort) {
156
- if (process.platform !== "win32") {
157
- return {
158
- active: null,
159
- url: null,
160
- blocker: "unsupported_platform",
161
- message: "Remote support is currently scripted for Windows hosts only.",
162
- port,
163
- installed: { cloudflared: false, tailscale: false },
164
- };
165
- }
166
- const launcher = scriptPath(config, "remote-support.cmd");
167
- if (!existsSync(launcher)) {
168
- return {
169
- active: null,
170
- url: null,
171
- blocker: "launch_failed",
172
- message: `Launcher not found: ${launcher}`,
173
- port,
174
- installed: await detectInstalled(),
175
- };
176
- }
177
326
  if (!(await toolAvailable(tool))) {
178
327
  return {
179
328
  active: null,
@@ -184,14 +333,17 @@ export function createRemoteSupportService(config: RemoteSupportServiceConfig):
184
333
  installed: await detectInstalled(),
185
334
  };
186
335
  }
336
+ // Replace any stale run of the same tool before launching a new one, so
337
+ // two instances never fight for the same local port.
338
+ await stopTool(tool);
187
339
  const target = requestedPort ?? port;
188
- const result = await runScript(config, ["start", tool, String(target)]);
189
- if (result.status !== 0 && !result.stdout.includes("started")) {
340
+ const result = await launchDetached(dir, tool, target);
341
+ if (result.error) {
190
342
  return {
191
343
  active: null,
192
344
  url: null,
193
345
  blocker: "launch_failed",
194
- message: result.stdout || `Failed to start ${tool}.`,
346
+ message: result.error,
195
347
  port,
196
348
  installed: await detectInstalled(),
197
349
  };
@@ -200,7 +352,11 @@ export function createRemoteSupportService(config: RemoteSupportServiceConfig):
200
352
  },
201
353
 
202
354
  async stop(tool) {
203
- await runScript(config, tool === undefined ? ["stop"] : ["stop", tool]);
355
+ if (tool === undefined) {
356
+ for (const name of REMOTE_SUPPORT_TOOLS) await stopTool(name);
357
+ } else {
358
+ await stopTool(tool);
359
+ }
204
360
  return await readStatus();
205
361
  },
206
362
  };
@@ -16,6 +16,7 @@ import { classifyPoolConnectError, classifyPoolProbeResponse, parseEgressIp } fr
16
16
  import { encryptCredential } from "../../../security/crypto";
17
17
  import { createValidatedFetch } from "../../../network/outbound-fetch";
18
18
  import { classifyProviderProbeAnswer } from "./probe-verdict";
19
+ import { buildProviderProbeRequest } from "../../../providers/provider-probe";
19
20
  import type { StoredProviderProbe } from "./contracts";
20
21
  import {
21
22
  createHttpProxyAgent,
@@ -302,7 +303,6 @@ export class DrizzleNetworkPoolStore implements NetworkPoolStore {
302
303
  providerId: string,
303
304
  poolId?: string,
304
305
  ): Promise<PoolProviderProbeResult> {
305
- const { buildProviderProbeRequest } = await import("../../../providers/provider-probe");
306
306
  const spec = await buildProviderProbeRequest(providerId);
307
307
  if (!spec) {
308
308
  return { poolId: label, providerId, status: "unreachable", reason: "Unknown provider" };