@moondeck/station 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) 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/meta/_journal.json +223 -0
  36. package/db/schema.ts +850 -0
  37. package/images/standard/Dockerfile +347 -0
  38. package/images/standard/README.md +167 -0
  39. package/images/standard/container/code-server-settings.json +63 -0
  40. package/images/standard/container/corepack-version +1 -0
  41. package/images/standard/container/uai-init +704 -0
  42. package/images/standard/container/uai-materialize-runtimes +1527 -0
  43. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  44. package/images/standard/tool-versions +2 -0
  45. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  46. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  47. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  48. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  49. package/lib/agent-cli.ts +1507 -0
  50. package/lib/agent-errors.ts +22 -0
  51. package/lib/agent.ts +672 -0
  52. package/lib/agents/attach-compatibility.ts +51 -0
  53. package/lib/agents/claude.ts +918 -0
  54. package/lib/agents/codex-token-usage.fixture.json +130 -0
  55. package/lib/agents/codex.ts +1392 -0
  56. package/lib/agents/cursor-period-usage.ts +253 -0
  57. package/lib/agents/cursor.ts +562 -0
  58. package/lib/agents/dispatch.ts +78 -0
  59. package/lib/agents/durable-proc.ts +334 -0
  60. package/lib/agents/factory.ts +57 -0
  61. package/lib/agents/grok.ts +381 -0
  62. package/lib/agents/kimi.ts +329 -0
  63. package/lib/agents/machine-durable.ts +604 -0
  64. package/lib/agents/mock.ts +133 -0
  65. package/lib/agents/mode.ts +64 -0
  66. package/lib/agents/opencode.ts +508 -0
  67. package/lib/agents/output-source.ts +3 -0
  68. package/lib/agents/proc.ts +221 -0
  69. package/lib/agents/rate-limit.ts +337 -0
  70. package/lib/agents/registry.ts +182 -0
  71. package/lib/agents/session-state.ts +197 -0
  72. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  73. package/lib/agents/transport.ts +741 -0
  74. package/lib/agents/types.ts +254 -0
  75. package/lib/agents/uai.ts +106 -0
  76. package/lib/agents/usage.ts +44 -0
  77. package/lib/apple-one-off.ts +277 -0
  78. package/lib/apple-runtime-recycle.ts +237 -0
  79. package/lib/apple-uninstall-teardown.ts +224 -0
  80. package/lib/assignment-evidence.ts +94 -0
  81. package/lib/attachments.ts +48 -0
  82. package/lib/browser-testing.ts +1537 -0
  83. package/lib/capture-flow.ts +332 -0
  84. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  85. package/lib/cloud-credential-host.ts +512 -0
  86. package/lib/cloud-credential-leases.ts +2587 -0
  87. package/lib/cloud-credential-runtime.ts +1357 -0
  88. package/lib/cloud-state.ts +56 -0
  89. package/lib/codex-auth.ts +456 -0
  90. package/lib/command-db.ts +310 -0
  91. package/lib/compose-network-recovery.ts +283 -0
  92. package/lib/connection-secret-redaction.ts +25 -0
  93. package/lib/container-runtime.ts +1453 -0
  94. package/lib/credential-cleanup-inventory.ts +490 -0
  95. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  96. package/lib/credential-consumers/engines.ts +797 -0
  97. package/lib/credential-consumers/files.ts +934 -0
  98. package/lib/credential-consumers/github.ts +702 -0
  99. package/lib/credential-consumers/mcp.ts +389 -0
  100. package/lib/credential-consumers/task-files.ts +127 -0
  101. package/lib/credential-consumers/task-secrets.ts +10 -0
  102. package/lib/credential-store-pickup.ts +149 -0
  103. package/lib/credential-store-scope.ts +112 -0
  104. package/lib/credential-store.ts +932 -0
  105. package/lib/credentials-activation-authority.ts +709 -0
  106. package/lib/credentials-purge.ts +1006 -0
  107. package/lib/custom-connection-capture.ts +395 -0
  108. package/lib/custom-connection-launch.ts +384 -0
  109. package/lib/custom-connection-runtime.ts +141 -0
  110. package/lib/db.ts +80 -0
  111. package/lib/disk-pressure.ts +42 -0
  112. package/lib/docker-exec.ts +156 -0
  113. package/lib/engine-accounts.ts +1970 -0
  114. package/lib/engine-connect.ts +321 -0
  115. package/lib/engine-credential-env.ts +52 -0
  116. package/lib/engine-login.ts +2933 -0
  117. package/lib/engines.ts +1342 -0
  118. package/lib/enrollment-state.ts +251 -0
  119. package/lib/env-file.ts +246 -0
  120. package/lib/env-schema.ts +891 -0
  121. package/lib/env.ts +195 -0
  122. package/lib/fs-aliases.ts +49 -0
  123. package/lib/git-clone-credentials.ts +334 -0
  124. package/lib/git-diff.ts +530 -0
  125. package/lib/git-identity.ts +715 -0
  126. package/lib/github-git-auth.ts +207 -0
  127. package/lib/github-origin.ts +28 -0
  128. package/lib/github-tokens.ts +2439 -0
  129. package/lib/host-cloud-url.ts +62 -0
  130. package/lib/host-config.ts +279 -0
  131. package/lib/host-env.ts +424 -0
  132. package/lib/host-logs.ts +965 -0
  133. package/lib/internal-connections.ts +5 -0
  134. package/lib/keyed-promise-tail.ts +23 -0
  135. package/lib/launch-mode.ts +25 -0
  136. package/lib/lease.ts +239 -0
  137. package/lib/legacy-host.ts +769 -0
  138. package/lib/legacy-runtime-retirement.ts +424 -0
  139. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  140. package/lib/machine-exec.ts +238 -0
  141. package/lib/machine-gateway-tunnel.ts +344 -0
  142. package/lib/machine-keys.ts +65 -0
  143. package/lib/machine-project-env-loader.ts +152 -0
  144. package/lib/machine-provider-aws.ts +923 -0
  145. package/lib/machine-provider-local.ts +338 -0
  146. package/lib/machine-provider.ts +90 -0
  147. package/lib/managed-activation-watcher.ts +72 -0
  148. package/lib/managed-install-owner-watcher.ts +55 -0
  149. package/lib/managed-operation-drain.ts +57 -0
  150. package/lib/managed-runtime.ts +4296 -0
  151. package/lib/managed-update-scheduler.ts +125 -0
  152. package/lib/mcp-capture.ts +865 -0
  153. package/lib/mcp-config-lock.ts +2 -0
  154. package/lib/mcp-connections.ts +959 -0
  155. package/lib/mcp-gateway.ts +2594 -0
  156. package/lib/obs.ts +512 -0
  157. package/lib/openrouter-budget.ts +247 -0
  158. package/lib/orchestrator.ts +11409 -0
  159. package/lib/person-capture.ts +327 -0
  160. package/lib/preview-ports.ts +85 -0
  161. package/lib/preview-sidecar.ts +331 -0
  162. package/lib/project-env-authority.ts +1319 -0
  163. package/lib/project-env-feature.ts +74 -0
  164. package/lib/project-env-legacy-tags.ts +130 -0
  165. package/lib/project-env-loader.ts +242 -0
  166. package/lib/project-env-replica.ts +2094 -0
  167. package/lib/project-env-schema-capture.ts +342 -0
  168. package/lib/project-env-staging.ts +148 -0
  169. package/lib/project-env-task-policy.ts +176 -0
  170. package/lib/project-env-task-replica.ts +1752 -0
  171. package/lib/release-feed.ts +61 -0
  172. package/lib/release-manifest.ts +710 -0
  173. package/lib/release-trust.ts +46 -0
  174. package/lib/repo-clone.ts +38 -0
  175. package/lib/routing/shadow.ts +261 -0
  176. package/lib/runner-boot.ts +10 -0
  177. package/lib/runtime-activation-tail.ts +279 -0
  178. package/lib/runtime-archive.ts +1216 -0
  179. package/lib/runtime-authority.ts +166 -0
  180. package/lib/runtime-guard.ts +36 -0
  181. package/lib/runtime-provider-state.ts +169 -0
  182. package/lib/runtime-reconnect.ts +86 -0
  183. package/lib/runtime-state.ts +1033 -0
  184. package/lib/safe-http.ts +364 -0
  185. package/lib/secrets-proxy/address.ts +95 -0
  186. package/lib/secrets-proxy/audit.ts +30 -0
  187. package/lib/secrets-proxy/ca-store.ts +200 -0
  188. package/lib/secrets-proxy/ca.ts +470 -0
  189. package/lib/secrets-proxy/container-steps.ts +138 -0
  190. package/lib/secrets-proxy/der.ts +273 -0
  191. package/lib/secrets-proxy/engine.ts +1633 -0
  192. package/lib/secrets-proxy/host.ts +93 -0
  193. package/lib/secrets-proxy/hostname.ts +57 -0
  194. package/lib/secrets-proxy/placeholders.ts +196 -0
  195. package/lib/secrets-proxy/policy.ts +150 -0
  196. package/lib/secrets-proxy/readiness.ts +148 -0
  197. package/lib/secrets-proxy/runtime.ts +582 -0
  198. package/lib/secrets-proxy/scrub.ts +559 -0
  199. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  200. package/lib/secrets-proxy/task-scripts.ts +262 -0
  201. package/lib/secrets-proxy/task-trust.ts +605 -0
  202. package/lib/secrets-proxy/trust-watch.ts +80 -0
  203. package/lib/secrets.ts +71 -0
  204. package/lib/shared-files-s3.ts +976 -0
  205. package/lib/shared-files.ts +156 -0
  206. package/lib/sidecar-assign.ts +49 -0
  207. package/lib/sidecar-guard.ts +58 -0
  208. package/lib/sidecar-resume.ts +178 -0
  209. package/lib/sidecar.ts +69 -0
  210. package/lib/signin-box-credentials.ts +465 -0
  211. package/lib/signin-box.ts +114 -0
  212. package/lib/skill-index-example.fixture.ts +56 -0
  213. package/lib/skill-layout.ts +417 -0
  214. package/lib/skills.ts +489 -0
  215. package/lib/ssh.ts +401 -0
  216. package/lib/standard-image-cleanup.ts +117 -0
  217. package/lib/standard-image-pin.ts +207 -0
  218. package/lib/standard-image.ts +1697 -0
  219. package/lib/stopped-task-status-queue.ts +120 -0
  220. package/lib/task-container-cli.ts +269 -0
  221. package/lib/task-credential-cleanup.ts +752 -0
  222. package/lib/task-diff-sandbox.ts +464 -0
  223. package/lib/task-diff.ts +91 -0
  224. package/lib/task-env-forwarder.ts +228 -0
  225. package/lib/task-env-source.ts +249 -0
  226. package/lib/task-environment/anchor.ts +31 -0
  227. package/lib/task-environment/apple-container.ts +860 -0
  228. package/lib/task-environment/docker.ts +1344 -0
  229. package/lib/task-environment/index.ts +466 -0
  230. package/lib/task-environment/legacy-adoption.ts +467 -0
  231. package/lib/task-environment/machine-environment.ts +61 -0
  232. package/lib/task-environment/machine-task-up.ts +1227 -0
  233. package/lib/task-environment/machine.ts +974 -0
  234. package/lib/task-environment/registry.ts +59 -0
  235. package/lib/task-environment/self-identity.ts +20 -0
  236. package/lib/task-environment/self.ts +566 -0
  237. package/lib/task-environment/types.ts +642 -0
  238. package/lib/task-environment/workspace-files.ts +685 -0
  239. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  240. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  241. package/lib/task-files-machine-retirement.ts +116 -0
  242. package/lib/task-files-readiness.ts +127 -0
  243. package/lib/task-gateway-port.ts +22 -0
  244. package/lib/task-gateway-protocol.ts +190 -0
  245. package/lib/task-gateway-recovery.ts +47 -0
  246. package/lib/task-gateway-state.ts +512 -0
  247. package/lib/task-identity.ts +19 -0
  248. package/lib/task-inventory.ts +682 -0
  249. package/lib/task-machine-credentials-activation.ts +95 -0
  250. package/lib/task-machine-swap-safety.ts +729 -0
  251. package/lib/task-person-credentials.ts +76 -0
  252. package/lib/task-project-ops-host.ts +147 -0
  253. package/lib/task-project-ops.ts +1062 -0
  254. package/lib/task-secret-staging.ts +16 -0
  255. package/lib/task-status.ts +46 -0
  256. package/lib/task-token.ts +46 -0
  257. package/lib/token-broker-adopt.ts +344 -0
  258. package/lib/token-broker-lambda.ts +105 -0
  259. package/lib/token-broker-local.ts +392 -0
  260. package/lib/token-broker-revision.ts +310 -0
  261. package/lib/token-broker-ssm.ts +525 -0
  262. package/lib/token-broker.ts +218 -0
  263. package/lib/token-lanes/openai-oauth.ts +123 -0
  264. package/lib/token-lanes/registry.ts +45 -0
  265. package/lib/token-lanes/types.ts +163 -0
  266. package/lib/token-lanes/xai-oauth.ts +165 -0
  267. package/lib/transcript.ts +128 -0
  268. package/lib/tunnel-registry.ts +220 -0
  269. package/lib/tunnel-runtime.ts +235 -0
  270. package/lib/ulid.ts +16 -0
  271. package/lib/user-bus-env.ts +116 -0
  272. package/lib/user-credentials-store.ts +435 -0
  273. package/lib/vnode-pressure.ts +142 -0
  274. package/package.json +131 -4
  275. package/runner/runner.mjs +442 -0
  276. package/scripts/agent/_common.sh +633 -0
  277. package/scripts/agent/env-hook.sh +626 -0
  278. package/scripts/agent/moondeck-cli.sh +5 -0
  279. package/scripts/agent/task-down.sh +291 -0
  280. package/scripts/agent/task-status.sh +70 -0
  281. package/scripts/agent/task-up.sh +3761 -0
  282. package/scripts/agent/uai-env-exec.sh +26 -0
  283. package/scripts/install/darwin.ts +1048 -0
  284. package/scripts/install/linux.ts +958 -0
  285. package/scripts/install/types.ts +78 -0
  286. package/scripts/install/util.ts +254 -0
  287. package/scripts/install/win.ts +143 -0
  288. package/src/activation-respawn.ts +155 -0
  289. package/src/apple-tunnel-route.ts +104 -0
  290. package/src/capture-wire.ts +102 -0
  291. package/src/cli.ts +2583 -0
  292. package/src/cloud-credentials.ts +2144 -0
  293. package/src/cloud-origins.ts +23 -0
  294. package/src/credential-location.ts +248 -0
  295. package/src/credential-wire-validation.ts +25 -0
  296. package/src/custom-connections.ts +237 -0
  297. package/src/engine-account-paths.ts +11 -0
  298. package/src/engine-models.ts +39 -0
  299. package/src/engine-usage.ts +104 -0
  300. package/src/event-outbox.ts +185 -0
  301. package/src/index.ts +2899 -0
  302. package/src/load-env.ts +97 -0
  303. package/src/machine-tunnel-route.ts +156 -0
  304. package/src/main.ts +6349 -0
  305. package/src/never-proxied-domains.ts +31 -0
  306. package/src/outbound-frame.ts +31 -0
  307. package/src/paths.ts +81 -0
  308. package/src/project-env-latest.ts +130 -0
  309. package/src/project-env-policy.ts +396 -0
  310. package/src/project-env-wording.ts +53 -0
  311. package/src/protocol.ts +5545 -0
  312. package/src/runtime-bootstrap.ts +165 -0
  313. package/src/station-identity.ts +96 -0
  314. package/src/task-env-names.ts +28 -0
  315. package/src/task-secrets.ts +75 -0
  316. package/src/tunnel-dial.ts +102 -0
  317. package/src/ui/server.ts +852 -0
  318. package/src/ui/types.ts +173 -0
  319. package/src/workspace-path-policy.ts +41 -0
  320. package/ui/app.js +1102 -0
  321. package/ui/index.html +90 -0
  322. package/ui/style.css +685 -0
  323. package/ui/uai-favicon.svg +21 -0
  324. package/ui/uai-logo-black.svg +9 -0
  325. package/ui/uai-wheel.svg +9 -0
