@moondeck/station 0.0.1 → 1.0.1

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 (330) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/0031_first_turn_receipts.sql +9 -0
  36. package/db/migrations/meta/_journal.json +230 -0
  37. package/db/schema.ts +871 -0
  38. package/images/standard/Dockerfile +347 -0
  39. package/images/standard/README.md +167 -0
  40. package/images/standard/container/code-server-settings.json +63 -0
  41. package/images/standard/container/corepack-version +1 -0
  42. package/images/standard/container/uai-init +704 -0
  43. package/images/standard/container/uai-materialize-runtimes +1527 -0
  44. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  45. package/images/standard/tool-versions +2 -0
  46. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  47. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  48. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  49. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  50. package/lib/agent-cli.ts +1507 -0
  51. package/lib/agent-errors.ts +22 -0
  52. package/lib/agent.ts +672 -0
  53. package/lib/agents/attach-compatibility.ts +51 -0
  54. package/lib/agents/claude.ts +918 -0
  55. package/lib/agents/codex-token-usage.fixture.json +130 -0
  56. package/lib/agents/codex.ts +1392 -0
  57. package/lib/agents/cursor-period-usage.ts +253 -0
  58. package/lib/agents/cursor.ts +562 -0
  59. package/lib/agents/dispatch.ts +78 -0
  60. package/lib/agents/durable-proc.ts +334 -0
  61. package/lib/agents/factory.ts +57 -0
  62. package/lib/agents/grok.ts +381 -0
  63. package/lib/agents/kimi.ts +329 -0
  64. package/lib/agents/machine-durable.ts +604 -0
  65. package/lib/agents/mock.ts +133 -0
  66. package/lib/agents/mode.ts +64 -0
  67. package/lib/agents/opencode.ts +508 -0
  68. package/lib/agents/output-source.ts +3 -0
  69. package/lib/agents/proc.ts +221 -0
  70. package/lib/agents/rate-limit.ts +337 -0
  71. package/lib/agents/registry.ts +182 -0
  72. package/lib/agents/session-state.ts +197 -0
  73. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  74. package/lib/agents/transport.ts +741 -0
  75. package/lib/agents/types.ts +254 -0
  76. package/lib/agents/uai.ts +106 -0
  77. package/lib/agents/usage.ts +44 -0
  78. package/lib/apple-one-off.ts +277 -0
  79. package/lib/apple-runtime-recycle.ts +237 -0
  80. package/lib/apple-uninstall-teardown.ts +224 -0
  81. package/lib/assignment-evidence.ts +94 -0
  82. package/lib/attachments.ts +48 -0
  83. package/lib/browser-testing.ts +1537 -0
  84. package/lib/capture-flow.ts +332 -0
  85. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  86. package/lib/cloud-credential-host.ts +512 -0
  87. package/lib/cloud-credential-leases.ts +2587 -0
  88. package/lib/cloud-credential-runtime.ts +1357 -0
  89. package/lib/cloud-state.ts +56 -0
  90. package/lib/codex-auth.ts +456 -0
  91. package/lib/command-db.ts +310 -0
  92. package/lib/compose-network-recovery.ts +283 -0
  93. package/lib/connection-secret-redaction.ts +25 -0
  94. package/lib/container-runtime.ts +1470 -0
  95. package/lib/credential-cleanup-inventory.ts +490 -0
  96. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  97. package/lib/credential-consumers/engines.ts +797 -0
  98. package/lib/credential-consumers/files.ts +934 -0
  99. package/lib/credential-consumers/github.ts +702 -0
  100. package/lib/credential-consumers/mcp.ts +389 -0
  101. package/lib/credential-consumers/task-files.ts +127 -0
  102. package/lib/credential-consumers/task-secrets.ts +10 -0
  103. package/lib/credential-store-pickup.ts +149 -0
  104. package/lib/credential-store-scope.ts +112 -0
  105. package/lib/credential-store.ts +932 -0
  106. package/lib/credentials-activation-authority.ts +709 -0
  107. package/lib/credentials-purge.ts +1006 -0
  108. package/lib/custom-connection-capture.ts +395 -0
  109. package/lib/custom-connection-launch.ts +384 -0
  110. package/lib/custom-connection-runtime.ts +141 -0
  111. package/lib/db.ts +80 -0
  112. package/lib/disk-pressure.ts +42 -0
  113. package/lib/docker-exec.ts +156 -0
  114. package/lib/engine-accounts.ts +1970 -0
  115. package/lib/engine-connect.ts +321 -0
  116. package/lib/engine-credential-env.ts +52 -0
  117. package/lib/engine-login.ts +2933 -0
  118. package/lib/engines.ts +1342 -0
  119. package/lib/enrollment-state.ts +251 -0
  120. package/lib/env-file.ts +246 -0
  121. package/lib/env-schema.ts +937 -0
  122. package/lib/env.ts +195 -0
  123. package/lib/first-turn-receipts.ts +62 -0
  124. package/lib/fs-aliases.ts +49 -0
  125. package/lib/git-clone-credentials.ts +334 -0
  126. package/lib/git-diff.ts +530 -0
  127. package/lib/git-identity.ts +715 -0
  128. package/lib/github-git-auth.ts +207 -0
  129. package/lib/github-origin.ts +28 -0
  130. package/lib/github-tokens.ts +2439 -0
  131. package/lib/host-cloud-url.ts +62 -0
  132. package/lib/host-config.ts +279 -0
  133. package/lib/host-env.ts +424 -0
  134. package/lib/host-logs.ts +965 -0
  135. package/lib/internal-connections.ts +5 -0
  136. package/lib/keyed-promise-tail.ts +23 -0
  137. package/lib/launch-mode.ts +25 -0
  138. package/lib/lease.ts +239 -0
  139. package/lib/legacy-host.ts +769 -0
  140. package/lib/legacy-runtime-retirement.ts +424 -0
  141. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  142. package/lib/machine-exec.ts +238 -0
  143. package/lib/machine-gateway-tunnel.ts +344 -0
  144. package/lib/machine-keys.ts +65 -0
  145. package/lib/machine-project-env-loader.ts +152 -0
  146. package/lib/machine-provider-aws.ts +923 -0
  147. package/lib/machine-provider-local.ts +338 -0
  148. package/lib/machine-provider.ts +90 -0
  149. package/lib/managed-activation-watcher.ts +72 -0
  150. package/lib/managed-install-owner-watcher.ts +55 -0
  151. package/lib/managed-operation-drain.ts +57 -0
  152. package/lib/managed-runtime.ts +4296 -0
  153. package/lib/managed-update-scheduler.ts +125 -0
  154. package/lib/mcp-capture.ts +865 -0
  155. package/lib/mcp-config-lock.ts +2 -0
  156. package/lib/mcp-connections.ts +959 -0
  157. package/lib/mcp-gateway.ts +2594 -0
  158. package/lib/obs.ts +512 -0
  159. package/lib/openrouter-budget.ts +247 -0
  160. package/lib/orchestrator.ts +11409 -0
  161. package/lib/person-capture.ts +327 -0
  162. package/lib/preview-ports.ts +85 -0
  163. package/lib/preview-sidecar.ts +331 -0
  164. package/lib/project-env-authority.ts +1319 -0
  165. package/lib/project-env-feature.ts +74 -0
  166. package/lib/project-env-legacy-tags.ts +130 -0
  167. package/lib/project-env-loader.ts +242 -0
  168. package/lib/project-env-replica.ts +2094 -0
  169. package/lib/project-env-schema-capture.ts +342 -0
  170. package/lib/project-env-staging.ts +148 -0
  171. package/lib/project-env-task-policy.ts +176 -0
  172. package/lib/project-env-task-replica.ts +1752 -0
  173. package/lib/release-feed.ts +61 -0
  174. package/lib/release-manifest.ts +710 -0
  175. package/lib/release-trust.ts +46 -0
  176. package/lib/repo-clone.ts +38 -0
  177. package/lib/routing/shadow.ts +261 -0
  178. package/lib/runner-boot.ts +10 -0
  179. package/lib/runtime-activation-tail.ts +279 -0
  180. package/lib/runtime-archive.ts +1216 -0
  181. package/lib/runtime-authority.ts +166 -0
  182. package/lib/runtime-guard.ts +36 -0
  183. package/lib/runtime-provider-state.ts +169 -0
  184. package/lib/runtime-reconnect.ts +86 -0
  185. package/lib/runtime-state.ts +1033 -0
  186. package/lib/safe-http.ts +364 -0
  187. package/lib/secrets-proxy/address.ts +95 -0
  188. package/lib/secrets-proxy/audit.ts +30 -0
  189. package/lib/secrets-proxy/ca-store.ts +200 -0
  190. package/lib/secrets-proxy/ca.ts +470 -0
  191. package/lib/secrets-proxy/container-steps.ts +138 -0
  192. package/lib/secrets-proxy/der.ts +273 -0
  193. package/lib/secrets-proxy/engine.ts +1633 -0
  194. package/lib/secrets-proxy/host.ts +93 -0
  195. package/lib/secrets-proxy/hostname.ts +57 -0
  196. package/lib/secrets-proxy/placeholders.ts +196 -0
  197. package/lib/secrets-proxy/policy.ts +150 -0
  198. package/lib/secrets-proxy/readiness.ts +148 -0
  199. package/lib/secrets-proxy/runtime.ts +582 -0
  200. package/lib/secrets-proxy/scrub.ts +559 -0
  201. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  202. package/lib/secrets-proxy/task-scripts.ts +262 -0
  203. package/lib/secrets-proxy/task-trust.ts +605 -0
  204. package/lib/secrets-proxy/trust-watch.ts +80 -0
  205. package/lib/secrets.ts +71 -0
  206. package/lib/shared-files-s3.ts +976 -0
  207. package/lib/shared-files.ts +156 -0
  208. package/lib/sidecar-assign.ts +49 -0
  209. package/lib/sidecar-guard.ts +58 -0
  210. package/lib/sidecar-resume.ts +178 -0
  211. package/lib/sidecar.ts +69 -0
  212. package/lib/signin-box-credentials.ts +465 -0
  213. package/lib/signin-box.ts +114 -0
  214. package/lib/skill-index-example.fixture.ts +56 -0
  215. package/lib/skill-layout.ts +417 -0
  216. package/lib/skills.ts +489 -0
  217. package/lib/ssh.ts +401 -0
  218. package/lib/standard-image-cleanup.ts +117 -0
  219. package/lib/standard-image-pin.ts +207 -0
  220. package/lib/standard-image.ts +1697 -0
  221. package/lib/stopped-task-status-queue.ts +120 -0
  222. package/lib/task-container-cli.ts +269 -0
  223. package/lib/task-credential-cleanup.ts +752 -0
  224. package/lib/task-diff-sandbox.ts +464 -0
  225. package/lib/task-diff.ts +91 -0
  226. package/lib/task-env-forwarder.ts +228 -0
  227. package/lib/task-env-source.ts +249 -0
  228. package/lib/task-environment/anchor.ts +31 -0
  229. package/lib/task-environment/apple-container.ts +860 -0
  230. package/lib/task-environment/docker.ts +1344 -0
  231. package/lib/task-environment/index.ts +466 -0
  232. package/lib/task-environment/legacy-adoption.ts +467 -0
  233. package/lib/task-environment/machine-environment.ts +61 -0
  234. package/lib/task-environment/machine-task-up.ts +1227 -0
  235. package/lib/task-environment/machine.ts +974 -0
  236. package/lib/task-environment/registry.ts +59 -0
  237. package/lib/task-environment/self-identity.ts +20 -0
  238. package/lib/task-environment/self.ts +566 -0
  239. package/lib/task-environment/types.ts +642 -0
  240. package/lib/task-environment/workspace-files.ts +685 -0
  241. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  242. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  243. package/lib/task-files-machine-retirement.ts +116 -0
  244. package/lib/task-files-readiness.ts +127 -0
  245. package/lib/task-gateway-port.ts +22 -0
  246. package/lib/task-gateway-protocol.ts +190 -0
  247. package/lib/task-gateway-recovery.ts +47 -0
  248. package/lib/task-gateway-state.ts +512 -0
  249. package/lib/task-identity.ts +19 -0
  250. package/lib/task-inventory.ts +682 -0
  251. package/lib/task-machine-credentials-activation.ts +95 -0
  252. package/lib/task-machine-swap-safety.ts +729 -0
  253. package/lib/task-person-credentials.ts +76 -0
  254. package/lib/task-project-ops-host.ts +147 -0
  255. package/lib/task-project-ops.ts +1062 -0
  256. package/lib/task-secret-staging.ts +16 -0
  257. package/lib/task-status.ts +46 -0
  258. package/lib/task-token.ts +46 -0
  259. package/lib/token-broker-adopt.ts +344 -0
  260. package/lib/token-broker-lambda.ts +105 -0
  261. package/lib/token-broker-local.ts +392 -0
  262. package/lib/token-broker-revision.ts +310 -0
  263. package/lib/token-broker-ssm.ts +525 -0
  264. package/lib/token-broker.ts +218 -0
  265. package/lib/token-lanes/openai-oauth.ts +123 -0
  266. package/lib/token-lanes/registry.ts +45 -0
  267. package/lib/token-lanes/types.ts +163 -0
  268. package/lib/token-lanes/xai-oauth.ts +165 -0
  269. package/lib/transcript.ts +128 -0
  270. package/lib/tunnel-registry.ts +220 -0
  271. package/lib/tunnel-runtime.ts +235 -0
  272. package/lib/ulid.ts +16 -0
  273. package/lib/user-bus-env.ts +116 -0
  274. package/lib/user-credentials-store.ts +435 -0
  275. package/lib/vnode-pressure.ts +142 -0
  276. package/package.json +115 -8
  277. package/runner/runner.mjs +442 -0
  278. package/scripts/agent/_common.sh +633 -0
  279. package/scripts/agent/env-hook.sh +626 -0
  280. package/scripts/agent/moondeck-cli.sh +5 -0
  281. package/scripts/agent/task-down.sh +291 -0
  282. package/scripts/agent/task-status.sh +70 -0
  283. package/scripts/agent/task-up.sh +3761 -0
  284. package/scripts/agent/uai-env-exec.sh +26 -0
  285. package/scripts/install/darwin.ts +1048 -0
  286. package/scripts/install/linux.ts +958 -0
  287. package/scripts/install/types.ts +78 -0
  288. package/scripts/install/util.ts +254 -0
  289. package/scripts/install/win.ts +143 -0
  290. package/src/activation-respawn.ts +155 -0
  291. package/src/apple-tunnel-route.ts +104 -0
  292. package/src/bridge-diagnostics.ts +39 -0
  293. package/src/capture-wire.ts +102 -0
  294. package/src/cli.ts +2585 -0
  295. package/src/cloud-credentials.ts +2144 -0
  296. package/src/cloud-origins.ts +23 -0
  297. package/src/credential-location.ts +248 -0
  298. package/src/credential-wire-validation.ts +25 -0
  299. package/src/custom-connections.ts +237 -0
  300. package/src/engine-account-paths.ts +11 -0
  301. package/src/engine-models.ts +39 -0
  302. package/src/engine-usage.ts +104 -0
  303. package/src/enrollment-next-steps.ts +24 -0
  304. package/src/event-outbox.ts +185 -0
  305. package/src/index.ts +2907 -0
  306. package/src/load-env.ts +97 -0
  307. package/src/machine-tunnel-route.ts +156 -0
  308. package/src/main.ts +6363 -0
  309. package/src/never-proxied-domains.ts +31 -0
  310. package/src/outbound-frame.ts +31 -0
  311. package/src/paths.ts +81 -0
  312. package/src/project-env-latest.ts +130 -0
  313. package/src/project-env-policy.ts +396 -0
  314. package/src/project-env-wording.ts +53 -0
  315. package/src/protocol.ts +5550 -0
  316. package/src/runtime-bootstrap.ts +165 -0
  317. package/src/station-identity.ts +96 -0
  318. package/src/task-env-names.ts +28 -0
  319. package/src/task-secrets.ts +75 -0
  320. package/src/tunnel-dial.ts +102 -0
  321. package/src/ui/server.ts +852 -0
  322. package/src/ui/types.ts +173 -0
  323. package/src/workspace-path-policy.ts +41 -0
  324. package/ui/app.js +1102 -0
  325. package/ui/index.html +90 -0
  326. package/ui/style.css +685 -0
  327. package/ui/uai-favicon.svg +21 -0
  328. package/ui/uai-logo-black.svg +9 -0
  329. package/ui/uai-wheel.svg +9 -0
  330. package/bin/moondeck-station.mjs +0 -3
package/lib/engines.ts ADDED
@@ -0,0 +1,1342 @@
1
+ /**
2
+ * Engine connect/disconnect for the local host UI (ADR-028, ADR-044 P2).
3
+ *
4
+ * A single descriptor table for the AI coding engines the host can run
5
+ * (Claude, Codex, Kimi, Grok, Cursor) plus the connect/disconnect/status logic
6
+ * the local UI drives (the desktop app renders that same UI). It writes the
7
+ * `.env.local` the RUNNING launchd/npm host reads (UAI_HOME) and sets
8
+ * `process.env` so a connect takes effect WITHOUT a host restart — the
9
+ * adapters' `available()` and task-up's env injection both read `process.env`.
10
+ *
11
+ * CLI resolution: a host supervised by the desktop app (or launchd) inherits a
12
+ * minimal GUI PATH that lacks wherever the engine CLIs live (~/.local/bin,
13
+ * npm/nvm/asdf prefixes, Homebrew), so spawning a bare `claude`/`codex` would
14
+ * ENOENT even though the CLI is installed. We resolve binaries the way the
15
+ * owner's terminal would — well-known install dirs first, then the login
16
+ * shell's PATH (same trick as the desktop's docker.ts) — and spawn with that
17
+ * PATH so `#!/usr/bin/env node` shebangs resolve too.
18
+ *
19
+ * Three auth modes:
20
+ * - token-command (Claude): `claude setup-token` shows a token; we run the
21
+ * CLI under a PTY (script(1)), capture the `sk-ant-oat…`
22
+ * value live from the terminal stream and persist it as
23
+ * CLAUDE_CODE_OAUTH_TOKEN. A pasted token is accepted too.
24
+ * The PTY is load-bearing: the CLI's Ink UI buffers ALL
25
+ * output until unmount when stdout isn't a TTY and its
26
+ * final screens wait for a keypress — under plain pipes it
27
+ * emits nothing and never exits (the eternal "Connecting…"
28
+ * of 0.8.11). We resolve as soon as the token appears and
29
+ * kill the CLI rather than wait for it to end on its own.
30
+ * - login-command (Codex/Kimi/Grok): `<cli> login` runs a browser OAuth and
31
+ * writes a config file; success = that file appearing.
32
+ * - api-key (Cursor): no command — persist a pasted CURSOR_API_KEY.
33
+ *
34
+ * Besides its primary mode, an engine can accept a pasted API key as an
35
+ * alternative (`apiKeyHint`/`apiKeyUrl` in the catalog drive the UI):
36
+ * - claude → ANTHROPIC_API_KEY (the adapter already forwards it; a pasted
37
+ * `sk-ant-oat…` still lands in CLAUDE_CODE_OAUTH_TOKEN — classified by
38
+ * prefix, so either credential works in either paste box).
39
+ * - codex → ~/.codex/auth.json `{"OPENAI_API_KEY": …}`, the same plaintext
40
+ * file `codex login --with-api-key` writes. Written directly (not via the
41
+ * CLI): task-up docker-cp's that FILE into containers, so a keychain-backed
42
+ * CLI login would break task auth, and this works with no host CLI at all.
43
+ * - grok → XAI_API_KEY (documented pay-as-you-go fallback of the grok CLI;
44
+ * the adapter forwards it into task containers).
45
+ * - kimi → none: Kimi Code reads keys only from its config.toml via the
46
+ * interactive `/login`, never from the environment — so we don't offer a
47
+ * paste box we can't honor.
48
+ *
49
+ * Detection mirrors each adapter's `available()`: env/`.env.local` for
50
+ * claude/cursor, a config file under the owner home for codex/kimi/grok.
51
+ *
52
+ * Every side-effecting seam (spawn, the `.env.local` path, the owner home,
53
+ * process.env) is injectable so the flows are unit-testable without touching
54
+ * the real filesystem or spawning anything.
55
+ */
56
+
57
+ import {
58
+ execFile as nodeExecFile,
59
+ spawn as nodeSpawn,
60
+ type ChildProcess,
61
+ } from "node:child_process";
62
+ import {
63
+ chmodSync,
64
+ existsSync,
65
+ mkdirSync,
66
+ readdirSync,
67
+ readFileSync,
68
+ rmSync,
69
+ writeFileSync,
70
+ } from "node:fs";
71
+ import { homedir } from "node:os";
72
+ import { dirname, join } from "node:path";
73
+
74
+ import { env } from "./env";
75
+
76
+ export type EngineKind =
77
+ | "claude"
78
+ | "codex"
79
+ | "kimi"
80
+ | "grok"
81
+ | "cursor"
82
+ | "opencode";
83
+ export type EngineAuthMode =
84
+ | "token-command"
85
+ | "login-command"
86
+ | "api-key"
87
+ // The owner signs in with the engine's OWN interactive CLI in their terminal
88
+ // (e.g. `opencode auth login` — an arrow-key TUI we can't drive from the
89
+ // fire-and-log connect panel), and we just detect the credential it writes.
90
+ | "external-login";
91
+
92
+ /** One entry of the engine catalog the UI's "Add engine" panel renders. */
93
+ export interface EngineCatalogEntry {
94
+ kind: EngineKind;
95
+ label: string;
96
+ authMode: EngineAuthMode;
97
+ /** One-line help shown under the engine in the picker. */
98
+ notes: string | null;
99
+ /** Where to mint an API key (api-key mode only). */
100
+ getKeyUrl: string | null;
101
+ /** Placeholder for the pasted-API-key alternative; null = not supported. */
102
+ apiKeyHint: string | null;
103
+ /** Where to mint a key for the pasted-API-key alternative. */
104
+ apiKeyUrl: string | null;
105
+ /**
106
+ * Human-readable install command the UI shows next to "Install & connect"
107
+ * when the CLI is missing (null = nothing to install, e.g. Cursor).
108
+ */
109
+ installHint: string | null;
110
+ /**
111
+ * The command the owner runs in their OWN terminal to sign in
112
+ * (external-login mode only; null otherwise).
113
+ */
114
+ loginCmd: string | null;
115
+ }
116
+
117
+ interface EngineDescriptor {
118
+ label: string;
119
+ authMode: EngineAuthMode;
120
+ notes: string;
121
+ getKeyUrl?: string;
122
+ /** Pasted-API-key alternative (absent = login/token only, e.g. kimi). */
123
+ apiKeyHint?: string;
124
+ apiKeyUrl?: string;
125
+ /** external-login: the CLI command the owner runs in their terminal. */
126
+ loginCmd?: string;
127
+ }
128
+
129
+ /** Static descriptor table — the single source of truth for engine metadata. */
130
+ const DESCRIPTORS: Record<EngineKind, EngineDescriptor> = {
131
+ claude: {
132
+ label: "Claude",
133
+ authMode: "token-command",
134
+ notes:
135
+ "Opens your browser to authorize Claude, then captures the token automatically.",
136
+ apiKeyHint: "sk-ant-…",
137
+ apiKeyUrl: "https://console.anthropic.com/settings/keys",
138
+ },
139
+ codex: {
140
+ label: "Codex",
141
+ authMode: "login-command",
142
+ notes: "Opens your browser to sign in to your OpenAI Codex account.",
143
+ apiKeyHint: "sk-…",
144
+ apiKeyUrl: "https://platform.openai.com/api-keys",
145
+ },
146
+ kimi: {
147
+ label: "Kimi Code",
148
+ authMode: "login-command",
149
+ notes:
150
+ "Sign in with your Moonshot Kimi Code subscription. Activates after the next image rebuild.",
151
+ },
152
+ grok: {
153
+ label: "Grok",
154
+ authMode: "login-command",
155
+ notes:
156
+ "Sign in with your xAI Grok subscription. Activates after the next image rebuild.",
157
+ apiKeyHint: "xai-…",
158
+ apiKeyUrl: "https://console.x.ai",
159
+ },
160
+ cursor: {
161
+ label: "Cursor",
162
+ authMode: "api-key",
163
+ notes: "Paste a Cursor API key. Requires Cursor Pro.",
164
+ getKeyUrl: "https://cursor.com/dashboard?tab=integrations",
165
+ },
166
+ opencode: {
167
+ label: "OpenCode",
168
+ // `opencode auth login` is an interactive arrow-key TUI (provider picker →
169
+ // per-provider auth) — it can't be driven from the fire-and-log connect
170
+ // panel, so the owner runs it in their own terminal and we detect the
171
+ // auth.json it writes.
172
+ authMode: "external-login",
173
+ loginCmd: "opencode auth login",
174
+ notes:
175
+ "Sign in from your terminal with `opencode auth login` (an interactive picker), then check the connection here. Activates after the next image rebuild.",
176
+ },
177
+ };
178
+
179
+ /** Display order (matches the cloud picker's ordering intent). */
180
+ const ORDER: EngineKind[] = [
181
+ "claude",
182
+ "codex",
183
+ "kimi",
184
+ "grok",
185
+ "cursor",
186
+ "opencode",
187
+ ];
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Injectable seams.
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /** How a login/token command is spawned. Tests provide a fake. */
194
+ export type EngineSpawn = (
195
+ command: string,
196
+ args: string[],
197
+ env: NodeJS.ProcessEnv,
198
+ ) => ChildProcess;
199
+
200
+ export interface EngineSeams {
201
+ /** Spawn a CLI, piping stdin/stdout/stderr. */
202
+ spawn: EngineSpawn;
203
+ /** Absolute path to the `.env.local` the running host reads (UAI_HOME). */
204
+ envLocalPath: () => string;
205
+ /** The owner's real home — where codex/kimi/grok write their config dirs. */
206
+ ownerHome: () => string;
207
+ /** The live process env (adapters + task-up read creds from here). */
208
+ procEnv: NodeJS.ProcessEnv;
209
+ /** PATH as the owner's login shell sees it (null when unprobeable). */
210
+ loginShellPath: () => Promise<string | null>;
211
+ /** System-wide bin dirs probed for engine CLIs (tests pin to []). */
212
+ systemBinDirs: string[];
213
+ /**
214
+ * Wrap a CLI invocation so the child gets a real PTY (script(1)), or null
215
+ * to spawn directly. Tests pin null; token-command needs the wrap (see
216
+ * defaultPtyWrap for why).
217
+ */
218
+ ptyWrap: ((bin: string, args: string[]) => PtyCommand | null) | null;
219
+ }
220
+
221
+ /** A wrapped invocation: what to actually spawn to run a CLI under a PTY. */
222
+ export interface PtyCommand {
223
+ command: string;
224
+ args: string[];
225
+ }
226
+
227
+ function defaultSeams(): EngineSeams {
228
+ return {
229
+ spawn: (command, args, spawnEnv) =>
230
+ nodeSpawn(command, args, {
231
+ // macOS: stdin MUST be /dev/null ("ignore"), never a node pipe. Node's
232
+ // child pipes are SOCKETPAIRS, and Apple's BSD script(1) hard-exits on
233
+ // a socket stdin ("tcgetattr/ioctl: Operation not supported on
234
+ // socket") while tolerating /dev/null (ENOTTY → notty mode). The
235
+ // "Press Enter" auto-ack is therefore Linux-only; the real token flow
236
+ // needs no input before the token renders (verified empirically).
237
+ stdio: [
238
+ process.platform === "darwin" ? "ignore" : "pipe",
239
+ "pipe",
240
+ "pipe",
241
+ ],
242
+ env: spawnEnv,
243
+ }),
244
+ envLocalPath: () => join(env.uaiHome, ".env.local"),
245
+ ownerHome: () => process.env.UAI_OWNER_HOME?.trim() || homedir(),
246
+ procEnv: process.env,
247
+ loginShellPath: probeLoginShellPath,
248
+ systemBinDirs: ["/opt/homebrew/bin", "/usr/local/bin"],
249
+ ptyWrap: defaultPtyWrap,
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Lend a CLI a real PTY via script(1) — no native deps, ships with macOS
255
+ * (BSD) and Linux (util-linux; argument conventions differ, hence the
256
+ * branch). Ink-based CLIs (claude) render nothing under plain pipes: Ink
257
+ * detects a non-TTY stdout and buffers every frame until unmount, and the
258
+ * interactive screens can't complete without a terminal — so the child sat
259
+ * silent forever. The inner `stty cols 400` matters too: at the default 80
260
+ * columns the token line WRAPS and a wrapped token extracts truncated.
261
+ */
262
+ function defaultPtyWrap(bin: string, args: string[]): PtyCommand | null {
263
+ if (!existsSync("/usr/bin/script")) return null;
264
+ const stty = "stty cols 400 rows 100 2>/dev/null";
265
+ if (process.platform === "darwin") {
266
+ // BSD script: `script -q /dev/null command [args…]` — command is exec'd
267
+ // as an argv (no shell), so route through sh only to set the width.
268
+ return {
269
+ command: "/usr/bin/script",
270
+ args: ["-q", "/dev/null", "/bin/sh", "-c", `${stty}; exec "$0" "$@"`, bin, ...args],
271
+ };
272
+ }
273
+ if (process.platform === "linux") {
274
+ // util-linux script: the command is a single shell string (-c).
275
+ const quoted = [bin, ...args]
276
+ .map((a) => `'${a.replace(/'/g, `'\\''`)}'`)
277
+ .join(" ");
278
+ return {
279
+ command: "/usr/bin/script",
280
+ args: ["-qec", `${stty}; exec ${quoted}`, "/dev/null"],
281
+ };
282
+ }
283
+ return null;
284
+ }
285
+
286
+ /**
287
+ * The PATH the owner's shell exports. Probes an INTERACTIVE login shell
288
+ * first: nvm/asdf/fnm put their PATH setup in `.zshrc`/`.bashrc`, which a
289
+ * plain login shell (`-lc`) never sources — that gap hid `npm i -g` installs
290
+ * from the probe. Falls back to `-lc` for profiles that misbehave when run
291
+ * interactively without a terminal. Null on failure of both; callers fall
292
+ * back to the inherited PATH.
293
+ *
294
+ * Cached (module-level, 5 min): the CLI-found statuses probe on every
295
+ * /api/engines poll and shelling out each time would be rude. Staleness is
296
+ * harmless — every installer this module runs lands in a candidate dir,
297
+ * which is checked before the probe.
298
+ */
299
+ export async function probeLoginShellPath(
300
+ budgetMs: number = LOGIN_PATH_PROBE_BUDGET_MS,
301
+ seams: Partial<LoginShellProbeSeams> = {},
302
+ ): Promise<string | null> {
303
+ const { runShell, now: clock } = { ...defaultProbeSeams(), ...seams };
304
+ const now = clock();
305
+ if (loginPathCache && now - loginPathCache.at < LOGIN_PATH_CACHE_MS) {
306
+ return loginPathCache.value;
307
+ }
308
+ // ONE deadline for the pair. A per-spawn timeout bounds each attempt but not
309
+ // the request: two 8s attempts is a 16s stall on a live /api/engines poll.
310
+ //
311
+ // The pair shares the budget, but `-lic` may not SPEND it all: the reserve
312
+ // below keeps the `-lc` retry reachable. Sharing one deadline outright would
313
+ // starve the fallback in exactly the case it exists for — see the reserve.
314
+ const deadline = now + budgetMs;
315
+ const value =
316
+ (await runBounded(
317
+ runShell,
318
+ clock,
319
+ "-lic",
320
+ deadline - fallbackReserve(budgetMs),
321
+ )) ?? (await runBounded(runShell, clock, "-lc", deadline));
322
+ loginPathCache = { value, at: now };
323
+ return value;
324
+ }
325
+
326
+ /**
327
+ * Convert the remaining slice of the shared budget into one attempt's timeout,
328
+ * skipping the spawn entirely when a previous attempt already consumed it.
329
+ */
330
+ async function runBounded(
331
+ runShell: LoginShellProbeSeams["runShell"],
332
+ clock: LoginShellProbeSeams["now"],
333
+ flags: string,
334
+ deadline: number,
335
+ ): Promise<string | null> {
336
+ const remaining = deadline - clock();
337
+ if (remaining <= 0) return null;
338
+ return runShell(flags, remaining);
339
+ }
340
+
341
+ /** Drop the memoized login PATH. Exported for tests (module-level cache). */
342
+ export function resetLoginShellPathCache(): void {
343
+ loginPathCache = null;
344
+ }
345
+
346
+ let loginPathCache: { value: string | null; at: number } | null = null;
347
+ const LOGIN_PATH_CACHE_MS = 5 * 60_000;
348
+ /**
349
+ * Total wall-clock budget for the login-shell PATH probe, across BOTH
350
+ * attempts. `/api/engines` awaits this on a cold cache, so the bound has to
351
+ * be on the pair — see probeLoginShellPath.
352
+ */
353
+ const LOGIN_PATH_PROBE_BUDGET_MS = 8_000;
354
+ /**
355
+ * Slice of the budget held back so the `-lc` retry can always run.
356
+ *
357
+ * The interactive attempt is the one that stalls — it sources `.zshrc`, which
358
+ * is what makes it slow — and that is precisely when the non-interactive
359
+ * fallback is worth trying, because `-lc` skips that file. Letting `-lic`
360
+ * spend the whole budget therefore starves the fallback in the one case it
361
+ * exists for, and the probe resolves null: NOT "no login PATH" but "we gave
362
+ * up", cached as the former for LOGIN_PATH_CACHE_MS. `/api/engines` then
363
+ * reports every CLI absent for five minutes on a box where they are installed.
364
+ *
365
+ * Bounding the pair and keeping the fallback are not in tension: the total is
366
+ * still `budgetMs`, it is just no longer spendable entirely by the attempt
367
+ * most likely to hang.
368
+ */
369
+ const LOGIN_PATH_FALLBACK_RESERVE_MS = 3_000;
370
+
371
+ /**
372
+ * Never larger than half the budget, so a caller-shrunk budget (tests) still
373
+ * gives both attempts a real slice instead of collapsing one to zero.
374
+ */
375
+ function fallbackReserve(budgetMs: number): number {
376
+ return Math.min(LOGIN_PATH_FALLBACK_RESERVE_MS, Math.floor(budgetMs / 2));
377
+ }
378
+
379
+ /**
380
+ * The probe's two side effects, injectable like every other seam in this file.
381
+ *
382
+ * Budget arithmetic is the whole point of this code and it is a function of
383
+ * elapsed time, so a test that races real `sleep`s against a real clock is
384
+ * measuring the runner's load, not the logic — it fails on a busy self-hosted
385
+ * runner exactly like the stall it was written to prevent. Pin both and the
386
+ * assertions become the timeouts handed to each attempt.
387
+ */
388
+ export interface LoginShellProbeSeams {
389
+ /** Run `$SHELL <flags> /usr/bin/env`, bounded by `timeoutMs`. */
390
+ runShell: (flags: string, timeoutMs: number) => Promise<string | null>;
391
+ now: () => number;
392
+ }
393
+
394
+ function defaultProbeSeams(): LoginShellProbeSeams {
395
+ return { runShell: shellPath, now: Date.now };
396
+ }
397
+
398
+ /**
399
+ * Run `$SHELL <flags> /usr/bin/env` and pull PATH out. `/usr/bin/env` (not
400
+ * `echo $PATH`) so the value is the real colon-joined exported PATH under any
401
+ * shell — fish's `$PATH` is a list and would echo space-joined — and the
402
+ * LAST `PATH=` line wins so profile echo noise can't spoof it.
403
+ */
404
+ function shellPath(flags: string, timeoutMs: number): Promise<string | null> {
405
+ return new Promise((resolve) => {
406
+ const shell = process.env.SHELL?.trim() || "/bin/zsh";
407
+ nodeExecFile(
408
+ shell,
409
+ [flags, "/usr/bin/env"],
410
+ { timeout: timeoutMs },
411
+ (err, stdout) => {
412
+ if (err) return resolve(null);
413
+ const lines = String(stdout)
414
+ .split(/\r?\n/)
415
+ .filter((l) => l.startsWith("PATH="));
416
+ const last = lines[lines.length - 1];
417
+ resolve(last ? last.slice("PATH=".length).trim() || null : null);
418
+ },
419
+ );
420
+ });
421
+ }
422
+
423
+ function withDefaults(seams: Partial<EngineSeams>): EngineSeams {
424
+ return { ...defaultSeams(), ...seams };
425
+ }
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // Public catalog / status.
429
+ // ---------------------------------------------------------------------------
430
+
431
+ /** The static engine catalog (kind, label, authMode, notes, key URLs). */
432
+ export function engineCatalog(): EngineCatalogEntry[] {
433
+ return ORDER.map((kind) => {
434
+ const d = DESCRIPTORS[kind];
435
+ return {
436
+ kind,
437
+ label: d.label,
438
+ authMode: d.authMode,
439
+ notes: d.notes,
440
+ getKeyUrl: d.getKeyUrl ?? null,
441
+ apiKeyHint: d.apiKeyHint ?? null,
442
+ apiKeyUrl: d.apiKeyUrl ?? null,
443
+ installHint:
444
+ kind === "codex" ? CODEX_INSTALL_HINT : (INSTALLERS[kind] ?? null),
445
+ loginCmd: d.loginCmd ?? null,
446
+ };
447
+ });
448
+ }
449
+
450
+ /** kind → whether the engine's CLI is resolvable on this host right now. */
451
+ export async function engineCliStatuses(
452
+ seams: Partial<EngineSeams> = {},
453
+ ): Promise<Record<EngineKind, boolean>> {
454
+ const s = withDefaults(seams);
455
+ // Resolve the login PATH at most ONCE per call. Every kind that isn't in a
456
+ // candidate dir falls through to the probe, so six kinds would otherwise
457
+ // await it six times — bounding each probe wouldn't bound the request. The
458
+ // default seam memoizes for 5 min, but the per-request bound must not
459
+ // depend on that cache being warm.
460
+ let loginOnce: Promise<string | null> | null = null;
461
+ const scoped: EngineSeams = {
462
+ ...s,
463
+ loginShellPath: () => (loginOnce ??= s.loginShellPath()),
464
+ };
465
+ const out = {} as Record<EngineKind, boolean>;
466
+ for (const kind of ORDER) out[kind] = await engineCliFound(kind, scoped);
467
+ return out;
468
+ }
469
+
470
+ /** kind → connected, for every engine. */
471
+ export function engineStatuses(
472
+ seams: Partial<EngineSeams> = {},
473
+ ): Record<EngineKind, boolean> {
474
+ const s = withDefaults(seams);
475
+ return {
476
+ claude: detect("claude", s),
477
+ codex: detect("codex", s),
478
+ kimi: detect("kimi", s),
479
+ grok: detect("grok", s),
480
+ cursor: detect("cursor", s),
481
+ opencode: detect("opencode", s),
482
+ };
483
+ }
484
+
485
+ /** Whether a single engine is connected on this host right now. */
486
+ export function detectEngine(
487
+ kind: EngineKind,
488
+ seams: Partial<EngineSeams> = {},
489
+ ): boolean {
490
+ return detect(kind, withDefaults(seams));
491
+ }
492
+
493
+ export function isEngineKind(value: unknown): value is EngineKind {
494
+ return (
495
+ value === "claude" ||
496
+ value === "codex" ||
497
+ value === "kimi" ||
498
+ value === "grok" ||
499
+ value === "cursor" ||
500
+ value === "opencode"
501
+ );
502
+ }
503
+
504
+ // ---------------------------------------------------------------------------
505
+ // Connect / disconnect.
506
+ // ---------------------------------------------------------------------------
507
+
508
+ export interface ConnectOptions {
509
+ /** A pasted API key — primary for Cursor, alternative for claude/codex/grok. */
510
+ apiKey?: string;
511
+ /** token-command manual fallback: a pasted token or API key (Claude). */
512
+ pastedToken?: string;
513
+ /**
514
+ * ADR-076: when set, a token captured by the token-command flow (or pasted)
515
+ * is handed to this sink INSTEAD of being persisted as the default credential
516
+ * — used to register an EXTRA account. The sink's result becomes the connect
517
+ * result.
518
+ */
519
+ onToken?: (token: string) => ConnectResult | Promise<ConnectResult>;
520
+ }
521
+
522
+ export interface ConnectResult {
523
+ ok: boolean;
524
+ message: string;
525
+ }
526
+
527
+ /**
528
+ * Connect an engine. api-key/paste resolve synchronously; login/token spawn a
529
+ * CLI and stream its output to `onLog` (the browser OAuth happens meanwhile).
530
+ */
531
+ export async function connectEngine(
532
+ kind: EngineKind,
533
+ opts: ConnectOptions,
534
+ onLog: (line: string) => void,
535
+ seams: Partial<EngineSeams> = {},
536
+ ): Promise<ConnectResult> {
537
+ const s = withDefaults(seams);
538
+ const d = DESCRIPTORS[kind];
539
+
540
+ // A pasted API key connects without spawning anything — the only flow for
541
+ // api-key engines (Cursor), an alternative for the others.
542
+ if (d.authMode === "api-key" || opts.apiKey !== undefined) {
543
+ return saveApiKey(kind, d, opts.apiKey ?? "", s);
544
+ }
545
+
546
+ // external-login (OpenCode): the owner ran the engine's own interactive CLI
547
+ // in their terminal — we never spawn it. "Connect" just re-detects the
548
+ // credential that login wrote.
549
+ if (d.authMode === "external-login") {
550
+ return detect(kind, s)
551
+ ? { ok: true, message: `${d.label} connected.` }
552
+ : {
553
+ ok: false,
554
+ message: d.loginCmd
555
+ ? `Not signed in yet. Run \`${d.loginCmd}\` in your terminal, then check again.`
556
+ : `${d.label} isn't signed in yet.`,
557
+ };
558
+ }
559
+
560
+ if (d.authMode === "token-command") {
561
+ // Manual paste fallback — accept a token (or API key) without spawning.
562
+ if (opts.pastedToken !== undefined) {
563
+ const token = opts.pastedToken.trim();
564
+ if (!token) return { ok: false, message: "Paste a token." };
565
+ if (/\s/.test(token) || token.length < 20) {
566
+ return {
567
+ ok: false,
568
+ message: "That doesn't look like a token. Paste just the value.",
569
+ };
570
+ }
571
+ // Adding an extra account? Route the pasted value to the sink instead of
572
+ // the default credential.
573
+ if (opts.onToken) return await opts.onToken(token);
574
+ // One paste box serves both credential kinds — classify by prefix so an
575
+ // `sk-ant-api…` platform key doesn't get stored as an OAuth token.
576
+ upsertEnvLocal(
577
+ token.startsWith("sk-ant-api")
578
+ ? "ANTHROPIC_API_KEY"
579
+ : "CLAUDE_CODE_OAUTH_TOKEN",
580
+ token,
581
+ s,
582
+ );
583
+ return { ok: true, message: `${d.label} connected.` };
584
+ }
585
+ return runTokenCommand(kind, d.label, onLog, s, opts.onToken);
586
+ }
587
+
588
+ return runLoginCommand(kind, d.label, onLog, s);
589
+ }
590
+
591
+ /** Persist a pasted API key where the engine's adapter actually reads it. */
592
+ function saveApiKey(
593
+ kind: EngineKind,
594
+ d: EngineDescriptor,
595
+ raw: string,
596
+ s: EngineSeams,
597
+ ): ConnectResult {
598
+ const key = raw.trim();
599
+ if (!key) return { ok: false, message: `Paste your ${d.label} API key.` };
600
+ if (/\s/.test(key) || key.length < 8) {
601
+ return {
602
+ ok: false,
603
+ message: "That doesn't look like an API key. Paste just the value.",
604
+ };
605
+ }
606
+ switch (kind) {
607
+ case "cursor":
608
+ upsertEnvLocal("CURSOR_API_KEY", key, s);
609
+ break;
610
+ case "claude":
611
+ // Accept either credential kind here too (see the paste fallback above).
612
+ upsertEnvLocal(
613
+ key.startsWith("sk-ant-oat") ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY",
614
+ key,
615
+ s,
616
+ );
617
+ break;
618
+ case "grok":
619
+ // The grok CLI's documented pay-as-you-go fallback; the adapter forwards
620
+ // it into task containers.
621
+ upsertEnvLocal("XAI_API_KEY", key, s);
622
+ break;
623
+ case "codex":
624
+ writeCodexAuthJson(key, s);
625
+ break;
626
+ case "kimi":
627
+ return {
628
+ ok: false,
629
+ message:
630
+ "Kimi Code authenticates via subscription sign-in — it doesn't read API keys from the environment.",
631
+ };
632
+ }
633
+ return { ok: true, message: `${d.label} connected.` };
634
+ }
635
+
636
+ /**
637
+ * Write `~/.codex/auth.json` the way `codex login --with-api-key` does. We
638
+ * write the plaintext file directly (not via the CLI) because task-up
639
+ * docker-cp's this exact FILE into containers — a keychain-backed CLI login
640
+ * would leave nothing to copy — and it must work without a host codex CLI.
641
+ */
642
+ function writeCodexAuthJson(key: string, s: EngineSeams): void {
643
+ const file = join(s.ownerHome(), ".codex", "auth.json");
644
+ mkdirSync(dirname(file), { recursive: true });
645
+ writeFileSync(file, `${JSON.stringify({ OPENAI_API_KEY: key }, null, 2)}\n`, {
646
+ mode: 0o600,
647
+ });
648
+ try {
649
+ chmodSync(file, 0o600);
650
+ } catch {
651
+ /* best effort */
652
+ }
653
+ }
654
+
655
+ /** Disconnect an engine: forget its credential (env line and/or config file). */
656
+ export function disconnectEngine(
657
+ kind: EngineKind,
658
+ seams: Partial<EngineSeams> = {},
659
+ ): void {
660
+ const s = withDefaults(seams);
661
+ if (kind === "cursor") {
662
+ removeEnvLocal("CURSOR_API_KEY", s);
663
+ return;
664
+ }
665
+ if (kind === "claude") {
666
+ removeEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", s);
667
+ removeEnvLocal("ANTHROPIC_API_KEY", s);
668
+ removeEnvLocal("ANTHROPIC_AUTH_TOKEN", s);
669
+ return;
670
+ }
671
+ // grok can be connected via login (auth.json) OR a pasted key — drop both.
672
+ if (kind === "grok") removeEnvLocal("XAI_API_KEY", s);
673
+ // login-command engines: remove the config file the adapter detects.
674
+ try {
675
+ rmSync(configPath(kind, s), { force: true });
676
+ } catch {
677
+ /* best effort */
678
+ }
679
+ }
680
+
681
+ // ---------------------------------------------------------------------------
682
+ // Install a missing engine CLI (ADR-074).
683
+ // ---------------------------------------------------------------------------
684
+
685
+ /**
686
+ * Pinned installer one-liners — the same vendor installers the standard task
687
+ * image already trusts (host-agent/images/standard/Dockerfile), shown
688
+ * VERBATIM in the UI before running. Every one lands its binary in a
689
+ * candidateBins dir, so the next resolve finds it with no restart:
690
+ * claude → ~/.local/bin, kimi → ~/.kimi-code/bin, grok → ~/.grok/bin.
691
+ * Codex has no vendor script — resolved at run time (brew, then npm).
692
+ */
693
+ const INSTALLERS: Partial<Record<EngineKind, string>> = {
694
+ claude: "curl -fsSL https://claude.ai/install.sh | bash",
695
+ kimi: "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
696
+ grok: "curl -fsSL https://x.ai/cli/install.sh | bash",
697
+ opencode: "curl -fsSL https://opencode.ai/install | bash",
698
+ };
699
+
700
+ const CODEX_INSTALL_HINT = "brew install codex — or: npm i -g @openai/codex";
701
+
702
+ /** Installers can compile/download; give them real time. */
703
+ const INSTALL_TIMEOUT_MS = 15 * 60_000;
704
+
705
+ export interface InstallResult {
706
+ ok: boolean;
707
+ message: string;
708
+ }
709
+
710
+ /**
711
+ * Install an engine's CLI with its pinned installer, streaming output to
712
+ * `onLog`. Refuses when there's nothing to install (api-key engines) and
713
+ * short-circuits when the CLI is already resolvable. Success = the installer
714
+ * exits 0 AND the CLI resolves afterwards.
715
+ */
716
+ export async function installEngineCli(
717
+ kind: EngineKind,
718
+ onLog: (line: string) => void,
719
+ seams: Partial<EngineSeams> = {},
720
+ ): Promise<InstallResult> {
721
+ const s = withDefaults(seams);
722
+ const d = DESCRIPTORS[kind];
723
+ if (d.authMode === "api-key") {
724
+ return {
725
+ ok: false,
726
+ message: `${d.label} doesn't use a CLI on the host — paste an API key instead.`,
727
+ };
728
+ }
729
+ if (await engineCliFound(kind, s)) {
730
+ return { ok: true, message: `${d.label} CLI is already installed.` };
731
+ }
732
+ const inv = await resolveInstaller(kind, s);
733
+ if (!inv) {
734
+ return {
735
+ ok: false,
736
+ message:
737
+ "No way to install Codex automatically: install Homebrew or Node.js first, then `brew install codex` or `npm i -g @openai/codex`.",
738
+ };
739
+ }
740
+ let child: ChildProcess;
741
+ try {
742
+ child = s.spawn(inv.command, inv.args, {
743
+ ...s.procEnv,
744
+ ...(inv.path ? { PATH: inv.path } : {}),
745
+ });
746
+ } catch (err) {
747
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
748
+ }
749
+ return new Promise((resolve) => {
750
+ let settled = false;
751
+ let timer: NodeJS.Timeout | undefined;
752
+ const done = (r: InstallResult, kill = false): void => {
753
+ if (settled) return;
754
+ settled = true;
755
+ clearTimeout(timer);
756
+ if (kill) {
757
+ try {
758
+ child.kill();
759
+ } catch {
760
+ /* already gone */
761
+ }
762
+ }
763
+ resolve(r);
764
+ };
765
+ const relay = createLineRelay(onLog);
766
+ child.stdout?.on("data", relay);
767
+ child.stderr?.on("data", relay);
768
+ child.on("error", (err: NodeJS.ErrnoException) =>
769
+ done({ ok: false, message: err.message }),
770
+ );
771
+ child.on("exit", (code) => {
772
+ void (async () => {
773
+ if (code !== 0) {
774
+ done({
775
+ ok: false,
776
+ message: `Install failed (exit ${code ?? "?"}) — see the log above.`,
777
+ });
778
+ return;
779
+ }
780
+ done(
781
+ (await engineCliFound(kind, s))
782
+ ? { ok: true, message: `${d.label} CLI installed.` }
783
+ : {
784
+ ok: false,
785
+ message:
786
+ "The installer finished but the CLI still isn't findable — install it manually, then try Connect.",
787
+ },
788
+ );
789
+ })();
790
+ });
791
+ timer = setTimeout(
792
+ () =>
793
+ done(
794
+ { ok: false, message: "The installer timed out — see the log above." },
795
+ true,
796
+ ),
797
+ INSTALL_TIMEOUT_MS,
798
+ );
799
+ });
800
+ }
801
+
802
+ interface InstallInvocation {
803
+ command: string;
804
+ args: string[];
805
+ /** Child PATH override (npm needs its own dir leading so node resolves). */
806
+ path?: string;
807
+ }
808
+
809
+ /** Vendor script via sh for most engines; brew-then-npm for Codex. */
810
+ async function resolveInstaller(
811
+ kind: EngineKind,
812
+ s: EngineSeams,
813
+ ): Promise<InstallInvocation | null> {
814
+ if (kind !== "codex") {
815
+ const script = INSTALLERS[kind];
816
+ return script ? { command: "/bin/sh", args: ["-c", script] } : null;
817
+ }
818
+ const brew = s.systemBinDirs.map((dir) => join(dir, "brew")).find(existsSync);
819
+ if (brew) return { command: brew, args: ["install", "codex"] };
820
+ const npm = await findBinNamed("npm", s);
821
+ if (npm) {
822
+ return {
823
+ command: npm,
824
+ args: ["install", "-g", "@openai/codex"],
825
+ path: prependPath([dirname(npm)], s.procEnv.PATH ?? ""),
826
+ };
827
+ }
828
+ return null;
829
+ }
830
+
831
+ /** Find an arbitrary tool the same way engine CLIs are found. */
832
+ async function findBinNamed(
833
+ name: string,
834
+ s: EngineSeams,
835
+ ): Promise<string | null> {
836
+ const home = s.ownerHome();
837
+ const dirs = [
838
+ join(home, ".local", "bin"),
839
+ ...nodeManagerBinDirs(home),
840
+ ...s.systemBinDirs,
841
+ ];
842
+ for (const dir of dirs) {
843
+ const file = join(dir, name);
844
+ if (existsSync(file)) return file;
845
+ }
846
+ const login = await s.loginShellPath();
847
+ const dir = login?.split(":").find((p) => p && existsSync(join(p, name)));
848
+ return dir ? join(dir, name) : null;
849
+ }
850
+
851
+ /** Whether an engine's CLI resolves right now (candidates, then login PATH). */
852
+ async function engineCliFound(
853
+ kind: EngineKind,
854
+ s: EngineSeams,
855
+ ): Promise<boolean> {
856
+ if (DESCRIPTORS[kind].authMode === "api-key") return true; // never spawned
857
+ if (candidateBins(kind, s).some(existsSync)) return true;
858
+ const login = await s.loginShellPath();
859
+ return !!login
860
+ ?.split(":")
861
+ .some((dir) => dir.length > 0 && existsSync(join(dir, kind)));
862
+ }
863
+
864
+ /**
865
+ * Accumulate a child's output and forward each distinct, meaningful,
866
+ * COMPLETE line to `onLog` once — same discipline as the token flow's
867
+ * relay, for plain (non-PTY) installer output with \r progress noise.
868
+ */
869
+ function createLineRelay(onLog: (line: string) => void): (b: Buffer) => void {
870
+ let raw = "";
871
+ const forwarded = new Set<string>();
872
+ return (b) => {
873
+ raw += b.toString("utf8");
874
+ if (raw.length > 262_144) raw = raw.slice(-131_072);
875
+ const text = sanitizeTerminalOutput(raw);
876
+ for (const line of significantLines(text.slice(0, text.lastIndexOf("\n") + 1))) {
877
+ if (!forwarded.has(line)) {
878
+ forwarded.add(line);
879
+ onLog(line);
880
+ }
881
+ }
882
+ };
883
+ }
884
+
885
+ /**
886
+ * Flatten a PTY byte stream into plain text lines. Strips OSC sequences
887
+ * (incl. OSC-8 hyperlink wrappers, keeping their visible text), CSI
888
+ * sequences (cursor-column moves become a space — Ink positions words with
889
+ * `ESC[<n>G` instead of writing spaces), stray ESC singles, and normalizes
890
+ * `\r` to `\n`. Identity on already-plain text apart from whitespace runs.
891
+ */
892
+ export function sanitizeTerminalOutput(raw: string): string {
893
+ return raw
894
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // OSC …BEL / …ST
895
+ .replace(/\x1b\[[0-9;:?<=>]*[!-/]*[@-~]/g, " ") // CSI
896
+ .replace(/\x1b[()][0-9A-B]/g, "") // charset selects
897
+ .replace(/\x1b[78=>DEHM]/g, "") // save/restore cursor etc.
898
+ .replace(/\r\n?/g, "\n")
899
+ .replace(/[^\S\n]+/g, " ");
900
+ }
901
+
902
+ /** Lines worth showing a human: non-blank, not spinner or logo-art glyphs. */
903
+ function significantLines(text: string): string[] {
904
+ return text
905
+ .split("\n")
906
+ .map((l) => l.trim())
907
+ .filter((l) => l.length > 1 && !/^[·✢✳✶✻✽*+.…|/\\\-░▒▓█▄▀\s]+$/u.test(l));
908
+ }
909
+
910
+ /**
911
+ * Extract a Claude OAuth token from `claude setup-token` output. The CLI shows
912
+ * the token (an `sk-ant-oat…` value) on its own line near the end; scan from the
913
+ * bottom for it, falling back to a lone long token-charset line.
914
+ */
915
+ export function extractClaudeToken(stdout: string): string | null {
916
+ const lines = stdout
917
+ .split(/\r?\n/)
918
+ .map((l) => l.trim())
919
+ .filter(Boolean);
920
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
921
+ const m = /(sk-ant-[A-Za-z0-9_-]{16,})/.exec(lines[i] ?? "");
922
+ if (m?.[1]) return m[1];
923
+ }
924
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
925
+ const line = lines[i] ?? "";
926
+ if (/^[A-Za-z0-9_-]{40,}$/.test(line)) return line;
927
+ }
928
+ return null;
929
+ }
930
+
931
+ // ---------------------------------------------------------------------------
932
+ // Internals.
933
+ // ---------------------------------------------------------------------------
934
+
935
+ function detect(kind: EngineKind, s: EngineSeams): boolean {
936
+ switch (kind) {
937
+ case "claude":
938
+ return (
939
+ envOrFileHas("CLAUDE_CODE_OAUTH_TOKEN", s) ||
940
+ envOrFileHas("ANTHROPIC_API_KEY", s) ||
941
+ envOrFileHas("ANTHROPIC_AUTH_TOKEN", s)
942
+ );
943
+ case "cursor":
944
+ return envOrFileHas("CURSOR_API_KEY", s);
945
+ case "grok":
946
+ return existsSync(configPath(kind, s)) || envOrFileHas("XAI_API_KEY", s);
947
+ case "codex":
948
+ case "kimi":
949
+ case "opencode":
950
+ return existsSync(configPath(kind, s));
951
+ }
952
+ }
953
+
954
+ /** True when `key` is set in process.env or present in `.env.local`. */
955
+ function envOrFileHas(key: string, s: EngineSeams): boolean {
956
+ if (s.procEnv[key]) return true;
957
+ try {
958
+ const body = readFileSync(s.envLocalPath(), "utf8");
959
+ return new RegExp(`^${key}=.+`, "m").test(body);
960
+ } catch {
961
+ return false;
962
+ }
963
+ }
964
+
965
+ /** Config file whose presence gates a login-command engine. */
966
+ function configPath(kind: EngineKind, s: EngineSeams): string {
967
+ const home = s.ownerHome();
968
+ if (kind === "codex") return join(home, ".codex", "auth.json");
969
+ if (kind === "kimi") {
970
+ return join(home, ".kimi-code", "credentials", "kimi-code.json");
971
+ }
972
+ if (kind === "opencode") {
973
+ return join(home, ".local", "share", "opencode", "auth.json");
974
+ }
975
+ return join(home, ".grok", "auth.json");
976
+ }
977
+
978
+ /**
979
+ * Well-known install locations for an engine CLI — the dirs a GUI-launched
980
+ * host's minimal PATH misses. Home-relative entries resolve against the OWNER
981
+ * home (seam-injectable). The binary name equals the engine kind for all four
982
+ * CLI engines.
983
+ */
984
+ function candidateBins(kind: EngineKind, s: EngineSeams): string[] {
985
+ const home = s.ownerHome();
986
+ const common = [
987
+ join(home, ".local", "bin"),
988
+ ...nodeManagerBinDirs(home),
989
+ ...s.systemBinDirs,
990
+ ];
991
+ switch (kind) {
992
+ case "claude":
993
+ // Native installer symlinks ~/.local/bin/claude (preferred); the older
994
+ // "local install" migration keeps a wrapper at ~/.claude/local/claude.
995
+ return [
996
+ join(home, ".local", "bin"),
997
+ join(home, ".claude", "local"),
998
+ ...nodeManagerBinDirs(home),
999
+ ...s.systemBinDirs,
1000
+ ].map((d) => join(d, "claude"));
1001
+ case "codex":
1002
+ return common.map((d) => join(d, "codex"));
1003
+ case "kimi":
1004
+ return [join(home, ".kimi-code", "bin"), ...common].map((d) =>
1005
+ join(d, "kimi"),
1006
+ );
1007
+ case "grok":
1008
+ return [join(home, ".grok", "bin"), ...common].map((d) => join(d, "grok"));
1009
+ case "opencode":
1010
+ // opencode.ai/install lands the binary in ~/.opencode/bin.
1011
+ return [join(home, ".opencode", "bin"), ...common].map((d) =>
1012
+ join(d, "opencode"),
1013
+ );
1014
+ case "cursor":
1015
+ return []; // api-key mode — never spawned
1016
+ }
1017
+ }
1018
+
1019
+ /**
1020
+ * Bin dirs of the common node version managers — `npm i -g` lands CLIs here,
1021
+ * and none of these are on a GUI PATH or even in a NON-interactive login
1022
+ * shell's PATH (their init lives in .zshrc/.bashrc), which is exactly how an
1023
+ * installed `codex` still read "not found". Deterministic dirs beat shell
1024
+ * probing: no subprocess, no profile side effects.
1025
+ */
1026
+ function nodeManagerBinDirs(home: string): string[] {
1027
+ const dirs = [
1028
+ join(home, ".asdf", "shims"), // shims exec asdf by absolute path
1029
+ join(home, ".volta", "bin"),
1030
+ join(home, ".bun", "bin"),
1031
+ join(home, ".npm-global", "bin"),
1032
+ // fnm's stable default-alias bin — the per-shell multishell paths its
1033
+ // shell init exports are ephemeral. macOS, XDG, and legacy layouts.
1034
+ join(home, "Library", "Application Support", "fnm", "aliases", "default", "bin"),
1035
+ join(home, ".local", "share", "fnm", "aliases", "default", "bin"),
1036
+ join(home, ".fnm", "aliases", "default", "bin"),
1037
+ ];
1038
+ // nvm has no stable "current" symlink — enumerate installed versions,
1039
+ // newest first (numeric compare: lexicographic would put v9 over v22).
1040
+ // A version dir's bin also holds its `node`, so the shebang of whatever
1041
+ // we find there resolves against the same dir once it leads PATH.
1042
+ try {
1043
+ const versions = join(home, ".nvm", "versions", "node");
1044
+ dirs.push(
1045
+ ...readdirSync(versions)
1046
+ .sort(compareVersionDesc)
1047
+ .map((v) => join(versions, v, "bin")),
1048
+ );
1049
+ } catch {
1050
+ /* no nvm */
1051
+ }
1052
+ return dirs;
1053
+ }
1054
+
1055
+ function compareVersionDesc(a: string, b: string): number {
1056
+ const parts = (s: string): number[] =>
1057
+ s.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
1058
+ const [a1 = 0, a2 = 0, a3 = 0] = parts(a);
1059
+ const [b1 = 0, b2 = 0, b3 = 0] = parts(b);
1060
+ return b1 - a1 || b2 - a2 || b3 - a3;
1061
+ }
1062
+
1063
+ interface ResolvedBin {
1064
+ /** Command to spawn — absolute when found, else the bare name. */
1065
+ bin: string;
1066
+ /** PATH for the child, augmented so nested lookups (node, git) resolve. */
1067
+ path: string;
1068
+ }
1069
+
1070
+ /**
1071
+ * Find the engine CLI the way the owner's terminal would: well-known install
1072
+ * dirs first (fast, no subprocess), then the login shell's PATH (sources the
1073
+ * owner's profile — covers nvm/asdf/npm prefixes), else the bare name against
1074
+ * the inherited PATH so a genuinely missing CLI still surfaces the friendly
1075
+ * ENOENT "install it first" message.
1076
+ */
1077
+ async function resolveEngineBin(
1078
+ kind: EngineKind,
1079
+ s: EngineSeams,
1080
+ ): Promise<ResolvedBin> {
1081
+ const inherited = s.procEnv.PATH ?? "";
1082
+ for (const file of candidateBins(kind, s)) {
1083
+ if (existsSync(file)) {
1084
+ return { bin: file, path: prependPath([dirname(file)], inherited) };
1085
+ }
1086
+ }
1087
+ const login = await s.loginShellPath();
1088
+ if (login) {
1089
+ const dirs = login.split(":").filter(Boolean);
1090
+ const dir = dirs.find((d) => existsSync(join(d, kind)));
1091
+ // Found or not, hand the login PATH to the child — it is the environment
1092
+ // where the CLI (and whatever it shells out to) is known to work.
1093
+ return {
1094
+ bin: dir ? join(dir, kind) : kind,
1095
+ path: prependPath(dirs, inherited),
1096
+ };
1097
+ }
1098
+ return { bin: kind, path: inherited };
1099
+ }
1100
+
1101
+ /** Prepend dirs to a colon-joined PATH, deduped, order-preserving. */
1102
+ function prependPath(prepend: string[], current: string): string {
1103
+ const seen = new Set<string>();
1104
+ return [...prepend, ...current.split(":")]
1105
+ .filter((p) => p.length > 0 && !seen.has(p) && (seen.add(p), true))
1106
+ .join(":");
1107
+ }
1108
+
1109
+ /** Resolve the CLI and spawn it (optionally under a PTY) with the augmented PATH. */
1110
+ async function spawnEngineCli(
1111
+ kind: EngineKind,
1112
+ args: string[],
1113
+ s: EngineSeams,
1114
+ pty = false,
1115
+ ): Promise<ChildProcess> {
1116
+ const { bin, path } = await resolveEngineBin(kind, s);
1117
+ const wrapped = pty && s.ptyWrap ? s.ptyWrap(bin, args) : null;
1118
+ const spawnEnv: NodeJS.ProcessEnv = { ...s.procEnv, PATH: path };
1119
+ // A GUI-supervised host has no TERM; give the PTY child a sane one.
1120
+ if (wrapped && !spawnEnv.TERM) spawnEnv.TERM = "xterm-256color";
1121
+ return wrapped
1122
+ ? s.spawn(wrapped.command, wrapped.args, spawnEnv)
1123
+ : s.spawn(bin, args, spawnEnv);
1124
+ }
1125
+
1126
+ /** Browser authorization is human-paced — give it real time before bailing. */
1127
+ const TOKEN_FLOW_TIMEOUT_MS = 10 * 60_000;
1128
+
1129
+ const TOKEN_FLOW_FALLBACK =
1130
+ "Try again, or run `claude setup-token` in your terminal and paste the token here.";
1131
+
1132
+ async function runTokenCommand(
1133
+ kind: EngineKind,
1134
+ label: string,
1135
+ onLog: (line: string) => void,
1136
+ s: EngineSeams,
1137
+ onToken?: (token: string) => ConnectResult | Promise<ConnectResult>,
1138
+ ): Promise<ConnectResult> {
1139
+ let child: ChildProcess;
1140
+ try {
1141
+ child = await spawnEngineCli(kind, ["setup-token"], s, true);
1142
+ } catch (err) {
1143
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
1144
+ }
1145
+ return new Promise((resolve) => {
1146
+ let settled = false;
1147
+ let timer: NodeJS.Timeout | undefined;
1148
+ const done = (r: ConnectResult, kill = false): void => {
1149
+ if (settled) return;
1150
+ settled = true;
1151
+ clearTimeout(timer);
1152
+ if (kill) {
1153
+ try {
1154
+ child.kill();
1155
+ } catch {
1156
+ /* already gone */
1157
+ }
1158
+ }
1159
+ resolve(r);
1160
+ };
1161
+ let raw = "";
1162
+ let capturing = false;
1163
+ const forwarded = new Set<string>();
1164
+ const acked = new Set<string>();
1165
+ const saveToken = (token: string): void => {
1166
+ // The token renders on several redraws (and again on exit) — capture once.
1167
+ if (capturing) return;
1168
+ capturing = true;
1169
+ // ADR-076: adding an EXTRA account routes the captured token to the sink
1170
+ // (which stores it as a separate account) instead of the default env.
1171
+ if (onToken) {
1172
+ Promise.resolve(onToken(token))
1173
+ .then((r) => done(r, true))
1174
+ .catch((err) =>
1175
+ done(
1176
+ { ok: false, message: err instanceof Error ? err.message : String(err) },
1177
+ true,
1178
+ ),
1179
+ );
1180
+ return;
1181
+ }
1182
+ upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
1183
+ done({ ok: true, message: `${label} connected.` }, true);
1184
+ };
1185
+ const onChunk = (b: Buffer): void => {
1186
+ raw += b.toString("utf8");
1187
+ // A pending spinner redraws for minutes — keep only the tail (the token
1188
+ // and final screens are at the end) so re-sanitizing stays cheap.
1189
+ if (raw.length > 262_144) raw = raw.slice(-131_072);
1190
+ const text = sanitizeTerminalOutput(raw);
1191
+ // Act only on COMPLETE lines: a trailing partial may still be missing
1192
+ // the rest of an escape sequence — or the rest of the TOKEN, and the
1193
+ // extractor would happily return a truncated prefix.
1194
+ const complete = text.slice(0, text.lastIndexOf("\n") + 1);
1195
+ for (const line of significantLines(complete)) {
1196
+ // Each distinct line once — Ink redraws the same screen constantly
1197
+ // and would flood the UI log.
1198
+ if (!forwarded.has(line)) {
1199
+ forwarded.add(line);
1200
+ onLog(line);
1201
+ }
1202
+ // Auto-acknowledge keypress gates (once per distinct screen text) —
1203
+ // with a PTY the CLI accepts input, and nobody is at its keyboard.
1204
+ if (/press enter/i.test(line) && !acked.has(line)) {
1205
+ acked.add(line);
1206
+ try {
1207
+ child.stdin?.write("\r");
1208
+ } catch {
1209
+ /* best effort */
1210
+ }
1211
+ }
1212
+ }
1213
+ // The whole point: capture the token the moment it renders. The CLI's
1214
+ // final screen lingers for a keypress; we don't wait for exit.
1215
+ const token = extractClaudeToken(complete);
1216
+ if (token) saveToken(token);
1217
+ };
1218
+ child.stdout?.on("data", onChunk);
1219
+ child.stderr?.on("data", onChunk);
1220
+ child.on("error", (err: NodeJS.ErrnoException) =>
1221
+ done({
1222
+ ok: false,
1223
+ message:
1224
+ err.code === "ENOENT"
1225
+ ? `${label} CLI not found on PATH — install Claude Code first.`
1226
+ : err.message,
1227
+ }),
1228
+ );
1229
+ child.on("exit", () => {
1230
+ const token = extractClaudeToken(sanitizeTerminalOutput(raw));
1231
+ if (!token) {
1232
+ done({
1233
+ ok: false,
1234
+ message: `The sign-in didn't finish. ${TOKEN_FLOW_FALLBACK}`,
1235
+ });
1236
+ return;
1237
+ }
1238
+ saveToken(token);
1239
+ });
1240
+ timer = setTimeout(
1241
+ () =>
1242
+ done(
1243
+ {
1244
+ ok: false,
1245
+ message: `Timed out waiting for the browser authorization. ${TOKEN_FLOW_FALLBACK}`,
1246
+ },
1247
+ true,
1248
+ ),
1249
+ TOKEN_FLOW_TIMEOUT_MS,
1250
+ );
1251
+ });
1252
+ }
1253
+
1254
+ async function runLoginCommand(
1255
+ kind: EngineKind,
1256
+ label: string,
1257
+ onLog: (line: string) => void,
1258
+ s: EngineSeams,
1259
+ ): Promise<ConnectResult> {
1260
+ let child: ChildProcess;
1261
+ try {
1262
+ child = await spawnEngineCli(kind, ["login"], s);
1263
+ } catch (err) {
1264
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
1265
+ }
1266
+ return new Promise((resolve) => {
1267
+ let settled = false;
1268
+ const done = (r: ConnectResult): void => {
1269
+ if (!settled) {
1270
+ settled = true;
1271
+ resolve(r);
1272
+ }
1273
+ };
1274
+ child.stdout?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
1275
+ child.stderr?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
1276
+ child.on("error", (err: NodeJS.ErrnoException) =>
1277
+ done({
1278
+ ok: false,
1279
+ message:
1280
+ err.code === "ENOENT"
1281
+ ? `${label} CLI not found — install it first.`
1282
+ : err.message,
1283
+ }),
1284
+ );
1285
+ child.on("exit", () => {
1286
+ done(
1287
+ detect(kind, s)
1288
+ ? { ok: true, message: `${label} connected.` }
1289
+ : { ok: false, message: `${label} login didn't complete.` },
1290
+ );
1291
+ });
1292
+ });
1293
+ }
1294
+
1295
+ function relay(text: string, onLog: (line: string) => void): void {
1296
+ for (const l of text.split(/\r?\n/)) {
1297
+ if (l.trim()) onLog(l.trim());
1298
+ }
1299
+ }
1300
+
1301
+ /** Upsert KEY=value into the host's `.env.local` (0600) AND process.env. */
1302
+ function upsertEnvLocal(key: string, value: string, s: EngineSeams): void {
1303
+ const file = s.envLocalPath();
1304
+ mkdirSync(dirname(file), { recursive: true });
1305
+ let body = "";
1306
+ try {
1307
+ body = readFileSync(file, "utf8");
1308
+ } catch {
1309
+ /* new file */
1310
+ }
1311
+ const line = `${key}=${value}`;
1312
+ const re = new RegExp(`^${key}=.*$`, "m");
1313
+ body = re.test(body)
1314
+ ? body.replace(re, line)
1315
+ : body + (body && !body.endsWith("\n") ? "\n" : "") + line + "\n";
1316
+ writeFileSync(file, body, { mode: 0o600 });
1317
+ try {
1318
+ chmodSync(file, 0o600);
1319
+ } catch {
1320
+ /* best effort */
1321
+ }
1322
+ // Immediate effect: the running host reads creds from process.env (adapters'
1323
+ // available() + task-up env injection), not by reloading .env.local — so set
1324
+ // it here or a connect wouldn't take effect until a restart.
1325
+ s.procEnv[key] = value;
1326
+ }
1327
+
1328
+ /** Remove a KEY line from `.env.local` and process.env (no-op if absent). */
1329
+ function removeEnvLocal(key: string, s: EngineSeams): void {
1330
+ const file = s.envLocalPath();
1331
+ try {
1332
+ const body = readFileSync(file, "utf8");
1333
+ const next = body
1334
+ .split(/\r?\n/)
1335
+ .filter((l) => !new RegExp(`^${key}=`).test(l))
1336
+ .join("\n");
1337
+ writeFileSync(file, next, { mode: 0o600 });
1338
+ } catch {
1339
+ /* no file */
1340
+ }
1341
+ delete s.procEnv[key];
1342
+ }