@@ -0,0 +1,2933 @@
1
+ /**
2
+ * Generation-scoped remote engine login (ADR-100).
3
+ *
4
+ * OAuth happens in a short-lived standard-image container, never in a task
5
+ * container. The cloud sees only a validated HTTPS authorization URL and
6
+ * secret-blind progress. One-time codes/callback queries are accepted once,
7
+ * consumed locally, and are never copied into an event, error, or log line.
8
+ */
9
+
10
+ import { spawn } from "node:child_process";
11
+ import { randomBytes } from "node:crypto";
12
+ import { constants, existsSync } from "node:fs";
13
+ import {
14
+ chmod,
15
+ lstat,
16
+ mkdir,
17
+ mkdtemp,
18
+ open,
19
+ readdir,
20
+ readFile,
21
+ realpath,
22
+ rename,
23
+ rm,
24
+ unlink,
25
+ } from "node:fs/promises";
26
+ import { homedir, tmpdir } from "node:os";
27
+ import { basename, dirname, join, resolve } from "node:path";
28
+
29
+ import {
30
+ ENGINE_LOGIN_KINDS,
31
+ HOST_ACCOUNT_SCOPE,
32
+ isEngineAccountLabel,
33
+ isEngineAccountScope,
34
+ isHostOpId,
35
+ MAX_ENGINE_LOGIN_QUERY_CHARS,
36
+ MAX_ENGINE_LOGIN_TEXT_CHARS,
37
+ MAX_ENGINE_LOGIN_URL_CHARS,
38
+ type EngineLoginCapability,
39
+ type EngineLoginErrorCode,
40
+ type EngineLoginEventFrame,
41
+ type EngineLoginKind,
42
+ } from "../src/protocol";
43
+ import type { CaptureFlowWire } from "../src/cloud-credentials";
44
+ import type { CaptureOutcome, EngineCaptureMaterial } from "./capture-flow";
45
+ import {
46
+ addCodexAccountFromAuthJson,
47
+ addEngineAccount,
48
+ addGrokAccountFromAuthJson,
49
+ } from "./engine-accounts";
50
+ import { reinjectCodexRunningTasks } from "./codex-auth";
51
+ import {
52
+ appleContainerRuntimeBinding,
53
+ pinnedContainerRuntimeProvider,
54
+ } from "./container-runtime";
55
+ import { dockerCli } from "./docker-exec";
56
+ import { env } from "./env";
57
+ import {
58
+ serializeEnvFileMutation,
59
+ writeEnvValuesAtomic,
60
+ } from "./env-file";
61
+ import { ASDF_DATA_VOLUME, STANDARD_IMAGE_TAG } from "./standard-image";
62
+
63
+ export const ENGINE_LOGIN_TIMEOUT_MS = 10 * 60_000;
64
+ /** Delay before the separate submit keystroke — long enough for the TUI to
65
+ * finish ingesting the pasted code chunk, short enough to feel instant. */
66
+ export const SUBMIT_KEYSTROKE_DELAY_MS = 300;
67
+ // Global serialization keeps a failed per-operation stale-resource sweep from
68
+ // ever deleting another live login's labeled container or marked temp dir.
69
+ /**
70
+ * Concurrent login containers per runner. Workspace sign-ins run side by
71
+ * side (ADR-136 sign-in box: a user is never told to wait for someone else's
72
+ * login); only the host's DEFAULT slot is exclusive, per engine. Bounded so
73
+ * a burst cannot fork containers without limit.
74
+ */
75
+ export const MAX_CONCURRENT_ENGINE_LOGINS = 16;
76
+
77
+ const LOGIN_MOUNT = "/run/uai-engine-login";
78
+ const LOGIN_HOME = `${LOGIN_MOUNT}/home`;
79
+ const CODEX_HOME = `${LOGIN_MOUNT}/codex`;
80
+ /** grok writes its auth.json under GROK_HOME (host 0.9.105 remote sign-in). */
81
+ const GROK_HOME = `${LOGIN_MOUNT}/grok`;
82
+ /** The login image only carries grok when the host had it configured at its
83
+ * last image build; a shared host never did. Install into the op's private
84
+ * HOME on the fly (x.ai's installer, ~seconds) when the image lacks it. */
85
+ const GROK_LOGIN_SHELL =
86
+ 'g="$(command -v grok 2>/dev/null || true)"; ' +
87
+ '[ -n "$g" ] || { curl -fsSL https://x.ai/cli/install.sh | bash >/dev/null 2>&1; g="$HOME/.grok/bin/grok"; }; ' +
88
+ '[ -x "$g" ] || { echo "grok CLI is not available in the login image" >&2; exit 127; }; ' +
89
+ 'exec "$g" login --device-auth';
90
+ const MAX_OUTPUT_TAIL_BYTES = 64 * 1_024;
91
+ /** script(1) transcripts of a login run a few KB; 1MB is a hostile-file cap. */
92
+ const MAX_TRANSCRIPT_BYTES = 1_024 * 1_024;
93
+ const MAX_CODEX_AUTH_BYTES = 1024 * 1024;
94
+ const DOCKER_CONTROL_TIMEOUT_MS = 15_000;
95
+ const DOCKER_CLEANUP_TIMEOUT_MS = 5_000;
96
+ const DOCKER_CLEANUP_ATTEMPTS = 3;
97
+ const DOCKER_RUN_STOP_GRACE_MS = 1_000;
98
+ const MAX_STALE_LOGIN_CONTAINERS = 64;
99
+ const MAX_STALE_LOGIN_TEMP_DIRS = 64;
100
+ const ENGINE_LOGIN_TEMP_DIR_PATTERN = /^uai-engine-login-[A-Za-z0-9]{6}$/;
101
+ const ENGINE_LOGIN_TEMP_MARKER = ".uai-engine-login-owner";
102
+ const ENGINE_LOGIN_TEMP_MARKER_VERSION = "v1";
103
+ export const ENGINE_LOGIN_CONTAINER_LABEL = "io.moondeck.engine-login";
104
+ export const ENGINE_LOGIN_CONTAINER_LABEL_VALUE = "v1";
105
+
106
+ type EmitEngineLoginEvent = (event: EngineLoginEventFrame) => void;
107
+
108
+ export interface EngineLoginProcessResult {
109
+ readonly code: number | null;
110
+ readonly spawnFailed?: boolean;
111
+ }
112
+
113
+ /** A running `docker run --rm` login container. */
114
+ export interface EngineLoginProcess {
115
+ readonly containerName: string;
116
+ readonly completion: Promise<EngineLoginProcessResult>;
117
+ writeInput(value: string): void;
118
+ stop(): Promise<void>;
119
+ }
120
+
121
+ export interface EngineLoginContainerRequest {
122
+ readonly engine: EngineLoginKind;
123
+ readonly containerName: string;
124
+ readonly tempDir: string;
125
+ }
126
+
127
+ export interface EngineLoginDockerResult {
128
+ readonly status: number | null;
129
+ readonly stdout: string;
130
+ readonly stderr: string;
131
+ }
132
+
133
+ export type EngineLoginDockerExec = (
134
+ args: string[],
135
+ options: {
136
+ input?: string;
137
+ timeoutMs: number;
138
+ maxOutputBytes: number;
139
+ },
140
+ ) => Promise<EngineLoginDockerResult>;
141
+
142
+ export interface EngineLoginTimers {
143
+ setTimeout(callback: () => void, delayMs: number): unknown;
144
+ clearTimeout(timer: unknown): void;
145
+ }
146
+
147
+ export interface AccountAddOutcome {
148
+ ok: boolean;
149
+ message: string;
150
+ id?: string;
151
+ }
152
+
153
+ export interface EngineLoginSeams {
154
+ createTempDir(): Promise<string>;
155
+ removeTempDir(path: string): Promise<void>;
156
+ startContainer(
157
+ request: EngineLoginContainerRequest,
158
+ onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
159
+ ): Promise<EngineLoginProcess>;
160
+ replayCodexCallback(
161
+ containerName: string,
162
+ callbackUrl: string,
163
+ ): Promise<void>;
164
+ reconcileStaleContainers(): Promise<void>;
165
+ /**
166
+ * Read the script(1) PTY transcript from the login leaf (null when absent
167
+ * or unreadable). The transcript is written INSIDE the container, so it is
168
+ * the pure PTY byte stream — structurally free of the container runtime's
169
+ * own stdio chatter, which on the Apple CLI painted XPC noise over the
170
+ * very cells the TUI's diff renderer skipped (live 2026-08-21).
171
+ */
172
+ readTranscript(tempDir: string): Promise<string | null>;
173
+ persistClaudeToken(token: string): Promise<void>;
174
+ persistCodexAuth(sourceFile: string): Promise<void>;
175
+ /** ADR-116: labeled extra-account captures (never the default slots).
176
+ * Sync on a host that seals the capture locally; a promise on the ADR-136
177
+ * sign-in box, whose sink awaits the credential store's write. */
178
+ addClaudeAccount(
179
+ label: string,
180
+ token: string,
181
+ scope: string,
182
+ ): AccountAddOutcome | Promise<AccountAddOutcome>;
183
+ addCodexAccount(
184
+ label: string,
185
+ authJson: string,
186
+ scope: string,
187
+ ): AccountAddOutcome | Promise<AccountAddOutcome>;
188
+ addGrokAccount(
189
+ label: string,
190
+ authJson: string,
191
+ scope: string,
192
+ ): AccountAddOutcome | Promise<AccountAddOutcome>;
193
+ persistGrokAuth(sourceFile: string): Promise<void>;
194
+ /** ADR-154: the sign-in box's sink for a capture flow; absent elsewhere. */
195
+ captureEngineAccount?(
196
+ flow: CaptureFlowWire,
197
+ engine: EngineLoginKind,
198
+ material: EngineCaptureMaterial,
199
+ ): Promise<CaptureOutcome>;
200
+ /** Optional engines ride the standard image only when configured; a fresh
201
+ * account may be the first configuration → rebuild (queue-deduped). */
202
+ ensureStandardImage(): Promise<void>;
203
+ refreshEngineAccountAgents(engine: "claude"): Promise<void>;
204
+ reinjectCodexRunningTasks(): Promise<void>;
205
+ ownerHome(): string;
206
+ procEnv: NodeJS.ProcessEnv;
207
+ fileExists(path: string): boolean;
208
+ randomHex(bytes: number): string;
209
+ timers: EngineLoginTimers;
210
+ }
211
+
212
+ export interface EngineLoginManagerOptions {
213
+ /** The running orchestrator owns this operation; main.ts supplies it. */
214
+ refreshEngineAccountAgents(engine: "claude"): Promise<void>;
215
+ /**
216
+ * Exact host directory used for short-lived bind inputs. main.ts supplies
217
+ * a private leaf under UAI_HOME; ambient Docker mounts it directly.
218
+ */
219
+ tempRoot?: string;
220
+ /** Defer Docker inventory until the first start when no runtime is selected. */
221
+ reconcileOnCreate?: boolean;
222
+ /** ADR-136 sign-in box: only labeled, workspace-scoped sign-ins are
223
+ * admitted — the default slots are the host owner's and the box has none. */
224
+ workspaceOnly?: boolean;
225
+ seams?: Partial<EngineLoginSeams>;
226
+ /** Rebuild the standard image after a successful capture (optional engines). */
227
+ ensureStandardImage?: () => Promise<unknown>;
228
+ }
229
+
230
+ type OperationStage =
231
+ | "starting"
232
+ | "awaiting_input"
233
+ | "awaiting_callback"
234
+ | "awaiting_confirmation";
235
+
236
+ interface CodexCallbackTarget {
237
+ readonly origin: string;
238
+ readonly pathname: string;
239
+ readonly port: string;
240
+ readonly hostname: string;
241
+ readonly state: string;
242
+ }
243
+
244
+ interface LoginOperation {
245
+ readonly opId: string;
246
+ readonly engine: EngineLoginKind;
247
+ readonly emit: EmitEngineLoginEvent;
248
+ active: boolean;
249
+ finishing: boolean;
250
+ stage: OperationStage;
251
+ timer: unknown | null;
252
+ process: EngineLoginProcess | null;
253
+ tempDir: string | null;
254
+ launchPromise: Promise<void> | null;
255
+ callbackPromise: Promise<boolean> | null;
256
+ finishPromise: Promise<void> | null;
257
+ cleanupPromise: Promise<void> | null;
258
+ settlementPromise: Promise<void> | null;
259
+ suppressTerminalEvent: boolean;
260
+ outputTail: string;
261
+ /** stdout-only tail — the credential boundary (stderr never joins it). */
262
+ stdoutTail: string;
263
+ inputUsed: boolean;
264
+ callbackUsed: boolean;
265
+ pendingInput: string | null;
266
+ callbackTarget: CodexCallbackTarget | null;
267
+ /** ADR-154: when set, the capture is written as this revision of a cloud
268
+ * credential and nothing else happens to it. */
269
+ captureFlow: CaptureFlowWire | null;
270
+ /** ADR-116: when set, the captured credential lands in a labeled extra
271
+ * account instead of the default slot. */
272
+ accountLabel: string | null;
273
+ /** ADR-131 D3: the scope a labeled capture is stored under. */
274
+ accountScope: string;
275
+ /** Diagnosis breadcrumb: has ANY output arrived after the one-time code
276
+ * was written? Distinguishes "stdin never delivered" from "CLI answered
277
+ * and we ignored it" without reading the PTY transcript. */
278
+ postInputOutputSeen: boolean;
279
+ }
280
+
281
+ const defaultTimers: EngineLoginTimers = {
282
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
283
+ clearTimeout: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
284
+ };
285
+
286
+ function unrefTimer(timer: unknown): void {
287
+ if (
288
+ typeof timer === "object" &&
289
+ timer !== null &&
290
+ "unref" in timer &&
291
+ typeof (timer as { unref?: unknown }).unref === "function"
292
+ ) {
293
+ (timer as { unref(): void }).unref();
294
+ }
295
+ }
296
+
297
+ function ownerHome(): string {
298
+ return process.env.UAI_OWNER_HOME?.trim() || homedir();
299
+ }
300
+
301
+ function envLocalPath(): string {
302
+ return join(env.uaiHome, ".env.local");
303
+ }
304
+
305
+ export async function persistClaudeTokenAtomic(
306
+ token: string,
307
+ options: {
308
+ envFile?: string;
309
+ procEnv?: NodeJS.ProcessEnv;
310
+ } = {},
311
+ ): Promise<void> {
312
+ if (extractClaudeOAuthToken(token) !== token) {
313
+ throw new Error("invalid Claude OAuth token");
314
+ }
315
+ const file = options.envFile ?? envLocalPath();
316
+ await serializeEnvFileMutation(file, () => {
317
+ writeEnvValuesAtomic(file, { CLAUDE_CODE_OAUTH_TOKEN: token });
318
+ });
319
+ // Agent availability and task env injection read the live process env.
320
+ (options.procEnv ?? process.env).CLAUDE_CODE_OAUTH_TOKEN = token;
321
+ }
322
+
323
+ export async function persistCodexAuthAtomic(
324
+ sourceFile: string,
325
+ options: { ownerHome?: string } = {},
326
+ ): Promise<void> {
327
+ const body = await readBoundedRegularFile(sourceFile, MAX_CODEX_AUTH_BYTES);
328
+ let parsed: unknown;
329
+ try {
330
+ parsed = JSON.parse(body.toString("utf8"));
331
+ } catch {
332
+ throw new Error("Codex produced invalid auth data");
333
+ }
334
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
335
+ throw new Error("Codex produced invalid auth data");
336
+ }
337
+ await writeSensitiveFileAtomic(
338
+ join(options.ownerHome ?? ownerHome(), ".codex", "auth.json"),
339
+ body,
340
+ );
341
+ }
342
+
343
+ export async function persistGrokAuthAtomic(
344
+ sourceFile: string,
345
+ options: { ownerHome?: string } = {},
346
+ ): Promise<void> {
347
+ const body = await readBoundedRegularFile(sourceFile, MAX_CODEX_AUTH_BYTES);
348
+ let parsed: unknown;
349
+ try {
350
+ parsed = JSON.parse(body.toString("utf8"));
351
+ } catch {
352
+ throw new Error("Grok produced invalid auth data");
353
+ }
354
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
355
+ throw new Error("Grok produced invalid auth data");
356
+ }
357
+ await writeSensitiveFileAtomic(
358
+ join(options.ownerHome ?? ownerHome(), ".grok", "auth.json"),
359
+ body,
360
+ );
361
+ }
362
+
363
+ function defaultSeams(options: EngineLoginManagerOptions): EngineLoginSeams {
364
+ const tempRoot = options.tempRoot;
365
+ return {
366
+ createTempDir: () => createEngineLoginTempDir(tempRoot),
367
+ removeTempDir: (path) => rm(path, { recursive: true, force: true }),
368
+ startContainer: startLoginContainer,
369
+ replayCodexCallback,
370
+ reconcileStaleContainers: () =>
371
+ reconcileStaleEngineLoginResources({ tempRoot }),
372
+ readTranscript: async (tempDir) => {
373
+ try {
374
+ const body = await readBoundedRegularFile(
375
+ join(tempDir, "typescript"),
376
+ MAX_TRANSCRIPT_BYTES,
377
+ );
378
+ return body.toString("utf8");
379
+ } catch {
380
+ return null;
381
+ }
382
+ },
383
+ persistClaudeToken: (token) => persistClaudeTokenAtomic(token),
384
+ persistCodexAuth: (sourceFile) => persistCodexAuthAtomic(sourceFile),
385
+ addClaudeAccount: (label, token, scope) =>
386
+ addEngineAccount("claude", label, { token }, {}, scope),
387
+ addCodexAccount: (label, authJson, scope) =>
388
+ addCodexAccountFromAuthJson(label, authJson, {}, scope),
389
+ addGrokAccount: (label, authJson, scope) =>
390
+ addGrokAccountFromAuthJson(label, authJson, {}, scope),
391
+ persistGrokAuth: (sourceFile) => persistGrokAuthAtomic(sourceFile),
392
+ ensureStandardImage: async () => {
393
+ await options.ensureStandardImage?.();
394
+ },
395
+ refreshEngineAccountAgents: options.refreshEngineAccountAgents,
396
+ reinjectCodexRunningTasks: () => reinjectCodexRunningTasks(),
397
+ ownerHome,
398
+ procEnv: process.env,
399
+ fileExists: existsSync,
400
+ randomHex: (bytes) => randomBytes(bytes).toString("hex"),
401
+ timers: defaultTimers,
402
+ };
403
+ }
404
+
405
+ function withDefaults(options: EngineLoginManagerOptions): EngineLoginSeams {
406
+ const defaults = defaultSeams(options);
407
+ return {
408
+ ...defaults,
409
+ ...options.seams,
410
+ timers: { ...defaults.timers, ...options.seams?.timers },
411
+ };
412
+ }
413
+
414
+ /**
415
+ * One manager belongs to one authenticated socket generation. Disconnect must
416
+ * call `stopAll()` before dropping the socket emitter.
417
+ */
418
+ export class EngineLoginManager {
419
+ private readonly seams: EngineLoginSeams;
420
+ private readonly workspaceOnly: boolean;
421
+ private readonly operations = new Map<string, LoginOperation>();
422
+ /**
423
+ * The DEFAULT-SLOT lock: one unlabeled capture per engine at a time, since
424
+ * two of them would race on the host's own `~/.codex`-style slot. Labeled
425
+ * (workspace) captures land in isolated accounts and never take it — on
426
+ * the ADR-136 sign-in box every workspace's sign-in runs alongside every
427
+ * other's; a user is never told to wait for someone else's login.
428
+ */
429
+ private readonly defaultSlotOperations = new Map<EngineLoginKind, LoginOperation>();
430
+ private reconciliation: Promise<boolean>;
431
+
432
+ constructor(options: EngineLoginManagerOptions) {
433
+ this.seams = withDefaults(options);
434
+ this.workspaceOnly = options.workspaceOnly === true;
435
+ this.reconciliation =
436
+ options.reconcileOnCreate === false
437
+ ? Promise.resolve(false)
438
+ : this.runReconciliation();
439
+ }
440
+
441
+ /** Secret-free current capability state. v1 deliberately claims no expiry. */
442
+ capabilities(): EngineLoginCapability[] {
443
+ const claudeConfigured = Boolean(
444
+ this.seams.procEnv.CLAUDE_CODE_OAUTH_TOKEN ||
445
+ this.seams.procEnv.ANTHROPIC_API_KEY ||
446
+ this.seams.procEnv.ANTHROPIC_AUTH_TOKEN,
447
+ );
448
+ const codexConfigured = this.seams.fileExists(
449
+ join(this.seams.ownerHome(), ".codex", "auth.json"),
450
+ );
451
+ const grokConfigured =
452
+ this.seams.fileExists(join(this.seams.ownerHome(), ".grok", "auth.json")) ||
453
+ Boolean(this.seams.procEnv.XAI_API_KEY);
454
+ return [
455
+ {
456
+ engine: "claude",
457
+ status: claudeConfigured ? "configured" : "missing",
458
+ loginSupported: true,
459
+ },
460
+ {
461
+ engine: "codex",
462
+ status: codexConfigured ? "configured" : "missing",
463
+ loginSupported: true,
464
+ },
465
+ {
466
+ engine: "grok",
467
+ status: grokConfigured ? "configured" : "missing",
468
+ loginSupported: true,
469
+ },
470
+ ];
471
+ }
472
+
473
+ /** Reserve and launch without waiting for a human-paced OAuth flow. */
474
+ start(
475
+ opId: string,
476
+ engine: EngineLoginKind,
477
+ emit: EmitEngineLoginEvent,
478
+ label?: string,
479
+ scope?: string,
480
+ flow?: CaptureFlowWire,
481
+ ): boolean {
482
+ if (!isHostOpId(opId)) return false;
483
+ // ADR-154: a capture flow names its own destination; it runs only on the
484
+ // sign-in box, which alone writes cloud credentials.
485
+ if (
486
+ flow !== undefined &&
487
+ (!this.workspaceOnly || this.seams.captureEngineAccount === undefined)
488
+ ) {
489
+ safeEmit(
490
+ emit,
491
+ failedEvent(opId, engine, "start_failed", "Only the Moondeck Cloud sign-in box writes cloud credentials."),
492
+ );
493
+ return false;
494
+ }
495
+ if (scope !== undefined && !isEngineAccountScope(scope)) {
496
+ safeEmit(
497
+ emit,
498
+ failedEvent(opId, engine, "start_failed", "The account scope is invalid."),
499
+ );
500
+ return false;
501
+ }
502
+ // ADR-136 sign-in box: nothing here may write a default slot, and a
503
+ // host-scope account would belong to a runner that owns no credential.
504
+ if (
505
+ this.workspaceOnly &&
506
+ flow === undefined &&
507
+ (scope === undefined || scope === HOST_ACCOUNT_SCOPE || label === undefined)
508
+ ) {
509
+ safeEmit(
510
+ emit,
511
+ failedEvent(
512
+ opId,
513
+ engine,
514
+ "start_failed",
515
+ "The Moondeck Cloud sign-in box signs in workspace accounts only.",
516
+ ),
517
+ );
518
+ return false;
519
+ }
520
+ // A workspace's sign-in is always a labeled account: the default slot is
521
+ // the host owner's and must never be written on a tenant's behalf.
522
+ if (scope !== undefined && scope !== HOST_ACCOUNT_SCOPE && label === undefined) {
523
+ safeEmit(
524
+ emit,
525
+ failedEvent(opId, engine, "start_failed", "A workspace account needs a label."),
526
+ );
527
+ return false;
528
+ }
529
+ if (label !== undefined && !isEngineAccountLabel(label)) {
530
+ safeEmit(
531
+ emit,
532
+ failedEvent(opId, engine, "start_failed", "The account label is invalid."),
533
+ );
534
+ return false;
535
+ }
536
+ if (this.operations.has(opId)) {
537
+ safeEmit(emit, failedEvent(opId, engine, "unavailable", "Login operation already exists."));
538
+ return false;
539
+ }
540
+ if (label === undefined && this.defaultSlotOperations.has(engine)) {
541
+ safeEmit(
542
+ emit,
543
+ failedEvent(
544
+ opId,
545
+ engine,
546
+ "unavailable",
547
+ `A ${engineLabel(engine)} login is already in progress.`,
548
+ ),
549
+ );
550
+ return false;
551
+ }
552
+ // Every login is a container; bound the fan-out without serialising it.
553
+ if (this.operations.size >= MAX_CONCURRENT_ENGINE_LOGINS) {
554
+ safeEmit(
555
+ emit,
556
+ failedEvent(
557
+ opId,
558
+ engine,
559
+ "unavailable",
560
+ "Too many sign-ins are running on this station right now; try again in a minute.",
561
+ ),
562
+ );
563
+ return false;
564
+ }
565
+
566
+ const operation: LoginOperation = {
567
+ opId,
568
+ engine,
569
+ emit,
570
+ active: true,
571
+ finishing: false,
572
+ stage: "starting",
573
+ timer: null,
574
+ process: null,
575
+ tempDir: null,
576
+ launchPromise: null,
577
+ callbackPromise: null,
578
+ finishPromise: null,
579
+ cleanupPromise: null,
580
+ settlementPromise: null,
581
+ suppressTerminalEvent: false,
582
+ outputTail: "",
583
+ stdoutTail: "",
584
+ inputUsed: false,
585
+ callbackUsed: false,
586
+ pendingInput: null,
587
+ callbackTarget: null,
588
+ captureFlow: flow ?? null,
589
+ accountLabel: label ?? null,
590
+ accountScope: scope ?? HOST_ACCOUNT_SCOPE,
591
+ postInputOutputSeen: false,
592
+ };
593
+ this.operations.set(opId, operation);
594
+ if (label === undefined && flow === undefined) {
595
+ this.defaultSlotOperations.set(engine, operation);
596
+ }
597
+ operation.timer = this.seams.timers.setTimeout(() => {
598
+ void this.timeout(operation);
599
+ }, ENGINE_LOGIN_TIMEOUT_MS);
600
+ unrefTimer(operation.timer);
601
+
602
+ safeEmit(emit, {
603
+ kind: "engine.login.event",
604
+ opId,
605
+ engine,
606
+ phase: "starting",
607
+ message: `Starting ${engineLabel(engine)} login on this host.`,
608
+ });
609
+ operation.launchPromise = this.launch(operation);
610
+ return true;
611
+ }
612
+
613
+ /** Consume Claude's one-time code exactly once. */
614
+ input(opId: string, text: string): boolean {
615
+ const operation = this.operations.get(opId);
616
+ // Every rejection names itself in the host log: a silently dropped code
617
+ // reads as "Waiting for the host…" forever on the cloud pane (live
618
+ // 2026-08-20, the guard chain gave the operator nothing to act on).
619
+ // Never log `text` — it is one-time OAuth material.
620
+ if (!operation) {
621
+ console.warn(`[engine-login] input dropped: unknown opId ${opId}`);
622
+ return false;
623
+ }
624
+ const rejection = !this.isCurrent(operation)
625
+ ? "operation belongs to a previous bridge generation"
626
+ : operation.engine !== "claude"
627
+ ? `engine is ${operation.engine}, not claude`
628
+ : operation.stage !== "awaiting_input"
629
+ ? `stage is ${operation.stage}, not awaiting_input`
630
+ : operation.inputUsed
631
+ ? "a code was already submitted for this operation"
632
+ : operation.finishing
633
+ ? "the operation is already finishing"
634
+ : null;
635
+ if (rejection !== null) {
636
+ console.warn(
637
+ `[engine-login] input dropped for ${opId}: ${rejection}`,
638
+ );
639
+ return false;
640
+ }
641
+ if (!isValidOneTimeInput(text)) {
642
+ console.warn(
643
+ `[engine-login] input rejected for ${opId}: failed one-time-input validation (length ${text.length})`,
644
+ );
645
+ void this.fail(
646
+ operation,
647
+ "invalid_input",
648
+ "The one-time authorization code was rejected.",
649
+ );
650
+ return false;
651
+ }
652
+ console.log(
653
+ `[engine-login] input accepted for ${opId} (${text.length} chars)`,
654
+ );
655
+ operation.inputUsed = true;
656
+ operation.outputTail = "";
657
+ operation.stdoutTail = "";
658
+ if (!operation.process) {
659
+ console.log(
660
+ `[engine-login] input buffered for ${opId} (login process still starting)`,
661
+ );
662
+ operation.pendingInput = text;
663
+ return true;
664
+ }
665
+ return this.deliverOneTimeCode(operation, text);
666
+ }
667
+
668
+ /**
669
+ * The submit is a SEPARATE, delayed carriage return — both halves of that
670
+ * are load-bearing, reproduced live 2026-08-20/21:
671
+ * - `\r`, not `\n`: the Ink raw-mode prompt only submits on carriage
672
+ * return (a `\n` types the code and sits forever).
673
+ * - a separate delayed write, not one chunk: a real 92-char code + `\r`
674
+ * in one write is treated as PASTE data and the embedded `\r` is
675
+ * swallowed — stars appear, nothing submits (short test codes slipped
676
+ * under the paste threshold, which is why every isolated repro passed
677
+ * while production hung).
678
+ */
679
+ private deliverOneTimeCode(operation: LoginOperation, text: string): boolean {
680
+ const process = operation.process;
681
+ if (!process) return false;
682
+ try {
683
+ process.writeInput(text);
684
+ console.log(
685
+ `[engine-login] input written to the login process for ${operation.opId}`,
686
+ );
687
+ } catch {
688
+ console.warn(
689
+ `[engine-login] input write FAILED for ${operation.opId}: the login process rejected stdin`,
690
+ );
691
+ void this.fail(
692
+ operation,
693
+ "invalid_input",
694
+ "The login process could not accept the one-time code.",
695
+ );
696
+ return false;
697
+ }
698
+ const submitTimer = setTimeout(() => {
699
+ if (
700
+ !this.isCurrent(operation) ||
701
+ operation.finishing ||
702
+ operation.process !== process
703
+ ) {
704
+ return;
705
+ }
706
+ try {
707
+ process.writeInput("\r");
708
+ console.log(
709
+ `[engine-login] submit keystroke dispatched for ${operation.opId}`,
710
+ );
711
+ } catch {
712
+ void this.fail(
713
+ operation,
714
+ "invalid_input",
715
+ "The login process could not accept the submit keystroke.",
716
+ );
717
+ }
718
+ }, SUBMIT_KEYSTROKE_DELAY_MS);
719
+ unrefTimer(submitTimer);
720
+ return true;
721
+ }
722
+
723
+ /** Validate and replay a Codex callback only to this operation's listener. */
724
+ async callback(opId: string, value: string): Promise<boolean> {
725
+ const operation = this.operations.get(opId);
726
+ // Same named-rejection contract as input() (0.9.51): every silent exit
727
+ // here left an operator staring at "Waiting for the host…" with zero
728
+ // evidence on either side (live 2026-08-21 — the callback path was the
729
+ // last breadcrumb-free room in the login pipeline).
730
+ const refuse = (reason: string): false => {
731
+ console.log(
732
+ `[engine-login] callback refused for ${opId}: ${reason}`,
733
+ );
734
+ return false;
735
+ };
736
+ if (!operation) return refuse("unknown opId");
737
+ if (!this.isCurrent(operation)) return refuse("superseded operation");
738
+ if (operation.engine !== "codex") return refuse("wrong engine");
739
+ if (operation.stage !== "awaiting_callback") {
740
+ return refuse(`wrong stage (${operation.stage})`);
741
+ }
742
+ if (operation.callbackUsed) return refuse("callback already used");
743
+ if (operation.finishing) return refuse("operation finishing");
744
+ if (!operation.process) return refuse("no login process");
745
+ if (!operation.callbackTarget) return refuse("no callback target");
746
+ console.log(
747
+ `[engine-login] callback accepted for ${opId} (${value.length} chars); validating`,
748
+ );
749
+ const query = validateCodexCallback(value, operation.callbackTarget);
750
+ if (!query) {
751
+ console.warn(
752
+ `[engine-login] callback failed validation for ${opId} (state/shape mismatch with this attempt's authorize URL)`,
753
+ );
754
+ await this.fail(
755
+ operation,
756
+ "invalid_callback",
757
+ "The localhost callback was rejected.",
758
+ );
759
+ return false;
760
+ }
761
+ console.log(
762
+ `[engine-login] callback validated for ${opId}; replaying into the login container`,
763
+ );
764
+ operation.callbackUsed = true;
765
+ const callbackUrl = callbackReplayUrl(operation.callbackTarget, query);
766
+ const replay = (async () => {
767
+ try {
768
+ await this.seams.replayCodexCallback(
769
+ operation.process!.containerName,
770
+ callbackUrl,
771
+ );
772
+ console.log(
773
+ `[engine-login] callback replay delivered for ${operation.opId}; waiting for the CLI to finish`,
774
+ );
775
+ } catch {
776
+ if (this.isCurrent(operation)) {
777
+ await this.fail(
778
+ operation,
779
+ "invalid_callback",
780
+ "The localhost callback could not be delivered.",
781
+ );
782
+ }
783
+ return false;
784
+ }
785
+ return this.isCurrent(operation) || operation.finishing;
786
+ })();
787
+ operation.callbackPromise = replay;
788
+ try {
789
+ return await replay;
790
+ } finally {
791
+ if (operation.callbackPromise === replay) {
792
+ operation.callbackPromise = null;
793
+ }
794
+ }
795
+ }
796
+
797
+ /** Explicit operator cancellation emits one terminal event. */
798
+ async stop(opId: string): Promise<boolean> {
799
+ const operation = this.operations.get(opId);
800
+ if (!operation || !this.isCurrent(operation)) return false;
801
+ if (operation.finishPromise) {
802
+ // Credential commit is the irreversible boundary. Once it starts, join
803
+ // the truthful success/failure outcome instead of claiming cancellation.
804
+ await this.ensureSettlement(operation);
805
+ return true;
806
+ }
807
+ this.detach(operation);
808
+ safeEmit(operation.emit, {
809
+ kind: "engine.login.event",
810
+ opId: operation.opId,
811
+ engine: operation.engine,
812
+ phase: "cancelled",
813
+ message: "Login cancelled.",
814
+ });
815
+ await this.ensureSettlement(operation);
816
+ return true;
817
+ }
818
+
819
+ /**
820
+ * Generation teardown. Detach every operation and secret before awaiting
821
+ * Docker/fs cleanup, so no late child callback can touch a dead socket.
822
+ */
823
+ async stopAll(): Promise<void> {
824
+ const operations = [...this.operations.values()];
825
+ for (const operation of operations) {
826
+ operation.suppressTerminalEvent = true;
827
+ if (!operation.finishPromise) this.detach(operation);
828
+ else this.ensureSettlement(operation);
829
+ }
830
+ await Promise.all(
831
+ operations.map((operation) => this.ensureSettlement(operation)),
832
+ );
833
+ }
834
+
835
+ activeCount(): number {
836
+ let active = 0;
837
+ for (const operation of this.operations.values()) {
838
+ if (operation.active) active += 1;
839
+ }
840
+ return active;
841
+ }
842
+
843
+ private async launch(operation: LoginOperation): Promise<void> {
844
+ let tempDir: string | null = null;
845
+ let processHandle: EngineLoginProcess | null = null;
846
+ try {
847
+ if (!(await this.reconciliation)) {
848
+ // A daemon blip during the eager startup sweep gets one new bounded
849
+ // reconciliation on the next attempted login; never add an orphan on
850
+ // top of state whose ownership could not be established.
851
+ this.reconciliation = this.runReconciliation();
852
+ if (!(await this.reconciliation)) {
853
+ throw new Error("stale login containers could not be reconciled");
854
+ }
855
+ }
856
+ if (!this.isCurrent(operation)) return;
857
+ tempDir = await this.seams.createTempDir();
858
+ if (!this.isCurrent(operation)) {
859
+ await this.removeDetachedTemp(tempDir);
860
+ return;
861
+ }
862
+ operation.tempDir = tempDir;
863
+ const containerName = `uai-login-${operation.engine}-${this.seams.randomHex(8)}`;
864
+ processHandle = await this.seams.startContainer(
865
+ { engine: operation.engine, containerName, tempDir },
866
+ (chunk, stream) => this.onOutput(operation, chunk, stream ?? "stdout"),
867
+ );
868
+ if (!this.isCurrent(operation)) {
869
+ await this.stopDetachedProcess(processHandle);
870
+ if (operation.tempDir === tempDir) operation.tempDir = null;
871
+ await this.removeDetachedTemp(tempDir);
872
+ return;
873
+ }
874
+ operation.process = processHandle;
875
+ if (operation.pendingInput !== null) {
876
+ const pending = operation.pendingInput;
877
+ operation.pendingInput = null;
878
+ if (!this.deliverOneTimeCode(operation, pending)) return;
879
+ }
880
+ void processHandle.completion.then(
881
+ (result) => this.onProcessComplete(operation, result),
882
+ () =>
883
+ this.fail(
884
+ operation,
885
+ "start_failed",
886
+ "The login container stopped unexpectedly.",
887
+ ),
888
+ );
889
+ } catch (error) {
890
+ // The operator sees a one-line failure; the journal must say WHY (the
891
+ // 2026-09-09 sweep refusal was silent for six hours).
892
+ console.warn(
893
+ `[engine-login] ${operation.engine} login could not start: ${
894
+ error instanceof Error ? error.message : String(error)
895
+ }`,
896
+ );
897
+ if (processHandle && !operation.process) {
898
+ await this.stopDetachedProcess(processHandle);
899
+ }
900
+ if (tempDir && !operation.tempDir) await this.removeDetachedTemp(tempDir);
901
+ if (this.isCurrent(operation)) {
902
+ await this.fail(
903
+ operation,
904
+ "start_failed",
905
+ "The login container could not be started.",
906
+ );
907
+ }
908
+ }
909
+ }
910
+
911
+ private onOutput(
912
+ operation: LoginOperation,
913
+ chunk: string,
914
+ stream: "stdout" | "stderr" = "stdout",
915
+ ): void {
916
+ if (!this.isCurrent(operation) || operation.finishing) return;
917
+ operation.outputTail = appendUtf8Tail(
918
+ operation.outputTail,
919
+ chunk,
920
+ MAX_OUTPUT_TAIL_BYTES,
921
+ );
922
+ if (stream === "stdout") {
923
+ // The CREDENTIAL boundary reads stdout only: stderr chunks (the Apple
924
+ // runtime's XPC chatter) interleaving into a merged tail once produced
925
+ // a corrupted token-shaped capture (live 2026-08-21). The merged tail
926
+ // above stays for URL detection and human-facing evidence.
927
+ operation.stdoutTail = appendUtf8Tail(
928
+ operation.stdoutTail,
929
+ chunk,
930
+ MAX_OUTPUT_TAIL_BYTES,
931
+ );
932
+ }
933
+
934
+ if (operation.engine === "claude") {
935
+ if (operation.inputUsed && !operation.postInputOutputSeen) {
936
+ operation.postInputOutputSeen = true;
937
+ console.log(
938
+ `[engine-login] output resumed after input for ${operation.opId} — the code reached the login TUI`,
939
+ );
940
+ }
941
+ // The TUI answers a bad/expired/mismatched code with an error AND an
942
+ // open prompt — no exit, no token. Detect it and fail visibly instead
943
+ // of the silent wait-for-timeout that hid the 2026-08-20 hangs.
944
+ if (operation.inputUsed && !operation.finishing) {
945
+ const cliError = detectClaudeCliError(operation.outputTail);
946
+ if (cliError) {
947
+ console.warn(
948
+ `[engine-login] login CLI rejected the code for ${operation.opId}: ${cliError}`,
949
+ );
950
+ void this.fail(
951
+ operation,
952
+ "invalid_input",
953
+ `The sign-in CLI rejected the code: ${cliError} Make sure the code comes from THIS attempt's authorization page, then retry.`,
954
+ );
955
+ return;
956
+ }
957
+ }
958
+ // Before the one-time input is consumed, token-shaped output could only
959
+ // be an authorization URL/detail or hostile terminal content. This also
960
+ // prevents an input echo from becoming the credential boundary.
961
+ // The scan reads the RECONSTRUCTED screen, not the raw stream: the
962
+ // TUI's diff renderer skips already-correct cells, so the raw bytes
963
+ // never carry the contiguous token (see renderTerminalScreenText).
964
+ const token = operation.inputUsed
965
+ ? extractClaudeOAuthToken(renderTerminalScreenText(operation.stdoutTail))
966
+ : null;
967
+ if (token) {
968
+ console.log(
969
+ `[engine-login] token detected in CLI output for ${operation.opId}; persisting`,
970
+ );
971
+ operation.outputTail = "";
972
+ operation.stdoutTail = "";
973
+ this.beginClaudeFinish(operation, token);
974
+ return;
975
+ }
976
+ if (operation.stage === "starting") {
977
+ const authorizeUrl = findSafeHttpsUrl(operation.outputTail);
978
+ if (authorizeUrl) {
979
+ console.log(
980
+ `[engine-login] authorize URL found for ${operation.opId}; awaiting the one-time code`,
981
+ );
982
+ operation.stage = "awaiting_input";
983
+ safeEmit(operation.emit, authorizeEvent(operation, authorizeUrl));
984
+ safeEmit(operation.emit, {
985
+ kind: "engine.login.event",
986
+ opId: operation.opId,
987
+ engine: operation.engine,
988
+ phase: "awaiting_input",
989
+ message: "Paste the one-time authorization code.",
990
+ });
991
+ }
992
+ }
993
+ return;
994
+ }
995
+
996
+ if (operation.stage === "starting") {
997
+ const device =
998
+ operation.engine === "grok"
999
+ ? findGrokDeviceAuthorization(operation.outputTail)
1000
+ : findCodexDeviceAuthorization(operation.outputTail);
1001
+ if (device) {
1002
+ console.log(
1003
+ `[engine-login] ${operation.engine} device authorization found for ${operation.opId}; awaiting operator confirmation`,
1004
+ );
1005
+ operation.stage = "awaiting_confirmation";
1006
+ safeEmit(
1007
+ operation.emit,
1008
+ authorizeEvent(operation, device.verificationUrl),
1009
+ );
1010
+ safeEmit(operation.emit, {
1011
+ kind: "engine.login.event",
1012
+ opId: operation.opId,
1013
+ engine: operation.engine,
1014
+ phase: "awaiting_confirmation",
1015
+ code: device.userCode,
1016
+ message: `Enter code ${device.userCode} on the verification page. Nothing to paste here — this completes on its own.`,
1017
+ });
1018
+ return;
1019
+ }
1020
+ if (operation.engine !== "codex") return;
1021
+ const authorization = findCodexAuthorization(operation.outputTail);
1022
+ if (authorization) {
1023
+ // Symmetric with the claude branch: this breadcrumb's ABSENCE was
1024
+ // unreadable during the 2026-08-21 hunt — silence meant either "no
1025
+ // output arrived" or "detection fired, codex just never says so".
1026
+ console.log(
1027
+ `[engine-login] codex authorize URL found for ${operation.opId}; awaiting the callback URL`,
1028
+ );
1029
+ operation.callbackTarget = authorization.target;
1030
+ operation.stage = "awaiting_callback";
1031
+ safeEmit(
1032
+ operation.emit,
1033
+ authorizeEvent(operation, authorization.authorizeUrl),
1034
+ );
1035
+ safeEmit(operation.emit, {
1036
+ kind: "engine.login.event",
1037
+ opId: operation.opId,
1038
+ engine: operation.engine,
1039
+ phase: "awaiting_callback",
1040
+ message: "Paste the failed localhost callback URL.",
1041
+ });
1042
+ }
1043
+ }
1044
+ }
1045
+
1046
+ private async onProcessComplete(
1047
+ operation: LoginOperation,
1048
+ result: EngineLoginProcessResult,
1049
+ ): Promise<void> {
1050
+ if (!this.isCurrent(operation) || operation.finishing) return;
1051
+ if (
1052
+ (operation.engine === "codex" || operation.engine === "grok") &&
1053
+ result.code === 0
1054
+ ) {
1055
+ this.beginCodexFinish(operation);
1056
+ if (operation.finishPromise) await operation.finishPromise;
1057
+ return;
1058
+ }
1059
+ if (
1060
+ operation.engine === "claude" &&
1061
+ result.code === 0 &&
1062
+ operation.inputUsed &&
1063
+ operation.tempDir
1064
+ ) {
1065
+ // A clean exit after the code went in means the CLI almost certainly
1066
+ // minted and printed the token — a stream capture that saw nothing is
1067
+ // the capture's failure, not the login's. The relayed stream can be
1068
+ // polluted by the container runtime's own chatter (the Apple CLI's XPC
1069
+ // noise painted characters over the cells the TUI's diff renderer
1070
+ // skipped, corrupting the reconstructed screen — live 2026-08-21 on
1071
+ // the Air, straight after the Linux fix). The PTY transcript is
1072
+ // written inside the container and cannot contain that noise, so it
1073
+ // gets the authoritative re-read before this operation is failed.
1074
+ const transcript = await this.seams.readTranscript(operation.tempDir);
1075
+ if (!this.isCurrent(operation) || operation.finishing) return;
1076
+ const token = transcript
1077
+ ? extractClaudeOAuthToken(renderTerminalScreenText(transcript))
1078
+ : null;
1079
+ if (token) {
1080
+ console.log(
1081
+ `[engine-login] token recovered from the PTY transcript for ${operation.opId}; persisting`,
1082
+ );
1083
+ operation.outputTail = "";
1084
+ operation.stdoutTail = "";
1085
+ this.beginClaudeFinish(operation, token);
1086
+ if (operation.finishPromise) await operation.finishPromise;
1087
+ return;
1088
+ }
1089
+ }
1090
+ const evidence = lastSignificantOutputLine(operation.outputTail);
1091
+ console.warn(
1092
+ `[engine-login] login process for ${operation.opId} ended (exit ${result.code ?? "spawn-failed"}) before completion` +
1093
+ (evidence ? `; last output: ${evidence}` : ""),
1094
+ );
1095
+ await this.fail(
1096
+ operation,
1097
+ result.spawnFailed ? "unavailable" : "start_failed",
1098
+ result.spawnFailed
1099
+ ? "The login runtime is unavailable."
1100
+ : `${engineLabel(operation.engine)} login did not complete.${evidence ? ` Last output: ${evidence}` : ""}`,
1101
+ );
1102
+ }
1103
+
1104
+ private beginClaudeFinish(
1105
+ operation: LoginOperation,
1106
+ token: string,
1107
+ ): void {
1108
+ if (!this.isCurrent(operation) || operation.finishing) return;
1109
+ operation.finishing = true;
1110
+ this.clearOperationTimer(operation);
1111
+ operation.finishPromise = this.commitClaude(operation, token);
1112
+ }
1113
+
1114
+ private async commitClaude(
1115
+ operation: LoginOperation,
1116
+ token: string,
1117
+ ): Promise<void> {
1118
+ let terminal: EngineLoginEventFrame;
1119
+ if (operation.captureFlow !== null) {
1120
+ await this.finishCapture(operation, {
1121
+ authKind: "env",
1122
+ env: { CLAUDE_CODE_OAUTH_TOKEN: token },
1123
+ });
1124
+ return;
1125
+ }
1126
+ if (operation.accountLabel !== null) {
1127
+ // ADR-116: labeled capture — an extra account, not the default slot.
1128
+ // Extras are picked up at the next agent spawn / rotation, so no
1129
+ // running-agent refresh is needed (mirrors the local accounts API).
1130
+ const added = await this.seams.addClaudeAccount(
1131
+ operation.accountLabel,
1132
+ token,
1133
+ operation.accountScope,
1134
+ );
1135
+ terminal = added.ok
1136
+ ? {
1137
+ kind: "engine.login.event",
1138
+ opId: operation.opId,
1139
+ engine: operation.engine,
1140
+ phase: "succeeded",
1141
+ message: added.message,
1142
+ ...(added.id === undefined ? {} : { accountId: added.id }),
1143
+ }
1144
+ : failedEvent(
1145
+ operation.opId,
1146
+ operation.engine,
1147
+ "persist_failed",
1148
+ added.message,
1149
+ );
1150
+ await this.finishCommit(operation, terminal);
1151
+ return;
1152
+ }
1153
+ try {
1154
+ await this.seams.persistClaudeToken(token);
1155
+ } catch {
1156
+ terminal = failedEvent(
1157
+ operation.opId,
1158
+ operation.engine,
1159
+ "persist_failed",
1160
+ "Claude authorized, but its credential could not be saved.",
1161
+ );
1162
+ await this.finishCommit(operation, terminal);
1163
+ return;
1164
+ }
1165
+ // The credential is already durable. Refresh existing agents in the
1166
+ // background: it may traverse many task containers, and must not retain
1167
+ // this ephemeral login container/temp dir or block generation teardown.
1168
+ runBestEffort(() => this.seams.refreshEngineAccountAgents("claude"));
1169
+ terminal = {
1170
+ kind: "engine.login.event",
1171
+ opId: operation.opId,
1172
+ engine: operation.engine,
1173
+ phase: "succeeded",
1174
+ message: "Claude connected.",
1175
+ };
1176
+ await this.finishCommit(operation, terminal);
1177
+ }
1178
+
1179
+ /** ADR-154: hand a capture to the box's sink and report its revision. */
1180
+ private async finishCapture(
1181
+ operation: LoginOperation,
1182
+ material: EngineCaptureMaterial,
1183
+ ): Promise<void> {
1184
+ const sink = this.seams.captureEngineAccount;
1185
+ const outcome =
1186
+ sink && operation.captureFlow
1187
+ ? await sink(operation.captureFlow, operation.engine, material)
1188
+ : { ok: false, message: "Only the Moondeck Cloud sign-in box writes cloud credentials." };
1189
+ await this.finishCommit(
1190
+ operation,
1191
+ outcome.ok && outcome.capture
1192
+ ? {
1193
+ kind: "engine.login.event",
1194
+ opId: operation.opId,
1195
+ engine: operation.engine,
1196
+ phase: "succeeded",
1197
+ message: outcome.message,
1198
+ capture: outcome.capture,
1199
+ }
1200
+ : failedEvent(operation.opId, operation.engine, "persist_failed", outcome.message),
1201
+ );
1202
+ }
1203
+
1204
+ private beginCodexFinish(operation: LoginOperation): void {
1205
+ if (
1206
+ !this.isCurrent(operation) ||
1207
+ operation.finishing ||
1208
+ !operation.tempDir
1209
+ ) {
1210
+ return;
1211
+ }
1212
+ operation.finishing = true;
1213
+ this.clearOperationTimer(operation);
1214
+ operation.finishPromise = this.commitCodex(operation);
1215
+ }
1216
+
1217
+ private async commitCodex(operation: LoginOperation): Promise<void> {
1218
+ const tempDir = operation.tempDir;
1219
+ if (!tempDir) return;
1220
+ // codex and grok share the shape: a device-code CLI that exits 0 after
1221
+ // writing auth.json under its HOME var (CODEX_HOME / GROK_HOME), which
1222
+ // the container mounts from the op's private leaf.
1223
+ const grok = operation.engine === "grok";
1224
+ const authFile = join(tempDir, grok ? "grok" : "codex", "auth.json");
1225
+ const label = grok ? "Grok" : "Codex";
1226
+ let terminal: EngineLoginEventFrame;
1227
+ if (operation.captureFlow !== null) {
1228
+ let contents: string;
1229
+ try {
1230
+ contents = await readFile(authFile, "utf8");
1231
+ } catch {
1232
+ await this.finishCommit(
1233
+ operation,
1234
+ failedEvent(
1235
+ operation.opId,
1236
+ operation.engine,
1237
+ "persist_failed",
1238
+ `${label} authorized, but its credential could not be read.`,
1239
+ ),
1240
+ );
1241
+ return;
1242
+ }
1243
+ await this.finishCapture(operation, {
1244
+ authKind: "config-dir",
1245
+ files: { "auth.json": contents.endsWith("\n") ? contents : `${contents}\n` },
1246
+ });
1247
+ return;
1248
+ }
1249
+ if (operation.accountLabel !== null) {
1250
+ // ADR-116: labeled capture — the auth.json this login produced becomes
1251
+ // an isolated extra account instead of overwriting ~/.codex.
1252
+ let added: AccountAddOutcome;
1253
+ try {
1254
+ const contents = await readFile(authFile, "utf8");
1255
+ added = await (grok
1256
+ ? this.seams.addGrokAccount(
1257
+ operation.accountLabel,
1258
+ contents,
1259
+ operation.accountScope,
1260
+ )
1261
+ : this.seams.addCodexAccount(
1262
+ operation.accountLabel,
1263
+ contents,
1264
+ operation.accountScope,
1265
+ ));
1266
+ } catch {
1267
+ added = {
1268
+ ok: false,
1269
+ message: `${label} authorized, but its credential could not be read.`,
1270
+ };
1271
+ }
1272
+ terminal = added.ok
1273
+ ? {
1274
+ kind: "engine.login.event",
1275
+ opId: operation.opId,
1276
+ engine: operation.engine,
1277
+ phase: "succeeded",
1278
+ message: added.message,
1279
+ ...(added.id === undefined ? {} : { accountId: added.id }),
1280
+ }
1281
+ : failedEvent(
1282
+ operation.opId,
1283
+ operation.engine,
1284
+ "persist_failed",
1285
+ added.message,
1286
+ );
1287
+ await this.finishCommit(operation, terminal);
1288
+ if (added.ok) runBestEffort(() => this.seams.ensureStandardImage());
1289
+ return;
1290
+ }
1291
+ try {
1292
+ if (grok) await this.seams.persistGrokAuth(authFile);
1293
+ else await this.seams.persistCodexAuth(authFile);
1294
+ } catch {
1295
+ terminal = failedEvent(
1296
+ operation.opId,
1297
+ operation.engine,
1298
+ "persist_failed",
1299
+ `${label} authorized, but its credential could not be saved.`,
1300
+ );
1301
+ await this.finishCommit(operation, terminal);
1302
+ return;
1303
+ }
1304
+ if (!grok) runBestEffort(() => this.seams.reinjectCodexRunningTasks());
1305
+ terminal = {
1306
+ kind: "engine.login.event",
1307
+ opId: operation.opId,
1308
+ engine: operation.engine,
1309
+ phase: "succeeded",
1310
+ message: `${label} connected.`,
1311
+ };
1312
+ await this.finishCommit(operation, terminal);
1313
+ runBestEffort(() => this.seams.ensureStandardImage());
1314
+ }
1315
+
1316
+ private async finishCommit(
1317
+ operation: LoginOperation,
1318
+ terminal: EngineLoginEventFrame,
1319
+ ): Promise<void> {
1320
+ if (this.isCurrent(operation)) this.detach(operation);
1321
+ if (!operation.suppressTerminalEvent) safeEmit(operation.emit, terminal);
1322
+ await this.cleanup(operation);
1323
+ }
1324
+
1325
+ private async timeout(operation: LoginOperation): Promise<void> {
1326
+ if (!this.isCurrent(operation)) return;
1327
+ if (operation.finishPromise) {
1328
+ await operation.finishPromise;
1329
+ return;
1330
+ }
1331
+ // ADR-116: attach the CLI's last visible line — a remote operator staring
1332
+ // at "Waiting for the host…" (live 2026-08-20, apple-container) otherwise
1333
+ // has zero evidence of where the flow died.
1334
+ const evidence = lastSignificantOutputLine(operation.outputTail);
1335
+ this.detach(operation);
1336
+ safeEmit(operation.emit, {
1337
+ kind: "engine.login.event",
1338
+ opId: operation.opId,
1339
+ engine: operation.engine,
1340
+ phase: "timed_out",
1341
+ errorCode: "timeout",
1342
+ message: evidence
1343
+ ? `Login timed out. Last output: ${evidence}`
1344
+ : "Login timed out.",
1345
+ });
1346
+ await this.ensureSettlement(operation);
1347
+ }
1348
+
1349
+ private async fail(
1350
+ operation: LoginOperation,
1351
+ errorCode: EngineLoginErrorCode,
1352
+ message: string,
1353
+ ): Promise<void> {
1354
+ if (!this.isCurrent(operation)) return;
1355
+ if (operation.finishPromise) {
1356
+ await operation.finishPromise;
1357
+ return;
1358
+ }
1359
+ this.detach(operation);
1360
+ safeEmit(
1361
+ operation.emit,
1362
+ failedEvent(operation.opId, operation.engine, errorCode, message),
1363
+ );
1364
+ await this.cleanup(operation);
1365
+ }
1366
+
1367
+ private detach(operation: LoginOperation): void {
1368
+ operation.active = false;
1369
+ operation.outputTail = "";
1370
+ operation.stdoutTail = "";
1371
+ operation.pendingInput = null;
1372
+ operation.callbackTarget = null;
1373
+ this.clearOperationTimer(operation);
1374
+ // Keep the exact operation reserved in both maps until cleanup settles.
1375
+ // stopAll() must be able to discover a terminal operation whose Docker/fs
1376
+ // cleanup is still in flight, and a replacement login must not overlap it.
1377
+ this.ensureSettlement(operation);
1378
+ }
1379
+
1380
+ private cleanup(operation: LoginOperation): Promise<void> {
1381
+ if (operation.cleanupPromise) return operation.cleanupPromise;
1382
+ const processHandle = operation.process;
1383
+ const tempDir = operation.tempDir;
1384
+ operation.process = null;
1385
+ operation.tempDir = null;
1386
+ operation.cleanupPromise = (async () => {
1387
+ if (processHandle) await this.stopDetachedProcess(processHandle);
1388
+ if (tempDir) await this.removeDetachedTemp(tempDir);
1389
+ })();
1390
+ return operation.cleanupPromise;
1391
+ }
1392
+
1393
+ private ensureSettlement(operation: LoginOperation): Promise<void> {
1394
+ if (operation.settlementPromise) return operation.settlementPromise;
1395
+ const launch = operation.launchPromise;
1396
+ const finish = operation.finishPromise;
1397
+ const callback = operation.callbackPromise;
1398
+ operation.settlementPromise = (async () => {
1399
+ // A detached launch owns any resource that appears after cancellation.
1400
+ // Join it before snapshotting cleanup, then stop the container promptly
1401
+ // while callback/credential completion settles independently.
1402
+ if (launch) await launch;
1403
+ const cleanup = this.cleanup(operation);
1404
+ await Promise.all([
1405
+ cleanup,
1406
+ ...(finish ? [finish] : []),
1407
+ ...(callback ? [callback] : []),
1408
+ ]);
1409
+ })().finally(() => {
1410
+ if (this.operations.get(operation.opId) === operation) {
1411
+ this.operations.delete(operation.opId);
1412
+ }
1413
+ if (this.defaultSlotOperations.get(operation.engine) === operation) {
1414
+ this.defaultSlotOperations.delete(operation.engine);
1415
+ }
1416
+ });
1417
+ return operation.settlementPromise;
1418
+ }
1419
+
1420
+ private async stopDetachedProcess(
1421
+ processHandle: EngineLoginProcess,
1422
+ ): Promise<void> {
1423
+ try {
1424
+ await processHandle.stop();
1425
+ } catch {
1426
+ // A failed exact removal is not absence proof. Serialize a bounded
1427
+ // labeled sweep behind any sweep already in flight and retain its false
1428
+ // result so the next launch must retry before creating more resources.
1429
+ await this.queueReconciliation();
1430
+ }
1431
+ }
1432
+
1433
+ private async removeDetachedTemp(path: string): Promise<void> {
1434
+ try {
1435
+ await this.seams.removeTempDir(path);
1436
+ } catch {
1437
+ // Codex may have written auth.json here. Queue the same container-first
1438
+ // reconciliation used at boot and keep its false result as a launch
1439
+ // gate rather than declaring a secret-bearing temp directory settled.
1440
+ await this.queueReconciliation();
1441
+ }
1442
+ }
1443
+
1444
+ private isCurrent(operation: LoginOperation): boolean {
1445
+ return operation.active && this.operations.get(operation.opId) === operation;
1446
+ }
1447
+
1448
+ private clearOperationTimer(operation: LoginOperation): void {
1449
+ if (operation.timer === null) return;
1450
+ this.seams.timers.clearTimeout(operation.timer);
1451
+ operation.timer = null;
1452
+ }
1453
+
1454
+ private runReconciliation(): Promise<boolean> {
1455
+ return this.seams.reconcileStaleContainers().then(
1456
+ () => true,
1457
+ () => false,
1458
+ );
1459
+ }
1460
+
1461
+ private queueReconciliation(): Promise<boolean> {
1462
+ const queued = this.reconciliation.then(() => this.runReconciliation());
1463
+ this.reconciliation = queued;
1464
+ return queued;
1465
+ }
1466
+ }
1467
+
1468
+ function runBestEffort(work: () => Promise<void>): void {
1469
+ try {
1470
+ void work().catch(() => {
1471
+ // Recovery/reconnect will inject the now-durable credential.
1472
+ });
1473
+ } catch {
1474
+ // Treat a synchronous adapter failure the same as a rejected promise.
1475
+ }
1476
+ }
1477
+
1478
+ export function createEngineLoginManager(
1479
+ options: EngineLoginManagerOptions,
1480
+ ): EngineLoginManager {
1481
+ return new EngineLoginManager(options);
1482
+ }
1483
+
1484
+ /** Detect the TUI's post-input rejection ("OAuth error: Invalid code…",
1485
+ * expired codes, …) in the sanitized output. ANSI stripping can eat spaces
1486
+ * ("Invalidcode"), so the patterns tolerate missing whitespace. Exported for
1487
+ * tests. */
1488
+ export function detectClaudeCliError(tail: string): string | null {
1489
+ const lines = tail
1490
+ .replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, "")
1491
+ .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, "")
1492
+ .split("\n")
1493
+ .map((line) => line.trim())
1494
+ .filter(Boolean);
1495
+ const match = lines.find((line) =>
1496
+ /oauth\s*error|invalid\s*cod|code\s*.{0,12}expired|authentication\s*failed/i.test(
1497
+ line,
1498
+ ),
1499
+ );
1500
+ if (!match) return null;
1501
+ if (/sk-ant-|Bearer |[A-Za-z0-9_-]{40,}/.test(match)) return null;
1502
+ return match.slice(0, 200);
1503
+ }
1504
+
1505
+ /** The last visible, sanitized line of the login CLI's output — bounded and
1506
+ * secret-checked so it can ride a terminal event message (ADR-116). Anything
1507
+ * token-shaped disqualifies the whole line rather than risking a partial. */
1508
+ function lastSignificantOutputLine(tail: string): string | null {
1509
+ // stripTerminalControl handles the shapes the old inline regex missed —
1510
+ // private-parameter CSI (kitty's ESC[<u, ESC[>4m) and charset selects
1511
+ // (ESC(B) — whose husks once surfaced verbatim as "(B[>4m[<u" evidence.
1512
+ const lines = stripTerminalControl(tail)
1513
+ .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, "")
1514
+ .split("\n")
1515
+ .map((line) => line.trim())
1516
+ .filter((line) => line.length > 3);
1517
+ const last = lines.at(-1);
1518
+ if (!last) return null;
1519
+ if (/sk-ant-|Bearer |[A-Za-z0-9_-]{40,}/.test(last)) return null;
1520
+ return last.slice(0, 200);
1521
+ }
1522
+
1523
+ function safeEmit(
1524
+ emit: EmitEngineLoginEvent,
1525
+ event: EngineLoginEventFrame,
1526
+ ): void {
1527
+ try {
1528
+ emit(event);
1529
+ } catch {
1530
+ // Socket teardown calls stopAll; never turn a closed emitter into a crash.
1531
+ }
1532
+ }
1533
+
1534
+ function engineLabel(engine: EngineLoginKind): "Claude" | "Codex" | "Grok" {
1535
+ return engine === "claude" ? "Claude" : engine === "codex" ? "Codex" : "Grok";
1536
+ }
1537
+
1538
+ function failedEvent(
1539
+ opId: string,
1540
+ engine: EngineLoginKind,
1541
+ errorCode: EngineLoginErrorCode,
1542
+ message: string,
1543
+ ): EngineLoginEventFrame {
1544
+ return {
1545
+ kind: "engine.login.event",
1546
+ opId,
1547
+ engine,
1548
+ phase: "failed",
1549
+ errorCode,
1550
+ message,
1551
+ };
1552
+ }
1553
+
1554
+ function authorizeEvent(
1555
+ operation: LoginOperation,
1556
+ url: string,
1557
+ ): EngineLoginEventFrame {
1558
+ return {
1559
+ kind: "engine.login.event",
1560
+ opId: operation.opId,
1561
+ engine: operation.engine,
1562
+ phase: "authorize",
1563
+ url,
1564
+ };
1565
+ }
1566
+
1567
+ function isValidOneTimeInput(value: string): boolean {
1568
+ return (
1569
+ value.length >= 1 &&
1570
+ value.length <= MAX_ENGINE_LOGIN_TEXT_CHARS &&
1571
+ value.trim() === value &&
1572
+ !/[\0-\x1f\x7f]/.test(value) &&
1573
+ // The PTY echoes input. Never let a caller supply a token-shaped value
1574
+ // that the output parser could mistake for the CLI's newly minted token.
1575
+ extractClaudeOAuthToken(value) === null
1576
+ );
1577
+ }
1578
+
1579
+ function appendUtf8Tail(current: string, chunk: string, maxBytes: number): string {
1580
+ const bytes = Buffer.from(current + chunk, "utf8");
1581
+ if (bytes.byteLength <= maxBytes) return bytes.toString("utf8");
1582
+ return bytes.subarray(bytes.byteLength - maxBytes).toString("utf8");
1583
+ }
1584
+
1585
+ function stripTerminalControl(value: string): string {
1586
+ return value
1587
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
1588
+ .replace(/\x1b\[[0-9;:?<=>]*[!-/]*[@-~]/g, "")
1589
+ .replace(/\x1b[()][0-9A-B]/g, "")
1590
+ .replace(/\x1b[78=>DEHM]/g, "")
1591
+ .replace(/\r/g, "\n");
1592
+ }
1593
+
1594
+ const SCREEN_MAX_ROWS = 200;
1595
+ const SCREEN_MAX_COLS = 600;
1596
+
1597
+ /**
1598
+ * Rebuild the visible terminal screen from raw TUI output. The login CLI's
1599
+ * renderer diffs frames: a repainted line SKIPS cells that already show the
1600
+ * right character and jumps the cursor over them. The minted token paints
1601
+ * over the "Paste code here if prompted >" prompt, whose `code` leaves an
1602
+ * 'o' exactly where `sk-ant-oat01-`'s 'o' lands — so the byte stream carries
1603
+ * `sk-ant-` ESC[10G `at01-…` and NEVER the contiguous token (live
1604
+ * 2026-08-21, first Linux host; the Air's earlier "corrupted" capture was
1605
+ * the same hole, not stderr interleave). Only a spatial reconstruction sees
1606
+ * what the operator sees, so the credential scan reads this rendering, never
1607
+ * the raw stream. Cells no frame painted stay spaces — a hole can only be
1608
+ * filled by content some frame actually painted there, never closed up.
1609
+ */
1610
+ export function renderTerminalScreenText(value: string): string {
1611
+ const rows: (string[] | undefined)[] = [];
1612
+ let row = 0;
1613
+ let col = 0;
1614
+ let savedRow = 0;
1615
+ let savedCol = 0;
1616
+ const clamp = () => {
1617
+ row = Math.min(Math.max(row, 0), SCREEN_MAX_ROWS - 1);
1618
+ col = Math.min(Math.max(col, 0), SCREEN_MAX_COLS);
1619
+ };
1620
+ const line = (): string[] => (rows[row] ??= []);
1621
+ const pattern =
1622
+ // OSC | CSI(params, final) | charset | bare ESC final | CR | LF | text
1623
+ /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[([0-9;:?<=>]*)[!-/]*([@-~])|\x1b[()][0-9A-B]|\x1b([78=>DEHM])|(\r)|(\n)|([^\x1b\r\n\x00-\x1f\x7f]+)|[\s\S]/g;
1624
+ for (const part of value.matchAll(pattern)) {
1625
+ const [, csiParams, csiFinal, escFinal, cr, lf, text] = part;
1626
+ if (text !== undefined) {
1627
+ const cells = line();
1628
+ for (const ch of text) {
1629
+ if (col < SCREEN_MAX_COLS) cells[col] = ch;
1630
+ col += 1;
1631
+ }
1632
+ clamp();
1633
+ continue;
1634
+ }
1635
+ if (cr !== undefined) {
1636
+ col = 0;
1637
+ continue;
1638
+ }
1639
+ if (lf !== undefined) {
1640
+ row += 1;
1641
+ clamp();
1642
+ continue;
1643
+ }
1644
+ if (escFinal !== undefined) {
1645
+ if (escFinal === "7") [savedRow, savedCol] = [row, col];
1646
+ else if (escFinal === "8") [row, col] = [savedRow, savedCol];
1647
+ else if (escFinal === "D" || escFinal === "E") row += 1;
1648
+ else if (escFinal === "M") row -= 1;
1649
+ if (escFinal === "E") col = 0;
1650
+ clamp();
1651
+ continue;
1652
+ }
1653
+ if (csiFinal === undefined) continue;
1654
+ const params = csiParams ?? "";
1655
+ // Private-parameter sequences (kitty keyboard pops, DEC modes) are not
1656
+ // cursor movement — never let e.g. `ESC[<u` restore a stale cursor.
1657
+ if (/[?<=>]/.test(params)) continue;
1658
+ const numbers = params.split(";").map((entry) => Number.parseInt(entry, 10));
1659
+ const n = (index: number, fallback: number): number => {
1660
+ const parsed = numbers[index];
1661
+ return Number.isFinite(parsed) && parsed! > 0 ? parsed! : fallback;
1662
+ };
1663
+ switch (csiFinal) {
1664
+ case "G":
1665
+ col = n(0, 1) - 1;
1666
+ break;
1667
+ case "A":
1668
+ row -= n(0, 1);
1669
+ break;
1670
+ case "B":
1671
+ row += n(0, 1);
1672
+ break;
1673
+ case "C":
1674
+ col += n(0, 1);
1675
+ break;
1676
+ case "D":
1677
+ col -= n(0, 1);
1678
+ break;
1679
+ case "E":
1680
+ row += n(0, 1);
1681
+ col = 0;
1682
+ break;
1683
+ case "F":
1684
+ row -= n(0, 1);
1685
+ col = 0;
1686
+ break;
1687
+ case "d":
1688
+ row = n(0, 1) - 1;
1689
+ break;
1690
+ case "H":
1691
+ case "f":
1692
+ row = n(0, 1) - 1;
1693
+ col = n(1, 1) - 1;
1694
+ break;
1695
+ case "s":
1696
+ [savedRow, savedCol] = [row, col];
1697
+ break;
1698
+ case "u":
1699
+ [row, col] = [savedRow, savedCol];
1700
+ break;
1701
+ case "K": {
1702
+ const cells = line();
1703
+ const mode = Number.isFinite(numbers[0]) ? numbers[0]! : 0;
1704
+ if (mode === 0) cells.length = Math.min(cells.length, col);
1705
+ else if (mode === 1) {
1706
+ for (let i = 0; i <= col && i < cells.length; i += 1) cells[i] = " ";
1707
+ } else if (mode === 2) cells.length = 0;
1708
+ break;
1709
+ }
1710
+ case "J": {
1711
+ const mode = Number.isFinite(numbers[0]) ? numbers[0]! : 0;
1712
+ if (mode === 0) {
1713
+ line().length = Math.min(line().length, col);
1714
+ rows.length = Math.min(rows.length, row + 1);
1715
+ } else if (mode === 1) {
1716
+ for (let i = 0; i < row; i += 1) rows[i] = undefined;
1717
+ const cells = line();
1718
+ for (let i = 0; i <= col && i < cells.length; i += 1) cells[i] = " ";
1719
+ } else {
1720
+ rows.length = 0;
1721
+ }
1722
+ break;
1723
+ }
1724
+ default:
1725
+ break;
1726
+ }
1727
+ clamp();
1728
+ }
1729
+ return rows
1730
+ .map((cells) => Array.from(cells ?? [], (cell) => cell ?? " ").join(""))
1731
+ .join("\n");
1732
+ }
1733
+
1734
+ function candidateHttpsUrls(value: string): string[] {
1735
+ const plain = stripTerminalControl(value);
1736
+ return [...plain.matchAll(/https:\/\/[^\s<>"'\x00-\x1f]+/g)]
1737
+ .map((match) => (match[0] ?? "").replace(/[),.;]+$/g, ""))
1738
+ .filter(Boolean);
1739
+ }
1740
+
1741
+ function parseSafeHttpsUrl(value: string): URL | null {
1742
+ if (value.length < 1 || value.length > MAX_ENGINE_LOGIN_URL_CHARS) return null;
1743
+ try {
1744
+ const url = new URL(value);
1745
+ if (
1746
+ url.protocol !== "https:" ||
1747
+ url.username ||
1748
+ url.password ||
1749
+ !url.hostname
1750
+ ) {
1751
+ return null;
1752
+ }
1753
+ return url.toString().length <= MAX_ENGINE_LOGIN_URL_CHARS ? url : null;
1754
+ } catch {
1755
+ return null;
1756
+ }
1757
+ }
1758
+
1759
+ /** Exported narrow parsers make secret-boundary regressions easy to fuzz. */
1760
+ export function findSafeHttpsUrl(value: string): string | null {
1761
+ for (const candidate of candidateHttpsUrls(value)) {
1762
+ const parsed = parseSafeHttpsUrl(candidate);
1763
+ if (parsed) return parsed.toString();
1764
+ }
1765
+ return null;
1766
+ }
1767
+
1768
+ /**
1769
+ * Codex `--device-auth` banner: a verification URL plus a short one-time
1770
+ * code ("AC57-MDV9G" shape). Both the phrase gate and the code shape are
1771
+ * required so container names, hashes, or URL fragments cannot fake a
1772
+ * device prompt. Reads the merged tail — codex prints to stderr.
1773
+ */
1774
+ export function findCodexDeviceAuthorization(
1775
+ value: string,
1776
+ ): { verificationUrl: string; userCode: string } | null {
1777
+ const plain = stripTerminalControl(value);
1778
+ if (!/one-time code|device code/i.test(plain)) return null;
1779
+ const verificationUrl = findSafeHttpsUrl(plain);
1780
+ if (!verificationUrl) return null;
1781
+ const code = /(?:^|\s)([A-Z0-9]{4,8}-[A-Z0-9]{4,10})(?:\s|$)/m.exec(plain);
1782
+ if (!code) return null;
1783
+ return { verificationUrl, userCode: code[1]! };
1784
+ }
1785
+
1786
+ /**
1787
+ * Grok `login --device-auth` banner (host 0.9.105): "To sign in, open this
1788
+ * URL in your browser: https://accounts.x.ai/oauth2/device?user_code=XXXX-XXXX
1789
+ * … Confirm this code in your browser: XXXX-XXXX". Phrase gate + code shape,
1790
+ * same posture as the codex parser; grok prints to stderr.
1791
+ */
1792
+ export function findGrokDeviceAuthorization(
1793
+ value: string,
1794
+ ): { verificationUrl: string; userCode: string } | null {
1795
+ const plain = stripTerminalControl(value);
1796
+ if (!/confirm this code|open this url/i.test(plain)) return null;
1797
+ const verificationUrl = findSafeHttpsUrl(plain);
1798
+ if (!verificationUrl) return null;
1799
+ const code =
1800
+ /user_code=([A-Z0-9]{4,8}-[A-Z0-9]{4,10})\b/.exec(plain) ??
1801
+ /(?:^|\s)([A-Z0-9]{4,8}-[A-Z0-9]{4,10})(?:\s|$)/m.exec(plain);
1802
+ if (!code) return null;
1803
+ return { verificationUrl, userCode: code[1]! };
1804
+ }
1805
+
1806
+ export function extractClaudeOAuthToken(value: string): string | null {
1807
+ const plain = stripTerminalControl(value);
1808
+ // The FULL `sk-ant-oat01-` prefix and a realistic minimum length are both
1809
+ // load-bearing: the TUI's diff renderer skips screen cells that already
1810
+ // hold the right character, so naive control-stripping glues the runs into
1811
+ // `sk-ant-at01-…` — one character short, token-shaped, wrong — which a
1812
+ // permissive scan once persisted as a "successful" login whose agents then
1813
+ // 401'd forever (live 2026-08-21). The strict prefix makes any mangled
1814
+ // capture a non-match; the actual token match happens against the
1815
+ // reconstructed screen (renderTerminalScreenText), where it is contiguous.
1816
+ const match =
1817
+ /(?:^|[^A-Za-z0-9_-])(sk-ant-oat01-[A-Za-z0-9_-]{64,4096})(?![A-Za-z0-9_-])/.exec(
1818
+ plain,
1819
+ );
1820
+ return match?.[1] ?? null;
1821
+ }
1822
+
1823
+ function isLocalhost(hostname: string): boolean {
1824
+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
1825
+ return (
1826
+ normalized === "localhost" ||
1827
+ normalized === "127.0.0.1" ||
1828
+ normalized === "::1"
1829
+ );
1830
+ }
1831
+
1832
+ function codexCallbackTarget(authorizeUrl: URL): CodexCallbackTarget | null {
1833
+ const redirectValues = [
1834
+ ...authorizeUrl.searchParams.getAll("redirect_uri"),
1835
+ ...authorizeUrl.searchParams.getAll("redirect_url"),
1836
+ ];
1837
+ const states = authorizeUrl.searchParams.getAll("state");
1838
+ if (
1839
+ redirectValues.length !== 1 ||
1840
+ !redirectValues[0] ||
1841
+ states.length !== 1 ||
1842
+ !states[0] ||
1843
+ states[0].length > MAX_ENGINE_LOGIN_QUERY_CHARS
1844
+ ) {
1845
+ return null;
1846
+ }
1847
+ const redirectValue = redirectValues[0];
1848
+ let redirect: URL;
1849
+ try {
1850
+ redirect = new URL(redirectValue);
1851
+ } catch {
1852
+ return null;
1853
+ }
1854
+ if (
1855
+ redirect.protocol !== "http:" ||
1856
+ !isLocalhost(redirect.hostname) ||
1857
+ !redirect.port ||
1858
+ redirect.username ||
1859
+ redirect.password ||
1860
+ redirect.search ||
1861
+ redirect.hash ||
1862
+ !/^\d{1,5}$/.test(redirect.port)
1863
+ ) {
1864
+ return null;
1865
+ }
1866
+ const port = Number(redirect.port);
1867
+ if (port < 1 || port > 65_535) return null;
1868
+ return {
1869
+ origin: redirect.origin,
1870
+ pathname: redirect.pathname || "/",
1871
+ port: redirect.port,
1872
+ hostname: redirect.hostname,
1873
+ state: states[0],
1874
+ };
1875
+ }
1876
+
1877
+ function findCodexAuthorization(
1878
+ value: string,
1879
+ ): { authorizeUrl: string; target: CodexCallbackTarget } | null {
1880
+ for (const candidate of candidateHttpsUrls(value)) {
1881
+ const authorizeUrl = parseSafeHttpsUrl(candidate);
1882
+ if (!authorizeUrl) continue;
1883
+ const target = codexCallbackTarget(authorizeUrl);
1884
+ if (target) return { authorizeUrl: authorizeUrl.toString(), target };
1885
+ }
1886
+ return null;
1887
+ }
1888
+
1889
+ function validateCodexCallback(
1890
+ value: string,
1891
+ target: CodexCallbackTarget,
1892
+ ): string | null {
1893
+ if (
1894
+ value.length < 1 ||
1895
+ value.length > MAX_ENGINE_LOGIN_QUERY_CHARS ||
1896
+ value.trim() !== value ||
1897
+ /[\0\r\n]/.test(value)
1898
+ ) {
1899
+ return null;
1900
+ }
1901
+
1902
+ let query: string;
1903
+ if (/^https?:\/\//i.test(value)) {
1904
+ let callback: URL;
1905
+ try {
1906
+ callback = new URL(value);
1907
+ } catch {
1908
+ return null;
1909
+ }
1910
+ if (
1911
+ callback.protocol !== "http:" ||
1912
+ !isLocalhost(callback.hostname) ||
1913
+ callback.hostname !== target.hostname ||
1914
+ callback.port !== target.port ||
1915
+ callback.pathname !== target.pathname ||
1916
+ callback.username ||
1917
+ callback.password ||
1918
+ callback.hash
1919
+ ) {
1920
+ return null;
1921
+ }
1922
+ query = callback.search.slice(1);
1923
+ } else {
1924
+ if (value.includes("#") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)) {
1925
+ return null;
1926
+ }
1927
+ query = value.startsWith("?") ? value.slice(1) : value;
1928
+ }
1929
+ if (!query || query.length > MAX_ENGINE_LOGIN_QUERY_CHARS) return null;
1930
+
1931
+ const params = new URLSearchParams(query);
1932
+ const codes = params.getAll("code");
1933
+ const states = params.getAll("state");
1934
+ if (
1935
+ codes.length !== 1 ||
1936
+ !codes[0] ||
1937
+ states.length !== 1 ||
1938
+ !states[0] ||
1939
+ states[0] !== target.state
1940
+ ) {
1941
+ return null;
1942
+ }
1943
+ const normalized = params.toString();
1944
+ return normalized.length <= MAX_ENGINE_LOGIN_QUERY_CHARS ? normalized : null;
1945
+ }
1946
+
1947
+ function callbackReplayUrl(
1948
+ target: CodexCallbackTarget,
1949
+ query: string,
1950
+ ): string {
1951
+ const callback = new URL(target.pathname, target.origin);
1952
+ callback.search = query;
1953
+ return callback.toString();
1954
+ }
1955
+
1956
+ const LOGS_FOLLOW_ATTEMPTS = 20;
1957
+ const LOGS_FOLLOW_RETRY_MS = 200;
1958
+ const LOGS_FOLLOW_ATTACH_PROBE_MS = 500;
1959
+
1960
+ function delayMs(ms: number): Promise<void> {
1961
+ return new Promise((resolve) => {
1962
+ const timer = setTimeout(resolve, ms);
1963
+ unrefTimer(timer);
1964
+ });
1965
+ }
1966
+
1967
+ /**
1968
+ * Stream a login container's output via `docker logs --follow`, which
1969
+ * replays from byte zero — immune to the run/attach race that loses the
1970
+ * container's first burst (see startLoginContainer). The container may not
1971
+ * exist yet when the first attempt fires, and the client's own
1972
+ * "No such container" stderr must never leak into the operation's output
1973
+ * tail, so each attempt buffers until it is provably attached: container
1974
+ * stdout, or survival past a probe window, count as proof; a quick exit
1975
+ * with the not-found error is a retry.
1976
+ */
1977
+ async function followLoginContainerLogs(
1978
+ containerName: string,
1979
+ runChild: { once(event: "close", listener: () => void): unknown },
1980
+ onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
1981
+ ): Promise<void> {
1982
+ let runClosed = false;
1983
+ runChild.once("close", () => {
1984
+ runClosed = true;
1985
+ });
1986
+ for (let attempt = 0; attempt < LOGS_FOLLOW_ATTEMPTS; attempt += 1) {
1987
+ const outcome = await followLoginContainerLogsOnce(
1988
+ containerName,
1989
+ onOutput,
1990
+ );
1991
+ if (outcome === "done") return;
1992
+ // The container is absent. Before it ever starts that means "retry";
1993
+ // after the run child closed it means the container already exited and
1994
+ // was removed — nothing will ever appear.
1995
+ if (runClosed) return;
1996
+ await delayMs(LOGS_FOLLOW_RETRY_MS);
1997
+ }
1998
+ }
1999
+
2000
+ function followLoginContainerLogsOnce(
2001
+ containerName: string,
2002
+ onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
2003
+ ): Promise<"done" | "absent"> {
2004
+ return new Promise((resolve) => {
2005
+ const follower = spawn(
2006
+ engineLoginBackend().command,
2007
+ ["logs", "--follow", containerName],
2008
+ { shell: false, stdio: ["ignore", "pipe", "pipe"] },
2009
+ );
2010
+ follower.stdout.setEncoding("utf8");
2011
+ follower.stderr.setEncoding("utf8");
2012
+ const buffered: Array<{ chunk: string; stream: "stdout" | "stderr" }> = [];
2013
+ let stderrTail = "";
2014
+ let attached = false;
2015
+ const flush = (): void => {
2016
+ attached = true;
2017
+ for (const entry of buffered) onOutput(entry.chunk, entry.stream);
2018
+ buffered.length = 0;
2019
+ };
2020
+ const deliver = (chunk: string, stream: "stdout" | "stderr"): void => {
2021
+ if (attached) {
2022
+ onOutput(chunk, stream);
2023
+ return;
2024
+ }
2025
+ buffered.push({ chunk, stream });
2026
+ if (buffered.length > 256) buffered.shift();
2027
+ };
2028
+ follower.stdout.on("data", (chunk: string) => {
2029
+ // Container stdout is proof of attachment — the docker client itself
2030
+ // never writes to stdout here.
2031
+ if (!attached) flush();
2032
+ onOutput(chunk, "stdout");
2033
+ });
2034
+ follower.stderr.on("data", (chunk: string) => {
2035
+ stderrTail = appendUtf8Tail(stderrTail, chunk, 4_096);
2036
+ deliver(chunk, "stderr");
2037
+ });
2038
+ const probe = setTimeout(() => {
2039
+ // Still running after the probe window: the client accepted the
2040
+ // container and is following; anything buffered is container stderr.
2041
+ if (follower.exitCode === null) flush();
2042
+ }, LOGS_FOLLOW_ATTACH_PROBE_MS);
2043
+ unrefTimer(probe);
2044
+ follower.once("error", () => {
2045
+ clearTimeout(probe);
2046
+ resolve("absent");
2047
+ });
2048
+ follower.once("close", () => {
2049
+ clearTimeout(probe);
2050
+ if (
2051
+ !attached &&
2052
+ /no such container|dead or marked for removal/i.test(stderrTail)
2053
+ ) {
2054
+ resolve("absent");
2055
+ return;
2056
+ }
2057
+ // A real follow that ended (container exited): deliver whatever was
2058
+ // buffered — a fast-crashing container's stderr arrives exactly here.
2059
+ if (!attached) flush();
2060
+ resolve("done");
2061
+ });
2062
+ });
2063
+ }
2064
+
2065
+ /** Exported for the follower-path tests and live harnesses. */
2066
+ export async function startLoginContainer(
2067
+ request: EngineLoginContainerRequest,
2068
+ onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
2069
+ ): Promise<EngineLoginProcess> {
2070
+ const apple = engineLoginBackend().apple;
2071
+ const args = engineLoginContainerArgs(request);
2072
+ const child = spawn(engineLoginBackend().command, args, {
2073
+ shell: false,
2074
+ stdio: ["pipe", "pipe", "pipe"],
2075
+ });
2076
+ child.stdout.setEncoding("utf8");
2077
+ child.stderr.setEncoding("utf8");
2078
+ if (apple) {
2079
+ // The Apple CLI keeps the attach-stdio path: its behavior differs from
2080
+ // Docker's and the claude flow's continuous TUI repaint has proven it
2081
+ // live; the transcript fallback covers its noise-corruption cases.
2082
+ child.stdout.on("data", (chunk: string) => onOutput(chunk, "stdout"));
2083
+ child.stderr.on("data", (chunk: string) => onOutput(chunk, "stderr"));
2084
+ } else {
2085
+ // Docker: the attached stream can MISS the container's first output
2086
+ // burst — `docker run` starts the container and the attach stream races
2087
+ // its earliest writes. codex login prints its authorize URL within
2088
+ // milliseconds and then goes silent, so a lost burst is a dead login
2089
+ // (live 2026-08-21 on the first Linux hosts: dockerd had the URL,
2090
+ // `docker logs` showed it, the attached client never relayed a byte).
2091
+ // Output truth therefore comes from `docker logs --follow`, which
2092
+ // replays from byte zero; the run child keeps ONLY stdin and lifecycle.
2093
+ // Its stdio still drains so the client can never block on backpressure.
2094
+ child.stdout.on("data", () => {});
2095
+ child.stderr.on("data", () => {});
2096
+ const logsDone = followLoginContainerLogs(
2097
+ request.containerName,
2098
+ child,
2099
+ onOutput,
2100
+ );
2101
+ // Without `--rm`, the exited container outlives the run child so the
2102
+ // follower can finish its replay; remove it once the follower is done
2103
+ // (bounded — a wedged follower must not leak containers).
2104
+ child.once("close", () => {
2105
+ void (async () => {
2106
+ await Promise.race([logsDone, delayMs(2_000)]);
2107
+ await removeEngineLoginContainer(request.containerName).catch(
2108
+ () => undefined,
2109
+ );
2110
+ })();
2111
+ });
2112
+ }
2113
+ child.stdin.on("error", () => {});
2114
+
2115
+ let stopped = false;
2116
+ let settled = false;
2117
+ let resolveCompletion: (result: EngineLoginProcessResult) => void = () => {};
2118
+ const completion = new Promise<EngineLoginProcessResult>((resolve) => {
2119
+ resolveCompletion = resolve;
2120
+ });
2121
+ const settle = (result: EngineLoginProcessResult): void => {
2122
+ if (settled) return;
2123
+ settled = true;
2124
+ resolveCompletion(result);
2125
+ };
2126
+ child.once("error", () => settle({ code: null, spawnFailed: true }));
2127
+ child.once("close", (code) => settle({ code }));
2128
+
2129
+ return {
2130
+ containerName: request.containerName,
2131
+ completion,
2132
+ writeInput(value) {
2133
+ if (stopped || child.stdin.destroyed) throw new Error("login input closed");
2134
+ child.stdin.write(value);
2135
+ },
2136
+ async stop() {
2137
+ if (stopped) return;
2138
+ stopped = true;
2139
+ await stopEngineLoginContainerProcess({
2140
+ completion,
2141
+ endInput: () => child.stdin.end(),
2142
+ terminate: () => child.kill("SIGTERM"),
2143
+ kill: () => child.kill("SIGKILL"),
2144
+ remove: () => removeEngineLoginContainer(request.containerName),
2145
+ });
2146
+ },
2147
+ };
2148
+ }
2149
+
2150
+ export interface EngineLoginProcessStopControl {
2151
+ readonly completion: Promise<EngineLoginProcessResult>;
2152
+ endInput(): void;
2153
+ terminate(): void;
2154
+ kill(): void;
2155
+ remove(): Promise<void>;
2156
+ }
2157
+
2158
+ /**
2159
+ * Close the docker-run client before proving/removing the daemon resource.
2160
+ * Otherwise an in-flight `docker run` can create the container just after an
2161
+ * early absence check, orphaning it across generation teardown.
2162
+ */
2163
+ export async function stopEngineLoginContainerProcess(
2164
+ control: EngineLoginProcessStopControl,
2165
+ ): Promise<void> {
2166
+ control.endInput();
2167
+ control.terminate();
2168
+ if (
2169
+ !(await promiseSettlesWithin(
2170
+ control.completion,
2171
+ DOCKER_RUN_STOP_GRACE_MS,
2172
+ ))
2173
+ ) {
2174
+ control.kill();
2175
+ if (
2176
+ !(await promiseSettlesWithin(
2177
+ control.completion,
2178
+ DOCKER_RUN_STOP_GRACE_MS,
2179
+ ))
2180
+ ) {
2181
+ throw new Error("login docker client did not stop");
2182
+ }
2183
+ }
2184
+ await control.remove();
2185
+ }
2186
+
2187
+ async function promiseSettlesWithin(
2188
+ promise: Promise<unknown>,
2189
+ timeoutMs: number,
2190
+ ): Promise<boolean> {
2191
+ let timer: ReturnType<typeof setTimeout> | null = null;
2192
+ try {
2193
+ return await Promise.race([
2194
+ promise.then(
2195
+ () => true,
2196
+ () => true,
2197
+ ),
2198
+ new Promise<boolean>((resolve) => {
2199
+ timer = setTimeout(() => resolve(false), timeoutMs);
2200
+ timer.unref?.();
2201
+ }),
2202
+ ]);
2203
+ } finally {
2204
+ if (timer) clearTimeout(timer);
2205
+ }
2206
+ }
2207
+
2208
+ /** Pure command builder kept exported for the isolation regression test. */
2209
+ export function engineLoginContainerArgs(
2210
+ request: EngineLoginContainerRequest,
2211
+ ): string[] {
2212
+ if (/[,\0\r\n]/.test(request.tempDir)) {
2213
+ throw new Error("unsafe temporary path");
2214
+ }
2215
+ const uidArgs =
2216
+ typeof process.getuid === "function" && typeof process.getgid === "function"
2217
+ ? ["--user", `${process.getuid()}:${process.getgid()}`]
2218
+ : [];
2219
+ const command =
2220
+ request.engine === "claude"
2221
+ ? [
2222
+ "/usr/bin/script",
2223
+ "-qefc",
2224
+ // A wide PTY first: `script` allocates 80×24 when stdin is no
2225
+ // terminal, the CLI hard-wraps the OAuth URL at that width, and
2226
+ // the streamed capture then sees only the first fragment — the
2227
+ // operator lands on claude.ai with "Missing redirect_uri" and half
2228
+ // a client id (live 2026-08-18). stty targets the PTY `script`
2229
+ // just allocated.
2230
+ "stty cols 500 rows 50 2>/dev/null; exec claude setup-token",
2231
+ // The PTY transcript lands in the op's private bind leaf (same
2232
+ // trust class as the codex auth.json captured there; the leaf is
2233
+ // 0700 and deleted at cleanup). A stuck flow can then be read
2234
+ // VERBATIM from the host — the 2026-08-20 hang was undiagnosable
2235
+ // because the typescript went to /dev/null.
2236
+ `${LOGIN_MOUNT}/typescript`,
2237
+ ]
2238
+ : request.engine === "grok"
2239
+ ? ["sh", "-c", GROK_LOGIN_SHELL]
2240
+ : // Device-code flow: no localhost callback, no paste — the CLI prints
2241
+ // a verification URL + one-time code, the operator confirms on the
2242
+ // provider's page, and the CLI polls to completion (ADR-116 v2,
2243
+ // 2026-08-24). The callback machinery below stays as the fallback
2244
+ // for CLIs that still print the localhost authorize URL.
2245
+ ["codex", "login", "--device-auth"];
2246
+ if (engineLoginBackend().apple) {
2247
+ // Same containment contract, Apple dialect: read-only root, all caps
2248
+ // dropped, a plain tmpfs at /tmp (the Apple CLI takes no mount options —
2249
+ // the VM boundary already denies device/suid tricks, which is also why
2250
+ // the missing no-new-privileges knob costs nothing here), and -v mounts
2251
+ // for the shared runtime volume + private temp leaf. The container runs
2252
+ // as node: ownership maps transparently across the VM boundary, so the
2253
+ // host uid/gid pin that Docker needs would be meaningless.
2254
+ return [
2255
+ "run",
2256
+ "--rm",
2257
+ "--name",
2258
+ request.containerName,
2259
+ "--label",
2260
+ `${ENGINE_LOGIN_CONTAINER_LABEL}=${ENGINE_LOGIN_CONTAINER_LABEL_VALUE}`,
2261
+ "--init",
2262
+ "--interactive",
2263
+ "--read-only",
2264
+ "--cap-drop",
2265
+ "ALL",
2266
+ "--tmpfs",
2267
+ "/tmp",
2268
+ "-v",
2269
+ `${ASDF_DATA_VOLUME}:/opt/asdf-data:ro`,
2270
+ "-v",
2271
+ `${request.tempDir}:${LOGIN_MOUNT}`,
2272
+ "--user",
2273
+ "node",
2274
+ "--workdir",
2275
+ "/home/node",
2276
+ "--env",
2277
+ `HOME=${LOGIN_HOME}`,
2278
+ "--env",
2279
+ `XDG_CONFIG_HOME=${LOGIN_HOME}/.config`,
2280
+ "--env",
2281
+ `XDG_DATA_HOME=${LOGIN_HOME}/.local/share`,
2282
+ "--env",
2283
+ `XDG_CACHE_HOME=${LOGIN_HOME}/.cache`,
2284
+ "--env",
2285
+ `CODEX_HOME=${CODEX_HOME}`,
2286
+ "--env",
2287
+ `GROK_HOME=${GROK_HOME}`,
2288
+ "--env",
2289
+ "ASDF_CONFIG_FILE=/dev/null",
2290
+ "--env",
2291
+ "ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions",
2292
+ STANDARD_IMAGE_TAG,
2293
+ ...command,
2294
+ ];
2295
+ }
2296
+ // No `--rm` in the Docker dialect: output truth comes from
2297
+ // `docker logs --follow` (see startLoginContainer), and an auto-removed
2298
+ // fast-exiting container would take its logs with it. The host removes
2299
+ // the container explicitly when the run child closes; the labeled stale
2300
+ // reconcile sweeps anything a crash leaves behind.
2301
+ return [
2302
+ "run",
2303
+ "--name",
2304
+ request.containerName,
2305
+ "--label",
2306
+ `${ENGINE_LOGIN_CONTAINER_LABEL}=${ENGINE_LOGIN_CONTAINER_LABEL_VALUE}`,
2307
+ "--init",
2308
+ "--interactive",
2309
+ "--read-only",
2310
+ "--cap-drop",
2311
+ "ALL",
2312
+ "--security-opt",
2313
+ "no-new-privileges:true",
2314
+ "--tmpfs",
2315
+ "/tmp:rw,nosuid,nodev,noexec,size=64m,mode=1777",
2316
+ "--mount",
2317
+ `type=volume,src=${ASDF_DATA_VOLUME},dst=/opt/asdf-data,readonly`,
2318
+ "--mount",
2319
+ `type=bind,src=${request.tempDir},dst=${LOGIN_MOUNT}`,
2320
+ ...uidArgs,
2321
+ "--workdir",
2322
+ "/home/node",
2323
+ "--env",
2324
+ `HOME=${LOGIN_HOME}`,
2325
+ "--env",
2326
+ `XDG_CONFIG_HOME=${LOGIN_HOME}/.config`,
2327
+ "--env",
2328
+ `XDG_DATA_HOME=${LOGIN_HOME}/.local/share`,
2329
+ "--env",
2330
+ `XDG_CACHE_HOME=${LOGIN_HOME}/.cache`,
2331
+ "--env",
2332
+ `CODEX_HOME=${CODEX_HOME}`,
2333
+ "--env",
2334
+ `GROK_HOME=${GROK_HOME}`,
2335
+ "--env",
2336
+ "ASDF_CONFIG_FILE=/dev/null",
2337
+ "--env",
2338
+ "ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions",
2339
+ STANDARD_IMAGE_TAG,
2340
+ ...command,
2341
+ ];
2342
+ }
2343
+
2344
+ async function replayCodexCallback(
2345
+ containerName: string,
2346
+ callbackUrl: string,
2347
+ ): Promise<void> {
2348
+ const request = codexCallbackReplayDockerRequest(containerName, callbackUrl);
2349
+ const result = await defaultEngineLoginDockerExec(request.args, {
2350
+ input: request.input,
2351
+ timeoutMs: DOCKER_CONTROL_TIMEOUT_MS,
2352
+ maxOutputBytes: 8 * 1_024,
2353
+ });
2354
+ if (result.status !== 0) throw new Error("callback replay failed");
2355
+ }
2356
+
2357
+ /**
2358
+ * Docker and the in-container curl process receive a fixed argv. The full
2359
+ * callback (including code/state) exists only in the child's stdin, so it is
2360
+ * absent from process listings, daemon metadata, and Docker error messages.
2361
+ */
2362
+ export function codexCallbackReplayDockerRequest(
2363
+ containerName: string,
2364
+ callbackUrl: string,
2365
+ ): { args: string[]; input: string } {
2366
+ if (!isEngineLoginContainerName(containerName)) {
2367
+ throw new Error("invalid login container name");
2368
+ }
2369
+ if (/[\0\r\n]/.test(callbackUrl)) {
2370
+ throw new Error("invalid callback URL");
2371
+ }
2372
+ const configUrl = callbackUrl
2373
+ .replace(/\\/g, "\\\\")
2374
+ .replace(/"/g, '\\"');
2375
+ return {
2376
+ args: [
2377
+ "exec",
2378
+ "--interactive",
2379
+ containerName,
2380
+ "/usr/bin/curl",
2381
+ "--fail",
2382
+ "--silent",
2383
+ "--show-error",
2384
+ // Follow the callback's redirect to the CLI's /success page: codex
2385
+ // writes auth.json on the callback but EXITS only after serving that
2386
+ // page — a real browser follows; a replay that stops at the redirect
2387
+ // leaves a completed login waiting forever (live 2026-08-24: the
2388
+ // first-ever completed pane codex login needed a manual /success
2389
+ // fetch to let the CLI exit).
2390
+ "--location",
2391
+ "--max-time",
2392
+ "10",
2393
+ "--output",
2394
+ "/dev/null",
2395
+ "--config",
2396
+ "-",
2397
+ ],
2398
+ input: `url = "${configUrl}"\n`,
2399
+ };
2400
+ }
2401
+
2402
+ const ENGINE_LOGIN_CONTAINER_NAME = new RegExp(
2403
+ `^uai-login-(?:${ENGINE_LOGIN_KINDS.join("|")})-[0-9a-f]{16}$`,
2404
+ );
2405
+
2406
+ /** `uai-login-<kind>-<16 hex>` for every login kind. The sweep, the log
2407
+ * follower, the remover, and the codex callback replay all gate on this, so
2408
+ * a kind missing here strands its containers AND blocks every later login. */
2409
+ export function isEngineLoginContainerName(value: string): boolean {
2410
+ return ENGINE_LOGIN_CONTAINER_NAME.test(value);
2411
+ }
2412
+
2413
+ /** The CLI that owns login containers (ADR-106): the ambient docker client,
2414
+ * or the bundled Apple CLI when that backend is pinned. Resolved per call so
2415
+ * an HMR reload cannot strand a stale path; the pin itself never changes
2416
+ * within a process. */
2417
+ function engineLoginBackend(): { command: string; apple: boolean } {
2418
+ if (pinnedContainerRuntimeProvider() === "apple-container") {
2419
+ const binding = appleContainerRuntimeBinding();
2420
+ if (binding !== null) {
2421
+ return { command: binding.containerCliPath, apple: true };
2422
+ }
2423
+ }
2424
+ return { command: "docker", apple: false };
2425
+ }
2426
+
2427
+ const defaultEngineLoginDockerExec: EngineLoginDockerExec = (args, options) => {
2428
+ const backend = engineLoginBackend();
2429
+ if (!backend.apple) return dockerCli(args, options);
2430
+ return new Promise((resolveExec) => {
2431
+ const child = spawn(backend.command, args, {
2432
+ shell: false,
2433
+ stdio: ["pipe", "pipe", "pipe"],
2434
+ });
2435
+ let stdout = "";
2436
+ let stderr = "";
2437
+ let settled = false;
2438
+ const timer = setTimeout(() => {
2439
+ child.kill("SIGKILL");
2440
+ const settle = setTimeout(() => settleResult(null), 5_000);
2441
+ settle.unref?.();
2442
+ }, options?.timeoutMs ?? 30_000);
2443
+ child.stdout.setEncoding("utf8");
2444
+ child.stderr.setEncoding("utf8");
2445
+ child.stdout.on("data", (chunk: string) => (stdout += chunk));
2446
+ child.stderr.on("data", (chunk: string) => (stderr += chunk));
2447
+ child.stdin.on("error", () => {});
2448
+ if (options?.input !== undefined) child.stdin.write(options.input);
2449
+ child.stdin.end();
2450
+ const settleResult = (status: number | null): void => {
2451
+ if (settled) return;
2452
+ settled = true;
2453
+ clearTimeout(timer);
2454
+ resolveExec({ status, stdout, stderr });
2455
+ };
2456
+ child.on("error", () => settleResult(null));
2457
+ child.on("close", (code) => settleResult(code));
2458
+ });
2459
+ };
2460
+
2461
+ /** The Apple CLI exits nonzero for both a missing container and a broken
2462
+ * apiserver; only the not-found error naming this exact container proves
2463
+ * absence. */
2464
+ function appleReportsAbsent(
2465
+ result: { status: number | null; stdout: string; stderr: string },
2466
+ containerName: string,
2467
+ ): boolean {
2468
+ // The complete bounded error line, never a substring: a not-found answer
2469
+ // for `<name>-shadow` must not prove the absence of `<name>`.
2470
+ return (
2471
+ result.status !== 0 &&
2472
+ result.stdout.trim() === "" &&
2473
+ result.stderr
2474
+ .split("\n")
2475
+ .some(
2476
+ (line) => line.trim() === `Error: container not found: ${containerName}`,
2477
+ )
2478
+ );
2479
+ }
2480
+
2481
+ /** Force-remove one owned container, proving either success or exact absence. */
2482
+ export async function removeEngineLoginContainer(
2483
+ containerName: string,
2484
+ exec: EngineLoginDockerExec = defaultEngineLoginDockerExec,
2485
+ ): Promise<void> {
2486
+ if (!isEngineLoginContainerName(containerName)) {
2487
+ throw new Error("invalid login container name");
2488
+ }
2489
+ const apple = engineLoginBackend().apple;
2490
+ for (let attempt = 0; attempt < DOCKER_CLEANUP_ATTEMPTS; attempt += 1) {
2491
+ let observedLabel: string | null = null;
2492
+ if (apple) {
2493
+ const inspected = await exec(["inspect", containerName], {
2494
+ timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
2495
+ maxOutputBytes: 256 * 1_024,
2496
+ });
2497
+ if (inspected.status !== 0) {
2498
+ if (await engineLoginContainerIsAbsent(containerName, exec)) return;
2499
+ continue;
2500
+ }
2501
+ try {
2502
+ const parsed = JSON.parse(inspected.stdout) as Array<{
2503
+ configuration?: { labels?: Record<string, unknown> };
2504
+ }>;
2505
+ const label =
2506
+ parsed[0]?.configuration?.labels?.[ENGINE_LOGIN_CONTAINER_LABEL];
2507
+ observedLabel = typeof label === "string" ? label : null;
2508
+ } catch {
2509
+ continue;
2510
+ }
2511
+ } else {
2512
+ const inspected = await exec(
2513
+ [
2514
+ "container",
2515
+ "inspect",
2516
+ "--format",
2517
+ `{{ index .Config.Labels \"${ENGINE_LOGIN_CONTAINER_LABEL}\" }}`,
2518
+ containerName,
2519
+ ],
2520
+ {
2521
+ timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
2522
+ maxOutputBytes: 8 * 1_024,
2523
+ },
2524
+ );
2525
+ if (inspected.status !== 0) {
2526
+ if (await engineLoginContainerIsAbsent(containerName, exec)) return;
2527
+ continue;
2528
+ }
2529
+ observedLabel = inspected.stdout.trim();
2530
+ }
2531
+ if (observedLabel !== ENGINE_LOGIN_CONTAINER_LABEL_VALUE) {
2532
+ throw new Error("login container ownership could not be confirmed");
2533
+ }
2534
+
2535
+ const removed = await exec(
2536
+ apple
2537
+ ? ["delete", "--force", containerName]
2538
+ : ["rm", "--force", containerName],
2539
+ {
2540
+ timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
2541
+ maxOutputBytes: 8 * 1_024,
2542
+ },
2543
+ );
2544
+ // Even a zero exit is followed by an exact-name absence proof. This closes
2545
+ // daemon/client races and detects a same-name replacement before cleanup
2546
+ // can be declared settled.
2547
+ if (await engineLoginContainerIsAbsent(containerName, exec)) return;
2548
+ }
2549
+ throw new Error("login container cleanup could not be confirmed");
2550
+ }
2551
+
2552
+ async function engineLoginContainerIsAbsent(
2553
+ containerName: string,
2554
+ exec: EngineLoginDockerExec,
2555
+ ): Promise<boolean> {
2556
+ if (engineLoginBackend().apple) {
2557
+ const inspected = await exec(["inspect", containerName], {
2558
+ timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
2559
+ maxOutputBytes: 256 * 1_024,
2560
+ });
2561
+ return appleReportsAbsent(inspected, containerName);
2562
+ }
2563
+ const inventory = await exec(
2564
+ [
2565
+ "container",
2566
+ "ls",
2567
+ "--all",
2568
+ "--quiet",
2569
+ "--filter",
2570
+ `name=^/${containerName}$`,
2571
+ ],
2572
+ {
2573
+ timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
2574
+ maxOutputBytes: 8 * 1_024,
2575
+ },
2576
+ );
2577
+ return inventory.status === 0 && inventory.stdout.trim() === "";
2578
+ }
2579
+
2580
+ /** Bounded boot sweep for containers left by a killed host or daemon blip. */
2581
+ export async function reconcileStaleEngineLoginContainers(
2582
+ exec: EngineLoginDockerExec = defaultEngineLoginDockerExec,
2583
+ ): Promise<void> {
2584
+ let names: string[] | null = null;
2585
+ const apple = engineLoginBackend().apple;
2586
+ for (let attempt = 0; attempt < DOCKER_CLEANUP_ATTEMPTS; attempt += 1) {
2587
+ if (apple) {
2588
+ // No server-side label filter exists; list everything and select by the
2589
+ // ownership label client-side (the container id IS its name).
2590
+ const inventory = await exec(["list", "--all", "--format", "json"], {
2591
+ timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
2592
+ maxOutputBytes: 4 * 1_024 * 1_024,
2593
+ });
2594
+ if (inventory.status !== 0) continue;
2595
+ try {
2596
+ const parsed = JSON.parse(inventory.stdout) as Array<{
2597
+ configuration?: { id?: unknown; labels?: Record<string, unknown> };
2598
+ }>;
2599
+ names = parsed
2600
+ .filter(
2601
+ (item) =>
2602
+ item.configuration?.labels?.[ENGINE_LOGIN_CONTAINER_LABEL] ===
2603
+ ENGINE_LOGIN_CONTAINER_LABEL_VALUE,
2604
+ )
2605
+ .map((item) => item.configuration?.id)
2606
+ .filter((id): id is string => typeof id === "string");
2607
+ } catch {
2608
+ continue;
2609
+ }
2610
+ break;
2611
+ }
2612
+ const inventory = await exec(
2613
+ [
2614
+ "container",
2615
+ "ls",
2616
+ "--all",
2617
+ "--format",
2618
+ "{{.Names}}",
2619
+ "--filter",
2620
+ `label=${ENGINE_LOGIN_CONTAINER_LABEL}=${ENGINE_LOGIN_CONTAINER_LABEL_VALUE}`,
2621
+ ],
2622
+ {
2623
+ timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
2624
+ maxOutputBytes: 64 * 1_024,
2625
+ },
2626
+ );
2627
+ if (inventory.status !== 0) continue;
2628
+ names = inventory.stdout
2629
+ .split("\n")
2630
+ .map((name) => name.trim())
2631
+ .filter(Boolean);
2632
+ break;
2633
+ }
2634
+ if (names === null) {
2635
+ throw new Error("stale login container inventory unavailable");
2636
+ }
2637
+ if (
2638
+ names.length > MAX_STALE_LOGIN_CONTAINERS ||
2639
+ names.some((name) => !isEngineLoginContainerName(name))
2640
+ ) {
2641
+ throw new Error("stale login container inventory is invalid");
2642
+ }
2643
+ for (const name of names) await removeEngineLoginContainer(name, exec);
2644
+ }
2645
+
2646
+ function engineLoginTempMarkerBody(name: string): string {
2647
+ return `${ENGINE_LOGIN_TEMP_MARKER_VERSION}:${name}\n`;
2648
+ }
2649
+
2650
+ /** Create the private login workspace and its no-follow ownership marker. */
2651
+ export async function createEngineLoginTempDir(
2652
+ requestedRoot?: string,
2653
+ ): Promise<string> {
2654
+ const root = requestedRoot ?? tmpdir();
2655
+ if (requestedRoot !== undefined) await ensurePrivateEngineLoginTempRoot(root);
2656
+ const path = await mkdtemp(join(root, "uai-engine-login-"));
2657
+ let marker: Awaited<ReturnType<typeof open>> | null = null;
2658
+ try {
2659
+ const name = basename(path);
2660
+ if (!ENGINE_LOGIN_TEMP_DIR_PATTERN.test(name)) {
2661
+ throw new Error("login temp directory name is invalid");
2662
+ }
2663
+ await chmod(path, 0o700);
2664
+ marker = await open(join(path, ENGINE_LOGIN_TEMP_MARKER), "wx", 0o600);
2665
+ await marker.writeFile(engineLoginTempMarkerBody(name), "utf8");
2666
+ await marker.sync();
2667
+ await marker.close();
2668
+ marker = null;
2669
+ // The container is told HOME and CODEX_HOME live under the leaf, and
2670
+ // codex refuses to start when CODEX_HOME does not exist (claude
2671
+ // self-creates its HOME). Create both up front.
2672
+ await mkdir(join(path, "home"), { mode: 0o700 });
2673
+ await mkdir(join(path, "codex"), { mode: 0o700 });
2674
+ await mkdir(join(path, "grok"), { mode: 0o700 });
2675
+ await syncDirectory(path);
2676
+ return path;
2677
+ } catch (error) {
2678
+ if (marker) await marker.close().catch(() => undefined);
2679
+ await rm(path, { recursive: true, force: true }).catch(() => undefined);
2680
+ throw error;
2681
+ }
2682
+ }
2683
+
2684
+ /** Prepare only the dedicated bind leaf, never a broader Uai/home directory. */
2685
+ async function ensurePrivateEngineLoginTempRoot(root: string): Promise<void> {
2686
+ if (
2687
+ !root.startsWith("/") ||
2688
+ resolve(root) !== root ||
2689
+ root.length <= 1 ||
2690
+ root.length > 4_096 ||
2691
+ /[\0\r\n]/.test(root)
2692
+ ) {
2693
+ throw new Error("login temp root is invalid");
2694
+ }
2695
+ await mkdir(root, { recursive: true, mode: 0o700 });
2696
+ const entry = await lstat(root);
2697
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
2698
+ if (
2699
+ !entry.isDirectory() ||
2700
+ entry.isSymbolicLink() ||
2701
+ (uid !== null && entry.uid !== uid) ||
2702
+ (await realpath(root)) !== root
2703
+ ) {
2704
+ throw new Error("login temp root is unsafe");
2705
+ }
2706
+ await chmod(root, 0o700);
2707
+ }
2708
+
2709
+ export interface EngineLoginTempReconcileOptions {
2710
+ readonly tempRoot?: string;
2711
+ readonly currentUid?: number | null;
2712
+ readonly readNames?: (root: string) => Promise<string[]>;
2713
+ readonly statEntry?: (path: string) => Promise<{
2714
+ isDirectory(): boolean;
2715
+ isSymbolicLink(): boolean;
2716
+ readonly uid: number;
2717
+ }>;
2718
+ readonly markerIsValid?: (
2719
+ path: string,
2720
+ name: string,
2721
+ currentUid: number | null,
2722
+ ) => Promise<boolean>;
2723
+ readonly removeEntry?: (path: string) => Promise<void>;
2724
+ }
2725
+
2726
+ /**
2727
+ * Remove only direct, exact mkdtemp-shaped directories owned by this service
2728
+ * user. Symlinks, foreign-owned directories, and unrelated `/tmp` entries are
2729
+ * deliberately untouched. Callers must reconcile labeled containers first so
2730
+ * no still-live login process can own one of these mounts.
2731
+ */
2732
+ export async function reconcileStaleEngineLoginTempDirs(
2733
+ options: EngineLoginTempReconcileOptions = {},
2734
+ ): Promise<void> {
2735
+ const root = options.tempRoot ?? tmpdir();
2736
+ // Injected inventory seams own their synthetic root metadata. Production
2737
+ // reconciliation always uses the real reader and validates the exact leaf.
2738
+ if (options.tempRoot !== undefined && options.readNames === undefined) {
2739
+ await ensurePrivateEngineLoginTempRoot(root);
2740
+ }
2741
+ const names = await (options.readNames ?? ((path) => readdir(path)))(root);
2742
+ const candidates = names.filter((name) =>
2743
+ ENGINE_LOGIN_TEMP_DIR_PATTERN.test(name),
2744
+ );
2745
+ const uid =
2746
+ options.currentUid === undefined
2747
+ ? typeof process.getuid === "function"
2748
+ ? process.getuid()
2749
+ : null
2750
+ : options.currentUid;
2751
+ const statEntry = options.statEntry ?? lstat;
2752
+ const markerIsValid = options.markerIsValid ?? engineLoginTempMarkerIsValid;
2753
+ const removeEntry =
2754
+ options.removeEntry ?? ((path: string) => rm(path, { recursive: true, force: true }));
2755
+
2756
+ const owned: string[] = [];
2757
+ for (const name of candidates) {
2758
+ const path = join(root, name);
2759
+ let stat: Awaited<ReturnType<typeof statEntry>>;
2760
+ try {
2761
+ stat = await statEntry(path);
2762
+ } catch (error) {
2763
+ if (isNodeErrorCode(error, "ENOENT")) continue;
2764
+ throw error;
2765
+ }
2766
+ if (
2767
+ stat.isSymbolicLink() ||
2768
+ !stat.isDirectory() ||
2769
+ (uid !== null && stat.uid !== uid)
2770
+ ) {
2771
+ continue;
2772
+ }
2773
+ if (!(await markerIsValid(path, name, uid))) continue;
2774
+ owned.push(path);
2775
+ }
2776
+ if (owned.length > MAX_STALE_LOGIN_TEMP_DIRS) {
2777
+ throw new Error("stale login temp inventory is invalid");
2778
+ }
2779
+ for (const path of owned) await removeEntry(path);
2780
+ }
2781
+
2782
+ async function engineLoginTempMarkerIsValid(
2783
+ path: string,
2784
+ name: string,
2785
+ currentUid: number | null,
2786
+ ): Promise<boolean> {
2787
+ const markerPath = join(path, ENGINE_LOGIN_TEMP_MARKER);
2788
+ let before: Awaited<ReturnType<typeof lstat>>;
2789
+ try {
2790
+ before = await lstat(markerPath);
2791
+ } catch (error) {
2792
+ if (isNodeErrorCode(error, "ENOENT")) return false;
2793
+ throw error;
2794
+ }
2795
+ const expected = Buffer.from(engineLoginTempMarkerBody(name), "utf8");
2796
+ if (
2797
+ before.isSymbolicLink() ||
2798
+ !before.isFile() ||
2799
+ before.size !== expected.byteLength ||
2800
+ (before.mode & 0o777) !== 0o600 ||
2801
+ (currentUid !== null && before.uid !== currentUid)
2802
+ ) {
2803
+ return false;
2804
+ }
2805
+ const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
2806
+ let handle: Awaited<ReturnType<typeof open>>;
2807
+ try {
2808
+ handle = await open(markerPath, constants.O_RDONLY | noFollow);
2809
+ } catch (error) {
2810
+ if (isNodeErrorCode(error, "ENOENT") || isNodeErrorCode(error, "ELOOP")) {
2811
+ return false;
2812
+ }
2813
+ throw error;
2814
+ }
2815
+ try {
2816
+ const opened = await handle.stat();
2817
+ if (
2818
+ !opened.isFile() ||
2819
+ opened.dev !== before.dev ||
2820
+ opened.ino !== before.ino ||
2821
+ opened.size !== expected.byteLength ||
2822
+ (opened.mode & 0o777) !== 0o600 ||
2823
+ (currentUid !== null && opened.uid !== currentUid)
2824
+ ) {
2825
+ return false;
2826
+ }
2827
+ const body = await handle.readFile();
2828
+ return body.equals(expected);
2829
+ } finally {
2830
+ await handle.close();
2831
+ }
2832
+ }
2833
+
2834
+ /** Container absence is proven before any secret-bearing temp is removed. */
2835
+ export async function reconcileStaleEngineLoginResources(
2836
+ options: EngineLoginTempReconcileOptions = {},
2837
+ ): Promise<void> {
2838
+ await reconcileStaleEngineLoginContainers();
2839
+ await reconcileStaleEngineLoginTempDirs(options);
2840
+ }
2841
+
2842
+ function isNodeErrorCode(error: unknown, code: string): boolean {
2843
+ return (
2844
+ error instanceof Error &&
2845
+ "code" in error &&
2846
+ (error as NodeJS.ErrnoException).code === code
2847
+ );
2848
+ }
2849
+
2850
+ async function readBoundedRegularFile(
2851
+ file: string,
2852
+ maxBytes: number,
2853
+ ): Promise<Buffer> {
2854
+ const before = await lstat(file);
2855
+ if (!before.isFile() || before.isSymbolicLink() || before.size > maxBytes) {
2856
+ throw new Error("credential output is not a bounded regular file");
2857
+ }
2858
+ const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
2859
+ const handle = await open(file, constants.O_RDONLY | noFollow);
2860
+ try {
2861
+ const opened = await handle.stat();
2862
+ if (!opened.isFile() || opened.size < 1 || opened.size > maxBytes) {
2863
+ throw new Error("credential output is not a bounded regular file");
2864
+ }
2865
+ const body = await handle.readFile();
2866
+ if (body.byteLength < 1 || body.byteLength > maxBytes) {
2867
+ throw new Error("credential output exceeds its size limit");
2868
+ }
2869
+ return body;
2870
+ } finally {
2871
+ await handle.close();
2872
+ }
2873
+ }
2874
+
2875
+ async function writeSensitiveFileAtomic(file: string, body: Buffer): Promise<void> {
2876
+ const parent = dirname(file);
2877
+ await ensureSensitiveParent(parent);
2878
+ const temporary = join(
2879
+ parent,
2880
+ `.auth.json.${process.pid}.${randomBytes(8).toString("hex")}.tmp`,
2881
+ );
2882
+ let handle: Awaited<ReturnType<typeof open>> | null = null;
2883
+ try {
2884
+ handle = await open(temporary, "wx", 0o600);
2885
+ await handle.writeFile(body);
2886
+ await handle.sync();
2887
+ await handle.close();
2888
+ handle = null;
2889
+ await rename(temporary, file);
2890
+ await chmod(file, 0o600);
2891
+ await syncDirectory(parent);
2892
+ } finally {
2893
+ if (handle) await handle.close().catch(() => undefined);
2894
+ await unlink(temporary).catch((error: NodeJS.ErrnoException) => {
2895
+ if (error.code !== "ENOENT") throw error;
2896
+ });
2897
+ }
2898
+ }
2899
+
2900
+ async function ensureSensitiveParent(parent: string): Promise<void> {
2901
+ try {
2902
+ const current = await lstat(parent);
2903
+ if (!current.isDirectory() || current.isSymbolicLink()) {
2904
+ throw new Error("credential directory is not a local directory");
2905
+ }
2906
+ return;
2907
+ } catch (error) {
2908
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
2909
+ }
2910
+
2911
+ await mkdir(parent, { recursive: true, mode: 0o700 });
2912
+ // Re-check after creation. This catches an existing symlink followed by a
2913
+ // recursive mkdir and a same-user replacement racing the initial ENOENT.
2914
+ const created = await lstat(parent);
2915
+ if (!created.isDirectory() || created.isSymbolicLink()) {
2916
+ throw new Error("credential directory is not a local directory");
2917
+ }
2918
+ }
2919
+
2920
+ async function syncDirectory(directory: string): Promise<void> {
2921
+ let handle: Awaited<ReturnType<typeof open>> | null = null;
2922
+ try {
2923
+ handle = await open(directory, "r");
2924
+ await handle.sync();
2925
+ } catch (error) {
2926
+ const code = (error as NodeJS.ErrnoException).code;
2927
+ if (code !== "EINVAL" && code !== "ENOTSUP" && code !== "EISDIR") {
2928
+ throw error;
2929
+ }
2930
+ } finally {
2931
+ if (handle) await handle.close();
2932
+ }
2933
+ }